mirror of
https://github.com/cline/cline.git
synced 2026-09-04 19:50:40 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fa2ba4e904 |
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"claude-dev": patch
|
||||
---
|
||||
|
||||
fix: use correct base URL for Vertex AI global endpoint with Claude models
|
||||
@@ -41,11 +41,11 @@ fi
|
||||
|
||||
# Install project dependencies
|
||||
echo "Installing dependencies..."
|
||||
bun run install:all
|
||||
npm run install:all
|
||||
|
||||
# Generate gRPC/protobuf types (required for TypeScript)
|
||||
echo "Generating proto types..."
|
||||
bun run protos
|
||||
npm run protos
|
||||
|
||||
echo ""
|
||||
echo "Session setup complete!"
|
||||
|
||||
@@ -1,266 +0,0 @@
|
||||
---
|
||||
name: publish-cli
|
||||
description: Use when preparing, tagging, and publishing an apps/cli npm release. Guides changelog drafting, apps/cli/package.json version bumps, cli-vX.Y.Z tags, local npm publishing, and the publish-cli GitHub workflow.
|
||||
---
|
||||
|
||||
# CLI Release
|
||||
|
||||
Use this skill when the user asks to release the CLI, publish `cline`, bump the CLI version, draft release notes, create a `cli-vX.Y.Z` tag, or trigger the CLI publish workflow.
|
||||
|
||||
The CLI is npm-only. Do not add alternate distribution or signing steps.
|
||||
|
||||
> Working directory: run every command below from the repository root. Paths and scripts (e.g. `apps/cli/package.json`, `sdk/packages/`, `bun release cli`, `bun run version`) are written relative to the repo root.
|
||||
|
||||
The skill should guide the user through one release preparation flow, then offer the publish path options. The two normal publish paths are GitHub Actions and local publishing from an authenticated machine.
|
||||
|
||||
## Release contract
|
||||
|
||||
- SDK prerequisite: the CLI depends on the SDK via `workspace:*` (`@cline/core`, `@cline/shared`, and friends). If the SDK changed since its last release, release the SDK first and wait for it to finish publishing before releasing the CLI. See "Step 0: Release the SDK first if it changed" below.
|
||||
- Version source: `apps/cli/package.json`.
|
||||
- Main release tag: `cli-vX.Y.Z`, where `X.Y.Z` matches `apps/cli/package.json`.
|
||||
- Nightly release version: `X.Y.Z-nightly.TIMESTAMP`.
|
||||
- Release prep includes approved release notes, a version bump, and an `apps/cli/CHANGELOG.md` update.
|
||||
- Publish paths:
|
||||
- GitHub workflow: `.github/workflows/cli-publish.yml`.
|
||||
- Local publish helper: `bun release cli`.
|
||||
- npm dist-tags and git tags are separate. `--tag latest` and `--tag nightly` are npm registry channels. `cli-vX.Y.Z` is a git tag for source history and GitHub releases.
|
||||
- The GitHub main release workflow runs from `main`, requires an existing `cli-vX.Y.Z` tag, checks out that tag, and publishes from it.
|
||||
- The GitHub nightly workflow publishes to npm with the `nightly` dist-tag and does not create a tag.
|
||||
- The local release helper requires a clean checkout and `cli-vX.Y.Z` to point at `HEAD` locally and on `origin` before publishing.
|
||||
- Local GitHub release creation requires `gh` to be authenticated with release permissions for the repo.
|
||||
- Always ask before pushing commits or tags.
|
||||
- Do not amend commits unless explicitly requested.
|
||||
|
||||
## Step 0: Release the SDK first if it changed
|
||||
|
||||
Do this before anything else in the Workflow below.
|
||||
|
||||
The CLI builds and ships against the SDK source in the monorepo (`workspace:*` for `@cline/core`, `@cline/shared`, and the rest), so a CLI release always contains the latest SDK code whether or not the SDK was released. The build and tests use that source too, not anything from npm. Releasing the SDK alongside the CLI is still worth doing for two reasons:
|
||||
|
||||
- Hub freshness. The hub daemon lives in `@cline/core` and stamps a `buildId` that defaults to the `@cline/core` package version (`resolveHubBuildId` in `sdk/packages/core/src/hub/discovery/index.ts`). A running hub is only retired and respawned when that `buildId` changes (`isCompatibleHubRecord` / `retireIncompatibleHub` in `sdk/packages/core/src/hub/daemon/index.ts`). So if the SDK code changed but the version did not, a user who upgrades the CLI keeps talking to their already-running hub, which is still executing the old SDK code. Bumping the SDK version makes the new CLI's `buildId` differ, so the stale hub is detected as incompatible and respawned with the fresh code.
|
||||
- Release hygiene. We want regular SDK releases; cutting one whenever we cut a CLI release keeps the published SDK in step with what the CLI ships.
|
||||
|
||||
So when the SDK has changed, release it first (which bumps the `@cline/core` version), then cut the CLI release on top of that bump. Leave the CLI's SDK dependency as `workspace:*` — the fix is to release the SDK, not to pin the CLI.
|
||||
|
||||
1. Check for unreleased SDK changes.
|
||||
|
||||
```sh
|
||||
git fetch origin --tags
|
||||
git tag --list 'sdk/sdk/v*' 'sdk-v*' --sort=-v:refname | head -1
|
||||
git log <last-sdk-tag>..origin/main --oneline --no-merges -- sdk/packages
|
||||
```
|
||||
|
||||
`sdk/<pkg>/v*` tags are created by the `sdk-publish.yml` workflow; `sdk-v*` tags are created by the local `bun release sdk` helper. Use whichever is newest as the baseline.
|
||||
|
||||
If `git log` prints no commits, the SDK is already up to date. Skip the rest of Step 0 and continue with the Workflow below.
|
||||
|
||||
If it prints commits, sanity-check the diff (ignore entries that are only the previous version-bump commit's lockfile or generated files), then release the SDK.
|
||||
|
||||
2. Decide the SDK version bump.
|
||||
|
||||
All SDK packages share one version, read from `sdk/packages/llms/package.json`. Ask whether this is patch, minor, major, or an explicit version. Patch is the default. Do not guess if the user has not made it clear.
|
||||
|
||||
3. Draft the SDK release notes and update the changelog.
|
||||
|
||||
Draft user-facing notes from the SDK commits found in step 1, translating commit messages into user-facing language (same approach as the CLI release notes below). Prepend a new `## <version>` section with those notes to the top of `sdk/CHANGELOG.md`, using the header format `## <version>` with no date — the same flat, newest-on-top format as `apps/cli/CHANGELOG.md`. This is the SDK changelog (all SDK packages share one version) and it is maintained by hand; the `sdk-publish.yml` workflow does not read it.
|
||||
|
||||
4. Bump versions and regenerate.
|
||||
|
||||
```sh
|
||||
bun run version <version>
|
||||
```
|
||||
|
||||
This bumps every SDK `package.json` to the new version, regenerates the lockfile and the generated model catalog, formats, and builds. Review the result.
|
||||
|
||||
5. Commit and push the bump to `main`.
|
||||
|
||||
The `sdk-publish.yml` workflow publishes the version that is committed on `main` and tags that commit, so the bump must land on `main` before the workflow runs.
|
||||
|
||||
```sh
|
||||
git add -A
|
||||
git commit -m "chore(sdk): release v<version>"
|
||||
```
|
||||
|
||||
Ask before pushing:
|
||||
|
||||
```sh
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
6. Trigger the SDK publish workflow on the `latest` channel.
|
||||
|
||||
```sh
|
||||
gh workflow run sdk-publish.yml -f channel=latest -f confirm_publish=publish
|
||||
gh run list --workflow=sdk-publish.yml --limit=1 --json databaseId,url,status,createdAt --jq '.[0]'
|
||||
```
|
||||
|
||||
The workflow runs the SDK tests, publishes `@cline/shared`, `@cline/llms`, `@cline/agents`, `@cline/core`, and `@cline/sdk` to npm with the `latest` dist-tag in dependency order, and pushes `sdk/<pkg>/v<version>` git tags.
|
||||
|
||||
7. Wait for the SDK workflow to succeed before starting the CLI release.
|
||||
|
||||
```sh
|
||||
gh run watch <run-id> --exit-status
|
||||
```
|
||||
|
||||
Do not start the CLI release until this run has finished successfully. The CLI does not install the SDK from npm, but cutting the CLI release on top of a clean, completed SDK release keeps the two in step: the CLI release commit then sits on top of the `@cline/core` version bump, so the shipped CLI carries the new version that forces a running hub to respawn with the new code, and you are not building a CLI release on top of an SDK release that failed midway.
|
||||
|
||||
After the SDK release succeeds, pull `main` so the CLI release is prepared on top of the SDK version bump:
|
||||
|
||||
```sh
|
||||
git checkout main && git pull --ff-only
|
||||
```
|
||||
|
||||
Then continue with the Workflow below.
|
||||
|
||||
For a local SDK publish from an authenticated machine instead of the workflow, `bun release sdk <version>` exists, but prefer the `sdk-publish.yml` workflow for normal releases so the CLI release can gate on a single GitHub Actions run.
|
||||
|
||||
## Workflow
|
||||
|
||||
Complete Step 0 first. Only proceed once the SDK is released (or you confirmed no SDK release was needed).
|
||||
|
||||
1. Gather context.
|
||||
|
||||
```sh
|
||||
git status --short --branch
|
||||
git fetch origin --tags
|
||||
git tag --list 'cli-v*' --sort=-v:refname | head -10
|
||||
node -p "require('./apps/cli/package.json').version"
|
||||
```
|
||||
|
||||
Find the latest CLI tag. If there is no `cli-v*` tag, use the first relevant CLI release commit as the baseline and say that the baseline is inferred.
|
||||
|
||||
2. Collect release commits.
|
||||
|
||||
```sh
|
||||
git log <last-cli-tag>..HEAD --oneline --no-merges -- apps/cli sdk/packages sdk/scripts .github/workflows/cli-publish.yml
|
||||
```
|
||||
|
||||
The `sdk/packages` commits matter here even though the SDK was released separately in Step 0: the CLI bundles the SDK, so SDK changes ship in this CLI release too. Read those commits and fold anything user-relevant to the CLI into the release notes (provider/model updates, behavior changes, fixes the CLI inherits). Skip SDK changes that are purely internal or have no CLI-visible effect.
|
||||
|
||||
3. Draft user-facing release notes.
|
||||
|
||||
Include user-facing features, fixes, behavior changes, compatibility changes, and notable install or release changes. Exclude pure refactors, tests, style, chores, and internal file moves unless they matter to users.
|
||||
|
||||
Write a flat bullet list. Translate commit messages into user-facing language. If a commit is unclear, read the full commit before summarizing it.
|
||||
|
||||
Present the draft and wait for approval before editing files.
|
||||
|
||||
4. Decide the version bump.
|
||||
|
||||
Ask whether this should be patch, minor, major, or an explicit version. Do not guess if the user has not made it clear.
|
||||
|
||||
5. Update release files.
|
||||
|
||||
Update `apps/cli/package.json` to the approved version.
|
||||
|
||||
Prepend a section to `apps/cli/CHANGELOG.md` for the approved version using the approved release notes. Use the header format `## X.Y.Z` with no date. The publish workflow extracts the top section of the changelog by matching `^## [0-9]` and pastes it verbatim into the GitHub release body and the Slack release announcement, so the section content is the release notes that get shipped.
|
||||
|
||||
6. Verify before committing.
|
||||
|
||||
Run focused checks first:
|
||||
|
||||
```sh
|
||||
bun -F @cline/cli typecheck
|
||||
bun -F @cline/cli test:unit
|
||||
```
|
||||
|
||||
For higher confidence, run:
|
||||
|
||||
```sh
|
||||
bun run types
|
||||
bun --cwd apps/cli run build:platforms:single
|
||||
```
|
||||
|
||||
If the user wants full release confidence before tagging, run:
|
||||
|
||||
```sh
|
||||
bun run test
|
||||
bun --cwd apps/cli run build:platforms
|
||||
```
|
||||
|
||||
Known local-only test failure: `src/commands/distribution-package.test.ts > rejects direct source package packing by default` will fail on machines that have `ignore-scripts=true` in `~/.npmrc` (set by the npm supply-chain hardening guide). Bun reads npm's `ignore-scripts` from `~/.npmrc`, so `bun pm pack --dry-run` skips the source-publish `prepack` guard and exits 0, which the test reads as a failure. CI does not set `ignore-scripts`, so the test passes there. Confirm by running `bun pm pack --dry-run` directly: with `~/.npmrc` in place it exits 0 with no guard output; with `~/.npmrc` moved aside it exits 1 and prints the guard message. This is not a release blocker by itself, but it does mean the local-publish path (`bun release cli`) will also bypass the source-publish guard on this machine; prefer the GitHub Actions publish path on machines with `ignore-scripts=true` set globally, or temporarily unset it (`npm config delete ignore-scripts` or `mv ~/.npmrc ~/.npmrc.bak`) for the duration of a local publish.
|
||||
|
||||
7. Commit release changes.
|
||||
|
||||
Only after the user approves the notes and version:
|
||||
|
||||
```sh
|
||||
git add apps/cli/package.json apps/cli/CHANGELOG.md
|
||||
git commit -m "chore(cli): release vX.Y.Z"
|
||||
```
|
||||
|
||||
Ask before pushing the release commit:
|
||||
|
||||
```sh
|
||||
git push origin HEAD
|
||||
```
|
||||
|
||||
For the GitHub main release path, ask before creating and pushing the release tag:
|
||||
|
||||
```sh
|
||||
git tag -a cli-vX.Y.Z -m "CLI vX.Y.Z"
|
||||
git push origin refs/tags/cli-vX.Y.Z
|
||||
```
|
||||
|
||||
8. Publish.
|
||||
|
||||
Ask the user which path to use:
|
||||
|
||||
- GitHub main release. Use this after the release commit is on `main` and the matching `cli-vX.Y.Z` tag has been pushed. The workflow publishes to npm from that tag, creates the GitHub release, and posts to Slack.
|
||||
- Local release. Use this when the user wants to publish from this machine. The local machine must be authenticated to npm and GitHub.
|
||||
- GitHub nightly release.
|
||||
- Stop after the version commit.
|
||||
|
||||
For GitHub main release:
|
||||
|
||||
```sh
|
||||
gh workflow run cli-publish.yml -f publish_target=main -f git_tag=cli-vX.Y.Z -f confirm_publish=publish
|
||||
gh run list --workflow=cli-publish.yml --limit=1 --json url,status,conclusion,createdAt --jq '.[0]'
|
||||
```
|
||||
|
||||
For GitHub nightly release:
|
||||
|
||||
```sh
|
||||
gh workflow run cli-publish.yml -f publish_target=nightly
|
||||
```
|
||||
|
||||
For forced GitHub nightly release:
|
||||
|
||||
```sh
|
||||
gh workflow run cli-publish.yml -f publish_target=nightly -f force_nightly_publish=true
|
||||
```
|
||||
|
||||
For local publish:
|
||||
|
||||
```sh
|
||||
gh auth status
|
||||
npm whoami
|
||||
git tag -a cli-vX.Y.Z -m "CLI vX.Y.Z"
|
||||
git push origin refs/tags/cli-vX.Y.Z
|
||||
bun release cli
|
||||
```
|
||||
|
||||
After a successful local publish, ask before running:
|
||||
|
||||
```sh
|
||||
gh release create cli-vX.Y.Z --verify-tag --title "CLI vX.Y.Z" --notes "Paste the approved release notes here."
|
||||
```
|
||||
|
||||
If publishing with another npm dist-tag:
|
||||
|
||||
```sh
|
||||
bun release cli --tag next
|
||||
```
|
||||
|
||||
9. Final response.
|
||||
|
||||
Report:
|
||||
|
||||
- version
|
||||
- tag
|
||||
- changelog file updated
|
||||
- commit hash
|
||||
- whether anything was pushed
|
||||
- publish path selected
|
||||
- workflow URL or local publish result
|
||||
- tests and builds run
|
||||
@@ -1,55 +0,0 @@
|
||||
# Bun (tooling) and Node (runtime)
|
||||
|
||||
This repo uses **bun** for package management and task running, and **Node** as
|
||||
the execution runtime. Both are correct at the same time; the distinction is the
|
||||
source of most confusion, so keep it straight before editing scripts, configs,
|
||||
docs, or comments.
|
||||
|
||||
## Use bun for tooling
|
||||
|
||||
- `bun install` (never `npm install` / `npm ci`)
|
||||
- `bun run <script>` (never `npm run <script>`)
|
||||
- `bunx <bin>` (never `npx <bin>`)
|
||||
- `bun <file>.ts` to run a TS entrypoint directly (no `ts-node` / `tsx`)
|
||||
- `bun esbuild.mjs` to drive the build (esbuild/vite are still the bundlers)
|
||||
- `bun run --parallel ...` for parallel tasks
|
||||
|
||||
The root `bun.lock` is the single lockfile for the whole workspace, including
|
||||
`apps/vscode`, `webview-ui`, and `testing-platform`. There are no per-package npm
|
||||
lockfiles.
|
||||
|
||||
## Node is the runtime — do NOT rewrite these to bun
|
||||
|
||||
The build product runs on Node: the VS Code extension host loads
|
||||
`dist/extension.js` as CommonJS under Node, and the standalone `cline-core` is a
|
||||
Node process. The following are Node runtime/ABI references and are correct as-is:
|
||||
|
||||
| Reference | Why it is Node |
|
||||
|-----------|----------------|
|
||||
| esbuild `platform: "node"` / `target: "node..."` | The bundle targets the Node runtime (extension host, standalone core). |
|
||||
| `TARGET_NODE_VERSION` (`scripts/package-standalone.mjs`) | Pins the Node ABI of the bundled standalone runtime (matches the JetBrains-packaged Node). |
|
||||
| `prebuild-install --target=<node version>` | Downloads native `.node` binaries for that Node ABI. |
|
||||
| `NODE_PATH=... node cline-core.js` | The standalone core is launched by Node, not bun. |
|
||||
| `node:` import specifiers (e.g. `node:fs`) | Node builtin module scheme; unrelated to tooling. |
|
||||
| `process.versions.node`, `engines.node`, `@types/node` | Runtime version probe / declared runtime / its types. |
|
||||
| `ELECTRON_RUN_AS_NODE` | VS Code/Electron runs the extension host as Node. |
|
||||
|
||||
When a file legitimately uses both bun and node (e.g. `package-standalone.mjs`
|
||||
does `bun install` but `prebuild-install --target=<node>`), the `node` token is
|
||||
the runtime/ABI target, not tooling. If unsure, leave it.
|
||||
|
||||
## Tests: bun vs the VS Code host
|
||||
|
||||
A test file's runner is decided by its import:
|
||||
|
||||
- **`import ... from "bun:test"`** → runs under `bun test` (the node-side unit
|
||||
suites + the SDK/model-catalog suites). `scripts/run-bun-unit-tests.ts`
|
||||
discovers these by the `bun:test` import and runs one isolated bun process per
|
||||
file. `build-tests.js` excludes them from the integration compile so the
|
||||
`bun:test` builtin never reaches Node.
|
||||
- **`import ... from "mocha"`** → runs under `@vscode/test-cli` in a real VS Code
|
||||
extension host (Node). These exercise the live `vscode` API and cannot run
|
||||
under bun.
|
||||
|
||||
So a file imports `bun:test` XOR `mocha`. Don't add `bun:test` to a test that
|
||||
needs the real extension host.
|
||||
@@ -1,128 +0,0 @@
|
||||
# Debug Harness
|
||||
|
||||
HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/server.ts`.
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Build extension first if needed (protos + esbuild):
|
||||
bun run protos && IS_DEV=true bun esbuild.mjs
|
||||
|
||||
# Launch (skip-build if already built):
|
||||
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
|
||||
# In another terminal:
|
||||
curl localhost:19229/api -d '{"method":"status"}'
|
||||
```
|
||||
|
||||
## Data Isolation
|
||||
|
||||
The debugee runs with `CLINE_DIR=~/.cline2` by default, separate from your real `~/.cline`.
|
||||
This prevents the debugee's logout from logging out the debugger, and vice versa.
|
||||
Override with `--cline-dir /tmp/test-dir`. Check with `status()` → `clineDir`.
|
||||
|
||||
## Browser Capture & OAuth
|
||||
|
||||
The debugee runs with `CLINE_CAPTURE_BROWSER=1`, which intercepts `openExternal()` in
|
||||
`src/utils/env.ts`. URLs are captured instead of opening a real browser:
|
||||
|
||||
- Logged to `$CLINE_DIR/data/debug-captured-urls.jsonl`
|
||||
- POSTed in real-time to `/captured-url` on the harness server
|
||||
- Queryable via `oauth.captured_urls`
|
||||
|
||||
### OAuth API
|
||||
|
||||
- **`oauth.captured_urls`** `{clear?}` — URLs the debugee tried to open
|
||||
- **`oauth.read_stored_token`** — Check auth token presence in secrets.json
|
||||
- **`oauth.simulate_callback`** `{path, code?, state?, provider?, token?}` — Build vscode:// callback URI
|
||||
- **`oauth.read_captured_urls_file`** — Read on-disk JSONL of captured URLs
|
||||
|
||||
### OAuth testing flow
|
||||
|
||||
For **Cline OAuth** (SDK local callback): The SDK starts a local HTTP server, the auth URL
|
||||
is captured. To complete: open the captured URL in a real browser (it redirects back to the
|
||||
SDK's callback server), OR extract the callback port and `curl http://127.0.0.1:PORT/callback?code=...`.
|
||||
|
||||
For **MCP/Provider OAuth** (vscode:// URI): The redirect goes to a vscode:// URI.
|
||||
`oauth.simulate_callback` only *builds* the URI — it does not deliver it, and the ESM
|
||||
extension host can't `require()` the handler. To actually deliver the callback, call the
|
||||
debug-only hook via `ext.evaluate` (with `awaitPromise: true`):
|
||||
`globalThis.__clineHandleUri("vscode://saoudrizwan.claude-dev/...?code=...&state=...")`.
|
||||
It runs the same `SharedUriHandler.handleUri` as VSCode's real URI handler and exists only
|
||||
when `CLINE_CAPTURE_BROWSER` is set (the harness always sets it; never ships in prod).
|
||||
For end-to-end MCP OAuth, get a real `code` from the local MCP OAuth test server
|
||||
(`bun run dev:mcp-oauth-test-server`).
|
||||
|
||||
## Navigating Views — Use Commands, Not Clicks
|
||||
|
||||
Don't try to find/click small sidebar icons. Use VSCode commands via command palette.
|
||||
Registered in `src/registry.ts`:
|
||||
|
||||
| Command | View |
|
||||
|---------|------|
|
||||
| `cline.accountButtonClicked` | Account / sign-in |
|
||||
| `cline.historyButtonClicked` | Task history |
|
||||
| `cline.settingsButtonClicked` | Settings |
|
||||
| `cline.mcpButtonClicked` | MCP servers |
|
||||
| `cline.plusButtonClicked` | New task (chat) |
|
||||
| `cline.worktreesButtonClicked` | Worktrees |
|
||||
|
||||
```bash
|
||||
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
|
||||
```
|
||||
|
||||
## Key commands
|
||||
|
||||
All via `POST localhost:19229/api` with `{"method":"...", "params":{...}}`:
|
||||
|
||||
- **`launch`** / **`shutdown`** — lifecycle
|
||||
- **`ui.screenshot`** — screenshot to `/tmp/cline-debug/`; returns `{path}` — **use `read_file` on the path to examine, do NOT `open` the file** (Preview.app covers the VSCode window)
|
||||
- **`ui.open_sidebar`** — open the Cline sidebar
|
||||
- **`ext.set_breakpoint`** `{file, line, condition?}` — breakpoint by source file (sourcemap-resolved)
|
||||
- **`ext.evaluate`** `{expression, callFrameId?}` — eval in extension host
|
||||
- **`ext.resume`** / **`ext.step_over`** / **`ext.step_into`** — stepping
|
||||
- **`ext.call_stack`** — inspect when paused
|
||||
- **`web.evaluate`** `{expression}` — eval in webview
|
||||
- **`web.post_message`** `{message}` — send postMessage to extension host via exposed vsCodeApi
|
||||
- **`wait_for_pause`** `{timeout?}` — block until breakpoint hit
|
||||
- **`ui.locator`** `{role?, testId?, text?, frame?}` — Playwright locator (auto-retries on stale sidebar frame)
|
||||
- **`ui.react_input`** `{text, selector?, clear?, submit?}` — set React textarea value via `execCommand('insertText')`; works reliably across multiple tasks
|
||||
- **`ui.send_message`** `{text, images?, files?, responseType?}` — send chat message bypassing the textarea entirely (via gRPC postMessage)
|
||||
- **`ui.command_palette`** `{command}` — run VSCode command
|
||||
|
||||
## Typical Session
|
||||
|
||||
```bash
|
||||
# 1. Launch
|
||||
curl localhost:19229/api -d '{"method":"launch","params":{"skipBuild":true}}'
|
||||
|
||||
# 2. Open sidebar + dismiss overlays (ALWAYS do this first)
|
||||
curl localhost:19229/api -d '{"method":"ui.open_sidebar"}'
|
||||
curl localhost:19229/api -d '{"method":"web.evaluate","params":{"expression":"document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
|
||||
|
||||
# 3. Navigate to view
|
||||
curl localhost:19229/api -d '{"method":"ui.command_palette","params":{"command":"cline.accountButtonClicked"}}'
|
||||
|
||||
# 4. Check captured OAuth URLs if testing auth
|
||||
curl localhost:19229/api -d '{"method":"oauth.captured_urls"}'
|
||||
|
||||
# 5. Verify
|
||||
curl localhost:19229/api -d '{"method":"ui.screenshot"}'
|
||||
```
|
||||
|
||||
## Caveats
|
||||
|
||||
- **⚠️ Dismiss promotional overlays FIRST**: On fresh launches, full-screen promo overlays block the sidebar. **Dismiss immediately after `ui.open_sidebar`**, before any other interaction or screenshot. May need to run twice:
|
||||
```bash
|
||||
curl localhost:19229/api -d '{"method": "ui.open_sidebar"}'
|
||||
curl localhost:19229/api -d '{"method": "web.evaluate", "params": {"expression": "document.querySelectorAll(\".sr-only\").forEach(el => el.parentElement?.click())"}}'
|
||||
```
|
||||
- **Screenshots — don't open the file**: `ui.screenshot` and `ui.sidebar_screenshot` save PNGs to `/tmp/cline-debug/` and return the `{path}`. Use `read_file` on that path to examine screenshots. Running `open <path>` launches Preview.app on macOS which covers the VSCode window.
|
||||
- **Scripts count = 0 after launch**: CDP connects after extension host starts, so scripts parsed during startup aren't tracked. Breakpoints still work via sourcemap resolution.
|
||||
- **Port 9230**: Extension host inspector. If another VSCode instance uses this port, the harness will fail to connect. Kill other debug instances first.
|
||||
- **macOS only** for now (Playwright Electron launch behavior).
|
||||
- **Webview CDP**: `connect_webview` may fail depending on Electron version. `web.evaluate` still works via Playwright's `frame.evaluate()` fallback.
|
||||
- **Sourcemap paths**: esbuild outputs relative paths like `../src/extension.ts` in the sourcemap. The resolver handles this, but if a file isn't found, use `ext.source_files` to see exact paths.
|
||||
- **OAuth with fake codes**: Browser capture intercepts the URL but doesn't provide a valid auth code. For real OAuth testing, open the captured URL in a browser. For unit testing, mock the token exchange.
|
||||
|
||||
See `src/dev/debug-harness/README.md` for full API reference.
|
||||
+103
-102
@@ -13,56 +13,11 @@ This file is the secret sauce for working effectively in this codebase. It captu
|
||||
**What NOT to add:** Stuff you can figure out from reading a few files, obvious patterns, or standard practices. This file should be high-signal, not comprehensive.
|
||||
|
||||
## Miscellaneous
|
||||
- The whole repo (including `apps/vscode`) uses **bun** for package management and task running. Emit `bun run X` / `bun install` / `bunx <bin>` / `bun file.ts`, never npm/npx. Node remains the *runtime* (VS Code's extension host and the standalone cline-core are Node), so Node-runtime tokens are legitimate and must not be "fixed" to bun — see @.clinerules/bun-and-node.md for the keep-list vs rewrite-list.
|
||||
- Avoid provider-specific string matching / hardcoded provider branches when fixing provider/config plumbing. Prefer provider metadata, shared catalog/defaults, explicit protocol/client capabilities, or centralized normalization utilities that apply by data shape rather than `providerId === "..."`. If a provider exception seems necessary, stop and explain why instead of adding ad-hoc string matching.
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `bun run compile`, not `bun run build`).
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
|
||||
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
|
||||
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
|
||||
- Additional instructions about making requests: @.clinerules/network.md
|
||||
|
||||
## Searching the Codebase — Avoiding Build Output
|
||||
|
||||
Several directories contain build output or generated code that produces
|
||||
noisy or unusable results with `search_files` / `grep`:
|
||||
|
||||
| Directory | What it is | Why it's a problem |
|
||||
|-----------|-----------|-------------------|
|
||||
| `out/` | esbuild bundle output | Mirrors `src/` structure as minified JS — every search gets duplicate hits on single-line files |
|
||||
| `dist/` | Packaged extension | Entire extension bundled into one minified `extension.js` (~1 long line) |
|
||||
| `dist-standalone/` | Standalone build output | Same minification issue |
|
||||
| `src/generated/` | Generated protobuf code | Auto-generated from `proto/`; not the source of truth |
|
||||
| `src/shared/proto/` | Generated proto type defs | Auto-generated from `proto/`; not the source of truth |
|
||||
| `node_modules/` | Dependencies | Huge, not project source |
|
||||
|
||||
### How to skip build output
|
||||
|
||||
**`search_files`** — Point at `src/` (not the project root) and use `file_pattern`:
|
||||
```
|
||||
search_files(path="src/core", regex="myFunction", file_pattern="*.ts")
|
||||
```
|
||||
The `file_pattern` parameter is the most effective filter — e.g. `"*.ts"`,
|
||||
`"*.tsx"`, `"*.proto"`.
|
||||
|
||||
**`grep` directly** — Exclude build dirs and restrict to source extensions:
|
||||
```bash
|
||||
grep -rn "myFunction" src/ --include="*.ts" --exclude-dir={out,dist,node_modules,generated}
|
||||
```
|
||||
|
||||
### When you must search minified files
|
||||
|
||||
Sometimes you need to verify what got bundled (e.g., checking if a change
|
||||
made it into the build). Minified files are typically one long line, so
|
||||
normal `grep` shows the entire file as context. Use these approaches:
|
||||
|
||||
- **`grep -oP`** to extract just the match with limited surrounding context:
|
||||
```bash
|
||||
grep -oP '.{0,40}myFunction.{0,40}' dist/extension.js
|
||||
```
|
||||
- **`read_file`** on files in `out/src/` — these have source maps and are
|
||||
more readable than `dist/extension.js` (which is the fully bundled output).
|
||||
- **Source maps** — `out/src/*.js.map` and `dist/extension.js.map` can be
|
||||
used to trace minified output back to original source locations.
|
||||
|
||||
## gRPC/Protobuf Communication
|
||||
The extension and webview communicate via gRPC-like protocol over VS Code message passing.
|
||||
|
||||
@@ -73,7 +28,7 @@ The extension and webview communicate via gRPC-like protocol over VS Code messag
|
||||
- Naming: Services `PascalCaseService`, RPCs `camelCase`, Messages `PascalCase`
|
||||
- For streaming responses, use `stream` keyword (see `subscribeToAuthCallback` in `account.proto`)
|
||||
|
||||
**Run `bun run protos`** after any proto changes—generates types in:
|
||||
**Run `npm run protos`** after any proto changes—generates types in:
|
||||
- `src/shared/proto/` - Shared type definitions
|
||||
- `src/generated/grpc-js/` - Service implementations
|
||||
- `src/generated/nice-grpc/` - Promise-based clients
|
||||
@@ -93,15 +48,104 @@ The extension and webview communicate via gRPC-like protocol over VS Code messag
|
||||
- `src/core/controller/task/explainChanges.ts` - Handler implementation
|
||||
- `webview-ui/src/components/chat/ChatRow.tsx` - UI rendering
|
||||
|
||||
## Adding a New API Provider
|
||||
When adding a new provider (e.g., "openai-codex"), you must update the proto conversion layer in THREE places or the provider will silently reset to Anthropic:
|
||||
|
||||
1. `proto/cline/models.proto` - Add to the `ApiProvider` enum (e.g., `OPENAI_CODEX = 40;`)
|
||||
2. `convertApiProviderToProto()` in `src/shared/proto-conversions/models/api-configuration-conversion.ts` - Add case mapping string to proto enum
|
||||
3. `convertProtoToApiProvider()` in the same file - Add case mapping proto enum back to string
|
||||
|
||||
**Why this matters:** Without these, the provider string hits the `default` case and returns `ANTHROPIC`. The webview, provider list, and handler all work fine, but the state silently resets when it round-trips through proto serialization. No error is thrown.
|
||||
|
||||
**Other files to update when adding a provider:**
|
||||
- `src/shared/api.ts` - Add to `ApiProvider` union type, define models
|
||||
- `src/shared/providers/providers.json` - Add to provider list for dropdown
|
||||
- `src/core/api/index.ts` - Register handler in `createHandlerForProvider()`
|
||||
- `webview-ui/src/components/settings/utils/providerUtils.ts` - Add cases in `getModelsForProvider()` and `normalizeApiConfiguration()`
|
||||
- `webview-ui/src/utils/validate.ts` - Add validation case
|
||||
- `webview-ui/src/components/settings/ApiOptions.tsx` - Render provider component
|
||||
|
||||
## Responses API Providers (OpenAI Codex, OpenAI Native)
|
||||
Providers using OpenAI's Responses API require native tool calling. XML tools don't work with the Responses API.
|
||||
|
||||
**Symptoms of broken native tool calling:**
|
||||
- Tools get called multiple times (e.g., `ask_followup_question` asks the same question twice)
|
||||
- Tool arguments get duplicated or malformed
|
||||
- The model responds but tools aren't recognized
|
||||
|
||||
**Root causes to check:**
|
||||
1. **Provider missing from `isNextGenModelProvider()`** in `src/utils/model-utils.ts`. The native variant matchers (e.g., `native-gpt-5/config.ts`) call this function. If your provider isn't in the list, the matcher returns false and falls back to XML tools.
|
||||
|
||||
2. **Model missing `apiFormat: ApiFormat.OPENAI_RESPONSES`** in its model info (`src/shared/api.ts`). This property signals that the model requires native tool calling. The task runner in `src/core/task/index.ts` checks this and forces `enableNativeToolCalls: true` regardless of user settings.
|
||||
|
||||
**When adding a new Responses API provider:**
|
||||
1. Add provider to `isNextGenModelProvider()` list in `src/utils/model-utils.ts`
|
||||
2. Set `apiFormat: ApiFormat.OPENAI_RESPONSES` on all models that use the Responses API
|
||||
3. The variant matcher and task runner will handle the rest automatically
|
||||
|
||||
## Adding Tools to System Prompt
|
||||
This is tricky—multiple prompt variants and configs. **Always search for existing similar tools first and follow their pattern.** Look at the full chain from prompt definition → variant configs → handler → UI before implementing.
|
||||
|
||||
1. **Add to `ClineDefaultTool` enum** in `src/shared/tools.ts`
|
||||
2. **Tool definition** in `src/core/prompts/system-prompt/tools/` (create file like `generate_explanation.ts`)
|
||||
- Define variants for each `ModelFamily` (generic, next-gen, xs, etc.)
|
||||
- Export variants array (e.g., `export const my_tool_variants = [GENERIC, NATIVE_NEXT_GEN, XS]`)
|
||||
- **Fallback behavior**: If a variant isn't defined for a model family, `ClineToolSet.getToolByNameWithFallback()` automatically falls back to GENERIC. So you only need to export `[GENERIC]` unless the tool needs model-specific behavior.
|
||||
3. **Register in `src/core/prompts/system-prompt/tools/init.ts`** - Import and spread into `allToolVariants`
|
||||
4. **Add to variant configs** - Each model family has its own config in `src/core/prompts/system-prompt/variants/*/config.ts`. Add your tool's enum to the `.tools()` list:
|
||||
- `generic/config.ts`, `next-gen/config.ts`, `gpt-5/config.ts`, `native-gpt-5/config.ts`, `native-gpt-5-1/config.ts`, `native-next-gen/config.ts`, `gemini-3/config.ts`, `glm/config.ts`, `hermes/config.ts`, `xs/config.ts`
|
||||
- **Important**: If you add to a variant's config, make sure the tool spec exports a variant for that ModelFamily (or relies on GENERIC fallback)
|
||||
5. **Create handler** in `src/core/task/tools/handlers/`
|
||||
6. **Wire up in `ToolExecutor.ts`** if needed for execution flow
|
||||
7. **Add to tool parsing** in `src/core/assistant-message/index.ts` if needed
|
||||
8. **If tool has UI feedback**: add `ClineSay` enum in proto, update `src/shared/ExtensionMessage.ts`, update `src/shared/proto-conversions/cline-message.ts`, update `webview-ui/src/components/chat/ChatRow.tsx`
|
||||
|
||||
## Modifying System Prompt
|
||||
**Read these first:** `src/core/prompts/system-prompt/README.md`, `tools/README.md`, `__tests__/README.md`
|
||||
|
||||
System prompt is modular: **components** (reusable sections) + **variants** (model-specific configs) + **templates** (with `{{PLACEHOLDER}}` resolution).
|
||||
|
||||
**Key directories:**
|
||||
- `components/` - Shared sections: `rules.ts`, `capabilities.ts`, `editing_files.ts`, etc.
|
||||
- `variants/` - Model-specific: `generic/`, `next-gen/`, `xs/`, `gpt-5/`, `gemini-3/`, `hermes/`, `glm/`, etc.
|
||||
- `templates/` - Template engine and placeholder definitions
|
||||
|
||||
**Variant tiers (ask user which to modify):**
|
||||
- **Next-gen** (Claude 4, GPT-5, Gemini 2.5): `next-gen/`, `native-next-gen/`, `native-gpt-5/`, `native-gpt-5-1/`, `gemini-3/`, `gpt-5/`
|
||||
- **Standard** (default fallback): `generic/`
|
||||
- **Local/small models**: `xs/`, `hermes/`, `glm/`
|
||||
|
||||
**How overrides work:** Variants can override components via `componentOverrides` in their `config.ts`, or provide a custom template in `template.ts` (e.g., `next-gen/template.ts` exports `rules_template`). If no override, the shared component from `components/` is used.
|
||||
|
||||
**Example: Adding a rule to RULES section**
|
||||
1. Check if variant overrides rules: look for `rules_template` in `variants/*/template.ts` or `componentOverrides.RULES` in `config.ts`
|
||||
2. If shared: modify `components/rules.ts`
|
||||
3. If overridden: modify that variant's template
|
||||
4. XS variant is special—has heavily condensed inline content in `template.ts`
|
||||
|
||||
**After any changes, regenerate snapshots:**
|
||||
```bash
|
||||
UPDATE_SNAPSHOTS=true npm run test:unit
|
||||
```
|
||||
Snapshots live in `__tests__/__snapshots__/`. Tests validate across model families and context variations (browser, MCP, focus chain).
|
||||
|
||||
## Modifying Default Slash Commands
|
||||
Three places need updates:
|
||||
- `src/core/slash-commands/index.ts` - Command definitions
|
||||
- `src/core/prompts/commands.ts` - System prompt integration
|
||||
- `webview-ui/src/utils/slash-commands.ts` - Webview autocomplete
|
||||
|
||||
## Adding New Global State Keys
|
||||
Adding a new key to global state requires updates in multiple places. Missing any step causes silent failures.
|
||||
|
||||
Required steps:
|
||||
1. Type definition in `src/shared/storage/state-keys.ts` - Add to `GlobalState` or `Settings` interface
|
||||
2. Add any default value or transform in `src/shared/storage/state-keys.ts` if the key needs one
|
||||
3. Read and write the value through `StateManager` (`setGlobalState()` / `getGlobalStateKey()`) after initialization
|
||||
2. Read from globalState in `src/core/storage/utils/state-helpers.ts`:
|
||||
- Add `const myKey = context.globalState.get<GlobalStateAndSettings["myKey"]>("myKey")` in `readGlobalStateFromDisk()`
|
||||
- Add to the return object: `myKey: myKey ?? defaultValue,`
|
||||
3. StateManager handles read/write via `setGlobalState()`/`getGlobalStateKey()` after initialization
|
||||
|
||||
Persistent state is file-backed through `StateManager`; do not add new runtime reads or writes against VS Code `ExtensionContext` storage. That storage is only a legacy migration source.
|
||||
Common mistake: Adding only the return value without the `context.globalState.get()` call. This compiles but the value is always `undefined` on load.
|
||||
|
||||
Settings plumbing gotcha: if a key is user-toggleable from settings, wire both controller update paths:
|
||||
- `src/core/controller/state/updateSettings.ts` for webview `updateSetting(...)`
|
||||
@@ -109,26 +153,28 @@ Settings plumbing gotcha: if a key is user-toggleable from settings, wire both c
|
||||
Missing one path causes a toggle to appear to change in one surface while the backend state stays unchanged.
|
||||
|
||||
Webview toggle gotcha: settings changes must also round-trip back in state payloads.
|
||||
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `bun run protos`
|
||||
- Add the field to `UpdateSettingsRequest` in `proto/cline/state.proto` (for webview update requests), then run `npm run protos`
|
||||
- Include the key in `Controller.getStateToPostToWebview()` (`src/core/controller/index.ts`)
|
||||
- Ensure `ExtensionState` and webview defaults include the key (`src/shared/ExtensionMessage.ts`, `webview-ui/src/context/ExtensionStateContext.tsx`)
|
||||
If this round-trip wiring is missing, the backend value can update but the toggle in webview appears stuck or reverts.
|
||||
|
||||
## StateManager Cache vs Direct globalState Access
|
||||
StateManager uses an in-memory cache populated during `StateManager.initialize()` from file-backed storage. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
|
||||
StateManager uses an in-memory cache populated during `StateManager.initialize(context)` in `common.ts`. For most state, use `controller.stateManager.setGlobalState()`/`getGlobalStateKey()`.
|
||||
|
||||
Exception: host migration code may read legacy VS Code storage before file-backed storage is initialized.
|
||||
Exception: State needed immediately at extension startup (before cache is ready)
|
||||
|
||||
Example pattern:
|
||||
When Window A sets state and immediately opens Window B, the new window's StateManager cache is populated from `context.globalState` during initialization. If you need to read state in Window B right at startup (e.g., in `common.ts` during `initialize()`), read directly from `context.globalState.get()` instead of StateManager's cache.
|
||||
|
||||
Example pattern (see `lastShownAnnouncementId` and `worktreeAutoOpenPath`):
|
||||
```typescript
|
||||
// Writing (normal pattern)
|
||||
controller.stateManager.setGlobalState("myKey", value)
|
||||
|
||||
// Reading after initialization
|
||||
const value = controller.stateManager.getGlobalStateKey("myKey")
|
||||
// Reading at startup in common.ts (bypass cache)
|
||||
const value = context.globalState.get<string>("myKey")
|
||||
```
|
||||
|
||||
Use `context.globalState` only in VS Code migration code that copies legacy ExtensionContext values into the shared file-backed stores.
|
||||
This is only needed for cross-window state read during the brief startup window before StateManager cache is fully usable. Normal state access after initialization should use StateManager.
|
||||
|
||||
## ChatRow Cancelled/Interrupted States
|
||||
When a ChatRow displays a loading/in-progress state (spinner), you must handle what happens when the task is cancelled. This is non-obvious because cancellation doesn't update the message content—you have to infer it from context.
|
||||
@@ -157,48 +203,3 @@ const isGenerating = explanationInfo.status === "generating" && !wasCancelled
|
||||
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
|
||||
|
||||
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
|
||||
|
||||
## Debug Harness: clear inherited VSCode/Electron env vars before launching
|
||||
|
||||
The debug harness (`apps/vscode/src/dev/debug-harness/server.ts`) launches a child
|
||||
VSCode via Playwright's `_electron.launch({ env: { ...process.env, ... } })`. If you
|
||||
run the harness from a process that was itself spawned by VSCode (e.g. the Cline
|
||||
extension host, an integrated terminal, or an agent running inside VSCode), the
|
||||
parent's VSCode/Electron env vars leak into the child and break the launch.
|
||||
|
||||
The fatal one is **`ELECTRON_RUN_AS_NODE=1`**: it makes the child VSCode binary run
|
||||
as plain Node, so it rejects every VSCode CLI flag. Symptom:
|
||||
|
||||
```
|
||||
.../Visual Studio Code.app/Contents/MacOS/Code: bad option: --extensionDevelopmentPath=...
|
||||
Error: Process failed to launch! (Playwright _electron.launch)
|
||||
```
|
||||
|
||||
This is NOT the macOS Playwright flakiness mentioned in the harness README — it's
|
||||
env inheritance. Fix: strip the inherited vars before starting the harness:
|
||||
|
||||
```bash
|
||||
env -u ELECTRON_RUN_AS_NODE -u ELECTRON_NO_ATTACH_CONSOLE \
|
||||
-u VSCODE_CLI -u VSCODE_CODE_CACHE_PATH -u VSCODE_CRASH_REPORTER_PROCESS_TYPE \
|
||||
-u VSCODE_CWD -u VSCODE_ESM_ENTRYPOINT -u VSCODE_HANDLES_UNCAUGHT_ERRORS \
|
||||
-u VSCODE_IPC_HOOK -u VSCODE_NLS_CONFIG -u VSCODE_PID -u VSCODE_L10N_BUNDLE_LOCATION \
|
||||
bun src/dev/debug-harness/server.ts --auto-launch --skip-build
|
||||
```
|
||||
|
||||
Check your own env with `env | grep -iE 'electron|vscode_'` first; `ELECTRON_RUN_AS_NODE=1`
|
||||
present means you must scrub before launching.
|
||||
|
||||
Other harness notes confirmed in practice:
|
||||
- The extension host is **ESM** (`VSCODE_ESM_ENTRYPOINT`), so `ext.evaluate` has no
|
||||
`require` and module-internal functions aren't reachable as globals. To inspect
|
||||
internal builders (e.g. `buildBedrockProviderConfig`), set a breakpoint with
|
||||
`ext.set_breakpoint` and read locals via `ext.evaluate` with the paused `callFrameId`
|
||||
— don't try to `require()` the bundle.
|
||||
- `web.evaluate` wraps the expression as a single returned expression; multi-statement
|
||||
snippets must be an IIFE `(() => { ...; return x; })()`, otherwise you get
|
||||
`SyntaxError: Unexpected token ';'`.
|
||||
- Webview settings inputs are `vscode-text-field` web components with debounced React
|
||||
onChange. Setting `.value` + dispatching events via `web.evaluate` is unreliable for
|
||||
some fields; focus the inner shadow `input` then use real keystrokes (`ui.type` +
|
||||
`ui.press Tab`, or click the dropdown option) to make the value persist.
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ Here, we use the common `StringRequest` and `KeyValuePair` types.
|
||||
|
||||
After editing a `.proto` file, regenerate the TypeScript code. From the project root, run:
|
||||
```bash
|
||||
bun run protos
|
||||
npm run protos
|
||||
```
|
||||
This command compiles all `.proto` files and outputs the generated code to `src/generated/` and `src/shared/`. Do not edit these generated files manually.
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
# SDK Adapter
|
||||
|
||||
The VSCode extension runs on the Cline SDK (`@cline/core`, `@cline/llms`,
|
||||
`@cline/shared`) through an adapter layer in `apps/vscode/src/sdk/`. The
|
||||
webview still talks gRPC; the adapter translates between gRPC handlers and SDK
|
||||
calls. See `apps/vscode/src/dev/debug-harness/README.md` for the debug harness.
|
||||
|
||||
## Conventions
|
||||
|
||||
1. **Look up SDK APIs, don't guess.** Use `kb_search(name="sdk", query="...")`
|
||||
before implementing against an SDK surface.
|
||||
2. **Reference the pre-SDK implementation when replacing a module.** Add a
|
||||
`// Replaces classic src/core/... (see origin/main)` header and use
|
||||
`kb_search(name="cline", commit="origin/main")` or
|
||||
`git show origin/main:path` to consult the prior implementation.
|
||||
3. **Single entry point.** There is one codepath — the SDK adapter. No
|
||||
`CLINE_SDK` env flag.
|
||||
4. **Use `{appBaseUrl}`**, never hardcode `app.cline.bot`.
|
||||
5. **Avoid `as` casts.** Use explicit conversion functions with tests. The
|
||||
branded types in `apps/vscode/src/sdk/model-catalog/contracts.ts` exist so
|
||||
casts are unnecessary outside parse/compute boundaries.
|
||||
|
||||
## Debug harness
|
||||
|
||||
- **Dismiss the Kanban/promo overlay** before any debug harness interaction.
|
||||
- **Use the command palette** to navigate tabs in the debug harness.
|
||||
@@ -91,7 +91,7 @@ On the main branch, create a commit that updates:
|
||||
|
||||
3. No changelog-entry file cleanup is needed. Contributors do not create changelog-entry files in this repo.
|
||||
|
||||
**No dependency install is needed.** A CHANGELOG + `version` bump does not change any dependency, and `bun.lock` does not pin workspace-package versions, so the lockfile stays consistent. The publish workflow runs `bun install --frozen-lockfile`, which would *fail* on an out-of-sync lock — so only run `bun install` here if you actually change dependencies (then commit the updated `bun.lock`).
|
||||
**Skip running `npm run install:all`** - release automation handles lockfile consistency as needed.
|
||||
|
||||
Commit with message format: `v{VERSION} Release Notes (hotfix)`
|
||||
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @dominiccooney
|
||||
/.github/ @saoudrizwan @arafatkatze @maxpaulus43 @candieduniverse
|
||||
/README.md @saoudrizwan @juanpflores
|
||||
|
||||
@@ -7,10 +7,10 @@ body:
|
||||
value: |
|
||||
**Important:** All bug reports must be reproducible using Claude Sonnet 4.5. Cline uses complex prompts so less capable models may not work as expected.
|
||||
- type: dropdown
|
||||
id: cline-surface
|
||||
id: plugin-type
|
||||
attributes:
|
||||
label: Cline Surface
|
||||
description: Which Cline surface are you reporting a bug for?
|
||||
label: Plugin Type
|
||||
description: Which plugin are you reporting a bug for?
|
||||
options:
|
||||
- VSCode Extension
|
||||
- JetBrains Plugin
|
||||
@@ -59,18 +59,6 @@ body:
|
||||
placeholder: 'e.g., cline:anthropic/claude-sonnet-4.5, gemini:gemini-2.5-pro-exp-03-25'
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: ide-diagnostics
|
||||
attributes:
|
||||
label: IDE / CLI Diagnostics
|
||||
description: |
|
||||
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
|
||||
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
|
||||
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
|
||||
- CLI: there is no About dialog. Run `cline --version` and paste the output.
|
||||
placeholder: Paste the copied About info or `cline --version` output here.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: system-info
|
||||
attributes:
|
||||
|
||||
@@ -9,14 +9,14 @@ This is a VS Code extension. Read `.clinerules/general.md` for tribal knowledge
|
||||
- **MCP**: `src/services/mcp/McpHub.ts`.
|
||||
|
||||
## Build & Test (Critical — non-obvious commands)
|
||||
- **Build**: `bun run compile` — NOT `bun run build`.
|
||||
- **Watch**: `bun run watch` (extension + webview).
|
||||
- **Protos**: `bun run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
|
||||
- **Tests**: `bun run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true bun run test:unit`.
|
||||
- **Build**: `npm run compile` — NOT `npm run build`.
|
||||
- **Watch**: `npm run watch` (extension + webview).
|
||||
- **Protos**: `npm run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
|
||||
- **Tests**: `npm run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true npm run test:unit`.
|
||||
|
||||
## Protobuf RPC Workflow (4 steps)
|
||||
1. **Define** in `proto/cline/*.proto`. Naming: `PascalCaseService`, `camelCase` RPCs, `PascalCase` Messages. Use `common.proto` shared types for simple data.
|
||||
2. **Generate**: `bun run protos`.
|
||||
2. **Generate**: `npm run protos`.
|
||||
3. **Backend handler**: `src/core/controller/<domain>/`.
|
||||
4. **Frontend call**: `UiServiceClient.myMethod(Request.create({...}))`.
|
||||
- Adding enums (e.g. `ClineSay`) → also update `src/shared/proto-conversions/cline-message.ts`.
|
||||
@@ -38,13 +38,13 @@ For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/mod
|
||||
4. Whitelist in `src/core/prompts/system-prompt/variants/*/config.ts` for each model family.
|
||||
5. Handler in `src/core/task/tools/handlers/`, wire in `ToolExecutor.ts`.
|
||||
6. If tool has UI: add `ClineSay` enum in proto → `ExtensionMessage.ts` → `cline-message.ts` → `ChatRow.tsx`.
|
||||
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true bun run test:unit`.
|
||||
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true npm run test:unit`.
|
||||
|
||||
## Modifying System Prompt
|
||||
Modular: `components/` (shared) + `variants/` (model-specific) + `templates/` (`{{PLACEHOLDER}}`). Variants override components via `componentOverrides` in `config.ts` or custom `template.ts`. XS variant is heavily condensed inline. Always regenerate snapshots after changes.
|
||||
|
||||
## Global State Keys (silent failure risk)
|
||||
Adding a key requires updating the typed storage definitions in `src/shared/storage/state-keys.ts`; runtime reads and writes should go through `StateManager`, not VS Code `ExtensionContext` storage. Persistent state is file-backed so it works across VS Code, CLI, and JetBrains hosts.
|
||||
Adding a key requires: type in `src/shared/storage/state-keys.ts`, read via `context.globalState.get()` in `src/core/storage/utils/state-helpers.ts` `readGlobalStateFromDisk()`, and add to return object. Missing the `.get()` call compiles fine but value is always `undefined`.
|
||||
|
||||
## Slash Commands (3 places)
|
||||
- `src/core/slash-commands/index.ts` — definitions.
|
||||
|
||||
@@ -2,7 +2,7 @@ version: 2
|
||||
updates:
|
||||
# Main extension dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/vscode"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
# Group all updates into a single PR
|
||||
@@ -20,7 +20,7 @@ updates:
|
||||
|
||||
# Webview UI dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/apps/vscode/webview-ui"
|
||||
directory: "/webview-ui"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
|
||||
@@ -59,7 +59,7 @@ We're not looking for exhaustive documentation - just evidence that you've thoug
|
||||
<!-- Put an 'x' in all boxes that apply -->
|
||||
|
||||
- [ ] Changes are limited to a single feature, bugfix or chore (split larger changes into separate PRs)
|
||||
- [ ] Tests are passing (`bun test`) and code is formatted and linted (`bun run format && bun run lint`)
|
||||
- [ ] Tests are passing (`npm test`) and code is formatted and linted (`npm run format && npm run lint`)
|
||||
- [ ] I have reviewed [contributor guidelines](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -33,7 +33,7 @@ permissions:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
working-directory: sdk
|
||||
|
||||
jobs:
|
||||
publish-main:
|
||||
@@ -105,12 +105,12 @@ jobs:
|
||||
PACKAGE_VERSION=$(node -p "require('./apps/cli/package.json').version")
|
||||
|
||||
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
|
||||
echo "apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
|
||||
echo "sdk/apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! printf "%s\n" "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||
echo "apps/cli/package.json has invalid version: ${VERSION}"
|
||||
echo "sdk/apps/cli/package.json has invalid version: ${VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -132,31 +132,13 @@ jobs:
|
||||
|
||||
- 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: Run tests
|
||||
run: bun run test
|
||||
|
||||
- name: Build platform binaries
|
||||
run: bun script/build.ts --install-native-variants --skip-sdk-build
|
||||
working-directory: apps/cli
|
||||
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 }}
|
||||
working-directory: sdk/apps/cli
|
||||
|
||||
- name: Verify build output
|
||||
env:
|
||||
@@ -194,7 +176,7 @@ jobs:
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
run: bun script/publish-npm.ts --tag latest
|
||||
working-directory: apps/cli
|
||||
working-directory: sdk/apps/cli
|
||||
|
||||
- name: Get Previous CLI Tag
|
||||
id: prev_tag
|
||||
@@ -331,15 +313,6 @@ jobs:
|
||||
- name: Build SDK packages
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
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: Run tests
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
@@ -375,16 +348,7 @@ jobs:
|
||||
- name: Build platform binaries
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: bun script/build.ts --install-native-variants --skip-sdk-build
|
||||
working-directory: apps/cli
|
||||
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 }}
|
||||
working-directory: sdk/apps/cli
|
||||
|
||||
- name: Verify build output
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
@@ -424,7 +388,7 @@ jobs:
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
run: bun script/publish-npm.ts --tag nightly
|
||||
working-directory: apps/cli
|
||||
working-directory: sdk/apps/cli
|
||||
|
||||
- name: Summary
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
|
||||
@@ -15,11 +15,9 @@ jobs:
|
||||
trigger-integration-test:
|
||||
name: Run Tests
|
||||
runs-on: ubuntu-latest
|
||||
# Auto-run only for trusted PR authors. Anyone else needs a maintainer
|
||||
# to opt their PR in by commenting /test-jetbrains.
|
||||
# Run on PR open/reopen, or when someone comments /test-jetbrains on a PR
|
||||
if: |
|
||||
(github.event_name == 'pull_request_target' &&
|
||||
contains(fromJSON('["MEMBER","OWNER","COLLABORATOR"]'), github.event.pull_request.author_association)) ||
|
||||
github.event_name == 'pull_request_target' ||
|
||||
(github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
contains(github.event.comment.body, '/test-jetbrains') &&
|
||||
@@ -29,8 +27,8 @@ jobs:
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ vars.CLINE_JETBRAINS_APP_ID }}
|
||||
private-key: ${{ secrets.CLINE_JETBRAINS_APP_KEY }}
|
||||
app-id: 1998650
|
||||
private-key: ${{ secrets.CLINE_JETBRAINS_WORKFLOW_KEY }}
|
||||
owner: cline
|
||||
repositories: intellij-plugin
|
||||
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
name: ext-vscode-publish-legacy
|
||||
|
||||
# Publishes the legacy (pre-SDK-migration) VS Code extension from the
|
||||
# `legacy-extension` branch. This branch holds the npm-based 3.89.x codebase,
|
||||
# rolled forward under a 4.0.x version so existing 4.0.0 users still receive
|
||||
# the update. The main `ext-vscode-publish-stable.yml` workflow (bun-based)
|
||||
# stays the path for releasing main once the SDK migration is solid.
|
||||
#
|
||||
# This workflow lives on and is dispatched from `main` (so it satisfies the
|
||||
# default-branch dispatch requirement), but it checks out and builds the
|
||||
# `legacy-extension` branch.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release-type:
|
||||
description: "Choose release type (release or pre-release)"
|
||||
required: true
|
||||
default: "release"
|
||||
type: choice
|
||||
options:
|
||||
- pre-release
|
||||
- release
|
||||
branch:
|
||||
description: "Branch holding the legacy extension code"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Gate the publish on the legacy branch's own npm-based test suite. We can't
|
||||
# reuse ./.github/workflows/ext-vscode-test.yml here — on main that's the
|
||||
# bun-based suite and it would test main, not the legacy branch — so the
|
||||
# essential quality + test steps are inlined against the checked-out legacy
|
||||
# branch.
|
||||
test:
|
||||
name: Test Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Run Quality Checks (lint + typecheck)
|
||||
run: npm run ci:check-all
|
||||
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Unit Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Webview Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
name: Publish Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
# Check out the legacy branch (NOT main). fetch-depth: 0 + tags so we
|
||||
# can create/push the release tag and compute the previous tag.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
lfs: true
|
||||
|
||||
- name: Resolve Release Tag
|
||||
id: resolve_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
run: |
|
||||
# Tag is derived from the package version on the legacy branch.
|
||||
VERSION=$(node -p "require('./apps/vscode/package.json').version")
|
||||
TAG="v$VERSION"
|
||||
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "Error: derived tag '$TAG' does not match vX.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
TAG_REF="refs/tags/$TAG"
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
if git show-ref --verify --quiet "$TAG_REF"; then
|
||||
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Error: tag '$TAG' already exists at $TAG_SHA, not at branch head ($HEAD_SHA)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag '$TAG' already exists at branch head. Continuing."
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$TAG" "$HEAD_SHA"
|
||||
git push origin "$TAG_REF"
|
||||
echo "Created and pushed tag '$TAG' from $BRANCH head $HEAD_SHA."
|
||||
fi
|
||||
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode install --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Get Version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify Tag Matches Package Version
|
||||
run: |
|
||||
TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
VERSION="v${{ steps.get_version.outputs.version }}"
|
||||
if [[ "$TAG" != "$VERSION" ]]; then
|
||||
echo "Error: tag '$TAG' does not match package version '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify Changelog Entry
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
|
||||
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
- name: Verify Marketplace Tokens
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
|
||||
exit 1
|
||||
fi
|
||||
echo "Marketplace publish tokens are configured."
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
RELEASE_TYPE: ${{ github.event.inputs.release-type }}
|
||||
run: |
|
||||
# Swap README.marketplace.md into README.md so both the GitHub
|
||||
# release artifact (vsce package below) and the marketplace
|
||||
# publish (npm run publish:marketplace below, which swaps
|
||||
# internally as an idempotent no-op) ship the same README.
|
||||
node scripts/marketplace-readme.mjs swap-in
|
||||
trap 'node scripts/marketplace-readme.mjs restore' EXIT
|
||||
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
if [ "$RELEASE_TYPE" = "pre-release" ]; then
|
||||
npm run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
npm run publish:marketplace
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.resolve_tag.outputs.tag }}
|
||||
files: "apps/vscode/*.vsix"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}
|
||||
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "*Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)*"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}"
|
||||
@@ -0,0 +1,66 @@
|
||||
# TODO: Fold this workflow's SDK login changes into ext-vscode-publish-nightly.yml
|
||||
# and delete this file. Pinned to dpc/sdk-migration-simpler-login while Max is iterating.
|
||||
# Owner: Max Paulus
|
||||
name: ext-vscode-publish-nightly-sdk
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 12 * * *' # 4 AM PST (UTC-8) = 12 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
env:
|
||||
# Keep the publish source pinned to one reviewed branch instead of accepting arbitrary refs.
|
||||
SDK_NIGHTLY_REF: dpc/sdk-migration-simpler-login
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Publish Cline New SDK Extension Nightly
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
environment: PublishNightly
|
||||
|
||||
steps:
|
||||
- name: Checkout trusted SDK nightly branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ env.SDK_NIGHTLY_REF }}
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
|
||||
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
|
||||
node-version: 22
|
||||
|
||||
- name: Install root dependencies
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Publish SDK nightly extension
|
||||
env:
|
||||
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 }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: npm run publish:marketplace:nightly
|
||||
@@ -20,7 +20,6 @@ jobs:
|
||||
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
|
||||
uses: ./.github/workflows/ext-vscode-test.yml
|
||||
|
||||
publish:
|
||||
@@ -31,12 +30,6 @@ jobs:
|
||||
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 selected branch
|
||||
@@ -47,53 +40,24 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show build source
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
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 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:
|
||||
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
|
||||
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
|
||||
node-version: 22
|
||||
|
||||
# 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
|
||||
- name: Install root dependencies
|
||||
run: npm ci --include=optional
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/ before
|
||||
# packaging/publishing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun run build:sdk
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# vsce is 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
|
||||
|
||||
@@ -111,12 +75,9 @@ 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 }}
|
||||
# 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
|
||||
run: npm run publish:marketplace:nightly
|
||||
|
||||
- name: Tag published commit
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
|
||||
@@ -27,10 +27,6 @@ permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-stable-${{ github.event.inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/ext-vscode-test.yml
|
||||
@@ -40,9 +36,6 @@ jobs:
|
||||
name: Publish Extension
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -54,7 +47,6 @@ jobs:
|
||||
|
||||
- name: Resolve Release Tag
|
||||
id: resolve_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
TAG: ${{ github.event.inputs.tag }}
|
||||
AUTO_CREATE: ${{ github.event.inputs.auto_create_tag_from_main }}
|
||||
@@ -106,61 +98,24 @@ jobs:
|
||||
echo "Error: tag '$TAG' does not exist in the repository"
|
||||
exit 1
|
||||
fi
|
||||
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
|
||||
if [[ "$TAG_SHA" != "$TESTED_SHA" ]]; then
|
||||
echo "Error: existing tag '$TAG' points to $TAG_SHA, but this workflow tested $TESTED_SHA"
|
||||
echo "Dispatch from the tag ref, or from the exact main commit the tag points to."
|
||||
exit 1
|
||||
fi
|
||||
echo "Using existing tag '$TAG' at tested SHA $TESTED_SHA."
|
||||
echo "Using existing tag '$TAG'."
|
||||
fi
|
||||
|
||||
git checkout --detach "$TAG_REF^{commit}"
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "resolved_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Node is still REQUIRED in the publish job (not just for install): the
|
||||
# publish scripts run as `node scripts/publish-*.mjs`, the version step uses
|
||||
# `node -p`, and `npx ovsx` needs npm. setup-bun does not provide a Node
|
||||
# runtime, so keep setup-node. Pinned to Node 22 because newer LTS
|
||||
# (Node 24 / npm 11) can make vsce's `npm list` detection fail with
|
||||
# ELSPROBLEMS during packaging.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Single root install resolves the whole bun workspace at once (replaces the
|
||||
# per-package `npm install` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install root dependencies
|
||||
run: npm install --include=optional
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/ before
|
||||
# packaging/publishing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun run build:sdk
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm install --include=optional
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# vsce is a workspace devDependency (on node_modules/.bin), but ovsx is not
|
||||
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
|
||||
# globally (npm is available via setup-node). vsce is installed globally too
|
||||
# to preserve the script's existing PATH expectations.
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
@@ -180,60 +135,6 @@ jobs:
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify Changelog Entry
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
|
||||
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
- name: Verify Marketplace Tokens
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
|
||||
exit 1
|
||||
fi
|
||||
echo "Marketplace publish tokens are configured."
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(
|
||||
git tag --merged "$CURRENT_TAG^" --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$' \
|
||||
| head -n 1 || true
|
||||
)
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between the matching version heading and the next release heading.
|
||||
CONTENT=$(awk -v version="${{ steps.get_version.outputs.version }}" '
|
||||
$0 == "## [" version "]" { found=1; next }
|
||||
found && /^## \[/ { exit }
|
||||
found { print }
|
||||
END { if (!found) exit 1 }
|
||||
' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
@@ -257,28 +158,38 @@ jobs:
|
||||
node scripts/marketplace-readme.mjs swap-in
|
||||
trap 'node scripts/marketplace-readme.mjs restore' EXIT
|
||||
|
||||
# Required to generate the .vsix. --no-dependencies: the extension
|
||||
# is fully esbuild-bundled, and under the bun workspace the @cline/*
|
||||
# deps are symlinks pointing outside the package, so without this vsce
|
||||
# would walk them and pull the whole monorepo into the .vsix.
|
||||
vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
# These scripts run under `node scripts/publish-marketplace.mjs`;
|
||||
# bun run just launches them. Node + npm (for `npx ovsx`) come from
|
||||
# setup-node above.
|
||||
if [ "$RELEASE_TYPE" = "pre-release" ]; then
|
||||
bun run publish:marketplace:prerelease
|
||||
npm run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
bun run publish:marketplace
|
||||
npm run publish:marketplace
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.resolve_tag.outputs.tag }}
|
||||
files: "apps/vscode/*.vsix"
|
||||
files: "*.vsix"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
|
||||
@@ -36,28 +36,24 @@ jobs:
|
||||
with:
|
||||
filters: |
|
||||
e2e:
|
||||
- 'apps/vscode/src/**'
|
||||
- 'apps/vscode/webview-ui/**'
|
||||
- 'apps/vscode/proto/**'
|
||||
- 'apps/vscode/tests/**'
|
||||
- 'apps/vscode/scripts/**'
|
||||
- 'apps/vscode/standalone/**'
|
||||
- 'apps/vscode/assets/**'
|
||||
- 'apps/vscode/walkthrough/**'
|
||||
- 'apps/vscode/package.json'
|
||||
- 'apps/vscode/webview-ui/package.json'
|
||||
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
|
||||
- 'bun.lock'
|
||||
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
|
||||
- 'sdk/packages/**'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/biome.jsonc'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
- 'apps/vscode/bunfig.toml'
|
||||
- 'apps/vscode/.vscode-test.mjs'
|
||||
- 'apps/vscode/.vscodeignore'
|
||||
- 'apps/vscode/playwright*.ts'
|
||||
- 'src/**'
|
||||
- 'webview-ui/**'
|
||||
- 'proto/**'
|
||||
- 'tests/**'
|
||||
- 'scripts/**'
|
||||
- 'standalone/**'
|
||||
- 'assets/**'
|
||||
- 'walkthrough/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'buf.yaml'
|
||||
- 'tsconfig*.json'
|
||||
- 'biome.jsonc'
|
||||
- 'esbuild.mjs'
|
||||
- '.mocharc.json'
|
||||
- '.vscode-test.mjs'
|
||||
- '.vscodeignore'
|
||||
- 'playwright*.ts'
|
||||
- '.github/workflows/ext-vscode-test-e2e.yml'
|
||||
|
||||
matrix_prep:
|
||||
@@ -83,33 +79,36 @@ jobs:
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
node-version: 22
|
||||
|
||||
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
|
||||
- name: Cache Bun install cache
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: bun-cache
|
||||
id: root-cache
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('webview-ui/package-lock.json') }}
|
||||
|
||||
# Cache VS Code installation
|
||||
- name: Cache VS Code
|
||||
uses: actions/cache@v4
|
||||
id: vscode-cache
|
||||
with:
|
||||
path: apps/vscode/.vscode-test
|
||||
key: vscode-${{ runner.os }}-stable-${{ hashFiles('apps/vscode/.vscode-test.mjs', 'apps/vscode/package.json') }}
|
||||
path: .vscode-test
|
||||
key: vscode-${{ runner.os }}-stable-${{ hashFiles('.vscode-test.mjs', 'package.json') }}
|
||||
restore-keys: |
|
||||
vscode-${{ runner.os }}-stable-
|
||||
|
||||
@@ -122,41 +121,20 @@ jobs:
|
||||
~/.cache/ms-playwright
|
||||
~/Library/Caches/ms-playwright
|
||||
~/AppData/Local/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
# Single root install resolves the entire bun workspace at once (replaces
|
||||
# the per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/
|
||||
# before building/packaging the extension for E2E.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun run build:sdk
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
# Force bash: the Windows runner defaults to pwsh, which can't parse this
|
||||
# POSIX test. Git Bash ships on GitHub's windows-latest images.
|
||||
shell: bash
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# NOTE: vsce is no longer installed globally. @vscode/vsce is a workspace
|
||||
# devDependency of apps/vscode (resolved into node_modules/.bin), and the
|
||||
# `test:e2e:build` script invokes `vsce` via `bun run`, which puts the local
|
||||
# .bin on PATH. No global install needed.
|
||||
- name: Install vsce
|
||||
run: npm install -g @vscode/vsce
|
||||
|
||||
- name: Install xvfb on Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
@@ -165,11 +143,11 @@ jobs:
|
||||
# Run optimized E2E tests (eliminates redundant builds)
|
||||
- name: Run E2E tests - Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
run: xvfb-run -a bun run test:e2e:optimal
|
||||
run: xvfb-run -a npm run test:e2e:optimal
|
||||
|
||||
- name: Run E2E tests - Non-Linux
|
||||
if: matrix.runner != 'ubuntu'
|
||||
run: bun run test:e2e:optimal
|
||||
run: npm run test:e2e:optimal
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ failure() }}
|
||||
|
||||
@@ -36,45 +36,38 @@ jobs:
|
||||
with:
|
||||
filters: |
|
||||
vscode:
|
||||
- 'apps/vscode/src/**'
|
||||
- 'apps/vscode/webview-ui/**'
|
||||
- 'apps/vscode/proto/**'
|
||||
- 'apps/vscode/tests/**'
|
||||
- 'apps/vscode/scripts/**'
|
||||
- 'apps/vscode/standalone/**'
|
||||
- 'apps/vscode/assets/**'
|
||||
- 'apps/vscode/walkthrough/**'
|
||||
- 'apps/vscode/package.json'
|
||||
- 'apps/vscode/webview-ui/package.json'
|
||||
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
|
||||
- 'bun.lock'
|
||||
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
|
||||
- 'sdk/packages/**'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/biome.jsonc'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
- 'apps/vscode/bunfig.toml'
|
||||
- 'apps/vscode/.vscode-test.mjs'
|
||||
- 'apps/vscode/test-setup.js'
|
||||
- 'src/**'
|
||||
- 'webview-ui/**'
|
||||
- 'proto/**'
|
||||
- 'tests/**'
|
||||
- 'scripts/**'
|
||||
- 'standalone/**'
|
||||
- 'assets/**'
|
||||
- 'walkthrough/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'buf.yaml'
|
||||
- 'tsconfig*.json'
|
||||
- 'biome.jsonc'
|
||||
- 'esbuild.mjs'
|
||||
- '.mocharc.json'
|
||||
- '.nycrc*.json'
|
||||
- '.vscode-test.mjs'
|
||||
- 'test-setup.js'
|
||||
- '.github/workflows/ext-vscode-test.yml'
|
||||
testing_platform:
|
||||
- 'apps/vscode/src/**'
|
||||
- 'apps/vscode/proto/**'
|
||||
- 'apps/vscode/standalone/**'
|
||||
- 'apps/vscode/testing-platform/**'
|
||||
- 'apps/vscode/testing-platform/package.json'
|
||||
- 'apps/vscode/tests/specs/**'
|
||||
- 'apps/vscode/package.json'
|
||||
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
|
||||
- 'bun.lock'
|
||||
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
|
||||
- 'sdk/packages/**'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
- 'apps/vscode/.vscodeignore'
|
||||
- 'apps/vscode/scripts/**'
|
||||
- 'src/**'
|
||||
- 'proto/**'
|
||||
- 'standalone/**'
|
||||
- 'testing-platform/**'
|
||||
- 'tests/specs/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
- 'buf.yaml'
|
||||
- 'tsconfig*.json'
|
||||
- 'esbuild.mjs'
|
||||
- '.vscodeignore'
|
||||
- 'scripts/**'
|
||||
- '.github/workflows/ext-vscode-test.yml'
|
||||
|
||||
quality-checks:
|
||||
@@ -82,45 +75,29 @@ jobs:
|
||||
if: needs.detect-changes.outputs.vscode == 'true' || needs.detect-changes.outputs.testing_platform == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
name: Quality Checks
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
webview-ui/package-lock.json
|
||||
|
||||
# Single root install resolves the entire bun workspace (apps/vscode,
|
||||
# webview-ui, testing-platform and the @cline/* SDK symlinks) at once,
|
||||
# so the previous per-package `npm ci` steps collapse into one.
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; their dist/
|
||||
# output must be built before the extension can type-check/compile.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Run Quality Checks (Parallel)
|
||||
run: bun run ci:check-all
|
||||
run: npm run ci:check-all
|
||||
|
||||
vscode-test:
|
||||
needs: [detect-changes, quality-checks]
|
||||
@@ -136,48 +113,31 @@ jobs:
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
webview-ui/package-lock.json
|
||||
|
||||
# Single root install resolves the entire bun workspace at once (replaces
|
||||
# the per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/
|
||||
# before compiling/testing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun run build:sdk
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
- name: Set up NPM on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# NOTE: The old `npm config set script-shell bash` step is intentionally
|
||||
# removed. Scripts are now launched with `bun run`, which uses Bun's own
|
||||
# built-in cross-platform shell rather than npm's configured script-shell,
|
||||
# so that npm-specific Windows workaround no longer applies. Bash-dependent
|
||||
# scripts (e.g. scripts/proto-lint.sh, standalone/runclinecore.sh) are
|
||||
# invoked explicitly via `bash ...` from within the package scripts, and
|
||||
# this job's `defaults.run.shell: bash` (Git Bash on Windows) still covers
|
||||
# the workflow `run:` blocks below.
|
||||
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||
|
||||
- name: Cache VS Code test runtime
|
||||
if: runner.os == 'Windows'
|
||||
@@ -189,51 +149,24 @@ jobs:
|
||||
# Build the extension and tests (without redundant checks)
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: bun run ci:build
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Vitest Suites (SDK adapter + model catalog)
|
||||
id: vitest_tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
# The vitest config sets passWithNoTests: true, so a broken glob/alias
|
||||
# would "pass" with zero tests. Capture output and assert a non-zero
|
||||
# test count to guard against silent skips.
|
||||
run: |
|
||||
set -o pipefail
|
||||
bun run test:vitest 2>&1 | tee vitest-output.log
|
||||
# Strip ANSI color codes before matching — vitest colorizes the
|
||||
# "Tests N passed" summary, so the count is not adjacent to the
|
||||
# "Tests" label in the raw bytes.
|
||||
if ! sed -r 's/\x1b\[[0-9;]*m//g' vitest-output.log | grep -Eq 'Tests[[:space:]]+[0-9]*[1-9][0-9]* (passed|failed)'; then
|
||||
echo "ERROR: vitest reported zero tests (possible silent skip)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Unit Tests (bun) - Linux
|
||||
- name: Unit Tests with coverage - Linux
|
||||
id: unit_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
# The node-side unit suite (.mocharc spec set) now runs under `bun test`
|
||||
# via scripts/run-bun-unit-tests.ts (one isolated bun process per file).
|
||||
# The runner exits non-zero on any failure and prints a final
|
||||
# "Files: N Pass: P Fail: F" summary; assert a non-zero pass count to
|
||||
# guard against an empty glob silently "passing".
|
||||
run: |
|
||||
set -o pipefail
|
||||
bun run test:unit 2>&1 | tee unit-output.log
|
||||
if ! grep -Eq 'Pass:[[:space:]]+[0-9]*[1-9][0-9]*' unit-output.log; then
|
||||
echo "ERROR: bun unit runner reported zero passing tests (possible silent skip)."
|
||||
exit 1
|
||||
fi
|
||||
npx nyc --nycrc-path .nycrc.unit.json --reporter=lcov npm run test:unit
|
||||
|
||||
- name: Unit Tests (bun) - Non-Linux
|
||||
- name: Unit Tests - Non-Linux
|
||||
id: unit_tests_non_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os != 'Linux' }}
|
||||
run: |
|
||||
bun run test:unit
|
||||
npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests - Linux
|
||||
id: integration_tests_linux
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' && runner.os == 'Linux' }}
|
||||
run: xvfb-run -a bun run test:coverage
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Extension Integration Tests - Non-Linux
|
||||
id: integration_tests_non_linux
|
||||
@@ -241,7 +174,7 @@ jobs:
|
||||
run: |
|
||||
for attempt in 1 2 3; do
|
||||
echo "Running extension integration tests (attempt ${attempt}/3)"
|
||||
if bun run test:integration; then
|
||||
if npm run test:integration; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -259,7 +192,7 @@ jobs:
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
bun run test:coverage
|
||||
npm run test:coverage
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -268,64 +201,53 @@ jobs:
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: |
|
||||
apps/vscode/webview-ui/coverage/lcov.info
|
||||
coverage-unit/lcov.info
|
||||
webview-ui/coverage/lcov.info
|
||||
|
||||
test-platform-integration:
|
||||
needs: [detect-changes, quality-checks]
|
||||
if: needs.detect-changes.outputs.testing_platform == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
webview-ui/package-lock.json
|
||||
testing-platform/package-lock.json
|
||||
|
||||
# Single root install resolves the whole bun workspace, including the
|
||||
# testing-platform package, so the separate per-package `npm ci` steps
|
||||
# (extension + webview-ui + testing-platform) collapse into one.
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/
|
||||
# before compiling the standalone core.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun run build:sdk
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Download ripgrep binaries
|
||||
run: bun run download-ripgrep
|
||||
run: npm run download-ripgrep
|
||||
|
||||
- name: Compile Standalone
|
||||
run: bun run compile-standalone
|
||||
run: npm run compile-standalone
|
||||
|
||||
- name: Install testing platform dependencies
|
||||
run: cd testing-platform && npm ci
|
||||
|
||||
- name: Running testing platform integration spec tests
|
||||
timeout-minutes: 7
|
||||
run: bun run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
|
||||
run: npm run test:tp-orchestrator -- tests/specs/ --count=1 --coverage
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-platform-integration-core-coverage
|
||||
path: apps/vscode/coverage/**/lcov.info
|
||||
path: coverage/**/lcov.info
|
||||
|
||||
# Keep the required "test" check as a tiny aggregate gate instead of the conditional
|
||||
# VS Code matrix. GitHub treats conditionally skipped jobs as successful required
|
||||
@@ -387,7 +309,7 @@ jobs:
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: apps/vscode
|
||||
path: .
|
||||
|
||||
- name: Upload core unit tests coverage to Qlty
|
||||
if: needs.detect-changes.outputs.vscode == 'true'
|
||||
@@ -396,7 +318,7 @@ jobs:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
# we can merge multiple files if necessary
|
||||
files: |
|
||||
apps/vscode/coverage-unit/lcov.info
|
||||
coverage-unit/lcov.info
|
||||
tag: unit:core
|
||||
|
||||
- name: Upload webview-ui unit tests coverage to Qlty
|
||||
@@ -406,7 +328,7 @@ jobs:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
# we can merge multiple files if necessary
|
||||
files: |
|
||||
apps/vscode/webview-ui/coverage/lcov.info
|
||||
webview-ui/coverage/lcov.info
|
||||
tag: unit:webview-ui
|
||||
add-prefix: webview-ui/
|
||||
|
||||
@@ -417,12 +339,12 @@ jobs:
|
||||
id: download-integration-coverage
|
||||
with:
|
||||
name: test-platform-integration-core-coverage
|
||||
path: apps/vscode/integration-core-coverage-reports
|
||||
path: integration-core-coverage-reports
|
||||
|
||||
- name: Upload core integration tests coverage to Qlty
|
||||
if: needs.detect-changes.outputs.testing_platform == 'true' && steps.download-integration-coverage.outcome == 'success'
|
||||
uses: qltysh/qlty-action/coverage@v2
|
||||
with:
|
||||
token: ${{ secrets.QLTY_COVERAGE_TOKEN }}
|
||||
files: apps/vscode/integration-core-coverage-reports/**/lcov.info
|
||||
files: integration-core-coverage-reports/**/lcov.info
|
||||
tag: integration:core
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
const labels = context.payload.issue.labels.map(l => l.name);
|
||||
|
||||
// Check if JetBrains Plugin is selected
|
||||
if (body.match(/###\s*Cline Surface\s*\n+JetBrains Plugin/i)) {
|
||||
if (body.match(/###\s*Plugin Type\s*\n+JetBrains Plugin/i)) {
|
||||
if (!labels.includes('JetBrains')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
}
|
||||
|
||||
// Check if VSCode Extension is selected
|
||||
if (body.match(/###\s*Cline Surface\s*\n+VSCode Extension/i)) {
|
||||
if (body.match(/###\s*Plugin Type\s*\n+VSCode Extension/i)) {
|
||||
if (!labels.includes('VS Code')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
}
|
||||
|
||||
// Check if CLI is selected
|
||||
if (body.match(/###\s*Cline Surface\s*\n+CLI/i)) {
|
||||
if (body.match(/###\s*Plugin Type\s*\n+CLI/i)) {
|
||||
if (!labels.includes('CLI')) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
|
||||
@@ -26,7 +26,7 @@ on:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
working-directory: sdk
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
env:
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
run: |
|
||||
BASE_VERSION=$(node -p "require('./sdk/packages/llms/package.json').version")
|
||||
BASE_VERSION=$(node -p "require('./packages/llms/package.json').version")
|
||||
|
||||
if [ "$CHANNEL" = "nightly" ]; then
|
||||
TIMESTAMP=$(date +%s)
|
||||
@@ -166,11 +166,11 @@ jobs:
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: bun sdk/scripts/version.ts "$VERSION"
|
||||
run: bun scripts/version.ts "$VERSION"
|
||||
|
||||
- name: Verify publishability
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: bun sdk/scripts/check-publish.ts
|
||||
run: bun scripts/check-publish.ts
|
||||
|
||||
- name: Prepare package tarball directory
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Publishing @cline/shared@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd sdk/packages/shared
|
||||
cd packages/shared
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Publishing @cline/llms@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd sdk/packages/llms
|
||||
cd packages/llms
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Publishing @cline/agents@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd sdk/packages/agents
|
||||
cd packages/agents
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Publishing @cline/core@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd sdk/packages/core
|
||||
cd packages/core
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
@@ -235,7 +235,7 @@ jobs:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Publishing @cline/sdk@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd sdk/packages/sdk
|
||||
cd packages/sdk
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ permissions:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
working-directory: sdk
|
||||
|
||||
jobs:
|
||||
quality-checks:
|
||||
@@ -96,12 +96,12 @@ jobs:
|
||||
|
||||
- name: Run SDK Tests (Windows)
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'windows-latest' }}
|
||||
run: bun -F './sdk/packages/**' test
|
||||
run: bun -F './packages/**' test
|
||||
|
||||
- name: Smoke test SQLite under Node
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os != 'windows-latest' }}
|
||||
timeout-minutes: 10
|
||||
run: bun sdk/scripts/ci-node-smoke.ts
|
||||
run: bun scripts/ci-node-smoke.ts
|
||||
|
||||
- name: Run TUI e2e tests
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'ubuntu-latest' && matrix.node-version == '24.x' }}
|
||||
@@ -109,4 +109,4 @@ jobs:
|
||||
|
||||
- name: Verify packages are publishable
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'ubuntu-latest' && matrix.node-version == '24.x' }}
|
||||
run: bun sdk/scripts/check-publish.ts
|
||||
run: bun scripts/check-publish.ts
|
||||
|
||||
+5
-30
@@ -13,15 +13,12 @@ tmp
|
||||
pnpm-lock.yaml
|
||||
|
||||
.clineignore
|
||||
.cline/enterprise
|
||||
.cline/remote-config
|
||||
**/.cline/remote-config
|
||||
.venv
|
||||
.actrc
|
||||
CLAUDE.local.md
|
||||
|
||||
apps/vscode/webview-ui/src/**/*.js
|
||||
apps/vscode/webview-ui/src/**/*.js.map
|
||||
webview-ui/src/**/*.js
|
||||
webview-ui/src/**/*.js.map
|
||||
|
||||
# Ignore coverage directories and files
|
||||
coverage
|
||||
@@ -38,9 +35,9 @@ coverage-unit
|
||||
.worktrees
|
||||
|
||||
## Generated files ##
|
||||
apps/vscode/src/generated/
|
||||
apps/vscode/src/shared/proto/
|
||||
apps/vscode/webview-ui/src/services/grpc-client.ts
|
||||
src/generated/
|
||||
src/shared/proto/
|
||||
webview-ui/src/services/grpc-client.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
# E2E Tests
|
||||
@@ -63,25 +60,3 @@ tests/**/cache
|
||||
# Backup created by scripts/marketplace-readme.mjs while publishing.
|
||||
# Should never be committed: only exists if a publish aborts mid-swap.
|
||||
.README.github.bak
|
||||
|
||||
# Tauri generated code
|
||||
apps/*/src-tauri/gen
|
||||
apps/*/src-tauri/bin
|
||||
apps/examples/*/src-tauri/gen
|
||||
apps/examples/*/src-tauri/bin
|
||||
# Tauri UI test snapshots
|
||||
apps/*/src/tests/.tui-test
|
||||
apps/*/src/tests/tui-traces
|
||||
apps/vscode/webview-ui/src/**/*.js
|
||||
apps/vscode/webview-ui/src/**/*.js.map
|
||||
|
||||
|
||||
# SDK Session files / User data
|
||||
.cline/data
|
||||
.cline/tmp
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
.cline/**/managed.json
|
||||
.cline/**/bundle.json
|
||||
apps/vscode/tsconfig.test.generated.json
|
||||
|
||||
+1
-11
@@ -1,11 +1 @@
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
echo "gitleaks is required for the pre-commit secret scan."
|
||||
echo "Install it with: brew install gitleaks"
|
||||
echo "Other install options: https://github.com/gitleaks/gitleaks#installing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gitleaks git --pre-commit --redact --staged --verbose || exit 1
|
||||
|
||||
cd apps/vscode && bunx lint-staged
|
||||
|
||||
lint-staged
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extension": [
|
||||
"ts"
|
||||
],
|
||||
"spec": [
|
||||
"src/**/__tests__/*.ts",
|
||||
"src/test/services/**/*.test.ts"
|
||||
],
|
||||
"require": [
|
||||
"ts-node/register",
|
||||
"source-map-support/register",
|
||||
"./src/test/requires.ts"
|
||||
],
|
||||
"recursive": true,
|
||||
"exit": true
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"all": true,
|
||||
"check-coverage": false,
|
||||
"reporter": [
|
||||
"text",
|
||||
"lcov"
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"**/*.d.ts",
|
||||
|
||||
"**/*.{test,spec}.{js,jsx,ts,tsx,mjs,cjs}",
|
||||
"**/__tests__/**",
|
||||
"**/test/**",
|
||||
"**/tests/**",
|
||||
"**/.nyc_output/**",
|
||||
"**/.vscode-test/**",
|
||||
"**/tests-results/**",
|
||||
"src/test/**",
|
||||
|
||||
"src/generated/**",
|
||||
|
||||
"**/node_modules/**",
|
||||
"**/dist/**",
|
||||
"**/out/**",
|
||||
"**/build/**",
|
||||
"**/coverage/**",
|
||||
"**/coverage-unit/**",
|
||||
"**/proto/**",
|
||||
|
||||
"**/*.{config,setup}.{js,ts,mjs,cjs}",
|
||||
"**/vite-env.d.ts",
|
||||
|
||||
"**/*.{css,scss,sass,less,styl}",
|
||||
"**/*.{svg,png,jpg,jpeg,gif,ico}",
|
||||
"**/*.{json,yaml,yml}"
|
||||
],
|
||||
"extension": [
|
||||
".ts",
|
||||
".js"
|
||||
],
|
||||
"cache": true,
|
||||
"sourceMap": true,
|
||||
"instrument": true,
|
||||
"report-dir": "./coverage-unit"
|
||||
}
|
||||
@@ -1,20 +1,9 @@
|
||||
import { defineConfig } from "@vscode/test-cli"
|
||||
import path from "path"
|
||||
|
||||
const vscodeTestVersion = process.env.VSCODE_TEST_VERSION ?? "stable"
|
||||
|
||||
export default defineConfig({
|
||||
files: [
|
||||
"out/src/{core,test,utils,shared,integrations,hosts,services}/**/*.test.js",
|
||||
"src/{core,test,utils,shared,integrations,hosts,services}/**/*.test.js",
|
||||
// The bun unit suite (src/**/__tests__/* and src/test/services/**) runs under
|
||||
// `bun test` (run-bun-unit-tests.ts) and imports `bun:test`, which this
|
||||
// Node-based runner cannot load. Exclude it here.
|
||||
"!out/src/**/__tests__/**/*.test.js",
|
||||
"!out/src/test/services/**/*.test.js",
|
||||
"!src/**/__tests__/**/*.test.js",
|
||||
"!src/test/services/**/*.test.js",
|
||||
],
|
||||
files: "{out/**/*.test.js,src/**/*.test.js,!src/test/e2e/**/*.test.js,!out/src/test/e2e/**/*.test.js}",
|
||||
mocha: {
|
||||
ui: "bdd",
|
||||
timeout: 20000, // Maximum time (in ms) that a test can run before failing
|
||||
Vendored
+36
-33
@@ -10,23 +10,23 @@
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}/apps/vscode",
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extension",
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"${workspaceFolder}/apps/vscode",
|
||||
"${workspaceFolder}",
|
||||
"--disable-extensions"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/apps/vscode/dist/**/*.js"
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"envFile": "${workspaceFolder}/apps/vscode/.env",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
"CLINE_ENVIRONMENT": "production"
|
||||
}
|
||||
},
|
||||
@@ -35,22 +35,22 @@
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}/apps/vscode",
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extension",
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"${workspaceFolder}/apps/vscode"
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/apps/vscode/dist/**/*.js"
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"envFile": "${workspaceFolder}/apps/vscode/.env",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
"CLINE_ENVIRONMENT": "staging"
|
||||
}
|
||||
},
|
||||
@@ -59,22 +59,22 @@
|
||||
"type": "extensionHost",
|
||||
"request": "launch",
|
||||
"args": [
|
||||
"--extensionDevelopmentPath=${workspaceFolder}/apps/vscode",
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"--disable-workspace-trust",
|
||||
"--disable-extension",
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"${workspaceFolder}/apps/vscode"
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/apps/vscode/dist/**/*.js"
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "${defaultBuildTask}",
|
||||
"envFile": "${workspaceFolder}/apps/vscode/.env",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
"CLINE_ENVIRONMENT": "local"
|
||||
}
|
||||
},
|
||||
@@ -84,27 +84,27 @@
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "${execPath}",
|
||||
"args": [
|
||||
"--user-data-dir=${workspaceFolder}/apps/vscode/dist/tmp/user",
|
||||
"--user-data-dir=${workspaceFolder}/dist/tmp/user",
|
||||
"--profile-temp",
|
||||
"--sync=off",
|
||||
"--disable-extension",
|
||||
"saoudrizwan.claude-dev", // Avoid conflicts with installed Cline
|
||||
"--disable-extension",
|
||||
"saoudrizwan.cline-nightly", // Avoid conflicts with installed Cline Nightly
|
||||
"--extensionDevelopmentPath=${workspaceFolder}/apps/vscode",
|
||||
"${workspaceFolder}/apps/vscode"
|
||||
"--extensionDevelopmentPath=${workspaceFolder}",
|
||||
"${workspaceFolder}"
|
||||
],
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/apps/vscode/dist/**/*.js"
|
||||
"${workspaceFolder}/dist/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "clean-tmp-user",
|
||||
"internalConsoleOptions": "openOnSessionStart",
|
||||
"postDebugTask": "stop",
|
||||
"envFile": "${workspaceFolder}/apps/vscode/.env",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"TEMP_PROFILE": "true",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}/apps/vscode",
|
||||
"DEV_WORKSPACE_FOLDER": "${workspaceFolder}",
|
||||
"CLINE_ENVIRONMENT": "production"
|
||||
}
|
||||
},
|
||||
@@ -117,22 +117,25 @@
|
||||
],
|
||||
"sourceMaps": true,
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/apps/vscode/**",
|
||||
"${workspaceFolder}/**",
|
||||
"!**/node_modules/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"outFiles": [
|
||||
"${workspaceFolder}/apps/vscode/dist/**/*.js",
|
||||
"${workspaceFolder}/apps/vscode/dist-standalone/**/*.js"
|
||||
"${workspaceFolder}/dist/**/*.js",
|
||||
"${workspaceFolder}/dist-standalone/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "compile-standalone",
|
||||
"runtimeExecutable": "bun",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": [
|
||||
"tsx"
|
||||
],
|
||||
"program": "scripts/test-standalone-core-api-server.ts",
|
||||
"envFile": "${workspaceFolder}/apps/vscode/.env",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"PROTOBUS_PORT": "26040",
|
||||
"HOSTBRIDGE_PORT": "26041",
|
||||
"WORKSPACE_DIR": "${workspaceFolder}/apps/vscode",
|
||||
"WORKSPACE_DIR": "${workspaceFolder}",
|
||||
"E2E_TEST": "true",
|
||||
"CLINE_ENVIRONMENT": "local"
|
||||
},
|
||||
@@ -148,10 +151,10 @@
|
||||
],
|
||||
"sourceMaps": true,
|
||||
"resolveSourceMapLocations": [
|
||||
"${workspaceFolder}/apps/vscode/**",
|
||||
"${workspaceFolder}/**",
|
||||
"!**/node_modules/**"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"cwd": "${workspaceFolder}",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": [
|
||||
"mocha"
|
||||
@@ -166,7 +169,7 @@
|
||||
"--exit",
|
||||
"${file}"
|
||||
],
|
||||
"envFile": "${workspaceFolder}/apps/vscode/.env",
|
||||
"envFile": "${workspaceFolder}/.env",
|
||||
"env": {
|
||||
"TS_NODE_PROJECT": "./tsconfig.unit-test.json",
|
||||
"NODE_ENV": "test",
|
||||
@@ -180,12 +183,12 @@
|
||||
"name": "Open Storybook",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "bun",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"storybook"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/apps/vscode/webview-ui",
|
||||
"cwd": "${workspaceFolder}/webview-ui",
|
||||
"console": "integratedTerminal",
|
||||
"internalConsoleOptions": "neverOpen",
|
||||
"serverReadyAction": {
|
||||
|
||||
Vendored
+2
-15
@@ -17,29 +17,16 @@
|
||||
// Protobuf settings
|
||||
"protoc": {
|
||||
"options": [
|
||||
"--proto_path=apps/vscode/proto"
|
||||
"--proto_path=proto"
|
||||
]
|
||||
},
|
||||
// Enable Lint and format using Biome
|
||||
"biome.enabled": true,
|
||||
"biome.requireConfiguration": true,
|
||||
"prettier.enable": false,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.removeUnused.biome": "always",
|
||||
"source.removeUnusedImports": "always",
|
||||
"source.organizeImports.biome": "always"
|
||||
},
|
||||
// Disable auto-forwarding ports to prevent Simple Browser from opening the Vite dev server
|
||||
|
||||
Vendored
+25
-62
@@ -5,28 +5,24 @@
|
||||
"tasks": [
|
||||
{
|
||||
"label": "compile-standalone",
|
||||
"type": "shell",
|
||||
"command": "bun run compile-standalone",
|
||||
"type": "npm",
|
||||
"script": "compile-standalone",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "npm: protos",
|
||||
"type": "shell",
|
||||
"command": "bun run protos",
|
||||
"type": "npm",
|
||||
"script": "protos",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
"presentation": {
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
@@ -64,8 +60,8 @@
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run build:webview",
|
||||
"type": "npm",
|
||||
"script": "build:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
@@ -78,15 +74,14 @@
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run build:webview:test",
|
||||
"type": "npm",
|
||||
"script": "build:webview:test",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": true,
|
||||
@@ -99,7 +94,6 @@
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"IS_TEST": "true"
|
||||
@@ -107,8 +101,8 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run dev:webview",
|
||||
"type": "npm",
|
||||
"script": "dev:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [
|
||||
{
|
||||
@@ -137,15 +131,14 @@
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run watch:esbuild",
|
||||
"type": "npm",
|
||||
"script": "watch:esbuild",
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
@@ -169,23 +162,21 @@
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"build-sdk:debug"
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run watch:esbuild:test",
|
||||
"type": "npm",
|
||||
"script": "watch:esbuild:test",
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
@@ -209,15 +200,13 @@
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild:test",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"build-sdk:debug"
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"env": {
|
||||
"IS_DEV": "true",
|
||||
"IS_TEST": "true"
|
||||
@@ -225,8 +214,8 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run watch:tsc",
|
||||
"type": "npm",
|
||||
"script": "watch:tsc",
|
||||
"group": "build",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
@@ -237,15 +226,11 @@
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run watch-tests",
|
||||
"label": "npm: watch-tests",
|
||||
"type": "npm",
|
||||
"script": "watch-tests",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
"dependsOn": [
|
||||
@@ -255,10 +240,7 @@
|
||||
"reveal": "always",
|
||||
"group": "watchers"
|
||||
},
|
||||
"group": "build",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode"
|
||||
}
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "tasks: watch-tests",
|
||||
@@ -280,11 +262,11 @@
|
||||
"dependsOn": [
|
||||
"watch"
|
||||
],
|
||||
"command": "rm -rf ${workspaceFolder}/apps/vscode/dist/tmp/user && mkdir -p ${workspaceFolder}/apps/vscode/dist/tmp/user"
|
||||
"command": "rm -rf ${workspaceFolder}/dist/tmp/user && mkdir -p ${workspaceFolder}/dist/tmp/user"
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run storybook",
|
||||
"type": "npm",
|
||||
"script": "storybook",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
@@ -297,7 +279,6 @@
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}/apps/vscode",
|
||||
"env": {
|
||||
"IS_DEV": "true"
|
||||
}
|
||||
@@ -311,25 +292,7 @@
|
||||
"$tsc"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "build-sdk:debug",
|
||||
"type": "shell",
|
||||
"command": "bun run build:sdk",
|
||||
"problemMatcher": [
|
||||
"$tsc"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"CLINE_SOURCEMAPS": "1"
|
||||
}
|
||||
"cwd": "${workspaceFolder}/sdk"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -5,31 +5,13 @@
|
||||
# Agent tooling, never shipped in the VSIX
|
||||
.agents/**
|
||||
.claude/**
|
||||
.cline/**
|
||||
.codex/**
|
||||
CLAUDE.local.md
|
||||
out/
|
||||
dist-standalone/
|
||||
node_modules/
|
||||
# Nested workspace-member node_modules (bun links these under each package).
|
||||
# Scoped to the sub-package dirs so it doesn't shadow the top-level
|
||||
# node_modules/@vscode/codicons re-include below.
|
||||
webview-ui/node_modules/**
|
||||
testing-platform/node_modules/**
|
||||
standalone/**/node_modules/**
|
||||
src/**
|
||||
standalone/**
|
||||
# Build/dev tooling and inputs — bundled into dist/extension.js, not needed in the VSIX.
|
||||
bunfig.toml
|
||||
esbuild.mjs
|
||||
knip.json
|
||||
biome.jsonc
|
||||
test-setup.js
|
||||
.env.example
|
||||
scripts/**
|
||||
proto/**
|
||||
testing-platform/**
|
||||
tests/**
|
||||
.gitignore
|
||||
.yarnrc
|
||||
esbuild.js
|
||||
@@ -52,7 +34,6 @@ sdk/**
|
||||
# only exists if a publish aborts mid-swap; neither should ship in the .vsix.
|
||||
README.marketplace.md
|
||||
.README.github.bak
|
||||
package.json.backup
|
||||
|
||||
# Custom
|
||||
**/demo.gif
|
||||
@@ -65,6 +46,7 @@ eslint-rules/
|
||||
old_docs/
|
||||
evals/
|
||||
.codespellrc
|
||||
.mocharc.json
|
||||
buf.yaml
|
||||
.clinerules/
|
||||
|
||||
@@ -96,9 +78,6 @@ old_docs/**
|
||||
e2e-build.mjs
|
||||
e2e.vsix
|
||||
test-results/
|
||||
coverage/**
|
||||
webview-ui/coverage/**
|
||||
webview-ui/.vite-port
|
||||
|
||||
# Ignore Storybook files
|
||||
**/*.stories.tsx
|
||||
-162
@@ -1,167 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [4.0.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add the SDK-backed VS Code extension runtime. Cline now runs tasks through the shared Cline SDK session layer for agent turns, tools, Plan/Act mode coordination, MCP, checkpoints, telemetry, provider changes, compaction, mistake limits, and task history.
|
||||
- Add ClinePass to the VS Code extension, including onboarding, provider selection, signup and subscription handoff, live model lists, entitlement and organization error states, out-of-credit prompts, and clearer ClinePass auth/error handling.
|
||||
- Add the Customize marketplace for discovering and managing Skills, MCP servers, and Plugins from the extension, including installed/marketplace tabs, search and filtering, install/uninstall flows, enable/disable controls, and support for plugin-bundled skills.
|
||||
- Cline Plugins: Plugins let you extend Cline with custom tools, workflows, skills, and MCP-powered capabilities tailored to your team or project. Install them from the new Customize marketplace to add specialized behavior, connect external services, and package reusable automations—so Cline can do more than code: it can adapt to the way you work.
|
||||
- Add queued prompts in chat. Messages submitted while Cline is already working are now queued, shown while the current turn streams, and can be cancelled before they run.
|
||||
- Add edit-and-regenerate support for previous user messages, with clearer Reset Chat and Reset Code actions.
|
||||
- Add generic SDK provider settings and model-catalog support so more providers can share the same model picker, reasoning controls, dynamic model IDs, provider config persistence, and custom model handling.
|
||||
- Add additional SDK-backed provider exposure and model/provider updates, including ClinePass models, refreshed Cline catalog data, Fireworks GLM 5.2, Kimi K2.6 Fast, Kimi K2.7 Code, Qwen 3.7 Plus, MiniMax M3 updates, SAP AI Core wiring, LiteLLM model fetching, Codex OAuth credentials, and OpenAI-compatible model settings.
|
||||
- Add MCP support for plugins and shared marketplace install/uninstall plumbing used by the VS Code extension.
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrate the VS Code extension from the legacy task implementation to the shared Cline SDK and move the extension build/package workflow to Bun.
|
||||
- Rework Plan/Act mode handling through SDK coordinators, including closer CLI parity and automatic continuation when switching from Plan to Act.
|
||||
- Rework provider and model configuration around `providers.json`, the model catalog, and SDK session config so settings are preserved consistently across provider switches and active sessions can restart when the selected provider changes.
|
||||
- Simplify provider settings UI by replacing many provider-specific views with shared generic settings components and consistent reasoning selectors.
|
||||
- Simplify terminal execution through the SDK run-commands path, including clearer non-interactive command guidance and safer structured command formatting.
|
||||
- Migrate legacy MCP files and formats into the shared settings file and protect MCP settings writes with safer locking/atomic updates.
|
||||
- Refresh the MCP hub automatically after marketplace installs so newly installed servers are available without a manual restart.
|
||||
- Reorganize MCP/Skills/Plugins entry points under Customize, hide workflows from the Customize menu, wrap Customize tabs on narrow screens, and allow the MCP Marketplace tab to be disabled remotely while installed MCP servers remain accessible.
|
||||
- Simplify auto-approval settings. Command auto-approval is now disabled by default for safer new and reset configurations, and the auto-approval UI has been streamlined.
|
||||
- Update task history handling for the SDK migration, including legacy task history visibility, metadata preservation on resume, and corrected deletion behavior.
|
||||
- Route compacting and mistake-limit behavior through the SDK so the Compact button and mistake tracking affect the active SDK session.
|
||||
- Remove the legacy Explain Changes feature as part of the SDK migration cleanup.
|
||||
- Temporarily disable subagents in the VS Code extension while the SDK-backed experience is stabilized.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix marketplace edge cases, including refreshing MCP servers after marketplace installs, disabling the MCP Marketplace tab from remote config, hiding workflows from Customize, surfacing plugin-bundled skills, and uninstalling shared marketplace entries.
|
||||
- Fix chat submission during active turns by queuing user messages instead of dropping or racing them, showing pending/queued states promptly, rendering direct user messages immediately, and removing delayed send behavior.
|
||||
- Fix editing previous user messages so Escape cancels editing locally and reset action labels are clearer.
|
||||
- Fix terminal reliability, including standalone Windows output capture, hardened PowerShell command handling, running-state display for in-progress commands, raw structured command preservation, single-quote handling, cwd setup timeouts, failing-command stdout capture, heredoc coalescing, and removal of duplicated command echoes in tool results.
|
||||
- Fix SDK tool-result and provider-message budgeting by truncating large tool outputs by default, capping assistant text, limiting bash/file-read/search output ingestion, bounding media budgets, batching outdated-read rewrites to preserve provider prefix caches, and normalizing JSON-like tool inputs by schema.
|
||||
- Fix login and feature-flag resolution by using the correct user/account identity on startup and simplifying the login UX.
|
||||
|
||||
## [3.89.2]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Complete the fix for the Anthropic provider on VS Code 1.123 and later by upgrading the bundled Anthropic SDK to a release compatible with the Node 24 runtime.
|
||||
- Update the Vertex AI provider to a compatible Anthropic Vertex SDK release so it works with the upgraded Anthropic SDK.
|
||||
|
||||
## [3.89.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore the Anthropic provider on VS Code 1.123 and later, where the updated Node 24 runtime broke the bundled Anthropic SDK.
|
||||
- Handle the DeepSeek V4 reasoning format.
|
||||
|
||||
## [3.89.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Claude Fable 5 model support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix MiniMax M3 thinking controls across gateways.
|
||||
|
||||
### Changed
|
||||
|
||||
- Clean up the Codex model list.
|
||||
|
||||
## [3.88.1]
|
||||
|
||||
### Added
|
||||
|
||||
- Add a debug section in settings for Cline testers.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Include the walkthrough markdown files in the VS Code extension package so the first-run walkthrough steps load correctly.
|
||||
|
||||
## [3.88.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add the latest Fireworks AI serverless models and update the default Fireworks model to Kimi K2.6.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix MCP server delete/add flows so settings writes do not cause the MCP server list to be emptied by the file watcher.
|
||||
- Remove stale Fireworks AI models and correct Fireworks model metadata and cache pricing.
|
||||
|
||||
### Changed
|
||||
|
||||
- Always use the upstream Cline recommended models endpoint instead of gating it behind a feature flag.
|
||||
|
||||
## [3.87.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add MiniMax M3 model support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Update VS Code extension dependencies to resolve security issues in `@xmldom/xmldom`, `basic-ftp`, `axios`, `undici`, and other direct/transitive packages.
|
||||
|
||||
## [3.86.2]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix `@` file mentions and workspace file search on VS Code 1.122+ by resolving the new bundled `@vscode/ripgrep-universal` per-platform binary layout before falling back to legacy ripgrep paths.
|
||||
|
||||
## [3.86.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix `@` file mentions failing to find files in some environments (notably VS Code Remote SSH, and after certain VS Code updates) by keeping the file-search fallback alive when the workspace index or bundled ripgrep binary is unavailable.
|
||||
|
||||
## [3.86.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Claude Opus 4.8 provider support, including 1M-context variants where available.
|
||||
- Add Moonshot Kimi K2.6 model support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Show prompt-cache support for Qwen 3.7 Max in the Cline provider.
|
||||
- Fix the VS Code nightly publish workflow startup permissions.
|
||||
|
||||
### Changed
|
||||
|
||||
- Move the VS Code extension project into `apps/vscode`.
|
||||
|
||||
## [3.85.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add GPT-5.5 support to SAP AI Core.
|
||||
- Add DeepSeek V4 Flash and Pro models.
|
||||
- Add Gemini 3.5 Flash to Gemini and Vertex providers.
|
||||
- Add `/lg-task` URI webhook integration for LG dashboard flows.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix Vertex AI global endpoint handling for Claude models.
|
||||
- Route Poolside Laguna models through next-gen prompts and native tool calling.
|
||||
|
||||
### Changed
|
||||
|
||||
- Update `diff` and `protobufjs` dependencies.
|
||||
|
||||
## [3.84.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add SAP AI Core support for additional hosted models
|
||||
|
||||
### Fixed
|
||||
|
||||
- Disable the MCP "Restart Server" button when a server is toggled off.
|
||||
|
||||
### Changed
|
||||
|
||||
- Remove the Cline Kanban launch modal and bundled demo media from the VS Code extension startup flow.
|
||||
|
||||
## [3.83.0]
|
||||
|
||||
### Fixed
|
||||
|
||||
+14
-15
@@ -45,7 +45,7 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
3. Install [bun](https://bun.com)
|
||||
4. Install the necessary dependencies for the extension and webview-gui:
|
||||
```bash
|
||||
cd apps/vscode && bun run install:all && cd ../..
|
||||
npm run install:all
|
||||
cd sdk && bun run build && cd ..
|
||||
```
|
||||
5. Generate Protocol Buffer files (required before first build):
|
||||
@@ -61,8 +61,8 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
2. Push your branch and create a PR on GitHub. Our CI will:
|
||||
- Run tests and checks
|
||||
3. Testing
|
||||
- Run `cd apps/vscode && bun run test` to run tests locally.
|
||||
- Before submitting PR, run `bun run format:fix` to format your code
|
||||
- Run `npm run test` to run tests locally.
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
|
||||
### Extension
|
||||
|
||||
@@ -73,13 +73,12 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
- If you dismissed the prompts, you can install them manually from the Extensions panel
|
||||
|
||||
2. **Local Development**
|
||||
- cd into the vscode extension, `cd apps/vscode`
|
||||
- Run `bun run install:all` to install dependencies
|
||||
- Run `bun run protos` to generate Protocol Buffer files (required before first build)
|
||||
- Run `bun run test` to run tests locally
|
||||
- Run `npm run install:all` to install dependencies
|
||||
- Run `npm run protos` to generate Protocol Buffer files (required before first build)
|
||||
- Run `npm run test` to run tests locally
|
||||
- Run → Start Debugging or `>Debug: Select and Start Debugging` and wait for a new VS Code instance to open
|
||||
- **Terminal Workflow**: Use `bun run dev` (generates protos + runs watch mode) or `bun run watch` (if protos already generated)
|
||||
- Before submitting PR, run `bun run format:fix` to format your code
|
||||
- **Terminal Workflow**: Use `npm run dev` (generates protos + runs watch mode) or `npm run watch` (if protos already generated)
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
|
||||
3. **Linux-specific Setup**
|
||||
VS Code extension tests on Linux require the following system libraries:
|
||||
@@ -135,8 +134,8 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
|
||||
2. **Code Quality**
|
||||
|
||||
- Run `bun run lint` to check code style
|
||||
- Run `bun run format` to automatically format code
|
||||
- Run `npm run lint` to check code style
|
||||
- Run `npm run format` to automatically format code
|
||||
- All PRs must pass CI checks which include both linting and formatting
|
||||
- Address any warnings or errors from linter before submitting
|
||||
- Follow TypeScript best practices and maintain type safety
|
||||
@@ -144,7 +143,7 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
3. **Testing**
|
||||
|
||||
- Add tests for new features
|
||||
- Run `bun test` to ensure all tests pass
|
||||
- Run `npm test` to ensure all tests pass
|
||||
- Update existing tests if your changes affect them
|
||||
- Include both unit tests and integration tests where appropriate
|
||||
|
||||
@@ -154,9 +153,9 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
|
||||
- **Running E2E tests:**
|
||||
```bash
|
||||
bun run test:e2e # Build and run all E2E tests
|
||||
bun run e2e # Run tests without rebuilding
|
||||
bun run test:e2e -- --debug # Run with interactive debugger
|
||||
npm run test:e2e # Build and run all E2E tests
|
||||
npm run e2e # Run tests without rebuilding
|
||||
npm run test:e2e -- --debug # Run with interactive debugger
|
||||
```
|
||||
|
||||
- **Writing E2E tests:**
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
<div align="center"><sub>
|
||||
English | <a href="https://github.com/cline/cline/blob/main/locales/es/README.md" target="_blank">Español</a> | <a href="https://github.com/cline/cline/blob/main/locales/de/README.md" target="_blank">Deutsch</a> | <a href="https://github.com/cline/cline/blob/main/locales/ja/README.md" target="_blank">日本語</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-cn/README.md" target="_blank">简体中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/zh-tw/README.md" target="_blank">繁體中文</a> | <a href="https://github.com/cline/cline/blob/main/locales/ko/README.md" target="_blank">한국어</a>
|
||||
</sub></div>
|
||||
|
||||
# Cline
|
||||
<div align="center">
|
||||
<table>
|
||||
@@ -15,7 +19,7 @@
|
||||
<a href="https://github.com/cline/cline/discussions/categories/feature-requests?discussions_q=is%3Aopen+category%3A%22Feature+Requests%22+sort%3Atop" target="_blank"><strong>Feature Requests</strong></a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://docs.cline.bot/getting-started/installing-cline" target="_blank"><strong>Getting Started</strong></a>
|
||||
<a href="https://docs.cline.bot/getting-started/for-new-coders" target="_blank"><strong>Getting Started</strong></a>
|
||||
</td>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -51,7 +51,7 @@ for CI/CD and scripting.
|
||||
npm i -g cline
|
||||
```
|
||||
|
||||
<a href="./apps/cli/README.md">Learn more</a>
|
||||
<a href="./sdk/apps/cli/README.md">Learn more</a>
|
||||
<br><br>
|
||||
|
||||
</td>
|
||||
@@ -129,7 +129,7 @@ npm install @cline/sdk
|
||||
| Product | Description | Location | CHANGELOG |
|
||||
|---------|------------|--------------|--------------|
|
||||
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/CHANGELOG.md) |
|
||||
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`apps/cli/`](https://github.com/cline/cline/tree/main/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/cli/CHANGELOG.md) |
|
||||
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`sdk/apps/cli/`](https://github.com/cline/cline/tree/main/sdk/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/apps/cli/CHANGELOG.md) |
|
||||
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) |
|
||||
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins | - |
|
||||
| **Kanban** | Web-based multi-agent task board. | [`cline/kanban`](https://github.com/cline/kanban) | [CHANGELOG.md](https://github.com/cline/kanban/blob/main/CHANGELOG.md) |
|
||||
@@ -212,12 +212,8 @@ cline schedule create "PR summary" \
|
||||
Chat with your agent from any messaging platform: Telegram, Slack, Discord, Google Chat, WhatsApp, and Linear. Each conversation thread maps to an agent session with full context. Set up access control to restrict who can interact with your agent.
|
||||
|
||||
```bash
|
||||
# Connect to Telegram
|
||||
cline connect telegram -k $BOT_TOKEN
|
||||
# Connect to Slack through webhook
|
||||
cline connect slack --bot-token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
# Connect to Slack using socket mode
|
||||
cline connect slack --bot-token $SLACK_TOKEN --app-token $SLACK_APP_TOKEN
|
||||
cline connect telegram -m my_bot -k $BOT_TOKEN
|
||||
cline connect slack --token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
```
|
||||
|
||||
## Headless CLI for CI/CD
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"root": false,
|
||||
"extends": [
|
||||
"../sdk/biome.json"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"a11y": {
|
||||
"noStaticElementInteractions": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.33
|
||||
|
||||
- Show a ClinePass subscription URL as a fallback during onboarding so you can still subscribe if the subscription screen can't open automatically
|
||||
- Hide the ClinePass promo for users who already have a ClinePass subscription
|
||||
- Use an adaptive plan accent color for ClinePass prompts so they fit the active theme
|
||||
|
||||
## 3.0.32
|
||||
|
||||
- Improved the ClinePass onboarding experience
|
||||
- Added an intermediate step before going to ClinePass model selection
|
||||
- Made the ClinePass subscription screen selectable
|
||||
- Promoted ClinePass in the startup notice
|
||||
- Used "ClinePass" as one word consistently and refined the provider UI copy
|
||||
- More accurate context compaction and clearer error messages (from SDK v0.0.54)
|
||||
|
||||
## 3.0.31
|
||||
|
||||
- Show when request cost is covered by your Cline subscription
|
||||
- Prompt to switch to ClinePass when you run out of credits, and list ClinePass features in the not-subscribed message
|
||||
- Added an option to open the subscription page from the ClinePass options
|
||||
- Added marketplace uninstall support and surfaced plugin-bundled skills
|
||||
- Require quoted prompts for one-shot mode
|
||||
- Capped MCP tool names at 64 characters for OpenAI-compatible providers
|
||||
- Updated coupon code
|
||||
|
||||
## 3.0.30
|
||||
|
||||
- Added a token count to the status bar, shown alongside cost
|
||||
- Added organization-specific error messages
|
||||
- Added SAP AI Core provider support
|
||||
- Refreshed the model catalog with the latest provider models
|
||||
- Preserved OpenRouter reasoning-disable behavior and improved OpenRouter prompt caching
|
||||
- Routed LiteLLM model fetches through the SDK and stopped unrelated models from appearing in the LiteLLM model list
|
||||
- Updated ClinePass models live, restored ClinePass models in onboarding, and improved ClinePass error messages
|
||||
- Threaded proxy/CA-aware networking into the inference path
|
||||
- Persisted Bedrock settings to providers.json
|
||||
- Normalized JSON-like tool inputs by schema for more reliable tool calls
|
||||
- Fixed an "ERROR: EMPTY CONTENT" message that could appear when an error occurred
|
||||
- Fixed a packaging issue (createRequire) that could break the CLI at runtime
|
||||
|
||||
## 3.0.29
|
||||
|
||||
- Costs are now hidden for Cline free models
|
||||
- Fixed Z.ai model metadata resolution for Z.ai models accessed through the Cline provider
|
||||
- Reverted the model-name-only display change from v3.0.28; the model picker, selector, and status bar return to their previous display behavior
|
||||
|
||||
## 3.0.28
|
||||
|
||||
- Added a ClinePass onboarding flow with selectable ClinePass models, plus improved ClinePass error handling
|
||||
- Added hub primitive catalogs and refreshed the hub dashboard design with a dedicated customizations breakout
|
||||
- Auto-approve toggles now apply immediately when changed
|
||||
- Feature flags now resolve using your user ID on startup
|
||||
- Fixed Cline model display names so they resolve by model name
|
||||
- Truncate large tool results by default (including MCP and custom tool output) to keep requests within context budget
|
||||
- Hardened parallel tool-call guidance for faster, more reliable multi-tool execution
|
||||
|
||||
## 3.0.27
|
||||
|
||||
- Added a `cline skill` command to install and manage skills, matching `cline plugin install` and `cline mcp` (installs default to the Cline agent directory)
|
||||
- Added a prefilled MCP install wizard command for quicker MCP server setup
|
||||
- Improved error handling and messaging when plugin MCP OAuth authorization fails
|
||||
- The CLI now rejects unknown commands and unquoted multi-word input with a clear error instead of silently treating bad arguments as a prompt
|
||||
|
||||
## 3.0.26
|
||||
|
||||
- Reverted the expandable model picker sections and ClinePass models, restoring the previous model-selection UI
|
||||
|
||||
## 3.0.25
|
||||
|
||||
- Added ClinePass support, with selectable ClinePass models in the model picker
|
||||
- Made model picker sections expandable
|
||||
- Added MCP server support to plugins, including authorizing plugin MCP OAuth during install
|
||||
- Encouraged parallel tool calls for faster task execution
|
||||
- Capped tool output for bash commands and file reads to keep large output within context limits
|
||||
- Allowed ranged reads on large files
|
||||
- Fixed apply_patch to fail when a hunk is skipped
|
||||
- Fixed run_commands to return captured stdout on failure and handle split heredocs
|
||||
- Fixed search tools to treat zero results as success
|
||||
- Fixed disabled-reasoning handling for StepFun flash
|
||||
- Fixed history resume rendering isolation
|
||||
- Fixed the Hugging Face URL
|
||||
- Fixed Cline OAuth token formatting in provider config
|
||||
|
||||
## 3.0.24
|
||||
|
||||
- Plugin commands can now submit prompts to the agent
|
||||
- Added support for overriding the API base URL
|
||||
- Open the verification URL automatically when starting device authentication
|
||||
- Enforced a single shared Cline Hub, so a stale hub is respawned after an upgrade
|
||||
- Suppressed flickering console windows on Windows
|
||||
- Fixed truncation of structured tool operation result strings so oversized tool output stays within limits
|
||||
- Stopped echoing the full command text in run_commands tool results
|
||||
|
||||
## 3.0.23
|
||||
|
||||
- Fixed Vertex AI GCP settings configuration
|
||||
- Fixed the Azure Foundry API version
|
||||
- Added support for configured agents as subagent tools
|
||||
- Centralized OAuth management into the SDK
|
||||
- Fixed an error caused by disabled reasoning on Fable 5
|
||||
|
||||
## 3.0.22
|
||||
|
||||
- Added support for the Claude Fable 5 model
|
||||
- Fixed MiniMax M3 thinking controls so they route correctly across gateways
|
||||
|
||||
## 3.0.21
|
||||
|
||||
- Added a global auto-update setting that controls automatic updates on CLI startup
|
||||
- Added a Cline credits refill link
|
||||
- Fixed scrolling for inline ask-question responses
|
||||
- Fixed connector thread session routing and stale hub session handling
|
||||
- Added support for Vertex AI Application Default Credentials (ADC) with tool use
|
||||
- Fixed empty message content replay for Bedrock
|
||||
- Cleaned up the OpenAI Codex model list
|
||||
|
||||
## 3.0.20
|
||||
|
||||
- Installed plugin wrappers are now named from their source (npm package name, git repo, remote filename, official slug, or local directory) instead of an opaque hash, making installed plugins easier to identify.
|
||||
|
||||
## 3.0.19
|
||||
|
||||
- Fixed CLI auto-update to use `npm update` so updates apply reliably, while preserving the installed release channel (e.g. nightly).
|
||||
|
||||
## 3.0.18
|
||||
|
||||
- Fix Slack channel mentions so replies post in the original message's thread.
|
||||
- Fix the abort indicator to clear immediately when a task is cancelled.
|
||||
- Sync the Fireworks AI model registry and refresh the bundled model catalog with current platform offerings.
|
||||
- Bump the bundled SDK to v0.0.43, which forces a running Cline Hub to restart so it picks up the latest SDK code.
|
||||
|
||||
## 3.0.17
|
||||
|
||||
- Fix a regression introduced in 3.0.15 where the interactive CLI could get stuck after stopping and restarting Cline Hub and then pressing Escape to cancel a request. The CLI now detects stale or missing sessions, recovers any pending messages, and starts a fresh session instead of failing with "session not found".
|
||||
- Fix Ctrl+C and Hub shutdown races that surfaced as "hook dispatch failed" and WebSocket connection errors from late hook events racing against Hub shutdown.
|
||||
- Fix the Hub daemon being shut down prematurely when a runtime request was aborted, so the daemon now stays alive.
|
||||
- Improve the Telegram connector with a new `--allowed-user-id` flag to restrict which Telegram users are authorized to interact with the agent.
|
||||
|
||||
## 3.0.16
|
||||
|
||||
- Install official Cline plugins by slug off the new github.com/cline/plugins collection.
|
||||
- Uninstall plugins using `cline plugin uninstall <plugin>` or in the TUI.
|
||||
- Plugins can now bundle skills, and plugin skills are grouped together in settings.
|
||||
- Add Slack socket mode support.
|
||||
- Allow a custom base URL for Anthropic vendor-type providers.
|
||||
- Fix OAuth token migration for users signed in through the old extension.
|
||||
- Use a union schema for read-files tool input validation.
|
||||
- Add a `CLINE_PLUGIN_IMPORT_TIMEOUT_MS` env override to control the plugin import timeout.
|
||||
|
||||
## 3.0.15
|
||||
|
||||
- Add Cline Hub, a web app for monitoring connected clients, viewing and driving sessions, streaming assistant output, and restarting the local hub, with local, LAN, and tunnel usage gated by a room secret.
|
||||
- Support global AGENTS rules so agent rules can be applied across all sessions, not just per-project.
|
||||
- Let plugins contribute static or dynamic rule content when installed in the sandbox.
|
||||
- Bind Discord sessions to individual message authors so different Discord users no longer share chat state in a thread.
|
||||
- Support participant mute targets in Discord: resolve `/mute` and `/unmute` from user mentions or raw user IDs to mute a specific participant in a thread.
|
||||
- Make OAuth URLs clickable in the TUI.
|
||||
- Refresh the bundled model catalog, adding Claude Opus 4.8, Moonshot Kimi K2.6, and Qwen3.7 Max (with cache support).
|
||||
- Discover SDK skill directories that are symlinked, including handling circular symlinks.
|
||||
- Steer active connector sessions across turn keys by matching on session ID, so replies continue the existing session instead of starting a duplicate.
|
||||
- Stop the Discord connector after repeated identical errors (per thread, within a time window) to prevent error messages from flooding a channel.
|
||||
- Fix Discord connector registration and reply fallback handling.
|
||||
- Fix SAP AI Core to use the AI SDK community provider.
|
||||
- Log ACP output as diagnostics instead of errors so normal output no longer appears as errors.
|
||||
|
||||
## 3.0.14
|
||||
|
||||
- Fix OTEL telemetry variable bundling so telemetry is correctly enabled in compiled CLI builds: guard against environments where `process.env` is undefined and remove optional chaining so bundlers can inline the values at build time.
|
||||
|
||||
## 3.0.13
|
||||
|
||||
- Show a loading dialog while resuming a session from history so the TUI no longer appears frozen during the load.
|
||||
- Speed up the `/clear` command by deferring new session creation until you send the next prompt, so clearing no longer blocks on spinning up an empty session.
|
||||
|
||||
## 3.0.12
|
||||
|
||||
- Show a loading dialog while the config screen switches provider or model so the transition no longer looks frozen.
|
||||
- Render the ask question tool prompt inline with the conversation so the question and suggested answers stay attached to the assistant turn that asked them, instead of appearing in a separate modal.
|
||||
- Allow manual `cline update` runs to install the latest published version immediately, bypassing the release age gate that delays automatic updates.
|
||||
- Refresh the bundled SDK to 0.0.42, updating the model catalog.
|
||||
|
||||
## 3.0.11
|
||||
|
||||
- Fix a regression in the ChatGPT OAuth provider where requests failed with `max_output_tokens not supported`, by restoring the full output token budget instead of applying an implicit cap.
|
||||
- Hide the `Space toggle` hint in the config footer when the highlighted row is not toggleable (rules, agents, hooks).
|
||||
- Authenticate Vertex Gemini through Google auth when `gcp.projectId` is configured, and surface the full Vertex model list instead of only Claude models.
|
||||
- Include tool names in tool result content blocks so message logs and session history consistently track which tool produced each result.
|
||||
|
||||
## 3.0.10
|
||||
|
||||
- Install plugins from `file://` URLs in addition to npm and git sources.
|
||||
- Show Ollama API key note in TUI settings so users know when to provide an API key.
|
||||
- Keep interactive sessions alive when idle or awaiting approval instead of treating them as ended, and stop reading message files for every session when `hydrate: false`.
|
||||
- Add Poolside as a provider.
|
||||
- Add Gemini 3.5 Flash to the Gemini provider model list.
|
||||
- Auto-detect Telegram bot username from the bot token so the Telegram connector no longer requires it to be configured separately.
|
||||
- Notify connectors when a scheduled execution fails, not just when it succeeds.
|
||||
- Bake OTEL telemetry variables into the CLI at build time so telemetry works in nightly and production builds.
|
||||
- Preserve model output token limits from the SDK model catalog so context window math matches the upstream provider.
|
||||
- Soften the visual treatment of rejected tool calls in the TUI.
|
||||
- Hide the skills tool from the system prompt when skills are disabled, and refresh slash commands after toggling a skill.
|
||||
- Restore AWS Bedrock profile-based auth during legacy config migration so profiles set via `awsAuthentication: "profile"` are preserved without `awsUseProfile`.
|
||||
- Cache global settings reads keyed by file mtime so repeated reads skip the JSON parse and zod validation on the hot path.
|
||||
|
||||
## 3.0.9
|
||||
|
||||
- Speed up CLI startup with plugins by loading sandboxed plugins concurrently and caching plugin tool descriptors per plugin, provider, and model.
|
||||
- Speed up plugin and tool config toggles by updating the TUI optimistically and persisting changes without reloading the full config or reimporting plugins.
|
||||
- Restore fuzzy ranking for the @-mention file picker so the most relevant files appear first.
|
||||
- Keep the interactive CLI session alive after cancelling a task instead of tearing the session down.
|
||||
- Accept dash-prefixed prompts when passed after `--`, so prompts starting with `-` are no longer parsed as flags.
|
||||
- Recover from hub abort cleanup failures so a cancel that hits an error no longer crashes the runtime host.
|
||||
- Route GLM thinking through provider metadata so thinking-enabled GLM models behave correctly through the gateway.
|
||||
|
||||
## 3.0.8
|
||||
|
||||
- Use Telegram numeric participant ids so renamed users stay linked to the same participant in the Telegram connector.
|
||||
- Keep failed plugins visible in the config UI with their load/setup phase and error details so broken plugin definitions are easier to diagnose.
|
||||
- Move the Create Session Fork shortcut from Opt+F to Opt+R so terminal word-right navigation works again.
|
||||
- Fix AWS Bedrock region and profile detection in the CLI onboarding, and surface bearer-token and additional Bedrock config fields in the provider config screens.
|
||||
- Fix inflated token usage counts caused by AgentRuntime.execute() not resetting usage between calls, which the local runtime host was then double-counting on top of the session baseline.
|
||||
|
||||
## 3.0.7
|
||||
|
||||
- Skip the ChatGPT OAuth model refresh on session startup so the CLI launches without the extra network round-trip.
|
||||
- Align the ChatGPT OAuth model catalog with the Codex provider list so the available models match the subscription tier.
|
||||
|
||||
## 3.0.6
|
||||
|
||||
- Fix ChatGPT provider model list to include the codex variants and the gpt-5.2, gpt-5.4, and gpt-5.4-mini subscription models.
|
||||
|
||||
## 3.0.5
|
||||
|
||||
- Show plugin-provided tools and slash commands in the CLI settings dialog by hydrating them through the sandbox.
|
||||
- Preserve hydrated plugin tools and config reload options when toggling settings, so they no longer disappear after a toggle.
|
||||
|
||||
## 3.0.4
|
||||
|
||||
- Improve light theme TUI colors so chat, status bar, tool output, and syntax highlighting render with better contrast on light terminals.
|
||||
- Fix plugin tools failing in the production npm build by bundling the SDK deps plugins import at runtime.
|
||||
|
||||
## 3.0.3
|
||||
|
||||
- Add `--worktree` flag that auto-creates a fresh git worktree under `~/.cline/worktrees/` and runs the task there. Works with `--taskId` and `--continue` so you can resume a task in an isolated worktree to try a different approach.
|
||||
- Show session status in the CLI history view and refresh status rows in place while the standalone history TUI is open.
|
||||
- Restore the OpenAI compatible provider in the auth flow and preserve stored model metadata when configuring or migrating OpenAI-compatible providers.
|
||||
- Fix dropped macOS screenshots when pasting them into the TUI or asking the agent to read them: paths containing U+202F (narrow no-break space) and other Unicode variants now resolve to the real file instead of failing with ENOENT.
|
||||
- Accept bearer token auth for AWS Bedrock and map AWS profiles correctly when configuring the Bedrock gateway.
|
||||
- Honor `--thinking none` for Ollama models that ship with reasoning enabled by default.
|
||||
- Recover from detached hub event errors instead of crashing the session.
|
||||
- Refine the shared system prompt with clearer guidance on tool output formatting, unsupported file reads, long-running shell commands, and final verification before completing a task.
|
||||
|
||||
## 3.0.2
|
||||
|
||||
- Fix token count display showing inflated numbers in the TUI.
|
||||
|
||||
## 3.0.1
|
||||
|
||||
- Fix CLI release cleanup scripts so they work correctly on Windows.
|
||||
- Fix the kanban migration notice wording in the TUI.
|
||||
|
||||
## 3.0.0
|
||||
|
||||
Introducing our new Cline CLI built on our new SDK and comes with a snappy new TUI.
|
||||
|
||||
Install:
|
||||
|
||||
```sh
|
||||
npm install -g cline
|
||||
```
|
||||
|
||||
For nightly builds:
|
||||
|
||||
```sh
|
||||
npm install -g cline@nightly
|
||||
```
|
||||
|
||||
## 0.0.13
|
||||
|
||||
- Detect prompt-cache support from cache write pricing so providers with write-only caching are represented correctly in the model catalog
|
||||
- Dual-publish `@clinebot/cli` mirror wrapper so existing users who installed via `npm i -g @clinebot/cli` continue receiving updates
|
||||
- Fix response truncation for OpenAI Codex model responses
|
||||
|
||||
## 0.0.12
|
||||
|
||||
- Fix markdown rendering in the published binary: headers, inline code, blockquotes, bold, italic, and lists now render with proper syntax highlighting (tables were the only element working before)
|
||||
- Add keyboard shortcuts for scrolling through the chat transcript (Page Up/Down, Home/End)
|
||||
- Preserve typed input when selecting slash command skills instead of clearing the prompt
|
||||
- Fix `--thinking none` being ignored when persisted reasoning settings existed, which caused DeepSeek API errors
|
||||
- Fix terminal cleanup on exit so the summary prints cleanly
|
||||
- Fix onboarding provider model resolution
|
||||
- Hide ChatGPT subscription provider usage costs
|
||||
- Handle file index prewarm timeouts gracefully instead of hanging
|
||||
|
||||
## 0.0.11
|
||||
|
||||
- Add `/skills` slash command for browsing and toggling available skills interactively
|
||||
- System prompts from AI SDK are now passed via the dedicated `system` option instead of being embedded in message history
|
||||
- Context compaction can now be triggered manually and runs more reliably
|
||||
- Disable the search tool in yolo mode so the model uses bash for searching instead
|
||||
- Fix `submit_and_exit` completion policy not being wired through to the runtime
|
||||
- Fix resumed sessions losing tool results when an abort interrupted tool execution mid-turn
|
||||
- Fix interactive sessions becoming unusable after aborting a running turn
|
||||
- Fix strict JSON schema mode rejecting valid tool schemas with unions, optional fields, and nullable types
|
||||
- Fix stray log output appearing over the TUI when the log file fallback wrote directly to the stderr file descriptor, bypassing the TUI's stdio capture
|
||||
- Refresh the built-in model catalog with the latest available models and pricing
|
||||
|
||||
## 0.0.10
|
||||
|
||||
- Improve local provider onboarding: setting up Ollama, LM Studio, or other local providers now prompts for the endpoint URL directly, supports typing a model ID manually when the provider returns no models, and correctly discovers models from your saved endpoint
|
||||
- Ctrl+C no longer cancels a running turn -- it now clears the input field or exits the CLI, matching standard terminal behavior. Use Escape to cancel a running turn instead
|
||||
- Thinking level chosen in the model picker now persists across CLI restarts instead of resetting to off
|
||||
- The context bar now shows visible progress as tokens are used, instead of appearing empty on some terminal themes
|
||||
- The status bar token count now shows actual context window usage instead of over-counting across multiple model calls in a turn
|
||||
- Resuming a saved session now correctly displays the accumulated cost
|
||||
- Sessions are now saved to disk after each assistant response, so conversation progress survives crashes or unexpected exits
|
||||
- Auto-compaction now runs inline during model requests, keeping long conversations within the context window automatically
|
||||
- The home screen robot now follows the cursor while you type
|
||||
- Hub websocket connections now automatically reconnect after going idle, so sessions no longer silently lose their connection to the hub daemon
|
||||
- MCP stdio servers on Windows no longer spawn visible console windows
|
||||
- Tool input schemas containing `allOf` clauses are now handled correctly instead of being rejected
|
||||
- Login now uses device auth exclusively
|
||||
- Fix chat input and chat view text losing its indent on wrapped lines
|
||||
|
||||
## 0.0.9
|
||||
|
||||
- Fix stray text appearing over the TUI when background operations (like hub restart messages) write directly to stdout/stderr during interactive sessions
|
||||
- Fix hub connection recovery: when a newer CLI instance restarts the shared hub daemon, already-running CLI sessions now automatically reconnect to the new hub endpoint instead of failing with transport errors
|
||||
|
||||
## 0.0.8
|
||||
|
||||
- Fix crash when pressing Escape to cancel a running turn
|
||||
- Add plugin and SDK tool toggles to the settings panel
|
||||
- Add `@cline/sdk` as a user-facing alias for `@cline/core`
|
||||
- Improve hub recovery with better error handling, logging, and recovery timeouts
|
||||
- Show session summary (ID, model, cost, resume command) on exit
|
||||
- Fix OAuth browser-launch failure
|
||||
- Fix compact no-op being reported indistinctly
|
||||
- Fix CLI history resume being non-transactional (could leave blank UI or corrupt session on disk)
|
||||
- Fix cross-client session history not loading Code/VS Code sessions, and fix interactive turn status showing stale state
|
||||
- Fix configuration file paths for hooks and rules (now resolve from `~/.cline/hooks` and `~/.cline/rules`)
|
||||
- Fix Telegram connector: honor `--no-tools` flag, lock tool-disabled mode across state changes, post replies as raw text to avoid markdown parse failures, add `/help` and `/start` commands
|
||||
- Clean up CLI program description and compact slash command descriptions
|
||||
- Clean up CLI flags
|
||||
|
||||
## 0.0.7
|
||||
|
||||
- Fix graceful recovery when the model returns malformed tool call inputs, preventing crashes mid-conversation
|
||||
- Add settings toggles for core skills (enable/disable individual skills from the settings panel)
|
||||
- Secure the local hub daemon with a discovery auth token, preventing unauthorized local access
|
||||
- Fix auto-approve tool policies being incorrectly reset after session restore
|
||||
- Fix npm wrapper detection for auto updates, so self-update works when the CLI is invoked through npm/npx shims
|
||||
- Improve fork session UX with clearer prompts and smoother flow
|
||||
- Fix manual thinking budget not being applied when using Anthropic models directly
|
||||
- Improve account onboarding flow with better error messages and step sequencing
|
||||
- Add enable/disable controls for individual tools and plugins
|
||||
- Fix abort handling so the public run promise resolves correctly when a run is cancelled
|
||||
- Fix markdown token styling in chat output
|
||||
- Fix chat auto-scrolling to bottom on message submit
|
||||
- Fix hub tool capabilities being routed to the wrong session
|
||||
- Revert loading extension-created sessions from history (was causing issues)
|
||||
|
||||
## 0.0.6
|
||||
|
||||
- Add checkpoint restore: press Esc twice or type `/undo` to rewind to a previous checkpoint, with options to restore chat only or chat + workspace
|
||||
- Fix clipboard: fall back to system clipboard (pbcopy, PowerShell, wl-copy, xclip) when OSC 52 fails, fixing copy for longer text selections
|
||||
- Fix prompt focus: restore focus to the prompt input after dialogs close, preventing the input from becoming unresponsive after using `/settings`
|
||||
|
||||
## 0.0.5
|
||||
|
||||
- The input field has been completely redesigned -- the old bordered box is replaced with a clean chevron-prompt style that adapts its background color to any terminal theme using perceptual OKLAB color math. Light terminals are fully supported now.
|
||||
- Pasting 5+ lines into the input shows a compact preview marker instead of flooding the textarea. The full content is still submitted.
|
||||
- Arrow-key history navigation respects cursor position so you don't lose your place when scrolling through previous prompts.
|
||||
- The TUI renders immediately instead of blocking while the hub daemon boots. Hub readiness and session hydration happen in the background.
|
||||
- Listing previous sessions no longer hydrates every full session, making `cline history` and the history picker snappy even with hundreds of sessions.
|
||||
- Updating the CLI no longer leaves you connected to a stale hub daemon. Incompatible versions are detected and replaced automatically, eliminating the "Unsupported hub schedule command" class of errors.
|
||||
- Schedules can now trigger on external events (webhooks, GitHub events, plugin-emitted signals) in addition to cron intervals, with deduplication, filtering, and retry policies.
|
||||
- Plugins can register automation event types that feed into the scheduling system, enabling custom triggers from any source.
|
||||
- Resuming a session automatically picks up any in-flight team runs without needing to remember or pass `--team-name`.
|
||||
- `providers.json` (which stores API keys and OAuth tokens) is now written with 0600 permissions, preventing other processes on the machine from reading it.
|
||||
- Models that emit `command` or `cmd` instead of `commands` (or `paths` instead of `path`) no longer fail. Common aliases are normalized before execution.
|
||||
|
||||
## 0.0.4
|
||||
|
||||
- Fix compiled binary spawning infinite hub daemon recursion loop
|
||||
|
||||
## 0.0.3
|
||||
|
||||
- Rewritten TUI from Ink to OpenTUI with streaming markdown, syntax-highlighted diffs, scrollable chat, and mouse support
|
||||
- Dialog system for model picker, tool approval, settings browser, session history, and onboarding
|
||||
- Interactive setup wizards: `cline connect`, `cline schedule`, `cline mcp`
|
||||
- Plan/Act mode toggle with system prompt and tool rebuilding on switch
|
||||
- Input autocomplete for slash commands and file mentions
|
||||
- Message queuing and steer messages during running turns
|
||||
- Platform-specific compiled binaries for macOS, Linux, and Windows (arm64 and x64)
|
||||
- npm trusted publishing via GitHub Actions OIDC
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import { loginAndSaveProviderOAuthCredentials } from "@cline/core";
|
||||
import { getPersistedProviderApiKey } from "../commands/auth";
|
||||
import { writeDiagnostic } from "../utils/output";
|
||||
|
||||
/**
|
||||
* Supported ACP OAuth provider IDs.
|
||||
*/
|
||||
export const ACP_AUTH_METHODS = [
|
||||
{ id: "cline", name: "Sign in with Cline" },
|
||||
{ id: "openai-codex", name: "Sign in with ChatGPT Subscription" },
|
||||
] as const;
|
||||
|
||||
export type AcpAuthMethodId = (typeof ACP_AUTH_METHODS)[number]["id"];
|
||||
|
||||
export function isAcpAuthMethodId(id: string): id is AcpAuthMethodId {
|
||||
return ACP_AUTH_METHODS.some((m) => m.id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an OAuth login for the given provider in ACP mode.
|
||||
*
|
||||
* Since stdin/stdout are used for the JSON-RPC transport, all user-facing
|
||||
* output is written to stderr and URLs are opened via the `open` package.
|
||||
* If the OAuth flow requires interactive prompts (rare), defaults are used
|
||||
* when available; otherwise an error is thrown.
|
||||
*/
|
||||
async function performOAuthLogin(input: {
|
||||
providerId: AcpAuthMethodId;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
}): Promise<string> {
|
||||
const [{ createOAuthClientCallbacks }, { default: open }] = await Promise.all(
|
||||
[import("@cline/core"), import("open")],
|
||||
);
|
||||
|
||||
const callbacks = createOAuthClientCallbacks({
|
||||
onPrompt: ({ defaultValue }) => {
|
||||
if (defaultValue) {
|
||||
return Promise.resolve(defaultValue);
|
||||
}
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
"OAuth flow requires interactive input which is unavailable in ACP mode",
|
||||
),
|
||||
);
|
||||
},
|
||||
onOutput: (message) => writeDiagnostic(`[acp/auth] ${message}`),
|
||||
openUrl: (url) => open(url, { wait: false }).then(() => undefined),
|
||||
onOpenUrlError: ({ url }) => {
|
||||
writeDiagnostic(
|
||||
`[acp/auth] Could not open browser automatically. Open this URL manually:\n${url}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
const settings = await loginAndSaveProviderOAuthCredentials(
|
||||
input.providerSettingsManager,
|
||||
input.providerId,
|
||||
{ callbacks },
|
||||
);
|
||||
const apiKey = getPersistedProviderApiKey(input.providerId, settings);
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
`OAuth login did not persist credentials for ${input.providerId}`,
|
||||
);
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
export interface AcpAuthResult {
|
||||
providerId: AcpAuthMethodId;
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate via OAuth for the given ACP auth method.
|
||||
*
|
||||
* Uses `ProviderSettingsManager` to check for existing credentials first,
|
||||
* falling back to a fresh OAuth login if needed.
|
||||
*/
|
||||
export async function authenticateAcpProvider(
|
||||
methodId: AcpAuthMethodId,
|
||||
providerSettingsManager: ProviderSettingsManager,
|
||||
): Promise<AcpAuthResult> {
|
||||
const existing = providerSettingsManager.getProviderSettings(methodId);
|
||||
|
||||
// Check for already-stored credentials.
|
||||
const existingKey = getPersistedProviderApiKey(methodId, existing);
|
||||
if (existingKey) {
|
||||
writeDiagnostic(`[acp/auth] Using existing credentials for ${methodId}`);
|
||||
return { providerId: methodId, apiKey: existingKey };
|
||||
}
|
||||
|
||||
// Perform a fresh OAuth login.
|
||||
writeDiagnostic(`[acp/auth] Starting OAuth login for ${methodId}…`);
|
||||
const apiKey = await performOAuthLogin({
|
||||
providerId: methodId,
|
||||
providerSettingsManager,
|
||||
});
|
||||
writeDiagnostic(`[acp/auth] Successfully authenticated with ${methodId}`);
|
||||
return { providerId: methodId, apiKey };
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("runAcpMode", () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock("@agentclientprotocol/sdk");
|
||||
vi.doUnmock("./acpAgent");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("writes the startup diagnostic without labeling it as an error", async () => {
|
||||
const stderrWrite = vi
|
||||
.spyOn(process.stderr, "write")
|
||||
.mockImplementation(() => true);
|
||||
|
||||
vi.doMock("@agentclientprotocol/sdk", () => ({
|
||||
ndJsonStream: vi.fn(() => ({})),
|
||||
AgentSideConnection: class {
|
||||
closed = Promise.resolve();
|
||||
},
|
||||
}));
|
||||
vi.doMock("./acpAgent", () => ({
|
||||
AcpAgent: class {},
|
||||
}));
|
||||
|
||||
const { runAcpMode } = await import("./index");
|
||||
|
||||
await runAcpMode();
|
||||
|
||||
expect(stderrWrite).toHaveBeenCalledWith(
|
||||
"[acp] starting ACP mode over stdio…\n",
|
||||
);
|
||||
expect(stderrWrite).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining("error:"),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,208 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync } from "node:fs";
|
||||
import { arch, platform, tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { runDashboardCommand, waitForProcessShutdown } from "./dashboard";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"WORKSPACE_ROOT",
|
||||
"CLINE_DIR",
|
||||
"CLINE_SANDBOX",
|
||||
"CLINE_SANDBOX_DATA_DIR",
|
||||
"CLINE_DATA_DIR",
|
||||
"CLINE_DB_DATA_DIR",
|
||||
"CLINE_SESSION_DATA_DIR",
|
||||
"CLINE_TEAM_DATA_DIR",
|
||||
"CLINE_PROVIDER_SETTINGS_PATH",
|
||||
"CLINE_HOOKS_LOG_PATH",
|
||||
"HOST",
|
||||
"CLINE_HUB_DASHBOARD_PORT",
|
||||
"PUBLIC_URL",
|
||||
"ROOM_SECRET",
|
||||
"CLINE_HUB_WEBVIEW_DIST_DIR",
|
||||
"CLINE_WRAPPER_PATH",
|
||||
] as const;
|
||||
|
||||
const originalEnv = Object.fromEntries(
|
||||
ENV_KEYS.map((key) => [key, process.env[key]]),
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = originalEnv[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("runDashboardCommand", () => {
|
||||
it("starts the dashboard server, opens the invite URL, and waits for shutdown", async () => {
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const opened: string[] = [];
|
||||
const stop = vi.fn();
|
||||
let observedEnv:
|
||||
| {
|
||||
workspaceRoot: string | undefined;
|
||||
clineDir: string | undefined;
|
||||
clineDataDir: string | undefined;
|
||||
providerSettingsPath: string | undefined;
|
||||
host: string | undefined;
|
||||
port: string | undefined;
|
||||
publicUrl: string | undefined;
|
||||
roomSecret: string | undefined;
|
||||
webviewDistDir: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
const webviewDistDir = mkdtempSync(join(tmpdir(), "cline-webview-dist-"));
|
||||
mkdirSync(webviewDistDir, { recursive: true });
|
||||
process.env.CLINE_HUB_WEBVIEW_DIST_DIR = webviewDistDir;
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
configDir: "/tmp/cline-config",
|
||||
cwd: "sdk",
|
||||
dataDir: ".cline-dashboard-data",
|
||||
host: "127.0.0.1",
|
||||
port: "9090",
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
roomSecret: "secret",
|
||||
io: {
|
||||
writeln: (text) => output.push(text ?? ""),
|
||||
writeErr: (text) => errors.push(text),
|
||||
},
|
||||
startServer: async () => {
|
||||
observedEnv = {
|
||||
workspaceRoot: process.env.WORKSPACE_ROOT,
|
||||
clineDir: process.env.CLINE_DIR,
|
||||
clineDataDir: process.env.CLINE_DATA_DIR,
|
||||
providerSettingsPath: process.env.CLINE_PROVIDER_SETTINGS_PATH,
|
||||
host: process.env.HOST,
|
||||
port: process.env.CLINE_HUB_DASHBOARD_PORT,
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
roomSecret: process.env.ROOM_SECRET,
|
||||
webviewDistDir: process.env.CLINE_HUB_WEBVIEW_DIST_DIR,
|
||||
};
|
||||
return {
|
||||
listenUrl: "http://127.0.0.1:9090/",
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
inviteUrl: "http://127.0.0.1:9090/?roomSecret=secret",
|
||||
hubUrl: "ws://127.0.0.1:25463/hub",
|
||||
stop,
|
||||
};
|
||||
},
|
||||
openUrl: async (url) => {
|
||||
opened.push(url);
|
||||
},
|
||||
waitForShutdown: async (server) => {
|
||||
await server.stop();
|
||||
},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(observedEnv).toEqual({
|
||||
workspaceRoot: resolve("sdk"),
|
||||
clineDir: "/tmp/cline-config",
|
||||
clineDataDir: resolve("sdk", ".cline-dashboard-data"),
|
||||
providerSettingsPath: join(
|
||||
resolve("sdk", ".cline-dashboard-data"),
|
||||
"settings",
|
||||
"providers.json",
|
||||
),
|
||||
host: "127.0.0.1",
|
||||
port: "9090",
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
roomSecret: "secret",
|
||||
webviewDistDir,
|
||||
});
|
||||
expect(opened).toEqual(["http://127.0.0.1:9090/?roomSecret=secret"]);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
expect(output.join("\n")).toContain("Cline dashboard listening at");
|
||||
expect(output.join("\n")).toContain("ws://127.0.0.1:25463/hub");
|
||||
expect(errors).toEqual([]);
|
||||
expect(process.env.WORKSPACE_ROOT).toBe(originalEnv.WORKSPACE_ROOT);
|
||||
expect(process.env.CLINE_HUB_WEBVIEW_DIST_DIR).toBe(webviewDistDir);
|
||||
});
|
||||
|
||||
it("honors --no-open behavior", async () => {
|
||||
const openUrl = vi.fn();
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
openBrowser: false,
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
startServer: async () => ({
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
stop: vi.fn(),
|
||||
}),
|
||||
openUrl,
|
||||
waitForShutdown: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finds webview assets from the published wrapper package layout", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-wrapper-layout-"));
|
||||
const wrapperPath = join(root, "node_modules", "cline", "bin", "cline");
|
||||
const platformName = platform() === "win32" ? "windows" : platform();
|
||||
const webviewDistDir = join(
|
||||
root,
|
||||
"node_modules",
|
||||
"cline",
|
||||
"node_modules",
|
||||
"@cline",
|
||||
`cli-${platformName}-${arch()}`,
|
||||
"cline-hub",
|
||||
"webview",
|
||||
);
|
||||
mkdirSync(join(wrapperPath, ".."), { recursive: true });
|
||||
mkdirSync(webviewDistDir, { recursive: true });
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
delete process.env.CLINE_HUB_WEBVIEW_DIST_DIR;
|
||||
let observedWebviewDistDir: string | undefined;
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
openBrowser: false,
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
startServer: async () => {
|
||||
observedWebviewDistDir = process.env.CLINE_HUB_WEBVIEW_DIST_DIR;
|
||||
return {
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
stop: vi.fn(),
|
||||
};
|
||||
},
|
||||
waitForShutdown: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(observedWebviewDistDir).toBe(webviewDistDir);
|
||||
});
|
||||
|
||||
it("settles shutdown when server stop rejects", async () => {
|
||||
const shutdown = waitForProcessShutdown({
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
stop: vi.fn(async () => {
|
||||
throw new Error("stop failed");
|
||||
}),
|
||||
});
|
||||
|
||||
process.emit("SIGINT", "SIGINT");
|
||||
|
||||
await expect(shutdown).rejects.toThrow("stop failed");
|
||||
});
|
||||
});
|
||||
@@ -1,215 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { arch, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import open from "open";
|
||||
import { configureSandboxEnvironment } from "../utils/helpers";
|
||||
import { c } from "../utils/output";
|
||||
|
||||
export interface DashboardServerHandle {
|
||||
listenUrl: string;
|
||||
publicUrl: string;
|
||||
inviteUrl: string;
|
||||
hubUrl?: string;
|
||||
stop: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface DashboardCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface RunDashboardCommandOptions {
|
||||
configDir?: string;
|
||||
cwd?: string;
|
||||
dataDir?: string;
|
||||
host?: string;
|
||||
port?: string;
|
||||
publicUrl?: string;
|
||||
roomSecret?: string;
|
||||
openBrowser?: boolean;
|
||||
io: DashboardCommandIo;
|
||||
startServer?: () => Promise<DashboardServerHandle>;
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
waitForShutdown?: (server: DashboardServerHandle) => Promise<void>;
|
||||
}
|
||||
|
||||
const DASHBOARD_PORT_ENV = "CLINE_HUB_DASHBOARD_PORT";
|
||||
const WEBVIEW_DIST_ENV = "CLINE_HUB_WEBVIEW_DIST_DIR";
|
||||
|
||||
function setEnvValue(name: string, value: string | undefined): () => void {
|
||||
const previous = process.env[name];
|
||||
if (value !== undefined) {
|
||||
process.env[name] = value;
|
||||
}
|
||||
return () => {
|
||||
if (previous === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = previous;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const SANDBOX_ENV_KEYS = [
|
||||
"CLINE_SANDBOX",
|
||||
"CLINE_SANDBOX_DATA_DIR",
|
||||
"CLINE_DATA_DIR",
|
||||
"CLINE_DB_DATA_DIR",
|
||||
"CLINE_SESSION_DATA_DIR",
|
||||
"CLINE_TEAM_DATA_DIR",
|
||||
"CLINE_PROVIDER_SETTINGS_PATH",
|
||||
"CLINE_HOOKS_LOG_PATH",
|
||||
] as const;
|
||||
|
||||
async function withDashboardEnvironment<T>(
|
||||
options: RunDashboardCommandOptions,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
|
||||
const restore = [
|
||||
setEnvValue("WORKSPACE_ROOT", options.cwd ? cwd : undefined),
|
||||
setEnvValue("CLINE_DIR", options.configDir?.trim() || undefined),
|
||||
setEnvValue("HOST", options.host),
|
||||
setEnvValue(DASHBOARD_PORT_ENV, options.port),
|
||||
setEnvValue("PUBLIC_URL", options.publicUrl),
|
||||
setEnvValue("ROOM_SECRET", options.roomSecret),
|
||||
setEnvValue(WEBVIEW_DIST_ENV, resolveDefaultWebviewDistDir()),
|
||||
...SANDBOX_ENV_KEYS.map((key) => setEnvValue(key, undefined)),
|
||||
];
|
||||
if (options.dataDir || process.env.CLINE_SANDBOX?.trim() === "1") {
|
||||
configureSandboxEnvironment({
|
||||
enabled: true,
|
||||
cwd,
|
||||
explicitDir: options.dataDir,
|
||||
});
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
for (let i = restore.length - 1; i >= 0; i--) {
|
||||
restore[i]?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDefaultWebviewDistDir(): string | undefined {
|
||||
if (process.env[WEBVIEW_DIST_ENV]?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
const candidates = [
|
||||
...resolveInstalledPlatformPackageWebviewCandidates(),
|
||||
// Source checkout: apps/cli/src/commands/dashboard.ts
|
||||
join(moduleDir, "../../../cline-hub/dist/webview"),
|
||||
// Node bundle: apps/cli/dist/index.js
|
||||
join(moduleDir, "cline-hub/webview"),
|
||||
// Compiled platform package: apps/cli/dist/<platform>/bin/cline
|
||||
join(dirname(process.execPath), "../cline-hub/webview"),
|
||||
];
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate));
|
||||
}
|
||||
|
||||
function resolveInstalledPlatformPackageWebviewCandidates(): string[] {
|
||||
const packageName = resolvePlatformPackageName();
|
||||
const starts = [
|
||||
process.env.CLINE_WRAPPER_PATH
|
||||
? dirname(process.env.CLINE_WRAPPER_PATH)
|
||||
: undefined,
|
||||
dirname(process.execPath),
|
||||
].filter((value): value is string => !!value?.trim());
|
||||
const candidates: string[] = [];
|
||||
for (const start of starts) {
|
||||
let current = start;
|
||||
for (;;) {
|
||||
candidates.push(
|
||||
join(current, "node_modules", packageName, "cline-hub/webview"),
|
||||
);
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function resolvePlatformPackageName(): string {
|
||||
const platformName = platform() === "win32" ? "windows" : platform();
|
||||
return `@cline/cli-${platformName}-${arch()}`;
|
||||
}
|
||||
|
||||
async function startDefaultDashboardServer(): Promise<DashboardServerHandle> {
|
||||
const { startClineHubDashboardServer } = await import("@cline/cline-hub");
|
||||
return await startClineHubDashboardServer();
|
||||
}
|
||||
|
||||
async function openDefaultUrl(url: string): Promise<void> {
|
||||
await open(url, { wait: false });
|
||||
}
|
||||
|
||||
export function waitForProcessShutdown(
|
||||
server: DashboardServerHandle,
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolveShutdown, rejectShutdown) => {
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
process.off("SIGINT", handleSignal);
|
||||
process.off("SIGTERM", handleSignal);
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
try {
|
||||
await server.stop();
|
||||
resolveShutdown();
|
||||
} catch (error) {
|
||||
rejectShutdown(error);
|
||||
}
|
||||
};
|
||||
|
||||
function handleSignal() {
|
||||
void stop();
|
||||
}
|
||||
|
||||
process.on("SIGINT", handleSignal);
|
||||
process.on("SIGTERM", handleSignal);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runDashboardCommand(
|
||||
options: RunDashboardCommandOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const server = await withDashboardEnvironment(options, () =>
|
||||
(options.startServer ?? startDefaultDashboardServer)(),
|
||||
);
|
||||
const dashboardUrl =
|
||||
server.inviteUrl || server.publicUrl || server.listenUrl;
|
||||
options.io.writeln(
|
||||
`${c.green}Cline dashboard listening at${c.reset} ${dashboardUrl}`,
|
||||
);
|
||||
if (server.hubUrl) {
|
||||
options.io.writeln(`${c.dim}Hub endpoint: ${server.hubUrl}${c.reset}`);
|
||||
}
|
||||
|
||||
if (options.openBrowser !== false) {
|
||||
try {
|
||||
await (options.openUrl ?? openDefaultUrl)(dashboardUrl);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io.writeErr(`Failed to open browser: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await (options.waitForShutdown ?? waitForProcessShutdown)(server);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
options.io.writeErr(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import { installMcpServer } from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildMcpInstallDefaults,
|
||||
buildMcpInstallTransport,
|
||||
runMcpInstallCommand,
|
||||
} from "./mcp";
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
installMcpServer: vi.fn((options) => {
|
||||
const { name, transport, warnings } =
|
||||
actual.buildMcpInstallTransport(options);
|
||||
return {
|
||||
name,
|
||||
status: "installed",
|
||||
transport,
|
||||
warnings,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("mcp install command", () => {
|
||||
it("builds stdio wizard defaults from command args", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "fs",
|
||||
targetArgs: [
|
||||
"npx",
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp/my dir",
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "fs",
|
||||
type: "stdio",
|
||||
command: 'npx -y @modelcontextprotocol/server-filesystem "/tmp/my dir"',
|
||||
});
|
||||
});
|
||||
|
||||
it("builds remote wizard defaults and normalizes http transport", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "ctx7",
|
||||
transport: "http",
|
||||
targetArgs: ["https://mcp.context7.com/mcp"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "ctx7",
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes streamable-http transport", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "docs",
|
||||
transport: "streamable-http",
|
||||
targetArgs: ["https://example.com/mcp"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "docs",
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds SSE wizard defaults", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "events",
|
||||
transport: "sse",
|
||||
targetArgs: ["https://example.com/sse"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "events",
|
||||
type: "sse",
|
||||
url: "https://example.com/sse",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing stdio command and invalid remote URL", () => {
|
||||
expect(() =>
|
||||
buildMcpInstallDefaults({
|
||||
name: "fs",
|
||||
}),
|
||||
).toThrow(/requires a command/);
|
||||
|
||||
expect(() =>
|
||||
buildMcpInstallDefaults({
|
||||
name: "bad",
|
||||
transport: "http",
|
||||
targetArgs: ["not-a-url"],
|
||||
}),
|
||||
).toThrow(/Invalid MCP server URL/);
|
||||
});
|
||||
|
||||
it("rejects remote URL schemes other than http and https", () => {
|
||||
expect(() =>
|
||||
buildMcpInstallDefaults({
|
||||
name: "bad",
|
||||
transport: "http",
|
||||
targetArgs: ["file:///etc/passwd"],
|
||||
}),
|
||||
).toThrow(/only http and https are supported/);
|
||||
});
|
||||
|
||||
it("builds direct stdio installs without shell-joining args", () => {
|
||||
expect(
|
||||
buildMcpInstallTransport({
|
||||
name: "fs",
|
||||
targetArgs: [
|
||||
"npx",
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp/my dir",
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "fs",
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/my dir"],
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("builds direct remote installs with headers and placeholder warnings", () => {
|
||||
expect(
|
||||
buildMcpInstallTransport({
|
||||
name: "docs",
|
||||
transport: "http",
|
||||
headers: ["Authorization: Bearer <token>"],
|
||||
targetArgs: ["https://example.com/mcp", "--header=X-Extra: yes"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "docs",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer <token>",
|
||||
"X-Extra": "yes",
|
||||
},
|
||||
},
|
||||
warnings: [
|
||||
'Header "Authorization" looks like it contains a placeholder. Update it in MCP settings before using this server.',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the add wizard with prefilled defaults", async () => {
|
||||
const runWizard = vi.fn(async () => 0);
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "ctx7",
|
||||
transport: "http",
|
||||
targetArgs: ["https://mcp.context7.com/mcp"],
|
||||
isTty: true,
|
||||
runWizard,
|
||||
io: { writeErr: vi.fn() },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(runWizard).toHaveBeenCalledWith({
|
||||
name: "ctx7",
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a TTY because it opens the wizard", async () => {
|
||||
const writeErr = vi.fn();
|
||||
const runWizard = vi.fn(async () => 0);
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "ctx7",
|
||||
transport: "http",
|
||||
targetArgs: ["https://mcp.context7.com/mcp"],
|
||||
isTty: false,
|
||||
runWizard,
|
||||
io: { writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(runWizard).not.toHaveBeenCalled();
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks for TTY before validating wizard install arguments", async () => {
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "fs",
|
||||
isTty: false,
|
||||
io: { writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
|
||||
);
|
||||
});
|
||||
|
||||
it("installs directly with --yes without requiring a TTY", async () => {
|
||||
const writeln = vi.fn();
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "docs",
|
||||
transport: "http",
|
||||
targetArgs: [
|
||||
"https://example.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
],
|
||||
isTty: false,
|
||||
yes: true,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(installMcpServer).toHaveBeenCalledWith({
|
||||
name: "docs",
|
||||
transport: "http",
|
||||
targetArgs: [
|
||||
"https://example.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
],
|
||||
isTty: false,
|
||||
yes: true,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
expect(writeln).toHaveBeenCalledWith("Installed MCP server docs.");
|
||||
expect(writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints direct install JSON with --yes --json", async () => {
|
||||
const writeln = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "fs",
|
||||
targetArgs: ["node", "server.js"],
|
||||
isTty: false,
|
||||
yes: true,
|
||||
json: true,
|
||||
io: { writeln, writeErr: vi.fn() },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(writeln.mock.calls[0]?.[0])).toMatchObject({
|
||||
name: "fs",
|
||||
status: "installed",
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import {
|
||||
type McpInstallOptions as CoreMcpInstallOptions,
|
||||
installMcpServer,
|
||||
type McpInstallResult,
|
||||
type McpServerTransportConfig,
|
||||
} from "@cline/core";
|
||||
import type { McpAddDefaults } from "../wizards/mcp";
|
||||
|
||||
export { buildMcpInstallTransport } from "@cline/core";
|
||||
|
||||
export interface McpCommandIo {
|
||||
writeln?: (text: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface McpInstallOptions extends CoreMcpInstallOptions {
|
||||
io?: McpCommandIo;
|
||||
isTty?: boolean;
|
||||
json?: boolean;
|
||||
runWizard?: (defaults: McpAddDefaults) => Promise<number>;
|
||||
yes?: boolean;
|
||||
}
|
||||
|
||||
export interface McpInstallDirectResult {
|
||||
name: string;
|
||||
status: "installed";
|
||||
transport: McpServerTransportConfig;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function normalizeTransportType(
|
||||
value: string | undefined,
|
||||
): McpServerTransportConfig["type"] {
|
||||
const normalized = (value ?? "stdio").trim();
|
||||
if (normalized === "http" || normalized === "streamable-http") {
|
||||
return "streamableHttp";
|
||||
}
|
||||
if (
|
||||
normalized === "stdio" ||
|
||||
normalized === "sse" ||
|
||||
normalized === "streamableHttp"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertValidUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid MCP server URL: ${url}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`Invalid MCP server URL: ${url} (only http and https are supported)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function quoteCommandArg(arg: string): string {
|
||||
if (/^[^\s"'\\]+$/.test(arg)) {
|
||||
return arg;
|
||||
}
|
||||
return `"${arg.replace(/(["\\])/g, "\\$1")}"`;
|
||||
}
|
||||
|
||||
export function buildMcpInstallDefaults(options: {
|
||||
name: string;
|
||||
targetArgs?: string[];
|
||||
transport?: string;
|
||||
}): McpAddDefaults {
|
||||
const name = options.name.trim();
|
||||
if (!name) {
|
||||
throw new Error("MCP server name is required");
|
||||
}
|
||||
const type = normalizeTransportType(options.transport);
|
||||
const targetArgs = options.targetArgs ?? [];
|
||||
if (type === "stdio") {
|
||||
if (targetArgs.length === 0) {
|
||||
throw new Error(
|
||||
"Stdio MCP install requires a command after the server name, for example: cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp",
|
||||
);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
command: targetArgs.map(quoteCommandArg).join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
if (targetArgs.length !== 1) {
|
||||
throw new Error(
|
||||
"Remote MCP install requires exactly one URL argument after the server name.",
|
||||
);
|
||||
}
|
||||
const url = targetArgs[0]?.trim() ?? "";
|
||||
assertValidUrl(url);
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
export function installMcpServerDirect(
|
||||
options: McpInstallOptions,
|
||||
): McpInstallDirectResult {
|
||||
const result: McpInstallResult = installMcpServer(options);
|
||||
return {
|
||||
name: result.name,
|
||||
status: result.status,
|
||||
transport: result.transport,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async function runPrefilledWizard(defaults: McpAddDefaults): Promise<number> {
|
||||
const { runMcpWizard } = await import("../wizards/mcp");
|
||||
return runMcpWizard({
|
||||
initialAction: "add",
|
||||
addDefaults: defaults,
|
||||
exitAfterInitialAction: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runMcpInstallCommand(
|
||||
options: McpInstallOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
if (options.yes) {
|
||||
const result = installMcpServerDirect(options);
|
||||
if (options.json) {
|
||||
options.io?.writeln?.(JSON.stringify(result));
|
||||
} else {
|
||||
options.io?.writeln?.(`Installed MCP server ${result.name}.`);
|
||||
for (const warning of result.warnings) {
|
||||
options.io?.writeErr(warning);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const isTty =
|
||||
options.isTty ?? (process.stdin.isTTY && process.stdout.isTTY);
|
||||
if (!isTty) {
|
||||
throw new Error(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
|
||||
);
|
||||
}
|
||||
const defaults = buildMcpInstallDefaults(options);
|
||||
return await (options.runWizard ?? runPrefilledWizard)(defaults);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,200 +0,0 @@
|
||||
import {
|
||||
installPlugin,
|
||||
type PluginInstallOptions,
|
||||
type PluginInstallResult,
|
||||
type PluginMcpOAuthCandidate,
|
||||
type PluginUninstallOptions,
|
||||
uninstallPlugin,
|
||||
} from "@cline/core";
|
||||
|
||||
export type {
|
||||
PluginInstallOptions,
|
||||
PluginInstallResult,
|
||||
PluginMcpOAuthCandidate,
|
||||
} from "@cline/core";
|
||||
export {
|
||||
collectPluginMcpOAuthCandidates,
|
||||
installPlugin,
|
||||
isOfficialPluginSlug,
|
||||
parsePluginSource,
|
||||
} from "@cline/core";
|
||||
|
||||
export interface PluginInstallMcpOAuthOptions {
|
||||
interactive?: boolean;
|
||||
selectCandidates?: (
|
||||
candidates: PluginMcpOAuthCandidate[],
|
||||
) => Promise<PluginMcpOAuthCandidate[]>;
|
||||
authorize?: (candidate: PluginMcpOAuthCandidate) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginInstallIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
type PluginInstallCommandOptions = PluginInstallOptions & {
|
||||
json?: boolean;
|
||||
io?: PluginInstallIo;
|
||||
mcpOAuth?: PluginInstallMcpOAuthOptions;
|
||||
};
|
||||
|
||||
function serializePluginInstallResult(
|
||||
result: PluginInstallResult,
|
||||
): Omit<PluginInstallResult, "mcpOAuthCandidates"> {
|
||||
return {
|
||||
source: result.source,
|
||||
installPath: result.installPath,
|
||||
entryPaths: result.entryPaths,
|
||||
mcpSyncFailures: result.mcpSyncFailures,
|
||||
};
|
||||
}
|
||||
|
||||
function isInteractivePluginInstall(
|
||||
options: PluginInstallCommandOptions,
|
||||
): boolean {
|
||||
return (
|
||||
options.mcpOAuth?.interactive ??
|
||||
(process.stdin.isTTY && process.stdout.isTTY)
|
||||
);
|
||||
}
|
||||
|
||||
async function selectMcpOAuthCandidatesWithClack(
|
||||
candidates: PluginMcpOAuthCandidate[],
|
||||
): Promise<PluginMcpOAuthCandidate[]> {
|
||||
const p = await import("@clack/prompts");
|
||||
const action = await p.select({
|
||||
message: "Authorize plugin MCP servers now?",
|
||||
options: [
|
||||
{
|
||||
value: "all",
|
||||
label: "Authorize all",
|
||||
hint: "open browser authorization for each server",
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose servers",
|
||||
hint: "select which servers to authorize",
|
||||
},
|
||||
{
|
||||
value: "skip",
|
||||
label: "Skip",
|
||||
},
|
||||
],
|
||||
});
|
||||
if (p.isCancel(action) || action === "skip") {
|
||||
return [];
|
||||
}
|
||||
if (action === "all") {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
const selectedNames = await p.multiselect({
|
||||
message: "Select MCP servers to authorize",
|
||||
options: candidates.map((candidate) => ({
|
||||
value: candidate.name,
|
||||
label: candidate.name,
|
||||
hint: `${candidate.transportType} [${candidate.pluginName}]`,
|
||||
})),
|
||||
required: false,
|
||||
});
|
||||
if (p.isCancel(selectedNames) || !Array.isArray(selectedNames)) {
|
||||
return [];
|
||||
}
|
||||
const selected = new Set(selectedNames);
|
||||
return candidates.filter((candidate) => selected.has(candidate.name));
|
||||
}
|
||||
|
||||
async function authorizeMcpOAuthCandidate(
|
||||
candidate: PluginMcpOAuthCandidate,
|
||||
): Promise<void> {
|
||||
const { authorizeMcpServerOAuthWithBrowser } = await import(
|
||||
"../wizards/mcp/oauth"
|
||||
);
|
||||
await authorizeMcpServerOAuthWithBrowser(candidate.name, {
|
||||
throwOnError: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function runPluginMcpOAuthFollowup(
|
||||
candidates: PluginMcpOAuthCandidate[],
|
||||
options: PluginInstallCommandOptions,
|
||||
): Promise<void> {
|
||||
if (candidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInteractivePluginInstall(options)) {
|
||||
options.io?.writeln("Plugin MCP servers may require OAuth authorization:");
|
||||
for (const candidate of candidates) {
|
||||
options.io?.writeln(
|
||||
` ${candidate.name} (${candidate.transportType}, plugin: ${candidate.pluginName})`,
|
||||
);
|
||||
}
|
||||
options.io?.writeln(
|
||||
'Run "cline mcp" and choose "Authorize OAuth" to authorize them.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected =
|
||||
options.mcpOAuth?.selectCandidates !== undefined
|
||||
? await options.mcpOAuth.selectCandidates(candidates)
|
||||
: await selectMcpOAuthCandidatesWithClack(candidates);
|
||||
const authorize = options.mcpOAuth?.authorize ?? authorizeMcpOAuthCandidate;
|
||||
for (const candidate of selected) {
|
||||
try {
|
||||
await authorize(candidate);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(
|
||||
`Warning: failed to authorize MCP server ${candidate.name}: ${message}. Run "cline mcp" and choose "Authorize OAuth" to retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPluginInstallCommand(
|
||||
options: PluginInstallCommandOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await installPlugin(options);
|
||||
if (options.json) {
|
||||
process.stdout.write(
|
||||
JSON.stringify(serializePluginInstallResult(result)),
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
options.io?.writeln(`Installed plugin from ${result.source}`);
|
||||
options.io?.writeln(` Path: ${result.installPath}`);
|
||||
for (const failure of result.mcpSyncFailures) {
|
||||
options.io?.writeErr(
|
||||
`Warning: failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
|
||||
);
|
||||
}
|
||||
await runPluginMcpOAuthFollowup(result.mcpOAuthCandidates, options);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPluginUninstallCommand(
|
||||
options: PluginUninstallOptions & { json?: boolean; io?: PluginInstallIo },
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await uninstallPlugin(options);
|
||||
if (options.json) {
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
return 0;
|
||||
}
|
||||
options.io?.writeln(`Uninstalled plugin ${result.name}`);
|
||||
options.io?.writeln(` Removed: ${result.installPath}`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSkillsArgs } from "./skill";
|
||||
|
||||
describe("buildSkillsArgs", () => {
|
||||
it("runs the skills package through npx with -y", () => {
|
||||
expect(buildSkillsArgs(["list"])).toEqual(["-y", "skills@latest", "list"]);
|
||||
});
|
||||
|
||||
it("injects --agent cline for install-style subcommands", () => {
|
||||
expect(buildSkillsArgs(["install", "owner/repo"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"add",
|
||||
"owner/repo",
|
||||
"--agent",
|
||||
"cline",
|
||||
]);
|
||||
expect(buildSkillsArgs(["add", "owner/repo"])).toContain("cline");
|
||||
expect(buildSkillsArgs(["i", "owner/repo"])).toContain("cline");
|
||||
expect(buildSkillsArgs(["update", "owner/repo"])).toContain("cline");
|
||||
});
|
||||
|
||||
it("aliases uninstall to the skills remove subcommand", () => {
|
||||
expect(buildSkillsArgs(["uninstall", "my-skill"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
"my-skill",
|
||||
"--agent",
|
||||
"cline",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not inject when the user already targeted an agent", () => {
|
||||
expect(
|
||||
buildSkillsArgs(["install", "owner/repo", "--agent", "cursor"]),
|
||||
).not.toContain("cline");
|
||||
expect(
|
||||
buildSkillsArgs(["install", "owner/repo", "-a", "cursor"]),
|
||||
).not.toContain("cline");
|
||||
expect(
|
||||
buildSkillsArgs(["install", "owner/repo", "--agent=cursor"]),
|
||||
).not.toContain("cline");
|
||||
});
|
||||
|
||||
it("aliases install and uninstall when agent options come before the subcommand", () => {
|
||||
expect(
|
||||
buildSkillsArgs(["--agent", "cursor", "install", "owner/repo"]),
|
||||
).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"--agent",
|
||||
"cursor",
|
||||
"add",
|
||||
"owner/repo",
|
||||
]);
|
||||
expect(
|
||||
buildSkillsArgs(["--agent=cursor", "uninstall", "my-skill"]),
|
||||
).toEqual(["-y", "skills@latest", "--agent=cursor", "remove", "my-skill"]);
|
||||
});
|
||||
|
||||
it("does not scope non-install subcommands to cline", () => {
|
||||
expect(buildSkillsArgs(["use", "owner/repo"])).not.toContain("--agent");
|
||||
expect(buildSkillsArgs(["list"])).not.toContain("--agent");
|
||||
});
|
||||
|
||||
it("scopes remove-style subcommands to cline", () => {
|
||||
expect(buildSkillsArgs(["remove"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
"--agent",
|
||||
"cline",
|
||||
]);
|
||||
expect(buildSkillsArgs(["rm", "my-skill"])).toContain("cline");
|
||||
expect(buildSkillsArgs(["r", "my-skill"])).toContain("cline");
|
||||
});
|
||||
|
||||
it("ignores leading flags when detecting the subcommand", () => {
|
||||
expect(buildSkillsArgs(["--global", "install", "owner/repo"])).toContain(
|
||||
"cline",
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards an empty arg list unchanged", () => {
|
||||
expect(buildSkillsArgs([])).toEqual(["-y", "skills@latest"]);
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import { type SpawnOptions, spawn } from "node:child_process";
|
||||
|
||||
export interface SkillCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
// `cline skill` is a thin wrapper around the open skills CLI
|
||||
// (https://www.npmjs.com/package/skills). We run it through `npx` so users
|
||||
// don't need a separate global install. Pin the version here if we ever need to
|
||||
// lock behavior to a known-good release.
|
||||
const SKILLS_PACKAGE = "skills@latest";
|
||||
|
||||
// Subcommands that write skill files into an agent's skills directory. For a
|
||||
// `cline skill` command we default these to Cline unless the user picked their
|
||||
// own agent. `use` is intentionally excluded: without --agent it prints the
|
||||
// generated prompt to stdout, whereas adding --agent would launch that agent
|
||||
// interactively instead — not what someone scoping to Cline would expect.
|
||||
const CLINE_SCOPED_SUBCOMMANDS = new Set([
|
||||
"add",
|
||||
"install",
|
||||
"i",
|
||||
"update",
|
||||
"remove",
|
||||
"rm",
|
||||
"r",
|
||||
"uninstall",
|
||||
]);
|
||||
|
||||
const SKILLS_SUBCOMMAND_ALIASES = new Map([
|
||||
["install", "add"],
|
||||
["uninstall", "remove"],
|
||||
]);
|
||||
|
||||
function hasAgentFlag(args: readonly string[]): boolean {
|
||||
return args.some(
|
||||
(arg) => arg === "-a" || arg === "--agent" || arg.startsWith("--agent="),
|
||||
);
|
||||
}
|
||||
|
||||
function optionConsumesNextValue(arg: string): boolean {
|
||||
return arg === "-a" || arg === "--agent";
|
||||
}
|
||||
|
||||
function findSubcommandIndex(args: readonly string[]): number {
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index];
|
||||
if (arg.startsWith("-")) {
|
||||
if (optionConsumesNextValue(arg)) {
|
||||
index++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findSubcommand(args: readonly string[]): string | undefined {
|
||||
const index = findSubcommandIndex(args);
|
||||
return index >= 0 ? args[index] : undefined;
|
||||
}
|
||||
|
||||
function normalizeSkillsSubcommandAliases(args: string[]): void {
|
||||
const index = findSubcommandIndex(args);
|
||||
if (index < 0) return;
|
||||
const alias = SKILLS_SUBCOMMAND_ALIASES.get(args[index]);
|
||||
if (alias) {
|
||||
args[index] = alias;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the argument list passed to `npx`, injecting `--agent cline` for
|
||||
* install-style subcommands unless the user already targeted an agent.
|
||||
*/
|
||||
export function buildSkillsArgs(userArgs: readonly string[]): string[] {
|
||||
const args = [...userArgs];
|
||||
const subcommand = findSubcommand(args);
|
||||
normalizeSkillsSubcommandAliases(args);
|
||||
if (
|
||||
subcommand &&
|
||||
CLINE_SCOPED_SUBCOMMANDS.has(subcommand) &&
|
||||
!hasAgentFlag(args)
|
||||
) {
|
||||
args.push("--agent", "cline");
|
||||
}
|
||||
return ["-y", SKILLS_PACKAGE, ...args];
|
||||
}
|
||||
|
||||
function resolveExitCode(
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
): number {
|
||||
if (code !== null) {
|
||||
return code;
|
||||
}
|
||||
switch (signal) {
|
||||
case "SIGINT":
|
||||
return 130;
|
||||
case "SIGTERM":
|
||||
return 143;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward all arguments to the open skills CLI via `npx skills`.
|
||||
*
|
||||
* Returns the child process exit code, or 1 if npx is unavailable or fails to
|
||||
* spawn. stdio is inherited so the skills CLI's interactive prompts and output
|
||||
* pass straight through to the user's terminal.
|
||||
*/
|
||||
export async function runSkillCommand(
|
||||
userArgs: readonly string[],
|
||||
io: SkillCommandIo,
|
||||
): Promise<number> {
|
||||
const args = buildSkillsArgs(userArgs);
|
||||
const isWindows = process.platform === "win32";
|
||||
const options: SpawnOptions = {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
...(isWindows ? { shell: true } : {}),
|
||||
};
|
||||
|
||||
return new Promise<number>((resolve) => {
|
||||
const child = spawn("npx", args, options);
|
||||
|
||||
const forward = (signal: NodeJS.Signals) => {
|
||||
child.kill(signal);
|
||||
};
|
||||
const handleSigint = () => forward("SIGINT");
|
||||
const handleSigterm = () => forward("SIGTERM");
|
||||
process.on("SIGINT", handleSigint);
|
||||
process.on("SIGTERM", handleSigterm);
|
||||
const cleanup = () => {
|
||||
process.off("SIGINT", handleSigint);
|
||||
process.off("SIGTERM", handleSigterm);
|
||||
};
|
||||
|
||||
child.once("error", (error: NodeJS.ErrnoException) => {
|
||||
cleanup();
|
||||
if (error.code === "ENOENT") {
|
||||
io.writeErr(
|
||||
'npx was not found. Install Node.js (which includes npx) to use "cline skill".',
|
||||
);
|
||||
} else {
|
||||
io.writeErr(`Failed to run npx ${SKILLS_PACKAGE}: ${error.message}`);
|
||||
}
|
||||
resolve(1);
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
cleanup();
|
||||
resolve(resolveExitCode(code, signal));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
autoUpdateOnStartup,
|
||||
checkForUpdates,
|
||||
getInstallationInfo,
|
||||
PackageManager,
|
||||
resolveCliHubOwnerContext,
|
||||
withMinimumReleaseAgeBypass,
|
||||
} from "./update";
|
||||
|
||||
const originalArgv = [...process.argv];
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
const originalDataDir = process.env.CLINE_DATA_DIR;
|
||||
const originalHubDiscoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
|
||||
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
const originalIsDev = process.env.IS_DEV;
|
||||
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createFile(path: string): string {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, "");
|
||||
return path;
|
||||
}
|
||||
|
||||
function createTempFile(pathSuffix: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-update-test-"));
|
||||
tempDirs.push(root);
|
||||
return createFile(join(root, pathSuffix));
|
||||
}
|
||||
|
||||
describe("getInstallationInfo", () => {
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
|
||||
}
|
||||
if (originalGlobalSettingsPath === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
|
||||
}
|
||||
if (originalIsDev === undefined) {
|
||||
delete process.env.IS_DEV;
|
||||
} else {
|
||||
process.env.IS_DEV = originalIsDev;
|
||||
}
|
||||
if (originalNoAutoUpdate === undefined) {
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
} else {
|
||||
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("detects npm installs from the wrapper path passed to the compiled binary", () => {
|
||||
const wrapperPath = createTempFile("lib/node_modules/cline/bin/cline");
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
expect(getInstallationInfo("1.2.3")).toEqual({
|
||||
packageManager: PackageManager.NPM,
|
||||
packageName: "cline",
|
||||
updateCommand: "npm update -g cline --tag latest",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the nightly tag when the current CLI version is nightly", () => {
|
||||
const wrapperPath = createTempFile("lib/node_modules/cline/bin/cline");
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
expect(getInstallationInfo("1.2.3-nightly.456")).toEqual({
|
||||
packageManager: PackageManager.NPM,
|
||||
packageName: "cline",
|
||||
updateCommand: "npm update -g cline --tag nightly",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to unknown when only Bun's virtual compiled path is available", () => {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
expect(getInstallationInfo("1.2.3")).toEqual({
|
||||
packageManager: PackageManager.UNKNOWN,
|
||||
packageName: "cline",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto update settings", () => {
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
|
||||
}
|
||||
if (originalGlobalSettingsPath === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
|
||||
}
|
||||
if (originalIsDev === undefined) {
|
||||
delete process.env.IS_DEV;
|
||||
} else {
|
||||
process.env.IS_DEV = originalIsDev;
|
||||
}
|
||||
if (originalNoAutoUpdate === undefined) {
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
} else {
|
||||
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips startup auto update when disabled globally", () => {
|
||||
const settingsPath = createTempFile("data/global-settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
|
||||
delete process.env.IS_DEV;
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockRejectedValue(new Error("should not fetch"));
|
||||
|
||||
autoUpdateOnStartup();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still lets manual update checks run when startup auto update is disabled", async () => {
|
||||
const settingsPath = createTempFile("data/global-settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: "0.0.0" }),
|
||||
} as Response);
|
||||
|
||||
await checkForUpdates({ includeKanban: false });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hub restart owner selection", () => {
|
||||
afterEach(() => {
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the shared hub owner outside production builds", () => {
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-update-test-data";
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
|
||||
expect(owner.discoveryPath).toContain("/locks/hub/owners/");
|
||||
expect(owner.discoveryPath).not.toBe(
|
||||
"/tmp/cline-update-test-data/locks/hub/production.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withMinimumReleaseAgeBypass", () => {
|
||||
it("adds the package-manager-specific cooldown bypass", () => {
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"npm update -g cline --tag latest",
|
||||
PackageManager.NPM,
|
||||
).command,
|
||||
).toBe("npm update -g cline --tag latest --min-release-age=0");
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass("bun add -g cline@latest", PackageManager.BUN)
|
||||
.command,
|
||||
).toBe("bun add -g cline@latest --minimum-release-age=0");
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"yarn global add cline@latest",
|
||||
PackageManager.YARN,
|
||||
).command,
|
||||
).toBe("yarn global add cline@latest");
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"yarn global add cline@latest",
|
||||
PackageManager.YARN,
|
||||
).env?.YARN_NPM_MINIMAL_AGE_GATE,
|
||||
).toBe("0");
|
||||
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"pnpm add -g cline@latest",
|
||||
PackageManager.PNPM,
|
||||
).env?.pnpm_config_minimum_release_age,
|
||||
).toBe("0");
|
||||
});
|
||||
});
|
||||
@@ -1,628 +0,0 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ConnectDiscordOptions } from "@cline/shared";
|
||||
import type { Thread } from "chat";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { readBindings, writeBindings } from "../thread-bindings";
|
||||
import { __test__, discordConnector } from "./discord";
|
||||
|
||||
const parseDiscordArgs = (rawArgs: string[]): ConnectDiscordOptions =>
|
||||
(
|
||||
discordConnector as unknown as {
|
||||
parseArgs(rawArgs: string[]): ConnectDiscordOptions;
|
||||
}
|
||||
).parseArgs(rawArgs);
|
||||
|
||||
type TestDiscordState = {
|
||||
sessionId?: string;
|
||||
enableTools?: boolean;
|
||||
autoApproveTools?: boolean;
|
||||
cwd?: string;
|
||||
workspaceRoot?: string;
|
||||
systemPrompt?: string;
|
||||
participantKey?: string;
|
||||
participantLabel?: string;
|
||||
welcomeSentAt?: string;
|
||||
};
|
||||
|
||||
function createThread(
|
||||
initialState: TestDiscordState,
|
||||
): Thread<TestDiscordState> {
|
||||
let state = { ...initialState };
|
||||
return {
|
||||
id: "discord:guild:channel:thread",
|
||||
channelId: "discord:guild:channel",
|
||||
isDM: false,
|
||||
get state() {
|
||||
return Promise.resolve(state);
|
||||
},
|
||||
async setState(nextState: TestDiscordState) {
|
||||
state = { ...nextState };
|
||||
},
|
||||
toJSON() {
|
||||
return {
|
||||
id: "discord:guild:channel:thread",
|
||||
channelId: "discord:guild:channel",
|
||||
isDM: false,
|
||||
state,
|
||||
};
|
||||
},
|
||||
} as unknown as Thread<TestDiscordState>;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("discordConnector", () => {
|
||||
it("accepts the documented app id and token aliases", () => {
|
||||
const options = parseDiscordArgs([
|
||||
"--app-id",
|
||||
"app-123",
|
||||
"--token",
|
||||
"bot-token",
|
||||
"--public-key",
|
||||
"public-key",
|
||||
"--base-url",
|
||||
"https://example.test",
|
||||
]);
|
||||
|
||||
expect(options.applicationId).toBe("app-123");
|
||||
expect(options.botToken).toBe("bot-token");
|
||||
expect(options.publicKey).toBe("public-key");
|
||||
expect(options.baseUrl).toBe("https://example.test");
|
||||
});
|
||||
|
||||
it("keeps accepting the explicit application id and bot token options", () => {
|
||||
const options = parseDiscordArgs([
|
||||
"--application-id",
|
||||
"app-456",
|
||||
"--bot-token",
|
||||
"other-token",
|
||||
"--public-key",
|
||||
"public-key",
|
||||
"--owner-user-id",
|
||||
"owner-123",
|
||||
]);
|
||||
|
||||
expect(options.applicationId).toBe("app-456");
|
||||
expect(options.botToken).toBe("other-token");
|
||||
expect(options.ownerUserId).toBe("owner-123");
|
||||
expect(options.allowBotAuthors).toBe(true);
|
||||
});
|
||||
|
||||
it("can explicitly ignore bot-authored Discord messages", () => {
|
||||
const options = parseDiscordArgs([
|
||||
"--application-id",
|
||||
"app-456",
|
||||
"--bot-token",
|
||||
"other-token",
|
||||
"--public-key",
|
||||
"public-key",
|
||||
"--ignore-bot-authors",
|
||||
]);
|
||||
|
||||
expect(options.allowBotAuthors).toBe(false);
|
||||
});
|
||||
|
||||
it("builds empty-runtime fallback replies from the current Discord turn", async () => {
|
||||
const priorMessages = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "previous question" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Previous reply." }],
|
||||
},
|
||||
];
|
||||
const currentMessages = [
|
||||
...priorMessages,
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "read README.md" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Summary from saved session." }],
|
||||
},
|
||||
];
|
||||
const client = {
|
||||
readMessages: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(priorMessages)
|
||||
.mockResolvedValueOnce(currentMessages),
|
||||
};
|
||||
|
||||
const resolveFallbackText =
|
||||
await __test__.createDiscordEmptyRuntimeReplyResolver({
|
||||
client: client as never,
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
await expect(resolveFallbackText?.()).resolves.toBe(
|
||||
"Summary from saved session.",
|
||||
);
|
||||
expect(client.readMessages).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not reuse prior Discord replies as empty-runtime fallback text", async () => {
|
||||
const priorMessages = [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "previous question" }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Previous reply." }],
|
||||
},
|
||||
];
|
||||
const currentMessages = [
|
||||
...priorMessages,
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "run ls /tmp" }],
|
||||
},
|
||||
{
|
||||
role: "tool",
|
||||
content: [{ type: "text", text: "tool output" }],
|
||||
},
|
||||
];
|
||||
const client = {
|
||||
readMessages: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(priorMessages)
|
||||
.mockResolvedValueOnce(currentMessages),
|
||||
};
|
||||
|
||||
const resolveFallbackText =
|
||||
await __test__.createDiscordEmptyRuntimeReplyResolver({
|
||||
client: client as never,
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
await expect(resolveFallbackText?.()).resolves.toBeUndefined();
|
||||
expect(client.readMessages).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("resolves Discord participants from normalized gateway message authors", () => {
|
||||
expect(
|
||||
__test__.resolveDiscordParticipant(
|
||||
{
|
||||
content: "<@1509620637721821224> Heyo",
|
||||
author: {
|
||||
id: "bot-message-author-should-not-win",
|
||||
username: "beebot",
|
||||
},
|
||||
},
|
||||
{
|
||||
userId: "850213762576810065",
|
||||
userName: "alice",
|
||||
fullName: "Alice Example",
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
key: "discord:user:850213762576810065",
|
||||
label: "Alice Example",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves Discord interaction users even when raw.data is command data", () => {
|
||||
expect(
|
||||
__test__.resolveDiscordParticipant({
|
||||
id: "interaction-1",
|
||||
data: { name: "ask" },
|
||||
member: {
|
||||
user: {
|
||||
id: "488220547356950529",
|
||||
username: "bob",
|
||||
global_name: "Bob Example",
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual({
|
||||
key: "discord:user:488220547356950529",
|
||||
label: "Bob Example",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates Discord participant metadata without changing the thread session", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "discord-participants-"));
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const thread = createThread({
|
||||
sessionId: "session-alice",
|
||||
participantKey: "discord:user:alice",
|
||||
participantLabel: "Alice",
|
||||
});
|
||||
writeBindings<TestDiscordState>(bindingsPath, {
|
||||
"discord:user:alice": {
|
||||
channelId: thread.channelId,
|
||||
isDM: thread.isDM,
|
||||
participantKey: "discord:user:alice",
|
||||
participantLabel: "Alice",
|
||||
serializedThread: JSON.stringify(thread.toJSON()),
|
||||
sessionId: "session-alice",
|
||||
state: {
|
||||
sessionId: "session-alice",
|
||||
participantKey: "discord:user:alice",
|
||||
participantLabel: "Alice",
|
||||
},
|
||||
updatedAt: "2026-05-26T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
await __test__.persistDiscordThreadContext({
|
||||
thread,
|
||||
bindingsPath,
|
||||
baseStartRequest: {
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
systemPrompt: "system",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
mode: "act",
|
||||
},
|
||||
message: {
|
||||
raw: {
|
||||
author: {
|
||||
id: "bob",
|
||||
username: "bob",
|
||||
global_name: "Bob",
|
||||
},
|
||||
},
|
||||
},
|
||||
errorLabel: "Discord",
|
||||
});
|
||||
|
||||
const binding =
|
||||
readBindings<TestDiscordState>(bindingsPath)[
|
||||
"discord:guild:channel:thread"
|
||||
];
|
||||
expect(binding?.state?.participantKey).toBe("discord:user:bob");
|
||||
expect(binding?.state?.participantLabel).toBe("Bob");
|
||||
expect(binding?.state?.sessionId).toBe("session-alice");
|
||||
expect(
|
||||
readBindings<TestDiscordState>(bindingsPath)["discord:user:alice"]?.state
|
||||
?.sessionId,
|
||||
).toBe("session-alice");
|
||||
});
|
||||
|
||||
it("adds Discord author context to runtime turns", () => {
|
||||
expect(
|
||||
__test__.formatDiscordRuntimeText(
|
||||
"Heyo",
|
||||
{
|
||||
key: "discord:user:850213762576810065",
|
||||
label: "Alice Example",
|
||||
},
|
||||
{
|
||||
ownerUserId: "850213762576810065",
|
||||
isDirectMention: false,
|
||||
isSubscribedThreadMessage: true,
|
||||
},
|
||||
),
|
||||
).toContain("authorId: 850213762576810065");
|
||||
expect(
|
||||
__test__.formatDiscordRuntimeText(
|
||||
"Heyo",
|
||||
{
|
||||
key: "discord:user:850213762576810065",
|
||||
label: "Alice Example",
|
||||
},
|
||||
{ ownerUserId: "850213762576810065" },
|
||||
),
|
||||
).toContain("isOwner: true");
|
||||
expect(
|
||||
__test__.formatDiscordRuntimeText(
|
||||
"Heyo",
|
||||
{
|
||||
key: "discord:user:850213762576810065",
|
||||
label: "Alice Example",
|
||||
},
|
||||
{
|
||||
isDirectMention: false,
|
||||
isSubscribedThreadMessage: true,
|
||||
},
|
||||
),
|
||||
).toContain("isDirectMention: false");
|
||||
expect(
|
||||
__test__.formatDiscordRuntimeText(
|
||||
"Heyo",
|
||||
{
|
||||
key: "discord:user:850213762576810065",
|
||||
label: "Alice Example",
|
||||
},
|
||||
{
|
||||
isDirectMention: false,
|
||||
isSubscribedThreadMessage: true,
|
||||
},
|
||||
),
|
||||
).toContain("isSubscribedThreadMessage: true");
|
||||
});
|
||||
|
||||
it("instructs Discord agents to use /idle for unrelated subscribed thread messages", () => {
|
||||
expect(__test__.DISCORD_SYSTEM_RULES).toContain("reply exactly /idle");
|
||||
expect(__test__.DISCORD_SYSTEM_RULES).toContain("isDirectMention is false");
|
||||
expect(__test__.DISCORD_SYSTEM_RULES).toContain("send /mute@BotName");
|
||||
expect(__test__.DISCORD_SYSTEM_RULES).toContain("send /unmute@BotName");
|
||||
expect(__test__.DISCORD_SYSTEM_RULES).toContain(
|
||||
"/mute@BotName @user-or-bot",
|
||||
);
|
||||
expect(__test__.DISCORD_SYSTEM_RULES).toContain(
|
||||
"/unmute@BotName @user-or-bot",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves Discord mute targets from user mentions and ids", () => {
|
||||
expect(__test__.resolveDiscordMuteTarget("<@123456789012345678>")).toEqual({
|
||||
participantKey: "discord:user:123456789012345678",
|
||||
participantLabel: "<@123456789012345678>",
|
||||
});
|
||||
expect(__test__.resolveDiscordMuteTarget("<@!123456789012345678>")).toEqual(
|
||||
{
|
||||
participantKey: "discord:user:123456789012345678",
|
||||
participantLabel: "<@123456789012345678>",
|
||||
},
|
||||
);
|
||||
expect(__test__.resolveDiscordMuteTarget("@123456789012345678")).toEqual({
|
||||
participantKey: "discord:user:123456789012345678",
|
||||
participantLabel: "<@123456789012345678>",
|
||||
});
|
||||
expect(__test__.resolveDiscordMuteTarget("@not-a-user-id")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves outbound Discord mention names to user mention ids", async () => {
|
||||
const fetchMock = vi.fn(async (url: string | URL) => {
|
||||
expect(String(url)).toContain(
|
||||
"/guilds/guild-123/members/search?query=cline-test-bot&limit=10",
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
nick: "cline-test-bot",
|
||||
user: {
|
||||
id: "1509620637721821224",
|
||||
username: "clinetestbot",
|
||||
bot: true,
|
||||
},
|
||||
},
|
||||
]),
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
__test__.resolveDiscordOutboundMentions({
|
||||
botToken: "token",
|
||||
threadId: "discord:guild-123:channel-123:thread-123",
|
||||
text: "@cline-test-bot how is your day?",
|
||||
}),
|
||||
).resolves.toBe("<@1509620637721821224> how is your day?");
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("repairs adapter-split hyphenated Discord mention names before resolving", async () => {
|
||||
const fetchMock = vi.fn(async (url: string | URL) => {
|
||||
expect(String(url)).toContain("query=cline-test-bot");
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
nick: "cline-test-bot",
|
||||
user: {
|
||||
id: "1509620637721821224",
|
||||
username: "clinetestbot",
|
||||
bot: true,
|
||||
},
|
||||
},
|
||||
]),
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
__test__.resolveDiscordOutboundMentions({
|
||||
botToken: "token",
|
||||
threadId: "discord:guild-123:channel-123:thread-123",
|
||||
text: "<@cline>-test-bot how is your day?",
|
||||
}),
|
||||
).resolves.toBe("<@1509620637721821224> how is your day?");
|
||||
});
|
||||
|
||||
it("does not resolve outbound mentions from non-exact Discord member search results", async () => {
|
||||
const fetchMock = vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
nick: "team-alice-bot",
|
||||
user: {
|
||||
id: "wrong-user",
|
||||
username: "team-alice-bot",
|
||||
bot: true,
|
||||
},
|
||||
},
|
||||
]),
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
__test__.resolveDiscordOutboundMentions({
|
||||
botToken: "token",
|
||||
threadId: "discord:guild-123:channel-123:thread-123",
|
||||
text: "@alice can you check this?",
|
||||
}),
|
||||
).resolves.toBe("@alice can you check this?");
|
||||
});
|
||||
|
||||
it("normalizes forwarded bot-role mentions as Discord mentions", async () => {
|
||||
const fetchMock = vi.fn(async (url: string | URL) => {
|
||||
expect(String(url)).toContain("/guilds/guild-role-test/members/app-123");
|
||||
return new Response(JSON.stringify({ roles: ["role-123"] }), {
|
||||
status: 200,
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const request = new Request("https://example.test/api/webhooks/discord", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "GATEWAY_MESSAGE_CREATE",
|
||||
data: {
|
||||
id: "message-1",
|
||||
guild_id: "guild-role-test",
|
||||
channel_id: "channel-1",
|
||||
content: "<@&role-123> hello",
|
||||
mention_roles: ["role-123"],
|
||||
mentions: [],
|
||||
author: {
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
bot: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const normalized = await __test__.normalizeDiscordForwardedGatewayRequest({
|
||||
request,
|
||||
botToken: "token",
|
||||
applicationId: "app-123",
|
||||
});
|
||||
const event = (await normalized.json()) as {
|
||||
data: { is_mention?: boolean };
|
||||
};
|
||||
|
||||
expect(event.data.is_mention).toBe(true);
|
||||
});
|
||||
|
||||
it("retries bot role lookups after transient Discord API failures", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("temporary", { status: 500 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ roles: ["role-123"] }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const buildRequest = () =>
|
||||
new Request("https://example.test/api/webhooks/discord", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
type: "GATEWAY_MESSAGE_CREATE",
|
||||
data: {
|
||||
id: "message-1",
|
||||
guild_id: "guild-retry-test",
|
||||
channel_id: "channel-1",
|
||||
content: "<@&role-123> hello",
|
||||
mention_roles: ["role-123"],
|
||||
mentions: [],
|
||||
author: {
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
bot: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const failed = await __test__.normalizeDiscordForwardedGatewayRequest({
|
||||
request: buildRequest(),
|
||||
botToken: "token",
|
||||
applicationId: "app-retry",
|
||||
});
|
||||
const failedEvent = (await failed.json()) as {
|
||||
data: { is_mention?: boolean };
|
||||
};
|
||||
expect(failedEvent.data.is_mention).toBeUndefined();
|
||||
|
||||
const retried = await __test__.normalizeDiscordForwardedGatewayRequest({
|
||||
request: buildRequest(),
|
||||
botToken: "token",
|
||||
applicationId: "app-retry",
|
||||
});
|
||||
const retriedEvent = (await retried.json()) as {
|
||||
data: { is_mention?: boolean };
|
||||
};
|
||||
|
||||
expect(retriedEvent.data.is_mention).toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("restores persisted thread subscriptions once on startup", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "discord-bindings-"));
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const subscribe = vi.fn(async () => undefined);
|
||||
const threads = new Map([
|
||||
[
|
||||
"thread-1",
|
||||
{
|
||||
id: "thread-1",
|
||||
subscribe,
|
||||
},
|
||||
],
|
||||
]);
|
||||
const bot = {
|
||||
reviver: () => (_key: string, value: unknown) => {
|
||||
if (
|
||||
value &&
|
||||
typeof value === "object" &&
|
||||
(value as { _type?: string })._type === "chat:Thread"
|
||||
) {
|
||||
return threads.get((value as { id: string }).id) ?? value;
|
||||
}
|
||||
return value;
|
||||
},
|
||||
};
|
||||
const logger = {
|
||||
core: { log: vi.fn() },
|
||||
} as unknown as Parameters<
|
||||
typeof __test__.restoreDiscordThreadSubscriptions
|
||||
>[0]["logger"];
|
||||
|
||||
writeFileSync(
|
||||
bindingsPath,
|
||||
JSON.stringify({
|
||||
"discord:user:1": {
|
||||
channelId: "discord:g:c",
|
||||
isDM: false,
|
||||
participantKey: "discord:user:1",
|
||||
serializedThread: JSON.stringify({
|
||||
_type: "chat:Thread",
|
||||
id: "thread-1",
|
||||
}),
|
||||
updatedAt: "2026-05-26T00:00:00.000Z",
|
||||
},
|
||||
duplicate: {
|
||||
channelId: "discord:g:c",
|
||||
isDM: false,
|
||||
serializedThread: JSON.stringify({
|
||||
_type: "chat:Thread",
|
||||
id: "thread-1",
|
||||
}),
|
||||
updatedAt: "2026-05-26T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const restored = await __test__.restoreDiscordThreadSubscriptions({
|
||||
bot,
|
||||
bindingsPath,
|
||||
logger,
|
||||
});
|
||||
|
||||
expect(restored).toBe(1);
|
||||
expect(subscribe).toHaveBeenCalledTimes(1);
|
||||
expect(logger.core.log).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,394 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ConnectTelegramOptions } from "@cline/shared";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { __test__, telegramConnector } from "./telegram";
|
||||
|
||||
const parseTelegramArgs = (rawArgs: string[]): ConnectTelegramOptions =>
|
||||
(
|
||||
telegramConnector as unknown as {
|
||||
parseArgs(rawArgs: string[]): ConnectTelegramOptions;
|
||||
}
|
||||
).parseArgs(rawArgs);
|
||||
|
||||
const originalClineDataDir = process.env.CLINE_DATA_DIR;
|
||||
const tempDataDirs: string[] = [];
|
||||
|
||||
function useTempClineDataDir(): string {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "cline-telegram-test-"));
|
||||
tempDataDirs.push(dataDir);
|
||||
process.env.CLINE_DATA_DIR = dataDir;
|
||||
return dataDir;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
if (originalClineDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalClineDataDir;
|
||||
}
|
||||
for (const dir of tempDataDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("telegramConnector", () => {
|
||||
it("honors --no-tools", () => {
|
||||
const options = parseTelegramArgs([
|
||||
"--bot-username",
|
||||
"test_bot",
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--no-tools",
|
||||
]);
|
||||
|
||||
expect(options.enableTools).toBe(false);
|
||||
});
|
||||
|
||||
it("enables tools by default", () => {
|
||||
const options = parseTelegramArgs([
|
||||
"--bot-username",
|
||||
"test_bot",
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
]);
|
||||
|
||||
expect(options.enableTools).toBe(true);
|
||||
});
|
||||
|
||||
it("builds an authorization hook from --allowed-user-id", () => {
|
||||
const options = parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--allowed-user-id",
|
||||
"1201547643",
|
||||
]);
|
||||
|
||||
expect(options.hookCommand).toBe(
|
||||
`jq -r ".payload.actor.participantKey" | grep -qx "telegram:id:1201547643" && echo '{"action":"allow"}' || echo '{"action":"deny","message":"unauthorized","reason":"not_on_allowlist"}'`,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unsafe --allowed-user-id values", () => {
|
||||
expect(() =>
|
||||
parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--allowed-user-id",
|
||||
"123; rm -rf /",
|
||||
]),
|
||||
).toThrow("digits only");
|
||||
});
|
||||
|
||||
it("rejects mixing --allowed-user-id with --hook-command", () => {
|
||||
expect(() =>
|
||||
parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--allowed-user-id",
|
||||
"1201547643",
|
||||
"--hook-command",
|
||||
"echo noop",
|
||||
]),
|
||||
).toThrow("either --allowed-user-id or --hook-command");
|
||||
});
|
||||
|
||||
it("rejects mixing --allowed-user-id with the hook command env var", () => {
|
||||
const originalHookCommand = process.env.CLINE_CONNECT_HOOK_COMMAND;
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND = "echo noop";
|
||||
try {
|
||||
expect(() =>
|
||||
parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
"--allowed-user-id",
|
||||
"1201547643",
|
||||
]),
|
||||
).toThrow("either --allowed-user-id or --hook-command");
|
||||
} finally {
|
||||
if (originalHookCommand === undefined) {
|
||||
delete process.env.CLINE_CONNECT_HOOK_COMMAND;
|
||||
} else {
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND = originalHookCommand;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("does not require the bot username", () => {
|
||||
const options = parseTelegramArgs([
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
]);
|
||||
|
||||
expect(options.botUsername).toBeUndefined();
|
||||
expect(options.botToken).toBe("123:test");
|
||||
});
|
||||
|
||||
it("normalizes an explicit bot username", () => {
|
||||
const options = parseTelegramArgs([
|
||||
"--bot-username",
|
||||
" @test_bot ",
|
||||
"--bot-token",
|
||||
"123:test",
|
||||
"--cwd",
|
||||
"/tmp/work",
|
||||
]);
|
||||
|
||||
expect(options.botUsername).toBe("test_bot");
|
||||
});
|
||||
|
||||
it("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 });
|
||||
writeFileSync(
|
||||
join(connectorDir, "resolved_bot.json"),
|
||||
JSON.stringify({
|
||||
botUsername: "resolved_bot",
|
||||
botId: "123",
|
||||
pid: process.pid,
|
||||
rpcAddress: "127.0.0.1:54321",
|
||||
startedAt: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
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),
|
||||
}),
|
||||
).resolves.toBe(0);
|
||||
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
expect(errors).toEqual([]);
|
||||
expect(output).toEqual([
|
||||
`[telegram] connector already running pid=${process.pid} rpc=127.0.0.1:54321`,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram bot username resolution", () => {
|
||||
it("reads the public Telegram bot id from a token", () => {
|
||||
expect(__test__.readTelegramBotId("123456:secret")).toBe("123456");
|
||||
expect(__test__.readTelegramBotId("not-a-token")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses the configured username without calling Telegram", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
throw new Error("unexpected fetch");
|
||||
});
|
||||
|
||||
await expect(
|
||||
__test__.resolveTelegramBotUsername(
|
||||
{
|
||||
botToken: "123:test",
|
||||
botUsername: "@configured_bot",
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
interactive: true,
|
||||
enableTools: true,
|
||||
rpcAddress: "127.0.0.1:0",
|
||||
},
|
||||
fetchImpl,
|
||||
),
|
||||
).resolves.toBe("configured_bot");
|
||||
expect(fetchImpl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches the username from Telegram getMe when omitted", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
result: { username: "resolved_bot" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
__test__.fetchTelegramBotUsername("123:test", fetchImpl),
|
||||
).resolves.toBe("resolved_bot");
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://api.telegram.org/bot123:test/getMe",
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces Telegram getMe failures", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
ok: false,
|
||||
description: "Unauthorized",
|
||||
}),
|
||||
{ status: 401, statusText: "Unauthorized" },
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
__test__.fetchTelegramBotUsername("bad-token", fetchImpl),
|
||||
).rejects.toThrow("Telegram getMe failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram participant resolution", () => {
|
||||
it("uses the stable numeric Telegram user id when username is also present", () => {
|
||||
const result = __test__.resolveTelegramParticipant({
|
||||
message: {
|
||||
from: {
|
||||
id: 1201547643,
|
||||
username: "AraFatKatze",
|
||||
first_name: "Ara",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "telegram:id:1201547643",
|
||||
label: "arafatkatze",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to username when Telegram does not provide a numeric user id", () => {
|
||||
const result = __test__.resolveTelegramParticipant({
|
||||
message: {
|
||||
from: {
|
||||
username: "Alice",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "telegram:user:alice",
|
||||
label: "alice",
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts string numeric user ids from raw Telegram payloads", () => {
|
||||
const result = __test__.resolveTelegramParticipant({
|
||||
message: {
|
||||
from: {
|
||||
id: "1201547643",
|
||||
username: "arafatkatze",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(result?.key).toBe("telegram:id:1201547643");
|
||||
});
|
||||
});
|
||||
|
||||
describe("telegram binding lookup", () => {
|
||||
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
legacy_thread_id: {
|
||||
channelId: "chat-123",
|
||||
isDM: true,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", cwd: "/tmp/work" },
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "new_thread_id",
|
||||
channelId: "chat-123",
|
||||
isDM: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
key: "legacy_thread_id",
|
||||
binding: {
|
||||
channelId: "chat-123",
|
||||
isDM: true,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", cwd: "/tmp/work" },
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers an exact thread id match over a channel fallback", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
current_thread_id: {
|
||||
channelId: "chat-123",
|
||||
isDM: true,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-2",
|
||||
state: { sessionId: "sess-2" },
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
legacy_thread_id: {
|
||||
channelId: "chat-123",
|
||||
isDM: true,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1" },
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "current_thread_id",
|
||||
channelId: "chat-123",
|
||||
isDM: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result?.key).toBe("current_thread_id");
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("does not reuse a binding by participant key across different chats", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
"telegram:user:alice": {
|
||||
channelId: "chat-123",
|
||||
isDM: true,
|
||||
participantKey: "telegram:user:alice",
|
||||
participantLabel: "alice",
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: {
|
||||
sessionId: "sess-1",
|
||||
participantKey: "telegram:user:alice",
|
||||
participantLabel: "alice",
|
||||
},
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "new_thread_id",
|
||||
channelId: "chat-999",
|
||||
isDM: true,
|
||||
participantKey: "telegram:user:alice",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { ConnectorCatalogEntry } from "@cline/shared";
|
||||
export { CONNECTOR_CATALOG, listConnectorCatalog } from "@cline/shared";
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getConnector, listConnectors } from "./registry";
|
||||
|
||||
describe("connector registry", () => {
|
||||
it("registers the Discord connector", async () => {
|
||||
expect(listConnectors().map((connector) => connector.name)).toContain(
|
||||
"discord",
|
||||
);
|
||||
|
||||
await expect(getConnector("discord")).resolves.toMatchObject({
|
||||
name: "discord",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,163 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockGetLastUsedProviderSettings,
|
||||
mockGetProviderSettings,
|
||||
mockResolveSystemPrompt,
|
||||
mockGetProviderCollection,
|
||||
mockGetBooleanFlagEnabled,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetLastUsedProviderSettings: vi.fn(),
|
||||
mockGetProviderSettings: vi.fn(),
|
||||
mockResolveSystemPrompt: vi.fn(),
|
||||
mockGetProviderCollection: vi.fn(),
|
||||
mockGetBooleanFlagEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings(options?: unknown) {
|
||||
return mockGetLastUsedProviderSettings(options);
|
||||
}
|
||||
|
||||
getProviderSettings(providerId: string) {
|
||||
return mockGetProviderSettings(providerId);
|
||||
}
|
||||
},
|
||||
CoreSessionService: class {},
|
||||
SqliteSessionStore: class {},
|
||||
Llms: {
|
||||
...actual.Llms,
|
||||
getProviderCollection: mockGetProviderCollection,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../runtime/prompt", () => ({
|
||||
resolveSystemPrompt: mockResolveSystemPrompt,
|
||||
}));
|
||||
|
||||
vi.mock("../utils/helpers", () => ({
|
||||
resolveWorkspaceRoot: vi.fn((cwd: string) => cwd),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: mockGetBooleanFlagEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../commands/auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../commands/auth")>(
|
||||
"../commands/auth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureOAuthProviderApiKey: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { buildConnectorStartRequest } from "./session-runtime";
|
||||
|
||||
describe("buildConnectorStartRequest", () => {
|
||||
beforeEach(() => {
|
||||
mockGetBooleanFlagEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
});
|
||||
|
||||
it("falls back to provider env vars when persisted settings have no api key", async () => {
|
||||
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "openrouter" });
|
||||
mockGetProviderSettings.mockReturnValue({
|
||||
provider: "openrouter",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
});
|
||||
mockGetProviderCollection.mockReturnValue({
|
||||
provider: { env: ["OPENROUTER_API_KEY"] },
|
||||
});
|
||||
mockResolveSystemPrompt.mockResolvedValue("system");
|
||||
process.env.OPENROUTER_API_KEY = "env-openrouter-key";
|
||||
|
||||
const request = await buildConnectorStartRequest({
|
||||
options: {
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
},
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("openrouter");
|
||||
expect(request.apiKey).toBe("env-openrouter-key");
|
||||
expect(request.model).toBe("anthropic/claude-sonnet-4.6");
|
||||
expect(mockGetLastUsedProviderSettings).toHaveBeenCalledWith({
|
||||
isClinePassEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
|
||||
mockGetProviderSettings.mockReturnValue({
|
||||
provider: "cline-pass",
|
||||
auth: { accessToken: "workos:resolved-token" },
|
||||
});
|
||||
mockGetProviderCollection.mockReturnValue({
|
||||
provider: { env: ["CLINE_API_KEY"] },
|
||||
});
|
||||
mockResolveSystemPrompt.mockResolvedValue("system");
|
||||
|
||||
const request = await buildConnectorStartRequest({
|
||||
options: {
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
},
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
|
||||
mockGetProviderSettings.mockReturnValue({
|
||||
provider: "cline-pass",
|
||||
auth: { accessToken: "workos:resolved-token" },
|
||||
});
|
||||
mockGetProviderCollection.mockReturnValue({
|
||||
provider: { env: ["CLINE_API_KEY"] },
|
||||
});
|
||||
mockResolveSystemPrompt.mockResolvedValue("system");
|
||||
|
||||
const request = await buildConnectorStartRequest({
|
||||
options: {
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
},
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
});
|
||||
});
|
||||
@@ -1,196 +0,0 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
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" | "pid" | "hubUrl"
|
||||
>;
|
||||
|
||||
const connectorFieldExtractors: Record<
|
||||
ConnectorFieldKey,
|
||||
(p: Record<string, unknown>) => string | number | 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 connectorConfigs: Record<
|
||||
string,
|
||||
{ required: ConnectorFieldKey[]; optional: ConnectorFieldKey[] }
|
||||
> = {
|
||||
discord: {
|
||||
required: ["userName", "applicationId"],
|
||||
optional: ["startedAt", "port", "baseUrl"],
|
||||
},
|
||||
telegram: { required: ["botUsername"], optional: ["startedAt"] },
|
||||
gchat: { required: ["userName"], optional: ["startedAt", "port", "baseUrl"] },
|
||||
linear: {
|
||||
required: ["userName"],
|
||||
optional: ["startedAt", "port", "baseUrl"],
|
||||
},
|
||||
slack: {
|
||||
required: ["userName"],
|
||||
optional: ["startedAt", "connectionMode", "port", "baseUrl"],
|
||||
},
|
||||
whatsapp: {
|
||||
required: ["userName"],
|
||||
optional: ["startedAt", "phoneNumberId", "port", "baseUrl"],
|
||||
},
|
||||
};
|
||||
|
||||
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: ActiveConnectorRecord["type"],
|
||||
statePath: string,
|
||||
): ActiveConnectorRecord | undefined {
|
||||
const parsed = readJsonRecord(statePath);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
const pid = typeof parsed.pid === "number" ? parsed.pid : undefined;
|
||||
const hubUrl =
|
||||
typeof parsed.hubUrl === "string"
|
||||
? parsed.hubUrl
|
||||
: typeof parsed.rpcAddress === "string"
|
||||
? parsed.rpcAddress
|
||||
: undefined;
|
||||
if (!pid || !hubUrl || !isProcessRunning(pid)) {
|
||||
return undefined;
|
||||
}
|
||||
const config = connectorConfigs[type];
|
||||
if (!config) {
|
||||
return undefined;
|
||||
}
|
||||
const fields: Partial<
|
||||
Omit<ActiveConnectorRecord, "id" | "type" | "pid" | "hubUrl">
|
||||
> = {};
|
||||
for (const key of config.required) {
|
||||
const value = connectorFieldExtractors[key](parsed);
|
||||
if (!value || (typeof value === "string" && !value.trim())) {
|
||||
return undefined;
|
||||
}
|
||||
(fields as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
for (const key of config.optional) {
|
||||
const value = connectorFieldExtractors[key](parsed);
|
||||
if (value !== undefined) {
|
||||
(fields as Record<string, unknown>)[key] = value;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: connectorRecordId(type, fields, pid),
|
||||
type,
|
||||
pid,
|
||||
hubUrl,
|
||||
...fields,
|
||||
} as ActiveConnectorRecord;
|
||||
}
|
||||
|
||||
export function listActiveConnectors(): ActiveConnectorRecord[] {
|
||||
const connectorTypes: ActiveConnectorRecord["type"][] = [
|
||||
"discord",
|
||||
"telegram",
|
||||
"gchat",
|
||||
"linear",
|
||||
"slack",
|
||||
"whatsapp",
|
||||
];
|
||||
const records: ActiveConnectorRecord[] = [];
|
||||
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);
|
||||
}
|
||||
const leftName = left.botUsername ?? left.userName ?? "";
|
||||
const rightName = right.botUsername ?? right.userName ?? "";
|
||||
return leftName.localeCompare(rightName);
|
||||
});
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { Thread } from "chat";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForDeliveryTarget,
|
||||
isParticipantMuted,
|
||||
isThreadMuted,
|
||||
readBindingForThread,
|
||||
readBindings,
|
||||
setParticipantMuted,
|
||||
setThreadMuted,
|
||||
writeBindings,
|
||||
} from "./thread-bindings";
|
||||
|
||||
type TestState = ConnectorThreadState & {
|
||||
teamId?: string;
|
||||
};
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createBindingsPath(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "thread-bindings-"));
|
||||
tempDirs.push(dir);
|
||||
return join(dir, "bindings.json");
|
||||
}
|
||||
|
||||
function createThread(input: {
|
||||
id: string;
|
||||
channelId: string;
|
||||
isDM: boolean;
|
||||
participantKey?: string;
|
||||
}): Thread<TestState> {
|
||||
return {
|
||||
id: input.id,
|
||||
channelId: input.channelId,
|
||||
isDM: input.isDM,
|
||||
toJSON: () => ({
|
||||
id: input.id,
|
||||
channelId: input.channelId,
|
||||
isDM: input.isDM,
|
||||
}),
|
||||
} as unknown as Thread<TestState>;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("thread binding refresh", () => {
|
||||
it("refreshes the serialized thread immediately when DM channel fallback rebinds a thread id", () => {
|
||||
const path = createBindingsPath();
|
||||
writeBindings<TestState>(path, {
|
||||
legacy_thread_id: {
|
||||
channelId: "slack:C123",
|
||||
isDM: true,
|
||||
serializedThread: JSON.stringify({
|
||||
id: "legacy_thread_id",
|
||||
channelId: "slack:C123",
|
||||
isDM: true,
|
||||
}),
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", teamId: "T123" },
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const binding = readBindingForThread<TestState>(
|
||||
path,
|
||||
createThread({
|
||||
id: "new_thread_id",
|
||||
channelId: "slack:C123",
|
||||
isDM: true,
|
||||
}),
|
||||
"Slack",
|
||||
);
|
||||
|
||||
expect(binding?.serializedThread).toContain("new_thread_id");
|
||||
const bindings = readBindings<TestState>(path);
|
||||
expect(bindings.legacy_thread_id).toBeUndefined();
|
||||
expect(bindings.new_thread_id?.serializedThread).toContain("new_thread_id");
|
||||
});
|
||||
|
||||
it("does not rebind a different thread by participant key", () => {
|
||||
const path = createBindingsPath();
|
||||
const participantKey = "slack:team:T123:user:U123";
|
||||
writeBindings<TestState>(path, {
|
||||
[participantKey]: {
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
participantKey,
|
||||
serializedThread: JSON.stringify({
|
||||
id: "legacy_thread_id",
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
}),
|
||||
sessionId: "sess-1",
|
||||
state: {
|
||||
sessionId: "sess-1",
|
||||
teamId: "T123",
|
||||
participantKey,
|
||||
},
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const binding = readBindingForThread<TestState>(
|
||||
path,
|
||||
createThread({
|
||||
id: "new_thread_id",
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
}),
|
||||
"Slack",
|
||||
participantKey,
|
||||
);
|
||||
|
||||
expect(binding).toBeUndefined();
|
||||
expect(
|
||||
readBindings<TestState>(path)[participantKey]?.serializedThread,
|
||||
).toContain("legacy_thread_id");
|
||||
});
|
||||
|
||||
it("resolves schedule delivery targets by exact binding key before participant metadata", () => {
|
||||
const path = createBindingsPath();
|
||||
writeBindings<TestState>(path, {
|
||||
"slack:C123:111.222": {
|
||||
kind: "conversation",
|
||||
channelId: "slack:C123",
|
||||
isDM: false,
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-thread",
|
||||
state: {
|
||||
sessionId: "sess-thread",
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
},
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const match = findBindingForDeliveryTarget<TestState>(
|
||||
readBindings<TestState>(path),
|
||||
{
|
||||
bindingKey: "slack:C123:111.222",
|
||||
threadId: "slack:C123:111.222",
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
},
|
||||
);
|
||||
|
||||
expect(match?.key).toBe("slack:C123:111.222");
|
||||
expect(match?.binding.sessionId).toBe("sess-thread");
|
||||
});
|
||||
|
||||
it("resolves schedule delivery targets by participant key when no exact thread binding exists", () => {
|
||||
const path = createBindingsPath();
|
||||
writeBindings<TestState>(path, {
|
||||
"slack:team:T123:user:U123": {
|
||||
channelId: "slack:C123",
|
||||
isDM: true,
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-participant",
|
||||
state: {
|
||||
sessionId: "sess-participant",
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
},
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
const match = findBindingForDeliveryTarget<TestState>(
|
||||
readBindings<TestState>(path),
|
||||
{
|
||||
participantKey: "slack:team:T123:user:U123",
|
||||
},
|
||||
);
|
||||
|
||||
expect(match?.key).toBe("slack:team:T123:user:U123");
|
||||
expect(match?.binding.sessionId).toBe("sess-participant");
|
||||
});
|
||||
|
||||
it("stores mute state at thread scope instead of participant scope", () => {
|
||||
const path = createBindingsPath();
|
||||
const thread = createThread({
|
||||
id: "thread-1",
|
||||
channelId: "discord:guild:channel",
|
||||
isDM: false,
|
||||
participantKey: "discord:user:alice",
|
||||
});
|
||||
|
||||
setThreadMuted(path, thread, true, "Discord");
|
||||
|
||||
expect(
|
||||
isThreadMuted(
|
||||
path,
|
||||
createThread({
|
||||
id: "thread-1",
|
||||
channelId: "discord:guild:channel",
|
||||
isDM: false,
|
||||
participantKey: "discord:user:bob",
|
||||
}),
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const binding = readBindingForThread<TestState>(
|
||||
path,
|
||||
thread,
|
||||
"Discord",
|
||||
"discord:user:alice",
|
||||
);
|
||||
expect(binding).toBeUndefined();
|
||||
|
||||
setThreadMuted(path, thread, false, "Discord");
|
||||
|
||||
expect(isThreadMuted(path, thread)).toBe(false);
|
||||
});
|
||||
|
||||
it("stores participant mute state scoped to the current thread", () => {
|
||||
const path = createBindingsPath();
|
||||
const thread = createThread({
|
||||
id: "thread-1",
|
||||
channelId: "discord:guild:channel",
|
||||
isDM: false,
|
||||
});
|
||||
const otherThread = createThread({
|
||||
id: "thread-2",
|
||||
channelId: "discord:guild:channel",
|
||||
isDM: false,
|
||||
});
|
||||
|
||||
setParticipantMuted(
|
||||
path,
|
||||
thread,
|
||||
{
|
||||
participantKey: "discord:user:bob",
|
||||
participantLabel: "Bob",
|
||||
},
|
||||
true,
|
||||
"Discord",
|
||||
);
|
||||
|
||||
expect(isParticipantMuted(path, thread, "discord:user:bob")).toBe(true);
|
||||
expect(isParticipantMuted(path, thread, "discord:user:alice")).toBe(false);
|
||||
expect(isParticipantMuted(path, otherThread, "discord:user:bob")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
readBindingForThread<TestState>(
|
||||
path,
|
||||
thread,
|
||||
"Discord",
|
||||
"discord:user:bob",
|
||||
),
|
||||
).toBeUndefined();
|
||||
|
||||
setParticipantMuted(
|
||||
path,
|
||||
thread,
|
||||
{ participantKey: "discord:user:bob" },
|
||||
false,
|
||||
"Discord",
|
||||
);
|
||||
|
||||
expect(isParticipantMuted(path, thread, "discord:user:bob")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearBindingSessionIds", () => {
|
||||
it("clears session ids from bindings and serialized thread state", () => {
|
||||
const path = createBindingsPath();
|
||||
writeBindings<TestState>(path, {
|
||||
thread_1: {
|
||||
channelId: "discord:C123",
|
||||
isDM: false,
|
||||
serializedThread: JSON.stringify({
|
||||
id: "thread_1",
|
||||
channelId: "discord:C123",
|
||||
isDM: false,
|
||||
sessionId: "legacy-root-session",
|
||||
state: {
|
||||
sessionId: "sess-1",
|
||||
cwd: "/tmp/work",
|
||||
teamId: "T123",
|
||||
},
|
||||
}),
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", cwd: "/tmp/work", teamId: "T123" },
|
||||
updatedAt: "2026-03-17T00:00:00.000Z",
|
||||
},
|
||||
});
|
||||
|
||||
clearBindingSessionIds<TestState>(path);
|
||||
|
||||
const binding = readBindings<TestState>(path).thread_1;
|
||||
expect(binding?.sessionId).toBeUndefined();
|
||||
expect(binding?.state?.sessionId).toBeUndefined();
|
||||
expect(binding?.state?.cwd).toBe("/tmp/work");
|
||||
const serializedThread = JSON.parse(binding?.serializedThread ?? "{}") as {
|
||||
sessionId?: string;
|
||||
state?: TestState;
|
||||
};
|
||||
expect(serializedThread.sessionId).toBeUndefined();
|
||||
expect(serializedThread.state?.sessionId).toBeUndefined();
|
||||
expect(serializedThread.state?.cwd).toBe("/tmp/work");
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import open from "open";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { palette } from "../tui/palette";
|
||||
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
|
||||
import type { CliMigrationNotice } from "./notice";
|
||||
|
||||
export function MigrationNoticeContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
notice: CliMigrationNotice;
|
||||
},
|
||||
) {
|
||||
const { dialogId, notice, resolve } = props;
|
||||
const subscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
|
||||
const openSubscriptionPage = useCallback(() => {
|
||||
setStatus("Opening ClinePass in your browser...");
|
||||
void open(subscriptionUrl, { wait: false })
|
||||
.then(() => {
|
||||
setStatus("Opened ClinePass in your browser.");
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus(
|
||||
"Could not open the browser automatically. Use the URL below.",
|
||||
);
|
||||
});
|
||||
}, [subscriptionUrl]);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
openSubscriptionPage();
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>{notice.title}</text>
|
||||
<box flexDirection="column">
|
||||
<text selectable>
|
||||
ClinePass is a $9.99/month subscription plan to get access to the
|
||||
latest open-weight coding models with enough quota for day-to-day
|
||||
work, at a much lower cost than paying API costs directly.
|
||||
</text>
|
||||
<text selectable>Try it now with a limited-time promo for $1.99.</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<box paddingX={1} backgroundColor={palette.act}>
|
||||
<text fg={palette.textOnSelection}>Open ClinePass</text>
|
||||
</box>
|
||||
</box>
|
||||
{status && <text fg={palette.muted}>{status}</text>}
|
||||
<text fg={palette.muted}>Press Enter to open, Esc to close</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,407 +0,0 @@
|
||||
import type {
|
||||
AgentEvent,
|
||||
ProviderSettingsManager,
|
||||
TeamEvent,
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import { SessionNotFoundError } from "@cline/core";
|
||||
import type { AgentTool, Message } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatCommandState } from "../../utils/chat-commands";
|
||||
import type { Config } from "../../utils/types";
|
||||
|
||||
const {
|
||||
mockCreateCliCore,
|
||||
mockCreateRuntimeHooks,
|
||||
mockLoadInteractiveResumeMessages,
|
||||
mockSetActiveCliSession,
|
||||
} = vi.hoisted(() => ({
|
||||
mockCreateCliCore: vi.fn(),
|
||||
mockCreateRuntimeHooks: vi.fn(),
|
||||
mockLoadInteractiveResumeMessages: vi.fn(),
|
||||
mockSetActiveCliSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../session/session", () => ({
|
||||
createCliCore: mockCreateCliCore,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/hooks", () => ({
|
||||
createRuntimeHooks: mockCreateRuntimeHooks,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/output", () => ({
|
||||
setActiveCliSession: mockSetActiveCliSession,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/resume", () => ({
|
||||
loadInteractiveResumeMessages: mockLoadInteractiveResumeMessages,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/approval", () => ({
|
||||
submitAndExitInTerminal: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../active-runtime", () => ({
|
||||
markAbortInProgress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../session-events", () => ({
|
||||
subscribeToAgentEvents: vi.fn(() => vi.fn()),
|
||||
subscribeToPendingPromptEvents: vi.fn(() => vi.fn()),
|
||||
}));
|
||||
|
||||
import { createInteractiveSessionRuntime } from "./session-runtime";
|
||||
|
||||
function makeConfig(): Config {
|
||||
return {
|
||||
apiKey: "",
|
||||
providerId: "cline",
|
||||
modelId: "openai/gpt-5.3-codex",
|
||||
verbose: false,
|
||||
sandbox: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
mode: "act",
|
||||
systemPrompt: "",
|
||||
enableTools: true,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: false,
|
||||
defaultToolAutoApprove: false,
|
||||
toolPolicies: {},
|
||||
cwd: "/tmp/work",
|
||||
workspaceRoot: "/tmp/work",
|
||||
};
|
||||
}
|
||||
|
||||
function makeChatCommandState(config: Config): ChatCommandState {
|
||||
return {
|
||||
enableTools: config.enableTools,
|
||||
autoApproveTools: config.defaultToolAutoApprove,
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot?.trim() || config.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSwitchToActModeTool(): AgentTool {
|
||||
return {
|
||||
name: "switch_to_act_mode",
|
||||
description: "Switch to act mode",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
execute: () => ({ ok: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function makeManager() {
|
||||
let startCount = 0;
|
||||
const start = vi.fn(async (_input?: unknown) => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: {
|
||||
session_id: sessionId,
|
||||
},
|
||||
};
|
||||
});
|
||||
return {
|
||||
start,
|
||||
stop: vi.fn(async () => {}),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
abort: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
get: vi.fn(),
|
||||
readMessages: vi.fn(async (): Promise<Message[]> => []),
|
||||
readTranscript: vi.fn(),
|
||||
ingestHookEvent: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
updateSessionModel: vi.fn(),
|
||||
pendingPrompts: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
restore: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeTurnResult() {
|
||||
return {
|
||||
text: "ok",
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "completed" as const,
|
||||
model: { id: "openai/gpt-5.3-codex", provider: "cline" },
|
||||
startedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endedAt: new Date("2026-01-01T00:00:00.100Z"),
|
||||
durationMs: 100,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function makeRuntime(
|
||||
manager: ReturnType<typeof makeManager>,
|
||||
options: {
|
||||
resumeSessionId?: string;
|
||||
resolveToolPolicy?: (toolName: string) => Config["toolPolicies"][string];
|
||||
} = {},
|
||||
) {
|
||||
mockCreateCliCore.mockResolvedValue(manager);
|
||||
const config = makeConfig();
|
||||
return createInteractiveSessionRuntime({
|
||||
config,
|
||||
providerSettingsManager: {} as ProviderSettingsManager,
|
||||
resumeSessionId: options.resumeSessionId,
|
||||
chatCommandState: makeChatCommandState(config),
|
||||
requestToolApproval: async (
|
||||
_request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> => ({ approved: true }),
|
||||
resolveToolPolicy:
|
||||
options.resolveToolPolicy ?? (() => ({ autoApprove: true })),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: makeSwitchToActModeTool(),
|
||||
onAgentEvent: (_event: AgentEvent) => {},
|
||||
onTeamEvent: (_event: TeamEvent) => {},
|
||||
onPendingPrompts: () => {},
|
||||
onPendingPromptSubmitted: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("createInteractiveSessionRuntime", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockCreateRuntimeHooks.mockReturnValue({
|
||||
hooks: undefined,
|
||||
shutdown: vi.fn(async () => {}),
|
||||
});
|
||||
mockLoadInteractiveResumeMessages.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("defers creating the replacement session after a new-session reset", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
|
||||
await runtime.resetForNewSession();
|
||||
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
expect(runtime.getActiveSessionId()).toBe("");
|
||||
expect(mockSetActiveCliSession).toHaveBeenLastCalledWith(undefined);
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("adds a live interactive approval policy hook to started sessions", async () => {
|
||||
const manager = makeManager();
|
||||
const upstreamBeforeTool = vi.fn(async () => ({
|
||||
input: { text: "updated" },
|
||||
}));
|
||||
mockCreateRuntimeHooks.mockReturnValueOnce({
|
||||
hooks: {
|
||||
beforeTool: upstreamBeforeTool,
|
||||
},
|
||||
shutdown: vi.fn(async () => {}),
|
||||
});
|
||||
const runtime = makeRuntime(manager, {
|
||||
resolveToolPolicy: (toolName) => ({
|
||||
autoApprove: toolName === "echo",
|
||||
}),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
const startInput = manager.start.mock.calls[0]?.[0] as
|
||||
| { config?: Config }
|
||||
| undefined;
|
||||
const beforeTool = startInput?.config?.hooks?.beforeTool;
|
||||
expect(beforeTool).toBeTypeOf("function");
|
||||
|
||||
const result = await beforeTool?.({
|
||||
snapshot: {
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
status: "running",
|
||||
iteration: 1,
|
||||
messages: [],
|
||||
pendingToolCalls: [],
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
name: "echo",
|
||||
description: "",
|
||||
inputSchema: {},
|
||||
execute: async () => "ok",
|
||||
},
|
||||
toolCall: {
|
||||
type: "tool-call",
|
||||
toolCallId: "call-1",
|
||||
toolName: "echo",
|
||||
input: { text: "original" },
|
||||
},
|
||||
input: { text: "original" },
|
||||
});
|
||||
|
||||
expect(upstreamBeforeTool).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({
|
||||
input: { text: "updated" },
|
||||
policy: { autoApprove: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("starts fresh after resetting an initially resumed session", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager, {
|
||||
resumeSessionId: "resumed-session",
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
manager,
|
||||
"resumed-session",
|
||||
);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
sessionId: "resumed-session",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await runtime.resetForNewSession();
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(mockLoadInteractiveResumeMessages).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
manager,
|
||||
undefined,
|
||||
);
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
config: expect.not.objectContaining({
|
||||
sessionId: "resumed-session",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps explicit empty restarts eager for config-driven restarts", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartEmpty();
|
||||
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("recovers and retries when the active interactive session disappeared", async () => {
|
||||
const manager = makeManager();
|
||||
const messages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: "hi" }],
|
||||
},
|
||||
];
|
||||
manager.readMessages.mockResolvedValue(messages);
|
||||
manager.send
|
||||
.mockRejectedValueOnce(new SessionNotFoundError("session-1"))
|
||||
.mockResolvedValueOnce(makeTurnResult());
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const result = await runtime.sendCurrentTurn({
|
||||
prompt: "second hi",
|
||||
mode: "act",
|
||||
});
|
||||
|
||||
expect(result?.finishReason).toBe("completed");
|
||||
expect(manager.readMessages).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
initialMessages: messages,
|
||||
}),
|
||||
);
|
||||
expect(manager.send).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
expect(manager.send).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ sessionId: "session-2" }),
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("waits for missing-session recovery before cleanup disposes the manager", async () => {
|
||||
const manager = makeManager();
|
||||
const recoveryRead = deferred<Message[]>();
|
||||
manager.readMessages
|
||||
.mockImplementationOnce(() => recoveryRead.promise)
|
||||
.mockResolvedValue([]);
|
||||
manager.get.mockResolvedValue(undefined);
|
||||
manager.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
manager.send.mockRejectedValueOnce(new SessionNotFoundError("session-1"));
|
||||
const runtime = makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const sendPromise = runtime
|
||||
.sendCurrentTurn({
|
||||
prompt: "second hi",
|
||||
mode: "act",
|
||||
})
|
||||
.catch((error) => error);
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.readMessages).toHaveBeenCalledWith("session-1");
|
||||
});
|
||||
|
||||
let cleanupSettled = false;
|
||||
const cleanupPromise = runtime.cleanup().finally(() => {
|
||||
cleanupSettled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(cleanupSettled).toBe(false);
|
||||
expect(manager.get).not.toHaveBeenCalled();
|
||||
expect(manager.dispose).not.toHaveBeenCalled();
|
||||
|
||||
recoveryRead.resolve([]);
|
||||
await cleanupPromise;
|
||||
const sendError = await sendPromise;
|
||||
|
||||
expect(sendError).toBeInstanceOf(SessionNotFoundError);
|
||||
expect(manager.dispose).toHaveBeenCalledWith("cli_interactive_shutdown");
|
||||
});
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveReasoningForModelChange } from "./run-interactive";
|
||||
|
||||
describe("resolveReasoningForModelChange", () => {
|
||||
it("persists disabled reasoning only when thinking is explicitly false", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: false, reasoningEffort: undefined },
|
||||
{ reasoning: { enabled: true, effort: "high" } },
|
||||
),
|
||||
).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it("persists enabled reasoning with the selected effort", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: true, reasoningEffort: "low" },
|
||||
{ reasoning: { enabled: false } },
|
||||
),
|
||||
).toEqual({ enabled: true, effort: "low" });
|
||||
});
|
||||
|
||||
it("persists enabled reasoning when thinking is explicitly true without effort", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: true, reasoningEffort: undefined },
|
||||
{ reasoning: { enabled: false } },
|
||||
),
|
||||
).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("preserves existing reasoning when thinking is unset", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: undefined, reasoningEffort: undefined },
|
||||
{ reasoning: { enabled: true, effort: "medium" } },
|
||||
),
|
||||
).toEqual({ enabled: true, effort: "medium" });
|
||||
});
|
||||
});
|
||||
@@ -1,311 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
const coreMocks = vi.hoisted(() => {
|
||||
const serviceOptions: Array<{
|
||||
apiBaseUrl: string;
|
||||
getAuthToken: () => Promise<string | undefined | null>;
|
||||
}> = [];
|
||||
return {
|
||||
getProviderSettings: vi.fn(),
|
||||
saveProviderSettings: vi.fn(),
|
||||
fetchMe: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
fetchOrganizationBalance: vi.fn(),
|
||||
fetchAvailableSubscriptionPlans: vi.fn(),
|
||||
fetchCurrentUserPlan: vi.fn(),
|
||||
serviceOptions,
|
||||
};
|
||||
});
|
||||
const telemetryMocks = vi.hoisted(() => ({
|
||||
identifyTelemetryAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
ClineAccountService: class {
|
||||
constructor(options: {
|
||||
apiBaseUrl: string;
|
||||
getAuthToken: () => Promise<string | undefined | null>;
|
||||
}) {
|
||||
coreMocks.serviceOptions.push(options);
|
||||
}
|
||||
fetchMe() {
|
||||
return coreMocks.fetchMe();
|
||||
}
|
||||
fetchBalance(userId?: string) {
|
||||
return coreMocks.fetchBalance(userId);
|
||||
}
|
||||
fetchOrganizationBalance(organizationId: string) {
|
||||
return coreMocks.fetchOrganizationBalance(organizationId);
|
||||
}
|
||||
fetchAvailableSubscriptionPlans(input?: {
|
||||
type?: "individual" | "teams";
|
||||
}) {
|
||||
return coreMocks.fetchAvailableSubscriptionPlans(input);
|
||||
}
|
||||
fetchCurrentUserPlan() {
|
||||
return coreMocks.fetchCurrentUserPlan();
|
||||
}
|
||||
},
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings(providerId: string) {
|
||||
return coreMocks.getProviderSettings(providerId);
|
||||
}
|
||||
saveProviderSettings(settings: unknown, options?: unknown) {
|
||||
coreMocks.saveProviderSettings(settings, options);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/telemetry", () => ({
|
||||
identifyTelemetryAccount: telemetryMocks.identifyTelemetryAccount,
|
||||
}));
|
||||
|
||||
function makeConfig(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: "",
|
||||
verbose: false,
|
||||
sandbox: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
mode: "act",
|
||||
defaultToolAutoApprove: false,
|
||||
toolPolicies: {},
|
||||
enableTools: true,
|
||||
cwd: "/tmp/workspace",
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
function mockFetchJson(body: unknown, status = 200): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
) as unknown as typeof fetch,
|
||||
);
|
||||
}
|
||||
|
||||
describe("createClineAccountService", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("refreshes persisted Cline OAuth credentials before creating the account service", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100_000);
|
||||
mockFetchJson({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: "new-access",
|
||||
refreshToken: "new-refresh",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: "2096-10-02T07:06:40.000Z",
|
||||
userInfo: {
|
||||
subject: "sub-new",
|
||||
email: "new@example.com",
|
||||
name: "New User",
|
||||
clineUserId: "acct-new",
|
||||
accounts: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "workos:old-access",
|
||||
refreshToken: "refresh-token",
|
||||
accountId: "acct-old",
|
||||
expiresAt: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const { createClineAccountService } = await import("./cline-account");
|
||||
const service = await createClineAccountService({ config: makeConfig() });
|
||||
|
||||
expect(service).toBeDefined();
|
||||
expect(globalThis.fetch).toHaveBeenCalled();
|
||||
expect(coreMocks.saveProviderSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "cline",
|
||||
auth: expect.objectContaining({
|
||||
accessToken: "workos:new-access",
|
||||
refreshToken: "new-refresh",
|
||||
accountId: "acct-new",
|
||||
expiresAt: 4_000_000_000_000,
|
||||
}),
|
||||
}),
|
||||
{ setLastUsed: false, tokenSource: "oauth" },
|
||||
);
|
||||
expect(await coreMocks.serviceOptions[0]?.getAuthToken()).toBe(
|
||||
"workos:new-access",
|
||||
);
|
||||
});
|
||||
|
||||
it("asks the user to re-authenticate when Cline OAuth credentials cannot refresh", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100_000);
|
||||
mockFetchJson(
|
||||
{
|
||||
error: "invalid_grant",
|
||||
error_description: "refresh expired",
|
||||
},
|
||||
401,
|
||||
);
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "workos:old-access",
|
||||
refreshToken: "refresh-token",
|
||||
expiresAt: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const { createClineAccountService } = await import("./cline-account");
|
||||
|
||||
await expect(
|
||||
createClineAccountService({ config: makeConfig() }),
|
||||
).rejects.toThrow(
|
||||
"Cline account requires re-authentication. Run cline auth cline.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadClineAccountSnapshot", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("identifies the loaded Cline account for telemetry and feature flags", async () => {
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
apiKey: "account-token",
|
||||
});
|
||||
const { loadClineAccountSnapshot } = await import("./cline-account");
|
||||
coreMocks.fetchMe.mockResolvedValue({
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
displayName: "User One",
|
||||
photoUrl: "",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Acme",
|
||||
organizationId: "org-1",
|
||||
roles: ["member"],
|
||||
},
|
||||
],
|
||||
});
|
||||
coreMocks.fetchBalance.mockResolvedValue({ balance: 10, userId: "user-1" });
|
||||
coreMocks.fetchOrganizationBalance.mockResolvedValue({
|
||||
balance: 20,
|
||||
organizationId: "org-1",
|
||||
});
|
||||
|
||||
await loadClineAccountSnapshot({ config: makeConfig() });
|
||||
|
||||
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
|
||||
{
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
provider: "cline",
|
||||
organizationId: "org-1",
|
||||
organizationName: "Acme",
|
||||
memberId: "member-1",
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadIndividualSubscriptionPlans", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("loads individual subscription plans through the authorized account service", async () => {
|
||||
const plans = [
|
||||
{
|
||||
id: "plan-1",
|
||||
interval: "Monthly",
|
||||
features: { included: ["Major open-weights models"] },
|
||||
},
|
||||
];
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
apiKey: "account-token",
|
||||
});
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockResolvedValue(plans);
|
||||
|
||||
const { loadIndividualSubscriptionPlans } = await import("./cline-account");
|
||||
const result = await loadIndividualSubscriptionPlans({
|
||||
config: makeConfig(),
|
||||
});
|
||||
|
||||
expect(coreMocks.fetchAvailableSubscriptionPlans).toHaveBeenCalledWith({
|
||||
type: "individual",
|
||||
});
|
||||
expect(result).toEqual(plans);
|
||||
});
|
||||
});
|
||||
@@ -1,285 +0,0 @@
|
||||
import {
|
||||
type ClineAccountBalance,
|
||||
type ClineAccountOrganization,
|
||||
type ClineAccountOrganizationBalance,
|
||||
type ClineSubscriptionPlan,
|
||||
type UserCurrentPlan,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
getValidClineCredentials,
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
|
||||
import { identifyTelemetryAccount } from "../utils/telemetry";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
export const CLINE_CREDITS_DASHBOARD_URL =
|
||||
"https://app.cline.bot/dashboard/account?tab=credits";
|
||||
|
||||
type ClineAccountConfig = Pick<Config, "apiKey" | "logger" | "providerId">;
|
||||
|
||||
const CLINE_PASS_PROVIDER_ID = "cline-pass";
|
||||
|
||||
export interface ClineAccountSnapshot {
|
||||
user: ClineAccountUser;
|
||||
balance: ClineAccountBalance;
|
||||
organizationBalance: ClineAccountOrganizationBalance | null;
|
||||
organizations: ClineAccountOrganization[];
|
||||
activeOrganization: ClineAccountOrganization | null;
|
||||
displayedBalance: number;
|
||||
}
|
||||
|
||||
export function formatClineCredits(value: number): string {
|
||||
return formatCreditBalance(normalizeCreditBalance(value));
|
||||
}
|
||||
|
||||
// FIXME: These message checks are temporary until structured error types are
|
||||
// passed through to the CLI instead of plain error strings.
|
||||
export function isClineAccountAuthErrorMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return (
|
||||
normalized === "no cline account auth token found" ||
|
||||
normalized.includes("requires re-authentication")
|
||||
);
|
||||
}
|
||||
|
||||
export function isClineAccountCreditsErrorMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return (
|
||||
normalized.includes("insufficient balance") &&
|
||||
normalized.includes("cline credits balance")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAccountApiBaseUrl(input: {
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): string {
|
||||
const settingsBaseUrl = input.clineProviderSettings?.baseUrl?.trim();
|
||||
if (settingsBaseUrl) {
|
||||
return settingsBaseUrl;
|
||||
}
|
||||
const configuredBaseUrl = input.clineApiBaseUrl?.trim();
|
||||
if (configuredBaseUrl) {
|
||||
return configuredBaseUrl;
|
||||
}
|
||||
return getClineEnvironmentConfig().apiBaseUrl;
|
||||
}
|
||||
|
||||
function resolveClineAccountAuthToken(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): string | undefined {
|
||||
const configApiKey =
|
||||
input.config.providerId === "cline" ? input.config.apiKey.trim() : "";
|
||||
return (
|
||||
getPersistedProviderApiKey("cline", input.clineProviderSettings) ||
|
||||
configApiKey ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveValidClineAccountAuthToken(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
manager: ProviderSettingsManager;
|
||||
apiBaseUrl: string;
|
||||
}): Promise<string | undefined> {
|
||||
const settings = input.clineProviderSettings;
|
||||
const credentials = settings
|
||||
? getProviderOAuthCredentialsFromSettings("cline", settings)
|
||||
: null;
|
||||
if (settings && credentials) {
|
||||
const nextCredentials = await getValidClineCredentials(credentials, {
|
||||
apiBaseUrl: input.apiBaseUrl,
|
||||
});
|
||||
if (!nextCredentials) {
|
||||
throw new Error(
|
||||
"Cline account requires re-authentication. Run cline auth cline.",
|
||||
);
|
||||
}
|
||||
const nextAccessToken = formatProviderOAuthApiKey("cline", nextCredentials);
|
||||
if (nextCredentials !== credentials) {
|
||||
saveLocalProviderOAuthCredentials(
|
||||
input.manager,
|
||||
"cline",
|
||||
settings,
|
||||
nextCredentials,
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
}
|
||||
return nextAccessToken;
|
||||
}
|
||||
return resolveClineAccountAuthToken({
|
||||
config: input.config,
|
||||
clineProviderSettings: settings,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createClineAccountService(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
providerSettingsManager?: ProviderSettingsManager;
|
||||
}): Promise<ClineAccountService | undefined> {
|
||||
const manager = input.providerSettingsManager ?? new ProviderSettingsManager();
|
||||
const settings =
|
||||
manager.getProviderSettings("cline") ?? input.clineProviderSettings;
|
||||
const apiBaseUrl = resolveAccountApiBaseUrl({
|
||||
clineApiBaseUrl: input.clineApiBaseUrl,
|
||||
clineProviderSettings: settings,
|
||||
});
|
||||
const authToken = await resolveValidClineAccountAuthToken({
|
||||
config: input.config,
|
||||
clineProviderSettings: settings,
|
||||
manager,
|
||||
apiBaseUrl,
|
||||
});
|
||||
if (!authToken) {
|
||||
return undefined;
|
||||
}
|
||||
return new ClineAccountService({
|
||||
apiBaseUrl,
|
||||
getAuthToken: async () => authToken,
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadClineAccountSnapshot(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): Promise<ClineAccountSnapshot> {
|
||||
const service = await createClineAccountService(input);
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
|
||||
const user = await service.fetchMe();
|
||||
const organizations = user.organizations ?? [];
|
||||
const activeOrganization =
|
||||
organizations.find((organization) => organization.active) ?? null;
|
||||
const [balance, organizationBalance] = await Promise.all([
|
||||
service.fetchBalance(user.id),
|
||||
activeOrganization
|
||||
? service.fetchOrganizationBalance(activeOrganization.organizationId)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const displayedBalance = activeOrganization
|
||||
? (organizationBalance?.balance ?? balance.balance)
|
||||
: balance.balance;
|
||||
const accountContext = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
provider: "cline",
|
||||
organizationId: activeOrganization?.organizationId,
|
||||
organizationName: activeOrganization?.name,
|
||||
memberId: activeOrganization?.memberId,
|
||||
};
|
||||
identifyTelemetryAccount(accountContext, input.config.logger);
|
||||
|
||||
return {
|
||||
user,
|
||||
balance,
|
||||
organizationBalance,
|
||||
organizations,
|
||||
activeOrganization,
|
||||
displayedBalance,
|
||||
};
|
||||
}
|
||||
|
||||
export async function switchClineAccount(input: {
|
||||
config: ClineAccountConfig;
|
||||
organizationId?: string | null;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): Promise<void> {
|
||||
const service = await createClineAccountService(input);
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
await service.switchAccount(input.organizationId);
|
||||
}
|
||||
|
||||
export async function loadIndividualSubscriptionPlans(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): Promise<ClineSubscriptionPlan[]> {
|
||||
const service = await createClineAccountService(input);
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
|
||||
}
|
||||
|
||||
export async function loadCurrentUserPlan(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): Promise<UserCurrentPlan | undefined> {
|
||||
const service = await createClineAccountService(input);
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchCurrentUserPlan();
|
||||
}
|
||||
|
||||
export async function loadCurrentUserPlanFromProviderSettings(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
clineApiBaseUrl?: string;
|
||||
}): Promise<UserCurrentPlan | undefined> {
|
||||
const service = await createClineAccountService({
|
||||
config: { apiKey: "", logger: undefined, providerId: "cline" },
|
||||
clineApiBaseUrl: input.clineApiBaseUrl,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
});
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchCurrentUserPlan();
|
||||
}
|
||||
|
||||
export async function loadIndividualSubscriptionPlansFromProviderSettings(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
clineApiBaseUrl?: string;
|
||||
}): Promise<ClineSubscriptionPlan[]> {
|
||||
const service = await createClineAccountService({
|
||||
config: { apiKey: "", logger: undefined, providerId: "cline" },
|
||||
clineApiBaseUrl: input.clineApiBaseUrl,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
});
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
|
||||
}
|
||||
|
||||
async function onChangeToClinePass(config: ClineAccountConfig) {
|
||||
try {
|
||||
await switchClineAccount({
|
||||
config: config,
|
||||
organizationId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
config.logger?.debug("Failed to switch ClinePass to personal account", {
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function onProviderChange(input: {
|
||||
config: ClineAccountConfig;
|
||||
providerId: string;
|
||||
}): Promise<void> {
|
||||
if (input.providerId === CLINE_PASS_PROVIDER_ID) {
|
||||
return onChangeToClinePass(input.config);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { DialogActions, DialogId } from "@opentui-ui/dialog/react";
|
||||
|
||||
export type LoadingDialogActions = Pick<DialogActions, "show" | "close">;
|
||||
|
||||
export async function withShownDialog<T>(
|
||||
dialog: Pick<DialogActions, "close">,
|
||||
show: () => DialogId,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const loadingDialogId = show();
|
||||
// Give OpenTUI a microtask to mount the loading dialog before work starts.
|
||||
await Promise.resolve();
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
dialog.close(loadingDialogId);
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import type { DialogActions, DialogId } from "@opentui-ui/dialog/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { withShownDialog } from "./loading-dialog-lifecycle";
|
||||
|
||||
type LoadingDialogCall =
|
||||
| {
|
||||
name: "show";
|
||||
}
|
||||
| {
|
||||
name: "close";
|
||||
id: DialogId | undefined;
|
||||
};
|
||||
|
||||
function createDialog(calls: LoadingDialogCall[]) {
|
||||
return {
|
||||
close: (id?: DialogId): DialogId | undefined => {
|
||||
calls.push({ name: "close", id });
|
||||
return id;
|
||||
},
|
||||
} satisfies Pick<DialogActions, "close">;
|
||||
}
|
||||
|
||||
function showLoading(calls: LoadingDialogCall[]): DialogId {
|
||||
calls.push({ name: "show" });
|
||||
return "loading-dialog";
|
||||
}
|
||||
|
||||
describe("withShownDialog", () => {
|
||||
it("shows a loading dialog while work runs", async () => {
|
||||
const calls: LoadingDialogCall[] = [];
|
||||
const events: string[] = [];
|
||||
const dialog = createDialog(calls);
|
||||
|
||||
const result = await withShownDialog(
|
||||
dialog,
|
||||
() => showLoading(calls),
|
||||
async () => {
|
||||
events.push("run");
|
||||
return 42;
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe(42);
|
||||
expect(events).toEqual(["run"]);
|
||||
expect(calls.map((call) => call.name)).toEqual(["show", "close"]);
|
||||
expect(calls[1]).toEqual({ name: "close", id: "loading-dialog" });
|
||||
});
|
||||
|
||||
it("closes the loading dialog when work fails", async () => {
|
||||
const calls: LoadingDialogCall[] = [];
|
||||
const dialog = createDialog(calls);
|
||||
|
||||
await expect(
|
||||
withShownDialog(
|
||||
dialog,
|
||||
() => showLoading(calls),
|
||||
async () => {
|
||||
throw new Error("failed");
|
||||
},
|
||||
),
|
||||
).rejects.toThrow("failed");
|
||||
|
||||
expect(calls.map((call) => call.name)).toEqual(["show", "close"]);
|
||||
expect(calls[1]).toEqual({ name: "close", id: "loading-dialog" });
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type {
|
||||
DialogId,
|
||||
DialogSize,
|
||||
DialogStyle,
|
||||
} from "@opentui-ui/dialog/react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
type LoadingDialogActions,
|
||||
withShownDialog,
|
||||
} from "./loading-dialog-lifecycle";
|
||||
|
||||
export interface LoadingDialogContentProps {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function LoadingDialogContent(props: LoadingDialogContentProps) {
|
||||
return (
|
||||
<box flexDirection="row" gap={1} paddingX={1}>
|
||||
<spinner name="dots" color="gray" />
|
||||
<text fg="gray">{props.message}</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export interface LoadingDialogOptions {
|
||||
size?: DialogSize;
|
||||
style?: DialogStyle;
|
||||
}
|
||||
|
||||
export function showLoadingDialog(
|
||||
dialog: LoadingDialogActions,
|
||||
message: string,
|
||||
options?: LoadingDialogOptions,
|
||||
): DialogId {
|
||||
return dialog.show({
|
||||
size: options?.size ?? "small",
|
||||
style: options?.style,
|
||||
closeOnEscape: false,
|
||||
closeOnClickOutside: false,
|
||||
content: () => <LoadingDialogContent message={message} />,
|
||||
});
|
||||
}
|
||||
|
||||
export async function withLoadingDialog<T>(
|
||||
dialog: LoadingDialogActions,
|
||||
message: string,
|
||||
run: () => Promise<T>,
|
||||
options?: LoadingDialogOptions,
|
||||
): Promise<T> {
|
||||
return await withShownDialog(
|
||||
dialog,
|
||||
() => showLoadingDialog(dialog, message, options),
|
||||
run,
|
||||
);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
|
||||
const CLINE_USAGE_BILLING_PATH = "/dashboard/account";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
|
||||
|
||||
export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl: string | undefined,
|
||||
): string {
|
||||
const url = new URL(
|
||||
CLINE_PASS_SUBSCRIPTION_PATH,
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
);
|
||||
url.searchParams.set("personal", "true");
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function buildClineUsageBillingPageUrl(
|
||||
appBaseUrl: string | undefined,
|
||||
): string {
|
||||
const url = new URL(
|
||||
CLINE_USAGE_BILLING_PATH,
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
);
|
||||
url.searchParams.set("tab", "credits");
|
||||
return url.toString();
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
buildClineUsageBillingPageUrl,
|
||||
} from "./provider-picker-helpers";
|
||||
|
||||
describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
it("opens the personal subscription page on production by default", () => {
|
||||
expect(buildClinePassSubscriptionPageUrl(undefined)).toBe(
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the configured app base URL", () => {
|
||||
expect(
|
||||
buildClinePassSubscriptionPageUrl("https://staging-app.cline.bot"),
|
||||
).toBe(
|
||||
"https://staging-app.cline.bot/dashboard/subscription?personal=true",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildClineUsageBillingPageUrl", () => {
|
||||
it("opens the credits tab on production by default", () => {
|
||||
expect(buildClineUsageBillingPageUrl(undefined)).toBe(
|
||||
"https://app.cline.bot/dashboard/account?tab=credits",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the configured app base URL", () => {
|
||||
expect(buildClineUsageBillingPageUrl("https://staging-app.cline.bot")).toBe(
|
||||
"https://staging-app.cline.bot/dashboard/account?tab=credits",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,507 +0,0 @@
|
||||
import type { ScrollBoxRenderable } from "@opentui/core";
|
||||
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { palette } from "../palette";
|
||||
import type { RuntimeToolInteraction } from "../types";
|
||||
import { formatApprovalParams } from "./dialogs/tool-approval";
|
||||
|
||||
export interface InlineToolResponseProps {
|
||||
interaction: RuntimeToolInteraction;
|
||||
accent: string;
|
||||
inputBackground: string;
|
||||
inputForeground: string;
|
||||
inputPlaceholder: string;
|
||||
onResolveToolApproval: (id: number, approved: boolean) => void;
|
||||
onResolveAskQuestion: (id: number, answer: string | null) => void;
|
||||
}
|
||||
|
||||
function isPrintableKey(name: string): boolean {
|
||||
return name.length === 1 || name === "space";
|
||||
}
|
||||
|
||||
function keyToText(name: string): string {
|
||||
return name === "space" ? " " : name;
|
||||
}
|
||||
|
||||
function getToolShellMaxHeight(terminalHeight: number): number {
|
||||
return Math.max(7, Math.min(14, Math.floor(terminalHeight * 0.38)));
|
||||
}
|
||||
|
||||
function getAskQuestionShellMaxHeight(terminalHeight: number): number {
|
||||
const preferredHeight = Math.max(11, Math.floor(terminalHeight * 0.58));
|
||||
const availableHeight = Math.max(7, terminalHeight - 3);
|
||||
return Math.min(18, preferredHeight, availableHeight);
|
||||
}
|
||||
|
||||
function getAskQuestionBodyHeight(shellMaxHeight: number): number {
|
||||
return Math.max(1, shellMaxHeight - 4);
|
||||
}
|
||||
|
||||
function addWrappedWidth(input: {
|
||||
rows: number;
|
||||
lineWidth: number;
|
||||
width: number;
|
||||
maxWidth: number;
|
||||
}): { rows: number; lineWidth: number } {
|
||||
if (input.width <= 0) {
|
||||
return { rows: input.rows, lineWidth: input.lineWidth };
|
||||
}
|
||||
|
||||
let rows = input.rows;
|
||||
let remainingWidth = input.width;
|
||||
let lineWidth = input.lineWidth;
|
||||
|
||||
if (lineWidth > 0) {
|
||||
const availableWidth = input.maxWidth - lineWidth;
|
||||
if (remainingWidth <= availableWidth) {
|
||||
return { rows, lineWidth: lineWidth + remainingWidth };
|
||||
}
|
||||
|
||||
remainingWidth -= Math.max(0, availableWidth);
|
||||
rows += 1;
|
||||
lineWidth = 0;
|
||||
}
|
||||
|
||||
rows += Math.max(0, Math.ceil(remainingWidth / input.maxWidth) - 1);
|
||||
lineWidth = remainingWidth % input.maxWidth || input.maxWidth;
|
||||
|
||||
return { rows, lineWidth };
|
||||
}
|
||||
|
||||
function countWrappedRows(text: string, width: number): number {
|
||||
const safeWidth = Math.max(1, width);
|
||||
const paragraphs = text.split("\n");
|
||||
let rows = 0;
|
||||
|
||||
for (const paragraph of paragraphs) {
|
||||
rows += 1;
|
||||
let lineWidth = 0;
|
||||
const tokens = paragraph.match(/\s+|\S+/g) ?? [];
|
||||
|
||||
for (const token of tokens) {
|
||||
const tokenWidth = Bun.stringWidth(token);
|
||||
const isWhitespace = /^\s+$/.test(token);
|
||||
|
||||
if (
|
||||
!isWhitespace &&
|
||||
lineWidth > 0 &&
|
||||
lineWidth + tokenWidth > safeWidth
|
||||
) {
|
||||
rows += 1;
|
||||
lineWidth = 0;
|
||||
}
|
||||
|
||||
const next = addWrappedWidth({
|
||||
rows,
|
||||
lineWidth,
|
||||
width: tokenWidth,
|
||||
maxWidth: safeWidth,
|
||||
});
|
||||
rows = next.rows;
|
||||
lineWidth = next.lineWidth;
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function getAskQuestionContentHeight(input: {
|
||||
terminalWidth: number;
|
||||
question: string;
|
||||
options: string[];
|
||||
customText: string;
|
||||
}): number {
|
||||
const questionWidth = Math.max(1, input.terminalWidth - 3);
|
||||
const optionTextWidth = Math.max(1, input.terminalWidth - 7);
|
||||
const questionRows = countWrappedRows(input.question, questionWidth);
|
||||
const optionRows = input.options.reduce(
|
||||
(rows, option) => rows + countWrappedRows(option, optionTextWidth),
|
||||
0,
|
||||
);
|
||||
const customRows = countWrappedRows(input.customText, optionTextWidth);
|
||||
return questionRows + 1 + optionRows + customRows;
|
||||
}
|
||||
|
||||
function getAskQuestionChoiceId(interactionId: number, index: number): string {
|
||||
return `ask-question-${interactionId.toString()}-choice-${index.toString()}`;
|
||||
}
|
||||
|
||||
function Shell(
|
||||
props: Pick<
|
||||
InlineToolResponseProps,
|
||||
"accent" | "inputBackground" | "inputForeground"
|
||||
> & {
|
||||
title: string;
|
||||
maxHeight?: number;
|
||||
overflow?: "hidden";
|
||||
children: React.ReactNode;
|
||||
},
|
||||
) {
|
||||
const { height } = useTerminalDimensions();
|
||||
const maxHeight = props.maxHeight ?? getToolShellMaxHeight(height);
|
||||
|
||||
return (
|
||||
<box
|
||||
flexDirection="column"
|
||||
width="100%"
|
||||
maxHeight={maxHeight}
|
||||
overflow={props.overflow}
|
||||
backgroundColor={props.inputBackground}
|
||||
paddingX={1}
|
||||
paddingY={1}
|
||||
gap={1}
|
||||
>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<text fg={palette.act}>{props.title}</text>
|
||||
</box>
|
||||
{props.children}
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ChoiceButton(props: {
|
||||
label: string;
|
||||
selected: boolean;
|
||||
selectedFg?: string;
|
||||
onPress: () => void;
|
||||
}) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes handle terminal mouse input.
|
||||
<box
|
||||
paddingX={1}
|
||||
backgroundColor={props.selected ? palette.selection : undefined}
|
||||
onMouseDown={props.onPress}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
props.selected
|
||||
? (props.selectedFg ?? palette.textOnSelection)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{props.label}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolApprovalResponse(
|
||||
props: InlineToolResponseProps & {
|
||||
interaction: Extract<RuntimeToolInteraction, { kind: "tool_approval" }>;
|
||||
},
|
||||
) {
|
||||
const [selected, setSelected] = useState<"approve" | "deny">("approve");
|
||||
const selectedRef = useRef(selected);
|
||||
selectedRef.current = selected;
|
||||
const request = props.interaction.request;
|
||||
const interactionId = props.interaction.id;
|
||||
const onResolveToolApproval = props.onResolveToolApproval;
|
||||
const params = formatApprovalParams(request.toolName, request.input);
|
||||
|
||||
const resolve = useCallback(
|
||||
(approved: boolean) => {
|
||||
onResolveToolApproval(interactionId, approved);
|
||||
},
|
||||
[interactionId, onResolveToolApproval],
|
||||
);
|
||||
|
||||
useKeyboard((key) => {
|
||||
if (key.name === "y") {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
if (key.name === "n" || key.name === "escape") {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
if (key.name === "left" || key.name === "right" || key.name === "tab") {
|
||||
setSelected((current) => (current === "approve" ? "deny" : "approve"));
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
resolve(selectedRef.current === "approve");
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Shell
|
||||
title="Cline needs permission"
|
||||
accent={props.accent}
|
||||
inputBackground={props.inputBackground}
|
||||
inputForeground={props.inputForeground}
|
||||
>
|
||||
<box flexDirection="column" gap={1}>
|
||||
<text fg="yellow">Approve tool call?</text>
|
||||
<text fg={props.accent} selectable>
|
||||
{request.toolName}
|
||||
</text>
|
||||
{params && (
|
||||
<box flexDirection="column" overflow="hidden">
|
||||
{params}
|
||||
</box>
|
||||
)}
|
||||
</box>
|
||||
<box flexDirection="row" gap={1}>
|
||||
<ChoiceButton
|
||||
label="[y] Approve"
|
||||
selected={selected === "approve"}
|
||||
onPress={() => resolve(true)}
|
||||
/>
|
||||
<ChoiceButton
|
||||
label="[n] Deny"
|
||||
selected={selected === "deny"}
|
||||
onPress={() => resolve(false)}
|
||||
/>
|
||||
</box>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
function AskQuestionResponse(
|
||||
props: InlineToolResponseProps & {
|
||||
interaction: Extract<RuntimeToolInteraction, { kind: "ask_question" }>;
|
||||
},
|
||||
) {
|
||||
const { interaction } = props;
|
||||
const { height, width } = useTerminalDimensions();
|
||||
const [selected, setSelected] = useState(0);
|
||||
const [customValue, setCustomValue] = useState("");
|
||||
const [customEmptyAttempted, setCustomEmptyAttempted] = useState(false);
|
||||
const scrollRef = useRef<ScrollBoxRenderable | null>(null);
|
||||
const selectedRef = useRef(0);
|
||||
const customValueRef = useRef("");
|
||||
const interactionId = interaction.id;
|
||||
const onResolveAskQuestion = props.onResolveAskQuestion;
|
||||
const customIndex = interaction.options.length;
|
||||
const isTyping = selected === customIndex;
|
||||
const totalChoices = interaction.options.length + 1;
|
||||
const shellMaxHeight = getAskQuestionShellMaxHeight(height);
|
||||
const maxBodyHeight = getAskQuestionBodyHeight(shellMaxHeight);
|
||||
const customText = isTyping
|
||||
? customValue
|
||||
? `${customValue}|`
|
||||
: customEmptyAttempted
|
||||
? "Type a response first..."
|
||||
: "Type a response..."
|
||||
: "Type a response...";
|
||||
const bodyHeight = Math.min(
|
||||
maxBodyHeight,
|
||||
getAskQuestionContentHeight({
|
||||
terminalWidth: width,
|
||||
question: interaction.question,
|
||||
options: interaction.options,
|
||||
customText,
|
||||
}),
|
||||
);
|
||||
|
||||
const selectIndex = useCallback(
|
||||
(index: number) => {
|
||||
selectedRef.current = index;
|
||||
setSelected(index);
|
||||
if (index !== customIndex) {
|
||||
setCustomEmptyAttempted(false);
|
||||
}
|
||||
},
|
||||
[customIndex],
|
||||
);
|
||||
|
||||
const setCustomText = useCallback((value: string) => {
|
||||
customValueRef.current = value;
|
||||
setCustomValue(value);
|
||||
if (value.trim()) {
|
||||
setCustomEmptyAttempted(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resolveAnswer = useCallback(
|
||||
(answer: string | null) => {
|
||||
onResolveAskQuestion(interactionId, answer);
|
||||
},
|
||||
[interactionId, onResolveAskQuestion],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const choiceId = getAskQuestionChoiceId(interactionId, selected);
|
||||
let canceled = false;
|
||||
const scrollSelectedChoiceIntoView = () => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
scrollRef.current?.scrollChildIntoView(choiceId);
|
||||
};
|
||||
|
||||
scrollSelectedChoiceIntoView();
|
||||
queueMicrotask(scrollSelectedChoiceIntoView);
|
||||
const timeout = setTimeout(scrollSelectedChoiceIntoView, 0);
|
||||
return () => {
|
||||
canceled = true;
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [interactionId, selected]);
|
||||
|
||||
useKeyboard((key) => {
|
||||
const typing = selectedRef.current === customIndex;
|
||||
if (key.name === "escape") {
|
||||
if (typing && customValueRef.current) {
|
||||
setCustomText("");
|
||||
return;
|
||||
}
|
||||
resolveAnswer(null);
|
||||
return;
|
||||
}
|
||||
if (typing && key.name === "backspace") {
|
||||
setCustomText(customValueRef.current.slice(0, -1));
|
||||
return;
|
||||
}
|
||||
if (typing && key.name === "delete") {
|
||||
setCustomText("");
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
if (typing) {
|
||||
const answer = customValueRef.current.trim();
|
||||
if (answer) {
|
||||
resolveAnswer(answer);
|
||||
return;
|
||||
}
|
||||
setCustomEmptyAttempted(true);
|
||||
return;
|
||||
}
|
||||
resolveAnswer(interaction.options[selectedRef.current] ?? "");
|
||||
return;
|
||||
}
|
||||
if (key.name === "up" || (key.ctrl && key.name === "p")) {
|
||||
const next =
|
||||
selectedRef.current <= 0 ? totalChoices - 1 : selectedRef.current - 1;
|
||||
selectIndex(next);
|
||||
return;
|
||||
}
|
||||
if (key.name === "down" || (key.ctrl && key.name === "n")) {
|
||||
const next =
|
||||
selectedRef.current >= totalChoices - 1 ? 0 : selectedRef.current + 1;
|
||||
selectIndex(next);
|
||||
return;
|
||||
}
|
||||
if (!typing && key.name >= "1" && key.name <= "9") {
|
||||
const index = Number.parseInt(key.name, 10) - 1;
|
||||
const option = interaction.options[index];
|
||||
if (option) {
|
||||
resolveAnswer(option);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (typing && !key.ctrl && !key.meta && isPrintableKey(key.name)) {
|
||||
const value = keyToText(key.name);
|
||||
setCustomText(`${customValueRef.current}${value}`);
|
||||
return;
|
||||
}
|
||||
if (!key.ctrl && !key.meta && isPrintableKey(key.name)) {
|
||||
const value = keyToText(key.name);
|
||||
setCustomText(value);
|
||||
selectIndex(customIndex);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Shell
|
||||
title="Cline is asking a question"
|
||||
accent={props.accent}
|
||||
inputBackground={props.inputBackground}
|
||||
inputForeground={props.inputForeground}
|
||||
maxHeight={shellMaxHeight}
|
||||
overflow="hidden"
|
||||
>
|
||||
<scrollbox
|
||||
ref={scrollRef}
|
||||
height={bodyHeight}
|
||||
width="100%"
|
||||
scrollY
|
||||
scrollX={false}
|
||||
viewportOptions={{ overflow: "hidden" }}
|
||||
contentOptions={{ flexDirection: "column" }}
|
||||
>
|
||||
<box flexDirection="column" gap={1} flexShrink={0} width="100%">
|
||||
<text fg={props.inputForeground} selectable flexShrink={0}>
|
||||
{interaction.question}
|
||||
</text>
|
||||
|
||||
<box flexDirection="column" flexShrink={0} width="100%">
|
||||
{interaction.options.map((option, index) => {
|
||||
const optionSelected = !isTyping && selected === index;
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes handle terminal mouse input.
|
||||
<box
|
||||
id={getAskQuestionChoiceId(interactionId, index)}
|
||||
key={`${index.toString()}:${option}`}
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
backgroundColor={
|
||||
optionSelected ? palette.selection : undefined
|
||||
}
|
||||
onMouseDown={() => resolveAnswer(option)}
|
||||
>
|
||||
<text
|
||||
fg={optionSelected ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{optionSelected ? ">" : " "}
|
||||
</text>
|
||||
<text
|
||||
fg={
|
||||
optionSelected
|
||||
? palette.textOnSelection
|
||||
: props.inputForeground
|
||||
}
|
||||
flexGrow={1}
|
||||
flexShrink={1}
|
||||
>
|
||||
{option}
|
||||
</text>
|
||||
</box>
|
||||
);
|
||||
})}
|
||||
{/* biome-ignore lint/a11y/noStaticElementInteractions: OpenTUI boxes handle terminal mouse input. */}
|
||||
<box
|
||||
id={getAskQuestionChoiceId(interactionId, customIndex)}
|
||||
paddingX={1}
|
||||
flexDirection="row"
|
||||
gap={1}
|
||||
flexShrink={0}
|
||||
width="100%"
|
||||
backgroundColor={isTyping ? palette.selection : undefined}
|
||||
onMouseDown={() => selectIndex(customIndex)}
|
||||
>
|
||||
<text
|
||||
fg={isTyping ? palette.textOnSelection : "gray"}
|
||||
flexShrink={0}
|
||||
>
|
||||
{isTyping ? ">" : " "}
|
||||
</text>
|
||||
{isTyping ? (
|
||||
<text fg={palette.textOnSelection} flexGrow={1} flexShrink={1}>
|
||||
{customText}
|
||||
</text>
|
||||
) : (
|
||||
<text fg={props.inputPlaceholder} flexGrow={1} flexShrink={1}>
|
||||
Type a response...
|
||||
</text>
|
||||
)}
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
</scrollbox>
|
||||
</Shell>
|
||||
);
|
||||
}
|
||||
|
||||
export function InlineToolResponse(props: InlineToolResponseProps) {
|
||||
if (props.interaction.kind === "tool_approval") {
|
||||
return <ToolApprovalResponse {...props} interaction={props.interaction} />;
|
||||
}
|
||||
|
||||
return <AskQuestionResponse {...props} interaction={props.interaction} />;
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
import { Llms } from "@cline/core";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import type { DialogActions } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type {
|
||||
InteractiveConfigData,
|
||||
InteractiveConfigItem,
|
||||
InteractiveConfigTab,
|
||||
LoadInteractiveConfigDataOptions,
|
||||
} from "../../tui/interactive-config";
|
||||
import type { CliCompactionMode, Config } from "../../utils/types";
|
||||
import {
|
||||
ConfigErrorContent,
|
||||
DeleteConfigItemConfirmContent,
|
||||
ExtDetailContent,
|
||||
} from "../components/dialogs/config-dialogs";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import { ConfigPanelContent } from "../views/config-view";
|
||||
import type { ConfigAction } from "../views/config-view-helpers";
|
||||
import type { OpenModelSelectorOptions } from "./use-model-selector";
|
||||
|
||||
export interface OpenConfigOptions {
|
||||
initialTab?: InteractiveConfigTab;
|
||||
}
|
||||
|
||||
export function useConfigPanel(opts: {
|
||||
dialog: DialogActions;
|
||||
config: Config;
|
||||
sessionUiMode: string;
|
||||
compactionMode: CliCompactionMode;
|
||||
toggleMode: () => void;
|
||||
toggleAutoApprove: () => void;
|
||||
setCompactionMode: (mode: CliCompactionMode) => void;
|
||||
termHeight: number;
|
||||
loadConfigData: (
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData>;
|
||||
onToggleConfigItem?: (
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
onDeleteConfigItem?: (
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
openModelSelector: (options?: OpenModelSelectorOptions) => Promise<void>;
|
||||
openMcpManager: (options?: { refocus?: boolean }) => Promise<boolean>;
|
||||
refocusTextarea: () => void;
|
||||
}) {
|
||||
const emptyConfigData = useMemo(
|
||||
() => ({
|
||||
workflows: [] as InteractiveConfigItem[],
|
||||
rules: [] as InteractiveConfigItem[],
|
||||
skills: [] as InteractiveConfigItem[],
|
||||
hooks: [] as InteractiveConfigItem[],
|
||||
agents: [] as InteractiveConfigItem[],
|
||||
plugins: [] as InteractiveConfigItem[],
|
||||
mcp: [] as InteractiveConfigItem[],
|
||||
tools: [] as InteractiveConfigItem[],
|
||||
workflowSlashCommands: [],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const openConfig = useCallback(
|
||||
async (options: OpenConfigOptions = {}) => {
|
||||
let keepOpen = true;
|
||||
let activeTab = options.initialTab;
|
||||
while (keepOpen) {
|
||||
const [data, providerInfo] = await withLoadingDialog(
|
||||
opts.dialog,
|
||||
"Loading settings...",
|
||||
async () =>
|
||||
await Promise.all([
|
||||
opts
|
||||
.loadConfigData({ includePluginTools: false })
|
||||
.catch(() => emptyConfigData),
|
||||
Llms.getProvider(opts.config.providerId).catch(() => undefined),
|
||||
]),
|
||||
);
|
||||
const providerDisplayName =
|
||||
providerInfo?.name ?? opts.config.providerId;
|
||||
const action = await opts.dialog.choice<ConfigAction>({
|
||||
size: "large",
|
||||
style: { maxHeight: opts.termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<ConfigAction>) => (
|
||||
<ConfigPanelContent
|
||||
{...ctx}
|
||||
config={opts.config}
|
||||
configData={data}
|
||||
loadConfigData={opts.loadConfigData}
|
||||
providerDisplayName={providerDisplayName}
|
||||
currentMode={opts.sessionUiMode}
|
||||
currentCompactionMode={opts.compactionMode}
|
||||
initialTab={activeTab}
|
||||
onActiveTabChange={(tab) => {
|
||||
activeTab = tab;
|
||||
}}
|
||||
onToggleConfigItem={opts.onToggleConfigItem}
|
||||
onDeleteConfigItem={opts.onDeleteConfigItem}
|
||||
onToggleMode={opts.toggleMode}
|
||||
onToggleAutoApprove={opts.toggleAutoApprove}
|
||||
onSetCompactionMode={opts.setCompactionMode}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
if (!action) {
|
||||
keepOpen = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action.kind === "open-provider") {
|
||||
await opts.openModelSelector({
|
||||
startWithProviderChange: true,
|
||||
onCancel: () => {},
|
||||
});
|
||||
} else if (action.kind === "open-model") {
|
||||
await opts.openModelSelector({ onCancel: () => {} });
|
||||
} else if (action.kind === "toggle-item") {
|
||||
await opts.onToggleConfigItem?.(action.item);
|
||||
} else if (action.kind === "delete-item") {
|
||||
const confirmed = await opts.dialog.choice<boolean>({
|
||||
closeOnEscape: true,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<DeleteConfigItemConfirmContent {...ctx} item={action.item} />
|
||||
),
|
||||
});
|
||||
if (confirmed && opts.onDeleteConfigItem) {
|
||||
try {
|
||||
await withLoadingDialog(
|
||||
opts.dialog,
|
||||
`Deleting ${action.item.name}...`,
|
||||
async () =>
|
||||
await opts.onDeleteConfigItem?.(action.item, {
|
||||
includePluginTools: false,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
await opts.dialog.choice<void>({
|
||||
closeOnEscape: true,
|
||||
content: (ctx: ChoiceContext<void>) => (
|
||||
<ConfigErrorContent
|
||||
{...ctx}
|
||||
title="Plugin delete failed"
|
||||
message={
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (action.kind === "ext-detail") {
|
||||
await opts.dialog.choice<void>({
|
||||
style: { maxHeight: opts.termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<void>) => (
|
||||
<ExtDetailContent
|
||||
{...ctx}
|
||||
item={action.item}
|
||||
onToggleConfigItem={opts.onToggleConfigItem}
|
||||
/>
|
||||
),
|
||||
});
|
||||
} else if (action.kind === "open-mcp") {
|
||||
const changed = await opts.openMcpManager({ refocus: false });
|
||||
if (changed) {
|
||||
keepOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
opts.refocusTextarea();
|
||||
},
|
||||
[opts, emptyConfigData],
|
||||
);
|
||||
|
||||
return openConfig;
|
||||
}
|
||||
@@ -1,203 +0,0 @@
|
||||
import type { AgentMode } from "@cline/core";
|
||||
import type { ToolApprovalRequest, ToolApprovalResult } from "@cline/shared";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { RuntimeToolInteraction, TuiProps } from "../types";
|
||||
|
||||
type PendingRuntimeToolInteraction =
|
||||
| {
|
||||
id: number;
|
||||
kind: "tool_approval";
|
||||
request: ToolApprovalRequest;
|
||||
resolve: (result: ToolApprovalResult) => void;
|
||||
}
|
||||
| {
|
||||
id: number;
|
||||
kind: "ask_question";
|
||||
question: string;
|
||||
options: string[];
|
||||
resolve: (answer: string) => void;
|
||||
};
|
||||
|
||||
function toRuntimeToolInteraction(
|
||||
pending: PendingRuntimeToolInteraction,
|
||||
): RuntimeToolInteraction {
|
||||
if (pending.kind === "tool_approval") {
|
||||
return {
|
||||
id: pending.id,
|
||||
kind: pending.kind,
|
||||
request: pending.request,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: pending.id,
|
||||
kind: pending.kind,
|
||||
question: pending.question,
|
||||
options: pending.options,
|
||||
};
|
||||
}
|
||||
|
||||
function deniedToolResult(request: ToolApprovalRequest): ToolApprovalResult {
|
||||
return {
|
||||
approved: false,
|
||||
reason: `Tool "${request.toolName}" was denied by user`,
|
||||
};
|
||||
}
|
||||
|
||||
function dismissPendingInteraction(pending: PendingRuntimeToolInteraction) {
|
||||
if (pending.kind === "tool_approval") {
|
||||
pending.resolve(deniedToolResult(pending.request));
|
||||
return;
|
||||
}
|
||||
pending.resolve("[User dismissed the question]");
|
||||
}
|
||||
|
||||
export function useRuntimeDialogBridge(input: {
|
||||
setToolApprover: TuiProps["setToolApprover"];
|
||||
setAskQuestion: TuiProps["setAskQuestion"];
|
||||
setModeChangeNotifier: TuiProps["setModeChangeNotifier"];
|
||||
setUiMode: (mode: AgentMode) => void;
|
||||
refocusTextarea: () => void;
|
||||
}) {
|
||||
const {
|
||||
setToolApprover,
|
||||
setAskQuestion,
|
||||
setModeChangeNotifier,
|
||||
setUiMode,
|
||||
refocusTextarea,
|
||||
} = input;
|
||||
const [interaction, setInteraction] = useState<RuntimeToolInteraction | null>(
|
||||
null,
|
||||
);
|
||||
const activeRef = useRef<PendingRuntimeToolInteraction | null>(null);
|
||||
const queueRef = useRef<PendingRuntimeToolInteraction[]>([]);
|
||||
const nextIdRef = useRef(1);
|
||||
|
||||
const activate = useCallback((pending: PendingRuntimeToolInteraction) => {
|
||||
activeRef.current = pending;
|
||||
setInteraction(toRuntimeToolInteraction(pending));
|
||||
}, []);
|
||||
|
||||
const enqueue = useCallback(
|
||||
(pending: PendingRuntimeToolInteraction) => {
|
||||
if (activeRef.current) {
|
||||
queueRef.current.push(pending);
|
||||
return;
|
||||
}
|
||||
activate(pending);
|
||||
},
|
||||
[activate],
|
||||
);
|
||||
|
||||
const finishActive = useCallback(
|
||||
(id: number) => {
|
||||
if (activeRef.current?.id !== id) {
|
||||
return false;
|
||||
}
|
||||
const next = queueRef.current.shift() ?? null;
|
||||
if (next) {
|
||||
activate(next);
|
||||
return true;
|
||||
}
|
||||
activeRef.current = null;
|
||||
setInteraction(null);
|
||||
return false;
|
||||
},
|
||||
[activate],
|
||||
);
|
||||
|
||||
const resolveToolApproval = useCallback(
|
||||
(id: number, approved: boolean) => {
|
||||
const pending = activeRef.current;
|
||||
if (!pending || pending.id !== id || pending.kind !== "tool_approval") {
|
||||
return;
|
||||
}
|
||||
pending.resolve(
|
||||
approved ? { approved: true } : deniedToolResult(pending.request),
|
||||
);
|
||||
const hasNext = finishActive(id);
|
||||
if (!hasNext) {
|
||||
refocusTextarea();
|
||||
}
|
||||
},
|
||||
[finishActive, refocusTextarea],
|
||||
);
|
||||
|
||||
const resolveAskQuestion = useCallback(
|
||||
(id: number, answer: string | null) => {
|
||||
const pending = activeRef.current;
|
||||
if (!pending || pending.id !== id || pending.kind !== "ask_question") {
|
||||
return;
|
||||
}
|
||||
pending.resolve(
|
||||
answer === null ? "[User dismissed the question]" : answer,
|
||||
);
|
||||
const hasNext = finishActive(id);
|
||||
if (!hasNext) {
|
||||
refocusTextarea();
|
||||
}
|
||||
},
|
||||
[finishActive, refocusTextarea],
|
||||
);
|
||||
|
||||
const dismissAll = useCallback(() => {
|
||||
if (activeRef.current) {
|
||||
dismissPendingInteraction(activeRef.current);
|
||||
activeRef.current = null;
|
||||
}
|
||||
for (const pending of queueRef.current) {
|
||||
dismissPendingInteraction(pending);
|
||||
}
|
||||
queueRef.current = [];
|
||||
setInteraction(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setToolApprover(
|
||||
(request) =>
|
||||
new Promise<ToolApprovalResult>((resolve) => {
|
||||
enqueue({
|
||||
id: nextIdRef.current,
|
||||
kind: "tool_approval",
|
||||
request,
|
||||
resolve,
|
||||
});
|
||||
nextIdRef.current += 1;
|
||||
}),
|
||||
);
|
||||
setAskQuestion(
|
||||
(question, options) =>
|
||||
new Promise<string>((resolve) => {
|
||||
enqueue({
|
||||
id: nextIdRef.current,
|
||||
kind: "ask_question",
|
||||
question,
|
||||
options,
|
||||
resolve,
|
||||
});
|
||||
nextIdRef.current += 1;
|
||||
}),
|
||||
);
|
||||
setModeChangeNotifier((mode) => {
|
||||
setUiMode(mode);
|
||||
});
|
||||
return () => {
|
||||
setToolApprover(null);
|
||||
setAskQuestion(null);
|
||||
setModeChangeNotifier(null);
|
||||
dismissAll();
|
||||
};
|
||||
}, [
|
||||
dismissAll,
|
||||
enqueue,
|
||||
setAskQuestion,
|
||||
setModeChangeNotifier,
|
||||
setToolApprover,
|
||||
setUiMode,
|
||||
]);
|
||||
|
||||
return {
|
||||
interaction,
|
||||
resolveToolApproval,
|
||||
resolveAskQuestion,
|
||||
};
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { rankMentionPaths } from "./interactive-welcome";
|
||||
|
||||
describe("TUI file mention search ranking", () => {
|
||||
it("keeps initialism matches for compact and hyphenated input", () => {
|
||||
const paths = [
|
||||
"src/components/Button.tsx",
|
||||
"src/domain/MyAmazingClassDefinition.ts",
|
||||
"docs/MACD.md",
|
||||
"packages/core/src/runtime/manager.ts",
|
||||
];
|
||||
|
||||
expect(rankMentionPaths(paths, "MACD", 10)).toEqual([
|
||||
"docs/MACD.md",
|
||||
"src/domain/MyAmazingClassDefinition.ts",
|
||||
]);
|
||||
expect(rankMentionPaths(paths, "M-A-C-D", 10)).toEqual([
|
||||
"docs/MACD.md",
|
||||
"src/domain/MyAmazingClassDefinition.ts",
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes common mention prefixes before matching workspace paths", () => {
|
||||
const paths = [
|
||||
"docs/architecture.md",
|
||||
"src/tui/interactive-welcome.ts",
|
||||
"src/tui/hooks/use-autocomplete.ts",
|
||||
];
|
||||
|
||||
expect(rankMentionPaths(paths, "./src/tui", 10)).toEqual([
|
||||
"src/tui/hooks/use-autocomplete.ts",
|
||||
"src/tui/interactive-welcome.ts",
|
||||
]);
|
||||
expect(rankMentionPaths(paths, "/docs", 10)).toEqual([
|
||||
"docs/architecture.md",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ranks filename matches ahead of path-only fuzzy matches", () => {
|
||||
const paths = [
|
||||
"src/migrations/add-column.ts",
|
||||
"src/domain/MyAmazingClassDefinition.ts",
|
||||
"docs/classes.md",
|
||||
];
|
||||
|
||||
expect(rankMentionPaths(paths, "class", 10)[0]).toBe("docs/classes.md");
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import type { ProviderConfigFieldKey } from "@cline/core";
|
||||
import { resolveAwsRegion } from "../../utils/aws-region";
|
||||
|
||||
export type ProviderConfigValues = Partial<
|
||||
Record<ProviderConfigFieldKey, string>
|
||||
>;
|
||||
|
||||
const DEFAULT_AWS_REGION = "us-east-1";
|
||||
const DEFAULT_GCP_REGION = "us-central1";
|
||||
|
||||
export function getDefaultAwsRegion(profile?: string): string {
|
||||
return (
|
||||
resolveAwsRegion({ profile: profile?.trim() || undefined }) ??
|
||||
DEFAULT_AWS_REGION
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveProviderConfigAwsRegion(
|
||||
values: ProviderConfigValues,
|
||||
): string {
|
||||
return values.awsRegion?.trim() || getDefaultAwsRegion(values.awsProfile);
|
||||
}
|
||||
|
||||
export function resolveProviderConfigGcp(values: ProviderConfigValues):
|
||||
| {
|
||||
projectId?: string;
|
||||
region?: string;
|
||||
}
|
||||
| undefined {
|
||||
const projectId = values.gcpProjectId?.trim() || undefined;
|
||||
if (!projectId) return undefined;
|
||||
return {
|
||||
projectId,
|
||||
region: values.gcpRegion?.trim() || DEFAULT_GCP_REGION,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveProviderConfigSap(values: ProviderConfigValues):
|
||||
| {
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
tokenUrl?: string;
|
||||
resourceGroup?: string;
|
||||
deploymentId?: string;
|
||||
}
|
||||
| undefined {
|
||||
const sap = {
|
||||
clientId: values.sapClientId?.trim() || undefined,
|
||||
clientSecret: values.sapClientSecret?.trim() || undefined,
|
||||
tokenUrl: values.sapTokenUrl?.trim() || undefined,
|
||||
resourceGroup: values.sapResourceGroup?.trim() || undefined,
|
||||
deploymentId: values.sapDeploymentId?.trim() || undefined,
|
||||
};
|
||||
return Object.values(sap).some((value) => value !== undefined)
|
||||
? sap
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function resolveProviderConfigAzure(values: ProviderConfigValues): {
|
||||
apiVersion?: string;
|
||||
} {
|
||||
return { apiVersion: values.azureApiVersion?.trim() ?? "" };
|
||||
}
|
||||
|
||||
export function updateProviderConfigValue(
|
||||
previous: ProviderConfigValues,
|
||||
field: ProviderConfigFieldKey,
|
||||
value: string,
|
||||
): ProviderConfigValues {
|
||||
const next: ProviderConfigValues = { ...previous, [field]: value };
|
||||
if (field !== "awsProfile") {
|
||||
return next;
|
||||
}
|
||||
|
||||
const previousRegion = previous.awsRegion?.trim();
|
||||
const previousProfileRegion = getDefaultAwsRegion(previous.awsProfile);
|
||||
if (!previousRegion || previousRegion === previousProfileRegion) {
|
||||
next.awsRegion = getDefaultAwsRegion(value);
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getToolErrorPresentation,
|
||||
isWarningToolError,
|
||||
unwrapToolError,
|
||||
} from "./tool-errors";
|
||||
|
||||
describe("tool error presentation", () => {
|
||||
it("unwraps JSON-encoded tool errors", () => {
|
||||
const raw = JSON.stringify({
|
||||
error: "Tool call run_commands was rejected before execution: nope",
|
||||
});
|
||||
|
||||
expect(unwrapToolError(raw)).toBe(
|
||||
"Tool call run_commands was rejected before execution: nope",
|
||||
);
|
||||
});
|
||||
|
||||
it("summarizes invalid tool input as a warning", () => {
|
||||
const raw = JSON.stringify({
|
||||
error:
|
||||
'Tool call run_commands was rejected before execution: Invalid input for tool run_commands: Type validation failed: Value: {"commands":[{"command":"cat file"}]}.\nError message: []',
|
||||
});
|
||||
|
||||
expect(getToolErrorPresentation(raw)).toMatchObject({
|
||||
severity: "warning",
|
||||
summary: "Invalid run_commands input; tool call skipped.",
|
||||
});
|
||||
expect(isWarningToolError(raw)).toBe(true);
|
||||
});
|
||||
|
||||
it("summarizes generic pre-execution rejections as warnings", () => {
|
||||
expect(
|
||||
getToolErrorPresentation(
|
||||
"Tool call editor was rejected before execution: approval request failed",
|
||||
),
|
||||
).toMatchObject({
|
||||
severity: "warning",
|
||||
summary: "editor call was skipped before execution.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-rejection failures as errors", () => {
|
||||
const presentation = getToolErrorPresentation("command failed with exit 1");
|
||||
|
||||
expect(presentation).toEqual({
|
||||
severity: "error",
|
||||
summary: "command failed with exit 1",
|
||||
detail: "command failed with exit 1",
|
||||
});
|
||||
});
|
||||
|
||||
it("summarizes JSON-wrapped hard errors without dumping stacks", () => {
|
||||
const raw = JSON.stringify({
|
||||
error:
|
||||
"Error: command failed with exit 1\n at runTool (/tmp/tool.ts:10:1)\n at async main (/tmp/main.ts:5:1)",
|
||||
});
|
||||
|
||||
expect(getToolErrorPresentation(raw)).toEqual({
|
||||
severity: "error",
|
||||
summary: "command failed with exit 1",
|
||||
detail:
|
||||
"Error: command failed with exit 1\n at runTool (/tmp/tool.ts:10:1)\n at async main (/tmp/main.ts:5:1)",
|
||||
});
|
||||
});
|
||||
|
||||
it("collapses long one-line hard errors", () => {
|
||||
const detail = `Validation failed: ${"x".repeat(180)}`;
|
||||
const presentation = getToolErrorPresentation(detail);
|
||||
|
||||
expect(presentation.severity).toBe("error");
|
||||
expect(presentation.detail).toBe(detail);
|
||||
expect(presentation.summary.length).toBeLessThanOrEqual(140);
|
||||
expect(presentation.summary.endsWith("...")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a generic summary when no string error can be extracted", () => {
|
||||
const presentation = getToolErrorPresentation(
|
||||
JSON.stringify({ code: "E_TOOL", data: { value: 1 } }),
|
||||
);
|
||||
|
||||
expect(presentation).toEqual({
|
||||
severity: "error",
|
||||
summary: "Tool returned a structured error.",
|
||||
detail: JSON.stringify({ code: "E_TOOL", data: { value: 1 } }),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
export interface ToolErrorPresentation {
|
||||
severity: "warning" | "error";
|
||||
summary: string;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
const MAX_ERROR_SUMMARY_LENGTH = 140;
|
||||
|
||||
function extractStringError(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return value.trim() || undefined;
|
||||
}
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
return (
|
||||
extractStringError(record.error) ??
|
||||
extractStringError(record.message) ??
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function unwrapToolError(error: string): string {
|
||||
let current = error.trim();
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
if (!current.startsWith("{") && !current.startsWith("[")) break;
|
||||
try {
|
||||
const parsed = JSON.parse(current) as unknown;
|
||||
const next = extractStringError(parsed);
|
||||
if (!next || next === current) break;
|
||||
current = next.trim();
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function summarizeInvalidInput(message: string): string | undefined {
|
||||
const rejected = message.match(
|
||||
/^Tool call\s+([A-Za-z0-9_-]+)\s+was rejected before execution:\s+Invalid input for tool\s+([A-Za-z0-9_-]+):\s*([^.\n]+)(?:\.|\n|$)/,
|
||||
);
|
||||
if (rejected) {
|
||||
return `Invalid ${rejected[2]} input; tool call skipped.`;
|
||||
}
|
||||
|
||||
const invalid = message.match(
|
||||
/^Invalid input for tool\s+([A-Za-z0-9_-]+):\s*([^.\n]+)(?:\.|\n|$)/,
|
||||
);
|
||||
if (invalid) {
|
||||
return `Invalid ${invalid[1]} input; tool call skipped.`;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function truncateSummary(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length <= MAX_ERROR_SUMMARY_LENGTH) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, MAX_ERROR_SUMMARY_LENGTH - 3).trimEnd()}...`;
|
||||
}
|
||||
|
||||
function summarizeErrorDetail(detail: string): string {
|
||||
const trimmed = detail.trim();
|
||||
if (!trimmed) {
|
||||
return "Tool failed.";
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
|
||||
return "Tool returned a structured error.";
|
||||
}
|
||||
|
||||
const firstLine =
|
||||
trimmed
|
||||
.replace(/\r\n/g, "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.find(Boolean) ?? "Tool failed.";
|
||||
const withoutGenericPrefix = firstLine.replace(/^Error:\s+/i, "");
|
||||
|
||||
return truncateSummary(withoutGenericPrefix.replace(/\s+/g, " "));
|
||||
}
|
||||
|
||||
export function getToolErrorPresentation(error: string): ToolErrorPresentation {
|
||||
const detail = unwrapToolError(error);
|
||||
const inputSummary = summarizeInvalidInput(detail);
|
||||
if (inputSummary) {
|
||||
return {
|
||||
severity: "warning",
|
||||
summary: inputSummary,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
const rejected = detail.match(
|
||||
/^Tool call\s+([A-Za-z0-9_-]+)\s+was rejected before execution:/,
|
||||
);
|
||||
if (rejected) {
|
||||
return {
|
||||
severity: "warning",
|
||||
summary: `${rejected[1]} call was skipped before execution.`,
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
severity: "error",
|
||||
summary: summarizeErrorDetail(detail),
|
||||
detail,
|
||||
};
|
||||
}
|
||||
|
||||
export function isWarningToolError(error: string | undefined): boolean {
|
||||
return error ? getToolErrorPresentation(error).severity === "warning" : false;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getCliNotSubscribedMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getCliSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "./cline-pass-errors";
|
||||
|
||||
describe("cline-pass-errors", () => {
|
||||
it("recognizes both raw and formatted ClinePass subscription messages", () => {
|
||||
expect(
|
||||
isClinePassSubscriptionError(
|
||||
"the user is not subscribed to required model plan",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const sdkFormatted =
|
||||
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/dashboard/subscription?personal=true";
|
||||
const formatted = getCliNotSubscribedMessage();
|
||||
expect(isClinePassSubscriptionError(sdkFormatted)).toBe(true);
|
||||
expect(isClinePassSubscriptionError(formatted)).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(sdkFormatted))).toBe(formatted);
|
||||
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
|
||||
});
|
||||
|
||||
it("formats the ClinePass subscription URL", () => {
|
||||
expect(getCliSubscriptionUrl()).toBe(
|
||||
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes and formats organization account individual subscription errors", () => {
|
||||
const raw =
|
||||
"403 Error 403: organization accounts cannot use individual model inference subscriptions";
|
||||
const formatted = getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
|
||||
expect(isClineOrgIndividualInferenceSubscriptionErrorMessage(raw)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
new Error(formatted),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
|
||||
});
|
||||
});
|
||||
@@ -1,90 +0,0 @@
|
||||
import {
|
||||
type ClineSubscriptionPlan,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
} from "@cline/core";
|
||||
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
return `${new URL(
|
||||
"/promo?code=CLI-8OFF&personal=true",
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString()}`;
|
||||
}
|
||||
|
||||
export function getCliNotSubscribedMessage(): string {
|
||||
return `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
|
||||
}
|
||||
|
||||
export function getIndividualPlanFeatures(
|
||||
plans: ClineSubscriptionPlan[],
|
||||
): string[] {
|
||||
const planWithFeatures = plans.find((plan) => plan.interval === "Monthly");
|
||||
|
||||
return planWithFeatures?.features?.included ?? [];
|
||||
}
|
||||
|
||||
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return (
|
||||
normalized.includes("no access to clinepass subscription models yet") &&
|
||||
normalized.includes("subscribe to clinepass")
|
||||
);
|
||||
}
|
||||
|
||||
export function isClinePassSubscriptionError(error: unknown): boolean {
|
||||
if (isClineNotSubscribedError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClineNotSubscribedError" ||
|
||||
isClineNotSubscribedMessage(error.message) ||
|
||||
isFormattedClinePassSubscriptionMessage(error.message)
|
||||
);
|
||||
}
|
||||
return (
|
||||
typeof error === "string" &&
|
||||
(isClineNotSubscribedMessage(error) ||
|
||||
isFormattedClinePassSubscriptionMessage(error))
|
||||
);
|
||||
}
|
||||
|
||||
export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
error: unknown,
|
||||
): boolean {
|
||||
if (isClineOrgIndividualInferenceSubscriptionError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError" ||
|
||||
isClineOrgIndividualInferenceSubscriptionMessage(error.message) ||
|
||||
error.message === getClineOrgIndividualInferenceSubscriptionMessage()
|
||||
);
|
||||
}
|
||||
return (
|
||||
typeof error === "string" &&
|
||||
(isClineOrgIndividualInferenceSubscriptionMessage(error) ||
|
||||
error === getClineOrgIndividualInferenceSubscriptionMessage())
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClinePassSubscriptionError(error)) {
|
||||
return getCliNotSubscribedMessage();
|
||||
}
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
|
||||
return getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
disposeCliFeatureFlagsService,
|
||||
getCliFeatureFlagsService,
|
||||
} from "./feature-flags";
|
||||
|
||||
describe("CLI feature flags singleton", () => {
|
||||
afterEach(async () => {
|
||||
await disposeCliFeatureFlagsService();
|
||||
});
|
||||
|
||||
it("recreates the singleton after disposal", async () => {
|
||||
const service = getCliFeatureFlagsService();
|
||||
|
||||
await disposeCliFeatureFlagsService();
|
||||
|
||||
expect(getCliFeatureFlagsService()).not.toBe(service);
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
type BasicLogger,
|
||||
type FeatureFlagsContext,
|
||||
FeatureFlagsService,
|
||||
type ITelemetryService,
|
||||
NoOpFeatureFlagsProvider,
|
||||
registerDisposable,
|
||||
resolveCoreDistinctId,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
buildClinePostHogClient,
|
||||
PostHogFeatureFlagsProvider,
|
||||
} from "@cline/core/services/feature-flags/posthog";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
let cliFeatureFlagsContext: FeatureFlagsContext = { clientName: "cline-cli" };
|
||||
let cliFeatureFlagsService: FeatureFlagsService | undefined;
|
||||
|
||||
const CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function resolveCliFeatureFlagsCachePath(): string {
|
||||
return join(resolveClineDataDir(), "cache", "feature-flags.json");
|
||||
}
|
||||
|
||||
function ensureCliDistinctId(): string {
|
||||
const distinctId = cliFeatureFlagsContext.distinctId?.trim();
|
||||
if (distinctId) {
|
||||
return distinctId;
|
||||
}
|
||||
const resolved = resolveCoreDistinctId();
|
||||
cliFeatureFlagsContext.distinctId = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getCliFeatureFlagsContext(): FeatureFlagsContext {
|
||||
ensureCliDistinctId();
|
||||
return { ...cliFeatureFlagsContext };
|
||||
}
|
||||
|
||||
export function getCliFeatureFlagsService(options?: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
}): FeatureFlagsService {
|
||||
if (!cliFeatureFlagsService) {
|
||||
const apiKey = process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
const provider =
|
||||
apiKey &&
|
||||
process.env.IS_TEST !== "true" &&
|
||||
process.env.E2E_TEST !== "true"
|
||||
? new PostHogFeatureFlagsProvider({
|
||||
client: buildClinePostHogClient(apiKey),
|
||||
config: {
|
||||
logger: options?.logger,
|
||||
},
|
||||
})
|
||||
: new NoOpFeatureFlagsProvider();
|
||||
|
||||
cliFeatureFlagsService = new FeatureFlagsService({
|
||||
provider,
|
||||
telemetry: options?.telemetry,
|
||||
logger: options?.logger,
|
||||
context: getCliFeatureFlagsContext(),
|
||||
cacheFilePath: resolveCliFeatureFlagsCachePath(),
|
||||
persistentCacheMaxAgeMs: CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS,
|
||||
});
|
||||
registerDisposable(disposeCliFeatureFlagsService);
|
||||
}
|
||||
|
||||
return cliFeatureFlagsService;
|
||||
}
|
||||
|
||||
export function refreshCliFeatureFlagsInBackground(logger?: BasicLogger): void {
|
||||
const service = getCliFeatureFlagsService({ logger });
|
||||
void service.poll().catch((error) => {
|
||||
logger?.error?.("Error refreshing CLI feature flags", { error });
|
||||
});
|
||||
}
|
||||
|
||||
export async function disposeCliFeatureFlagsService(): Promise<void> {
|
||||
if (!cliFeatureFlagsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = cliFeatureFlagsService;
|
||||
cliFeatureFlagsService = undefined;
|
||||
await current.dispose();
|
||||
}
|
||||
|
||||
export function setCliFeatureFlagsAccountContext(account: {
|
||||
id?: string;
|
||||
email?: string;
|
||||
}): void {
|
||||
const accountId = account.id?.trim();
|
||||
cliFeatureFlagsContext = {
|
||||
...cliFeatureFlagsContext,
|
||||
...(accountId ? { distinctId: accountId, userId: accountId } : {}),
|
||||
...(account.email?.trim() ? { email: account.email.trim() } : {}),
|
||||
};
|
||||
cliFeatureFlagsService?.setContext(getCliFeatureFlagsContext());
|
||||
}
|
||||
|
||||
export async function identifyFeatureFlagsAccount(
|
||||
account: { id?: string; email?: string },
|
||||
logger?: BasicLogger,
|
||||
): Promise<void> {
|
||||
setCliFeatureFlagsAccountContext(account);
|
||||
|
||||
if (!cliFeatureFlagsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cliFeatureFlagsService.poll();
|
||||
} catch (error) {
|
||||
logger?.error?.("Error polling CLI feature flags", { error });
|
||||
}
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearClineFreeModelCostCache,
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "./free-model-cost";
|
||||
|
||||
afterEach(() => {
|
||||
clearClineFreeModelCostCache();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("shouldZeroClineFreeModelCost", () => {
|
||||
it("uses the Cline free model list", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://cline.test/api/v1/ai/cline/recommended-models",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not zero non-Cline providers", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "openrouter",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not match a paid model by only the final path segment", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "acme/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("retries after a failed free model list fetch", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zeroCliUsageCost", () => {
|
||||
it("zeros total cost while preserving token usage", () => {
|
||||
expect(
|
||||
zeroCliUsageCost(
|
||||
{
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.001,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("zeroCliAgentEventCost", () => {
|
||||
it("zeros usage event cost fields", () => {
|
||||
const event = {
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cost: 0.001,
|
||||
totalCost: 0.001,
|
||||
} as AgentEvent;
|
||||
|
||||
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("zeros done event usage cost", () => {
|
||||
const event = {
|
||||
type: "done",
|
||||
reason: "completed",
|
||||
text: "ok",
|
||||
iterations: 1,
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.001,
|
||||
},
|
||||
} as AgentEvent;
|
||||
|
||||
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
|
||||
usage: { totalCost: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { Config } from "./types";
|
||||
|
||||
const CLINE_RECOMMENDED_MODELS_TIMEOUT_MS = 5_000;
|
||||
const freeModelIdsByBaseUrl = new Map<
|
||||
string,
|
||||
Promise<readonly string[] | undefined>
|
||||
>();
|
||||
|
||||
function normalizeModelId(modelId: string | undefined): string {
|
||||
return modelId?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function modelIdsMatch(selectedModelId: string, freeModelId: string): boolean {
|
||||
const selected = normalizeModelId(selectedModelId);
|
||||
const free = normalizeModelId(freeModelId);
|
||||
if (!selected || !free) return false;
|
||||
return selected === free;
|
||||
}
|
||||
|
||||
function resolveClineRecommendedModelsUrl(baseUrl: string): string {
|
||||
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
|
||||
const apiBaseUrl = normalizedBaseUrl.endsWith("/api/v1")
|
||||
? normalizedBaseUrl.slice(0, -"/api/v1".length)
|
||||
: normalizedBaseUrl;
|
||||
return `${apiBaseUrl}/api/v1/ai/cline/recommended-models`;
|
||||
}
|
||||
|
||||
async function fetchClineFreeModelIds(
|
||||
baseUrl: string,
|
||||
): Promise<readonly string[] | undefined> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
CLINE_RECOMMENDED_MODELS_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetch(resolveClineRecommendedModelsUrl(baseUrl), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const json = (await response.json()) as { free?: unknown };
|
||||
return Array.isArray(json.free)
|
||||
? json.free
|
||||
.map((model) =>
|
||||
model && typeof model === "object"
|
||||
? (model as Record<string, unknown>).id
|
||||
: undefined,
|
||||
)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
: [];
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
|
||||
const cacheKey = baseUrl.trim();
|
||||
let cached = freeModelIdsByBaseUrl.get(cacheKey);
|
||||
if (!cached) {
|
||||
cached = fetchClineFreeModelIds(cacheKey).then((ids) => {
|
||||
if (!ids) freeModelIdsByBaseUrl.delete(cacheKey);
|
||||
return ids;
|
||||
});
|
||||
freeModelIdsByBaseUrl.set(cacheKey, cached);
|
||||
}
|
||||
return cached.then((ids) => ids ?? []);
|
||||
}
|
||||
|
||||
export async function shouldZeroClineFreeModelCost(
|
||||
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
|
||||
): Promise<boolean> {
|
||||
if (config.providerId !== "cline") return false;
|
||||
const modelId = normalizeModelId(config.modelId);
|
||||
if (!modelId) return false;
|
||||
|
||||
const baseUrl =
|
||||
config.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
|
||||
const freeModelIds = await getClineFreeModelIds(baseUrl);
|
||||
return freeModelIds.some((freeModelId) =>
|
||||
modelIdsMatch(modelId, freeModelId),
|
||||
);
|
||||
}
|
||||
|
||||
export function zeroCliUsageCost<T extends { totalCost?: number } | undefined>(
|
||||
usage: T,
|
||||
shouldZeroCost: boolean,
|
||||
): T {
|
||||
if (
|
||||
!shouldZeroCost ||
|
||||
!usage ||
|
||||
typeof usage.totalCost !== "number" ||
|
||||
usage.totalCost === 0
|
||||
) {
|
||||
return usage;
|
||||
}
|
||||
return { ...usage, totalCost: 0 } as T;
|
||||
}
|
||||
|
||||
export function zeroCliAgentEventCost(
|
||||
event: AgentEvent,
|
||||
shouldZeroCost: boolean,
|
||||
): AgentEvent {
|
||||
if (!shouldZeroCost) return event;
|
||||
if (event.type === "done" && event.usage) {
|
||||
return {
|
||||
...event,
|
||||
usage: zeroCliUsageCost(event.usage, true),
|
||||
};
|
||||
}
|
||||
if (event.type !== "usage") return event;
|
||||
const next = { ...event } as Record<string, unknown>;
|
||||
if (typeof next.cost === "number") next.cost = 0;
|
||||
if (typeof next.totalCost === "number") next.totalCost = 0;
|
||||
return next as unknown as AgentEvent;
|
||||
}
|
||||
|
||||
export function clearClineFreeModelCostCache(): void {
|
||||
freeModelIdsByBaseUrl.clear();
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildHistoryResumeArgs } from "./history-resume";
|
||||
|
||||
describe("buildHistoryResumeArgs", () => {
|
||||
it("replaces the history subcommand with --id", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["history"],
|
||||
}),
|
||||
).toEqual(["--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("preserves global flags that precede the subcommand", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: [
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"history",
|
||||
"--limit",
|
||||
"5",
|
||||
],
|
||||
remainingArgs: ["history", "--limit", "5"],
|
||||
}),
|
||||
).toEqual([
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"--id",
|
||||
"sess_1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a global flag value that matches the subcommand alias", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["-m", "h", "h"],
|
||||
remainingArgs: ["h"],
|
||||
}),
|
||||
).toEqual(["-m", "h", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("forwards a config dir passed as a subcommand option", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history", "--config", "/tmp/conf"],
|
||||
remainingArgs: ["history", "--config", "/tmp/conf"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("does not duplicate a config dir already in the global flags", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["--config", "/tmp/conf", "history"],
|
||||
remainingArgs: ["history"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("recognizes the --config=<dir> spelling in global flags", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["--config=/tmp/conf", "history"],
|
||||
remainingArgs: ["history"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config=/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("returns undefined when remaining args are not a suffix of argv", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history", "--limit", "5"],
|
||||
remainingArgs: ["history", "--limit", "9"],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["extra", "history"],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import { resolveCliLaunchSpec } from "./internal-launch";
|
||||
|
||||
export interface HistoryResumeCommand {
|
||||
launcher: string;
|
||||
childArgs: string[];
|
||||
}
|
||||
|
||||
export interface BuildHistoryResumeArgsInput {
|
||||
sessionId: string;
|
||||
/** Full normalized CLI args (process.argv.slice(2) after normalization). */
|
||||
normalizedArgs: string[];
|
||||
/**
|
||||
* Commander's `program.args` after parsing: the `history` subcommand token
|
||||
* and everything following it. Must be a suffix of `normalizedArgs`.
|
||||
*/
|
||||
remainingArgs: string[];
|
||||
/**
|
||||
* Config dir resolved from the full argv. Forwarded explicitly because
|
||||
* `--config` may have been passed as a `history` subcommand option, which
|
||||
* would otherwise be dropped with the rest of the subcommand args.
|
||||
*/
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds argv for relaunching the CLI as `cline <globalFlags> --id <sessionId>`
|
||||
* after a session is picked in `cline history`. Returns undefined when the
|
||||
* global-flag prefix cannot be derived safely (caller falls back to resuming
|
||||
* in-process).
|
||||
*/
|
||||
export function buildHistoryResumeArgs(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): string[] | undefined {
|
||||
const { sessionId, normalizedArgs, remainingArgs, configDir } = input;
|
||||
const splitIndex = normalizedArgs.length - remainingArgs.length;
|
||||
if (splitIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
for (let i = 0; i < remainingArgs.length; i++) {
|
||||
if (normalizedArgs[splitIndex + i] !== remainingArgs[i]) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const globalArgs = normalizedArgs.slice(0, splitIndex);
|
||||
const args = [...globalArgs];
|
||||
const hasConfigFlag = globalArgs.some(
|
||||
(arg) => arg === "--config" || arg.startsWith("--config="),
|
||||
);
|
||||
if (configDir && !hasConfigFlag) {
|
||||
args.push("--config", configDir);
|
||||
}
|
||||
args.push("--id", sessionId);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function buildHistoryResumeCommand(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): HistoryResumeCommand | undefined {
|
||||
const childArgs = buildHistoryResumeArgs(input);
|
||||
if (!childArgs) {
|
||||
return undefined;
|
||||
}
|
||||
const spec = resolveCliLaunchSpec();
|
||||
if (!spec) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
launcher: spec.launcher,
|
||||
childArgs: [...spec.childArgsPrefix, ...childArgs],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes a history-picked session in a fresh `cline --id <sessionId>` child
|
||||
* process with inherited stdio, and returns its exit code. Creating a second
|
||||
* OpenTUI renderer in the picker's process can crash natively during teardown
|
||||
* (Bun "panic(main thread): Segmentation fault" on Ctrl+C), so the resumed
|
||||
* interactive TUI must get a process of its own.
|
||||
*
|
||||
* Returns undefined when the child cannot be launched; the caller should fall
|
||||
* back to resuming in-process.
|
||||
*/
|
||||
export async function spawnHistoryResume(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): Promise<number | undefined> {
|
||||
const command = buildHistoryResumeCommand(input);
|
||||
if (!command) {
|
||||
return undefined;
|
||||
}
|
||||
const { spawn } = await import("node:child_process");
|
||||
return await new Promise<number | undefined>((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(command.launcher, command.childArgs, {
|
||||
stdio: "inherit",
|
||||
});
|
||||
} catch {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
// The child shares this foreground process group, so terminal-generated
|
||||
// Ctrl+C already reaches it. Keep the parent alive to reap the child
|
||||
// without re-forwarding a second signal into the TUI teardown path.
|
||||
const suppressParentSignal = () => {};
|
||||
process.on("SIGINT", suppressParentSignal);
|
||||
process.on("SIGTERM", suppressParentSignal);
|
||||
const finish = (value: number | undefined) => {
|
||||
process.off("SIGINT", suppressParentSignal);
|
||||
process.off("SIGTERM", suppressParentSignal);
|
||||
resolve(value);
|
||||
};
|
||||
child.once("error", () => finish(undefined));
|
||||
child.once("exit", (code, signal) => finish(signal ? 1 : (code ?? 0)));
|
||||
});
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listLocalProviders: vi.fn(async () => ({ providers: [], settingsPath: "" })),
|
||||
getBooleanFlagEnabled: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
listLocalProviders: mocks.listLocalProviders,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: mocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("listLocalProviders", () => {
|
||||
it("passes the ClinePass feature flag into the SDK provider list", async () => {
|
||||
const { listLocalProviders } = await import("./provider-catalog");
|
||||
const manager = {} as never;
|
||||
|
||||
await listLocalProviders(manager);
|
||||
|
||||
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith("ext-cline-pass");
|
||||
expect(mocks.listLocalProviders).toHaveBeenCalledWith(manager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import {
|
||||
listLocalProviders as internalListLocalProviders,
|
||||
type ProviderSettingsManager,
|
||||
} from "@cline/core";
|
||||
import { getCliFeatureFlagsService } from "./feature-flags";
|
||||
|
||||
export async function listLocalProviders(
|
||||
manager: ProviderSettingsManager,
|
||||
): ReturnType<typeof internalListLocalProviders> {
|
||||
return await internalListLocalProviders(manager, {
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCliReasoning } from "./reasoning";
|
||||
|
||||
describe("resolveCliReasoning", () => {
|
||||
it("leaves reasoning unset when neither CLI nor persisted settings specify it", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: undefined,
|
||||
reasoningEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit --thinking none as disabled reasoning", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
thinkingExplicitlySet: true,
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers explicit --thinking over persisted reasoning settings", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: true,
|
||||
thinkingExplicitlySet: true,
|
||||
reasoningEffort: "low",
|
||||
persistedReasoning: { enabled: false },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: true,
|
||||
reasoningEffort: "low",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses persisted disabled reasoning when --thinking is unset", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
persistedReasoning: { enabled: false },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses persisted effort none as disabled reasoning when --thinking is unset", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
persistedReasoning: { effort: "none" },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses persisted active effort when --thinking is unset", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
persistedReasoning: { enabled: true, effort: "high" },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses medium effort when persisted reasoning is enabled without an effort", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
persistedReasoning: { enabled: true },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: true,
|
||||
reasoningEffort: "medium",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { ProviderSettings } from "@cline/core";
|
||||
import type { CliReasoningEffort } from "./types";
|
||||
|
||||
type ActiveCliReasoningEffort = Exclude<CliReasoningEffort, "none">;
|
||||
|
||||
const ACTIVE_REASONING_EFFORTS = new Set<ActiveCliReasoningEffort>([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
]);
|
||||
|
||||
export interface ResolveCliReasoningInput {
|
||||
thinking: boolean;
|
||||
thinkingExplicitlySet?: boolean;
|
||||
reasoningEffort?: CliReasoningEffort;
|
||||
persistedReasoning?: ProviderSettings["reasoning"];
|
||||
}
|
||||
|
||||
export interface ResolvedCliReasoning {
|
||||
thinking?: boolean;
|
||||
reasoningEffort?: ActiveCliReasoningEffort;
|
||||
}
|
||||
|
||||
function isActiveReasoningEffort(
|
||||
effort: unknown,
|
||||
): effort is ActiveCliReasoningEffort {
|
||||
return (
|
||||
typeof effort === "string" &&
|
||||
ACTIVE_REASONING_EFFORTS.has(effort as ActiveCliReasoningEffort)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCliReasoning({
|
||||
thinking,
|
||||
thinkingExplicitlySet,
|
||||
reasoningEffort,
|
||||
persistedReasoning,
|
||||
}: ResolveCliReasoningInput): ResolvedCliReasoning {
|
||||
if (thinkingExplicitlySet) {
|
||||
return {
|
||||
thinking,
|
||||
reasoningEffort: isActiveReasoningEffort(reasoningEffort)
|
||||
? reasoningEffort
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
persistedReasoning?.enabled === false ||
|
||||
persistedReasoning?.effort === "none"
|
||||
) {
|
||||
return { thinking: false, reasoningEffort: undefined };
|
||||
}
|
||||
|
||||
if (isActiveReasoningEffort(persistedReasoning?.effort)) {
|
||||
return { thinking: true, reasoningEffort: persistedReasoning.effort };
|
||||
}
|
||||
|
||||
if (persistedReasoning?.enabled === true) {
|
||||
return { thinking: true, reasoningEffort: "medium" };
|
||||
}
|
||||
|
||||
return { thinking: undefined, reasoningEffort: undefined };
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Llms } from "@cline/core";
|
||||
|
||||
export function shouldShowCliUsageCost(providerId: string): boolean {
|
||||
return Llms.shouldShowProviderUsageCost(providerId);
|
||||
}
|
||||
|
||||
export function shouldShowCliUsageCoveredBySubscription(
|
||||
providerId: string,
|
||||
): boolean {
|
||||
return Llms.resolveProviderUsageCostDisplay(providerId) === "subscription";
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PLATFORMS, shouldIncludeField } from "./platforms";
|
||||
|
||||
describe("connect wizard platform security fields", () => {
|
||||
it("does not ask Telegram users to re-enter the bot username", () => {
|
||||
const telegram = PLATFORMS.find((platform) => platform.id === "telegram");
|
||||
|
||||
expect(telegram?.fields.map((field) => field.label)).toEqual(["Bot token"]);
|
||||
expect(telegram?.fields.map((field) => field.flag)).toEqual(["-k"]);
|
||||
});
|
||||
|
||||
it("rejects unsafe Telegram and Slack access restriction identifiers", () => {
|
||||
const telegram = PLATFORMS.find((platform) => platform.id === "telegram");
|
||||
const slack = PLATFORMS.find((platform) => platform.id === "slack");
|
||||
|
||||
const telegramUser = telegram?.security?.fields.find(
|
||||
(field) => field.key === "userId",
|
||||
);
|
||||
const slackTeam = slack?.security?.fields.find(
|
||||
(field) => field.key === "teamId",
|
||||
);
|
||||
const slackUser = slack?.security?.fields.find(
|
||||
(field) => field.key === "userId",
|
||||
);
|
||||
|
||||
expect(telegramUser?.validate?.("123456")).toBeUndefined();
|
||||
expect(telegramUser?.validate?.("123; rm -rf /")).toContain("digits");
|
||||
expect(slackTeam?.validate?.("T01ABC123")).toBeUndefined();
|
||||
expect(slackTeam?.validate?.("T01;bad")).toContain("Slack workspace");
|
||||
expect(slackUser?.validate?.("U01ABC123")).toBeUndefined();
|
||||
expect(slackUser?.validate?.("U01$(bad)")).toContain("Slack member");
|
||||
});
|
||||
|
||||
it("uses the Telegram allowed user ID flag for wizard security", () => {
|
||||
const telegram = PLATFORMS.find((platform) => platform.id === "telegram");
|
||||
|
||||
const args = telegram?.security?.buildArgs({
|
||||
userId: "123456",
|
||||
});
|
||||
|
||||
expect(args).toEqual(["--allowed-user-id", "123456"]);
|
||||
});
|
||||
|
||||
it("builds an exact-match Slack authorization hook", () => {
|
||||
const slack = PLATFORMS.find((platform) => platform.id === "slack");
|
||||
|
||||
const args = slack?.security?.buildArgs({
|
||||
teamId: "T01ABC123",
|
||||
userId: "U01ABC123",
|
||||
});
|
||||
|
||||
expect(args).toEqual([
|
||||
"--hook-command",
|
||||
`jq -r ".payload.actor.participantKey" | grep -qx "slack:team:T01ABC123:user:U01ABC123" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("asks Slack users for mode-specific setup fields", () => {
|
||||
const slack = PLATFORMS.find((platform) => platform.id === "slack");
|
||||
const fields = slack?.fields ?? [];
|
||||
const webhookValues = { "--base-url": "https://example.test" };
|
||||
const socketValues = { "--base-url": "" };
|
||||
|
||||
expect(fields.map((field) => field.flag)).toEqual([
|
||||
"--bot-token",
|
||||
"--base-url",
|
||||
"--signing-secret",
|
||||
"--app-token",
|
||||
]);
|
||||
expect(
|
||||
fields
|
||||
.filter((field) => shouldIncludeField(field, webhookValues))
|
||||
.map((field) => field.flag),
|
||||
).toEqual(["--bot-token", "--base-url", "--signing-secret"]);
|
||||
expect(
|
||||
fields
|
||||
.filter((field) => shouldIncludeField(field, socketValues))
|
||||
.map((field) => field.flag),
|
||||
).toEqual(["--bot-token", "--base-url", "--app-token"]);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import {
|
||||
CONNECTOR_PLATFORMS,
|
||||
shouldIncludeConnectorField,
|
||||
} from "@cline/shared";
|
||||
|
||||
export type {
|
||||
ConnectorFieldCondition as FieldCondition,
|
||||
ConnectorFieldDef as FieldDef,
|
||||
ConnectorPlatformDef as PlatformDef,
|
||||
ConnectorSecurityDef as SecurityDef,
|
||||
ConnectorSecurityFieldDef as SecurityFieldDef,
|
||||
} from "@cline/shared";
|
||||
export { CONNECTOR_PLATFORMS, shouldIncludeConnectorField };
|
||||
|
||||
export const PLATFORMS = CONNECTOR_PLATFORMS;
|
||||
export const shouldIncludeField = shouldIncludeConnectorField;
|
||||
@@ -1,45 +0,0 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import {
|
||||
authorizeMcpServerOAuth,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
} from "@cline/core";
|
||||
import open from "open";
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.trim();
|
||||
if (message.length > 0) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export async function authorizeMcpServerOAuthWithBrowser(
|
||||
name: string,
|
||||
options: { throwOnError?: boolean } = {},
|
||||
): Promise<void> {
|
||||
p.log.info("Opening browser for MCP OAuth authorization");
|
||||
try {
|
||||
const result = await authorizeMcpServerOAuth({
|
||||
serverName: name,
|
||||
filePath: resolveDefaultMcpSettingsPath(),
|
||||
openUrl: async (url) => {
|
||||
p.log.message(`Authorization URL: ${url}`);
|
||||
await open(url, { wait: false });
|
||||
},
|
||||
onServerListening: (info) => {
|
||||
p.log.message(`Waiting for OAuth callback at ${info.callbackUrl}`);
|
||||
},
|
||||
});
|
||||
p.log.success(result.message);
|
||||
} catch (error) {
|
||||
if (options.throwOnError === true) {
|
||||
throw error instanceof Error ? error : new Error(toErrorMessage(error));
|
||||
}
|
||||
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
|
||||
p.log.warn(
|
||||
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
type McpServerOAuthState,
|
||||
McpSettingsUpdateSkippedError,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
|
||||
export interface McpServerEntry {
|
||||
name: string;
|
||||
transport: McpTransport;
|
||||
disabled?: boolean;
|
||||
oauth?: McpServerOAuthState;
|
||||
}
|
||||
|
||||
export type McpTransport =
|
||||
| {
|
||||
type: "stdio";
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
| { type: "sse"; url: string; headers?: Record<string, string> }
|
||||
| { type: "streamableHttp"; url: string; headers?: Record<string, string> };
|
||||
|
||||
export function getSettingsPath(): string {
|
||||
return resolveDefaultMcpSettingsPath();
|
||||
}
|
||||
|
||||
export function loadServers(): McpServerEntry[] {
|
||||
const path = getSettingsPath();
|
||||
if (!existsSync(path)) return [];
|
||||
try {
|
||||
const raw = readFileSync(path, "utf-8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
const servers = parsed.mcpServers ?? {};
|
||||
return Object.entries(servers).map(([name, value]) => {
|
||||
const entry = value as Record<string, unknown>;
|
||||
const transport = (entry.transport ?? entry) as McpTransport;
|
||||
const oauth =
|
||||
entry.oauth &&
|
||||
typeof entry.oauth === "object" &&
|
||||
!Array.isArray(entry.oauth)
|
||||
? (entry.oauth as McpServerOAuthState)
|
||||
: undefined;
|
||||
return {
|
||||
name,
|
||||
transport,
|
||||
disabled: entry.disabled === true,
|
||||
oauth,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getOwnServerRecord(
|
||||
servers: Record<string, unknown>,
|
||||
name: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!Object.hasOwn(servers, name)) {
|
||||
return undefined;
|
||||
}
|
||||
const value = servers[name];
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate the MCP settings file through @cline/core's locked read-update-write
|
||||
* helper. The mutator must be synchronous and pure; the helper may call it more
|
||||
* than once to verify deterministic output. Throw McpSettingsUpdateSkippedError
|
||||
* for normal no-op cases instead of returning a boolean that callers can ignore.
|
||||
*/
|
||||
function mutateServers(mutate: (servers: Record<string, unknown>) => void): void {
|
||||
updateMcpSettingsFileSync(getSettingsPath(), (settings) => {
|
||||
const serversValue = settings.mcpServers;
|
||||
const servers = serversValue && typeof serversValue === "object" && !Array.isArray(serversValue)
|
||||
? { ...(serversValue as Record<string, unknown>) }
|
||||
: {};
|
||||
mutate(servers);
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
}
|
||||
|
||||
export function addServer(name: string, transport: McpTransport): void {
|
||||
mutateServers((servers) => {
|
||||
servers[name] = { transport };
|
||||
});
|
||||
}
|
||||
|
||||
export function removeServer(name: string): boolean {
|
||||
try {
|
||||
mutateServers((servers) => {
|
||||
if (!(name in servers)) {
|
||||
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
|
||||
}
|
||||
delete servers[name];
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof McpSettingsUpdateSkippedError) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateServer(name: string, transport: McpTransport): void {
|
||||
mutateServers((servers) => {
|
||||
const existing =
|
||||
servers[name] && typeof servers[name] === "object"
|
||||
? (servers[name] as Record<string, unknown>)
|
||||
: {};
|
||||
servers[name] = { ...existing, transport };
|
||||
});
|
||||
}
|
||||
|
||||
export function clearServerOAuth(name: string): void {
|
||||
try {
|
||||
mutateServers((servers) => {
|
||||
const existing = getOwnServerRecord(servers, name);
|
||||
if (!existing) {
|
||||
throw new McpSettingsUpdateSkippedError(`MCP server not found: ${name}`);
|
||||
}
|
||||
delete existing.oauth;
|
||||
servers[name] = existing;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof McpSettingsUpdateSkippedError) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleServer(name: string, disabled: boolean): void {
|
||||
mutateServers((servers) => {
|
||||
const existing =
|
||||
servers[name] && typeof servers[name] === "object"
|
||||
? (servers[name] as Record<string, unknown>)
|
||||
: {};
|
||||
if (disabled) {
|
||||
existing.disabled = true;
|
||||
} else {
|
||||
delete existing.disabled;
|
||||
}
|
||||
servers[name] = existing;
|
||||
});
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
# Cline Hub
|
||||
|
||||
A browser dashboard for the local Cline hub. Open it to see who's connected, what sessions are running, drive a session from a chat box, and restart the hub when you need a fresh daemon.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- live list of connected hub clients (from `HubUIClient.subscribeUI`)
|
||||
- live list of active sessions with status, model, and titles
|
||||
- click a session to view its message history and stream new assistant output
|
||||
- start a new session from an initial prompt — workspace/provider/model are reused from the most recent session, or `CLINE_PROVIDER` / `CLINE_MODEL` env vars
|
||||
- send messages to the selected session and watch chunks stream back
|
||||
- **Restart Hub** button: gracefully stops the local detached hub and respawns a fresh one
|
||||
- optional LAN/tunnel exposure gated by a shared `ROOM_SECRET`
|
||||
|
||||
The dashboard registers two clients with the hub: a `cline-hub-server` (via `ClineCore`) for driving sessions and a `cline-hub-server` (via `HubUIClient`) for the admin view.
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
cd apps/cline-hub
|
||||
bun run start
|
||||
```
|
||||
|
||||
Open <http://127.0.0.1:8787> and click **Connect**. The server will discover or spawn a local detached hub on startup; the hub endpoint is printed in the console and shown in the sidebar.
|
||||
|
||||
For webview development with Vite hot reload:
|
||||
|
||||
```bash
|
||||
cd apps/cline-hub
|
||||
bun run dev
|
||||
```
|
||||
|
||||
This starts the Vite webview server on <http://127.0.0.1:5173> and the hub dashboard on <http://127.0.0.1:8787>. Open the dashboard URL; the served page loads webview modules from Vite, so changes under `src/webview/src` hot reload without rebuilding. Use `CLINE_HUB_WEBVIEW_DEV_PORT` or `CLINE_HUB_WEBVIEW_DEV_HOST` to change the Vite bind address.
|
||||
|
||||
To start a brand-new session, the dashboard needs to know which provider and model to use. It picks them up automatically from the most recent session on the hub. If there are no recent sessions, set `CLINE_PROVIDER` and `CLINE_MODEL` in the environment before running.
|
||||
|
||||
## Configuration
|
||||
|
||||
Environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
| --- | --- | --- |
|
||||
| `HOST` | `127.0.0.1` | Bind host for the dashboard. Use the default for same-machine development. Set `HOST=0.0.0.0` only when intentionally exposing the dashboard on a LAN/tunnel. |
|
||||
| `CLINE_HUB_DASHBOARD_PORT` | `8787` | Dashboard HTTP/WebSocket port. |
|
||||
| `PUBLIC_URL` | `http://<HOST>:<PORT>` (`127.0.0.1` when binding `0.0.0.0`) | URL printed for humans to open/copy. Set this to your LAN URL or tunnel URL. |
|
||||
| `ROOM_SECRET` | unset | Shared invite secret required for browser WebSocket connections when `HOST` is non-local. |
|
||||
| `WORKSPACE_ROOT` | current directory | Workspace passed to the hub on startup. |
|
||||
| `CLINE_PROVIDER` | unset | Fallback provider id when no recent session is available to copy from. |
|
||||
| `CLINE_MODEL` | unset | Fallback model id when no recent session is available to copy from. |
|
||||
|
||||
The server prints both the bind URL and the public/invite URL at startup. When `ROOM_SECRET` is set, the printed invite URL includes `?roomSecret=...`; the browser UI also lets you paste the secret manually.
|
||||
|
||||
Validate option parsing without starting a server:
|
||||
|
||||
```bash
|
||||
bun run smoke:options
|
||||
```
|
||||
|
||||
## LAN usage
|
||||
|
||||
Choose a strong room secret and bind explicitly to all interfaces:
|
||||
|
||||
```bash
|
||||
cd apps/cline-hub
|
||||
HOST=0.0.0.0 \
|
||||
CLINE_HUB_DASHBOARD_PORT=8787 \
|
||||
PUBLIC_URL=http://YOUR_LAN_IP:8787 \
|
||||
ROOM_SECRET='use-a-long-random-secret' \
|
||||
bun run start
|
||||
```
|
||||
|
||||
Share the printed invite URL with another machine on the same LAN.
|
||||
|
||||
`ROOM_SECRET` is required for `HOST=0.0.0.0`; without it the dashboard exits before listening.
|
||||
|
||||
## Tunnel usage
|
||||
|
||||
Start the dashboard locally with an explicit secret:
|
||||
|
||||
```bash
|
||||
cd apps/cline-hub
|
||||
ROOM_SECRET='use-a-long-random-secret' bun run start
|
||||
```
|
||||
|
||||
In another terminal, expose the local port with your tunnel provider, for example:
|
||||
|
||||
```bash
|
||||
ngrok http 8787
|
||||
```
|
||||
|
||||
Restart the dashboard with the tunnel URL as `PUBLIC_URL` so the printed invite URL is copyable:
|
||||
|
||||
```bash
|
||||
PUBLIC_URL=https://YOUR-TUNNEL.example \
|
||||
ROOM_SECRET='use-a-long-random-secret' \
|
||||
bun run start
|
||||
```
|
||||
|
||||
Share only the printed invite URL with trusted participants.
|
||||
|
||||
## Restarting the hub
|
||||
|
||||
Clicking **Restart Hub** in the sidebar:
|
||||
|
||||
1. Detaches the dashboard's `ClineCore` and `HubUIClient` from the current hub.
|
||||
2. Calls `stopLocalHubServerGracefully()` to shut the local detached hub down.
|
||||
3. Calls `ensureDetachedHubServer(workspaceRoot)` to spawn a fresh hub.
|
||||
4. Reconnects and broadcasts the new hub state to every open browser tab.
|
||||
|
||||
Sessions running on the previous hub are stopped along with the hub. Other clients connected to that hub (CLI, VS Code, menubar) will see their connection drop and reconnect to the new daemon on next request.
|
||||
|
||||
## Security warning
|
||||
|
||||
This is an example dashboard, not a production admin tool. Exposing it on a LAN or tunnel lets anyone with the invite secret list clients/sessions on your hub, drive sessions, and restart the hub. Use a long random `ROOM_SECRET`, only share the URL with trusted participants, and stop the process when you are done. The hub and agent runtime remain owned by the host machine.
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"name": "@cline/cline-hub",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Browser dashboard for the Cline hub: live clients, sessions, streaming chat, and hub restart.",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": "./src/server.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"build:webview": "bun run --cwd src/webview build",
|
||||
"dev": "bun run src/dev.ts",
|
||||
"start": "bun run src/server.ts",
|
||||
"smoke:options": "bun run src/validate-options.ts",
|
||||
"test": "bunx vitest run --config vitest.config.ts",
|
||||
"typecheck": "bun tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cline/core": "workspace:*",
|
||||
"@cline/llms": "workspace:*",
|
||||
"@cline/shared": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import process from "node:process";
|
||||
|
||||
const webviewHost =
|
||||
process.env.CLINE_HUB_WEBVIEW_DEV_HOST?.trim() || "127.0.0.1";
|
||||
const webviewPort = process.env.CLINE_HUB_WEBVIEW_DEV_PORT?.trim() || "5173";
|
||||
const webviewDevServerUrl =
|
||||
process.env.VITE_DEV_SERVER_URL?.trim() ||
|
||||
`http://${webviewHost}:${webviewPort}`;
|
||||
|
||||
const cwd = process.cwd();
|
||||
const webviewCwd = join(cwd, "src", "webview");
|
||||
|
||||
const children: Bun.Subprocess[] = [];
|
||||
let shuttingDown = false;
|
||||
|
||||
function spawn(
|
||||
name: string,
|
||||
command: string[],
|
||||
options: {
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
},
|
||||
): Bun.Subprocess {
|
||||
const child = Bun.spawn(command, {
|
||||
...options,
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
});
|
||||
children.push(child);
|
||||
void child.exited.then((code) => {
|
||||
if (!shuttingDown) {
|
||||
console.error(`[cline-hub:dev] ${name} exited with code ${code}`);
|
||||
shutdown(code === 0 ? 0 : 1);
|
||||
}
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
function shutdown(exitCode = 0): void {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
for (const child of children) {
|
||||
try {
|
||||
child.kill();
|
||||
} catch {
|
||||
// The process may have already exited.
|
||||
}
|
||||
}
|
||||
process.exitCode = exitCode;
|
||||
}
|
||||
|
||||
process.on("SIGINT", () => shutdown(0));
|
||||
process.on("SIGTERM", () => shutdown(0));
|
||||
|
||||
console.log(`[cline-hub:dev] Vite webview: ${webviewDevServerUrl}`);
|
||||
console.log("[cline-hub:dev] Hub dashboard: http://127.0.0.1:8787/");
|
||||
|
||||
spawn(
|
||||
"webview",
|
||||
[
|
||||
"bun",
|
||||
"run",
|
||||
"dev",
|
||||
"--host",
|
||||
webviewHost,
|
||||
"--port",
|
||||
webviewPort,
|
||||
"--strictPort",
|
||||
],
|
||||
{
|
||||
cwd: webviewCwd,
|
||||
env: process.env,
|
||||
},
|
||||
);
|
||||
|
||||
spawn("server", ["bun", "run", "src/server.ts"], {
|
||||
cwd,
|
||||
env: {
|
||||
...process.env,
|
||||
VITE_DEV_SERVER_URL: webviewDevServerUrl,
|
||||
},
|
||||
});
|
||||
|
||||
await Promise.allSettled(children.map((child) => child.exited));
|
||||
@@ -1,115 +0,0 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
export interface ClineHubServerOptions {
|
||||
host: string;
|
||||
port: number;
|
||||
publicUrl: string;
|
||||
roomSecret?: string;
|
||||
workspaceRoot: string;
|
||||
}
|
||||
|
||||
const DEFAULT_HOST = "127.0.0.1";
|
||||
const DEFAULT_PORT = 8787;
|
||||
const DASHBOARD_PORT_ENV = "CLINE_HUB_DASHBOARD_PORT";
|
||||
|
||||
function parsePort(value: string | undefined): number {
|
||||
if (!value?.trim()) return DEFAULT_PORT;
|
||||
const port = Number.parseInt(value, 10);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(
|
||||
`${DASHBOARD_PORT_ENV} must be an integer from 1 to 65535, got ${value}`,
|
||||
);
|
||||
}
|
||||
return port;
|
||||
}
|
||||
|
||||
function normalizeHost(value: string | undefined): string {
|
||||
return value?.trim() || DEFAULT_HOST;
|
||||
}
|
||||
|
||||
function normalizePublicUrl(
|
||||
value: string | undefined,
|
||||
host: string,
|
||||
port: number,
|
||||
): string {
|
||||
const fallbackHost = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
|
||||
const raw = value?.trim() || `http://${fallbackHost}:${port}`;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`PUBLIC_URL must be a valid http(s) URL, got ${raw}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`PUBLIC_URL must use http: or https:, got ${parsed.protocol}`,
|
||||
);
|
||||
}
|
||||
if (shouldAddDashboardPortToPublicUrl(parsed, port)) {
|
||||
parsed.port = String(port);
|
||||
}
|
||||
parsed.hash = "";
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function normalizeRoomSecret(value: string | undefined): string | undefined {
|
||||
const secret = value?.trim();
|
||||
return secret ? secret : undefined;
|
||||
}
|
||||
|
||||
function isLocalBindHost(host: string): boolean {
|
||||
return host === "127.0.0.1" || host === "localhost" || host === "::1";
|
||||
}
|
||||
|
||||
export function isNonLocalBindHost(host: string): boolean {
|
||||
return !isLocalBindHost(host);
|
||||
}
|
||||
|
||||
export function resolveClineHubServerOptions(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): ClineHubServerOptions {
|
||||
const host = normalizeHost(env.HOST);
|
||||
const port = parsePort(env[DASHBOARD_PORT_ENV]);
|
||||
const publicUrl = normalizePublicUrl(env.PUBLIC_URL, host, port);
|
||||
const roomSecret = normalizeRoomSecret(env.ROOM_SECRET);
|
||||
if (isNonLocalBindHost(host) && !roomSecret) {
|
||||
throw new Error(
|
||||
`ROOM_SECRET is required when HOST=${host}. Use HOST=127.0.0.1 for local-only development or set ROOM_SECRET before exposing this example on a LAN/tunnel.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
host,
|
||||
port,
|
||||
publicUrl,
|
||||
roomSecret,
|
||||
workspaceRoot: env.WORKSPACE_ROOT?.trim() || process.cwd(),
|
||||
};
|
||||
}
|
||||
|
||||
function isDefaultProtocolPort(url: URL, port: number): boolean {
|
||||
return (
|
||||
(url.protocol === "http:" && port === 80) ||
|
||||
(url.protocol === "https:" && port === 443)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldAddDashboardPortToPublicUrl(url: URL, port: number): boolean {
|
||||
if (url.port || isDefaultProtocolPort(url, port)) return false;
|
||||
const hostname = url.hostname.replace(/^\[|\]$/g, "");
|
||||
return hostname === "localhost" || isIP(hostname) !== 0;
|
||||
}
|
||||
|
||||
export function buildInviteUrl(
|
||||
publicUrl: string,
|
||||
roomSecret: string | undefined,
|
||||
): string {
|
||||
const url = new URL(publicUrl);
|
||||
if (roomSecret) {
|
||||
url.searchParams.set("roomSecret", roomSecret);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user