mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3402e30a65 | |||
| e48560dc21 | |||
| 9520826cc1 | |||
| 10a6c06a15 | |||
| b475a0d029 | |||
| 1820360468 | |||
| 39f5e564f6 | |||
| 3f2fe65c19 | |||
| ede87d82f7 | |||
| e6bb1a14ec | |||
| 42ab1b94a2 | |||
| 97d8a33db0 | |||
| 4961bf2898 | |||
| 47f3654b70 | |||
| 7b4a0bf40a | |||
| cf814e1479 | |||
| 40e64baa0a | |||
| 540b9234dc | |||
| 52828aab71 | |||
| 33dafb193b | |||
| 643d945d65 | |||
| f3a215cd0e | |||
| 4fce10248e | |||
| 154f9e0e11 | |||
| c041089a6d | |||
| 62f11cd1d3 | |||
| ea38d1049d | |||
| cfb4ef49be | |||
| a94d1b2c08 | |||
| 5685c2fa36 | |||
| ba28c556b4 | |||
| 0fbcbc45f5 | |||
| b37d8e466b | |||
| 482ae279f8 | |||
| ddb16f7dc6 | |||
| 4aace9e226 | |||
| 437f7eb745 | |||
| 1107df80d3 | |||
| a1a88c4258 | |||
| 5320885770 | |||
| 6fcbd039fa | |||
| 79ffd2f5fb | |||
| 9a83fcb4fa | |||
| 0ad9de2317 | |||
| 1bba06ae6f | |||
| 5575f681f2 | |||
| 4922935564 | |||
| 7e39120191 | |||
| 8c36159c43 | |||
| a193f19468 | |||
| 855d31c86f | |||
| 1acacda3f5 | |||
| 6f2f159f7e | |||
| 96da30d8c7 | |||
| 7a0d48c2e4 | |||
| 18a29b7563 | |||
| 3d8a849f03 | |||
| e7e0e2b559 | |||
| 695492a97b | |||
| 7460d460ac |
@@ -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,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.
|
||||
@@ -6,10 +6,10 @@ HTTP-controlled debugger for the VSCode extension at `src/dev/debug-harness/serv
|
||||
|
||||
```bash
|
||||
# Build extension first if needed (protos + esbuild):
|
||||
bun run protos && IS_DEV=true bun esbuild.mjs
|
||||
npm run protos && IS_DEV=true node esbuild.mjs
|
||||
|
||||
# Launch (skip-build if already built):
|
||||
bun src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
npx tsx src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
|
||||
# In another terminal:
|
||||
curl localhost:19229/api -d '{"method":"status"}'
|
||||
@@ -51,7 +51,7 @@ debug-only hook via `ext.evaluate` (with `awaitPromise: true`):
|
||||
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`).
|
||||
(`npm run dev:mcp-oauth-test-server`).
|
||||
|
||||
## Navigating Views — Use Commands, Not Clicks
|
||||
|
||||
|
||||
+17
-14
@@ -13,9 +13,8 @@ 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
|
||||
@@ -73,7 +72,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
|
||||
@@ -98,10 +97,12 @@ Adding a new key to global state requires updates in multiple places. Missing an
|
||||
|
||||
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 +110,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.
|
||||
@@ -182,7 +185,7 @@ 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
|
||||
npx tsx 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`
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ On the main branch, create a commit that updates:
|
||||
|
||||
3. No changelog-entry file cleanup is needed. Contributors do not create changelog-entry files in this repo.
|
||||
|
||||
**No dependency install is needed.** A CHANGELOG + `version` bump does not change any dependency, and `bun.lock` does not pin workspace-package versions, so the lockfile stays consistent. The publish workflow runs `bun install --frozen-lockfile`, which would *fail* on an out-of-sync lock — so only run `bun install` here if you actually change dependencies (then commit the updated `bun.lock`).
|
||||
**Skip running `npm run install:all`** - release automation handles lockfile consistency as needed.
|
||||
|
||||
Commit with message format: `v{VERSION} Release Notes (hotfix)`
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ body:
|
||||
value: |
|
||||
**Important:** All bug reports must be reproducible using Claude Sonnet 4.5. Cline uses complex prompts so less capable models may not work as expected.
|
||||
- type: dropdown
|
||||
id: cline-surface
|
||||
id: plugin-type
|
||||
attributes:
|
||||
label: Cline Surface
|
||||
description: Which Cline surface are you reporting a bug for?
|
||||
label: Plugin Type
|
||||
description: Which plugin are you reporting a bug for?
|
||||
options:
|
||||
- VSCode Extension
|
||||
- JetBrains Plugin
|
||||
@@ -59,18 +59,6 @@ body:
|
||||
placeholder: 'e.g., cline:anthropic/claude-sonnet-4.5, gemini:gemini-2.5-pro-exp-03-25'
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: ide-diagnostics
|
||||
attributes:
|
||||
label: IDE / CLI Diagnostics
|
||||
description: |
|
||||
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
|
||||
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
|
||||
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
|
||||
- CLI: there is no About dialog. Run `cline --version` and paste the output.
|
||||
placeholder: Paste the copied About info or `cline --version` output here.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: system-info
|
||||
attributes:
|
||||
|
||||
@@ -9,14 +9,14 @@ This is a VS Code extension. Read `.clinerules/general.md` for tribal knowledge
|
||||
- **MCP**: `src/services/mcp/McpHub.ts`.
|
||||
|
||||
## Build & Test (Critical — non-obvious commands)
|
||||
- **Build**: `bun run compile` — NOT `bun run build`.
|
||||
- **Watch**: `bun run watch` (extension + webview).
|
||||
- **Protos**: `bun run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
|
||||
- **Tests**: `bun run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true bun run test:unit`.
|
||||
- **Build**: `npm run compile` — NOT `npm run build`.
|
||||
- **Watch**: `npm run watch` (extension + webview).
|
||||
- **Protos**: `npm run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
|
||||
- **Tests**: `npm run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true npm run test:unit`.
|
||||
|
||||
## Protobuf RPC Workflow (4 steps)
|
||||
1. **Define** in `proto/cline/*.proto`. Naming: `PascalCaseService`, `camelCase` RPCs, `PascalCase` Messages. Use `common.proto` shared types for simple data.
|
||||
2. **Generate**: `bun run protos`.
|
||||
2. **Generate**: `npm run protos`.
|
||||
3. **Backend handler**: `src/core/controller/<domain>/`.
|
||||
4. **Frontend call**: `UiServiceClient.myMethod(Request.create({...}))`.
|
||||
- Adding enums (e.g. `ClineSay`) → also update `src/shared/proto-conversions/cline-message.ts`.
|
||||
@@ -38,13 +38,13 @@ For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/mod
|
||||
4. Whitelist in `src/core/prompts/system-prompt/variants/*/config.ts` for each model family.
|
||||
5. Handler in `src/core/task/tools/handlers/`, wire in `ToolExecutor.ts`.
|
||||
6. If tool has UI: add `ClineSay` enum in proto → `ExtensionMessage.ts` → `cline-message.ts` → `ChatRow.tsx`.
|
||||
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true bun run test:unit`.
|
||||
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true npm run test:unit`.
|
||||
|
||||
## Modifying System Prompt
|
||||
Modular: `components/` (shared) + `variants/` (model-specific) + `templates/` (`{{PLACEHOLDER}}`). Variants override components via `componentOverrides` in `config.ts` or custom `template.ts`. XS variant is heavily condensed inline. Always regenerate snapshots after changes.
|
||||
|
||||
## Global State Keys (silent failure risk)
|
||||
Adding a key requires updating the typed storage definitions in `src/shared/storage/state-keys.ts`; runtime reads and writes should go through `StateManager`, not VS Code `ExtensionContext` storage. Persistent state is file-backed so it works across VS Code, CLI, and JetBrains hosts.
|
||||
Adding a key requires: type in `src/shared/storage/state-keys.ts`, read via `context.globalState.get()` in `src/core/storage/utils/state-helpers.ts` `readGlobalStateFromDisk()`, and add to return object. Missing the `.get()` call compiles fine but value is always `undefined`.
|
||||
|
||||
## Slash Commands (3 places)
|
||||
- `src/core/slash-commands/index.ts` — definitions.
|
||||
|
||||
@@ -59,7 +59,7 @@ We're not looking for exhaustive documentation - just evidence that you've thoug
|
||||
<!-- Put an 'x' in all boxes that apply -->
|
||||
|
||||
- [ ] Changes are limited to a single feature, bugfix or chore (split larger changes into separate PRs)
|
||||
- [ ] Tests are passing (`bun test`) and code is formatted and linted (`bun run format && bun run lint`)
|
||||
- [ ] Tests are passing (`npm test`) and code is formatted and linted (`npm run format && npm run lint`)
|
||||
- [ ] I have reviewed [contributor guidelines](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
name: ext-vscode-publish-legacy
|
||||
|
||||
# Publishes the legacy (pre-SDK-migration) VS Code extension from the
|
||||
# `legacy-extension` branch. This branch holds the npm-based 3.89.x codebase,
|
||||
# rolled forward under a 4.0.x version so existing 4.0.0 users still receive
|
||||
# the update. The main `ext-vscode-publish-stable.yml` workflow (bun-based)
|
||||
# stays the path for releasing main once the SDK migration is solid.
|
||||
#
|
||||
# This workflow lives on and is dispatched from `main` (so it satisfies the
|
||||
# default-branch dispatch requirement), but it checks out and builds the
|
||||
# `legacy-extension` branch.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release-type:
|
||||
description: "Choose release type (release or pre-release)"
|
||||
required: true
|
||||
default: "release"
|
||||
type: choice
|
||||
options:
|
||||
- pre-release
|
||||
- release
|
||||
branch:
|
||||
description: "Branch holding the legacy extension code"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Gate the publish on the legacy branch's own npm-based test suite. We can't
|
||||
# reuse ./.github/workflows/ext-vscode-test.yml here — on main that's the
|
||||
# bun-based suite and it would test main, not the legacy branch — so the
|
||||
# essential quality + test steps are inlined against the checked-out legacy
|
||||
# branch.
|
||||
test:
|
||||
name: Test Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Run Quality Checks (lint + typecheck)
|
||||
run: npm run ci:check-all
|
||||
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Unit Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Webview Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
name: Publish Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
# Check out the legacy branch (NOT main). fetch-depth: 0 + tags so we
|
||||
# can create/push the release tag and compute the previous tag.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
lfs: true
|
||||
|
||||
- name: Resolve Release Tag
|
||||
id: resolve_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
run: |
|
||||
# Tag is derived from the package version on the legacy branch.
|
||||
VERSION=$(node -p "require('./apps/vscode/package.json').version")
|
||||
TAG="v$VERSION"
|
||||
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "Error: derived tag '$TAG' does not match vX.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
TAG_REF="refs/tags/$TAG"
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
if git show-ref --verify --quiet "$TAG_REF"; then
|
||||
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Error: tag '$TAG' already exists at $TAG_SHA, not at branch head ($HEAD_SHA)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag '$TAG' already exists at branch head. Continuing."
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$TAG" "$HEAD_SHA"
|
||||
git push origin "$TAG_REF"
|
||||
echo "Created and pushed tag '$TAG' from $BRANCH head $HEAD_SHA."
|
||||
fi
|
||||
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode install --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Get Version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify Tag Matches Package Version
|
||||
run: |
|
||||
TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
VERSION="v${{ steps.get_version.outputs.version }}"
|
||||
if [[ "$TAG" != "$VERSION" ]]; then
|
||||
echo "Error: tag '$TAG' does not match package version '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify Changelog Entry
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
|
||||
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
- name: Verify Marketplace Tokens
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
|
||||
exit 1
|
||||
fi
|
||||
echo "Marketplace publish tokens are configured."
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
RELEASE_TYPE: ${{ github.event.inputs.release-type }}
|
||||
run: |
|
||||
# Swap README.marketplace.md into README.md so both the GitHub
|
||||
# release artifact (vsce package below) and the marketplace
|
||||
# publish (npm run publish:marketplace below, which swaps
|
||||
# internally as an idempotent no-op) ship the same README.
|
||||
node scripts/marketplace-readme.mjs swap-in
|
||||
trap 'node scripts/marketplace-readme.mjs restore' EXIT
|
||||
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
if [ "$RELEASE_TYPE" = "pre-release" ]; then
|
||||
npm run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
npm run publish:marketplace
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.resolve_tag.outputs.tag }}
|
||||
files: "apps/vscode/*.vsix"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}
|
||||
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "*Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)*"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}"
|
||||
@@ -1,9 +1,6 @@
|
||||
name: ext-vscode-publish-nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 4:00 AM PST (12:00 UTC)
|
||||
- cron: "0 12 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
@@ -34,9 +31,8 @@ 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.
|
||||
# Publish commands run from the VS Code extension package. Dependency install
|
||||
# is done from the monorepo root because the repo is Bun workspace-managed.
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
@@ -56,47 +52,33 @@ jobs:
|
||||
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 expectations.
|
||||
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.13
|
||||
|
||||
# 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
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# @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: 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 --include=optional
|
||||
|
||||
# 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
|
||||
|
||||
@@ -114,9 +96,7 @@ 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 }}
|
||||
|
||||
@@ -27,10 +27,6 @@ permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-stable-${{ github.event.inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/ext-vscode-test.yml
|
||||
@@ -106,61 +102,24 @@ jobs:
|
||||
echo "Error: tag '$TAG' does not exist in the repository"
|
||||
exit 1
|
||||
fi
|
||||
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
|
||||
if [[ "$TAG_SHA" != "$TESTED_SHA" ]]; then
|
||||
echo "Error: existing tag '$TAG' points to $TAG_SHA, but this workflow tested $TESTED_SHA"
|
||||
echo "Dispatch from the tag ref, or from the exact main commit the tag points to."
|
||||
exit 1
|
||||
fi
|
||||
echo "Using existing tag '$TAG' at tested SHA $TESTED_SHA."
|
||||
echo "Using existing tag '$TAG'."
|
||||
fi
|
||||
|
||||
git checkout --detach "$TAG_REF^{commit}"
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "resolved_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Node is still REQUIRED in the publish job (not just for install): the
|
||||
# publish scripts run as `node scripts/publish-*.mjs`, the version step uses
|
||||
# `node -p`, and `npx ovsx` needs npm. setup-bun does not provide a Node
|
||||
# runtime, so keep setup-node. Pinned to Node 22 because newer LTS
|
||||
# (Node 24 / npm 11) can make vsce's `npm list` detection fail with
|
||||
# ELSPROBLEMS during packaging.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Single root install resolves the whole bun workspace at once (replaces the
|
||||
# per-package `npm install` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install root dependencies
|
||||
run: npm install --include=optional
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/ before
|
||||
# packaging/publishing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun run build:sdk
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm install --include=optional
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# vsce is a workspace devDependency (on node_modules/.bin), but ovsx is not
|
||||
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
|
||||
# globally (npm is available via setup-node). vsce is installed globally too
|
||||
# to preserve the script's existing PATH expectations.
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
@@ -180,60 +139,6 @@ jobs:
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify Changelog Entry
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
|
||||
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
- name: Verify Marketplace Tokens
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
|
||||
exit 1
|
||||
fi
|
||||
echo "Marketplace publish tokens are configured."
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(
|
||||
git tag --merged "$CURRENT_TAG^" --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$' \
|
||||
| head -n 1 || true
|
||||
)
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between the matching version heading and the next release heading.
|
||||
CONTENT=$(awk -v version="${{ steps.get_version.outputs.version }}" '
|
||||
$0 == "## [" version "]" { found=1; next }
|
||||
found && /^## \[/ { exit }
|
||||
found { print }
|
||||
END { if (!found) exit 1 }
|
||||
' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
@@ -257,23 +162,35 @@ jobs:
|
||||
node scripts/marketplace-readme.mjs swap-in
|
||||
trap 'node scripts/marketplace-readme.mjs restore' EXIT
|
||||
|
||||
# Required to generate the .vsix. --no-dependencies: the extension
|
||||
# is fully esbuild-bundled, and under the bun workspace the @cline/*
|
||||
# deps are symlinks pointing outside the package, so without this vsce
|
||||
# would walk them and pull the whole monorepo into the .vsix.
|
||||
vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
# These scripts run under `node scripts/publish-marketplace.mjs`;
|
||||
# bun run just launches them. Node + npm (for `npx ovsx`) come from
|
||||
# setup-node above.
|
||||
if [ "$RELEASE_TYPE" = "pre-release" ]; then
|
||||
bun run publish:marketplace:prerelease
|
||||
npm run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
bun run publish:marketplace
|
||||
npm run publish:marketplace
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
|
||||
@@ -45,16 +45,12 @@ jobs:
|
||||
- 'apps/vscode/assets/**'
|
||||
- 'apps/vscode/walkthrough/**'
|
||||
- 'apps/vscode/package.json'
|
||||
- 'apps/vscode/webview-ui/package.json'
|
||||
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
|
||||
- 'bun.lock'
|
||||
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
|
||||
- 'sdk/packages/**'
|
||||
- 'apps/vscode/package-lock.json'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/biome.jsonc'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
- 'apps/vscode/bunfig.toml'
|
||||
- 'apps/vscode/.mocharc.json'
|
||||
- 'apps/vscode/.vscode-test.mjs'
|
||||
- 'apps/vscode/.vscodeignore'
|
||||
- 'apps/vscode/playwright*.ts'
|
||||
@@ -88,20 +84,26 @@ jobs:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
node-version: 22
|
||||
|
||||
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
|
||||
- name: Cache Bun install cache
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: bun-cache
|
||||
id: root-cache
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
path: apps/vscode/node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('apps/vscode/package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: apps/vscode/webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('apps/vscode/webview-ui/package-lock.json') }}
|
||||
|
||||
# Cache VS Code installation
|
||||
- name: Cache VS Code
|
||||
@@ -122,41 +124,20 @@ jobs:
|
||||
~/.cache/ms-playwright
|
||||
~/Library/Caches/ms-playwright
|
||||
~/AppData/Local/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('apps/vscode/package-lock.json') }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
# Single root install resolves the entire bun workspace at once (replaces
|
||||
# the per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/
|
||||
# before building/packaging the extension for E2E.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun run build:sdk
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
# Force bash: the Windows runner defaults to pwsh, which can't parse this
|
||||
# POSIX test. Git Bash ships on GitHub's windows-latest images.
|
||||
shell: bash
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# NOTE: vsce is no longer installed globally. @vscode/vsce is a workspace
|
||||
# devDependency of apps/vscode (resolved into node_modules/.bin), and the
|
||||
# `test:e2e:build` script invokes `vsce` via `bun run`, which puts the local
|
||||
# .bin on PATH. No global install needed.
|
||||
- name: Install vsce
|
||||
run: npm install -g @vscode/vsce
|
||||
|
||||
- name: Install xvfb on Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
@@ -165,11 +146,11 @@ jobs:
|
||||
# Run optimized E2E tests (eliminates redundant builds)
|
||||
- name: Run E2E tests - Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
run: xvfb-run -a bun run test:e2e:optimal
|
||||
run: xvfb-run -a npm run test:e2e:optimal
|
||||
|
||||
- name: Run E2E tests - Non-Linux
|
||||
if: matrix.runner != 'ubuntu'
|
||||
run: bun run test:e2e:optimal
|
||||
run: npm run test:e2e:optimal
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ failure() }}
|
||||
|
||||
@@ -45,16 +45,14 @@ jobs:
|
||||
- 'apps/vscode/assets/**'
|
||||
- 'apps/vscode/walkthrough/**'
|
||||
- 'apps/vscode/package.json'
|
||||
- 'apps/vscode/webview-ui/package.json'
|
||||
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
|
||||
- 'package.json'
|
||||
- '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/.mocharc.json'
|
||||
- 'apps/vscode/.nycrc*.json'
|
||||
- 'apps/vscode/.vscode-test.mjs'
|
||||
- 'apps/vscode/test-setup.js'
|
||||
- '.github/workflows/ext-vscode-test.yml'
|
||||
@@ -63,13 +61,10 @@ jobs:
|
||||
- 'apps/vscode/proto/**'
|
||||
- 'apps/vscode/standalone/**'
|
||||
- 'apps/vscode/testing-platform/**'
|
||||
- 'apps/vscode/testing-platform/package.json'
|
||||
- 'apps/vscode/tests/specs/**'
|
||||
- 'apps/vscode/package.json'
|
||||
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
|
||||
- 'package.json'
|
||||
- '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'
|
||||
@@ -89,38 +84,33 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
bun-version: 1.3.13
|
||||
|
||||
# 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
|
||||
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 --include=optional
|
||||
|
||||
- name: Run Quality Checks (Parallel)
|
||||
run: bun run ci:check-all
|
||||
run: npm run ci:check-all
|
||||
|
||||
vscode-test:
|
||||
needs: [detect-changes, quality-checks]
|
||||
@@ -141,43 +131,35 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
bun-version: 1.3.13
|
||||
|
||||
# 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
|
||||
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: 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 --include=optional
|
||||
|
||||
# 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.
|
||||
- name: Set up NPM on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||
|
||||
- name: Cache VS Code test runtime
|
||||
if: runner.os == 'Windows'
|
||||
@@ -189,51 +171,29 @@ 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
|
||||
run: npm run test:vitest
|
||||
|
||||
- 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 +201,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 +219,7 @@ jobs:
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
bun run test:coverage
|
||||
npm run test:coverage
|
||||
|
||||
- name: Save Coverage Reports
|
||||
uses: actions/upload-artifact@v4
|
||||
@@ -268,6 +228,7 @@ jobs:
|
||||
with:
|
||||
name: pr-coverage-reports
|
||||
path: |
|
||||
apps/vscode/coverage-unit/lcov.info
|
||||
apps/vscode/webview-ui/coverage/lcov.info
|
||||
|
||||
test-platform-integration:
|
||||
@@ -281,45 +242,45 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
apps/vscode/testing-platform/package-lock.json
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
bun-version: 1.3.13
|
||||
|
||||
# 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
|
||||
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 --include=optional
|
||||
|
||||
- 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 --include=optional
|
||||
|
||||
- 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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -84,11 +84,3 @@ apps/vscode/webview-ui/src/**/*.js.map
|
||||
*.db-wal
|
||||
.cline/**/managed.json
|
||||
.cline/**/bundle.json
|
||||
apps/vscode/tsconfig.test.generated.json
|
||||
.next/dev/static
|
||||
**/src-tauri/target/debug/.fingerprint
|
||||
apps/examples/desktop-app/src-tauri/target
|
||||
apps/examples/desktop-app/webview/.next
|
||||
|
||||
# Next.js generated type shim (churns between dev and build)
|
||||
apps/examples/desktop-app/webview/next-env.d.ts
|
||||
|
||||
@@ -39,6 +39,14 @@
|
||||
"sdk/packages/core/src/auth/**"
|
||||
],
|
||||
"severity": "high"
|
||||
},
|
||||
{
|
||||
"id": "sdk-telemetry-doc-update",
|
||||
"rule": "Any PR that adds new event constants to CORE_TELEMETRY_EVENTS in packages/core/src/services/telemetry/core-events.ts, adds new capture* helper functions, or changes the payload shape of an existing event must update the Event Catalog section in DOC.md. Flag PRs that modify core-events.ts without a corresponding change to DOC.md.",
|
||||
"scope": [
|
||||
"sdk/packages/core/src/services/telemetry/core-events.ts"
|
||||
],
|
||||
"severity": "medium"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,9 +16,13 @@
|
||||
"path": "sdk/packages/core/src/services/telemetry/OpenTelemetryProvider.ts",
|
||||
"description": "OpenTelemetry-backed provider that wires logs/metrics/traces exporters. Contains createConfiguredTelemetryService and createConfiguredTelemetryHandle, the canonical factories every host should use."
|
||||
},
|
||||
{
|
||||
"path": "DOC.md",
|
||||
"description": "Public API and event documentation. The Event Catalog and 'Activation funnel' sections must be kept in sync with core-events.ts. Host integration rules (CLI dir ordering, hub daemon metadata forwarding) are documented here."
|
||||
},
|
||||
{
|
||||
"path": "sdk/ARCHITECTURE.md",
|
||||
"description": "Architecture reference. Telemetry design decisions and completion semantics (submit_and_exit anchoring) are documented here. Use as ground truth for design intent."
|
||||
"description": "Architecture reference. Telemetry design decisions, completion semantics (submit_and_exit anchoring), and hub-daemon telemetry forwarding are documented here. Use as ground truth for design intent."
|
||||
},
|
||||
{
|
||||
"path": "sdk/AGENTS.md",
|
||||
|
||||
+17
-20
@@ -36,11 +36,8 @@ event names. It exports:
|
||||
|
||||
1. Add the constant to `CORE_TELEMETRY_EVENTS`
|
||||
2. Add a typed `capture*()` helper alongside it (with a typed `properties` parameter)
|
||||
3. Add a unit test in `core-events.test.ts` asserting the event flows through the
|
||||
opt-out-respecting `capture` path and never `captureRequired` (opt-out is enforced by
|
||||
`OptedOutTelemetryService`, whose `capture` is a no-op — the test convention is
|
||||
"emits X as a normal opt-out-respecting event"). Events that intentionally bypass
|
||||
opt-out must use `captureRequired` and assert that explicitly.
|
||||
3. Update the Event Catalog section in `DOC.md`
|
||||
4. Add a unit test in `core-events.test.ts` asserting the event is dropped when telemetry is opted out
|
||||
|
||||
## The Activation Funnel
|
||||
|
||||
@@ -85,7 +82,7 @@ The CLI accepts `--config <dir>`. The CLI **must** apply `setClineDir(...)` and
|
||||
and any other on-disk telemetry state lands under `~/.cline` instead of the user's chosen
|
||||
config dir.
|
||||
|
||||
The canonical pattern is in `apps/cli/src/main.ts`:
|
||||
The canonical pattern is in `apps/cli/src/main.ts` (PR #357):
|
||||
|
||||
```ts
|
||||
if (configDir) setClineDir(configDir);
|
||||
@@ -93,18 +90,18 @@ setHomeDir(homedir());
|
||||
captureCliExtensionActivated(); // <-- after dir overrides
|
||||
```
|
||||
|
||||
## Hub Daemon Telemetry
|
||||
## Hub Daemon Metadata Forwarding
|
||||
|
||||
The detached hub daemon (`sdk/packages/core/src/hub/daemon/entry.ts`) hosts the
|
||||
`LocalRuntimeHost` that emits `task.conversation_turn` and `task.tokens` for every
|
||||
hub-backed session, so the daemon must own its own `ITelemetryService`. It builds one via
|
||||
`createHubDaemonTelemetry()` (`sdk/packages/core/src/hub/daemon/telemetry.ts`), which
|
||||
identifies from the cached cline account (re-resolved periodically, since the daemon often
|
||||
starts before login) and flushes on every shutdown path, including startup failure.
|
||||
Hosts that spawn a detached `@cline/core/hub/daemon-entry` process must forward telemetry
|
||||
metadata into the daemon argv so the daemon can reconstruct an equivalent
|
||||
`ITelemetryService`. The expected payload is base64-encoded JSON with snake_case keys:
|
||||
|
||||
Flag changes that remove this wiring, construct runtime hosts inside the daemon without
|
||||
passing its telemetry handle, or add daemon exit paths that skip the flush — hub-backed
|
||||
sessions would silently drop their lifecycle telemetry (this exact bug shipped once).
|
||||
```
|
||||
{ extension_version, cline_type, platform, platform_version, os_type, os_version, is_remote_workspace }
|
||||
```
|
||||
|
||||
The reference implementation is `apps/vscode/src/hub-daemon.ts` (PR #357). Without this
|
||||
forwarding, hub-backed sessions silently drop their lifecycle telemetry.
|
||||
|
||||
## Auth Lifecycle Completeness
|
||||
|
||||
@@ -123,10 +120,10 @@ canonical examples of all four phases.
|
||||
|
||||
## Single Telemetry Service Per Host
|
||||
|
||||
On VS Code, all callers go through the lazy `telemetryService` proxy in
|
||||
`apps/vscode/src/services/telemetry/index.ts`, which constructs the service once on first
|
||||
use. Do not let individual controllers construct their own `ITelemetryService` — that
|
||||
fragments distinct-id state, opt-out tracking, and flush ownership.
|
||||
On VS Code, the telemetry handle is built **once** in `activate()`
|
||||
(`apps/vscode/src/telemetry.ts`) and the same instance is passed into the sidebar, panel
|
||||
command, and daemon spawn payload. Do not let individual controllers construct their own
|
||||
`ITelemetryService` — that fragments distinct-id state, opt-out tracking, and flush ownership.
|
||||
|
||||
The CLI follows the same pattern via the `getCliTelemetryService()` singleton in
|
||||
`apps/cli/src/utils/telemetry.ts`, which is memoized by the activation gate in
|
||||
|
||||
+1
-1
@@ -7,5 +7,5 @@ fi
|
||||
|
||||
gitleaks git --pre-commit --redact --staged --verbose || exit 1
|
||||
|
||||
cd apps/vscode && bunx lint-staged
|
||||
cd apps/vscode && lint-staged
|
||||
|
||||
|
||||
Vendored
+5
-2
@@ -126,7 +126,10 @@
|
||||
"${workspaceFolder}/apps/vscode/dist-standalone/**/*.js"
|
||||
],
|
||||
"preLaunchTask": "compile-standalone",
|
||||
"runtimeExecutable": "bun",
|
||||
"runtimeExecutable": "npx",
|
||||
"runtimeArgs": [
|
||||
"tsx"
|
||||
],
|
||||
"program": "scripts/test-standalone-core-api-server.ts",
|
||||
"envFile": "${workspaceFolder}/apps/vscode/.env",
|
||||
"env": {
|
||||
@@ -180,7 +183,7 @@
|
||||
"name": "Open Storybook",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"runtimeExecutable": "bun",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
"run",
|
||||
"storybook"
|
||||
|
||||
Vendored
+1
-14
@@ -22,24 +22,11 @@
|
||||
},
|
||||
// Enable Lint and format using Biome
|
||||
"biome.enabled": true,
|
||||
"biome.requireConfiguration": true,
|
||||
"prettier.enable": false,
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[javascriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"[typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.removeUnused.biome": "always",
|
||||
"source.removeUnusedImports": "always",
|
||||
"source.organizeImports.biome": "always"
|
||||
},
|
||||
// Disable auto-forwarding ports to prevent Simple Browser from opening the Vite dev server
|
||||
|
||||
Vendored
+19
-39
@@ -6,7 +6,7 @@
|
||||
{
|
||||
"label": "compile-standalone",
|
||||
"type": "shell",
|
||||
"command": "bun run compile-standalone",
|
||||
"command": "npm run compile-standalone",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
@@ -19,7 +19,7 @@
|
||||
{
|
||||
"label": "npm: protos",
|
||||
"type": "shell",
|
||||
"command": "bun run protos",
|
||||
"command": "npm run protos",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
"presentation": {
|
||||
@@ -65,10 +65,10 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run build:webview",
|
||||
"command": "npm run build:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
"isBackground": true,
|
||||
"label": "npm: build:webview",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
@@ -86,10 +86,10 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run build:webview:test",
|
||||
"command": "npm run build:webview:test",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
"isBackground": true,
|
||||
"label": "npm: build:webview:test",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
@@ -108,22 +108,22 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run dev:webview",
|
||||
"command": "npm run dev:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [
|
||||
{
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^(?!)((?:.*))$",
|
||||
"kind": "file",
|
||||
"regexp": ".",
|
||||
"file": 1,
|
||||
"message": 1
|
||||
"location": 2,
|
||||
"message": 3
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": "^Building webview for|^\\s*VITE",
|
||||
"endsPattern": "^.*Local:\\s+http://127\\.0\\.0\\.1:[0-9]+/"
|
||||
"beginsPattern": ".",
|
||||
"endsPattern": "."
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -145,7 +145,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run watch:esbuild",
|
||||
"command": "npm run watch:esbuild",
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
@@ -169,8 +169,7 @@
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"build-sdk:debug"
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -185,7 +184,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run watch:esbuild:test",
|
||||
"command": "npm run watch:esbuild:test",
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
@@ -209,8 +208,7 @@
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild:test",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"build-sdk:debug"
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -226,7 +224,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run watch:tsc",
|
||||
"command": "npm run watch:tsc",
|
||||
"group": "build",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
@@ -244,7 +242,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run watch-tests",
|
||||
"command": "npm run watch-tests",
|
||||
"label": "npm: watch-tests",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
@@ -284,7 +282,7 @@
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run storybook",
|
||||
"command": "npm run storybook",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
@@ -313,24 +311,6 @@
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
|
||||
@@ -1,97 +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
|
||||
|
||||
+14
-14
@@ -45,7 +45,7 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
3. Install [bun](https://bun.com)
|
||||
4. Install the necessary dependencies for the extension and webview-gui:
|
||||
```bash
|
||||
cd apps/vscode && bun run install:all && cd ../..
|
||||
cd apps/vscode && npm run install:all && cd ../..
|
||||
cd sdk && bun run build && cd ..
|
||||
```
|
||||
5. Generate Protocol Buffer files (required before first build):
|
||||
@@ -61,8 +61,8 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
2. Push your branch and create a PR on GitHub. Our CI will:
|
||||
- Run tests and checks
|
||||
3. Testing
|
||||
- Run `cd apps/vscode && bun run test` to run tests locally.
|
||||
- Before submitting PR, run `bun run format:fix` to format your code
|
||||
- Run `cd apps/vscode && npm run test` to run tests locally.
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
|
||||
### Extension
|
||||
|
||||
@@ -74,12 +74,12 @@ We also welcome contributions to our [documentation](https://github.com/cline/cl
|
||||
|
||||
2. **Local Development**
|
||||
- cd into the vscode extension, `cd apps/vscode`
|
||||
- Run `bun run install:all` to install dependencies
|
||||
- Run `bun run protos` to generate Protocol Buffer files (required before first build)
|
||||
- Run `bun run test` to run tests locally
|
||||
- Run `npm run install:all` to install dependencies
|
||||
- Run `npm run protos` to generate Protocol Buffer files (required before first build)
|
||||
- Run `npm run test` to run tests locally
|
||||
- Run → Start Debugging or `>Debug: Select and Start Debugging` and wait for a new VS Code instance to open
|
||||
- **Terminal Workflow**: Use `bun run dev` (generates protos + runs watch mode) or `bun run watch` (if protos already generated)
|
||||
- Before submitting PR, run `bun run format:fix` to format your code
|
||||
- **Terminal Workflow**: Use `npm run dev` (generates protos + runs watch mode) or `npm run watch` (if protos already generated)
|
||||
- Before submitting PR, run `npm run format:fix` to format your code
|
||||
|
||||
3. **Linux-specific Setup**
|
||||
VS Code extension tests on Linux require the following system libraries:
|
||||
@@ -135,8 +135,8 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
|
||||
2. **Code Quality**
|
||||
|
||||
- Run `bun run lint` to check code style
|
||||
- Run `bun run format` to automatically format code
|
||||
- Run `npm run lint` to check code style
|
||||
- Run `npm run format` to automatically format code
|
||||
- All PRs must pass CI checks which include both linting and formatting
|
||||
- Address any warnings or errors from linter before submitting
|
||||
- Follow TypeScript best practices and maintain type safety
|
||||
@@ -144,7 +144,7 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
3. **Testing**
|
||||
|
||||
- Add tests for new features
|
||||
- Run `bun test` to ensure all tests pass
|
||||
- Run `npm test` to ensure all tests pass
|
||||
- Update existing tests if your changes affect them
|
||||
- Include both unit tests and integration tests where appropriate
|
||||
|
||||
@@ -154,9 +154,9 @@ Anyone can contribute code to Cline, but we ask that you follow these guidelines
|
||||
|
||||
- **Running E2E tests:**
|
||||
```bash
|
||||
bun run test:e2e # Build and run all E2E tests
|
||||
bun run e2e # Run tests without rebuilding
|
||||
bun run test:e2e -- --debug # Run with interactive debugger
|
||||
npm run test:e2e # Build and run all E2E tests
|
||||
npm run e2e # Run tests without rebuilding
|
||||
npm run test:e2e -- --debug # Run with interactive debugger
|
||||
```
|
||||
|
||||
- **Writing E2E tests:**
|
||||
|
||||
@@ -149,7 +149,7 @@ Toggle between Plan mode and Act mode. In Plan mode, Cline explores your codebas
|
||||
|
||||
## Rules and Skills
|
||||
|
||||
Define project-specific rules in `.clinerules` files that guide how Cline works in your codebase: coding standards, architecture conventions, deployment procedures, testing requirements. Rules are picked up automatically by the CLI, VS Code extension, and JetBrains plugin. Use skills to let the model load specific rules when needed.
|
||||
Define project-specific rules in `.clinerules` files that guide how Cline works in your codebase: coding standards, architecture conventions, deployment procedures, testing requirements. Rules are picked up automatically by the CLI, VS Code extension, and JetBrains plugin. Use skills to let the model load specific rules when needed.
|
||||
|
||||
## Works With Every Model
|
||||
|
||||
@@ -158,10 +158,10 @@ Cline is not locked to a single AI provider. Use whichever model fits your workf
|
||||
| Provider | Models |
|
||||
|----------|--------|
|
||||
| Anthropic | Claude Opus, Sonnet, Haiku |
|
||||
| OpenAI | GPT series models |
|
||||
| Google | Gemini series models |
|
||||
| OpenAI | GPT series model |
|
||||
| Google | Gemini series model |
|
||||
| OpenRouter | 200+ models from any provider |
|
||||
| Vercel AI Gateway | Route to many providers through one gateway |
|
||||
| Vercel AI Gateway | Models through Vercel AI Gateway |
|
||||
| AWS Bedrock | Claude, Llama, and more |
|
||||
| Azure / GCP Vertex | All hosted models |
|
||||
| Cerebras / Groq | Fast inference models |
|
||||
@@ -212,12 +212,8 @@ cline schedule create "PR summary" \
|
||||
Chat with your agent from any messaging platform: Telegram, Slack, Discord, Google Chat, WhatsApp, and Linear. Each conversation thread maps to an agent session with full context. Set up access control to restrict who can interact with your agent.
|
||||
|
||||
```bash
|
||||
# Connect to Telegram
|
||||
cline connect telegram -k $BOT_TOKEN
|
||||
# Connect to Slack through webhook
|
||||
cline connect slack --bot-token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
# Connect to Slack using socket mode
|
||||
cline connect slack --bot-token $SLACK_TOKEN --app-token $SLACK_APP_TOKEN
|
||||
cline connect slack --token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
```
|
||||
|
||||
## Headless CLI for CI/CD
|
||||
|
||||
+114
-4
@@ -1,14 +1,124 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"root": false,
|
||||
"extends": [
|
||||
"../sdk/biome.json"
|
||||
],
|
||||
"extends": ["../sdk/biome.json"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"a11y": {
|
||||
"noStaticElementInteractions": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["vscode/**"],
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"indentWidth": 4,
|
||||
"lineWidth": 130,
|
||||
"lineEnding": "lf",
|
||||
"formatWithErrors": true
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"semicolons": "asNeeded",
|
||||
"arrowParentheses": "always",
|
||||
"bracketSameLine": true,
|
||||
"bracketSpacing": true,
|
||||
"jsxQuoteStyle": "double",
|
||||
"quoteProperties": "asNeeded",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
},
|
||||
"json": {
|
||||
"formatter": {
|
||||
"trailingCommas": "none",
|
||||
"expand": "always"
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"domains": {
|
||||
"react": "recommended"
|
||||
},
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "info",
|
||||
"noUndeclaredVariables": "off",
|
||||
"noEmptyPattern": "info",
|
||||
"useJsxKeyInIterable": "off",
|
||||
"noInnerDeclarations": "off",
|
||||
"useHookAtTopLevel": "info",
|
||||
"useYield": "info",
|
||||
"noConstructorReturn": "off",
|
||||
"noInvalidPositionAtImportRule": "off",
|
||||
"noSwitchDeclarations": "off",
|
||||
"noUnusedImports": "error"
|
||||
},
|
||||
"a11y": "info",
|
||||
"style": {
|
||||
"useNodejsImportProtocol": "off",
|
||||
"useImportType": "off",
|
||||
"useBlockStatements": "off",
|
||||
"useNamingConvention": "off",
|
||||
"useThrowOnlyError": "info",
|
||||
"useConsistentArrayType": "off",
|
||||
"noParameterAssign": "off",
|
||||
"useAsConstAssertion": "off",
|
||||
"useDefaultParameterLast": "off",
|
||||
"noNonNullAssertion": "info",
|
||||
"useEnumInitializers": "off",
|
||||
"useSelfClosingElements": "info",
|
||||
"useSingleVarDeclarator": "off",
|
||||
"useNumberNamespace": "info",
|
||||
"noInferrableTypes": "info",
|
||||
"useTemplate": "info",
|
||||
"noUselessElse": "info"
|
||||
},
|
||||
"suspicious": {
|
||||
"noDoubleEquals": "warn",
|
||||
"noImplicitAnyLet": "info",
|
||||
"noThenProperty": "off",
|
||||
"noAsyncPromiseExecutor": "info",
|
||||
"noImportAssign": "off",
|
||||
"noExplicitAny": "info",
|
||||
"noControlCharactersInRegex": "warn",
|
||||
"noShadowRestrictedNames": "off",
|
||||
"noArrayIndexKey": "info",
|
||||
"noAssignInExpressions": "info",
|
||||
"useIterableCallbackReturn": "info"
|
||||
},
|
||||
"complexity": {
|
||||
"noUselessConstructor": "info",
|
||||
"useOptionalChain": "info",
|
||||
"noBannedTypes": "warn",
|
||||
"useLiteralKeys": "info",
|
||||
"noUselessCatch": "info",
|
||||
"noUselessSwitchCase": "info",
|
||||
"noStaticOnlyClass": "info"
|
||||
},
|
||||
"security": {
|
||||
"noDangerouslySetInnerHtml": "info"
|
||||
}
|
||||
}
|
||||
},
|
||||
"css": {
|
||||
"parser": {
|
||||
"tailwindDirectives": true
|
||||
}
|
||||
},
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on",
|
||||
"useSortedAttributes": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": ["vscode/src/dev/grit/process-env.grit"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -9,13 +9,12 @@ Use this skill when the user asks to release the CLI, publish `cline`, bump the
|
||||
|
||||
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.
|
||||
> Working directory: this skill lives in the SDK sub-monorepo. Run `cd sdk` (from the repo root) before any of the shell commands below. Paths in commands and instructions (e.g. `apps/cli/package.json`, `bun release cli`) are written relative to `sdk/`.
|
||||
|
||||
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`.
|
||||
@@ -31,93 +30,8 @@ The skill should guide the user through one release preparation flow, then offer
|
||||
- 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
|
||||
@@ -132,10 +46,10 @@ Find the latest CLI tag. If there is no `cli-v*` tag, use the first relevant CLI
|
||||
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
|
||||
git log <last-cli-tag>..HEAD --oneline --no-merges -- apps/cli packages 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.
|
||||
If the release includes broader SDK changes that affect the CLI, also inspect commits outside `apps/cli`.
|
||||
|
||||
3. Draft user-facing release notes.
|
||||
|
||||
@@ -1,214 +1,5 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.40
|
||||
|
||||
- Added a manual API key escape hatch for Cline OAuth providers, so you can enter a key by hand from settings
|
||||
- Fixed provider config not reloading when switching models
|
||||
- Fixed auto-update failing to detect Bun global installs after symlink resolution
|
||||
- Fixed unexpected logouts caused by transient network or server errors during token refresh
|
||||
- The ClinePass usage-limit error is now surfaced clearly when you hit the limit
|
||||
- Session id is now preserved when continuing within the same session
|
||||
- Hardened context compaction budget handling
|
||||
|
||||
## 3.0.39
|
||||
|
||||
- You can now select Cline free models on the ClinePass provider in the model picker
|
||||
- Removed the retired ClinePass GLM 5.1 model
|
||||
- Fixed OpenAI Codex model metadata under the GPT Subscription provider
|
||||
- `str_replace` edits now report accurate diffs
|
||||
- Fixed context compaction so canonical session history is preserved
|
||||
- The detached hub daemon now emits telemetry, and telemetry identity now includes `user_id`
|
||||
- Cline provider requests now send versioned client-identity headers
|
||||
|
||||
## 3.0.38
|
||||
|
||||
- New plan/act accent palette: act mode is now blue (`#79b8ff`) and plan mode amber, replacing the old cyan/yellow — applied across dialogs, the model selector, config, onboarding, markdown, and syntax highlighting, with light-theme variants tuned for contrast
|
||||
- Restyled chat input: a minimal frame with full-width horizontal rules and a bold accent prompt glyph instead of the tinted background, plus slimmer user-message bubbles
|
||||
- Assistant markdown accents are now tinted by the mode (plan/act) they were produced in
|
||||
- Polished the status bar usage display and ClinePass model name
|
||||
- Harmonized the success/diff green and dark syntax-highlighting colors with the new brand palette
|
||||
- The thinking-level picker now defaults its cursor to Medium instead of Off
|
||||
- `read_files` now tolerates malformed input from weaker models: line-range entries (`start_line`/`end_line`) sent as separate array items are coalesced back onto the preceding file path instead of being rejected (from SDK v0.0.58)
|
||||
- Models in the live catalog that don't report a context window now default to a 128K input-token limit, so under-specified models get a usable context budget (from SDK v0.0.57)
|
||||
|
||||
## 3.0.37
|
||||
|
||||
- Weaker models (e.g. DeepSeek) that emit malformed tool calls — wrong argument types or truncated JSON — are now handled gracefully and run instead of erroring out
|
||||
- Plan/act mode switches are now visible to the model, so it knows when you change modes mid-session
|
||||
- Fixed plan/act mode notices being dropped from prompts sent to the model
|
||||
- Fixed a race where switching modes in an empty session could trigger an unexpected restart
|
||||
|
||||
## 3.0.36
|
||||
|
||||
- Fixed plan mode's `switch_to_act_mode` tool not taking effect until the end of the turn: the model would keep running with plan-mode tools (no file editor) and fall back to editing files through shell commands. Switching to act mode now ends the plan-mode run and automatically continues with the approved plan using the full act-mode toolset. A Tab mode toggle racing a completing turn can no longer auto-start plan execution you didn't approve.
|
||||
|
||||
## 3.0.35
|
||||
|
||||
- ClinePass is now enabled for all CLI users
|
||||
- Recover missing interactive sessions when reading messages
|
||||
- Format structured commands in history export
|
||||
- Add the subscription promo code when linking to the dashboard subscription page
|
||||
- Add Tencent TokenHub as a provider (from SDK v0.0.55)
|
||||
- Fix first-prompt truncation on high-output models (e.g. MiniMax M3) that could immediately auto-compact and cut the initial task down to just the input wrapper (from SDK v0.0.55)
|
||||
- Use a curated default when migrating legacy provider settings (from SDK v0.0.55)
|
||||
- Advertise run commands as shell strings (from SDK v0.0.55)
|
||||
- Refresh the bundled model catalog with the latest provider models (from SDK v0.0.55)
|
||||
|
||||
## 3.0.34
|
||||
|
||||
- Fixed the ClinePass upgrade notice appearing immediately after completing onboarding.
|
||||
- Improved the wording of the ClinePass onboarding step.
|
||||
- Streamlined the Cline provider picker by merging the subscription and usage/billing options into one and removing the credits link.
|
||||
|
||||
## 3.0.33
|
||||
|
||||
- Show a ClinePass subscription URL as a fallback during onboarding so you can still subscribe if the subscription screen can't open automatically
|
||||
- Hide the ClinePass promo for users who already have a ClinePass subscription
|
||||
- Use an adaptive plan accent color for ClinePass prompts so they fit the active theme
|
||||
|
||||
## 3.0.32
|
||||
|
||||
- Improved the ClinePass onboarding experience
|
||||
- Added an intermediate step before going to ClinePass model selection
|
||||
- Made the ClinePass subscription screen selectable
|
||||
- Promoted ClinePass in the startup notice
|
||||
- Used "ClinePass" as one word consistently and refined the provider UI copy
|
||||
- More accurate context compaction and clearer error messages (from SDK v0.0.54)
|
||||
|
||||
## 3.0.31
|
||||
|
||||
- Show when request cost is covered by your Cline subscription
|
||||
- Prompt to switch to ClinePass when you run out of credits, and list ClinePass features in the not-subscribed message
|
||||
- Added an option to open the subscription page from the ClinePass options
|
||||
- Added marketplace uninstall support and surfaced plugin-bundled skills
|
||||
- Require quoted prompts for one-shot mode
|
||||
- Capped MCP tool names at 64 characters for OpenAI-compatible providers
|
||||
- Updated coupon code
|
||||
|
||||
## 3.0.30
|
||||
|
||||
- Added a token count to the status bar, shown alongside cost
|
||||
- Added organization-specific error messages
|
||||
- Added SAP AI Core provider support
|
||||
- Refreshed the model catalog with the latest provider models
|
||||
- Preserved OpenRouter reasoning-disable behavior and improved OpenRouter prompt caching
|
||||
- Routed LiteLLM model fetches through the SDK and stopped unrelated models from appearing in the LiteLLM model list
|
||||
- Updated ClinePass models live, restored ClinePass models in onboarding, and improved ClinePass error messages
|
||||
- Threaded proxy/CA-aware networking into the inference path
|
||||
- Persisted Bedrock settings to providers.json
|
||||
- Normalized JSON-like tool inputs by schema for more reliable tool calls
|
||||
- Fixed an "ERROR: EMPTY CONTENT" message that could appear when an error occurred
|
||||
- Fixed a packaging issue (createRequire) that could break the CLI at runtime
|
||||
|
||||
## 3.0.29
|
||||
|
||||
- Costs are now hidden for Cline free models
|
||||
- Fixed Z.ai model metadata resolution for Z.ai models accessed through the Cline provider
|
||||
- Reverted the model-name-only display change from v3.0.28; the model picker, selector, and status bar return to their previous display behavior
|
||||
|
||||
## 3.0.28
|
||||
|
||||
- Added a ClinePass onboarding flow with selectable ClinePass models, plus improved ClinePass error handling
|
||||
- Added hub primitive catalogs and refreshed the hub dashboard design with a dedicated customizations breakout
|
||||
- Auto-approve toggles now apply immediately when changed
|
||||
- Feature flags now resolve using your user ID on startup
|
||||
- Fixed Cline model display names so they resolve by model name
|
||||
- Truncate large tool results by default (including MCP and custom tool output) to keep requests within context budget
|
||||
- Hardened parallel tool-call guidance for faster, more reliable multi-tool execution
|
||||
|
||||
## 3.0.27
|
||||
|
||||
- Added a `cline skill` command to install and manage skills, matching `cline plugin install` and `cline mcp` (installs default to the Cline agent directory)
|
||||
- Added a prefilled MCP install wizard command for quicker MCP server setup
|
||||
- Improved error handling and messaging when plugin MCP OAuth authorization fails
|
||||
- The CLI now rejects unknown commands and unquoted multi-word input with a clear error instead of silently treating bad arguments as a prompt
|
||||
|
||||
## 3.0.26
|
||||
|
||||
- Reverted the expandable model picker sections and ClinePass models, restoring the previous model-selection UI
|
||||
|
||||
## 3.0.25
|
||||
|
||||
- Added ClinePass support, with selectable ClinePass models in the model picker
|
||||
- Made model picker sections expandable
|
||||
- Added MCP server support to plugins, including authorizing plugin MCP OAuth during install
|
||||
- Encouraged parallel tool calls for faster task execution
|
||||
- Capped tool output for bash commands and file reads to keep large output within context limits
|
||||
- Allowed ranged reads on large files
|
||||
- Fixed apply_patch to fail when a hunk is skipped
|
||||
- Fixed run_commands to return captured stdout on failure and handle split heredocs
|
||||
- Fixed search tools to treat zero results as success
|
||||
- Fixed disabled-reasoning handling for StepFun flash
|
||||
- Fixed history resume rendering isolation
|
||||
- Fixed the Hugging Face URL
|
||||
- Fixed Cline OAuth token formatting in provider config
|
||||
|
||||
## 3.0.24
|
||||
|
||||
- Plugin commands can now submit prompts to the agent
|
||||
- Added support for overriding the API base URL
|
||||
- Open the verification URL automatically when starting device authentication
|
||||
- Enforced a single shared Cline Hub, so a stale hub is respawned after an upgrade
|
||||
- Suppressed flickering console windows on Windows
|
||||
- Fixed truncation of structured tool operation result strings so oversized tool output stays within limits
|
||||
- Stopped echoing the full command text in run_commands tool results
|
||||
|
||||
## 3.0.23
|
||||
|
||||
- Fixed Vertex AI GCP settings configuration
|
||||
- Fixed the Azure Foundry API version
|
||||
- Added support for configured agents as subagent tools
|
||||
- Centralized OAuth management into the SDK
|
||||
- Fixed an error caused by disabled reasoning on Fable 5
|
||||
|
||||
## 3.0.22
|
||||
|
||||
- Added support for the Claude Fable 5 model
|
||||
- Fixed MiniMax M3 thinking controls so they route correctly across gateways
|
||||
|
||||
## 3.0.21
|
||||
|
||||
- Added a global auto-update setting that controls automatic updates on CLI startup
|
||||
- Added a Cline credits refill link
|
||||
- Fixed scrolling for inline ask-question responses
|
||||
- Fixed connector thread session routing and stale hub session handling
|
||||
- Added support for Vertex AI Application Default Credentials (ADC) with tool use
|
||||
- Fixed empty message content replay for Bedrock
|
||||
- Cleaned up the OpenAI Codex model list
|
||||
|
||||
## 3.0.20
|
||||
|
||||
- Installed plugin wrappers are now named from their source (npm package name, git repo, remote filename, official slug, or local directory) instead of an opaque hash, making installed plugins easier to identify.
|
||||
|
||||
## 3.0.19
|
||||
|
||||
- Fixed CLI auto-update to use `npm update` so updates apply reliably, while preserving the installed release channel (e.g. nightly).
|
||||
|
||||
## 3.0.18
|
||||
|
||||
- Fix Slack channel mentions so replies post in the original message's thread.
|
||||
- Fix the abort indicator to clear immediately when a task is cancelled.
|
||||
- Sync the Fireworks AI model registry and refresh the bundled model catalog with current platform offerings.
|
||||
- Bump the bundled SDK to v0.0.43, which forces a running Cline Hub to restart so it picks up the latest SDK code.
|
||||
|
||||
## 3.0.17
|
||||
|
||||
- Fix a regression introduced in 3.0.15 where the interactive CLI could get stuck after stopping and restarting Cline Hub and then pressing Escape to cancel a request. The CLI now detects stale or missing sessions, recovers any pending messages, and starts a fresh session instead of failing with "session not found".
|
||||
- Fix Ctrl+C and Hub shutdown races that surfaced as "hook dispatch failed" and WebSocket connection errors from late hook events racing against Hub shutdown.
|
||||
- Fix the Hub daemon being shut down prematurely when a runtime request was aborted, so the daemon now stays alive.
|
||||
- Improve the Telegram connector with a new `--allowed-user-id` flag to restrict which Telegram users are authorized to interact with the agent.
|
||||
|
||||
## 3.0.16
|
||||
|
||||
- Install official Cline plugins by slug off the new github.com/cline/plugins collection.
|
||||
- Uninstall plugins using `cline plugin uninstall <plugin>` or in the TUI.
|
||||
- Plugins can now bundle skills, and plugin skills are grouped together in settings.
|
||||
- Add Slack socket mode support.
|
||||
- Allow a custom base URL for Anthropic vendor-type providers.
|
||||
- Fix OAuth token migration for users signed in through the old extension.
|
||||
- Use a union schema for read-files tool input validation.
|
||||
- Add a `CLINE_PLUGIN_IMPORT_TIMEOUT_MS` env override to control the plugin import timeout.
|
||||
|
||||
## 3.0.15
|
||||
|
||||
- Add Cline Hub, a web app for monitoring connected clients, viewing and driving sessions, streaming assistant output, and restarting the local hub, with local, LAN, and tunnel usage gated by a room secret.
|
||||
|
||||
@@ -416,7 +416,7 @@ Then attach VS Code or Chrome DevTools to `ws://127.0.0.1:6499`.
|
||||
|
||||
## Publishing
|
||||
|
||||
The CLI is published as the `cline` wrapper package on npm with platform-specific binaries under `@cline/cli-*`. The release flow lives in the `publish-cli` skill (`.cline/skills/publish-cli/SKILL.md` at the repo root).
|
||||
The CLI is published as the `cline` wrapper package on npm with platform-specific binaries under `@cline/cli-*`. The release flow lives in the `publish-cli` skill (`apps/cli/.cline/skills/publish-cli/SKILL.md`).
|
||||
|
||||
From the `apps/cli` workspace:
|
||||
|
||||
|
||||
@@ -163,30 +163,6 @@ cline auth --provider anthropic --apikey sk-... --modelid claude-sonnet-4-6
|
||||
cline auth --provider openai-native --apikey sk-... --modelid gpt-5 --baseurl https://api.example.com/v1
|
||||
```
|
||||
|
||||
### MCP servers
|
||||
|
||||
Manage MCP servers with the interactive wizard:
|
||||
|
||||
```sh
|
||||
cline mcp
|
||||
cline config mcp
|
||||
```
|
||||
|
||||
Open the add-server wizard with the name, transport, and command or URL already filled in with `cline mcp install` (`cline mcp add` also works). Stdio servers use everything after `--` as the command and arguments:
|
||||
|
||||
```sh
|
||||
cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp
|
||||
```
|
||||
|
||||
Remote HTTP and SSE servers take a name, transport, and URL. The wizard still asks for auth details before saving:
|
||||
|
||||
```sh
|
||||
cline mcp install ctx7 --transport http https://mcp.context7.com/mcp
|
||||
cline mcp install events --transport sse https://example.com/sse
|
||||
```
|
||||
|
||||
Because this command opens the wizard, it requires a TTY.
|
||||
|
||||
### Connectors
|
||||
|
||||
Bridge a chat surface into RPC-backed Cline sessions. Each conversation thread maps to a session with full context. Supported platforms: Telegram, Slack, Google Chat, WhatsApp, and Linear.
|
||||
@@ -198,9 +174,6 @@ cline connect telegram -k 123456:ABCDEF...
|
||||
# Slack (webhook mode)
|
||||
cline connect slack --bot-token $SLACK_BOT_TOKEN --signing-secret $SLACK_SIGNING_SECRET --base-url https://your-domain.com
|
||||
|
||||
# Slack (socket mode)
|
||||
cline connect slack --bot-token $SLACK_BOT_TOKEN --app-token $SLACK_APP_TOKEN
|
||||
|
||||
# Google Chat (webhook mode)
|
||||
cline connect gchat --base-url https://your-domain.com
|
||||
|
||||
|
||||
+1
-15
@@ -85,20 +85,6 @@ const result = await Bun.build({
|
||||
],
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
...(process.env.TELEMETRY_SERVICE_API_KEY
|
||||
? {
|
||||
"process.env.TELEMETRY_SERVICE_API_KEY": defineProcessEnv(
|
||||
"TELEMETRY_SERVICE_API_KEY",
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(process.env.ERROR_SERVICE_API_KEY
|
||||
? {
|
||||
"process.env.ERROR_SERVICE_API_KEY": defineProcessEnv(
|
||||
"ERROR_SERVICE_API_KEY",
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
"process.env.OTEL_TELEMETRY_ENABLED": defineProcessEnv(
|
||||
"OTEL_TELEMETRY_ENABLED",
|
||||
),
|
||||
@@ -121,7 +107,7 @@ const result = await Bun.build({
|
||||
},
|
||||
env: "OTEL_*",
|
||||
banner:
|
||||
'import { createRequire as __clineCreateRequire } from "node:module"; const require = __clineCreateRequire(import.meta.url);',
|
||||
'import { createRequire as __createRequire } from "node:module"; const require = __createRequire(import.meta.url);',
|
||||
});
|
||||
|
||||
if (result.logs.length > 0) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.40",
|
||||
"version": "3.0.15",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -87,7 +87,6 @@
|
||||
"open": "^10.2.0",
|
||||
"opentui-spinner": "^0.0.6",
|
||||
"pino": "^10.3.1",
|
||||
"posthog-node": "^5.8.0",
|
||||
"react": "19.2.4",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"react-reconciler": "0.32.0",
|
||||
|
||||
@@ -511,7 +511,6 @@ export class AcpAgent implements Agent {
|
||||
|
||||
private async buildConfig(session: SessionState): Promise<Config> {
|
||||
const cwd = session.cwd || process.cwd();
|
||||
const workspaceRoot = resolveWorkspaceRoot(cwd);
|
||||
// Resolve credentials: env vars take precedence, then session provider.
|
||||
const providerId = process.env.CLINE_PROVIDER ?? session.currentProviderId;
|
||||
const apiKey = process.env.CLINE_API_KEY ?? this.authResult?.apiKey ?? "";
|
||||
@@ -520,7 +519,6 @@ export class AcpAgent implements Agent {
|
||||
providerId,
|
||||
mode: session.currentMode,
|
||||
});
|
||||
const cliBuildInfo = getCliBuildInfo();
|
||||
|
||||
return {
|
||||
providerId,
|
||||
@@ -539,23 +537,7 @@ export class AcpAgent implements Agent {
|
||||
enableAgentTeams: false,
|
||||
enableTools: true,
|
||||
cwd,
|
||||
workspaceRoot,
|
||||
extensionContext: {
|
||||
client: {
|
||||
name: "cline-acp",
|
||||
version: cliBuildInfo.version,
|
||||
platform: "cli",
|
||||
platformVersion: cliBuildInfo.version,
|
||||
isMultiRoot: false,
|
||||
},
|
||||
workspace: {
|
||||
rootPath: workspaceRoot,
|
||||
cwd,
|
||||
workspaceName: cwd,
|
||||
ide: "Terminal Shell",
|
||||
platform: process.platform,
|
||||
},
|
||||
},
|
||||
workspaceRoot: resolveWorkspaceRoot(cwd),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+59
-24
@@ -1,6 +1,11 @@
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import { loginAndSaveProviderOAuthCredentials } from "@cline/core";
|
||||
import { getPersistedProviderApiKey } from "../commands/auth";
|
||||
import type { ProviderSettings, ProviderSettingsManager } from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { OAuthCredentials } from "../commands/auth";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
saveOAuthProviderSettings,
|
||||
toProviderApiKey,
|
||||
} from "../commands/auth";
|
||||
import { writeDiagnostic } from "../utils/output";
|
||||
|
||||
/**
|
||||
@@ -25,13 +30,37 @@ export function isAcpAuthMethodId(id: string): id is AcpAuthMethodId {
|
||||
* 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")],
|
||||
);
|
||||
async function performOAuthLogin(
|
||||
providerId: AcpAuthMethodId,
|
||||
existingSettings: ProviderSettings | undefined,
|
||||
): Promise<OAuthCredentials> {
|
||||
const [{ createOAuthClientCallbacks }, { default: open }, coreOAuth] =
|
||||
await Promise.all([
|
||||
import("@cline/core"),
|
||||
import("open"),
|
||||
import("@cline/core").then((m) => ({
|
||||
loginClineOAuth: m.loginClineOAuth as (input: {
|
||||
useWorkOSDeviceAuth?: boolean;
|
||||
apiBaseUrl: string;
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>,
|
||||
loginOpenAICodex: m.loginOpenAICodex as (input: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
}) => Promise<OAuthCredentials>,
|
||||
})),
|
||||
]);
|
||||
|
||||
const callbacks = createOAuthClientCallbacks({
|
||||
onPrompt: ({ defaultValue }) => {
|
||||
@@ -53,18 +82,18 @@ async function performOAuthLogin(input: {
|
||||
},
|
||||
});
|
||||
|
||||
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}`,
|
||||
);
|
||||
if (providerId === "cline") {
|
||||
return coreOAuth.loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
existingSettings?.baseUrl?.trim() ||
|
||||
getClineEnvironmentConfig().apiBaseUrl,
|
||||
callbacks,
|
||||
useWorkOSDeviceAuth: true,
|
||||
});
|
||||
}
|
||||
return apiKey;
|
||||
|
||||
// openai-codex
|
||||
return coreOAuth.loginOpenAICodex(callbacks);
|
||||
}
|
||||
|
||||
export interface AcpAuthResult {
|
||||
@@ -93,10 +122,16 @@ export async function authenticateAcpProvider(
|
||||
|
||||
// Perform a fresh OAuth login.
|
||||
writeDiagnostic(`[acp/auth] Starting OAuth login for ${methodId}…`);
|
||||
const apiKey = await performOAuthLogin({
|
||||
providerId: methodId,
|
||||
const credentials = await performOAuthLogin(methodId, existing);
|
||||
|
||||
saveOAuthProviderSettings(
|
||||
providerSettingsManager,
|
||||
});
|
||||
methodId,
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
|
||||
const apiKey = toProviderApiKey(methodId, credentials);
|
||||
writeDiagnostic(`[acp/auth] Successfully authenticated with ${methodId}`);
|
||||
return { providerId: methodId, apiKey };
|
||||
}
|
||||
|
||||
@@ -746,27 +746,6 @@ Break work into clear steps.`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("routes mcp install and requires a TTY for the prefilled wizard", () => {
|
||||
const result = runCli(
|
||||
[
|
||||
"mcp",
|
||||
"install",
|
||||
"fs",
|
||||
"--",
|
||||
"npx",
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp",
|
||||
],
|
||||
{ env: createIsolatedEnv() },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(asText(result.stderr)).toContain(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY.",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists available tools", () => {
|
||||
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
|
||||
|
||||
@@ -18,8 +18,6 @@ interface KeyStep {
|
||||
const INITIAL_RENDER_DELAY_SECONDS = 2.5;
|
||||
const POST_ACTION_SETTLE_SECONDS = 1.0;
|
||||
const INTERACTIVE_TEST_TIMEOUT_MS = 40_000;
|
||||
const HISTORY_PICKER_READY_DELAY_SECONDS = 8.0;
|
||||
const HISTORY_RESUME_READY_DELAY_SECONDS = 15.0;
|
||||
|
||||
function normalizeTerminalOutput(output: string): string {
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally strips ANSI escape sequences
|
||||
@@ -53,40 +51,16 @@ function buildScriptCommand(scriptedInput: string, launchArgs: string): string {
|
||||
return `(${scriptedInput}) | script ${quietFlag} /dev/null ${toShellSingleQuotedLiteral(bunExec)} ${launchArgs}`;
|
||||
}
|
||||
|
||||
function createCliEnv(): NodeJS.ProcessEnv {
|
||||
function runInteractiveCli(
|
||||
steps: KeyStep[],
|
||||
options?: { launchConfigView?: boolean },
|
||||
): CliResult {
|
||||
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-home-"));
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-data-"));
|
||||
const sessionDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-sessions-"));
|
||||
const teamDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-teams-"));
|
||||
tempDirs.push(homeDir, dataDir, sessionDir, teamDir);
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
|
||||
CLINE_SESSION_DATA_DIR: sessionDir,
|
||||
CLINE_TEAM_DATA_DIR: teamDir,
|
||||
CLINE_SESSION_BACKEND_MODE: "local",
|
||||
CLINE_PROVIDER_SETTINGS_PATH: path.join(
|
||||
dataDir,
|
||||
"settings",
|
||||
"providers.json",
|
||||
),
|
||||
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
|
||||
};
|
||||
}
|
||||
|
||||
function runInteractiveCli(
|
||||
steps: KeyStep[],
|
||||
options?: {
|
||||
launchConfigView?: boolean;
|
||||
launchArgs?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
},
|
||||
): CliResult {
|
||||
const env = options?.env ?? createCliEnv();
|
||||
|
||||
const scriptedInput = [
|
||||
...steps,
|
||||
// Exit each interactive run explicitly so tests do not idle until timeout.
|
||||
@@ -106,13 +80,9 @@ function runInteractiveCli(
|
||||
"-k",
|
||||
"test-key",
|
||||
];
|
||||
const launchArgs = (
|
||||
options?.launchArgs
|
||||
? [cliEntry, ...options.launchArgs]
|
||||
: options?.launchConfigView
|
||||
? [...baseArgs, "config"]
|
||||
: baseArgs
|
||||
)
|
||||
const launchArgs = [
|
||||
...(options?.launchConfigView ? [...baseArgs, "config"] : baseArgs),
|
||||
]
|
||||
.map((arg) => toShellSingleQuotedLiteral(arg))
|
||||
.join(" ");
|
||||
const command = buildScriptCommand(scriptedInput, launchArgs);
|
||||
@@ -120,7 +90,21 @@ function runInteractiveCli(
|
||||
return spawnSync("bash", ["-lc", command], {
|
||||
cwd: cliRoot,
|
||||
encoding: "utf8",
|
||||
env,
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
|
||||
CLINE_SESSION_DATA_DIR: sessionDir,
|
||||
CLINE_TEAM_DATA_DIR: teamDir,
|
||||
CLINE_SESSION_BACKEND_MODE: "local",
|
||||
CLINE_PROVIDER_SETTINGS_PATH: path.join(
|
||||
dataDir,
|
||||
"settings",
|
||||
"providers.json",
|
||||
),
|
||||
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
|
||||
},
|
||||
timeout: INTERACTIVE_TEST_TIMEOUT_MS,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
@@ -204,62 +188,6 @@ describe("cli interactive e2e", () => {
|
||||
expect(output).toContain("/ for commands · @ for files");
|
||||
});
|
||||
|
||||
it("resumes a history-picked session and survives Ctrl+C without a native crash", {
|
||||
timeout: 120_000,
|
||||
}, () => {
|
||||
const env = createCliEnv();
|
||||
// Seed one session; the invalid key makes the run fail fast while
|
||||
// still persisting a resumable session record.
|
||||
const seed = spawnSync(
|
||||
bunExec,
|
||||
[
|
||||
cliEntry,
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"-k",
|
||||
"test-key",
|
||||
"hello",
|
||||
],
|
||||
{ cwd: cliRoot, encoding: "utf8", env, timeout: 60_000 },
|
||||
);
|
||||
expect(seed.error).toBeUndefined();
|
||||
const history = spawnSync(bunExec, [cliEntry, "history", "--json"], {
|
||||
cwd: cliRoot,
|
||||
encoding: "utf8",
|
||||
env,
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(history.error).toBeUndefined();
|
||||
expect(history.status).toBe(0);
|
||||
const historyRows = JSON.parse(history.stdout) as unknown[];
|
||||
expect(historyRows.length).toBeGreaterThan(0);
|
||||
|
||||
// history picker -> Enter resumes the seeded session in the
|
||||
// interactive TUI -> double Ctrl+C exits it. Regression guard for
|
||||
// the Bun "panic(main thread): Segmentation fault" that occurred
|
||||
// when the resumed TUI shared the picker's process (a second
|
||||
// OpenTUI renderer in one process crashes natively on teardown).
|
||||
const result = runInteractiveCli(
|
||||
[
|
||||
// Select the seeded session in the picker.
|
||||
{ delaySeconds: HISTORY_PICKER_READY_DELAY_SECONDS, input: "\r" },
|
||||
// Give the resumed TUI time to start, then double-press
|
||||
// Ctrl+C; the harness appends the final press 0.2s later.
|
||||
{ delaySeconds: HISTORY_RESUME_READY_DELAY_SECONDS, input: "\u0003" },
|
||||
],
|
||||
{ launchArgs: ["history"], env },
|
||||
);
|
||||
const output = outputOf(result);
|
||||
// The exit summary only prints after the resumed interactive TUI ran
|
||||
// and shut down cleanly; the history picker alone never prints it.
|
||||
expect(output).toContain("Session Summary");
|
||||
expect(output).not.toContain("panic(");
|
||||
expect(output).not.toContain("Segmentation fault");
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it("launches config view directly with `cline config`", () => {
|
||||
const result = runInteractiveCli(
|
||||
[{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "" }],
|
||||
|
||||
@@ -2,37 +2,7 @@ import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
normalizeAuthProviderId,
|
||||
parseAuthCommandArgs,
|
||||
saveOAuthProviderSettings,
|
||||
} from "./auth";
|
||||
|
||||
describe("parseAuthCommandArgs", () => {
|
||||
it("parses Azure API version quick setup option", () => {
|
||||
expect(
|
||||
parseAuthCommandArgs([
|
||||
"--provider",
|
||||
"openai-compatible",
|
||||
"--apikey",
|
||||
"key",
|
||||
"--modelid",
|
||||
"gpt-4.1",
|
||||
"--baseurl",
|
||||
"https://example.openai.azure.com/openai/deployments/gpt-4.1",
|
||||
"--azure-api-version",
|
||||
"2025-01-01-preview",
|
||||
]),
|
||||
).toMatchObject({
|
||||
explicitProvider: "openai-compatible",
|
||||
apikey: "key",
|
||||
modelid: "gpt-4.1",
|
||||
baseurl: "https://example.openai.azure.com/openai/deployments/gpt-4.1",
|
||||
azureApiVersion: "2025-01-01-preview",
|
||||
});
|
||||
});
|
||||
});
|
||||
import { getPersistedProviderApiKey, saveOAuthProviderSettings } from "./auth";
|
||||
|
||||
describe("saveOAuthProviderSettings", () => {
|
||||
it("preserves existing manual apiKey while updating OAuth tokens", () => {
|
||||
@@ -97,12 +67,6 @@ describe("getPersistedProviderApiKey", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeAuthProviderId", () => {
|
||||
it("keeps CLI-only codex shorthand in CLI parsing", () => {
|
||||
expect(normalizeAuthProviderId("codex")).toBe("openai-codex");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAuthTuiRuntime", () => {
|
||||
it("loads OpenTUI React after provider catalog initialization", async () => {
|
||||
const cliRoot = fileURLToPath(new URL("../..", import.meta.url));
|
||||
|
||||
+125
-46
@@ -3,12 +3,11 @@ import {
|
||||
BUILT_IN_PROVIDER,
|
||||
createOAuthClientCallbacks,
|
||||
ensureCustomProvidersLoaded,
|
||||
getProviderAuthHandler,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
listLocalProviders,
|
||||
type ProviderSettings,
|
||||
type ProviderSettingsManager,
|
||||
saveProviderOAuthCredentials,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import React from "react";
|
||||
@@ -21,8 +20,6 @@ import {
|
||||
type OAuthCredentials,
|
||||
toProviderApiKey,
|
||||
} from "../utils/provider-auth";
|
||||
import { listLocalProviders } from "../utils/provider-catalog";
|
||||
import { identifyTelemetryAccount } from "../utils/telemetry";
|
||||
|
||||
export {
|
||||
getPersistedProviderApiKey,
|
||||
@@ -40,6 +37,40 @@ const c = {
|
||||
green: "\x1b[32m",
|
||||
};
|
||||
|
||||
type CoreOAuthApi = {
|
||||
loginClineOAuth: (input: {
|
||||
apiBaseUrl: string;
|
||||
useWorkOSDeviceAuth?: boolean;
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>;
|
||||
loginOcaOAuth: (input: {
|
||||
mode?: "internal" | "external";
|
||||
callbacks: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
};
|
||||
}) => Promise<OAuthCredentials>;
|
||||
loginOpenAICodex: (input: {
|
||||
onAuth: (info: { url: string; instructions?: string }) => void;
|
||||
onPrompt: (prompt: {
|
||||
message: string;
|
||||
defaultValue?: string;
|
||||
}) => Promise<string>;
|
||||
onManualCodeInput?: () => Promise<string>;
|
||||
}) => Promise<OAuthCredentials>;
|
||||
};
|
||||
|
||||
type AuthIo = {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
@@ -50,7 +81,6 @@ type AuthQuickSetupInput = {
|
||||
apikey: string;
|
||||
modelid: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
};
|
||||
|
||||
type AuthCommandInput = {
|
||||
@@ -60,7 +90,6 @@ type AuthCommandInput = {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
};
|
||||
|
||||
type ParsedAuthCommandArgs = {
|
||||
@@ -68,10 +97,30 @@ type ParsedAuthCommandArgs = {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
parseError?: string;
|
||||
};
|
||||
|
||||
let cachedCoreOAuthApi: Promise<CoreOAuthApi> | undefined;
|
||||
|
||||
async function getCoreOAuthApi(): Promise<CoreOAuthApi> {
|
||||
if (!cachedCoreOAuthApi) {
|
||||
cachedCoreOAuthApi = import("@cline/core").then((module) => {
|
||||
const runtimeApi = module as Partial<CoreOAuthApi>;
|
||||
if (
|
||||
typeof runtimeApi.loginClineOAuth !== "function" ||
|
||||
typeof runtimeApi.loginOcaOAuth !== "function" ||
|
||||
typeof runtimeApi.loginOpenAICodex !== "function"
|
||||
) {
|
||||
throw new Error(
|
||||
"Installed @cline/core does not expose OAuth login helpers required by the CLI",
|
||||
);
|
||||
}
|
||||
return runtimeApi as CoreOAuthApi;
|
||||
});
|
||||
}
|
||||
return cachedCoreOAuthApi;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the `auth` subcommand for Commander.
|
||||
*
|
||||
@@ -88,8 +137,7 @@ export function createAuthCommand(): Command {
|
||||
.option("-p, --provider <id>", "provider id")
|
||||
.option("-k, --apikey <key>", "API key")
|
||||
.option("-m, --modelid <id>", "model id")
|
||||
.option("-b, --baseurl <url>", "base URL")
|
||||
.option("--azure-api-version <version>", "Azure API version");
|
||||
.option("-b, --baseurl <url>", "base URL");
|
||||
return cmd;
|
||||
}
|
||||
|
||||
@@ -106,7 +154,6 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
|
||||
apikey?: string;
|
||||
modelid?: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
}>();
|
||||
const positionalProvider = cmd.args[0];
|
||||
return {
|
||||
@@ -114,7 +161,6 @@ export function parseAuthCommandArgs(args: string[]): ParsedAuthCommandArgs {
|
||||
apikey: opts.apikey,
|
||||
modelid: opts.modelid,
|
||||
baseurl: opts.baseurl,
|
||||
azureApiVersion: opts.azureApiVersion,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,12 +200,6 @@ async function ensureQuickSetupInputValid(
|
||||
) {
|
||||
return "base URL is only supported for OpenAI and OpenAI-compatible providers";
|
||||
}
|
||||
if (
|
||||
input.azureApiVersion?.trim() &&
|
||||
normalizedProvider !== BUILT_IN_PROVIDER.OPENAI_COMPATIBLE
|
||||
) {
|
||||
return "Azure API version is only supported for OpenAI-compatible providers";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -169,7 +209,6 @@ function saveQuickAuthProviderSettings(input: {
|
||||
apikey: string;
|
||||
modelid: string;
|
||||
baseurl?: string;
|
||||
azureApiVersion?: string;
|
||||
}): void {
|
||||
const existing = input.providerSettingsManager.getProviderSettings(
|
||||
input.providerId,
|
||||
@@ -185,12 +224,6 @@ function saveQuickAuthProviderSettings(input: {
|
||||
if (input.baseurl?.trim()) {
|
||||
nextSettings.baseUrl = input.baseurl.trim();
|
||||
}
|
||||
if (input.azureApiVersion?.trim()) {
|
||||
nextSettings.azure = {
|
||||
...(nextSettings.azure ?? {}),
|
||||
apiVersion: input.azureApiVersion.trim(),
|
||||
};
|
||||
}
|
||||
input.providerSettingsManager.saveProviderSettings(nextSettings);
|
||||
}
|
||||
|
||||
@@ -239,18 +272,64 @@ function createOAuthCallbacks(io: AuthIo): {
|
||||
});
|
||||
}
|
||||
|
||||
async function loginWithOAuthProvider(
|
||||
providerId: string,
|
||||
existing: ProviderSettings | undefined,
|
||||
io: AuthIo,
|
||||
): Promise<OAuthCredentials> {
|
||||
const oauthApi = await getCoreOAuthApi();
|
||||
const callbacks = createOAuthCallbacks(io);
|
||||
|
||||
if (providerId === "cline") {
|
||||
return oauthApi.loginClineOAuth({
|
||||
apiBaseUrl:
|
||||
existing?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
useWorkOSDeviceAuth: true,
|
||||
callbacks,
|
||||
});
|
||||
}
|
||||
|
||||
if (providerId === "oca") {
|
||||
const mode = existing?.oca?.mode;
|
||||
return oauthApi.loginOcaOAuth({
|
||||
mode,
|
||||
callbacks,
|
||||
});
|
||||
}
|
||||
|
||||
if (providerId === "openai-codex") {
|
||||
return oauthApi.loginOpenAICodex(callbacks);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Provider "${providerId}" does not support CLI OAuth flow (supported: cline, openai-codex, oca)`,
|
||||
);
|
||||
}
|
||||
|
||||
export function saveOAuthProviderSettings(
|
||||
providerSettingsManager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
existing: ProviderSettings | undefined,
|
||||
credentials: OAuthCredentials,
|
||||
): ProviderSettings {
|
||||
return saveProviderOAuthCredentials({
|
||||
manager: providerSettingsManager,
|
||||
providerId,
|
||||
settings: existing,
|
||||
credentials,
|
||||
const auth = {
|
||||
...(existing?.auth ?? {}),
|
||||
accessToken: toProviderApiKey(providerId, credentials),
|
||||
refreshToken: credentials.refresh,
|
||||
accountId: credentials.accountId,
|
||||
} as ProviderSettings["auth"] & { expiresAt?: number };
|
||||
auth.expiresAt = credentials.expires;
|
||||
const merged: ProviderSettings = {
|
||||
...(existing ?? {
|
||||
provider: providerId as ProviderSettings["provider"],
|
||||
}),
|
||||
provider: providerId as ProviderSettings["provider"],
|
||||
auth,
|
||||
};
|
||||
providerSettingsManager.saveProviderSettings(merged, {
|
||||
tokenSource: "oauth",
|
||||
});
|
||||
return merged;
|
||||
}
|
||||
|
||||
export async function ensureOAuthProviderApiKey(input: {
|
||||
@@ -269,14 +348,19 @@ export async function ensureOAuthProviderApiKey(input: {
|
||||
selectedProviderSettings: input.existingSettings,
|
||||
};
|
||||
}
|
||||
const selectedProviderSettings = await loginAndSaveProviderOAuthCredentials(
|
||||
const credentials = await loginWithOAuthProvider(
|
||||
input.providerId,
|
||||
input.existingSettings,
|
||||
input.io,
|
||||
);
|
||||
const selectedProviderSettings = saveOAuthProviderSettings(
|
||||
input.providerSettingsManager,
|
||||
input.providerId,
|
||||
{ callbacks: createOAuthCallbacks(input.io) },
|
||||
input.existingSettings,
|
||||
credentials,
|
||||
);
|
||||
const handler = getProviderAuthHandler(input.providerId);
|
||||
return {
|
||||
apiKey: handler?.getApiKey(selectedProviderSettings),
|
||||
apiKey: toProviderApiKey(input.providerId, credentials),
|
||||
selectedProviderSettings,
|
||||
};
|
||||
}
|
||||
@@ -286,14 +370,12 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
|
||||
const apikey = input.apikey?.trim() ?? "";
|
||||
const modelid = input.modelid?.trim() ?? "";
|
||||
const baseurl = input.baseurl?.trim();
|
||||
const azureApiVersion = input.azureApiVersion?.trim();
|
||||
const validationError = await ensureQuickSetupInputValid(
|
||||
{
|
||||
provider: providerId,
|
||||
apikey,
|
||||
modelid,
|
||||
baseurl,
|
||||
azureApiVersion,
|
||||
},
|
||||
input.providerSettingsManager,
|
||||
);
|
||||
@@ -307,7 +389,6 @@ async function runQuickAuthSetup(input: AuthCommandInput): Promise<number> {
|
||||
apikey,
|
||||
modelid,
|
||||
baseurl,
|
||||
azureApiVersion,
|
||||
});
|
||||
input.io.writeln(
|
||||
`${c.green}Provider configured:${c.reset} ${c.cyan}${providerId}${c.reset} (${modelid})`,
|
||||
@@ -392,13 +473,12 @@ export async function runAuthCommand(input: AuthCommandInput): Promise<number> {
|
||||
const hasQuickSetupFlags =
|
||||
typeof input.apikey === "string" ||
|
||||
typeof input.modelid === "string" ||
|
||||
typeof input.baseurl === "string" ||
|
||||
typeof input.azureApiVersion === "string";
|
||||
typeof input.baseurl === "string";
|
||||
|
||||
if (hasQuickSetupFlags) {
|
||||
if (!input.explicitProvider?.trim()) {
|
||||
input.io.writeErr(
|
||||
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl/--azure-api-version",
|
||||
"auth quick setup requires --provider <id> when using --apikey/--modelid/--baseurl",
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
@@ -435,15 +515,14 @@ export async function runAuthProviderCommand(
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
const settings = await loginAndSaveProviderOAuthCredentials(
|
||||
const existing = providerSettingsManager.getProviderSettings(providerId);
|
||||
const credentials = await loginWithOAuthProvider(providerId, existing, io);
|
||||
saveOAuthProviderSettings(
|
||||
providerSettingsManager,
|
||||
providerId,
|
||||
{ callbacks: createOAuthCallbacks(io) },
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
identifyTelemetryAccount({
|
||||
id: settings.auth?.accountId,
|
||||
provider: providerId,
|
||||
});
|
||||
io.writeln(
|
||||
`${c.green}You are now logged in to ${c.cyan}${providerId}${c.reset}`,
|
||||
);
|
||||
|
||||
@@ -6,15 +6,6 @@ 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",
|
||||
@@ -47,9 +38,6 @@ describe("runDashboardCommand", () => {
|
||||
let observedEnv:
|
||||
| {
|
||||
workspaceRoot: string | undefined;
|
||||
clineDir: string | undefined;
|
||||
clineDataDir: string | undefined;
|
||||
providerSettingsPath: string | undefined;
|
||||
host: string | undefined;
|
||||
port: string | undefined;
|
||||
publicUrl: string | undefined;
|
||||
@@ -62,9 +50,7 @@ describe("runDashboardCommand", () => {
|
||||
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",
|
||||
@@ -76,9 +62,6 @@ describe("runDashboardCommand", () => {
|
||||
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,
|
||||
@@ -104,13 +87,6 @@ describe("runDashboardCommand", () => {
|
||||
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",
|
||||
|
||||
@@ -3,7 +3,6 @@ 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 {
|
||||
@@ -20,9 +19,7 @@ interface DashboardCommandIo {
|
||||
}
|
||||
|
||||
export interface RunDashboardCommandOptions {
|
||||
configDir?: string;
|
||||
cwd?: string;
|
||||
dataDir?: string;
|
||||
host?: string;
|
||||
port?: string;
|
||||
publicUrl?: string;
|
||||
@@ -39,9 +36,10 @@ 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;
|
||||
if (value === undefined) {
|
||||
return () => {};
|
||||
}
|
||||
process.env[name] = value;
|
||||
return () => {
|
||||
if (previous === undefined) {
|
||||
delete process.env[name];
|
||||
@@ -51,39 +49,21 @@ function setEnvValue(name: string, value: string | undefined): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
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(
|
||||
"WORKSPACE_ROOT",
|
||||
options.cwd ? resolve(options.cwd) : 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 {
|
||||
|
||||
@@ -14,7 +14,6 @@ import { getCliBuildInfo } from "../utils/common";
|
||||
const {
|
||||
mockSpawnSync,
|
||||
mockResolveClineDataDir,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockReadHubDiscovery,
|
||||
mockProbeHubServer,
|
||||
@@ -25,15 +24,6 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
mockSpawnSync: vi.fn(),
|
||||
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: path.join(
|
||||
"/tmp/cline-data",
|
||||
"locks",
|
||||
"hub",
|
||||
"production.json",
|
||||
),
|
||||
})),
|
||||
mockResolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: path.join(
|
||||
@@ -62,7 +52,6 @@ vi.mock("node:child_process", () => ({
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
resolveClineDataDir: mockResolveClineDataDir,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
@@ -87,15 +76,6 @@ describe("runDoctorCommand", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveClineDataDir.mockReturnValue("/tmp/cline-data");
|
||||
mockResolveProductionHubOwnerContext.mockReturnValue({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: path.join(
|
||||
"/tmp/cline-data",
|
||||
"locks",
|
||||
"hub",
|
||||
"production.json",
|
||||
),
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
mockStopAllConnectors.mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
@@ -130,8 +110,7 @@ describe("runDoctorCommand", () => {
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "--" &&
|
||||
args[2] === "/apps/cli/src/index.ts"
|
||||
args[1] === "/apps/cli/src/index.ts"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
@@ -282,8 +261,7 @@ describe("runDoctorCommand", () => {
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "--" &&
|
||||
args[2] === "/src-tauri/bin/code-sidecar"
|
||||
args[1] === "/src-tauri/bin/code-sidecar"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
|
||||
@@ -7,11 +7,10 @@ import {
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveClineDataDir,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { formatUptime } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
@@ -55,7 +54,6 @@ type DoctorStatus = {
|
||||
hubStartedAt?: string;
|
||||
hubUptime?: string;
|
||||
listeningPids: number[];
|
||||
staleHubPids: number[];
|
||||
hubStartupLocks: StartupArtifact[];
|
||||
staleCliPids: number[];
|
||||
staleSidecarPids: number[];
|
||||
@@ -79,11 +77,7 @@ function listMatchingProcesses(pattern: string): ProcessRecord[] {
|
||||
if (process.platform === "win32") {
|
||||
return [];
|
||||
}
|
||||
// "--" stops pgrep's option parsing so patterns that start with dashes
|
||||
// (e.g. the "--cline-hub-daemon" marker) are treated as patterns.
|
||||
const result = spawnSync("pgrep", ["-fal", "--", pattern], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
const result = spawnSync("pgrep", ["-fal", pattern], { encoding: "utf8" });
|
||||
if (result.status !== 0 && result.status !== 1) {
|
||||
return [];
|
||||
}
|
||||
@@ -154,25 +148,6 @@ function listStaleCliPids(): number[] {
|
||||
.map((record) => record.pid);
|
||||
}
|
||||
|
||||
function listStaleHubPids(currentHubPids: number[]): number[] {
|
||||
const current = new Set(currentHubPids.filter((pid) => pid > 0));
|
||||
const patterns = [
|
||||
"/sdk/packages/core/src/hub/daemon/entry.ts",
|
||||
"/sdk/packages/core/dist/hub/daemon/entry.js",
|
||||
"--cline-hub-daemon",
|
||||
];
|
||||
const records = new Map<number, ProcessRecord>();
|
||||
for (const pattern of patterns) {
|
||||
for (const record of listMatchingProcesses(pattern)) {
|
||||
if (current.has(record.pid) || /\bpgrep\s+-fal\b/.test(record.command)) {
|
||||
continue;
|
||||
}
|
||||
records.set(record.pid, record);
|
||||
}
|
||||
}
|
||||
return [...records.values()].map((record) => record.pid);
|
||||
}
|
||||
|
||||
function listStaleSidecarPids(): number[] {
|
||||
const patterns = [
|
||||
"/apps/examples/desktop-app/sidecar/index.ts",
|
||||
@@ -260,7 +235,7 @@ function readStartupArtifact(path: string): StartupArtifact | undefined {
|
||||
}
|
||||
|
||||
function listHubStartupLocks(_cwd: string): StartupArtifact[] {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const ownerPath = join(`${owner.discoveryPath}.lock`, "owner.json");
|
||||
if (!existsSync(ownerPath)) {
|
||||
return [];
|
||||
@@ -284,7 +259,7 @@ async function clearHubStartupArtifacts(
|
||||
_cwd: string,
|
||||
options?: { clearDiscovery?: boolean },
|
||||
): Promise<{ startupLocks: number; discovery: number }> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const startupLocks = listHubStartupLocks(_cwd);
|
||||
let clearedStartupLocks = 0;
|
||||
for (const artifact of startupLocks) {
|
||||
@@ -316,25 +291,14 @@ function formatHubUptimeFromStartedAt(
|
||||
return formatUptime(Date.now() - timestamp);
|
||||
}
|
||||
|
||||
function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url, { authToken: discovery.authToken })
|
||||
? await probeHubServer(discovery.url)
|
||||
: undefined;
|
||||
const current = health ?? discovery;
|
||||
const hubUptime = formatHubUptimeFromStartedAt(health?.startedAt);
|
||||
const listeningPids = listListeningPids(current?.port);
|
||||
const currentHubPids = [
|
||||
...(current?.pid ? [current.pid] : []),
|
||||
...listeningPids,
|
||||
];
|
||||
return {
|
||||
cwd,
|
||||
hubUrl: current?.url,
|
||||
@@ -342,8 +306,7 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
hubPid: current?.pid,
|
||||
hubStartedAt: health?.startedAt,
|
||||
hubUptime,
|
||||
listeningPids,
|
||||
staleHubPids: listStaleHubPids(currentHubPids),
|
||||
listeningPids: listListeningPids(current?.port),
|
||||
hubStartupLocks: listHubStartupLocks(cwd),
|
||||
staleCliPids: listStaleCliPids(),
|
||||
staleSidecarPids: listStaleSidecarPids(),
|
||||
@@ -425,7 +388,6 @@ export async function runDoctorCommand(
|
||||
);
|
||||
writeln(`hub uptime ${c.dim}${before.hubUptime ?? "n/a"}${c.reset}`);
|
||||
writeln(formatPidList("hub listeners", before.listeningPids));
|
||||
writeln(formatPidList("stale hub daemons", before.staleHubPids));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"hub startup locks",
|
||||
@@ -450,7 +412,6 @@ export async function runDoctorCommand(
|
||||
}
|
||||
if (
|
||||
before.listeningPids.length > 0 ||
|
||||
before.staleHubPids.length > 0 ||
|
||||
before.staleCliPids.length > 0 ||
|
||||
before.staleSidecarPids.length > 0
|
||||
) {
|
||||
@@ -462,9 +423,7 @@ export async function runDoctorCommand(
|
||||
}
|
||||
|
||||
const gracefullyStoppedHub = before.hubHealthy
|
||||
? await stopLocalHubServerGracefully(resolveCliHubOwnerContext()).catch(
|
||||
() => false,
|
||||
)
|
||||
? await stopLocalHubServerGracefully().catch(() => false)
|
||||
: false;
|
||||
const refreshedAfterGracefulStop = gracefullyStoppedHub
|
||||
? await collectDoctorStatus(opts.cwd)
|
||||
@@ -472,20 +431,13 @@ export async function runDoctorCommand(
|
||||
const killedHub = gracefullyStoppedHub
|
||||
? 0
|
||||
: killPids(refreshedAfterGracefulStop.listeningPids);
|
||||
const staleHubTargets = before.staleHubPids.filter(
|
||||
(pid) => !refreshedAfterGracefulStop.listeningPids.includes(pid),
|
||||
);
|
||||
const killedStaleHubs = killPids(staleHubTargets);
|
||||
const staleCliTargets = before.staleCliPids.filter(
|
||||
(pid) =>
|
||||
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
|
||||
!staleHubTargets.includes(pid),
|
||||
(pid) => !refreshedAfterGracefulStop.listeningPids.includes(pid),
|
||||
);
|
||||
const killedCli = killPids(staleCliTargets);
|
||||
const staleSidecarTargets = before.staleSidecarPids.filter(
|
||||
(pid) =>
|
||||
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
|
||||
!staleHubTargets.includes(pid) &&
|
||||
!staleCliTargets.includes(pid),
|
||||
);
|
||||
const killedSidecars = killPids(staleSidecarTargets);
|
||||
@@ -507,7 +459,6 @@ export async function runDoctorCommand(
|
||||
after,
|
||||
killed: {
|
||||
hubListeners: killedHub,
|
||||
staleHubDaemons: killedStaleHubs,
|
||||
cliProcesses: killedCli,
|
||||
sidecarProcesses: killedSidecars,
|
||||
connectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
@@ -520,7 +471,6 @@ export async function runDoctorCommand(
|
||||
return 0;
|
||||
}
|
||||
writeln(`killed hub listeners ${c.dim}${killedHub}${c.reset}`);
|
||||
writeln(`killed stale hub daemons ${c.dim}${killedStaleHubs}${c.reset}`);
|
||||
writeln(`killed cli processes ${c.dim}${killedCli}${c.reset}`);
|
||||
writeln(`killed sidecar processes ${c.dim}${killedSidecars}${c.reset}`);
|
||||
writeln(
|
||||
@@ -537,7 +487,6 @@ export async function runDoctorCommand(
|
||||
);
|
||||
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
|
||||
writeln(formatPidList("remaining hub listeners", after.listeningPids));
|
||||
writeln(formatPidList("remaining stale hub daemons", after.staleHubPids));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining hub startup locks",
|
||||
|
||||
@@ -313,45 +313,6 @@ describe("runHistoryExport", () => {
|
||||
await expect(readFile(outputPath, "utf8")).resolves.toContain("world");
|
||||
});
|
||||
|
||||
it("exports run_commands history with structured command objects", async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "cline-history-export-"));
|
||||
const outputPath = join(tempDir, "export.html");
|
||||
const artifact = {
|
||||
version: 1,
|
||||
updated_at: "2026-04-22T17:42:10.123Z",
|
||||
sessionId: "sess_1",
|
||||
messages: [
|
||||
{
|
||||
id: "m1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_1",
|
||||
name: "run_commands",
|
||||
input: {
|
||||
commands: [{ command: "cmd", args: ["/c", "dir"] }],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
} satisfies NonNullable<
|
||||
Awaited<ReturnType<typeof readSessionMessagesArtifact>>
|
||||
>;
|
||||
mockedReadSessionMessagesArtifact.mockResolvedValue(artifact);
|
||||
const io = {
|
||||
writeln: vi.fn(),
|
||||
writeErr: vi.fn(),
|
||||
};
|
||||
|
||||
const code = await runHistoryExport("sess_1", outputPath, "text", io);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(io.writeErr).not.toHaveBeenCalled();
|
||||
await expect(readFile(outputPath, "utf8")).resolves.toContain("cmd /c dir");
|
||||
});
|
||||
|
||||
it("fails when the session artifact is missing", async () => {
|
||||
mockedReadSessionMessagesArtifact.mockResolvedValue(undefined);
|
||||
const io = {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockClearHubDiscovery,
|
||||
mockEnsureDetachedHubServer,
|
||||
mockProbeHubServer,
|
||||
mockReadHubDiscovery,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockStopLocalHubServerGracefully,
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -13,10 +12,6 @@ const {
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
})),
|
||||
mockResolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
@@ -29,25 +24,13 @@ vi.mock("@cline/core", () => ({
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
}));
|
||||
|
||||
import { createHubCommand } from "./hub";
|
||||
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
|
||||
describe("createHubCommand", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it("includes uptime in hub status output", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(
|
||||
new Date("2026-01-01T00:01:05.000Z").getTime(),
|
||||
@@ -90,37 +73,4 @@ describe("createHubCommand", () => {
|
||||
uptime: "1m 5s",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the selected owner to graceful stop", async () => {
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 50174,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
|
||||
const output: string[] = [];
|
||||
let exitCode = 0;
|
||||
const cmd = createHubCommand(
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
(code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
);
|
||||
|
||||
await cmd.parseAsync(["stop"], { from: "user" });
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockStopLocalHubServerGracefully).toHaveBeenCalledWith({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
});
|
||||
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,11 +3,10 @@ import {
|
||||
ensureDetachedHubServer,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { formatUptime } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
|
||||
interface HubCommandIo {
|
||||
@@ -16,9 +15,9 @@ interface HubCommandIo {
|
||||
}
|
||||
|
||||
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (await stopLocalHubServerGracefully(owner)) {
|
||||
if (await stopLocalHubServerGracefully()) {
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return true;
|
||||
}
|
||||
@@ -47,12 +46,6 @@ function formatHubUptimeFromStartedAt(
|
||||
return formatUptime(Date.now() - timestamp);
|
||||
}
|
||||
|
||||
function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
export function createHubCommand(
|
||||
io: HubCommandIo,
|
||||
setExitCode: (code: number) => void,
|
||||
@@ -119,12 +112,10 @@ export function createHubCommand(
|
||||
|
||||
hub.command("status").action(
|
||||
action(async () => {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url, {
|
||||
authToken: discovery.authToken,
|
||||
})
|
||||
? await probeHubServer(discovery.url)
|
||||
: undefined;
|
||||
const uptime = formatHubUptimeFromStartedAt(health?.startedAt);
|
||||
io.writeln(
|
||||
|
||||
@@ -168,8 +168,6 @@ export function buildKanbanSpawnOptions(
|
||||
detached: shouldDetachKanbanProcess(platform),
|
||||
...(platform === "win32" ? { shell: true } : {}),
|
||||
...options,
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,8 +178,6 @@ function buildKanbanInstallSpawnOptions(
|
||||
return {
|
||||
detached: false,
|
||||
stdio: "inherit",
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
...(platform === "win32" ? { shell: true } : {}),
|
||||
...options,
|
||||
};
|
||||
@@ -207,8 +203,6 @@ export function getInstalledKanbanVersion(): string | null {
|
||||
const result = spawnSync(getKanbanCommand(), ["--version"], {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
return null;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,10 @@ import {
|
||||
} from "@cline/shared/storage";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
collectPluginMcpOAuthCandidates,
|
||||
installPlugin,
|
||||
isOfficialPluginSlug,
|
||||
parsePluginSource,
|
||||
runPluginInstallCommand,
|
||||
runPluginUninstallCommand,
|
||||
} from "./plugin";
|
||||
|
||||
type FetchCall = (
|
||||
@@ -36,7 +34,6 @@ describe("plugin install command", () => {
|
||||
let originalHome: string | undefined;
|
||||
let originalClineDir: string | undefined;
|
||||
let originalClineDataDir: string | undefined;
|
||||
let originalMcpSettingsPath: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "cli-plugin-install-"));
|
||||
@@ -45,7 +42,6 @@ describe("plugin install command", () => {
|
||||
originalHome = process.env.HOME;
|
||||
originalClineDir = process.env.CLINE_DIR;
|
||||
originalClineDataDir = process.env.CLINE_DATA_DIR;
|
||||
originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
process.env.HOME = home;
|
||||
process.env.CLINE_DIR = join(home, ".cline");
|
||||
process.env.CLINE_DATA_DIR = join(home, ".cline", "data");
|
||||
@@ -94,11 +90,6 @@ describe("plugin install command", () => {
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalClineDataDir;
|
||||
}
|
||||
if (originalMcpSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath;
|
||||
}
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -247,10 +238,6 @@ describe("plugin install command", () => {
|
||||
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
|
||||
"official-web-search",
|
||||
);
|
||||
const wrapperManifest = JSON.parse(
|
||||
readFileSync(join(result.installPath, "package.json"), "utf8"),
|
||||
) as { name?: string };
|
||||
expect(wrapperManifest.name).toBe("web-search");
|
||||
expect(existsSync(join(result.installPath, "repo"))).toBe(false);
|
||||
expect(
|
||||
existsSync(join(result.installPath, "package", "other-plugin")),
|
||||
@@ -340,10 +327,6 @@ describe("plugin install command", () => {
|
||||
expect(result.installPath).toContain(
|
||||
join(workspace, ".cline", "plugins", "_installed", "local"),
|
||||
);
|
||||
const wrapperManifest = JSON.parse(
|
||||
readFileSync(join(result.installPath, "package.json"), "utf8"),
|
||||
) as { name?: string };
|
||||
expect(wrapperManifest.name).toBe("web-search");
|
||||
expect(readFileSync(result.entryPaths[0] ?? "", "utf8")).toContain(
|
||||
"local-web-search",
|
||||
);
|
||||
@@ -472,8 +455,7 @@ describe("plugin install command", () => {
|
||||
|
||||
const wrapperManifest = JSON.parse(
|
||||
readFileSync(join(result.installPath, "package.json"), "utf8"),
|
||||
) as { name?: string; cline?: { plugins?: Array<{ paths?: string[] }> } };
|
||||
expect(wrapperManifest.name).toBe("plugin-package");
|
||||
) as { cline?: { plugins?: Array<{ paths?: string[] }> } };
|
||||
expect(wrapperManifest.cline?.plugins?.[0]?.paths).toHaveLength(1);
|
||||
expect(wrapperManifest.cline?.plugins?.[0]?.paths?.[0]).toContain(
|
||||
"package/index.ts",
|
||||
@@ -611,54 +593,6 @@ describe("plugin install command", () => {
|
||||
).toContain("installed-v1");
|
||||
});
|
||||
|
||||
it("uninstalls a package plugin by package name", async () => {
|
||||
const source = join(root, "uninstall-package");
|
||||
const npmCommandPath = join(root, "fake-npm.sh");
|
||||
await mkdir(source, { recursive: true });
|
||||
await writeFile(
|
||||
join(source, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "cli-uninstall-plugin",
|
||||
cline: {
|
||||
plugins: [{ paths: ["./index.ts"], capabilities: ["tools"] }],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
"utf8",
|
||||
);
|
||||
await writeFile(
|
||||
join(source, "index.ts"),
|
||||
"export default { name: 'cli-uninstall-plugin', manifest: { capabilities: ['tools'] } };",
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(npmCommandPath, "#!/bin/sh\nexit 0\n", {
|
||||
encoding: "utf8",
|
||||
mode: 0o755,
|
||||
});
|
||||
|
||||
const installed = await installPlugin({
|
||||
source,
|
||||
npmCommand: npmCommandPath,
|
||||
});
|
||||
const output: string[] = [];
|
||||
const code = await runPluginUninstallCommand({
|
||||
name: "cli-uninstall-plugin",
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(existsSync(installed.installPath)).toBe(false);
|
||||
expect(output.join("\n")).toContain(
|
||||
"Uninstalled plugin cli-uninstall-plugin",
|
||||
);
|
||||
});
|
||||
|
||||
it("prints JSON output for command callers", async () => {
|
||||
const source = join(root, "json.ts");
|
||||
writeFileSync(
|
||||
@@ -684,341 +618,11 @@ describe("plugin install command", () => {
|
||||
expect(code).toBe(0);
|
||||
const parsed = JSON.parse(stdout.join("")) as { installPath: string };
|
||||
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
|
||||
expect("mcpOAuthCandidates" in parsed).toBe(false);
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not run MCP OAuth follow-up for JSON plugin installs", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "json-oauth-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "json-oauth-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "json-oauth-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const stdout: string[] = [];
|
||||
const originalWrite = process.stdout.write;
|
||||
const authorize = vi.fn();
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
stdout.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
json: true,
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
mcpOAuth: {
|
||||
interactive: true,
|
||||
selectCandidates: async (candidates) => candidates,
|
||||
authorize,
|
||||
},
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
expect(authorize).not.toHaveBeenCalled();
|
||||
const parsed = JSON.parse(stdout.join("")) as {
|
||||
installPath: string;
|
||||
mcpOAuthCandidates?: unknown;
|
||||
};
|
||||
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
|
||||
expect(parsed.mcpOAuthCandidates).toBeUndefined();
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
});
|
||||
|
||||
it("warns when plugin MCP settings sync fails after install", async () => {
|
||||
const source = join(root, "mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "mcp-plugin",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const blockedDirectory = join(root, "not-a-directory");
|
||||
writeFileSync(blockedDirectory, "file", "utf8");
|
||||
const originalSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(
|
||||
blockedDirectory,
|
||||
"cline_mcp_settings.json",
|
||||
);
|
||||
const output: string[] = [];
|
||||
try {
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(output.join("\n")).toContain("Installed plugin from");
|
||||
expect(output.join("\n")).toContain(
|
||||
"Warning: failed to sync plugin MCP servers",
|
||||
);
|
||||
expect(output.join("\n")).toContain("mcp-plugin");
|
||||
} finally {
|
||||
if (originalSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = originalSettingsPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("detects plugin-owned remote MCP servers as OAuth candidates", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "oauth-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "oauth-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "oauth-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await installPlugin({ source });
|
||||
|
||||
expect(result.mcpOAuthCandidates).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "oauth-docs",
|
||||
pluginName: "oauth-mcp-plugin",
|
||||
transportType: "streamableHttp",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat remote MCP servers with static headers as OAuth candidates", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "headers-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "headers-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "headers-docs",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
headers: { Authorization: "Bearer token" },
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await installPlugin({ source });
|
||||
|
||||
expect(result.mcpOAuthCandidates).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips plugin MCP OAuth candidates that already have tokens", async () => {
|
||||
const settingsPath = join(root, "mcp-settings.json");
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
const source = join(root, "authorized-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "authorized-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "authorized-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const result = await installPlugin({ source });
|
||||
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<string, { oauth?: unknown }>;
|
||||
};
|
||||
const server = settings.mcpServers?.["authorized-docs"];
|
||||
if (!server) {
|
||||
throw new Error("Expected authorized-docs MCP server to be written");
|
||||
}
|
||||
server.oauth = { tokens: { access_token: "oauth-token" } };
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8");
|
||||
|
||||
expect(
|
||||
collectPluginMcpOAuthCandidates({
|
||||
pluginPaths: result.entryPaths,
|
||||
settingsPath,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("authorizes selected plugin MCP OAuth candidates during interactive installs", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "interactive-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "interactive-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "interactive-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const authorized: string[] = [];
|
||||
const output: string[] = [];
|
||||
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
mcpOAuth: {
|
||||
interactive: true,
|
||||
selectCandidates: async (candidates) => candidates,
|
||||
authorize: async (candidate) => {
|
||||
authorized.push(candidate.name);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(authorized).toEqual(["interactive-docs"]);
|
||||
expect(output.join("\n")).toContain("Installed plugin from");
|
||||
});
|
||||
|
||||
it("keeps plugin install successful when MCP OAuth authorization fails", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "failing-oauth-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "failing-oauth-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "failing-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const output: string[] = [];
|
||||
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
mcpOAuth: {
|
||||
interactive: true,
|
||||
selectCandidates: async (candidates) => candidates,
|
||||
authorize: async () => {
|
||||
throw new Error("oauth unavailable");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(output.join("\n")).toContain(
|
||||
"Warning: failed to authorize MCP server failing-docs: oauth unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("prints guidance for plugin MCP OAuth candidates in non-interactive installs", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "non-interactive-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "non-interactive-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "non-interactive-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const output: string[] = [];
|
||||
const authorize = vi.fn();
|
||||
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
mcpOAuth: {
|
||||
interactive: false,
|
||||
authorize,
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(authorize).not.toHaveBeenCalled();
|
||||
expect(output.join("\n")).toContain(
|
||||
"Plugin MCP servers may require OAuth authorization",
|
||||
);
|
||||
expect(output.join("\n")).toContain("non-interactive-docs");
|
||||
expect(output.join("\n")).toContain('Run "cline mcp"');
|
||||
});
|
||||
|
||||
it("prints JSON output for official plugin installs", async () => {
|
||||
const officialPluginsRepo = await createOfficialPluginsRepo({
|
||||
"json-plugin": {
|
||||
|
||||
+1016
-150
File diff suppressed because it is too large
Load Diff
@@ -33,7 +33,7 @@ export function addRootOptions(cmd: Command): Command {
|
||||
.option("-c, --cwd <path>", "Working directory")
|
||||
.option(
|
||||
"--thinking <level>",
|
||||
"Set reasoning effort: none|low|medium|high|xhigh. Bare --thinking uses medium; omitted leaves provider default.",
|
||||
"Set reasoning effort level between none|low|medium|high|xhigh (default: medium)",
|
||||
)
|
||||
.option("--compaction <mode>", CLI_COMPACTION_MODE_OPTION_DESCRIPTION)
|
||||
.option(
|
||||
@@ -116,6 +116,7 @@ export function createProgram(): Command {
|
||||
writeOut: () => {}, // suppress by default; main.ts re-enables for routing
|
||||
writeErr: () => {},
|
||||
})
|
||||
.allowUnknownOption()
|
||||
.allowExcessArguments()
|
||||
.enablePositionalOptions()
|
||||
.argument(
|
||||
|
||||
@@ -78,74 +78,6 @@ describe("saveLocalProviderSettings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("merges and clears Azure provider settings", () => {
|
||||
const save = vi.fn();
|
||||
const manager = {
|
||||
read: vi.fn().mockReturnValue({
|
||||
providers: {},
|
||||
}),
|
||||
write: vi.fn(),
|
||||
getFilePath: vi.fn().mockReturnValue("/tmp/providers.json"),
|
||||
getProviderSettings: vi.fn().mockReturnValue({
|
||||
provider: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2024-10-21",
|
||||
useIdentity: true,
|
||||
},
|
||||
}),
|
||||
saveProviderSettings: save,
|
||||
};
|
||||
|
||||
saveLocalProviderSettings(
|
||||
manager as unknown as ProviderSettingsManager,
|
||||
{
|
||||
action: "saveProviderSettings",
|
||||
providerId: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
},
|
||||
} as SaveProviderSettingsActionRequest,
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenCalledTimes(1);
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
{
|
||||
provider: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
useIdentity: true,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
|
||||
save.mockClear();
|
||||
manager.getProviderSettings.mockReturnValue({
|
||||
provider: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "2025-01-01-preview",
|
||||
},
|
||||
});
|
||||
|
||||
saveLocalProviderSettings(
|
||||
manager as unknown as ProviderSettingsManager,
|
||||
{
|
||||
action: "saveProviderSettings",
|
||||
providerId: "openai-compatible",
|
||||
azure: {
|
||||
apiVersion: "",
|
||||
},
|
||||
} as SaveProviderSettingsActionRequest,
|
||||
);
|
||||
|
||||
expect(save).toHaveBeenCalledWith(
|
||||
{
|
||||
provider: "openai-compatible",
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps OAuth auth fields when updating manual apiKey", () => {
|
||||
const save = vi.fn();
|
||||
const manager = {
|
||||
|
||||
@@ -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,24 +1,15 @@
|
||||
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 { afterEach, describe, expect, it } 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 {
|
||||
@@ -36,42 +27,11 @@ function createTempFile(pathSuffix: string): string {
|
||||
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 });
|
||||
}
|
||||
@@ -85,7 +45,7 @@ describe("getInstallationInfo", () => {
|
||||
expect(getInstallationInfo("1.2.3")).toEqual({
|
||||
packageManager: PackageManager.NPM,
|
||||
packageName: "cline",
|
||||
updateCommand: "npm update -g cline --tag latest",
|
||||
updateCommand: "npm install -g cline@latest",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,23 +57,7 @@ describe("getInstallationInfo", () => {
|
||||
expect(getInstallationInfo("1.2.3-nightly.456")).toEqual({
|
||||
packageManager: PackageManager.NPM,
|
||||
packageName: "cline",
|
||||
updateCommand: "npm update -g cline --tag nightly",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects bun global installs from the resolved install path", () => {
|
||||
// bun symlinks ~/.bun/bin/cline -> ~/.bun/install/global/node_modules/...,
|
||||
// and realpathSync resolves through the symlink before detection runs.
|
||||
const wrapperPath = createTempFile(
|
||||
".bun/install/global/node_modules/cline/bin/cline",
|
||||
);
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
expect(getInstallationInfo("1.2.3")).toEqual({
|
||||
packageManager: PackageManager.BUN,
|
||||
packageName: "cline",
|
||||
updateCommand: "bun add -g cline@latest",
|
||||
updateCommand: "npm install -g cline@nightly",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,122 +72,14 @@ describe("getInstallationInfo", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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",
|
||||
"npm install -g cline@latest",
|
||||
PackageManager.NPM,
|
||||
).command,
|
||||
).toBe("npm update -g cline --tag latest --min-release-age=0");
|
||||
).toBe("npm install -g cline@latest --min-release-age=0");
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass("bun add -g cline@latest", PackageManager.BUN)
|
||||
.command,
|
||||
|
||||
@@ -2,14 +2,11 @@ import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { realpathSync } from "node:fs";
|
||||
import {
|
||||
clearHubDiscovery,
|
||||
isAutoUpdateEnabledGlobally,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { version } from "../../package.json";
|
||||
import { ensureCliHubServer } from "../utils/hub-runtime";
|
||||
import { c, writeErr, writeln } from "../utils/output";
|
||||
@@ -118,12 +115,7 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
|
||||
updateCommand: `yarn global add ${DEFAULT_PACKAGE_NAME}@${tag}`,
|
||||
};
|
||||
}
|
||||
// `bun add -g` symlinks bins into ~/.bun/bin, but realpathSync resolves
|
||||
// them to ~/.bun/install/global/node_modules/..., so match both.
|
||||
if (
|
||||
scriptPath.includes("/.bun/bin") ||
|
||||
scriptPath.includes("/.bun/install/global/")
|
||||
) {
|
||||
if (scriptPath.includes("/.bun/bin")) {
|
||||
return {
|
||||
packageManager: PackageManager.BUN,
|
||||
packageName: DEFAULT_PACKAGE_NAME,
|
||||
@@ -134,7 +126,7 @@ export function getInstallationInfo(currentVersion: string): InstallationInfo {
|
||||
return {
|
||||
packageManager: PackageManager.NPM,
|
||||
packageName: DEFAULT_PACKAGE_NAME,
|
||||
updateCommand: `npm update -g ${DEFAULT_PACKAGE_NAME} --tag ${tag}`,
|
||||
updateCommand: `npm install -g ${DEFAULT_PACKAGE_NAME}@${tag}`,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
@@ -276,22 +268,13 @@ export function getPreferredKanbanInstaller(
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
export function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
async function waitForHubToStop(
|
||||
url: string,
|
||||
authToken: string | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const check = await probeHubServer(url, { authToken }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
const check = await probeHubServer(url).catch(() => undefined);
|
||||
if (!check?.url) return true;
|
||||
await sleep(100);
|
||||
}
|
||||
@@ -304,22 +287,20 @@ async function waitForHubToStop(
|
||||
* clears stale discovery, then re-ensures a fresh instance is spawned.
|
||||
*/
|
||||
async function restartHubServerIfRunning(): Promise<void> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url, {
|
||||
authToken: discovery.authToken,
|
||||
}).catch(() => undefined)
|
||||
? await probeHubServer(discovery.url).catch(() => undefined)
|
||||
: undefined;
|
||||
if (!discovery || !health?.url) return;
|
||||
if (!health?.url) return;
|
||||
|
||||
const pid = discovery?.pid;
|
||||
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
|
||||
|
||||
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
|
||||
let stopped = await stopLocalHubServerGracefully().catch(() => false);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
@@ -328,14 +309,14 @@ async function restartHubServerIfRunning(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
stopped = await waitForHubToStop(health.url, discovery.authToken, 3_000);
|
||||
stopped = await waitForHubToStop(health.url, 3_000);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
stopped = await waitForHubToStop(health.url, discovery.authToken, 2_000);
|
||||
stopped = await waitForHubToStop(health.url, 2_000);
|
||||
}
|
||||
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
@@ -359,30 +340,19 @@ async function restartHubServerIfRunning(): Promise<void> {
|
||||
export function autoUpdateOnStartup(): void {
|
||||
if (process.env.IS_DEV === "true") return;
|
||||
if (process.env.CLINE_NO_AUTO_UPDATE === "1") return;
|
||||
if (!isAutoUpdateEnabledGlobally()) return;
|
||||
|
||||
const { packageName, packageManager, updateCommand } =
|
||||
getInstallationInfo(version);
|
||||
const { packageName, updateCommand } = getInstallationInfo(version);
|
||||
if (!updateCommand) return;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const latest = await getLatestVersion(packageName, version);
|
||||
if (!latest || compareVersions(version, latest) >= 0) return;
|
||||
const autoUpdateCommand = withMinimumReleaseAgeBypass(
|
||||
updateCommand,
|
||||
packageManager,
|
||||
);
|
||||
const child = spawn(autoUpdateCommand.command, {
|
||||
const child = spawn(updateCommand, {
|
||||
shell: true,
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
env: autoUpdateCommand.env
|
||||
? { ...process.env, ...autoUpdateCommand.env }
|
||||
: process.env,
|
||||
// Prevent a console window from flashing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
env: process.env,
|
||||
});
|
||||
const exitCode = await waitForProcessExit(child);
|
||||
if (exitCode === 0) {
|
||||
|
||||
@@ -228,7 +228,7 @@ describe("discordConnector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("updates Discord participant metadata without changing the thread session", async () => {
|
||||
it("switches Discord thread state to the incoming participant without reusing the previous participant session", async () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), "discord-participants-"));
|
||||
const bindingsPath = join(dir, "threads.json");
|
||||
const thread = createThread({
|
||||
@@ -278,13 +278,11 @@ describe("discordConnector", () => {
|
||||
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");
|
||||
const bob =
|
||||
readBindings<TestDiscordState>(bindingsPath)["discord:user:bob"];
|
||||
expect(bob?.state?.participantKey).toBe("discord:user:bob");
|
||||
expect(bob?.state?.participantLabel).toBe("Bob");
|
||||
expect(bob?.state?.sessionId).toBeUndefined();
|
||||
expect(
|
||||
readBindings<TestDiscordState>(bindingsPath)["discord:user:alice"]?.state
|
||||
?.sessionId,
|
||||
|
||||
@@ -50,9 +50,10 @@ import {
|
||||
type ConnectorMuteTarget,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
mergeThreadState,
|
||||
persistMergedThreadState,
|
||||
readBindings,
|
||||
} from "../thread-bindings";
|
||||
@@ -563,17 +564,45 @@ async function postDiscordResolvedText(input: {
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCurrentStateWithParticipant(input: {
|
||||
currentState: DiscordThreadState;
|
||||
function resolveParticipantState(input: {
|
||||
bindingsPath: string;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
participant: DiscordParticipant;
|
||||
}): DiscordThreadState {
|
||||
const existing = findBindingForParticipantKey(
|
||||
readBindings<DiscordThreadState>(input.bindingsPath),
|
||||
input.participant.key,
|
||||
)?.binding.state;
|
||||
return {
|
||||
...input.currentState,
|
||||
...mergeThreadState<DiscordThreadState>(
|
||||
undefined,
|
||||
existing,
|
||||
input.baseStartRequest,
|
||||
),
|
||||
participantKey: input.participant.key,
|
||||
participantLabel: input.participant.label,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCurrentStateWithParticipant(input: {
|
||||
currentState: DiscordThreadState;
|
||||
bindingsPath: string;
|
||||
baseStartRequest: ChatStartSessionRequest;
|
||||
participant: DiscordParticipant;
|
||||
}): DiscordThreadState {
|
||||
if (input.currentState.participantKey === input.participant.key) {
|
||||
return {
|
||||
...input.currentState,
|
||||
participantLabel: input.participant.label,
|
||||
};
|
||||
}
|
||||
return resolveParticipantState({
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
participant: input.participant,
|
||||
});
|
||||
}
|
||||
|
||||
async function persistDiscordThreadContext(input: {
|
||||
thread: Thread<DiscordThreadState>;
|
||||
bindingsPath: string;
|
||||
@@ -595,6 +624,8 @@ async function persistDiscordThreadContext(input: {
|
||||
);
|
||||
const nextState = resolveCurrentStateWithParticipant({
|
||||
currentState,
|
||||
bindingsPath: input.bindingsPath,
|
||||
baseStartRequest: input.baseStartRequest,
|
||||
participant,
|
||||
});
|
||||
if (
|
||||
@@ -638,20 +669,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<DiscordThreadState>(input.bindingsPath);
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const binding = match?.binding;
|
||||
if (!binding?.serializedThread) {
|
||||
return;
|
||||
@@ -1102,7 +1133,9 @@ class DiscordConnector extends ConnectorBase<
|
||||
isSubscribedThreadMessage?: boolean;
|
||||
},
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const queueKey =
|
||||
(await loadThreadState(thread, bindingsPath, startRequest))
|
||||
.participantKey || thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { __test__ } from "./gchat";
|
||||
|
||||
describe("gchat binding lookup", () => {
|
||||
it("does not fall back to channel identity for a different space thread id", () => {
|
||||
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
legacy_thread_id: {
|
||||
@@ -21,7 +21,17 @@ describe("gchat binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(result).toEqual({
|
||||
key: "legacy_thread_id",
|
||||
binding: {
|
||||
channelId: "space-123",
|
||||
isDM: false,
|
||||
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", () => {
|
||||
@@ -55,7 +65,7 @@ describe("gchat binding lookup", () => {
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("does not reuse a binding by participant key across different spaces", () => {
|
||||
it("reuses a binding by participant key across different spaces", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
"gchat:email:alice@example.com": {
|
||||
@@ -81,6 +91,7 @@ describe("gchat binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(result?.key).toBe("gchat:email:alice@example.com");
|
||||
expect(result?.binding.sessionId).toBe("sess-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
type ConnectorBindingStore,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
@@ -191,20 +191,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<GoogleChatThreadState>(input.bindingsPath);
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const binding = match?.binding;
|
||||
if (!binding?.serializedThread) {
|
||||
return;
|
||||
@@ -590,7 +590,9 @@ class GoogleChatConnector extends ConnectorBase<
|
||||
thread: Thread<GoogleChatThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const queueKey =
|
||||
(await loadThreadState(thread, bindingsPath, startRequest))
|
||||
.participantKey || thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { __test__ } from "./linear";
|
||||
|
||||
describe("linear binding lookup", () => {
|
||||
it("does not fall back to channel identity for a different issue thread id", () => {
|
||||
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
legacy_thread_id: {
|
||||
@@ -21,7 +21,17 @@ describe("linear binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(result).toEqual({
|
||||
key: "legacy_thread_id",
|
||||
binding: {
|
||||
channelId: "linear:issue:ISS-123",
|
||||
isDM: false,
|
||||
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", () => {
|
||||
@@ -55,7 +65,7 @@ describe("linear binding lookup", () => {
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("does not reuse a binding by participant key across different issue threads", () => {
|
||||
it("reuses a binding by participant key across different issue threads", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
"linear:user:user_123": {
|
||||
@@ -81,6 +91,7 @@ describe("linear binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(result?.key).toBe("linear:user:user_123");
|
||||
expect(result?.binding.sessionId).toBe("sess-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
type ConnectorBindingStore,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
@@ -229,20 +229,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<LinearThreadState>(input.bindingsPath);
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const binding = match?.binding;
|
||||
if (!binding?.serializedThread) {
|
||||
return;
|
||||
@@ -625,7 +625,9 @@ class LinearConnector extends ConnectorBase<
|
||||
thread: Thread<LinearThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const queueKey =
|
||||
(await loadThreadState(thread, bindingsPath, startRequest))
|
||||
.participantKey || thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
|
||||
@@ -29,7 +29,7 @@ export function getConnectorSystemRules(
|
||||
}
|
||||
|
||||
const CONNECTOR_FIRST_CONTACT_MESSAGE = [
|
||||
"Connected to Cline.",
|
||||
"Connected.",
|
||||
"Your chat history is kept separately for your account.",
|
||||
"Send /new to start a fresh session or /whereami for thread details.",
|
||||
].join("\n");
|
||||
|
||||
@@ -1,74 +1,15 @@
|
||||
import type { ConnectSlackOptions } from "@cline/shared";
|
||||
import { type Message, ThreadImpl } from "chat";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __test__, slackConnector } from "./slack";
|
||||
|
||||
const parseSlackArgs = (rawArgs: string[]): ConnectSlackOptions =>
|
||||
(
|
||||
slackConnector as unknown as {
|
||||
parseArgs(rawArgs: string[]): ConnectSlackOptions;
|
||||
}
|
||||
).parseArgs(rawArgs);
|
||||
import { __test__ } from "./slack";
|
||||
|
||||
describe("slack binding lookup", () => {
|
||||
const participantKey = __test__.buildSlackParticipantKey("T123", "U123");
|
||||
|
||||
it("infers Slack webhook mode from a base URL", () => {
|
||||
expect(__test__.inferSlackConnectionMode("https://example.test")).toBe(
|
||||
"webhook",
|
||||
);
|
||||
expect(__test__.inferSlackConnectionMode(" ")).toBe("socket");
|
||||
expect(__test__.inferSlackConnectionMode(undefined)).toBe("socket");
|
||||
});
|
||||
|
||||
it("uses webhook mode when Slack args include a base URL", () => {
|
||||
const options = parseSlackArgs([
|
||||
"--bot-token",
|
||||
"xoxb-token",
|
||||
"--signing-secret",
|
||||
"secret",
|
||||
"--app-token",
|
||||
"xapp-ignored",
|
||||
"--base-url",
|
||||
"https://example.test",
|
||||
]);
|
||||
|
||||
expect(options.connectionMode).toBe("webhook");
|
||||
expect(options.baseUrl).toBe("https://example.test");
|
||||
expect(options.signingSecret).toBe("secret");
|
||||
expect(options.appToken).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses socket mode when Slack args omit a base URL", () => {
|
||||
const previousBaseUrl = process.env.BASE_URL;
|
||||
delete process.env.BASE_URL;
|
||||
let options: ConnectSlackOptions;
|
||||
try {
|
||||
options = parseSlackArgs([
|
||||
"--bot-token",
|
||||
"xoxb-token",
|
||||
"--app-token",
|
||||
"xapp-token",
|
||||
]);
|
||||
} finally {
|
||||
if (previousBaseUrl === undefined) {
|
||||
delete process.env.BASE_URL;
|
||||
} else {
|
||||
process.env.BASE_URL = previousBaseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
expect(options.connectionMode).toBe("socket");
|
||||
expect(options.baseUrl).toBeUndefined();
|
||||
expect(options.appToken).toBe("xapp-token");
|
||||
});
|
||||
|
||||
it("falls back to DM channel identity when a restarted connector gets a new thread id", () => {
|
||||
it("falls back to channel identity when a restarted connector gets a new thread id", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
legacy_thread_id: {
|
||||
channelId: "slack:C123",
|
||||
isDM: true,
|
||||
isDM: false,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", cwd: "/tmp/work", teamId: "T123" },
|
||||
@@ -78,7 +19,7 @@ describe("slack binding lookup", () => {
|
||||
{
|
||||
id: "new_thread_id",
|
||||
channelId: "slack:C123",
|
||||
isDM: true,
|
||||
isDM: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -86,7 +27,7 @@ describe("slack binding lookup", () => {
|
||||
key: "legacy_thread_id",
|
||||
binding: {
|
||||
channelId: "slack:C123",
|
||||
isDM: true,
|
||||
isDM: false,
|
||||
serializedThread: "{}",
|
||||
sessionId: "sess-1",
|
||||
state: { sessionId: "sess-1", cwd: "/tmp/work", teamId: "T123" },
|
||||
@@ -126,7 +67,7 @@ describe("slack binding lookup", () => {
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("does not reuse a binding by participant key across different threads", () => {
|
||||
it("reuses a binding by participant key across different threads", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
[participantKey]: {
|
||||
@@ -153,7 +94,8 @@ describe("slack binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(result?.key).toBe(participantKey);
|
||||
expect(result?.binding.sessionId).toBe("sess-1");
|
||||
});
|
||||
|
||||
it("builds Slack participant keys with a team scope", () => {
|
||||
@@ -215,105 +157,6 @@ describe("slack binding lookup", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes top-level channel mentions to the original Slack post thread", () => {
|
||||
const original = new ThreadImpl({
|
||||
adapterName: "slack",
|
||||
channelId: "slack:C123",
|
||||
id: "slack:C123:",
|
||||
isDM: false,
|
||||
});
|
||||
const message = {
|
||||
raw: {
|
||||
channel: "C123",
|
||||
text: "<@U999> help",
|
||||
ts: "1710000000.123456",
|
||||
type: "app_mention",
|
||||
user: "U123",
|
||||
},
|
||||
} as Message;
|
||||
|
||||
const normalized = __test__.resolveSlackChannelMentionThread(
|
||||
original,
|
||||
message,
|
||||
);
|
||||
|
||||
expect(normalized.id).toBe("slack:C123:1710000000.123456");
|
||||
expect(normalized.channelId).toBe("slack:C123");
|
||||
expect(normalized.isDM).toBe(false);
|
||||
});
|
||||
|
||||
it("uses Slack thread_ts instead of reply ts for in-thread mentions", () => {
|
||||
const original = new ThreadImpl({
|
||||
adapterName: "slack",
|
||||
channelId: "slack:C123",
|
||||
id: "slack:C123:1710000001.654321",
|
||||
isDM: false,
|
||||
});
|
||||
const message = {
|
||||
raw: {
|
||||
channel: "C123",
|
||||
text: "<@U999> follow up",
|
||||
thread_ts: "1710000000.123456",
|
||||
ts: "1710000001.654321",
|
||||
type: "app_mention",
|
||||
user: "U123",
|
||||
},
|
||||
} as Message;
|
||||
|
||||
const normalized = __test__.resolveSlackChannelMentionThread(
|
||||
original,
|
||||
message,
|
||||
);
|
||||
|
||||
expect(normalized.id).toBe("slack:C123:1710000000.123456");
|
||||
expect(normalized.channelId).toBe("slack:C123");
|
||||
expect(normalized.isDM).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps Slack mention threads that already target the original post", () => {
|
||||
const original = new ThreadImpl({
|
||||
adapterName: "slack",
|
||||
channelId: "slack:C123",
|
||||
id: "slack:C123:1710000000.123456",
|
||||
isDM: false,
|
||||
});
|
||||
const message = {
|
||||
raw: {
|
||||
channel: "C123",
|
||||
text: "<@U999> help",
|
||||
ts: "1710000000.123456",
|
||||
type: "app_mention",
|
||||
user: "U123",
|
||||
},
|
||||
} as Message;
|
||||
|
||||
expect(__test__.resolveSlackChannelMentionThread(original, message)).toBe(
|
||||
original,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not rewrite Slack DM mention threads", () => {
|
||||
const original = new ThreadImpl({
|
||||
adapterName: "slack",
|
||||
channelId: "slack:D123",
|
||||
id: "slack:D123:",
|
||||
isDM: true,
|
||||
});
|
||||
const message = {
|
||||
raw: {
|
||||
channel: "D123",
|
||||
text: "help",
|
||||
ts: "1710000000.123456",
|
||||
type: "message",
|
||||
user: "U123",
|
||||
},
|
||||
} as Message;
|
||||
|
||||
expect(__test__.resolveSlackChannelMentionThread(original, message)).toBe(
|
||||
original,
|
||||
);
|
||||
});
|
||||
|
||||
it("routes Slack posts through the installation bot token for a team", async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await __test__.withSlackTeamBotToken({
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
type Adapter,
|
||||
Chat,
|
||||
ConsoleLogger,
|
||||
type Message,
|
||||
type Thread,
|
||||
ThreadImpl,
|
||||
} from "chat";
|
||||
@@ -51,7 +50,7 @@ import {
|
||||
type ConnectorThreadBinding,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
@@ -80,14 +79,6 @@ type SlackThreadState = ConnectorThreadState & {
|
||||
teamId?: string;
|
||||
};
|
||||
|
||||
type SlackConnectionMode = ConnectSlackOptions["connectionMode"];
|
||||
|
||||
function inferSlackConnectionMode(
|
||||
baseUrl: string | undefined,
|
||||
): SlackConnectionMode {
|
||||
return baseUrl?.trim() ? "webhook" : "socket";
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength = 160): string {
|
||||
return truncateConnectorText(value, maxLength);
|
||||
}
|
||||
@@ -193,56 +184,6 @@ function extractSlackTeamId(raw: unknown): string | undefined {
|
||||
return value?.trim() || undefined;
|
||||
}
|
||||
|
||||
function extractSlackMessageRecord(
|
||||
raw: unknown,
|
||||
): Record<string, unknown> | undefined {
|
||||
const record = asRecord(raw);
|
||||
return asRecord(record?.event) ?? asRecord(record?.message) ?? record;
|
||||
}
|
||||
|
||||
function extractSlackChannelFromId(id: string): string | undefined {
|
||||
const parts = id.split(":");
|
||||
return parts[0] === "slack" ? readString(parts[1]) : undefined;
|
||||
}
|
||||
|
||||
function resolveSlackChannelMentionThread(
|
||||
thread: Thread<SlackThreadState>,
|
||||
message: Message,
|
||||
): Thread<SlackThreadState> {
|
||||
if (thread.isDM) {
|
||||
return thread;
|
||||
}
|
||||
const event = extractSlackMessageRecord(message.raw);
|
||||
const threadTs = readString(event?.thread_ts) ?? readString(event?.ts);
|
||||
if (!threadTs) {
|
||||
return thread;
|
||||
}
|
||||
const channel =
|
||||
readString(event?.channel) ??
|
||||
extractSlackChannelFromId(thread.id) ??
|
||||
extractSlackChannelFromId(thread.channelId);
|
||||
if (!channel) {
|
||||
return thread;
|
||||
}
|
||||
const threadId = `slack:${channel}:${threadTs}`;
|
||||
const channelId = `slack:${channel}`;
|
||||
if (thread.id === threadId && thread.channelId === channelId) {
|
||||
return thread;
|
||||
}
|
||||
return new ThreadImpl<SlackThreadState>({
|
||||
adapterName: "slack",
|
||||
channelId,
|
||||
channelVisibility: thread.channelVisibility,
|
||||
currentMessage: message,
|
||||
fallbackStreamingPlaceholderText: null,
|
||||
id: threadId,
|
||||
initialMessage: message,
|
||||
isDM: false,
|
||||
isSubscribedContext: false,
|
||||
streamingUpdateIntervalMs: 500,
|
||||
});
|
||||
}
|
||||
|
||||
async function withSlackBindingBotToken<T>(input: {
|
||||
slack: Pick<SlackAdapter, "getInstallation" | "withBotToken">;
|
||||
binding: ConnectorThreadBinding<SlackThreadState>;
|
||||
@@ -376,20 +317,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<SlackThreadState>(input.bindingsPath);
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const binding = match?.binding;
|
||||
const deliveryThreadId = match?.key || threadId || bindingKey;
|
||||
if (!binding?.serializedThread) {
|
||||
@@ -439,10 +380,7 @@ class SlackConnector extends ConnectorBase<
|
||||
SlackConnectorState
|
||||
> {
|
||||
constructor() {
|
||||
super(
|
||||
"slack",
|
||||
"Slack webhook/socket bridge backed by RPC runtime sessions",
|
||||
);
|
||||
super("slack", "Slack webhook bridge backed by RPC runtime sessions");
|
||||
}
|
||||
|
||||
protected override createCommand(): Command {
|
||||
@@ -455,7 +393,6 @@ class SlackConnector extends ConnectorBase<
|
||||
"Slack bot token for single-workspace mode",
|
||||
)
|
||||
.option("--signing-secret <secret>", "Slack signing secret")
|
||||
.option("--app-token <token>", "Slack app-level token for socket mode")
|
||||
.option("--client-id <id>", "Slack OAuth client id")
|
||||
.option("--client-secret <secret>", "Slack OAuth client secret")
|
||||
.option(
|
||||
@@ -496,7 +433,6 @@ class SlackConnector extends ConnectorBase<
|
||||
"Environment:",
|
||||
" SLACK_BOT_TOKEN Single-workspace bot token",
|
||||
" SLACK_SIGNING_SECRET Slack signing secret",
|
||||
" SLACK_APP_TOKEN App-level token for socket mode",
|
||||
" SLACK_CLIENT_ID OAuth client id",
|
||||
" SLACK_CLIENT_SECRET OAuth client secret",
|
||||
" SLACK_ENCRYPTION_KEY Optional installation encryption key",
|
||||
@@ -509,7 +445,6 @@ class SlackConnector extends ConnectorBase<
|
||||
userName?: string;
|
||||
botToken?: string;
|
||||
signingSecret?: string;
|
||||
appToken?: string;
|
||||
clientId?: string;
|
||||
clientSecret?: string;
|
||||
encryptionKey?: string;
|
||||
@@ -532,50 +467,17 @@ class SlackConnector extends ConnectorBase<
|
||||
this.parseOptionalInteger(opts.port, "port") ??
|
||||
Number.parseInt(process.env.PORT ?? "8787", 10);
|
||||
const port = Number.isFinite(parsedPort) ? parsedPort : 8787;
|
||||
const baseUrl = opts.baseUrl?.trim() || process.env.BASE_URL?.trim();
|
||||
const connectionMode = inferSlackConnectionMode(baseUrl);
|
||||
const isSocketMode = connectionMode === "socket";
|
||||
if (isSocketMode && (opts.clientId?.trim() || opts.clientSecret?.trim())) {
|
||||
throw new Error(
|
||||
"Slack socket mode does not support --client-id or --client-secret",
|
||||
);
|
||||
}
|
||||
const botToken =
|
||||
opts.botToken?.trim() || process.env.SLACK_BOT_TOKEN?.trim();
|
||||
const appToken = isSocketMode
|
||||
? opts.appToken?.trim() || process.env.SLACK_APP_TOKEN?.trim()
|
||||
: undefined;
|
||||
if (isSocketMode && !appToken) {
|
||||
throw new Error(
|
||||
"Slack socket mode requires --app-token or SLACK_APP_TOKEN",
|
||||
);
|
||||
}
|
||||
if (isSocketMode && !botToken) {
|
||||
throw new Error(
|
||||
"Slack socket mode requires --bot-token or SLACK_BOT_TOKEN",
|
||||
);
|
||||
}
|
||||
return {
|
||||
userName:
|
||||
opts.userName?.trim() ||
|
||||
process.env.SLACK_BOT_USERNAME?.trim() ||
|
||||
"cline-slack",
|
||||
connectionMode,
|
||||
botToken,
|
||||
botToken: opts.botToken?.trim() || process.env.SLACK_BOT_TOKEN?.trim(),
|
||||
signingSecret:
|
||||
connectionMode === "webhook"
|
||||
? opts.signingSecret?.trim() ||
|
||||
process.env.SLACK_SIGNING_SECRET?.trim()
|
||||
: opts.signingSecret?.trim(),
|
||||
appToken,
|
||||
clientId:
|
||||
connectionMode === "webhook"
|
||||
? opts.clientId?.trim() || process.env.SLACK_CLIENT_ID?.trim()
|
||||
: undefined,
|
||||
opts.signingSecret?.trim() || process.env.SLACK_SIGNING_SECRET?.trim(),
|
||||
clientId: opts.clientId?.trim() || process.env.SLACK_CLIENT_ID?.trim(),
|
||||
clientSecret:
|
||||
connectionMode === "webhook"
|
||||
? opts.clientSecret?.trim() || process.env.SLACK_CLIENT_SECRET?.trim()
|
||||
: undefined,
|
||||
opts.clientSecret?.trim() || process.env.SLACK_CLIENT_SECRET?.trim(),
|
||||
encryptionKey:
|
||||
opts.encryptionKey?.trim() || process.env.SLACK_ENCRYPTION_KEY?.trim(),
|
||||
installationKeyPrefix:
|
||||
@@ -598,7 +500,10 @@ class SlackConnector extends ConnectorBase<
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND?.trim(),
|
||||
port,
|
||||
host: opts.host?.trim() || process.env.HOST?.trim() || "0.0.0.0",
|
||||
baseUrl,
|
||||
baseUrl:
|
||||
opts.baseUrl?.trim() ||
|
||||
process.env.BASE_URL?.trim() ||
|
||||
`http://127.0.0.1:${port}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -694,11 +599,9 @@ class SlackConnector extends ConnectorBase<
|
||||
readState: (path) => this.readConnectorState(path),
|
||||
isRunning: (state) => isProcessRunning(state.pid),
|
||||
formatAlreadyRunningMessage: (state) =>
|
||||
state.connectionMode === "socket"
|
||||
? `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} mode=socket`
|
||||
: `[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
`[slack] connector already running pid=${state.pid} rpc=${state.rpcAddress} url=${state.baseUrl}`,
|
||||
formatBackgroundStartMessage: (pid) =>
|
||||
`[slack] starting background connector pid=${pid} user=${options.userName} mode=${options.connectionMode}`,
|
||||
`[slack] starting background connector pid=${pid} user=${options.userName}`,
|
||||
foregroundHint:
|
||||
"[slack] use `cline connect slack -i ...` to run in the foreground",
|
||||
launchFailureMessage: "failed to launch Slack connector in background",
|
||||
@@ -715,7 +618,6 @@ class SlackConnector extends ConnectorBase<
|
||||
const consoleLogger = new ConsoleLogger("info", "slack-connect");
|
||||
const slackConfig: Record<string, unknown> = {
|
||||
logger: consoleLogger,
|
||||
mode: options.connectionMode,
|
||||
userName: options.userName,
|
||||
};
|
||||
if (options.botToken?.trim()) {
|
||||
@@ -724,9 +626,6 @@ class SlackConnector extends ConnectorBase<
|
||||
if (options.signingSecret?.trim()) {
|
||||
slackConfig.signingSecret = options.signingSecret.trim();
|
||||
}
|
||||
if (options.appToken?.trim()) {
|
||||
slackConfig.appToken = options.appToken.trim();
|
||||
}
|
||||
if (options.clientId?.trim()) {
|
||||
slackConfig.clientId = options.clientId.trim();
|
||||
}
|
||||
@@ -795,12 +694,10 @@ class SlackConnector extends ConnectorBase<
|
||||
await client.connect();
|
||||
this.writeConnectorState(statePath, {
|
||||
userName: options.userName,
|
||||
connectionMode: options.connectionMode,
|
||||
pid: process.pid,
|
||||
rpcAddress,
|
||||
...(options.connectionMode === "webhook"
|
||||
? { port: options.port, baseUrl: options.baseUrl }
|
||||
: {}),
|
||||
port: options.port,
|
||||
baseUrl: options.baseUrl,
|
||||
startedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
@@ -826,7 +723,7 @@ class SlackConnector extends ConnectorBase<
|
||||
bindingsPath,
|
||||
startRequest,
|
||||
);
|
||||
const queueKey = thread.id;
|
||||
const queueKey = currentState.participantKey || thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await withSlackTeamBotToken({
|
||||
@@ -945,10 +842,9 @@ class SlackConnector extends ConnectorBase<
|
||||
};
|
||||
|
||||
bot.onNewMention(async (thread, message) => {
|
||||
const mentionThread = resolveSlackChannelMentionThread(thread, message);
|
||||
await mentionThread.subscribe();
|
||||
await thread.subscribe();
|
||||
await persistSlackThreadContext({
|
||||
thread: mentionThread,
|
||||
thread,
|
||||
bindingsPath,
|
||||
baseStartRequest: startRequest,
|
||||
rawMessage: message.raw,
|
||||
@@ -956,7 +852,7 @@ class SlackConnector extends ConnectorBase<
|
||||
});
|
||||
if (
|
||||
await maybeHandleConnectorApprovalReply({
|
||||
thread: mentionThread,
|
||||
thread,
|
||||
text: message.text,
|
||||
client,
|
||||
clientId,
|
||||
@@ -966,7 +862,7 @@ class SlackConnector extends ConnectorBase<
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await handleTurn(mentionThread, message.text);
|
||||
await handleTurn(thread, message.text);
|
||||
});
|
||||
|
||||
bot.onSubscribedMessage(async (thread, message) => {
|
||||
@@ -1052,64 +948,48 @@ class SlackConnector extends ConnectorBase<
|
||||
},
|
||||
});
|
||||
|
||||
let webhookUrl: string | undefined;
|
||||
let oauthCallbackUrl: string | undefined;
|
||||
const server =
|
||||
options.connectionMode === "webhook"
|
||||
? await (async () => {
|
||||
const baseUrl = options.baseUrl?.trim();
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
"Slack webhook mode requires --base-url or BASE_URL",
|
||||
);
|
||||
}
|
||||
webhookUrl = `${baseUrl.replace(/\/$/, "")}/api/webhooks/slack`;
|
||||
oauthCallbackUrl = `${baseUrl.replace(/\/$/, "")}/api/oauth/slack/callback`;
|
||||
return startConnectorWebhookServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
routes: {
|
||||
"/api/webhooks/slack": async (request) =>
|
||||
bot.webhooks.slack(request),
|
||||
"/api/oauth/slack/callback": async (request) => {
|
||||
try {
|
||||
const result = await slack.handleOAuthCallback(request);
|
||||
return new Response(
|
||||
`Slack installation stored for team ${result.teamId}. You can return to Slack.`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
loggerAdapter.core.log("Slack OAuth callback failed", {
|
||||
severity: "warn",
|
||||
transport: "slack",
|
||||
error: message,
|
||||
});
|
||||
return new Response(`Slack OAuth error: ${message}`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
},
|
||||
"/health": () => new Response("ok"),
|
||||
"/": () =>
|
||||
new Response(
|
||||
[
|
||||
"Slack connector is running.",
|
||||
"Connection mode: webhook",
|
||||
`Webhook URL: ${webhookUrl}`,
|
||||
`OAuth callback URL: ${oauthCallbackUrl}`,
|
||||
options.botToken?.trim()
|
||||
? "Auth mode: single workspace"
|
||||
: options.clientId?.trim() &&
|
||||
options.clientSecret?.trim()
|
||||
? "Auth mode: multi-workspace OAuth"
|
||||
: "Auth mode: incomplete (set bot token or OAuth credentials)",
|
||||
].join("\n"),
|
||||
),
|
||||
},
|
||||
const webhookUrl = `${options.baseUrl.replace(/\/$/, "")}/api/webhooks/slack`;
|
||||
const oauthCallbackUrl = `${options.baseUrl.replace(/\/$/, "")}/api/oauth/slack/callback`;
|
||||
const server = await startConnectorWebhookServer({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
routes: {
|
||||
"/api/webhooks/slack": async (request) => bot.webhooks.slack(request),
|
||||
"/api/oauth/slack/callback": async (request) => {
|
||||
try {
|
||||
const result = await slack.handleOAuthCallback(request);
|
||||
return new Response(
|
||||
`Slack installation stored for team ${result.teamId}. You can return to Slack.`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
loggerAdapter.core.log("Slack OAuth callback failed", {
|
||||
severity: "warn",
|
||||
transport: "slack",
|
||||
error: message,
|
||||
});
|
||||
})()
|
||||
: undefined;
|
||||
return new Response(`Slack OAuth error: ${message}`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
},
|
||||
"/health": () => new Response("ok"),
|
||||
"/": () =>
|
||||
new Response(
|
||||
[
|
||||
"Slack connector is running.",
|
||||
`Webhook URL: ${webhookUrl}`,
|
||||
`OAuth callback URL: ${oauthCallbackUrl}`,
|
||||
options.botToken?.trim()
|
||||
? "Auth mode: single workspace"
|
||||
: options.clientId?.trim() && options.clientSecret?.trim()
|
||||
? "Auth mode: multi-workspace OAuth"
|
||||
: "Auth mode: incomplete (set bot token or OAuth credentials)",
|
||||
].join("\n"),
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const stopEventStream = client.streamEvents(
|
||||
{ clientId: `${clientId}-server-events` },
|
||||
@@ -1172,22 +1052,17 @@ class SlackConnector extends ConnectorBase<
|
||||
process.once("SIGINT", () => requestStop("sigint"));
|
||||
process.once("SIGTERM", () => requestStop("sigterm"));
|
||||
|
||||
if (options.connectionMode === "webhook") {
|
||||
io.writeln(`[slack] listening on ${options.host}:${options.port}`);
|
||||
io.writeln(`[slack] configure Slack webhook URL: ${webhookUrl}`);
|
||||
io.writeln(
|
||||
`[slack] configure Slack OAuth callback URL: ${oauthCallbackUrl}`,
|
||||
);
|
||||
} else {
|
||||
io.writeln("[slack] socket mode connected");
|
||||
}
|
||||
io.writeln(`[slack] listening on ${options.host}:${options.port}`);
|
||||
io.writeln(`[slack] configure Slack webhook URL: ${webhookUrl}`);
|
||||
io.writeln(
|
||||
`[slack] configure Slack OAuth callback URL: ${oauthCallbackUrl}`,
|
||||
);
|
||||
|
||||
await stopPromise;
|
||||
clearBindingSessionIds<SlackThreadState>(bindingsPath);
|
||||
stopTaskUpdateStream();
|
||||
stopEventStream();
|
||||
await server?.close();
|
||||
await bot.shutdown();
|
||||
await server.close();
|
||||
userInstructionService.stop();
|
||||
client.close();
|
||||
this.removeStateFile(statePath);
|
||||
@@ -1198,11 +1073,9 @@ class SlackConnector extends ConnectorBase<
|
||||
export const slackConnector: ConnectCommandDefinition = new SlackConnector();
|
||||
|
||||
export const __test__ = {
|
||||
inferSlackConnectionMode,
|
||||
buildSlackParticipantKey,
|
||||
resolveSlackParticipant,
|
||||
normalizeSlackMessageEventChannelType,
|
||||
resolveSlackChannelMentionThread,
|
||||
withSlackTeamBotToken,
|
||||
isSlackInvalidThreadTsError,
|
||||
findBindingForThread: (
|
||||
|
||||
@@ -76,15 +76,7 @@ cline connect telegram -k "$TELEGRAM_BOT_TOKEN" --no-tools
|
||||
|
||||
When the connector starts with `--no-tools`, chat commands such as `/tools on` and `/yolo on` cannot re-enable tools for that connector run.
|
||||
|
||||
For participant restrictions, run the interactive connector wizard with `cline connect`. The Telegram wizard asks whether to restrict access, points you to `@userinfobot`, and configures your numeric Telegram user ID.
|
||||
|
||||
You can also pass the user ID directly:
|
||||
|
||||
```bash
|
||||
cline connect telegram -k "$TELEGRAM_BOT_TOKEN" --allowed-user-id 12345
|
||||
```
|
||||
|
||||
You can also pass a manual `--hook-command` that returns `{"action":"deny"}` for unauthorized `session.authorize` events. If neither access option is configured, messages are allowed.
|
||||
For participant restrictions, run the interactive connector wizard with `cline connect` or pass a `--hook-command` that returns `{"action":"deny"}` for unauthorized `session.authorize` events. If no hook is configured, messages are allowed.
|
||||
|
||||
## Message Delivery
|
||||
|
||||
|
||||
@@ -62,72 +62,6 @@ describe("telegramConnector", () => {
|
||||
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",
|
||||
@@ -363,7 +297,7 @@ describe("telegram binding lookup", () => {
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("does not reuse a binding by participant key across different chats", () => {
|
||||
it("reuses a binding by participant key across different chats", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
"telegram:user:alice": {
|
||||
@@ -389,6 +323,7 @@ describe("telegram binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(result?.key).toBe("telegram:user:alice");
|
||||
expect(result?.binding.sessionId).toBe("sess-1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@ import {
|
||||
type ConnectorBindingStore,
|
||||
type ConnectorThreadState,
|
||||
clearBindingSessionIds,
|
||||
findBindingForDeliveryTarget,
|
||||
findBindingForParticipantKey,
|
||||
findBindingForThread,
|
||||
loadThreadState,
|
||||
persistMergedThreadState,
|
||||
@@ -89,20 +89,6 @@ function readTelegramBotId(botToken: string): string | undefined {
|
||||
return /^\d+$/.test(botId) ? botId : undefined;
|
||||
}
|
||||
|
||||
function normalizeAllowedTelegramUserId(value: string): string {
|
||||
const userId = value.trim();
|
||||
if (!/^\d+$/.test(userId)) {
|
||||
throw new Error(
|
||||
"connect telegram --allowed-user-id must contain digits only",
|
||||
);
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
function buildTelegramAllowedUserHookCommand(userId: string): string {
|
||||
return `jq -r ".payload.actor.participantKey" | grep -qx "telegram:id:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny","message":"unauthorized","reason":"not_on_allowlist"}'`;
|
||||
}
|
||||
|
||||
function describeTelegramGetMeFailure(
|
||||
response: Response,
|
||||
body: string,
|
||||
@@ -293,20 +279,20 @@ async function deliverScheduledResult(input: {
|
||||
const threadId =
|
||||
typeof delivery.threadId === "string" ? delivery.threadId.trim() : "";
|
||||
const bindingKey =
|
||||
typeof delivery.bindingKey === "string" ? delivery.bindingKey.trim() : "";
|
||||
const participantKey =
|
||||
typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey && !participantKey) {
|
||||
typeof delivery.bindingKey === "string"
|
||||
? delivery.bindingKey.trim()
|
||||
: typeof delivery.participantKey === "string"
|
||||
? delivery.participantKey.trim()
|
||||
: "";
|
||||
if (!threadId && !bindingKey) {
|
||||
return;
|
||||
}
|
||||
const bindings = readBindings<TelegramThreadState>(input.bindingsPath);
|
||||
const match = findBindingForDeliveryTarget(bindings, {
|
||||
bindingKey,
|
||||
threadId,
|
||||
participantKey,
|
||||
});
|
||||
const match = bindingKey
|
||||
? findBindingForParticipantKey(bindings, bindingKey)
|
||||
: threadId
|
||||
? { key: threadId, binding: bindings[threadId] }
|
||||
: undefined;
|
||||
const binding = match?.binding;
|
||||
const deliveryThreadId = match?.key || threadId;
|
||||
if (!binding?.serializedThread) {
|
||||
@@ -432,10 +418,6 @@ class TelegramConnector extends ConnectorBase<
|
||||
.option("--mode <act|plan>", "Agent mode", "act")
|
||||
.option("-i, --interactive", "Keep connector in foreground")
|
||||
.option("--no-tools", "Disable tools for Telegram sessions")
|
||||
.option(
|
||||
"--allowed-user-id <id>",
|
||||
"Only allow this Telegram user ID to use the bot",
|
||||
)
|
||||
.option(
|
||||
"--hook-command <command>",
|
||||
"Run a shell command for connector events",
|
||||
@@ -452,7 +434,6 @@ class TelegramConnector extends ConnectorBase<
|
||||
"Notes:",
|
||||
" - Without -i, the connector is launched in the background.",
|
||||
" - Tools are enabled by default for Telegram sessions.",
|
||||
" - Use --allowed-user-id or `cline connect` to restrict Telegram access.",
|
||||
" - Bot username is discovered from the Telegram bot token when omitted.",
|
||||
" - Provider/model default to the CLI's last-used provider settings.",
|
||||
].join("\n"),
|
||||
@@ -473,7 +454,6 @@ class TelegramConnector extends ConnectorBase<
|
||||
tools?: boolean;
|
||||
rpcAddress?: string;
|
||||
hookCommand?: string;
|
||||
allowedUserId?: string;
|
||||
}>();
|
||||
const botUsername =
|
||||
normalizeTelegramBotUsername(opts.botUsername ?? "") ||
|
||||
@@ -485,15 +465,6 @@ class TelegramConnector extends ConnectorBase<
|
||||
if (!botToken) {
|
||||
throw new Error("connect telegram requires -k/--bot-token <token>");
|
||||
}
|
||||
const hookCommand =
|
||||
opts.hookCommand?.trim() ||
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND?.trim();
|
||||
const allowedUserId = opts.allowedUserId?.trim();
|
||||
if (hookCommand && allowedUserId) {
|
||||
throw new Error(
|
||||
"connect telegram accepts either --allowed-user-id or --hook-command, not both",
|
||||
);
|
||||
}
|
||||
return {
|
||||
botToken,
|
||||
...(botUsername ? { botUsername } : {}),
|
||||
@@ -509,11 +480,9 @@ class TelegramConnector extends ConnectorBase<
|
||||
opts.rpcAddress?.trim() ||
|
||||
process.env.CLINE_RPC_ADDRESS?.trim() ||
|
||||
resolveDefaultCliRpcAddress(),
|
||||
hookCommand: allowedUserId
|
||||
? buildTelegramAllowedUserHookCommand(
|
||||
normalizeAllowedTelegramUserId(allowedUserId),
|
||||
)
|
||||
: hookCommand,
|
||||
hookCommand:
|
||||
opts.hookCommand?.trim() ||
|
||||
process.env.CLINE_CONNECT_HOOK_COMMAND?.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -788,7 +757,9 @@ class TelegramConnector extends ConnectorBase<
|
||||
thread: Thread<TelegramThreadState>,
|
||||
text: string,
|
||||
) => {
|
||||
const queueKey = thread.id;
|
||||
const queueKey =
|
||||
(await loadThreadState(thread, bindingsPath, startRequest))
|
||||
.participantKey || thread.id;
|
||||
const runTurn = async () => {
|
||||
try {
|
||||
await handleConnectorUserTurn({
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("whatsapp binding lookup", () => {
|
||||
expect(result?.binding.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("does not reuse a binding by participant key across different threads", () => {
|
||||
it("reuses a binding by participant key across different threads", () => {
|
||||
const result = __test__.findBindingForThread(
|
||||
{
|
||||
"whatsapp:user:15551234567": {
|
||||
@@ -91,6 +91,7 @@ describe("whatsapp binding lookup", () => {
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(result?.key).toBe("whatsapp:user:15551234567");
|
||||
expect(result?.binding.sessionId).toBe("sess-1");
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user