mirror of
https://github.com/cline/cline.git
synced 2026-09-04 20:02:30 +08:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d3a257b08 | |||
| 293d1cee0c | |||
| 5dcfa9cf79 | |||
| 1e3972bcf0 | |||
| 5a5e23b79f | |||
| 70b5f4bc6f | |||
| 15bf76beb3 | |||
| 07a8cb28b8 | |||
| 08e32a26f6 | |||
| 44257db17e | |||
| d93481d511 | |||
| 3f2976b290 | |||
| 877ba07f0d | |||
| d21433e7ca | |||
| 8e956fa3b9 | |||
| 3fdf8fc135 | |||
| 991e33f385 | |||
| 2bd7bbd44e | |||
| db823a5b96 | |||
| c17dec92ea | |||
| a37ab9366d | |||
| c7bbbda086 | |||
| 9e777c0f4b | |||
| 57df42db8e | |||
| 1f86e4bc37 | |||
| af3d81cf99 | |||
| 068688d162 | |||
| 6f8522e9c4 | |||
| e67f31a684 | |||
| 6777756a8e | |||
| 0f30864f8a | |||
| 367446c5d1 | |||
| d2c5e739fb | |||
| 87a0048968 | |||
| 7d7708b1c9 | |||
| 05d07e2bd7 | |||
| 69fa94804c | |||
| 04623ebe04 | |||
| b8849c49cd | |||
| 87bf1bf727 | |||
| bd55f2d328 | |||
| bbdd9d34a8 | |||
| 3a9c97b322 | |||
| e17ba43260 | |||
| e5f35422a4 | |||
| ed3401bfcc | |||
| 5dfc32daf8 | |||
| a039cded0f | |||
| fee494cd17 | |||
| 97ccdbbcd2 | |||
| 28c69b7d9f | |||
| f75044c635 | |||
| 3b31317881 | |||
| f5b3c4fe4a | |||
| 5f29ab5953 | |||
| 51f0bf7b9f | |||
| 7fc6dd3479 | |||
| 10d8dbb884 |
@@ -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,11 +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). Run with node, NOT bun — Playwright's
|
||||
# Electron launch times out under bun:
|
||||
node src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
# Launch (skip-build if already built):
|
||||
npx tsx src/dev/debug-harness/server.ts --skip-build --auto-launch
|
||||
|
||||
# In another terminal:
|
||||
curl localhost:19229/api -d '{"method":"status"}'
|
||||
@@ -44,15 +43,8 @@ For **Cline OAuth** (SDK local callback): The SDK starts a local HTTP server, th
|
||||
is captured. To complete: open the captured URL in a real browser (it redirects back to the
|
||||
SDK's callback server), OR extract the callback port and `curl http://127.0.0.1:PORT/callback?code=...`.
|
||||
|
||||
For **MCP/Provider OAuth** (vscode:// URI): The redirect goes to a vscode:// URI.
|
||||
`oauth.simulate_callback` only *builds* the URI — it does not deliver it, and the ESM
|
||||
extension host can't `require()` the handler. To actually deliver the callback, call the
|
||||
debug-only hook via `ext.evaluate` (with `awaitPromise: true`):
|
||||
`globalThis.__clineHandleUri("vscode://saoudrizwan.claude-dev/...?code=...&state=...")`.
|
||||
It runs the same `SharedUriHandler.handleUri` as VSCode's real URI handler and exists only
|
||||
when `CLINE_CAPTURE_BROWSER` is set (the harness always sets it; never ships in prod).
|
||||
For end-to-end MCP OAuth, get a real `code` from the local MCP OAuth test server
|
||||
(`bun run dev:mcp-oauth-test-server`).
|
||||
For **MCP/Provider OAuth** (vscode:// URI): The redirect goes to a vscode:// URI. Use
|
||||
`oauth.simulate_callback` to build it, then inject via `ext.evaluate` calling the URI handler.
|
||||
|
||||
## Navigating Views — Use Commands, Not Clicks
|
||||
|
||||
|
||||
+16
-59
@@ -13,10 +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`).
|
||||
- When reading a configuration files that users may edit, use `readFileStrippingUtf8Bom`, `readFileSyncStrippingUtf8Bom`, or `stripUtf8Bom` from `@cline/shared/node`. DON'T strip byte order marks of user files handled by tools/passed to models.
|
||||
- This is a VS Code extension—check `package.json` for available scripts before trying to verify builds (e.g., `npm run compile`, not `npm run build`).
|
||||
- When creating PRs, contributors should not create changelog-entry files. Maintainers handle release versioning and changelog curation during the release process.
|
||||
- When adding new feature flags, see this PR as a reference https://github.com/cline/cline/pull/7566
|
||||
- Additional instructions about making requests: @.clinerules/network.md
|
||||
@@ -74,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
|
||||
@@ -99,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(...)`
|
||||
@@ -110,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.
|
||||
@@ -158,48 +160,3 @@ const isGenerating = explanationInfo.status === "generating" && !wasCancelled
|
||||
**See also:** `BrowserSessionRow.tsx` uses similar pattern with `isLastApiReqInterrupted` and `isLastMessageResume`.
|
||||
|
||||
**Backend side:** When streaming is cancelled, clean up properly (close tabs, clear comments, etc.) by checking `taskState.abort` after the streaming function returns.
|
||||
|
||||
## Debug Harness: clear inherited VSCode/Electron env vars before launching
|
||||
|
||||
The debug harness (`apps/vscode/src/dev/debug-harness/server.ts`) launches a child
|
||||
VSCode via Playwright's `_electron.launch({ env: { ...process.env, ... } })`. If you
|
||||
run the harness from a process that was itself spawned by VSCode (e.g. the Cline
|
||||
extension host, an integrated terminal, or an agent running inside VSCode), the
|
||||
parent's VSCode/Electron env vars leak into the child and break the launch.
|
||||
|
||||
The fatal one is **`ELECTRON_RUN_AS_NODE=1`**: it makes the child VSCode binary run
|
||||
as plain Node, so it rejects every VSCode CLI flag. Symptom:
|
||||
|
||||
```
|
||||
.../Visual Studio Code.app/Contents/MacOS/Code: bad option: --extensionDevelopmentPath=...
|
||||
Error: Process failed to launch! (Playwright _electron.launch)
|
||||
```
|
||||
|
||||
This is NOT the macOS Playwright flakiness mentioned in the harness README — it's
|
||||
env inheritance. Fix: strip the inherited vars before starting the harness:
|
||||
|
||||
```bash
|
||||
env -u ELECTRON_RUN_AS_NODE -u ELECTRON_NO_ATTACH_CONSOLE \
|
||||
-u VSCODE_CLI -u VSCODE_CODE_CACHE_PATH -u VSCODE_CRASH_REPORTER_PROCESS_TYPE \
|
||||
-u VSCODE_CWD -u VSCODE_ESM_ENTRYPOINT -u VSCODE_HANDLES_UNCAUGHT_ERRORS \
|
||||
-u VSCODE_IPC_HOOK -u VSCODE_NLS_CONFIG -u VSCODE_PID -u VSCODE_L10N_BUNDLE_LOCATION \
|
||||
bun src/dev/debug-harness/server.ts --auto-launch --skip-build
|
||||
```
|
||||
|
||||
Check your own env with `env | grep -iE 'electron|vscode_'` first; `ELECTRON_RUN_AS_NODE=1`
|
||||
present means you must scrub before launching.
|
||||
|
||||
Other harness notes confirmed in practice:
|
||||
- The extension host is **ESM** (`VSCODE_ESM_ENTRYPOINT`), so `ext.evaluate` has no
|
||||
`require` and module-internal functions aren't reachable as globals. To inspect
|
||||
internal builders (e.g. `buildBedrockProviderConfig`), set a breakpoint with
|
||||
`ext.set_breakpoint` and read locals via `ext.evaluate` with the paused `callFrameId`
|
||||
— don't try to `require()` the bundle.
|
||||
- `web.evaluate` wraps the expression as a single returned expression; multi-statement
|
||||
snippets must be an IIFE `(() => { ...; return x; })()`, otherwise you get
|
||||
`SyntaxError: Unexpected token ';'`.
|
||||
- Webview settings inputs are `vscode-text-field` web components with debounced React
|
||||
onChange. Setting `.value` + dispatching events via `web.evaluate` is unreliable for
|
||||
some fields; focus the inner shadow `input` then use real keystrokes (`ui.type` +
|
||||
`ui.press Tab`, or click the dropdown option) to make the value persist.
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ Here, we use the common `StringRequest` and `KeyValuePair` types.
|
||||
|
||||
After editing a `.proto` file, regenerate the TypeScript code. From the project root, run:
|
||||
```bash
|
||||
bun run protos
|
||||
npm run protos
|
||||
```
|
||||
This command compiles all `.proto` files and outputs the generated code to `src/generated/` and `src/shared/`. Do not edit these generated files manually.
|
||||
|
||||
|
||||
@@ -91,7 +91,7 @@ On the main branch, create a commit that updates:
|
||||
|
||||
3. No changelog-entry file cleanup is needed. Contributors do not create changelog-entry files in this repo.
|
||||
|
||||
**No dependency install is needed.** A CHANGELOG + `version` bump does not change any dependency, and `bun.lock` does not pin workspace-package versions, so the lockfile stays consistent. The publish workflow runs `bun install --frozen-lockfile`, which would *fail* on an out-of-sync lock — so only run `bun install` here if you actually change dependencies (then commit the updated `bun.lock`).
|
||||
**Skip running `npm run install:all`** - release automation handles lockfile consistency as needed.
|
||||
|
||||
Commit with message format: `v{VERSION} Release Notes (hotfix)`
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ body:
|
||||
value: |
|
||||
**Important:** All bug reports must be reproducible using Claude Sonnet 4.5. Cline uses complex prompts so less capable models may not work as expected.
|
||||
- type: dropdown
|
||||
id: cline-surface
|
||||
id: plugin-type
|
||||
attributes:
|
||||
label: Cline Surface
|
||||
description: Which Cline surface are you reporting a bug for?
|
||||
label: Plugin Type
|
||||
description: Which plugin are you reporting a bug for?
|
||||
options:
|
||||
- VSCode Extension
|
||||
- JetBrains Plugin
|
||||
@@ -59,18 +59,6 @@ body:
|
||||
placeholder: 'e.g., cline:anthropic/claude-sonnet-4.5, gemini:gemini-2.5-pro-exp-03-25'
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: ide-diagnostics
|
||||
attributes:
|
||||
label: IDE / CLI Diagnostics
|
||||
description: |
|
||||
Paste the "About" diagnostics for your Cline surface. This captures the IDE build, runtime, and host details we need.
|
||||
- VSCode Extension: open `Help → About` (Windows/Linux) or `Code → About Visual Studio Code` (macOS), then copy the info.
|
||||
- JetBrains Plugin: open `Help → About` (Windows/Linux) or `<IDE name> → About` (macOS), then click `Copy` to grab build, runtime, OS, memory, and cores.
|
||||
- CLI: there is no About dialog. Run `cline --version` and paste the output.
|
||||
placeholder: Paste the copied About info or `cline --version` output here.
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: system-info
|
||||
attributes:
|
||||
|
||||
@@ -9,14 +9,14 @@ This is a VS Code extension. Read `.clinerules/general.md` for tribal knowledge
|
||||
- **MCP**: `src/services/mcp/McpHub.ts`.
|
||||
|
||||
## Build & Test (Critical — non-obvious commands)
|
||||
- **Build**: `bun run compile` — NOT `bun run build`.
|
||||
- **Watch**: `bun run watch` (extension + webview).
|
||||
- **Protos**: `bun run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
|
||||
- **Tests**: `bun run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true bun run test:unit`.
|
||||
- **Build**: `npm run compile` — NOT `npm run build`.
|
||||
- **Watch**: `npm run watch` (extension + webview).
|
||||
- **Protos**: `npm run protos` — run **immediately** after any `.proto` change. Generates into `src/shared/proto/`, `src/generated/`.
|
||||
- **Tests**: `npm run test:unit`. After prompt/tool changes: `UPDATE_SNAPSHOTS=true npm run test:unit`.
|
||||
|
||||
## Protobuf RPC Workflow (4 steps)
|
||||
1. **Define** in `proto/cline/*.proto`. Naming: `PascalCaseService`, `camelCase` RPCs, `PascalCase` Messages. Use `common.proto` shared types for simple data.
|
||||
2. **Generate**: `bun run protos`.
|
||||
2. **Generate**: `npm run protos`.
|
||||
3. **Backend handler**: `src/core/controller/<domain>/`.
|
||||
4. **Frontend call**: `UiServiceClient.myMethod(Request.create({...}))`.
|
||||
- Adding enums (e.g. `ClineSay`) → also update `src/shared/proto-conversions/cline-message.ts`.
|
||||
@@ -38,13 +38,13 @@ For Responses API providers: add to `isNextGenModelProvider()` in `src/utils/mod
|
||||
4. Whitelist in `src/core/prompts/system-prompt/variants/*/config.ts` for each model family.
|
||||
5. Handler in `src/core/task/tools/handlers/`, wire in `ToolExecutor.ts`.
|
||||
6. If tool has UI: add `ClineSay` enum in proto → `ExtensionMessage.ts` → `cline-message.ts` → `ChatRow.tsx`.
|
||||
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true bun run test:unit`.
|
||||
7. Regenerate snapshots: `UPDATE_SNAPSHOTS=true npm run test:unit`.
|
||||
|
||||
## Modifying System Prompt
|
||||
Modular: `components/` (shared) + `variants/` (model-specific) + `templates/` (`{{PLACEHOLDER}}`). Variants override components via `componentOverrides` in `config.ts` or custom `template.ts`. XS variant is heavily condensed inline. Always regenerate snapshots after changes.
|
||||
|
||||
## Global State Keys (silent failure risk)
|
||||
Adding a key requires updating the typed storage definitions in `src/shared/storage/state-keys.ts`; runtime reads and writes should go through `StateManager`, not VS Code `ExtensionContext` storage. Persistent state is file-backed so it works across VS Code, CLI, and JetBrains hosts.
|
||||
Adding a key requires: type in `src/shared/storage/state-keys.ts`, read via `context.globalState.get()` in `src/core/storage/utils/state-helpers.ts` `readGlobalStateFromDisk()`, and add to return object. Missing the `.get()` call compiles fine but value is always `undefined`.
|
||||
|
||||
## Slash Commands (3 places)
|
||||
- `src/core/slash-commands/index.ts` — definitions.
|
||||
|
||||
@@ -59,7 +59,7 @@ We're not looking for exhaustive documentation - just evidence that you've thoug
|
||||
<!-- Put an 'x' in all boxes that apply -->
|
||||
|
||||
- [ ] Changes are limited to a single feature, bugfix or chore (split larger changes into separate PRs)
|
||||
- [ ] Tests are passing (`bun test`) and code is formatted and linted (`bun run format && bun run lint`)
|
||||
- [ ] Tests are passing (`npm test`) and code is formatted and linted (`npm run format && npm run lint`)
|
||||
- [ ] I have reviewed [contributor guidelines](https://github.com/cline/cline/blob/main/CONTRIBUTING.md)
|
||||
|
||||
### Screenshots
|
||||
|
||||
@@ -33,7 +33,7 @@ permissions:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
working-directory: sdk
|
||||
|
||||
jobs:
|
||||
publish-main:
|
||||
@@ -105,12 +105,12 @@ jobs:
|
||||
PACKAGE_VERSION=$(node -p "require('./apps/cli/package.json').version")
|
||||
|
||||
if [ "$PACKAGE_VERSION" != "$VERSION" ]; then
|
||||
echo "apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
|
||||
echo "sdk/apps/cli/package.json version ${PACKAGE_VERSION} does not match ${TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! printf "%s\n" "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then
|
||||
echo "apps/cli/package.json has invalid version: ${VERSION}"
|
||||
echo "sdk/apps/cli/package.json has invalid version: ${VERSION}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -147,7 +147,7 @@ jobs:
|
||||
|
||||
- name: Build platform binaries
|
||||
run: bun script/build.ts --install-native-variants --skip-sdk-build
|
||||
working-directory: apps/cli
|
||||
working-directory: sdk/apps/cli
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
@@ -194,7 +194,7 @@ jobs:
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
run: bun script/publish-npm.ts --tag latest
|
||||
working-directory: apps/cli
|
||||
working-directory: sdk/apps/cli
|
||||
|
||||
- name: Get Previous CLI Tag
|
||||
id: prev_tag
|
||||
@@ -375,7 +375,7 @@ jobs:
|
||||
- name: Build platform binaries
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: bun script/build.ts --install-native-variants --skip-sdk-build
|
||||
working-directory: apps/cli
|
||||
working-directory: sdk/apps/cli
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
@@ -424,7 +424,7 @@ jobs:
|
||||
env:
|
||||
NPM_CONFIG_PROVENANCE: "true"
|
||||
run: bun script/publish-npm.ts --tag nightly
|
||||
working-directory: apps/cli
|
||||
working-directory: sdk/apps/cli
|
||||
|
||||
- name: Summary
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
|
||||
@@ -1,205 +0,0 @@
|
||||
name: ext-vscode-ab-package
|
||||
|
||||
# Build (and optionally publish) the combined A/B VSIX: a tiny loader plus two
|
||||
# complete extension bundles — `next/` from the SDK-based apps/vscode on main,
|
||||
# `legacy/` from the legacy-extension branch. Cohort selection happens at
|
||||
# runtime via PostHog flags; see apps/vscode-rollout/README.md for the design
|
||||
# and the rollout runbook.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: "Combined VSIX version — must exceed every previously published version (e.g. 4.1.0)"
|
||||
required: true
|
||||
type: string
|
||||
next-ref:
|
||||
description: "Ref to build the next (SDK) bundle from"
|
||||
required: true
|
||||
default: "main"
|
||||
type: string
|
||||
legacy-ref:
|
||||
description: "Ref to build the legacy bundle from"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
publish:
|
||||
description: "Publish to the VS Code Marketplace (unchecked: just build the .vsix artifact)"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-ab-package-${{ github.event.inputs.version }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
package:
|
||||
name: Build combined (legacy + next) VSIX
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
steps:
|
||||
- name: Checkout next (SDK) source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.next-ref }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.legacy-ref }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install next workspace dependencies
|
||||
working-directory: next-src
|
||||
run: bun install
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; apps/vscode's
|
||||
# `package` script does NOT build them, so without this the esbuild step
|
||||
# fails on a fresh checkout. (The nightly workflow already does this.)
|
||||
- name: Build SDK packages
|
||||
working-directory: next-src
|
||||
run: bun run build:sdk
|
||||
|
||||
# Stamp the combined version into each bundle's package.json AFTER
|
||||
# install and BEFORE its build: the About tab and telemetry
|
||||
# extension_version read the bundle's own manifest, so without this
|
||||
# the VSIX reports three different versions depending on where you
|
||||
# look. (The nightly workflow gets the same alignment via nightlify.mjs.)
|
||||
- name: Align next bundle version
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ github.event.inputs.version }}"
|
||||
|
||||
- name: Build next bundle
|
||||
working-directory: next-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# Inlined by esbuild: attributes every telemetry event with
|
||||
# extension_variant and unlocks the bundle's authoritative
|
||||
# extension.rollout.bundle_activated capture. Rollout builds only.
|
||||
CLINE_ROLLOUT_VARIANT: next
|
||||
# Match the stable publish workflow's OpenTelemetry production defaults.
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: bun run package
|
||||
|
||||
- name: Install legacy dependencies
|
||||
working-directory: legacy-src
|
||||
run: |
|
||||
npm --prefix apps/vscode install --include=optional
|
||||
npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Align legacy bundle version
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/set-version.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ github.event.inputs.version }}"
|
||||
|
||||
- name: Build legacy bundle
|
||||
working-directory: legacy-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ROLLOUT_VARIANT: legacy
|
||||
# Match the stable publish workflow's OpenTelemetry production defaults.
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: npm run package
|
||||
|
||||
- name: Build loader and run rollout tests
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
run: |
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run build:production
|
||||
|
||||
- name: Stitch combined VSIX staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: |
|
||||
node scripts/stitch.mjs \
|
||||
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
|
||||
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
|
||||
--loader dist/extension.js \
|
||||
--version "${{ github.event.inputs.version }}" \
|
||||
--out "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
- name: Smoke-test loader against staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
# This workflow publishes the STABLE identity. If nightlify ever leaks
|
||||
# into this path the union manifest would ship under the wrong name.
|
||||
# The bundle sub-manifest checks guard the set-version.mjs stamping:
|
||||
# the About tab and telemetry extension_version read those files.
|
||||
- name: Assert stable manifest identity
|
||||
working-directory: staging
|
||||
env:
|
||||
EXPECTED_VERSION: ${{ github.event.inputs.version }}
|
||||
run: |
|
||||
node -e '
|
||||
const assert = require("node:assert");
|
||||
const expected = process.env.EXPECTED_VERSION;
|
||||
const pkg = require("./package.json");
|
||||
assert.equal(pkg.name, "claude-dev", `unexpected name ${pkg.name}`);
|
||||
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
|
||||
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
|
||||
for (const bundle of ["next", "legacy"]) {
|
||||
const sub = require(`./${bundle}/package.json`);
|
||||
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
|
||||
}
|
||||
console.log(`stable identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle versions aligned)`);
|
||||
'
|
||||
|
||||
- name: Package VSIX
|
||||
working-directory: staging
|
||||
run: |
|
||||
npm install -g @vscode/vsce
|
||||
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
|
||||
# both standalone bundle workflows. No SendGrid credential is intentionally
|
||||
# supplied here; inspect the reported artifact before widening the exemption.
|
||||
vsce package --no-dependencies --allow-package-secrets sendgrid --out "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: claude-dev-${{ github.event.inputs.version }}
|
||||
path: staging/claude-dev-${{ github.event.inputs.version }}.vsix
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Publish to Marketplace
|
||||
if: ${{ github.event.inputs.publish == 'true' }}
|
||||
working-directory: staging
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish."
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "claude-dev-${{ github.event.inputs.version }}.vsix"
|
||||
@@ -1,294 +0,0 @@
|
||||
name: ext-vscode-publish-legacy
|
||||
|
||||
# Publishes the legacy (pre-SDK-migration) VS Code extension from the
|
||||
# `legacy-extension` branch. This branch holds the npm-based 3.89.x codebase,
|
||||
# rolled forward under a 4.0.x version so existing 4.0.0 users still receive
|
||||
# the update. The main `ext-vscode-publish-stable.yml` workflow (bun-based)
|
||||
# stays the path for releasing main once the SDK migration is solid.
|
||||
#
|
||||
# This workflow lives on and is dispatched from `main` (so it satisfies the
|
||||
# default-branch dispatch requirement), but it checks out and builds the
|
||||
# `legacy-extension` branch.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release-type:
|
||||
description: "Choose release type (release or pre-release)"
|
||||
required: true
|
||||
default: "release"
|
||||
type: choice
|
||||
options:
|
||||
- pre-release
|
||||
- release
|
||||
branch:
|
||||
description: "Branch holding the legacy extension code"
|
||||
required: true
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-legacy-${{ github.event.inputs.branch }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
# Gate the publish on the legacy branch's own npm-based test suite. We can't
|
||||
# reuse ./.github/workflows/ext-vscode-test.yml here — on main that's the
|
||||
# bun-based suite and it would test main, not the legacy branch — so the
|
||||
# essential quality + test steps are inlined against the checked-out legacy
|
||||
# branch.
|
||||
test:
|
||||
name: Test Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui ci
|
||||
|
||||
- name: Run Quality Checks (lint + typecheck)
|
||||
run: npm run ci:check-all
|
||||
|
||||
- name: Build Tests and Extension
|
||||
id: build_step
|
||||
run: npm run ci:build
|
||||
|
||||
- name: Unit Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: npm run test:unit
|
||||
|
||||
- name: Extension Integration Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: xvfb-run -a npm run test:coverage
|
||||
|
||||
- name: Webview Tests
|
||||
if: ${{ !cancelled() && steps.build_step.outcome == 'success' }}
|
||||
run: |
|
||||
cd webview-ui
|
||||
npm run test:coverage
|
||||
|
||||
publish:
|
||||
needs: test
|
||||
name: Publish Legacy Extension
|
||||
runs-on: ubuntu-latest
|
||||
environment: publish
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
# Check out the legacy branch (NOT main). fetch-depth: 0 + tags so we
|
||||
# can create/push the release tag and compute the previous tag.
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch }}
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
lfs: true
|
||||
|
||||
- name: Resolve Release Tag
|
||||
id: resolve_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
BRANCH: ${{ github.event.inputs.branch }}
|
||||
run: |
|
||||
# Tag is derived from the package version on the legacy branch.
|
||||
VERSION=$(node -p "require('./apps/vscode/package.json').version")
|
||||
TAG="v$VERSION"
|
||||
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$ ]]; then
|
||||
echo "Error: derived tag '$TAG' does not match vX.Y.Z"
|
||||
exit 1
|
||||
fi
|
||||
TAG_REF="refs/tags/$TAG"
|
||||
HEAD_SHA=$(git rev-parse HEAD)
|
||||
|
||||
if git show-ref --verify --quiet "$TAG_REF"; then
|
||||
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
|
||||
if [[ "$TAG_SHA" != "$HEAD_SHA" ]]; then
|
||||
echo "Error: tag '$TAG' already exists at $TAG_SHA, not at branch head ($HEAD_SHA)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag '$TAG' already exists at branch head. Continuing."
|
||||
else
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag "$TAG" "$HEAD_SHA"
|
||||
git push origin "$TAG_REF"
|
||||
echo "Created and pushed tag '$TAG' from $BRANCH head $HEAD_SHA."
|
||||
fi
|
||||
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install extension dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode install --include=optional
|
||||
|
||||
- name: Install webview-ui dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Get Version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Verify Tag Matches Package Version
|
||||
run: |
|
||||
TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
VERSION="v${{ steps.get_version.outputs.version }}"
|
||||
if [[ "$TAG" != "$VERSION" ]]; then
|
||||
echo "Error: tag '$TAG' does not match package version '$VERSION'"
|
||||
exit 1
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify Changelog Entry
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
|
||||
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
- name: Verify Marketplace Tokens
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
|
||||
exit 1
|
||||
fi
|
||||
echo "Marketplace publish tokens are configured."
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
RELEASE_TYPE: ${{ github.event.inputs.release-type }}
|
||||
run: |
|
||||
# Swap README.marketplace.md into README.md so both the GitHub
|
||||
# release artifact (vsce package below) and the marketplace
|
||||
# publish (npm run publish:marketplace below, which swaps
|
||||
# internally as an idempotent no-op) ship the same README.
|
||||
node scripts/marketplace-readme.mjs swap-in
|
||||
trap 'node scripts/marketplace-readme.mjs restore' EXIT
|
||||
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
if [ "$RELEASE_TYPE" = "pre-release" ]; then
|
||||
npm run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
npm run publish:marketplace
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ steps.resolve_tag.outputs.tag }}
|
||||
files: "apps/vscode/*.vsix"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
**Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}
|
||||
prerelease: ${{ github.event.inputs.release-type == 'pre-release' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post release to Slack
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "*Cline ${{ steps.resolve_tag.outputs.tag }} (legacy)*"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "Full Changelog: https://github.com/${{ github.repository }}/compare/${{ steps.prev_tag.outputs.prev_tag }}...${{ steps.resolve_tag.outputs.tag }}"
|
||||
@@ -1,40 +1,14 @@
|
||||
name: ext-vscode-publish-nightly
|
||||
|
||||
# Publishes saoudrizwan.cline-nightly as the COMBINED A/B VSIX: the rollout
|
||||
# loader plus two complete extension bundles — `next/` from this ref's
|
||||
# apps/vscode (SDK-based) and `legacy/` from the legacy-extension branch.
|
||||
# Cohort selection happens at runtime via PostHog flags; see
|
||||
# apps/vscode-rollout/README.md for the design and rollout runbook.
|
||||
#
|
||||
# The stable-identity equivalent of this pipeline is ext-vscode-ab-package.yml
|
||||
# (manual dispatch, publishes claude-dev). Shared logic lives in
|
||||
# apps/vscode-rollout/scripts (nightlify/gen-manifest/stitch/smoke) so both
|
||||
# workflows stay thin. The single-bundle nightly path this replaced
|
||||
# (apps/vscode/scripts/publish-nightly.mjs) remains for manual feature-branch
|
||||
# pre-release publishes.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Every day at 4:00 AM PST (12:00 UTC)
|
||||
- cron: "0 12 * * *"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
legacy-ref:
|
||||
description: "Ref to build the legacy bundle from"
|
||||
required: false
|
||||
default: "legacy-extension"
|
||||
type: string
|
||||
dry-run:
|
||||
description: "Build and upload the .vsix artifact without publishing or tagging"
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
run-name: "Publish Combined Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
run-name: "Publish Nightly from ${{ github.ref_name }} @ ${{ github.sha }}"
|
||||
|
||||
# Prevent concurrent publish runs on the same branch: the version is generated
|
||||
# from a seconds-resolution timestamp, so parallel runs on the same ref can
|
||||
# collide on the same version and cause publish failures or inconsistent tagging.
|
||||
# Prevent concurrent publish runs on the same branch. The nightly publish script
|
||||
# generates the extension version from a seconds-resolution timestamp, so parallel
|
||||
# runs on the same ref can collide on the same version and cause publish failures
|
||||
# or inconsistent tagging. Runs on different branches proceed independently.
|
||||
concurrency:
|
||||
group: ext-vscode-publish-nightly-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
@@ -43,7 +17,7 @@ permissions: {}
|
||||
|
||||
jobs:
|
||||
test:
|
||||
if: github.repository == 'cline/cline'
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
@@ -53,106 +27,59 @@ jobs:
|
||||
needs: test
|
||||
permissions:
|
||||
contents: write
|
||||
name: Publish Cline (Nightly) Combined Extension
|
||||
# Defense in depth: only protected main may enter the publishing environment.
|
||||
# This `if` is advisory because a dispatched branch runs its own copy of this
|
||||
# file; the enforced gate is the PublishNightly environment's deployment-branch
|
||||
# policy, which must also allow only main.
|
||||
if: github.repository == 'cline/cline' && github.ref == 'refs/heads/main'
|
||||
name: Publish Cline (Nightly) Extension
|
||||
if: github.repository == 'cline/cline' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/dpc/sdk-migration-simpler-login')
|
||||
runs-on: ubuntu-latest
|
||||
environment: PublishNightly
|
||||
# The VS Code extension's package.json and lockfiles live under apps/vscode/
|
||||
# (the repo root has no package.json). Mirror ext-vscode-test.yml so install
|
||||
# and publish steps run in the correct workspace.
|
||||
defaults:
|
||||
run:
|
||||
working-directory: apps/vscode
|
||||
|
||||
steps:
|
||||
- name: Checkout next (SDK) source
|
||||
- name: Checkout selected branch
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
path: next-src
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout legacy source
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
# NOTE: inputs are empty strings on `schedule` events, so the ||
|
||||
# fallback (not the input's declared default) is what the cron uses.
|
||||
ref: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
path: legacy-src
|
||||
lfs: true
|
||||
persist-credentials: false
|
||||
|
||||
- name: Show build sources
|
||||
env:
|
||||
# Routed through env rather than interpolated into the script body so
|
||||
# a crafted dispatch input can't inject shell (hygiene: dispatchers
|
||||
# need write access anyway, but keep the pattern clean).
|
||||
LEGACY_REF: ${{ inputs.legacy-ref || 'legacy-extension' }}
|
||||
- name: Show build source
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
echo "next: $(git -C next-src rev-parse HEAD)"
|
||||
echo "legacy: $(git -C legacy-src rev-parse HEAD) ($LEGACY_REF)"
|
||||
echo "Building ref: $GITHUB_REF"
|
||||
echo "Building sha: $GITHUB_SHA"
|
||||
git --no-pager log -1 --oneline
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Node is required beyond install: the rollout scripts run under node and
|
||||
# publishing shells out to vsce/ovsx. Pinned to Node 22 because newer LTS
|
||||
# (Node 24 / npm 11) can make vsce's dependency detection fail.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
# Keep publish environment aligned with test workflow/tooling lockfile expectations.
|
||||
# Newer LTS (Node 24 / npm 11) can make `npm list` fail with ELSPROBLEMS during vsce packaging.
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
# ONE version for the next bundle, the legacy bundle, and the union
|
||||
# manifest: gen-manifest hard-fails if the bundle identities diverge.
|
||||
# Same scheme as the standalone nightly: <major>.<minor>.<unix-seconds>
|
||||
# from next's base version, so it keeps outranking earlier nightlies.
|
||||
- name: Compute nightly version
|
||||
id: version
|
||||
run: |
|
||||
BASE=$(node -p "require('./next-src/apps/vscode/package.json').version")
|
||||
VERSION="$(echo "$BASE" | cut -d. -f1,2).$(date +%s)"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "Combined nightly version: $VERSION (base $BASE)"
|
||||
- name: Install root dependencies
|
||||
run: npm ci --include=optional
|
||||
|
||||
- name: Install next workspace dependencies
|
||||
working-directory: next-src
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
|
||||
- name: Build SDK packages
|
||||
working-directory: next-src
|
||||
run: bun run build:sdk
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
working-directory: next-src/apps/vscode
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# Rewrite each bundle's package.json to the cline-nightly identity BEFORE
|
||||
# its build (runtime command/config IDs derive from the manifest) and
|
||||
# AFTER dependency install (workspace self-links key off the original
|
||||
# package name).
|
||||
- name: Nightlify next bundle manifest
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/next-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Build next bundle
|
||||
working-directory: next-src/apps/vscode
|
||||
- name: Publish Nightly Extension
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
# Inlined by esbuild: attributes every telemetry event with
|
||||
# extension_variant and unlocks the bundle's authoritative
|
||||
# extension.rollout.bundle_activated capture. Rollout builds only.
|
||||
CLINE_ROLLOUT_VARIANT: next
|
||||
CLINE_ENVIRONMENT: production
|
||||
# OpenTelemetry production defaults (can be overridden at runtime)
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
@@ -160,129 +87,10 @@ jobs:
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: bun run package
|
||||
|
||||
- name: Install legacy dependencies
|
||||
working-directory: legacy-src
|
||||
run: |
|
||||
npm --prefix apps/vscode install --include=optional
|
||||
npm --prefix apps/vscode/webview-ui install --include=optional
|
||||
|
||||
- name: Nightlify legacy bundle manifest
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/nightlify.mjs --dir "$GITHUB_WORKSPACE/legacy-src/apps/vscode" --version "${{ steps.version.outputs.version }}"
|
||||
|
||||
- name: Build legacy bundle
|
||||
working-directory: legacy-src/apps/vscode
|
||||
env:
|
||||
CLINE_ENVIRONMENT: production
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
ERROR_SERVICE_API_KEY: ${{ secrets.ERROR_SERVICE_API_KEY }}
|
||||
CLINE_ROLLOUT_VARIANT: legacy
|
||||
# Legacy's esbuild inlines these too (its own publish workflow passes
|
||||
# them) — omitting them here would ship the legacy bundle with the
|
||||
# OTel pipeline dead, unlike what legacy users get today.
|
||||
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
|
||||
OTEL_LOGS_EXPORTER: otlp
|
||||
OTEL_METRICS_EXPORTER: otlp
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL: ${{ secrets.OTEL_EXPORTER_OTLP_PROTOCOL }}
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: ${{ secrets.OTEL_EXPORTER_OTLP_ENDPOINT }}
|
||||
OTEL_EXPORTER_OTLP_HEADERS: ${{ secrets.OTEL_EXPORTER_OTLP_HEADERS }}
|
||||
run: npm run package
|
||||
|
||||
- name: Build loader and run rollout tests
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
env:
|
||||
TELEMETRY_SERVICE_API_KEY: ${{ secrets.TELEMETRY_SERVICE_API_KEY }}
|
||||
run: |
|
||||
bun run typecheck
|
||||
bun run test
|
||||
bun run build:production
|
||||
|
||||
- name: Stitch combined VSIX staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: |
|
||||
node scripts/stitch.mjs \
|
||||
--next "$GITHUB_WORKSPACE/next-src/apps/vscode" \
|
||||
--legacy "$GITHUB_WORKSPACE/legacy-src/apps/vscode" \
|
||||
--loader dist/extension.js \
|
||||
--version "${{ steps.version.outputs.version }}" \
|
||||
--out "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
- name: Smoke-test loader against staging
|
||||
working-directory: next-src/apps/vscode-rollout
|
||||
run: node scripts/smoke-loader.mjs "$GITHUB_WORKSPACE/staging"
|
||||
|
||||
# The nightly identity must have fully propagated (nightlify -> both
|
||||
# bundle manifests -> union manifest) or we'd publish over the stable
|
||||
# extension ID. The bundle sub-manifest checks guard the version
|
||||
# stamping: the About tab and telemetry extension_version read those.
|
||||
- name: Assert nightly manifest identity
|
||||
working-directory: staging
|
||||
env:
|
||||
EXPECTED_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
node -e '
|
||||
const assert = require("node:assert");
|
||||
const expected = process.env.EXPECTED_VERSION;
|
||||
const pkg = require("./package.json");
|
||||
assert.equal(pkg.name, "cline-nightly", `unexpected name ${pkg.name}`);
|
||||
assert.equal(pkg.publisher, "saoudrizwan", `unexpected publisher ${pkg.publisher}`);
|
||||
assert.equal(pkg.version, expected, `unexpected union version ${pkg.version}`);
|
||||
for (const bundle of ["next", "legacy"]) {
|
||||
const sub = require(`./${bundle}/package.json`);
|
||||
assert.equal(sub.name, "cline-nightly", `unexpected ${bundle} bundle name ${sub.name}`);
|
||||
assert.equal(sub.version, expected, `unexpected ${bundle} bundle version ${sub.version}`);
|
||||
}
|
||||
console.log(`nightly identity ok: ${pkg.publisher}.${pkg.name}@${pkg.version} (bundle identities aligned)`);
|
||||
'
|
||||
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
- name: Package VSIX
|
||||
working-directory: staging
|
||||
# Preserve the narrowly scoped VSCE `sendgrid` scanner exemption used by
|
||||
# both standalone bundle workflows. No SendGrid credential is intentionally
|
||||
# supplied here; inspect the reported artifact before widening the exemption.
|
||||
run: vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-nightly-${{ steps.version.outputs.version }}.vsix"
|
||||
|
||||
- name: Upload VSIX artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: cline-nightly-${{ steps.version.outputs.version }}
|
||||
path: staging/cline-nightly-${{ steps.version.outputs.version }}.vsix
|
||||
if-no-files-found: error
|
||||
|
||||
# The job is main-only; step-level dry-run gating still permits a build-only
|
||||
# rehearsal without publishing or tagging.
|
||||
- name: Publish to VS Code Marketplace and Open VSX
|
||||
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
|
||||
working-directory: staging
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish."
|
||||
exit 1
|
||||
fi
|
||||
vsce publish --no-dependencies --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix"
|
||||
if [[ -n "$OVSX_PAT" ]]; then
|
||||
npx ovsx publish --packagePath "cline-nightly-${{ steps.version.outputs.version }}.vsix" --pat "$OVSX_PAT"
|
||||
else
|
||||
echo "WARNING: OVSX_PAT not set; skipping Open VSX publish."
|
||||
fi
|
||||
run: npm run publish:marketplace:nightly
|
||||
|
||||
- name: Tag published commit
|
||||
if: github.ref == 'refs/heads/main' && inputs.dry-run != true
|
||||
# Best-effort bookkeeping: the default GITHUB_TOKEN cannot create a ref
|
||||
# whose commit modifies workflow files (no workflows permission exists
|
||||
# for it), so this step fails whenever HEAD touched .github/workflows.
|
||||
# The publish already succeeded by this point — don't mark the run red;
|
||||
# push the tag manually with user credentials when it matters.
|
||||
continue-on-error: true
|
||||
working-directory: next-src
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -290,11 +98,10 @@ jobs:
|
||||
SHORT_SHA=$(git rev-parse --short=12 HEAD)
|
||||
TIMESTAMP=$(date -u +"%Y%m%d%H%M%S")
|
||||
TAG="nightly-${SAFE_REF}-${TIMESTAMP}-${SHORT_SHA}"
|
||||
LEGACY_SHA=$(git -C "$GITHUB_WORKSPACE/legacy-src" rev-parse HEAD)
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git tag -a "$TAG" -m "Cline Nightly (combined A/B) published from ${GITHUB_REF_NAME} at ${GITHUB_SHA} (legacy bundle: ${LEGACY_SHA})"
|
||||
git tag -a "$TAG" -m "Cline Nightly published from ${GITHUB_REF_NAME} at ${GITHUB_SHA}"
|
||||
# Use an explicit HTTPS remote with GH_TOKEN because checkout was run with
|
||||
# persist-credentials: false, so actions/checkout did not persist a git credential helper.
|
||||
git push "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "refs/tags/${TAG}"
|
||||
|
||||
@@ -27,10 +27,6 @@ permissions:
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: ext-vscode-publish-stable-${{ github.event.inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
test:
|
||||
uses: ./.github/workflows/ext-vscode-test.yml
|
||||
@@ -106,61 +102,24 @@ jobs:
|
||||
echo "Error: tag '$TAG' does not exist in the repository"
|
||||
exit 1
|
||||
fi
|
||||
TAG_SHA=$(git rev-list -n 1 "$TAG_REF^{commit}")
|
||||
if [[ "$TAG_SHA" != "$TESTED_SHA" ]]; then
|
||||
echo "Error: existing tag '$TAG' points to $TAG_SHA, but this workflow tested $TESTED_SHA"
|
||||
echo "Dispatch from the tag ref, or from the exact main commit the tag points to."
|
||||
exit 1
|
||||
fi
|
||||
echo "Using existing tag '$TAG' at tested SHA $TESTED_SHA."
|
||||
echo "Using existing tag '$TAG'."
|
||||
fi
|
||||
|
||||
git checkout --detach "$TAG_REF^{commit}"
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "resolved_sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
|
||||
# Node is still REQUIRED in the publish job (not just for install): the
|
||||
# publish scripts run as `node scripts/publish-*.mjs`, the version step uses
|
||||
# `node -p`, and `npx ovsx` needs npm. setup-bun does not provide a Node
|
||||
# runtime, so keep setup-node. Pinned to Node 22 because newer LTS
|
||||
# (Node 24 / npm 11) can make vsce's `npm list` detection fail with
|
||||
# ELSPROBLEMS during packaging.
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
# Single root install resolves the whole bun workspace at once (replaces the
|
||||
# per-package `npm install` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install root dependencies
|
||||
run: npm install --include=optional
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/ before
|
||||
# packaging/publishing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun run build:sdk
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm install --include=optional
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# vsce is a workspace devDependency (on node_modules/.bin), but ovsx is not
|
||||
# vendored and the publish script invokes it via `npx ovsx`, so install ovsx
|
||||
# globally (npm is available via setup-node). vsce is installed globally too
|
||||
# to preserve the script's existing PATH expectations.
|
||||
- name: Install Publishing Tools
|
||||
run: npm install -g @vscode/vsce ovsx
|
||||
|
||||
@@ -180,60 +139,6 @@ jobs:
|
||||
fi
|
||||
echo "Tag and package version match: $TAG"
|
||||
|
||||
- name: Verify Changelog Entry
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
EXPECTED_HEADING="## [${{ steps.get_version.outputs.version }}]"
|
||||
FIRST_HEADING=$(grep -m 1 '^## \[' CHANGELOG.md || true)
|
||||
if [[ "$FIRST_HEADING" != "$EXPECTED_HEADING" ]]; then
|
||||
echo "Error: CHANGELOG.md must start with '$EXPECTED_HEADING' before publishing."
|
||||
echo "Current first release heading: ${FIRST_HEADING:-<none>}"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found changelog entry for ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
- name: Verify Marketplace Tokens
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
OVSX_PAT: ${{ secrets.OVSX_PAT }}
|
||||
run: |
|
||||
if [[ -z "$VSCE_PAT" ]]; then
|
||||
echo "Error: VSCE_PAT is required to publish the stable VS Code extension."
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "$OVSX_PAT" ]]; then
|
||||
echo "Error: OVSX_PAT is required to publish the stable Open VSX extension."
|
||||
exit 1
|
||||
fi
|
||||
echo "Marketplace publish tokens are configured."
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(
|
||||
git tag --merged "$CURRENT_TAG^" --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+([-.][0-9A-Za-z.]+)?$' \
|
||||
| head -n 1 || true
|
||||
)
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between the matching version heading and the next release heading.
|
||||
CONTENT=$(awk -v version="${{ steps.get_version.outputs.version }}" '
|
||||
$0 == "## [" version "]" { found=1; next }
|
||||
found && /^## \[/ { exit }
|
||||
found { print }
|
||||
END { if (!found) exit 1 }
|
||||
' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Package and Publish Extension
|
||||
env:
|
||||
VSCE_PAT: ${{ secrets.VSCE_PAT }}
|
||||
@@ -257,23 +162,35 @@ jobs:
|
||||
node scripts/marketplace-readme.mjs swap-in
|
||||
trap 'node scripts/marketplace-readme.mjs restore' EXIT
|
||||
|
||||
# Required to generate the .vsix. --no-dependencies: the extension
|
||||
# is fully esbuild-bundled, and under the bun workspace the @cline/*
|
||||
# deps are symlinks pointing outside the package, so without this vsce
|
||||
# would walk them and pull the whole monorepo into the .vsix.
|
||||
vsce package --no-dependencies --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
# Required to generate the .vsix
|
||||
vsce package --allow-package-secrets sendgrid --out "cline-${{ steps.get_version.outputs.version }}.vsix"
|
||||
|
||||
# These scripts run under `node scripts/publish-marketplace.mjs`;
|
||||
# bun run just launches them. Node + npm (for `npx ovsx`) come from
|
||||
# setup-node above.
|
||||
if [ "$RELEASE_TYPE" = "pre-release" ]; then
|
||||
bun run publish:marketplace:prerelease
|
||||
npm run publish:marketplace:prerelease
|
||||
echo "Successfully published pre-release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
else
|
||||
bun run publish:marketplace
|
||||
npm run publish:marketplace
|
||||
echo "Successfully published release version ${{ steps.get_version.outputs.version }} to VS Code Marketplace and Open VSX Registry"
|
||||
fi
|
||||
|
||||
- name: Get Previous Tag
|
||||
id: prev_tag
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
CURRENT_TAG="${{ steps.resolve_tag.outputs.tag }}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 "$CURRENT_TAG^" 2>/dev/null || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
id: changelog
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: |
|
||||
# Get content between first ## [ and second ## [
|
||||
CONTENT=$(awk '/^## \[/{if(found) exit; found=1; next} found{print}' CHANGELOG.md)
|
||||
echo "content<<EOF" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "EOF" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
|
||||
@@ -45,16 +45,12 @@ jobs:
|
||||
- 'apps/vscode/assets/**'
|
||||
- 'apps/vscode/walkthrough/**'
|
||||
- 'apps/vscode/package.json'
|
||||
- 'apps/vscode/webview-ui/package.json'
|
||||
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
|
||||
- 'bun.lock'
|
||||
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
|
||||
- 'sdk/packages/**'
|
||||
- 'apps/vscode/package-lock.json'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/biome.jsonc'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
- 'apps/vscode/bunfig.toml'
|
||||
- 'apps/vscode/.mocharc.json'
|
||||
- 'apps/vscode/.vscode-test.mjs'
|
||||
- 'apps/vscode/.vscodeignore'
|
||||
- 'apps/vscode/playwright*.ts'
|
||||
@@ -88,20 +84,26 @@ jobs:
|
||||
working-directory: apps/vscode
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
node-version: 22
|
||||
|
||||
# Cache Bun's global install cache - keyed on the authoritative root bun.lock.
|
||||
- name: Cache Bun install cache
|
||||
# Cache root dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache root dependencies
|
||||
uses: actions/cache@v4
|
||||
id: bun-cache
|
||||
id: root-cache
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-
|
||||
path: apps/vscode/node_modules
|
||||
key: ${{ runner.os }}-npm-${{ hashFiles('apps/vscode/package-lock.json') }}
|
||||
|
||||
# Cache webview-ui dependencies - only reuse if package-lock.json exactly matches
|
||||
- name: Cache webview-ui dependencies
|
||||
uses: actions/cache@v4
|
||||
id: webview-cache
|
||||
with:
|
||||
path: apps/vscode/webview-ui/node_modules
|
||||
key: ${{ runner.os }}-npm-webview-${{ hashFiles('apps/vscode/webview-ui/package-lock.json') }}
|
||||
|
||||
# Cache VS Code installation
|
||||
- name: Cache VS Code
|
||||
@@ -122,41 +124,20 @@ jobs:
|
||||
~/.cache/ms-playwright
|
||||
~/Library/Caches/ms-playwright
|
||||
~/AppData/Local/ms-playwright
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('bun.lock') }}
|
||||
key: playwright-browsers-${{ runner.os }}-${{ hashFiles('apps/vscode/package-lock.json') }}
|
||||
restore-keys: |
|
||||
playwright-browsers-${{ runner.os }}-
|
||||
|
||||
# Single root install resolves the entire bun workspace at once (replaces
|
||||
# the per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install root dependencies
|
||||
run: npm ci
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/
|
||||
# before building/packaging the extension for E2E.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun run build:sdk
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
# Force bash: the Windows runner defaults to pwsh, which can't parse this
|
||||
# POSIX test. Git Bash ships on GitHub's windows-latest images.
|
||||
shell: bash
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# NOTE: vsce is no longer installed globally. @vscode/vsce is a workspace
|
||||
# devDependency of apps/vscode (resolved into node_modules/.bin), and the
|
||||
# `test:e2e:build` script invokes `vsce` via `bun run`, which puts the local
|
||||
# .bin on PATH. No global install needed.
|
||||
- name: Install vsce
|
||||
run: npm install -g @vscode/vsce
|
||||
|
||||
- name: Install xvfb on Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
@@ -165,11 +146,11 @@ jobs:
|
||||
# Run optimized E2E tests (eliminates redundant builds)
|
||||
- name: Run E2E tests - Linux
|
||||
if: matrix.runner == 'ubuntu'
|
||||
run: xvfb-run -a bun run test:e2e:optimal
|
||||
run: xvfb-run -a npm run test:e2e:optimal
|
||||
|
||||
- name: Run E2E tests - Non-Linux
|
||||
if: matrix.runner != 'ubuntu'
|
||||
run: bun run test:e2e:optimal
|
||||
run: npm run test:e2e:optimal
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: ${{ failure() }}
|
||||
|
||||
@@ -45,16 +45,13 @@ jobs:
|
||||
- 'apps/vscode/assets/**'
|
||||
- 'apps/vscode/walkthrough/**'
|
||||
- 'apps/vscode/package.json'
|
||||
- 'apps/vscode/webview-ui/package.json'
|
||||
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
|
||||
- 'bun.lock'
|
||||
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
|
||||
- 'sdk/packages/**'
|
||||
- 'apps/vscode/package-lock.json'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/biome.jsonc'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
- 'apps/vscode/bunfig.toml'
|
||||
- 'apps/vscode/.mocharc.json'
|
||||
- 'apps/vscode/.nycrc*.json'
|
||||
- 'apps/vscode/.vscode-test.mjs'
|
||||
- 'apps/vscode/test-setup.js'
|
||||
- '.github/workflows/ext-vscode-test.yml'
|
||||
@@ -63,13 +60,9 @@ jobs:
|
||||
- 'apps/vscode/proto/**'
|
||||
- 'apps/vscode/standalone/**'
|
||||
- 'apps/vscode/testing-platform/**'
|
||||
- 'apps/vscode/testing-platform/package.json'
|
||||
- 'apps/vscode/tests/specs/**'
|
||||
- 'apps/vscode/package.json'
|
||||
# Root bun lockfile is authoritative for the whole workspace (incl. apps/vscode).
|
||||
- 'bun.lock'
|
||||
# SDK source packages are local workspace symlinks (@cline/*), so SDK changes affect the build.
|
||||
- 'sdk/packages/**'
|
||||
- 'apps/vscode/package-lock.json'
|
||||
- 'apps/vscode/buf.yaml'
|
||||
- 'apps/vscode/tsconfig*.json'
|
||||
- 'apps/vscode/esbuild.mjs'
|
||||
@@ -89,44 +82,31 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
# Single root install resolves the entire bun workspace (apps/vscode,
|
||||
# webview-ui, testing-platform and the @cline/* SDK symlinks) at once,
|
||||
# so the previous per-package `npm ci` steps collapse into one.
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install root dependencies
|
||||
run: npm ci --include=optional
|
||||
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]
|
||||
if: needs.detect-changes.outputs.vscode == 'true'
|
||||
env:
|
||||
VSCODE_TEST_VERSION: 1.101.0
|
||||
VSCODE_TEST_VERSION: 1.103.0
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -141,43 +121,27 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
|
||||
# Single root install resolves the entire bun workspace at once (replaces
|
||||
# the per-package `npm ci` steps for apps/vscode + webview-ui).
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install root dependencies
|
||||
run: npm ci --include=optional
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# @cline/* are local workspace symlinks to source packages; build dist/
|
||||
# before compiling/testing the extension.
|
||||
- name: Build SDK packages
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun run build:sdk
|
||||
- name: Install webview-ui dependencies
|
||||
run: cd webview-ui && npm ci --include=optional
|
||||
|
||||
- name: Assert better-sqlite3 native binary present
|
||||
- name: Set up NPM on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: |
|
||||
NODE_FILE="node_modules/better-sqlite3/build/Release/better_sqlite3.node"
|
||||
if [ ! -f "$NODE_FILE" ]; then
|
||||
echo "ERROR: better-sqlite3 native binary missing at apps/vscode/$NODE_FILE"
|
||||
echo "(bun trustedDependencies postinstall likely did not run)"
|
||||
exit 1
|
||||
fi
|
||||
echo "Found better-sqlite3 native binary: $NODE_FILE"
|
||||
|
||||
# NOTE: The old `npm config set script-shell bash` step is intentionally
|
||||
# removed. Scripts are now launched with `bun run`, which uses Bun's own
|
||||
# built-in cross-platform shell rather than npm's configured script-shell,
|
||||
# so that npm-specific Windows workaround no longer applies. Bash-dependent
|
||||
# scripts (e.g. scripts/proto-lint.sh, standalone/runclinecore.sh) are
|
||||
# invoked explicitly via `bash ...` from within the package scripts, and
|
||||
# this job's `defaults.run.shell: bash` (Git Bash on Windows) still covers
|
||||
# the workflow `run:` blocks below.
|
||||
npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe"
|
||||
|
||||
- name: Cache VS Code test runtime
|
||||
if: runner.os == 'Windows'
|
||||
@@ -189,51 +153,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 +183,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 +201,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 +210,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 +224,36 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
bun-version: 1.3.14
|
||||
node-version: 22
|
||||
cache: 'npm'
|
||||
cache-dependency-path: |
|
||||
apps/vscode/package-lock.json
|
||||
apps/vscode/webview-ui/package-lock.json
|
||||
apps/vscode/testing-platform/package-lock.json
|
||||
|
||||
# Single root install resolves the whole bun workspace, including the
|
||||
# testing-platform package, so the separate per-package `npm ci` steps
|
||||
# (extension + webview-ui + testing-platform) collapse into one.
|
||||
- name: Install workspace dependencies
|
||||
working-directory: ${{ github.workspace }}
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Install root dependencies
|
||||
run: npm ci --include=optional
|
||||
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,
|
||||
|
||||
@@ -26,7 +26,7 @@ on:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
working-directory: sdk
|
||||
|
||||
jobs:
|
||||
test:
|
||||
@@ -148,7 +148,7 @@ jobs:
|
||||
env:
|
||||
CHANNEL: ${{ steps.channel.outputs.channel }}
|
||||
run: |
|
||||
BASE_VERSION=$(node -p "require('./sdk/packages/llms/package.json').version")
|
||||
BASE_VERSION=$(node -p "require('./packages/llms/package.json').version")
|
||||
|
||||
if [ "$CHANNEL" = "nightly" ]; then
|
||||
TIMESTAMP=$(date +%s)
|
||||
@@ -166,11 +166,11 @@ jobs:
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: bun sdk/scripts/version.ts "$VERSION"
|
||||
run: bun scripts/version.ts "$VERSION"
|
||||
|
||||
- name: Verify publishability
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
run: bun sdk/scripts/check-publish.ts
|
||||
run: bun scripts/check-publish.ts
|
||||
|
||||
- name: Prepare package tarball directory
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
@@ -187,7 +187,7 @@ jobs:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Publishing @cline/shared@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd sdk/packages/shared
|
||||
cd packages/shared
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Publishing @cline/llms@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd sdk/packages/llms
|
||||
cd packages/llms
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
@@ -211,7 +211,7 @@ jobs:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Publishing @cline/agents@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd sdk/packages/agents
|
||||
cd packages/agents
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
@@ -223,7 +223,7 @@ jobs:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Publishing @cline/core@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd sdk/packages/core
|
||||
cd packages/core
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
@@ -235,7 +235,7 @@ jobs:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
echo "Publishing @cline/sdk@${VERSION} with tag '${CHANNEL}'..."
|
||||
cd sdk/packages/sdk
|
||||
cd packages/sdk
|
||||
TARBALL=$(bun pm pack --destination "$RUNNER_TEMP/sdk-npm-packs" --quiet)
|
||||
npm publish "$RUNNER_TEMP/sdk-npm-packs/$(basename "$TARBALL")" --tag "$CHANNEL" --access public
|
||||
|
||||
@@ -260,41 +260,6 @@ jobs:
|
||||
git push origin "refs/tags/${TAG}"
|
||||
done
|
||||
|
||||
- name: Get Previous SDK Tag
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
id: prev_tag
|
||||
env:
|
||||
VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
# The checkout is shallow and tagless, so fetch the release tags explicitly.
|
||||
git fetch origin "+refs/tags/sdk/sdk/v*:refs/tags/sdk/sdk/v*"
|
||||
PREV_TAG=$(git tag -l 'sdk/sdk/v*' | grep -vx "sdk/sdk/v${VERSION}" | sort -V | tail -1 || echo "")
|
||||
echo "prev_tag=$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Get Changelog Entry
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
id: changelog
|
||||
run: |
|
||||
# Grab content between the first "## " header and the next one in sdk/CHANGELOG.md
|
||||
CONTENT=$(awk '/^## [0-9]/{if(found) exit; found=1; next} found{print}' sdk/CHANGELOG.md)
|
||||
DELIMITER=$(openssl rand -hex 8)
|
||||
echo "content<<${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
echo "$CONTENT" >> $GITHUB_OUTPUT
|
||||
echo "${DELIMITER}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create GitHub Release
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: sdk/sdk/v${{ steps.version.outputs.version }}
|
||||
name: "SDK v${{ steps.version.outputs.version }}"
|
||||
body: |
|
||||
${{ steps.changelog.outputs.content }}
|
||||
|
||||
${{ steps.prev_tag.outputs.prev_tag != '' && format('**Full Changelog**: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Summary
|
||||
if: steps.check_commits.outputs.skip != 'true'
|
||||
env:
|
||||
@@ -315,26 +280,3 @@ jobs:
|
||||
echo " - sdk/core/v${VERSION}"
|
||||
echo " - sdk/sdk/v${VERSION}"
|
||||
fi
|
||||
|
||||
- name: Post release to Slack
|
||||
if: steps.check_commits.outputs.skip != 'true' && steps.channel.outputs.channel == 'latest'
|
||||
uses: slackapi/slack-github-action@v3.0.1
|
||||
with:
|
||||
method: chat.postMessage
|
||||
token: ${{ secrets.SLACK_RELEASE_BOT_TOKEN }}
|
||||
payload: |
|
||||
channel: "C0APVKGGZFC"
|
||||
text: "Cline SDK v${{ steps.version.outputs.version }}"
|
||||
blocks:
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: "Cline SDK v${{ steps.version.outputs.version }}"
|
||||
- type: "section"
|
||||
text:
|
||||
type: "mrkdwn"
|
||||
text: ${{ toJSON(steps.changelog.outputs.content) }}
|
||||
- type: "context"
|
||||
elements:
|
||||
- type: "mrkdwn"
|
||||
text: "<https://www.npmjs.com/package/@cline/sdk/v/${{ steps.version.outputs.version }}|View on npm>${{ steps.prev_tag.outputs.prev_tag != '' && format(' | Full Changelog: https://github.com/{0}/compare/{1}...sdk/sdk/v{2}', github.repository, steps.prev_tag.outputs.prev_tag, steps.version.outputs.version) || '' }}"
|
||||
|
||||
@@ -21,7 +21,7 @@ permissions:
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: .
|
||||
working-directory: sdk
|
||||
|
||||
jobs:
|
||||
quality-checks:
|
||||
@@ -96,12 +96,12 @@ jobs:
|
||||
|
||||
- name: Run SDK Tests (Windows)
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'windows-latest' }}
|
||||
run: bun -F './sdk/packages/**' test
|
||||
run: bun -F './packages/**' test
|
||||
|
||||
- name: Smoke test SQLite under Node
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os != 'windows-latest' }}
|
||||
timeout-minutes: 10
|
||||
run: bun sdk/scripts/ci-node-smoke.ts
|
||||
run: bun scripts/ci-node-smoke.ts
|
||||
|
||||
- name: Run TUI e2e tests
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'ubuntu-latest' && matrix.node-version == '24.x' }}
|
||||
@@ -109,4 +109,4 @@ jobs:
|
||||
|
||||
- name: Verify packages are publishable
|
||||
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && steps.build_cli_step.outcome == 'success' && matrix.os == 'ubuntu-latest' && matrix.node-version == '24.x' }}
|
||||
run: bun sdk/scripts/check-publish.ts
|
||||
run: bun scripts/check-publish.ts
|
||||
|
||||
-19
@@ -64,17 +64,6 @@ tests/**/cache
|
||||
# Should never be committed: only exists if a publish aborts mid-swap.
|
||||
.README.github.bak
|
||||
|
||||
# Tauri generated code
|
||||
apps/*/src-tauri/gen
|
||||
apps/*/src-tauri/bin
|
||||
apps/examples/*/src-tauri/gen
|
||||
apps/examples/*/src-tauri/bin
|
||||
# Tauri UI test snapshots
|
||||
apps/*/src/tests/.tui-test
|
||||
apps/*/src/tests/tui-traces
|
||||
apps/vscode/webview-ui/src/**/*.js
|
||||
apps/vscode/webview-ui/src/**/*.js.map
|
||||
|
||||
|
||||
# SDK Session files / User data
|
||||
.cline/data
|
||||
@@ -84,11 +73,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
|
||||
|
||||
+1
-11
@@ -1,11 +1 @@
|
||||
if ! command -v gitleaks >/dev/null 2>&1; then
|
||||
echo "gitleaks is required for the pre-commit secret scan."
|
||||
echo "Install it with: brew install gitleaks"
|
||||
echo "Other install options: https://github.com/gitleaks/gitleaks#installing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
gitleaks git --pre-commit --redact --staged --verbose || exit 1
|
||||
|
||||
cd apps/vscode && bunx lint-staged
|
||||
|
||||
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
+30
-51
@@ -5,8 +5,8 @@
|
||||
"tasks": [
|
||||
{
|
||||
"label": "compile-standalone",
|
||||
"type": "shell",
|
||||
"command": "bun run compile-standalone",
|
||||
"type": "npm",
|
||||
"script": "compile-standalone",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"presentation": {
|
||||
@@ -18,8 +18,8 @@
|
||||
},
|
||||
{
|
||||
"label": "npm: protos",
|
||||
"type": "shell",
|
||||
"command": "bun run protos",
|
||||
"type": "npm",
|
||||
"script": "protos",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
"presentation": {
|
||||
@@ -64,11 +64,11 @@
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run build:webview",
|
||||
"type": "npm",
|
||||
"script": "build:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
"isBackground": true,
|
||||
"label": "npm: build:webview",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
@@ -85,11 +85,11 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run build:webview:test",
|
||||
"type": "npm",
|
||||
"script": "build:webview:test",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
"isBackground": true,
|
||||
"label": "npm: build:webview:test",
|
||||
"dependsOn": [
|
||||
"npm: protos"
|
||||
@@ -107,23 +107,23 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run dev:webview",
|
||||
"type": "npm",
|
||||
"script": "dev:webview",
|
||||
"group": "build",
|
||||
"problemMatcher": [
|
||||
{
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^(?!)((?:.*))$",
|
||||
"kind": "file",
|
||||
"regexp": ".",
|
||||
"file": 1,
|
||||
"message": 1
|
||||
"location": 2,
|
||||
"message": 3
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": "^Building webview for|^\\s*VITE",
|
||||
"endsPattern": "^.*Local:\\s+http://127\\.0\\.0\\.1:[0-9]+/"
|
||||
"beginsPattern": ".",
|
||||
"endsPattern": "."
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -144,8 +144,8 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run watch:esbuild",
|
||||
"type": "npm",
|
||||
"script": "watch:esbuild",
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
@@ -169,8 +169,7 @@
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"build-sdk:debug"
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -184,8 +183,8 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run watch:esbuild:test",
|
||||
"type": "npm",
|
||||
"script": "watch:esbuild:test",
|
||||
"group": "build",
|
||||
"problemMatcher": {
|
||||
"pattern": [
|
||||
@@ -209,8 +208,7 @@
|
||||
"isBackground": true,
|
||||
"label": "npm: watch:esbuild:test",
|
||||
"dependsOn": [
|
||||
"npm: protos",
|
||||
"build-sdk:debug"
|
||||
"npm: protos"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
@@ -225,8 +223,8 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run watch:tsc",
|
||||
"type": "npm",
|
||||
"script": "watch:tsc",
|
||||
"group": "build",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
@@ -243,9 +241,8 @@
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run watch-tests",
|
||||
"label": "npm: watch-tests",
|
||||
"type": "npm",
|
||||
"script": "watch-tests",
|
||||
"problemMatcher": "$tsc-watch",
|
||||
"isBackground": true,
|
||||
"dependsOn": [
|
||||
@@ -283,8 +280,8 @@
|
||||
"command": "rm -rf ${workspaceFolder}/apps/vscode/dist/tmp/user && mkdir -p ${workspaceFolder}/apps/vscode/dist/tmp/user"
|
||||
},
|
||||
{
|
||||
"type": "shell",
|
||||
"command": "bun run storybook",
|
||||
"type": "npm",
|
||||
"script": "storybook",
|
||||
"group": "build",
|
||||
"problemMatcher": [],
|
||||
"isBackground": false,
|
||||
@@ -311,25 +308,7 @@
|
||||
"$tsc"
|
||||
],
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "build-sdk:debug",
|
||||
"type": "shell",
|
||||
"command": "bun run build:sdk",
|
||||
"problemMatcher": [
|
||||
"$tsc"
|
||||
],
|
||||
"presentation": {
|
||||
"group": "watch",
|
||||
"reveal": "always"
|
||||
},
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"CLINE_SOURCEMAPS": "1"
|
||||
}
|
||||
"cwd": "${workspaceFolder}/sdk"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
-102
@@ -1,107 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## [4.0.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add the SDK-backed VS Code extension runtime. Cline now runs tasks through the shared Cline SDK session layer for agent turns, tools, Plan/Act mode coordination, MCP, checkpoints, telemetry, provider changes, compaction, mistake limits, and task history.
|
||||
- Add ClinePass to the VS Code extension, including onboarding, provider selection, signup and subscription handoff, live model lists, entitlement and organization error states, out-of-credit prompts, and clearer ClinePass auth/error handling.
|
||||
- Add the Customize marketplace for discovering and managing Skills, MCP servers, and Plugins from the extension, including installed/marketplace tabs, search and filtering, install/uninstall flows, enable/disable controls, and support for plugin-bundled skills.
|
||||
- Cline Plugins: Plugins let you extend Cline with custom tools, workflows, skills, and MCP-powered capabilities tailored to your team or project. Install them from the new Customize marketplace to add specialized behavior, connect external services, and package reusable automations—so Cline can do more than code: it can adapt to the way you work.
|
||||
- Add queued prompts in chat. Messages submitted while Cline is already working are now queued, shown while the current turn streams, and can be cancelled before they run.
|
||||
- Add edit-and-regenerate support for previous user messages, with clearer Reset Chat and Reset Code actions.
|
||||
- Add generic SDK provider settings and model-catalog support so more providers can share the same model picker, reasoning controls, dynamic model IDs, provider config persistence, and custom model handling.
|
||||
- Add additional SDK-backed provider exposure and model/provider updates, including ClinePass models, refreshed Cline catalog data, Fireworks GLM 5.2, Kimi K2.6 Fast, Kimi K2.7 Code, Qwen 3.7 Plus, MiniMax M3 updates, SAP AI Core wiring, LiteLLM model fetching, Codex OAuth credentials, and OpenAI-compatible model settings.
|
||||
- Add MCP support for plugins and shared marketplace install/uninstall plumbing used by the VS Code extension.
|
||||
|
||||
### Changed
|
||||
|
||||
- Migrate the VS Code extension from the legacy task implementation to the shared Cline SDK and move the extension build/package workflow to Bun.
|
||||
- Rework Plan/Act mode handling through SDK coordinators, including closer CLI parity and automatic continuation when switching from Plan to Act.
|
||||
- Rework provider and model configuration around `providers.json`, the model catalog, and SDK session config so settings are preserved consistently across provider switches and active sessions can restart when the selected provider changes.
|
||||
- Simplify provider settings UI by replacing many provider-specific views with shared generic settings components and consistent reasoning selectors.
|
||||
- Simplify terminal execution through the SDK run-commands path, including clearer non-interactive command guidance and safer structured command formatting.
|
||||
- Migrate legacy MCP files and formats into the shared settings file and protect MCP settings writes with safer locking/atomic updates.
|
||||
- Refresh the MCP hub automatically after marketplace installs so newly installed servers are available without a manual restart.
|
||||
- Reorganize MCP/Skills/Plugins entry points under Customize, hide workflows from the Customize menu, wrap Customize tabs on narrow screens, and allow the MCP Marketplace tab to be disabled remotely while installed MCP servers remain accessible.
|
||||
- Simplify auto-approval settings. Command auto-approval is now disabled by default for safer new and reset configurations, and the auto-approval UI has been streamlined.
|
||||
- Update task history handling for the SDK migration, including legacy task history visibility, metadata preservation on resume, and corrected deletion behavior.
|
||||
- Route compacting and mistake-limit behavior through the SDK so the Compact button and mistake tracking affect the active SDK session.
|
||||
- Remove the legacy Explain Changes feature as part of the SDK migration cleanup.
|
||||
- Temporarily disable subagents in the VS Code extension while the SDK-backed experience is stabilized.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix marketplace edge cases, including refreshing MCP servers after marketplace installs, disabling the MCP Marketplace tab from remote config, hiding workflows from Customize, surfacing plugin-bundled skills, and uninstalling shared marketplace entries.
|
||||
- Fix chat submission during active turns by queuing user messages instead of dropping or racing them, showing pending/queued states promptly, rendering direct user messages immediately, and removing delayed send behavior.
|
||||
- Fix editing previous user messages so Escape cancels editing locally and reset action labels are clearer.
|
||||
- Fix terminal reliability, including standalone Windows output capture, hardened PowerShell command handling, running-state display for in-progress commands, raw structured command preservation, single-quote handling, cwd setup timeouts, failing-command stdout capture, heredoc coalescing, and removal of duplicated command echoes in tool results.
|
||||
- Fix SDK tool-result and provider-message budgeting by truncating large tool outputs by default, capping assistant text, limiting bash/file-read/search output ingestion, bounding media budgets, batching outdated-read rewrites to preserve provider prefix caches, and normalizing JSON-like tool inputs by schema.
|
||||
- Fix login and feature-flag resolution by using the correct user/account identity on startup and simplifying the login UX.
|
||||
|
||||
## [3.89.2]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Complete the fix for the Anthropic provider on VS Code 1.123 and later by upgrading the bundled Anthropic SDK to a release compatible with the Node 24 runtime.
|
||||
- Update the Vertex AI provider to a compatible Anthropic Vertex SDK release so it works with the upgraded Anthropic SDK.
|
||||
|
||||
## [3.89.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore the Anthropic provider on VS Code 1.123 and later, where the updated Node 24 runtime broke the bundled Anthropic SDK.
|
||||
- Handle the DeepSeek V4 reasoning format.
|
||||
|
||||
## [3.89.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add Claude Fable 5 model support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix MiniMax M3 thinking controls across gateways.
|
||||
|
||||
### Changed
|
||||
|
||||
- Clean up the Codex model list.
|
||||
|
||||
## [3.88.1]
|
||||
|
||||
### Added
|
||||
|
||||
- Add a debug section in settings for Cline testers.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Include the walkthrough markdown files in the VS Code extension package so the first-run walkthrough steps load correctly.
|
||||
|
||||
## [3.88.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add the latest Fireworks AI serverless models and update the default Fireworks model to Kimi K2.6.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fix MCP server delete/add flows so settings writes do not cause the MCP server list to be emptied by the file watcher.
|
||||
- Remove stale Fireworks AI models and correct Fireworks model metadata and cache pricing.
|
||||
|
||||
### Changed
|
||||
|
||||
- Always use the upstream Cline recommended models endpoint instead of gating it behind a feature flag.
|
||||
|
||||
## [3.87.0]
|
||||
|
||||
### Added
|
||||
|
||||
- Add MiniMax M3 model support.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Update VS Code extension dependencies to resolve security issues in `@xmldom/xmldom`, `basic-ftp`, `axios`, `undici`, and other direct/transitive packages.
|
||||
|
||||
## [3.86.2]
|
||||
|
||||
### Fixed
|
||||
|
||||
+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:**
|
||||
|
||||
@@ -51,7 +51,7 @@ for CI/CD and scripting.
|
||||
npm i -g cline
|
||||
```
|
||||
|
||||
<a href="./apps/cli/README.md">Learn more</a>
|
||||
<a href="./sdk/apps/cli/README.md">Learn more</a>
|
||||
<br><br>
|
||||
|
||||
</td>
|
||||
@@ -129,7 +129,7 @@ npm install @cline/sdk
|
||||
| Product | Description | Location | CHANGELOG |
|
||||
|---------|------------|--------------|--------------|
|
||||
| **SDK** | Node.js programmatic agent API and extension exports. | [`sdk/`](https://github.com/cline/cline/tree/main/sdk) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/CHANGELOG.md) |
|
||||
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`apps/cli/`](https://github.com/cline/cline/tree/main/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/cli/CHANGELOG.md) |
|
||||
| **CLI** | Terminal UI, headless mode, shell commands, and CLI-specific flows. | [`sdk/apps/cli/`](https://github.com/cline/cline/tree/main/sdk/apps/cli) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/sdk/apps/cli/CHANGELOG.md) |
|
||||
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`/`](https://github.com/cline/cline/tree/main) (WIP migrating) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) |
|
||||
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins | - |
|
||||
| **Kanban** | Web-based multi-agent task board. | [`cline/kanban`](https://github.com/cline/kanban) | [CHANGELOG.md](https://github.com/cline/kanban/blob/main/CHANGELOG.md) |
|
||||
@@ -149,7 +149,7 @@ Toggle between Plan mode and Act mode. In Plan mode, Cline explores your codebas
|
||||
|
||||
## Rules and Skills
|
||||
|
||||
Define project-specific rules in `.clinerules` files that guide how Cline works in your codebase: coding standards, architecture conventions, deployment procedures, testing requirements. Rules are picked up automatically by the CLI, VS Code extension, and JetBrains plugin. Use skills to let the model load specific rules when needed.
|
||||
Define project-specific rules in `.clinerules` files that guide how Cline works in your codebase: coding standards, architecture conventions, deployment procedures, testing requirements. Rules are picked up automatically by the CLI, VS Code extension, and JetBrains plugin. Use skills to let the model load specific rules when needed.
|
||||
|
||||
## Works With Every Model
|
||||
|
||||
@@ -158,10 +158,10 @@ Cline is not locked to a single AI provider. Use whichever model fits your workf
|
||||
| Provider | Models |
|
||||
|----------|--------|
|
||||
| Anthropic | Claude Opus, Sonnet, Haiku |
|
||||
| OpenAI | GPT series models |
|
||||
| Google | Gemini series models |
|
||||
| OpenAI | GPT series model |
|
||||
| Google | Gemini series model |
|
||||
| OpenRouter | 200+ models from any provider |
|
||||
| Vercel AI Gateway | Route to many providers through one gateway |
|
||||
| Vercel AI Gateway | Models through Vercel AI Gateway |
|
||||
| AWS Bedrock | Claude, Llama, and more |
|
||||
| Azure / GCP Vertex | All hosted models |
|
||||
| Cerebras / Groq | Fast inference models |
|
||||
@@ -212,12 +212,8 @@ cline schedule create "PR summary" \
|
||||
Chat with your agent from any messaging platform: Telegram, Slack, Discord, Google Chat, WhatsApp, and Linear. Each conversation thread maps to an agent session with full context. Set up access control to restrict who can interact with your agent.
|
||||
|
||||
```bash
|
||||
# Connect to Telegram
|
||||
cline connect telegram -k $BOT_TOKEN
|
||||
# Connect to Slack through webhook
|
||||
cline connect slack --bot-token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
# Connect to Slack using socket mode
|
||||
cline connect slack --bot-token $SLACK_TOKEN --app-token $SLACK_APP_TOKEN
|
||||
cline connect slack --token $SLACK_TOKEN --signing-secret $SECRET --base-url $URL
|
||||
```
|
||||
|
||||
## Headless CLI for CI/CD
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.5/schema.json",
|
||||
"root": false,
|
||||
"extends": [
|
||||
"../sdk/biome.json"
|
||||
],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"a11y": {
|
||||
"noStaticElementInteractions": "warn"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,281 +0,0 @@
|
||||
// Auto-discovery of OS trust anchors for the Cline CLI.
|
||||
//
|
||||
// Bun does not read the OS trust store, so the 3.x CLI cannot see corporate
|
||||
// MITM / self-signed CAs out of the box. This runs in the Node `bin/cline`
|
||||
// wrapper (not Bun), reads the full OS store via tls.getCACertificates("system")
|
||||
// (Node >= 22, no --use-system-ca flag), and hands the certs to the Bun child
|
||||
// via NODE_EXTRA_CA_CERTS, which both runtimes honor. Mirrors the JetBrains
|
||||
// plugin's configureCertificates(), sourcing from the OS instead of the IDE.
|
||||
//
|
||||
// Dependency-free CommonJS with injectable modules so it is unit-testable and
|
||||
// ships verbatim in the published wrapper package.
|
||||
|
||||
const PEM_MARKER = "-----BEGIN CERTIFICATE-----";
|
||||
const CERT_BLOCK =
|
||||
/-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g;
|
||||
|
||||
/**
|
||||
* Returns only the complete certificate blocks from PEM text, or null when
|
||||
* there are none. User files may also hold private keys (combined cert+key
|
||||
* PEMs) or other sections, which must never be copied into the managed
|
||||
* bundle. Files that contain nothing but certificates pass through verbatim
|
||||
* so unchanged bundles keep hash-skipping the rewrite.
|
||||
*/
|
||||
function sanitizePem(text) {
|
||||
const blocks = text.match(CERT_BLOCK) ?? [];
|
||||
if (blocks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const rest = text.replace(CERT_BLOCK, "");
|
||||
if (/^\s*$/.test(rest)) {
|
||||
return text;
|
||||
}
|
||||
return `${blocks.join("\n")}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns OS-trusted certificates as PEM strings, or [] when unavailable.
|
||||
* tls.getCACertificates("system") requires Node >= 22.
|
||||
*/
|
||||
function harvestSystemCerts(tlsModule) {
|
||||
try {
|
||||
const tls = tlsModule || require("node:tls");
|
||||
if (typeof tls.getCACertificates !== "function") {
|
||||
return [];
|
||||
}
|
||||
const certs = tls.getCACertificates("system");
|
||||
if (!Array.isArray(certs)) {
|
||||
return [];
|
||||
}
|
||||
return certs.filter(
|
||||
(cert) => typeof cert === "string" && cert.includes(PEM_MARKER),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file's certificate blocks as PEM text, or null when missing,
|
||||
* unreadable, or holding no complete certificate block.
|
||||
*/
|
||||
function readUserBundle(fsModule, userPath) {
|
||||
if (!userPath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const fs = fsModule || require("node:fs");
|
||||
const stat = fs.statSync(userPath, { throwIfNoEntry: false });
|
||||
if (!stat || !stat.isFile()) {
|
||||
return null;
|
||||
}
|
||||
// Binary DER would not have loaded in the runtime either; require PEM.
|
||||
return sanitizePem(fs.readFileSync(userPath, "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the user's NODE_EXTRA_CA_CERTS value into PEM strings. Node treats the
|
||||
* value as a single file, but some users set an OS-path-delimited list; the
|
||||
* whole value is tried as one file first, then split.
|
||||
* The managed bundle is excluded so reading it back never re-appends its certs.
|
||||
*/
|
||||
function readUserCerts(fsModule, pathModule, value, managedPath) {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
const fs = fsModule || require("node:fs");
|
||||
const path = pathModule || require("node:path");
|
||||
const candidates = [];
|
||||
const whole = readUserBundle(fs, value);
|
||||
if (whole) {
|
||||
candidates.push({ filePath: value, pem: whole });
|
||||
} else if (value.includes(path.delimiter)) {
|
||||
for (const segment of value.split(path.delimiter)) {
|
||||
const trimmed = segment.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
const pem = readUserBundle(fs, trimmed);
|
||||
if (pem) {
|
||||
candidates.push({ filePath: trimmed, pem });
|
||||
}
|
||||
}
|
||||
}
|
||||
const pems = [];
|
||||
for (const candidate of candidates) {
|
||||
const isManaged =
|
||||
managedPath &&
|
||||
path.resolve(candidate.filePath) === path.resolve(managedPath);
|
||||
if (!isManaged) {
|
||||
pems.push(candidate.pem);
|
||||
}
|
||||
}
|
||||
return pems;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concatenates the user PEMs (if any) and the system certificates into one
|
||||
* bundle. A separating newline is inserted between parts so adjacent END/BEGIN
|
||||
* markers cannot fuse into one invalid line.
|
||||
*/
|
||||
function buildBundle({ systemCerts, userPems }) {
|
||||
const parts = [...(userPems ?? []), ...systemCerts];
|
||||
return parts
|
||||
.map((part) => (part.endsWith("\n") ? part : `${part}\n`))
|
||||
.join("");
|
||||
}
|
||||
|
||||
/** Counts individual PEM certificates across the given bundle strings. */
|
||||
function countCerts(pems) {
|
||||
let count = 0;
|
||||
for (const pem of pems) {
|
||||
count += pem.split(PEM_MARKER).length - 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function readFileIfExists(fs, filePath) {
|
||||
try {
|
||||
return fs.readFileSync(filePath, "utf8");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveClineDir(env, os, path) {
|
||||
return env.CLINE_DIR?.trim() || path.join(os.homedir(), ".cline");
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the api-unavailable warning should print. Stamped per Node version
|
||||
* in the cline dir so the nudge shows once rather than on every command; a
|
||||
* version change (upgrade that still falls short, or downgrade) re-arms it.
|
||||
* When the stamp cannot be read or written, warn — bookkeeping failures must
|
||||
* never suppress a real diagnostic.
|
||||
*/
|
||||
function shouldWarnApiUnavailable(env, deps = {}) {
|
||||
const fs = deps.fs || require("node:fs");
|
||||
const os = deps.os || require("node:os");
|
||||
const path = deps.path || require("node:path");
|
||||
const version = deps.nodeVersion || process.versions.node;
|
||||
const dir = resolveClineDir(env, os, path);
|
||||
const stamp = path.join(dir, `.ca-api-warned-${version}`);
|
||||
try {
|
||||
if (fs.existsSync(stamp)) {
|
||||
return false;
|
||||
}
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(stamp, "", { mode: 0o600 });
|
||||
return true;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Atomically writes [content] to [target]; returns true on success. */
|
||||
function writeBundle(fs, dir, target, content) {
|
||||
const tmp = `${target}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
// Owner read/write: the bundle holds public CA material, not secrets,
|
||||
// but there is no reason to make it world-writable.
|
||||
fs.writeFileSync(tmp, content, { mode: 0o600 });
|
||||
try {
|
||||
fs.renameSync(tmp, target);
|
||||
} catch {
|
||||
// Windows can reject rename over a file a concurrent child holds open.
|
||||
fs.rmSync(target, { force: true });
|
||||
fs.renameSync(tmp, target);
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
// Never leave a partial temp file behind (e.g. ENOSPC mid-write).
|
||||
try {
|
||||
fs.rmSync(tmp, { force: true });
|
||||
} catch {
|
||||
// Ignore: best-effort cleanup.
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Harvests OS trust anchors, merges them with any user NODE_EXTRA_CA_CERTS, and
|
||||
* points env.NODE_EXTRA_CA_CERTS at a single managed PEM bundle. Mutates `env`
|
||||
* in place. Returns an outcome the caller can log; `action` is one of
|
||||
* "unchanged" | "written" | "write-failed-reused" | "write-failed" |
|
||||
* "no-system-certs" | "api-unavailable".
|
||||
*/
|
||||
function configureNodeExtraCaCerts(env, deps = {}) {
|
||||
const fs = deps.fs || require("node:fs");
|
||||
const os = deps.os || require("node:os");
|
||||
const path = deps.path || require("node:path");
|
||||
const tls = deps.tls || require("node:tls");
|
||||
|
||||
// tls.getCACertificates("system") needs Node >= 22.15; on older Nodes the
|
||||
// harvest cannot run at all, which the caller should surface to the user.
|
||||
if (typeof tls.getCACertificates !== "function") {
|
||||
return {
|
||||
action: "api-unavailable",
|
||||
path: null,
|
||||
systemCertCount: 0,
|
||||
userCertCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const systemCerts = harvestSystemCerts(tls);
|
||||
if (systemCerts.length === 0) {
|
||||
// Nothing to add: leave any user-provided NODE_EXTRA_CA_CERTS untouched
|
||||
// and let the runtime fall back to its bundled CAs.
|
||||
return {
|
||||
action: "no-system-certs",
|
||||
path: null,
|
||||
systemCertCount: 0,
|
||||
userCertCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const managedDir = resolveClineDir(env, os, path);
|
||||
const managedPath = path.join(managedDir, "cli-node-extra-ca-certs.pem");
|
||||
const userValue = (env.NODE_EXTRA_CA_CERTS || "").trim() || null;
|
||||
const userPems = readUserCerts(fs, path, userValue, managedPath);
|
||||
const bundle = buildBundle({ systemCerts, userPems });
|
||||
const base = {
|
||||
path: managedPath,
|
||||
systemCertCount: systemCerts.length,
|
||||
userCertCount: countCerts(userPems),
|
||||
};
|
||||
|
||||
// Skip the rewrite when the bundle is already current. Avoids per-launch I/O
|
||||
// and the concurrent-rename race in the steady state.
|
||||
if (readFileIfExists(fs, managedPath) === bundle) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "unchanged" };
|
||||
}
|
||||
|
||||
if (writeBundle(fs, managedDir, managedPath, bundle)) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "written" };
|
||||
}
|
||||
|
||||
// Write failed: fall back to a previously-written bundle if one exists.
|
||||
if (readFileIfExists(fs, managedPath)) {
|
||||
env.NODE_EXTRA_CA_CERTS = managedPath;
|
||||
return { ...base, action: "write-failed-reused" };
|
||||
}
|
||||
return { ...base, path: null, action: "write-failed" };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
harvestSystemCerts,
|
||||
sanitizePem,
|
||||
readUserBundle,
|
||||
readUserCerts,
|
||||
buildBundle,
|
||||
countCerts,
|
||||
configureNodeExtraCaCerts,
|
||||
shouldWarnApiUnavailable,
|
||||
};
|
||||
@@ -1,367 +0,0 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
// The helper ships as CommonJS in the published wrapper package, so it is
|
||||
// loaded via require rather than an ESM import.
|
||||
const caCerts = require("../../bin/ca-certs.cjs") as {
|
||||
harvestSystemCerts: (tls?: unknown) => string[];
|
||||
readUserBundle: (fs: unknown, p: string | null) => string | null;
|
||||
readUserCerts: (
|
||||
fs: unknown,
|
||||
path: unknown,
|
||||
value: string | null,
|
||||
managedPath: string | null,
|
||||
) => string[];
|
||||
buildBundle: (input: {
|
||||
systemCerts: string[];
|
||||
userPems?: string[];
|
||||
}) => string;
|
||||
countCerts: (pems: string[]) => number;
|
||||
configureNodeExtraCaCerts: (
|
||||
env: Record<string, string>,
|
||||
deps?: { tls?: unknown; fs?: unknown },
|
||||
) => {
|
||||
action: string;
|
||||
path: string | null;
|
||||
systemCertCount: number;
|
||||
userCertCount: number;
|
||||
};
|
||||
shouldWarnApiUnavailable: (
|
||||
env: Record<string, string>,
|
||||
deps?: { fs?: unknown; nodeVersion?: string },
|
||||
) => boolean;
|
||||
};
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const certSystem =
|
||||
"-----BEGIN CERTIFICATE-----\nSYSTEM\n-----END CERTIFICATE-----\n";
|
||||
const certUser = "-----BEGIN CERTIFICATE-----\nUSER\n-----END CERTIFICATE-----";
|
||||
|
||||
function fakeTls(certs: unknown) {
|
||||
return { getCACertificates: () => certs };
|
||||
}
|
||||
|
||||
describe("ca-certs", () => {
|
||||
let dir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
dir = mkdtempSync(join(tmpdir(), "cline-ca-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("harvestSystemCerts", () => {
|
||||
it("returns only PEM strings from the system store", () => {
|
||||
expect(
|
||||
caCerts.harvestSystemCerts(fakeTls([certSystem, "not-a-cert", 42])),
|
||||
).toEqual([certSystem]);
|
||||
});
|
||||
|
||||
it("returns [] when getCACertificates is unavailable", () => {
|
||||
expect(caCerts.harvestSystemCerts({})).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] when getCACertificates throws", () => {
|
||||
expect(
|
||||
caCerts.harvestSystemCerts({
|
||||
getCACertificates: () => {
|
||||
throw new Error("nope");
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readUserBundle", () => {
|
||||
it("returns PEM contents for a PEM file", () => {
|
||||
const p = join(dir, "user.pem");
|
||||
writeFileSync(p, certUser);
|
||||
expect(caCerts.readUserBundle(fs, p)).toBe(certUser);
|
||||
});
|
||||
|
||||
it("returns null for a non-PEM (DER) file", () => {
|
||||
const p = join(dir, "user.der");
|
||||
writeFileSync(p, Buffer.from([0x30, 0x82, 0x01, 0x02]));
|
||||
expect(caCerts.readUserBundle(fs, p)).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for a missing file and for null path", () => {
|
||||
expect(caCerts.readUserBundle(fs, join(dir, "nope.pem"))).toBeNull();
|
||||
expect(caCerts.readUserBundle(fs, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("strips non-certificate sections such as private keys", () => {
|
||||
// Combined cert+key files (nginx/haproxy style) are common; the key
|
||||
// must never reach the managed bundle.
|
||||
const p = join(dir, "combined.pem");
|
||||
writeFileSync(
|
||||
p,
|
||||
`${certUser}\n-----BEGIN PRIVATE KEY-----\nSECRET\n-----END PRIVATE KEY-----\n`,
|
||||
);
|
||||
const out = caCerts.readUserBundle(fs, p);
|
||||
expect(out).toContain("USER");
|
||||
expect(out).not.toContain("PRIVATE KEY");
|
||||
expect(out).not.toContain("SECRET");
|
||||
});
|
||||
|
||||
it("keeps certificates-only files verbatim", () => {
|
||||
// Byte-identical passthrough keeps the unchanged-skip hash stable.
|
||||
const p = join(dir, "clean.pem");
|
||||
writeFileSync(p, `${certUser}\n${certSystem}`);
|
||||
expect(caCerts.readUserBundle(fs, p)).toBe(`${certUser}\n${certSystem}`);
|
||||
});
|
||||
|
||||
it("returns null for a BEGIN marker without a complete block", () => {
|
||||
const p = join(dir, "truncated.pem");
|
||||
writeFileSync(p, "-----BEGIN CERTIFICATE-----\ntruncated");
|
||||
expect(caCerts.readUserBundle(fs, p)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("readUserCerts", () => {
|
||||
it("reads a single PEM file path", () => {
|
||||
const p = join(dir, "corp.pem");
|
||||
writeFileSync(p, certUser);
|
||||
expect(caCerts.readUserCerts(fs, path, p, null)).toEqual([certUser]);
|
||||
});
|
||||
|
||||
it("splits a legacy OS-path-delimited value and reads each PEM", () => {
|
||||
// Legacy footgun: NODE_EXTRA_CA_CERTS="a.pem;b.pem".
|
||||
const a = join(dir, "a.pem");
|
||||
const b = join(dir, "b.pem");
|
||||
writeFileSync(a, certUser);
|
||||
writeFileSync(b, certSystem);
|
||||
expect(
|
||||
caCerts.readUserCerts(fs, path, [a, b].join(delimiter), null),
|
||||
).toEqual([certUser, certSystem]);
|
||||
});
|
||||
|
||||
it("skips missing segments in a delimited value", () => {
|
||||
const a = join(dir, "a.pem");
|
||||
writeFileSync(a, certUser);
|
||||
const value = [a, join(dir, "missing.pem")].join(delimiter);
|
||||
expect(caCerts.readUserCerts(fs, path, value, null)).toEqual([certUser]);
|
||||
});
|
||||
|
||||
it("excludes the managed bundle from user certs", () => {
|
||||
const managed = join(dir, "cli-node-extra-ca-certs.pem");
|
||||
writeFileSync(managed, certUser);
|
||||
expect(caCerts.readUserCerts(fs, path, managed, managed)).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns [] for empty value", () => {
|
||||
expect(caCerts.readUserCerts(fs, path, null, null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBundle", () => {
|
||||
it("merges user PEMs before system certs", () => {
|
||||
expect(
|
||||
caCerts.buildBundle({
|
||||
systemCerts: [certSystem],
|
||||
userPems: [certUser],
|
||||
}),
|
||||
).toBe(`${certUser}\n${certSystem}`);
|
||||
});
|
||||
|
||||
it("inserts a separating newline so END/BEGIN markers do not fuse", () => {
|
||||
// certUser has no trailing newline, so this proves the boundary fix.
|
||||
const merged = caCerts.buildBundle({
|
||||
systemCerts: [certSystem],
|
||||
userPems: [certUser],
|
||||
});
|
||||
expect(merged).not.toContain(
|
||||
"-----END CERTIFICATE----------BEGIN CERTIFICATE-----",
|
||||
);
|
||||
});
|
||||
|
||||
it("handles no user PEMs", () => {
|
||||
expect(caCerts.buildBundle({ systemCerts: [certSystem] })).toBe(
|
||||
certSystem,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configureNodeExtraCaCerts", () => {
|
||||
it("writes a managed bundle and points the env var at it", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
});
|
||||
expect(out.action).toBe("written");
|
||||
expect(out.path).toBe(join(dir, "cli-node-extra-ca-certs.pem"));
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe(out.path);
|
||||
expect(readFileSync(out.path as string, "utf8")).toContain("SYSTEM");
|
||||
});
|
||||
|
||||
it("merges a user-supplied NODE_EXTRA_CA_CERTS with system certs", () => {
|
||||
const userPath = join(dir, "corp.pem");
|
||||
writeFileSync(userPath, certUser);
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: userPath,
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
});
|
||||
expect(out.userCertCount).toBe(1);
|
||||
const written = readFileSync(env.NODE_EXTRA_CA_CERTS, "utf8");
|
||||
expect(written).toContain("USER");
|
||||
expect(written).toContain("SYSTEM");
|
||||
});
|
||||
|
||||
it("reports unchanged and skips rewrite on the second run", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
expect(
|
||||
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
|
||||
.action,
|
||||
).toBe("written");
|
||||
expect(
|
||||
caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([certSystem]) })
|
||||
.action,
|
||||
).toBe("unchanged");
|
||||
});
|
||||
|
||||
it("does not re-append when the user already points at the managed bundle", () => {
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const first = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
}).path as string;
|
||||
const env2: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: first,
|
||||
};
|
||||
caCerts.configureNodeExtraCaCerts(env2, { tls: fakeTls([certSystem]) });
|
||||
const written = readFileSync(env2.NODE_EXTRA_CA_CERTS, "utf8");
|
||||
expect(written.match(/SYSTEM/g)?.length).toBe(1);
|
||||
});
|
||||
|
||||
it("no-ops when no system certs are available", () => {
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, { tls: fakeTls([]) });
|
||||
expect(out.action).toBe("no-system-certs");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
|
||||
});
|
||||
|
||||
it("reports api-unavailable on Nodes without getCACertificates", () => {
|
||||
const env: Record<string, string> = {
|
||||
CLINE_DIR: dir,
|
||||
NODE_EXTRA_CA_CERTS: "/user/corp.pem",
|
||||
};
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, { tls: {} });
|
||||
expect(out.action).toBe("api-unavailable");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBe("/user/corp.pem");
|
||||
});
|
||||
|
||||
it("reports write-failed when the bundle cannot be written", () => {
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
fs: failingFs,
|
||||
});
|
||||
expect(out.action).toBe("write-failed");
|
||||
expect(out.path).toBeNull();
|
||||
expect(env.NODE_EXTRA_CA_CERTS).toBeUndefined();
|
||||
});
|
||||
|
||||
it("reuses a stale bundle when the rewrite fails", () => {
|
||||
// First run writes the bundle normally.
|
||||
const env: Record<string, string> = { CLINE_DIR: dir };
|
||||
const managedPath = caCerts.configureNodeExtraCaCerts(env, {
|
||||
tls: fakeTls([certSystem]),
|
||||
}).path as string;
|
||||
|
||||
// Second run: writes fail, but the stale bundle is still readable.
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env2: Record<string, string> = { CLINE_DIR: dir };
|
||||
const out = caCerts.configureNodeExtraCaCerts(env2, {
|
||||
// A different system cert forces a rewrite attempt (not "unchanged").
|
||||
tls: fakeTls([certUser]),
|
||||
fs: failingFs,
|
||||
});
|
||||
|
||||
expect(out.action).toBe("write-failed-reused");
|
||||
expect(env2.NODE_EXTRA_CA_CERTS).toBe(managedPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe("countCerts", () => {
|
||||
it("counts individual certificates, not files", () => {
|
||||
// One file holding two certs must report 2, not 1.
|
||||
const twoInOne = `${certUser}\n${certSystem}`;
|
||||
expect(caCerts.countCerts([twoInOne])).toBe(2);
|
||||
expect(caCerts.countCerts([certUser, certSystem])).toBe(2);
|
||||
expect(caCerts.countCerts([])).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldWarnApiUnavailable", () => {
|
||||
it("warns once per Node version, then stays quiet", () => {
|
||||
const env = { CLINE_DIR: dir };
|
||||
const deps = { nodeVersion: "22.1.0" };
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(false);
|
||||
});
|
||||
|
||||
it("re-arms when the Node version changes", () => {
|
||||
const env = { CLINE_DIR: dir };
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.14.0" }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
caCerts.shouldWarnApiUnavailable(env, { nodeVersion: "22.1.0" }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("still warns when the stamp cannot be written", () => {
|
||||
const realFs = require("node:fs");
|
||||
const failingFs = {
|
||||
...realFs,
|
||||
mkdirSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
writeFileSync: () => {
|
||||
throw new Error("EACCES");
|
||||
},
|
||||
};
|
||||
const env = { CLINE_DIR: dir };
|
||||
const deps = { fs: failingFs, nodeVersion: "22.1.0" };
|
||||
// Bookkeeping failure must never suppress the diagnostic.
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
expect(caCerts.shouldWarnApiUnavailable(env, deps)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,208 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync } from "node:fs";
|
||||
import { arch, platform, tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { runDashboardCommand, waitForProcessShutdown } from "./dashboard";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"WORKSPACE_ROOT",
|
||||
"CLINE_DIR",
|
||||
"CLINE_SANDBOX",
|
||||
"CLINE_SANDBOX_DATA_DIR",
|
||||
"CLINE_DATA_DIR",
|
||||
"CLINE_DB_DATA_DIR",
|
||||
"CLINE_SESSION_DATA_DIR",
|
||||
"CLINE_TEAM_DATA_DIR",
|
||||
"CLINE_PROVIDER_SETTINGS_PATH",
|
||||
"CLINE_HOOKS_LOG_PATH",
|
||||
"HOST",
|
||||
"CLINE_HUB_DASHBOARD_PORT",
|
||||
"PUBLIC_URL",
|
||||
"ROOM_SECRET",
|
||||
"CLINE_HUB_WEBVIEW_DIST_DIR",
|
||||
"CLINE_WRAPPER_PATH",
|
||||
] as const;
|
||||
|
||||
const originalEnv = Object.fromEntries(
|
||||
ENV_KEYS.map((key) => [key, process.env[key]]),
|
||||
);
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = originalEnv[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("runDashboardCommand", () => {
|
||||
it("starts the dashboard server, opens the invite URL, and waits for shutdown", async () => {
|
||||
const output: string[] = [];
|
||||
const errors: string[] = [];
|
||||
const opened: string[] = [];
|
||||
const stop = vi.fn();
|
||||
let observedEnv:
|
||||
| {
|
||||
workspaceRoot: string | undefined;
|
||||
clineDir: string | undefined;
|
||||
clineDataDir: string | undefined;
|
||||
providerSettingsPath: string | undefined;
|
||||
host: string | undefined;
|
||||
port: string | undefined;
|
||||
publicUrl: string | undefined;
|
||||
roomSecret: string | undefined;
|
||||
webviewDistDir: string | undefined;
|
||||
}
|
||||
| undefined;
|
||||
const webviewDistDir = mkdtempSync(join(tmpdir(), "cline-webview-dist-"));
|
||||
mkdirSync(webviewDistDir, { recursive: true });
|
||||
process.env.CLINE_HUB_WEBVIEW_DIST_DIR = webviewDistDir;
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
configDir: "/tmp/cline-config",
|
||||
cwd: "sdk",
|
||||
dataDir: ".cline-dashboard-data",
|
||||
host: "127.0.0.1",
|
||||
port: "9090",
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
roomSecret: "secret",
|
||||
io: {
|
||||
writeln: (text) => output.push(text ?? ""),
|
||||
writeErr: (text) => errors.push(text),
|
||||
},
|
||||
startServer: async () => {
|
||||
observedEnv = {
|
||||
workspaceRoot: process.env.WORKSPACE_ROOT,
|
||||
clineDir: process.env.CLINE_DIR,
|
||||
clineDataDir: process.env.CLINE_DATA_DIR,
|
||||
providerSettingsPath: process.env.CLINE_PROVIDER_SETTINGS_PATH,
|
||||
host: process.env.HOST,
|
||||
port: process.env.CLINE_HUB_DASHBOARD_PORT,
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
roomSecret: process.env.ROOM_SECRET,
|
||||
webviewDistDir: process.env.CLINE_HUB_WEBVIEW_DIST_DIR,
|
||||
};
|
||||
return {
|
||||
listenUrl: "http://127.0.0.1:9090/",
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
inviteUrl: "http://127.0.0.1:9090/?roomSecret=secret",
|
||||
hubUrl: "ws://127.0.0.1:25463/hub",
|
||||
stop,
|
||||
};
|
||||
},
|
||||
openUrl: async (url) => {
|
||||
opened.push(url);
|
||||
},
|
||||
waitForShutdown: async (server) => {
|
||||
await server.stop();
|
||||
},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(observedEnv).toEqual({
|
||||
workspaceRoot: resolve("sdk"),
|
||||
clineDir: "/tmp/cline-config",
|
||||
clineDataDir: resolve("sdk", ".cline-dashboard-data"),
|
||||
providerSettingsPath: join(
|
||||
resolve("sdk", ".cline-dashboard-data"),
|
||||
"settings",
|
||||
"providers.json",
|
||||
),
|
||||
host: "127.0.0.1",
|
||||
port: "9090",
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
roomSecret: "secret",
|
||||
webviewDistDir,
|
||||
});
|
||||
expect(opened).toEqual(["http://127.0.0.1:9090/?roomSecret=secret"]);
|
||||
expect(stop).toHaveBeenCalledTimes(1);
|
||||
expect(output.join("\n")).toContain("Cline dashboard listening at");
|
||||
expect(output.join("\n")).toContain("ws://127.0.0.1:25463/hub");
|
||||
expect(errors).toEqual([]);
|
||||
expect(process.env.WORKSPACE_ROOT).toBe(originalEnv.WORKSPACE_ROOT);
|
||||
expect(process.env.CLINE_HUB_WEBVIEW_DIST_DIR).toBe(webviewDistDir);
|
||||
});
|
||||
|
||||
it("honors --no-open behavior", async () => {
|
||||
const openUrl = vi.fn();
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
openBrowser: false,
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
startServer: async () => ({
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
stop: vi.fn(),
|
||||
}),
|
||||
openUrl,
|
||||
waitForShutdown: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("finds webview assets from the published wrapper package layout", async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-wrapper-layout-"));
|
||||
const wrapperPath = join(root, "node_modules", "cline", "bin", "cline");
|
||||
const platformName = platform() === "win32" ? "windows" : platform();
|
||||
const webviewDistDir = join(
|
||||
root,
|
||||
"node_modules",
|
||||
"cline",
|
||||
"node_modules",
|
||||
"@cline",
|
||||
`cli-${platformName}-${arch()}`,
|
||||
"cline-hub",
|
||||
"webview",
|
||||
);
|
||||
mkdirSync(join(wrapperPath, ".."), { recursive: true });
|
||||
mkdirSync(webviewDistDir, { recursive: true });
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
delete process.env.CLINE_HUB_WEBVIEW_DIST_DIR;
|
||||
let observedWebviewDistDir: string | undefined;
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
openBrowser: false,
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
startServer: async () => {
|
||||
observedWebviewDistDir = process.env.CLINE_HUB_WEBVIEW_DIST_DIR;
|
||||
return {
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
stop: vi.fn(),
|
||||
};
|
||||
},
|
||||
waitForShutdown: async () => {},
|
||||
});
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(observedWebviewDistDir).toBe(webviewDistDir);
|
||||
});
|
||||
|
||||
it("settles shutdown when server stop rejects", async () => {
|
||||
const shutdown = waitForProcessShutdown({
|
||||
listenUrl: "http://127.0.0.1:8787/",
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
inviteUrl: "http://127.0.0.1:8787",
|
||||
stop: vi.fn(async () => {
|
||||
throw new Error("stop failed");
|
||||
}),
|
||||
});
|
||||
|
||||
process.emit("SIGINT", "SIGINT");
|
||||
|
||||
await expect(shutdown).rejects.toThrow("stop failed");
|
||||
});
|
||||
});
|
||||
@@ -1,215 +0,0 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { arch, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import open from "open";
|
||||
import { configureSandboxEnvironment } from "../utils/helpers";
|
||||
import { c } from "../utils/output";
|
||||
|
||||
export interface DashboardServerHandle {
|
||||
listenUrl: string;
|
||||
publicUrl: string;
|
||||
inviteUrl: string;
|
||||
hubUrl?: string;
|
||||
stop: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface DashboardCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface RunDashboardCommandOptions {
|
||||
configDir?: string;
|
||||
cwd?: string;
|
||||
dataDir?: string;
|
||||
host?: string;
|
||||
port?: string;
|
||||
publicUrl?: string;
|
||||
roomSecret?: string;
|
||||
openBrowser?: boolean;
|
||||
io: DashboardCommandIo;
|
||||
startServer?: () => Promise<DashboardServerHandle>;
|
||||
openUrl?: (url: string) => Promise<void>;
|
||||
waitForShutdown?: (server: DashboardServerHandle) => Promise<void>;
|
||||
}
|
||||
|
||||
const DASHBOARD_PORT_ENV = "CLINE_HUB_DASHBOARD_PORT";
|
||||
const WEBVIEW_DIST_ENV = "CLINE_HUB_WEBVIEW_DIST_DIR";
|
||||
|
||||
function setEnvValue(name: string, value: string | undefined): () => void {
|
||||
const previous = process.env[name];
|
||||
if (value !== undefined) {
|
||||
process.env[name] = value;
|
||||
}
|
||||
return () => {
|
||||
if (previous === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = previous;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const SANDBOX_ENV_KEYS = [
|
||||
"CLINE_SANDBOX",
|
||||
"CLINE_SANDBOX_DATA_DIR",
|
||||
"CLINE_DATA_DIR",
|
||||
"CLINE_DB_DATA_DIR",
|
||||
"CLINE_SESSION_DATA_DIR",
|
||||
"CLINE_TEAM_DATA_DIR",
|
||||
"CLINE_PROVIDER_SETTINGS_PATH",
|
||||
"CLINE_HOOKS_LOG_PATH",
|
||||
] as const;
|
||||
|
||||
async function withDashboardEnvironment<T>(
|
||||
options: RunDashboardCommandOptions,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
|
||||
const restore = [
|
||||
setEnvValue("WORKSPACE_ROOT", options.cwd ? cwd : undefined),
|
||||
setEnvValue("CLINE_DIR", options.configDir?.trim() || undefined),
|
||||
setEnvValue("HOST", options.host),
|
||||
setEnvValue(DASHBOARD_PORT_ENV, options.port),
|
||||
setEnvValue("PUBLIC_URL", options.publicUrl),
|
||||
setEnvValue("ROOM_SECRET", options.roomSecret),
|
||||
setEnvValue(WEBVIEW_DIST_ENV, resolveDefaultWebviewDistDir()),
|
||||
...SANDBOX_ENV_KEYS.map((key) => setEnvValue(key, undefined)),
|
||||
];
|
||||
if (options.dataDir || process.env.CLINE_SANDBOX?.trim() === "1") {
|
||||
configureSandboxEnvironment({
|
||||
enabled: true,
|
||||
cwd,
|
||||
explicitDir: options.dataDir,
|
||||
});
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
for (let i = restore.length - 1; i >= 0; i--) {
|
||||
restore[i]?.();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDefaultWebviewDistDir(): string | undefined {
|
||||
if (process.env[WEBVIEW_DIST_ENV]?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
||||
const candidates = [
|
||||
...resolveInstalledPlatformPackageWebviewCandidates(),
|
||||
// Source checkout: apps/cli/src/commands/dashboard.ts
|
||||
join(moduleDir, "../../../cline-hub/dist/webview"),
|
||||
// Node bundle: apps/cli/dist/index.js
|
||||
join(moduleDir, "cline-hub/webview"),
|
||||
// Compiled platform package: apps/cli/dist/<platform>/bin/cline
|
||||
join(dirname(process.execPath), "../cline-hub/webview"),
|
||||
];
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate));
|
||||
}
|
||||
|
||||
function resolveInstalledPlatformPackageWebviewCandidates(): string[] {
|
||||
const packageName = resolvePlatformPackageName();
|
||||
const starts = [
|
||||
process.env.CLINE_WRAPPER_PATH
|
||||
? dirname(process.env.CLINE_WRAPPER_PATH)
|
||||
: undefined,
|
||||
dirname(process.execPath),
|
||||
].filter((value): value is string => !!value?.trim());
|
||||
const candidates: string[] = [];
|
||||
for (const start of starts) {
|
||||
let current = start;
|
||||
for (;;) {
|
||||
candidates.push(
|
||||
join(current, "node_modules", packageName, "cline-hub/webview"),
|
||||
);
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function resolvePlatformPackageName(): string {
|
||||
const platformName = platform() === "win32" ? "windows" : platform();
|
||||
return `@cline/cli-${platformName}-${arch()}`;
|
||||
}
|
||||
|
||||
async function startDefaultDashboardServer(): Promise<DashboardServerHandle> {
|
||||
const { startClineHubDashboardServer } = await import("@cline/cline-hub");
|
||||
return await startClineHubDashboardServer();
|
||||
}
|
||||
|
||||
async function openDefaultUrl(url: string): Promise<void> {
|
||||
await open(url, { wait: false });
|
||||
}
|
||||
|
||||
export function waitForProcessShutdown(
|
||||
server: DashboardServerHandle,
|
||||
): Promise<void> {
|
||||
return new Promise<void>((resolveShutdown, rejectShutdown) => {
|
||||
let settled = false;
|
||||
|
||||
const cleanup = () => {
|
||||
process.off("SIGINT", handleSignal);
|
||||
process.off("SIGTERM", handleSignal);
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
try {
|
||||
await server.stop();
|
||||
resolveShutdown();
|
||||
} catch (error) {
|
||||
rejectShutdown(error);
|
||||
}
|
||||
};
|
||||
|
||||
function handleSignal() {
|
||||
void stop();
|
||||
}
|
||||
|
||||
process.on("SIGINT", handleSignal);
|
||||
process.on("SIGTERM", handleSignal);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runDashboardCommand(
|
||||
options: RunDashboardCommandOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const server = await withDashboardEnvironment(options, () =>
|
||||
(options.startServer ?? startDefaultDashboardServer)(),
|
||||
);
|
||||
const dashboardUrl =
|
||||
server.inviteUrl || server.publicUrl || server.listenUrl;
|
||||
options.io.writeln(
|
||||
`${c.green}Cline dashboard listening at${c.reset} ${dashboardUrl}`,
|
||||
);
|
||||
if (server.hubUrl) {
|
||||
options.io.writeln(`${c.dim}Hub endpoint: ${server.hubUrl}${c.reset}`);
|
||||
}
|
||||
|
||||
if (options.openBrowser !== false) {
|
||||
try {
|
||||
await (options.openUrl ?? openDefaultUrl)(dashboardUrl);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io.writeErr(`Failed to open browser: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await (options.waitForShutdown ?? waitForProcessShutdown)(server);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
options.io.writeErr(error instanceof Error ? error.message : String(error));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import { installMcpServer } from "@cline/core";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildMcpInstallDefaults,
|
||||
buildMcpInstallTransport,
|
||||
runMcpInstallCommand,
|
||||
} from "./mcp";
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
installMcpServer: vi.fn((options) => {
|
||||
const { name, transport, warnings } =
|
||||
actual.buildMcpInstallTransport(options);
|
||||
return {
|
||||
name,
|
||||
status: "installed",
|
||||
transport,
|
||||
warnings,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("mcp install command", () => {
|
||||
it("builds stdio wizard defaults from command args", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "fs",
|
||||
targetArgs: [
|
||||
"npx",
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp/my dir",
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "fs",
|
||||
type: "stdio",
|
||||
command: 'npx -y @modelcontextprotocol/server-filesystem "/tmp/my dir"',
|
||||
});
|
||||
});
|
||||
|
||||
it("builds remote wizard defaults and normalizes http transport", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "ctx7",
|
||||
transport: "http",
|
||||
targetArgs: ["https://mcp.context7.com/mcp"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "ctx7",
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes streamable-http transport", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "docs",
|
||||
transport: "streamable-http",
|
||||
targetArgs: ["https://example.com/mcp"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "docs",
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds SSE wizard defaults", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "events",
|
||||
transport: "sse",
|
||||
targetArgs: ["https://example.com/sse"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "events",
|
||||
type: "sse",
|
||||
url: "https://example.com/sse",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing stdio command and invalid remote URL", () => {
|
||||
expect(() =>
|
||||
buildMcpInstallDefaults({
|
||||
name: "fs",
|
||||
}),
|
||||
).toThrow(/requires a command/);
|
||||
|
||||
expect(() =>
|
||||
buildMcpInstallDefaults({
|
||||
name: "bad",
|
||||
transport: "http",
|
||||
targetArgs: ["not-a-url"],
|
||||
}),
|
||||
).toThrow(/Invalid MCP server URL/);
|
||||
});
|
||||
|
||||
it("rejects remote URL schemes other than http and https", () => {
|
||||
expect(() =>
|
||||
buildMcpInstallDefaults({
|
||||
name: "bad",
|
||||
transport: "http",
|
||||
targetArgs: ["file:///etc/passwd"],
|
||||
}),
|
||||
).toThrow(/only http and https are supported/);
|
||||
});
|
||||
|
||||
it("builds direct stdio installs without shell-joining args", () => {
|
||||
expect(
|
||||
buildMcpInstallTransport({
|
||||
name: "fs",
|
||||
targetArgs: [
|
||||
"npx",
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp/my dir",
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "fs",
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp/my dir"],
|
||||
},
|
||||
warnings: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("builds direct remote installs with headers and placeholder warnings", () => {
|
||||
expect(
|
||||
buildMcpInstallTransport({
|
||||
name: "docs",
|
||||
transport: "http",
|
||||
headers: ["Authorization: Bearer <token>"],
|
||||
targetArgs: ["https://example.com/mcp", "--header=X-Extra: yes"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "docs",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer <token>",
|
||||
"X-Extra": "yes",
|
||||
},
|
||||
},
|
||||
warnings: [
|
||||
'Header "Authorization" looks like it contains a placeholder. Update it in MCP settings before using this server.',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("opens the add wizard with prefilled defaults", async () => {
|
||||
const runWizard = vi.fn(async () => 0);
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "ctx7",
|
||||
transport: "http",
|
||||
targetArgs: ["https://mcp.context7.com/mcp"],
|
||||
isTty: true,
|
||||
runWizard,
|
||||
io: { writeErr: vi.fn() },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(runWizard).toHaveBeenCalledWith({
|
||||
name: "ctx7",
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a TTY because it opens the wizard", async () => {
|
||||
const writeErr = vi.fn();
|
||||
const runWizard = vi.fn(async () => 0);
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "ctx7",
|
||||
transport: "http",
|
||||
targetArgs: ["https://mcp.context7.com/mcp"],
|
||||
isTty: false,
|
||||
runWizard,
|
||||
io: { writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(runWizard).not.toHaveBeenCalled();
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks for TTY before validating wizard install arguments", async () => {
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "fs",
|
||||
isTty: false,
|
||||
io: { writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
|
||||
);
|
||||
});
|
||||
|
||||
it("installs directly with --yes without requiring a TTY", async () => {
|
||||
const writeln = vi.fn();
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "docs",
|
||||
transport: "http",
|
||||
targetArgs: [
|
||||
"https://example.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
],
|
||||
isTty: false,
|
||||
yes: true,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(installMcpServer).toHaveBeenCalledWith({
|
||||
name: "docs",
|
||||
transport: "http",
|
||||
targetArgs: [
|
||||
"https://example.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
],
|
||||
isTty: false,
|
||||
yes: true,
|
||||
io: { writeln, writeErr },
|
||||
});
|
||||
expect(writeln).toHaveBeenCalledWith("Installed MCP server docs.");
|
||||
expect(writeErr).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prints direct install JSON with --yes --json", async () => {
|
||||
const writeln = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "fs",
|
||||
targetArgs: ["node", "server.js"],
|
||||
isTty: false,
|
||||
yes: true,
|
||||
json: true,
|
||||
io: { writeln, writeErr: vi.fn() },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(JSON.parse(writeln.mock.calls[0]?.[0])).toMatchObject({
|
||||
name: "fs",
|
||||
status: "installed",
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import {
|
||||
type McpInstallOptions as CoreMcpInstallOptions,
|
||||
installMcpServer,
|
||||
type McpInstallResult,
|
||||
type McpServerTransportConfig,
|
||||
} from "@cline/core";
|
||||
import type { McpAddDefaults } from "../wizards/mcp";
|
||||
|
||||
export { buildMcpInstallTransport } from "@cline/core";
|
||||
|
||||
export interface McpCommandIo {
|
||||
writeln?: (text: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface McpInstallOptions extends CoreMcpInstallOptions {
|
||||
io?: McpCommandIo;
|
||||
isTty?: boolean;
|
||||
json?: boolean;
|
||||
runWizard?: (defaults: McpAddDefaults) => Promise<number>;
|
||||
yes?: boolean;
|
||||
}
|
||||
|
||||
export interface McpInstallDirectResult {
|
||||
name: string;
|
||||
status: "installed";
|
||||
transport: McpServerTransportConfig;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
function normalizeTransportType(
|
||||
value: string | undefined,
|
||||
): McpServerTransportConfig["type"] {
|
||||
const normalized = (value ?? "stdio").trim();
|
||||
if (normalized === "http" || normalized === "streamable-http") {
|
||||
return "streamableHttp";
|
||||
}
|
||||
if (
|
||||
normalized === "stdio" ||
|
||||
normalized === "sse" ||
|
||||
normalized === "streamableHttp"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertValidUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid MCP server URL: ${url}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`Invalid MCP server URL: ${url} (only http and https are supported)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function quoteCommandArg(arg: string): string {
|
||||
if (/^[^\s"'\\]+$/.test(arg)) {
|
||||
return arg;
|
||||
}
|
||||
return `"${arg.replace(/(["\\])/g, "\\$1")}"`;
|
||||
}
|
||||
|
||||
export function buildMcpInstallDefaults(options: {
|
||||
name: string;
|
||||
targetArgs?: string[];
|
||||
transport?: string;
|
||||
}): McpAddDefaults {
|
||||
const name = options.name.trim();
|
||||
if (!name) {
|
||||
throw new Error("MCP server name is required");
|
||||
}
|
||||
const type = normalizeTransportType(options.transport);
|
||||
const targetArgs = options.targetArgs ?? [];
|
||||
if (type === "stdio") {
|
||||
if (targetArgs.length === 0) {
|
||||
throw new Error(
|
||||
"Stdio MCP install requires a command after the server name, for example: cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp",
|
||||
);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
command: targetArgs.map(quoteCommandArg).join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
if (targetArgs.length !== 1) {
|
||||
throw new Error(
|
||||
"Remote MCP install requires exactly one URL argument after the server name.",
|
||||
);
|
||||
}
|
||||
const url = targetArgs[0]?.trim() ?? "";
|
||||
assertValidUrl(url);
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
export function installMcpServerDirect(
|
||||
options: McpInstallOptions,
|
||||
): McpInstallDirectResult {
|
||||
const result: McpInstallResult = installMcpServer(options);
|
||||
return {
|
||||
name: result.name,
|
||||
status: result.status,
|
||||
transport: result.transport,
|
||||
warnings: result.warnings,
|
||||
};
|
||||
}
|
||||
|
||||
async function runPrefilledWizard(defaults: McpAddDefaults): Promise<number> {
|
||||
const { runMcpWizard } = await import("../wizards/mcp");
|
||||
return runMcpWizard({
|
||||
initialAction: "add",
|
||||
addDefaults: defaults,
|
||||
exitAfterInitialAction: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runMcpInstallCommand(
|
||||
options: McpInstallOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
if (options.yes) {
|
||||
const result = installMcpServerDirect(options);
|
||||
if (options.json) {
|
||||
options.io?.writeln?.(JSON.stringify(result));
|
||||
} else {
|
||||
options.io?.writeln?.(`Installed MCP server ${result.name}.`);
|
||||
for (const warning of result.warnings) {
|
||||
options.io?.writeErr(warning);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
const isTty =
|
||||
options.isTty ?? (process.stdin.isTTY && process.stdout.isTTY);
|
||||
if (!isTty) {
|
||||
throw new Error(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY. Pass --yes to install noninteractively.",
|
||||
);
|
||||
}
|
||||
const defaults = buildMcpInstallDefaults(options);
|
||||
return await (options.runWizard ?? runPrefilledWizard)(defaults);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,200 +0,0 @@
|
||||
import {
|
||||
installPlugin,
|
||||
type PluginInstallOptions,
|
||||
type PluginInstallResult,
|
||||
type PluginMcpOAuthCandidate,
|
||||
type PluginUninstallOptions,
|
||||
uninstallPlugin,
|
||||
} from "@cline/core";
|
||||
|
||||
export type {
|
||||
PluginInstallOptions,
|
||||
PluginInstallResult,
|
||||
PluginMcpOAuthCandidate,
|
||||
} from "@cline/core";
|
||||
export {
|
||||
collectPluginMcpOAuthCandidates,
|
||||
installPlugin,
|
||||
isOfficialPluginSlug,
|
||||
parsePluginSource,
|
||||
} from "@cline/core";
|
||||
|
||||
export interface PluginInstallMcpOAuthOptions {
|
||||
interactive?: boolean;
|
||||
selectCandidates?: (
|
||||
candidates: PluginMcpOAuthCandidate[],
|
||||
) => Promise<PluginMcpOAuthCandidate[]>;
|
||||
authorize?: (candidate: PluginMcpOAuthCandidate) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginInstallIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
type PluginInstallCommandOptions = PluginInstallOptions & {
|
||||
json?: boolean;
|
||||
io?: PluginInstallIo;
|
||||
mcpOAuth?: PluginInstallMcpOAuthOptions;
|
||||
};
|
||||
|
||||
function serializePluginInstallResult(
|
||||
result: PluginInstallResult,
|
||||
): Omit<PluginInstallResult, "mcpOAuthCandidates"> {
|
||||
return {
|
||||
source: result.source,
|
||||
installPath: result.installPath,
|
||||
entryPaths: result.entryPaths,
|
||||
mcpSyncFailures: result.mcpSyncFailures,
|
||||
};
|
||||
}
|
||||
|
||||
function isInteractivePluginInstall(
|
||||
options: PluginInstallCommandOptions,
|
||||
): boolean {
|
||||
return (
|
||||
options.mcpOAuth?.interactive ??
|
||||
(process.stdin.isTTY && process.stdout.isTTY)
|
||||
);
|
||||
}
|
||||
|
||||
async function selectMcpOAuthCandidatesWithClack(
|
||||
candidates: PluginMcpOAuthCandidate[],
|
||||
): Promise<PluginMcpOAuthCandidate[]> {
|
||||
const p = await import("@clack/prompts");
|
||||
const action = await p.select({
|
||||
message: "Authorize plugin MCP servers now?",
|
||||
options: [
|
||||
{
|
||||
value: "all",
|
||||
label: "Authorize all",
|
||||
hint: "open browser authorization for each server",
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose servers",
|
||||
hint: "select which servers to authorize",
|
||||
},
|
||||
{
|
||||
value: "skip",
|
||||
label: "Skip",
|
||||
},
|
||||
],
|
||||
});
|
||||
if (p.isCancel(action) || action === "skip") {
|
||||
return [];
|
||||
}
|
||||
if (action === "all") {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
const selectedNames = await p.multiselect({
|
||||
message: "Select MCP servers to authorize",
|
||||
options: candidates.map((candidate) => ({
|
||||
value: candidate.name,
|
||||
label: candidate.name,
|
||||
hint: `${candidate.transportType} [${candidate.pluginName}]`,
|
||||
})),
|
||||
required: false,
|
||||
});
|
||||
if (p.isCancel(selectedNames) || !Array.isArray(selectedNames)) {
|
||||
return [];
|
||||
}
|
||||
const selected = new Set(selectedNames);
|
||||
return candidates.filter((candidate) => selected.has(candidate.name));
|
||||
}
|
||||
|
||||
async function authorizeMcpOAuthCandidate(
|
||||
candidate: PluginMcpOAuthCandidate,
|
||||
): Promise<void> {
|
||||
const { authorizeMcpServerOAuthWithBrowser } = await import(
|
||||
"../wizards/mcp/oauth"
|
||||
);
|
||||
await authorizeMcpServerOAuthWithBrowser(candidate.name, {
|
||||
throwOnError: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function runPluginMcpOAuthFollowup(
|
||||
candidates: PluginMcpOAuthCandidate[],
|
||||
options: PluginInstallCommandOptions,
|
||||
): Promise<void> {
|
||||
if (candidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInteractivePluginInstall(options)) {
|
||||
options.io?.writeln("Plugin MCP servers may require OAuth authorization:");
|
||||
for (const candidate of candidates) {
|
||||
options.io?.writeln(
|
||||
` ${candidate.name} (${candidate.transportType}, plugin: ${candidate.pluginName})`,
|
||||
);
|
||||
}
|
||||
options.io?.writeln(
|
||||
'Run "cline mcp" and choose "Authorize OAuth" to authorize them.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected =
|
||||
options.mcpOAuth?.selectCandidates !== undefined
|
||||
? await options.mcpOAuth.selectCandidates(candidates)
|
||||
: await selectMcpOAuthCandidatesWithClack(candidates);
|
||||
const authorize = options.mcpOAuth?.authorize ?? authorizeMcpOAuthCandidate;
|
||||
for (const candidate of selected) {
|
||||
try {
|
||||
await authorize(candidate);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(
|
||||
`Warning: failed to authorize MCP server ${candidate.name}: ${message}. Run "cline mcp" and choose "Authorize OAuth" to retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPluginInstallCommand(
|
||||
options: PluginInstallCommandOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await installPlugin(options);
|
||||
if (options.json) {
|
||||
process.stdout.write(
|
||||
JSON.stringify(serializePluginInstallResult(result)),
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
options.io?.writeln(`Installed plugin from ${result.source}`);
|
||||
options.io?.writeln(` Path: ${result.installPath}`);
|
||||
for (const failure of result.mcpSyncFailures) {
|
||||
options.io?.writeErr(
|
||||
`Warning: failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
|
||||
);
|
||||
}
|
||||
await runPluginMcpOAuthFollowup(result.mcpOAuthCandidates, options);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPluginUninstallCommand(
|
||||
options: PluginUninstallOptions & { json?: boolean; io?: PluginInstallIo },
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await uninstallPlugin(options);
|
||||
if (options.json) {
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
return 0;
|
||||
}
|
||||
options.io?.writeln(`Uninstalled plugin ${result.name}`);
|
||||
options.io?.writeln(` Removed: ${result.installPath}`);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSkillsArgs } from "./skill";
|
||||
|
||||
describe("buildSkillsArgs", () => {
|
||||
it("runs the skills package through npx with -y", () => {
|
||||
expect(buildSkillsArgs(["list"])).toEqual(["-y", "skills@latest", "list"]);
|
||||
});
|
||||
|
||||
it("injects --agent cline for install-style subcommands", () => {
|
||||
expect(buildSkillsArgs(["install", "owner/repo"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"add",
|
||||
"owner/repo",
|
||||
"--agent",
|
||||
"cline",
|
||||
]);
|
||||
expect(buildSkillsArgs(["add", "owner/repo"])).toContain("cline");
|
||||
expect(buildSkillsArgs(["i", "owner/repo"])).toContain("cline");
|
||||
expect(buildSkillsArgs(["update", "owner/repo"])).toContain("cline");
|
||||
});
|
||||
|
||||
it("aliases uninstall to the skills remove subcommand", () => {
|
||||
expect(buildSkillsArgs(["uninstall", "my-skill"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
"my-skill",
|
||||
"--agent",
|
||||
"cline",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not inject when the user already targeted an agent", () => {
|
||||
expect(
|
||||
buildSkillsArgs(["install", "owner/repo", "--agent", "cursor"]),
|
||||
).not.toContain("cline");
|
||||
expect(
|
||||
buildSkillsArgs(["install", "owner/repo", "-a", "cursor"]),
|
||||
).not.toContain("cline");
|
||||
expect(
|
||||
buildSkillsArgs(["install", "owner/repo", "--agent=cursor"]),
|
||||
).not.toContain("cline");
|
||||
});
|
||||
|
||||
it("aliases install and uninstall when agent options come before the subcommand", () => {
|
||||
expect(
|
||||
buildSkillsArgs(["--agent", "cursor", "install", "owner/repo"]),
|
||||
).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"--agent",
|
||||
"cursor",
|
||||
"add",
|
||||
"owner/repo",
|
||||
]);
|
||||
expect(
|
||||
buildSkillsArgs(["--agent=cursor", "uninstall", "my-skill"]),
|
||||
).toEqual(["-y", "skills@latest", "--agent=cursor", "remove", "my-skill"]);
|
||||
});
|
||||
|
||||
it("does not scope non-install subcommands to cline", () => {
|
||||
expect(buildSkillsArgs(["use", "owner/repo"])).not.toContain("--agent");
|
||||
expect(buildSkillsArgs(["list"])).not.toContain("--agent");
|
||||
});
|
||||
|
||||
it("scopes remove-style subcommands to cline", () => {
|
||||
expect(buildSkillsArgs(["remove"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
"--agent",
|
||||
"cline",
|
||||
]);
|
||||
expect(buildSkillsArgs(["rm", "my-skill"])).toContain("cline");
|
||||
expect(buildSkillsArgs(["r", "my-skill"])).toContain("cline");
|
||||
});
|
||||
|
||||
it("ignores leading flags when detecting the subcommand", () => {
|
||||
expect(buildSkillsArgs(["--global", "install", "owner/repo"])).toContain(
|
||||
"cline",
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards an empty arg list unchanged", () => {
|
||||
expect(buildSkillsArgs([])).toEqual(["-y", "skills@latest"]);
|
||||
});
|
||||
});
|
||||
@@ -1,160 +0,0 @@
|
||||
import { type SpawnOptions, spawn } from "node:child_process";
|
||||
|
||||
export interface SkillCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
// `cline skill` is a thin wrapper around the open skills CLI
|
||||
// (https://www.npmjs.com/package/skills). We run it through `npx` so users
|
||||
// don't need a separate global install. Pin the version here if we ever need to
|
||||
// lock behavior to a known-good release.
|
||||
const SKILLS_PACKAGE = "skills@latest";
|
||||
|
||||
// Subcommands that write skill files into an agent's skills directory. For a
|
||||
// `cline skill` command we default these to Cline unless the user picked their
|
||||
// own agent. `use` is intentionally excluded: without --agent it prints the
|
||||
// generated prompt to stdout, whereas adding --agent would launch that agent
|
||||
// interactively instead — not what someone scoping to Cline would expect.
|
||||
const CLINE_SCOPED_SUBCOMMANDS = new Set([
|
||||
"add",
|
||||
"install",
|
||||
"i",
|
||||
"update",
|
||||
"remove",
|
||||
"rm",
|
||||
"r",
|
||||
"uninstall",
|
||||
]);
|
||||
|
||||
const SKILLS_SUBCOMMAND_ALIASES = new Map([
|
||||
["install", "add"],
|
||||
["uninstall", "remove"],
|
||||
]);
|
||||
|
||||
function hasAgentFlag(args: readonly string[]): boolean {
|
||||
return args.some(
|
||||
(arg) => arg === "-a" || arg === "--agent" || arg.startsWith("--agent="),
|
||||
);
|
||||
}
|
||||
|
||||
function optionConsumesNextValue(arg: string): boolean {
|
||||
return arg === "-a" || arg === "--agent";
|
||||
}
|
||||
|
||||
function findSubcommandIndex(args: readonly string[]): number {
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index];
|
||||
if (arg.startsWith("-")) {
|
||||
if (optionConsumesNextValue(arg)) {
|
||||
index++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findSubcommand(args: readonly string[]): string | undefined {
|
||||
const index = findSubcommandIndex(args);
|
||||
return index >= 0 ? args[index] : undefined;
|
||||
}
|
||||
|
||||
function normalizeSkillsSubcommandAliases(args: string[]): void {
|
||||
const index = findSubcommandIndex(args);
|
||||
if (index < 0) return;
|
||||
const alias = SKILLS_SUBCOMMAND_ALIASES.get(args[index]);
|
||||
if (alias) {
|
||||
args[index] = alias;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the argument list passed to `npx`, injecting `--agent cline` for
|
||||
* install-style subcommands unless the user already targeted an agent.
|
||||
*/
|
||||
export function buildSkillsArgs(userArgs: readonly string[]): string[] {
|
||||
const args = [...userArgs];
|
||||
const subcommand = findSubcommand(args);
|
||||
normalizeSkillsSubcommandAliases(args);
|
||||
if (
|
||||
subcommand &&
|
||||
CLINE_SCOPED_SUBCOMMANDS.has(subcommand) &&
|
||||
!hasAgentFlag(args)
|
||||
) {
|
||||
args.push("--agent", "cline");
|
||||
}
|
||||
return ["-y", SKILLS_PACKAGE, ...args];
|
||||
}
|
||||
|
||||
function resolveExitCode(
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
): number {
|
||||
if (code !== null) {
|
||||
return code;
|
||||
}
|
||||
switch (signal) {
|
||||
case "SIGINT":
|
||||
return 130;
|
||||
case "SIGTERM":
|
||||
return 143;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward all arguments to the open skills CLI via `npx skills`.
|
||||
*
|
||||
* Returns the child process exit code, or 1 if npx is unavailable or fails to
|
||||
* spawn. stdio is inherited so the skills CLI's interactive prompts and output
|
||||
* pass straight through to the user's terminal.
|
||||
*/
|
||||
export async function runSkillCommand(
|
||||
userArgs: readonly string[],
|
||||
io: SkillCommandIo,
|
||||
): Promise<number> {
|
||||
const args = buildSkillsArgs(userArgs);
|
||||
const isWindows = process.platform === "win32";
|
||||
const options: SpawnOptions = {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
...(isWindows ? { shell: true } : {}),
|
||||
};
|
||||
|
||||
return new Promise<number>((resolve) => {
|
||||
const child = spawn("npx", args, options);
|
||||
|
||||
const forward = (signal: NodeJS.Signals) => {
|
||||
child.kill(signal);
|
||||
};
|
||||
const handleSigint = () => forward("SIGINT");
|
||||
const handleSigterm = () => forward("SIGTERM");
|
||||
process.on("SIGINT", handleSigint);
|
||||
process.on("SIGTERM", handleSigterm);
|
||||
const cleanup = () => {
|
||||
process.off("SIGINT", handleSigint);
|
||||
process.off("SIGTERM", handleSigterm);
|
||||
};
|
||||
|
||||
child.once("error", (error: NodeJS.ErrnoException) => {
|
||||
cleanup();
|
||||
if (error.code === "ENOENT") {
|
||||
io.writeErr(
|
||||
'npx was not found. Install Node.js (which includes npx) to use "cline skill".',
|
||||
);
|
||||
} else {
|
||||
io.writeErr(`Failed to run npx ${SKILLS_PACKAGE}: ${error.message}`);
|
||||
}
|
||||
resolve(1);
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
cleanup();
|
||||
resolve(resolveExitCode(code, signal));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,271 +0,0 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
autoUpdateOnStartup,
|
||||
checkForUpdates,
|
||||
getInstallationInfo,
|
||||
PackageManager,
|
||||
resolveCliHubOwnerContext,
|
||||
withMinimumReleaseAgeBypass,
|
||||
} from "./update";
|
||||
|
||||
const originalArgv = [...process.argv];
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
const originalDataDir = process.env.CLINE_DATA_DIR;
|
||||
const originalHubDiscoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
|
||||
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
const originalIsDev = process.env.IS_DEV;
|
||||
const originalNoAutoUpdate = process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createFile(path: string): string {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
writeFileSync(path, "");
|
||||
return path;
|
||||
}
|
||||
|
||||
function createTempFile(pathSuffix: string): string {
|
||||
const root = mkdtempSync(join(tmpdir(), "cline-update-test-"));
|
||||
tempDirs.push(root);
|
||||
return createFile(join(root, pathSuffix));
|
||||
}
|
||||
|
||||
describe("getInstallationInfo", () => {
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
|
||||
}
|
||||
if (originalGlobalSettingsPath === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
|
||||
}
|
||||
if (originalIsDev === undefined) {
|
||||
delete process.env.IS_DEV;
|
||||
} else {
|
||||
process.env.IS_DEV = originalIsDev;
|
||||
}
|
||||
if (originalNoAutoUpdate === undefined) {
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
} else {
|
||||
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("detects npm installs from the wrapper path passed to the compiled binary", () => {
|
||||
const wrapperPath = createTempFile("lib/node_modules/cline/bin/cline");
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
expect(getInstallationInfo("1.2.3")).toEqual({
|
||||
packageManager: PackageManager.NPM,
|
||||
packageName: "cline",
|
||||
updateCommand: "npm update -g cline --tag latest",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses the nightly tag when the current CLI version is nightly", () => {
|
||||
const wrapperPath = createTempFile("lib/node_modules/cline/bin/cline");
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
expect(getInstallationInfo("1.2.3-nightly.456")).toEqual({
|
||||
packageManager: PackageManager.NPM,
|
||||
packageName: "cline",
|
||||
updateCommand: "npm update -g cline --tag nightly",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects bun global installs from the resolved install path", () => {
|
||||
// bun symlinks ~/.bun/bin/cline -> ~/.bun/install/global/node_modules/...,
|
||||
// and realpathSync resolves through the symlink before detection runs.
|
||||
const wrapperPath = createTempFile(
|
||||
".bun/install/global/node_modules/cline/bin/cline",
|
||||
);
|
||||
process.env.CLINE_WRAPPER_PATH = wrapperPath;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
expect(getInstallationInfo("1.2.3")).toEqual({
|
||||
packageManager: PackageManager.BUN,
|
||||
packageName: "cline",
|
||||
updateCommand: "bun add -g cline@latest",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to unknown when only Bun's virtual compiled path is available", () => {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
process.argv = ["bun", "/$bunfs/root/cline", "update", "--verbose"];
|
||||
|
||||
expect(getInstallationInfo("1.2.3")).toEqual({
|
||||
packageManager: PackageManager.UNKNOWN,
|
||||
packageName: "cline",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto update settings", () => {
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
|
||||
}
|
||||
if (originalGlobalSettingsPath === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = originalGlobalSettingsPath;
|
||||
}
|
||||
if (originalIsDev === undefined) {
|
||||
delete process.env.IS_DEV;
|
||||
} else {
|
||||
process.env.IS_DEV = originalIsDev;
|
||||
}
|
||||
if (originalNoAutoUpdate === undefined) {
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
} else {
|
||||
process.env.CLINE_NO_AUTO_UPDATE = originalNoAutoUpdate;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips startup auto update when disabled globally", () => {
|
||||
const settingsPath = createTempFile("data/global-settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
|
||||
delete process.env.IS_DEV;
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, "fetch")
|
||||
.mockRejectedValue(new Error("should not fetch"));
|
||||
|
||||
autoUpdateOnStartup();
|
||||
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still lets manual update checks run when startup auto update is disabled", async () => {
|
||||
const settingsPath = createTempFile("data/global-settings.json");
|
||||
writeFileSync(settingsPath, JSON.stringify({ autoUpdateEnabled: false }));
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
|
||||
delete process.env.CLINE_NO_AUTO_UPDATE;
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ version: "0.0.0" }),
|
||||
} as Response);
|
||||
|
||||
await checkForUpdates({ includeKanban: false });
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hub restart owner selection", () => {
|
||||
afterEach(() => {
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the shared hub owner outside production builds", () => {
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-update-test-data";
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
|
||||
expect(owner.discoveryPath).toContain("/locks/hub/owners/");
|
||||
expect(owner.discoveryPath).not.toBe(
|
||||
"/tmp/cline-update-test-data/locks/hub/production.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withMinimumReleaseAgeBypass", () => {
|
||||
it("adds the package-manager-specific cooldown bypass", () => {
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"npm update -g cline --tag latest",
|
||||
PackageManager.NPM,
|
||||
).command,
|
||||
).toBe("npm update -g cline --tag latest --min-release-age=0");
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass("bun add -g cline@latest", PackageManager.BUN)
|
||||
.command,
|
||||
).toBe("bun add -g cline@latest --minimum-release-age=0");
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"yarn global add cline@latest",
|
||||
PackageManager.YARN,
|
||||
).command,
|
||||
).toBe("yarn global add cline@latest");
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"yarn global add cline@latest",
|
||||
PackageManager.YARN,
|
||||
).env?.YARN_NPM_MINIMAL_AGE_GATE,
|
||||
).toBe("0");
|
||||
|
||||
expect(
|
||||
withMinimumReleaseAgeBypass(
|
||||
"pnpm add -g cline@latest",
|
||||
PackageManager.PNPM,
|
||||
).env?.pnpm_config_minimum_release_age,
|
||||
).toBe("0");
|
||||
});
|
||||
});
|
||||
@@ -1,2 +0,0 @@
|
||||
export type { ConnectorCatalogEntry } from "@cline/shared";
|
||||
export { CONNECTOR_CATALOG, listConnectorCatalog } from "@cline/shared";
|
||||
@@ -1,163 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockGetLastUsedProviderSettings,
|
||||
mockGetProviderSettings,
|
||||
mockResolveSystemPrompt,
|
||||
mockGetProviderCollection,
|
||||
mockGetBooleanFlagEnabled,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetLastUsedProviderSettings: vi.fn(),
|
||||
mockGetProviderSettings: vi.fn(),
|
||||
mockResolveSystemPrompt: vi.fn(),
|
||||
mockGetProviderCollection: vi.fn(),
|
||||
mockGetBooleanFlagEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("@cline/core")>("@cline/core");
|
||||
return {
|
||||
...actual,
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings(options?: unknown) {
|
||||
return mockGetLastUsedProviderSettings(options);
|
||||
}
|
||||
|
||||
getProviderSettings(providerId: string) {
|
||||
return mockGetProviderSettings(providerId);
|
||||
}
|
||||
},
|
||||
CoreSessionService: class {},
|
||||
SqliteSessionStore: class {},
|
||||
Llms: {
|
||||
...actual.Llms,
|
||||
getProviderCollection: mockGetProviderCollection,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../runtime/prompt", () => ({
|
||||
resolveSystemPrompt: mockResolveSystemPrompt,
|
||||
}));
|
||||
|
||||
vi.mock("../utils/helpers", () => ({
|
||||
resolveWorkspaceRoot: vi.fn((cwd: string) => cwd),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: mockGetBooleanFlagEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../commands/auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../commands/auth")>(
|
||||
"../commands/auth",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
ensureOAuthProviderApiKey: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { buildConnectorStartRequest } from "./session-runtime";
|
||||
|
||||
describe("buildConnectorStartRequest", () => {
|
||||
beforeEach(() => {
|
||||
mockGetBooleanFlagEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
});
|
||||
|
||||
it("falls back to provider env vars when persisted settings have no api key", async () => {
|
||||
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "openrouter" });
|
||||
mockGetProviderSettings.mockReturnValue({
|
||||
provider: "openrouter",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
});
|
||||
mockGetProviderCollection.mockReturnValue({
|
||||
provider: { env: ["OPENROUTER_API_KEY"] },
|
||||
});
|
||||
mockResolveSystemPrompt.mockResolvedValue("system");
|
||||
process.env.OPENROUTER_API_KEY = "env-openrouter-key";
|
||||
|
||||
const request = await buildConnectorStartRequest({
|
||||
options: {
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
},
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("openrouter");
|
||||
expect(request.apiKey).toBe("env-openrouter-key");
|
||||
expect(request.model).toBe("anthropic/claude-sonnet-4.6");
|
||||
expect(mockGetLastUsedProviderSettings).toHaveBeenCalledWith({
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
|
||||
mockGetProviderSettings.mockReturnValue({
|
||||
provider: "cline-pass",
|
||||
auth: { accessToken: "workos:resolved-token" },
|
||||
});
|
||||
mockGetProviderCollection.mockReturnValue({
|
||||
provider: { env: ["CLINE_API_KEY"] },
|
||||
});
|
||||
mockResolveSystemPrompt.mockResolvedValue("system");
|
||||
|
||||
const request = await buildConnectorStartRequest({
|
||||
options: {
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
},
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.2",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.2");
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
|
||||
mockGetProviderSettings.mockReturnValue({
|
||||
provider: "cline-pass",
|
||||
auth: { accessToken: "workos:resolved-token" },
|
||||
});
|
||||
mockGetProviderCollection.mockReturnValue({
|
||||
provider: { env: ["CLINE_API_KEY"] },
|
||||
});
|
||||
mockResolveSystemPrompt.mockResolvedValue("system");
|
||||
|
||||
const request = await buildConnectorStartRequest({
|
||||
options: {
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
},
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.2",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.2");
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
// @jsxImportSource @opentui/react
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
|
||||
import open from "open";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { palette } from "../tui/palette";
|
||||
import { getCliSubscriptionUrl } from "../utils/cline-pass-errors";
|
||||
import type { CliMigrationNotice } from "./notice";
|
||||
|
||||
export function MigrationNoticeContent(
|
||||
props: ChoiceContext<boolean> & {
|
||||
notice: CliMigrationNotice;
|
||||
},
|
||||
) {
|
||||
const { dialogId, notice, resolve } = props;
|
||||
const subscriptionUrl = useMemo(() => getCliSubscriptionUrl(), []);
|
||||
const [status, setStatus] = useState<string | undefined>();
|
||||
|
||||
const openSubscriptionPage = useCallback(() => {
|
||||
setStatus("Opening ClinePass in your browser...");
|
||||
void open(subscriptionUrl, { wait: false })
|
||||
.then(() => {
|
||||
setStatus("Opened ClinePass in your browser.");
|
||||
})
|
||||
.catch(() => {
|
||||
setStatus(
|
||||
"Could not open the browser automatically. Use the URL below.",
|
||||
);
|
||||
});
|
||||
}, [subscriptionUrl]);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
if (key.name === "return" || key.name === "enter") {
|
||||
openSubscriptionPage();
|
||||
}
|
||||
}, dialogId);
|
||||
|
||||
return (
|
||||
<box flexDirection="column" paddingX={1} gap={1}>
|
||||
<text fg={palette.act}>{notice.title}</text>
|
||||
<box flexDirection="column">
|
||||
<text selectable>
|
||||
ClinePass is a $9.99/month subscription plan to get access to the
|
||||
latest open-weight coding models with enough quota for day-to-day
|
||||
work, at a much lower cost than paying API costs directly.
|
||||
</text>
|
||||
<text selectable>Try it now with a limited-time promo for $1.99.</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg={palette.act} selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<box paddingX={1} backgroundColor={palette.act}>
|
||||
<text fg={palette.textOnSelection}>Open ClinePass</text>
|
||||
</box>
|
||||
</box>
|
||||
{status && <text fg={palette.muted}>{status}</text>}
|
||||
<text fg={palette.muted}>Press Enter to open, Esc to close</text>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getClineCliMigrationNotice,
|
||||
markClineCliMigrationNoticeShown,
|
||||
resolveCliNoticeStatePath,
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider,
|
||||
} from "./notice";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
function createTempDataDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-cli-notice-"));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
describe("migration notice", () => {
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the notice for a fresh data dir", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
expect(getClineCliMigrationNotice(dataDir)?.title).toBe("Try ClinePass");
|
||||
});
|
||||
|
||||
it("shows when only the old Kanban notice was marked as shown", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
const noticePath = resolveCliNoticeStatePath(dataDir);
|
||||
mkdirSync(dirname(noticePath), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(
|
||||
noticePath,
|
||||
`${JSON.stringify(
|
||||
{ shown: { "cline-cli-tui-default": true } },
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
expect(getClineCliMigrationNotice(dataDir)?.id).toBe(
|
||||
"cline-cli-cline-pass-intro",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not show after the notice is marked as shown", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
markClineCliMigrationNoticeShown(dataDir);
|
||||
|
||||
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows after the notice is marked as shown when forced", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
markClineCliMigrationNoticeShown(dataDir);
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(dataDir, {
|
||||
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
|
||||
}),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not show when disabled through the environment", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(dataDir, {
|
||||
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not show when ClinePass is already the active provider", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(
|
||||
dataDir,
|
||||
{},
|
||||
{ activeProviderId: "cline-pass" },
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("suppresses the active ClinePass provider even when the provider id has surrounding whitespace", () => {
|
||||
expect(
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider(" cline-pass "),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not suppress the active ClinePass provider when forced", () => {
|
||||
expect(
|
||||
shouldSuppressClineCliMigrationNoticeForActiveProvider("cline-pass", {
|
||||
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("shows for the active ClinePass provider when forced", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(
|
||||
dataDir,
|
||||
{ CLINE_FORCE_CLINE_PASS_NOTICE: "1" },
|
||||
{ activeProviderId: "cline-pass" },
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("shows when forced even if disabled through the environment", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
expect(
|
||||
getClineCliMigrationNotice(dataDir, {
|
||||
CLINE_DISABLE_CLINE_PASS_NOTICE: "1",
|
||||
CLINE_FORCE_CLINE_PASS_NOTICE: "1",
|
||||
}),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it("marks the notice as shown", () => {
|
||||
const dataDir = createTempDataDir();
|
||||
|
||||
markClineCliMigrationNoticeShown(dataDir);
|
||||
|
||||
const rawState = readFileSync(resolveCliNoticeStatePath(dataDir), "utf8");
|
||||
expect(rawState).toContain("cline-cli-cline-pass-intro");
|
||||
expect(getClineCliMigrationNotice(dataDir)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,273 +0,0 @@
|
||||
import { createTool } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { resolveSystemPrompt } from "../prompt";
|
||||
import {
|
||||
ACT_MODE_CONTINUATION_PROMPT,
|
||||
type AppliedModeChange,
|
||||
applyInteractiveModeConfig,
|
||||
createInteractiveModeSwitchTool,
|
||||
createModeSwitchNoticeTracker,
|
||||
type PendingModeChange,
|
||||
sendTurnWithActModeContinuation,
|
||||
} from "./mode";
|
||||
|
||||
vi.mock("../prompt", () => ({
|
||||
resolveSystemPrompt: vi.fn(async (input: { mode?: string }) => {
|
||||
return `system prompt for ${input.mode ?? "unknown"}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
function makeConfig(): Config {
|
||||
return {
|
||||
apiKey: "",
|
||||
providerId: "cline",
|
||||
modelId: "openai/gpt-5.3-codex",
|
||||
verbose: false,
|
||||
sandbox: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
mode: "act",
|
||||
systemPrompt: "",
|
||||
enableTools: true,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: true,
|
||||
defaultToolAutoApprove: false,
|
||||
toolPolicies: {},
|
||||
cwd: process.cwd(),
|
||||
};
|
||||
}
|
||||
|
||||
const switchToActModeTool = createTool({
|
||||
name: "switch_to_act_mode",
|
||||
description: "Switch to act mode",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
execute: async () => "ok",
|
||||
});
|
||||
|
||||
describe("createInteractiveModeSwitchTool", () => {
|
||||
function makeSwitchTool(config: Config) {
|
||||
const pendingModeChange: PendingModeChange = {
|
||||
current: null,
|
||||
source: null,
|
||||
};
|
||||
const tuiModeChanged: {
|
||||
current: ((mode: "plan" | "act") => void) | null;
|
||||
} = { current: vi.fn() };
|
||||
const tool = createInteractiveModeSwitchTool({
|
||||
config,
|
||||
pendingModeChange,
|
||||
tuiModeChanged,
|
||||
});
|
||||
return { tool, pendingModeChange, tuiModeChanged };
|
||||
}
|
||||
|
||||
const toolContext = {
|
||||
agentId: "agent-1",
|
||||
iteration: 0,
|
||||
} as const;
|
||||
|
||||
it("completes the run so the model never continues with plan-mode tools", () => {
|
||||
const config = makeConfig();
|
||||
config.mode = "plan";
|
||||
const { tool } = makeSwitchTool(config);
|
||||
|
||||
// The act-mode tool set only exists after the session rebuild, which
|
||||
// happens between runs; without completesRun the model keeps working
|
||||
// with stale plan-mode tools after being told the switch succeeded.
|
||||
expect(tool.lifecycle?.completesRun).toBe(true);
|
||||
});
|
||||
|
||||
it("queues a tool-sourced mode change and notifies the TUI", async () => {
|
||||
const config = makeConfig();
|
||||
config.mode = "plan";
|
||||
const { tool, pendingModeChange, tuiModeChanged } = makeSwitchTool(config);
|
||||
|
||||
const result = await tool.execute({}, toolContext);
|
||||
|
||||
expect(pendingModeChange).toEqual({ current: "act", source: "tool" });
|
||||
expect(tuiModeChanged.current).toHaveBeenCalledWith("act");
|
||||
expect(result).toContain("successfully switched to act mode");
|
||||
});
|
||||
|
||||
it("errors instead of completing the run when already in act mode", async () => {
|
||||
const config = makeConfig();
|
||||
config.mode = "act";
|
||||
const { tool, pendingModeChange } = makeSwitchTool(config);
|
||||
|
||||
// A successful result would end the run via completesRun even though
|
||||
// nothing changed, so the no-op case must surface as a tool error.
|
||||
await expect(tool.execute({}, toolContext)).rejects.toThrow(
|
||||
"Already in act mode.",
|
||||
);
|
||||
expect(pendingModeChange.current).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("sendTurnWithActModeContinuation", () => {
|
||||
type TurnResult = { finishReason: string; iterations: number };
|
||||
|
||||
function makeHarness(input: {
|
||||
initial: TurnResult | undefined;
|
||||
continuation?: TurnResult | undefined;
|
||||
modeChanges: Array<AppliedModeChange | undefined>;
|
||||
}) {
|
||||
const applied = [...input.modeChanges];
|
||||
const sendContinuationTurn = vi.fn(async () => input.continuation);
|
||||
return {
|
||||
sendContinuationTurn,
|
||||
run: () =>
|
||||
sendTurnWithActModeContinuation<TurnResult>({
|
||||
sendInitialTurn: async () => input.initial,
|
||||
sendContinuationTurn,
|
||||
applyPendingModeChange: async () => applied.shift(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
it("continues the plan after a tool-initiated switch completes the run", async () => {
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
continuation: { finishReason: "completed", iterations: 3 },
|
||||
modeChanges: [{ mode: "act", source: "tool" }, undefined],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).toHaveBeenCalledWith(
|
||||
ACT_MODE_CONTINUATION_PROMPT,
|
||||
);
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 5 });
|
||||
});
|
||||
|
||||
it("does not continue after a UI-initiated mode change", async () => {
|
||||
// A Tab toggle can race a natural turn completion; a "ui" source must
|
||||
// never start executing a plan the user did not approve.
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
modeChanges: [{ mode: "act", source: "ui" }],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
|
||||
});
|
||||
|
||||
it("does not continue when the switch turn was aborted", async () => {
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "aborted", iterations: 1 },
|
||||
modeChanges: [{ mode: "act", source: "tool" }],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ finishReason: "aborted", iterations: 1 });
|
||||
});
|
||||
|
||||
it("does not continue when no mode change was pending", async () => {
|
||||
const { run, sendContinuationTurn } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
modeChanges: [undefined],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(sendContinuationTurn).not.toHaveBeenCalled();
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
|
||||
});
|
||||
|
||||
it("returns the switch turn result when the continuation yields nothing", async () => {
|
||||
const { run } = makeHarness({
|
||||
initial: { finishReason: "completed", iterations: 2 },
|
||||
continuation: undefined,
|
||||
modeChanges: [{ mode: "act", source: "tool" }, undefined],
|
||||
});
|
||||
|
||||
const result = await run();
|
||||
|
||||
expect(result).toEqual({ finishReason: "completed", iterations: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createModeSwitchNoticeTracker", () => {
|
||||
it("records a switch and clears it on consume", () => {
|
||||
const tracker = createModeSwitchNoticeTracker();
|
||||
|
||||
tracker.record("act", "plan");
|
||||
|
||||
expect(tracker.consume()).toEqual({ from: "act", to: "plan" });
|
||||
expect(tracker.consume()).toBeNull();
|
||||
});
|
||||
|
||||
it("cancels a round trip that returns to the mode the model last saw", () => {
|
||||
const tracker = createModeSwitchNoticeTracker();
|
||||
|
||||
tracker.record("act", "plan");
|
||||
tracker.record("plan", "act");
|
||||
|
||||
expect(tracker.consume()).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the original starting mode across chained switches", () => {
|
||||
const tracker = createModeSwitchNoticeTracker();
|
||||
|
||||
tracker.record("act", "plan");
|
||||
tracker.record("plan", "act");
|
||||
tracker.record("act", "plan");
|
||||
|
||||
expect(tracker.consume()).toEqual({ from: "act", to: "plan" });
|
||||
});
|
||||
|
||||
it("ignores a no-op switch", () => {
|
||||
const tracker = createModeSwitchNoticeTracker();
|
||||
|
||||
tracker.record("plan", "plan");
|
||||
|
||||
expect(tracker.consume()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyInteractiveModeConfig", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(resolveSystemPrompt).mockClear();
|
||||
});
|
||||
|
||||
it("adds the mode switch tool when entering plan mode", async () => {
|
||||
const config = makeConfig();
|
||||
|
||||
await applyInteractiveModeConfig({
|
||||
config,
|
||||
mode: "plan",
|
||||
switchToActModeTool,
|
||||
});
|
||||
|
||||
expect(config.mode).toBe("plan");
|
||||
expect(config.extraTools).toEqual([switchToActModeTool]);
|
||||
expect(config.systemPrompt).toBe("system prompt for plan");
|
||||
expect(resolveSystemPrompt).toHaveBeenCalledWith({
|
||||
cwd: config.cwd,
|
||||
providerId: config.providerId,
|
||||
mode: "plan",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes the mode switch tool when entering act mode", async () => {
|
||||
const config = makeConfig();
|
||||
config.extraTools = [switchToActModeTool];
|
||||
|
||||
await applyInteractiveModeConfig({
|
||||
config,
|
||||
mode: "act",
|
||||
switchToActModeTool,
|
||||
});
|
||||
|
||||
expect(config.mode).toBe("act");
|
||||
expect(config.extraTools).toEqual([]);
|
||||
expect(config.systemPrompt).toBe("system prompt for act");
|
||||
});
|
||||
});
|
||||
@@ -1,131 +0,0 @@
|
||||
import { createTool } from "@cline/shared";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { resolveSystemPrompt } from "../prompt";
|
||||
|
||||
export type InteractiveUiMode = "plan" | "act";
|
||||
|
||||
/**
|
||||
* Pending mode change plus who requested it. The switch_to_act_mode tool and
|
||||
* the TUI mode toggle share this slot, but only a tool-initiated switch means
|
||||
* "the user approved the plan" -- a UI toggle that lands as a turn finishes
|
||||
* must not trigger plan execution.
|
||||
*/
|
||||
export type PendingModeChange = {
|
||||
current: InteractiveUiMode | null;
|
||||
source: "tool" | "ui" | null;
|
||||
};
|
||||
|
||||
export type AppliedModeChange = {
|
||||
mode: InteractiveUiMode;
|
||||
source: "tool" | "ui";
|
||||
};
|
||||
|
||||
/**
|
||||
* Canned prompt that drives the auto-continue turn after the model calls
|
||||
* switch_to_act_mode. It is a synthetic user message, so transcript hydration
|
||||
* filters it out of the chat display.
|
||||
*/
|
||||
export const ACT_MODE_CONTINUATION_PROMPT =
|
||||
"The user approved switching to act mode. Continue with the approved plan now.";
|
||||
|
||||
export function createInteractiveModeSwitchTool(input: {
|
||||
config: Config;
|
||||
pendingModeChange: PendingModeChange;
|
||||
tuiModeChanged: { current: ((mode: InteractiveUiMode) => void) | null };
|
||||
}) {
|
||||
return createTool({
|
||||
name: "switch_to_act_mode",
|
||||
description:
|
||||
"Switch from plan mode to act mode. Switching to act mode immediately starts executing the plan, so only call this after the user has explicitly approved the plan in a message sent AFTER you presented it (e.g. 'looks good', 'go ahead', 'switch to act mode'). " +
|
||||
"Never call this in the same turn you present a plan, never call it proactively, and never treat the original task request as approval.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
timeoutMs: 5000,
|
||||
retryable: false,
|
||||
maxRetries: 0,
|
||||
// The act-mode tools only exist after the session is rebuilt with the
|
||||
// new mode config, which can't happen mid-run. End the run right after
|
||||
// the tool result so the model never keeps working with plan-mode tools
|
||||
// it was just told it no longer has; run-interactive applies the pending
|
||||
// change and auto-continues on the rebuilt session.
|
||||
lifecycle: {
|
||||
completesRun: true,
|
||||
},
|
||||
execute: async () => {
|
||||
if (input.config.mode === "act") {
|
||||
// Throw instead of returning: a successful result would end the
|
||||
// run via completesRun even though nothing changed.
|
||||
throw new Error("Already in act mode.");
|
||||
}
|
||||
input.pendingModeChange.current = "act";
|
||||
input.pendingModeChange.source = "tool";
|
||||
input.tuiModeChanged.current?.("act");
|
||||
return "You successfully switched to act mode, proceed with the plan. You now have access to editing files and running commands. (The switch_to_act_mode tool is only available in plan mode.)";
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one interactive turn, and when the model ended it by calling
|
||||
* switch_to_act_mode, continues the approved plan on the rebuilt act-mode
|
||||
* session instead of waiting for the user to prompt again.
|
||||
*
|
||||
* The continuation only fires for a tool-initiated switch on a turn that
|
||||
* finished "completed": a UI toggle mid-run aborts the turn, and even if the
|
||||
* toggle races a natural completion its source is "ui", so the user's Tab
|
||||
* press can never start executing a plan they did not approve.
|
||||
*/
|
||||
export async function sendTurnWithActModeContinuation<
|
||||
T extends { finishReason: string; iterations: number },
|
||||
>(input: {
|
||||
sendInitialTurn: () => Promise<T | undefined>;
|
||||
sendContinuationTurn: (prompt: string) => Promise<T | undefined>;
|
||||
applyPendingModeChange: () => Promise<AppliedModeChange | undefined>;
|
||||
}): Promise<T | undefined> {
|
||||
const result = await input.sendInitialTurn();
|
||||
const switched = await input.applyPendingModeChange();
|
||||
if (
|
||||
switched?.mode !== "act" ||
|
||||
switched.source !== "tool" ||
|
||||
result?.finishReason !== "completed"
|
||||
) {
|
||||
return result;
|
||||
}
|
||||
const continuation = await input.sendContinuationTurn(
|
||||
ACT_MODE_CONTINUATION_PROMPT,
|
||||
);
|
||||
// Honor a mode toggle made while the continuation was running.
|
||||
await input.applyPendingModeChange();
|
||||
if (!continuation) {
|
||||
return result;
|
||||
}
|
||||
return {
|
||||
...continuation,
|
||||
iterations: result.iterations + continuation.iterations,
|
||||
};
|
||||
}
|
||||
|
||||
// The tracker moved to @cline/shared so the VSCode extension can share the
|
||||
// exact round-trip-cancelling semantics; re-exported here to keep the CLI's
|
||||
// import surface stable.
|
||||
export {
|
||||
createModeSwitchNoticeTracker,
|
||||
type ModeSwitchNotice,
|
||||
} from "@cline/shared";
|
||||
|
||||
export async function applyInteractiveModeConfig(input: {
|
||||
config: Config;
|
||||
mode: InteractiveUiMode;
|
||||
switchToActModeTool: NonNullable<Config["extraTools"]>[number];
|
||||
}): Promise<void> {
|
||||
input.config.mode = input.mode;
|
||||
input.config.extraTools =
|
||||
input.mode === "plan" ? [input.switchToActModeTool] : [];
|
||||
input.config.systemPrompt = await resolveSystemPrompt({
|
||||
cwd: input.config.cwd,
|
||||
providerId: input.config.providerId,
|
||||
mode: input.mode,
|
||||
});
|
||||
}
|
||||
@@ -1,976 +0,0 @@
|
||||
import {
|
||||
createSessionCompactionState,
|
||||
type ProviderSettingsManager,
|
||||
type SessionManifest,
|
||||
SessionNotFoundError,
|
||||
SessionSource,
|
||||
type ToolApprovalRequest,
|
||||
type ToolApprovalResult,
|
||||
} from "@cline/core";
|
||||
import type { AgentTool, Message } from "@cline/shared";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChatCommandState } from "../../utils/chat-commands";
|
||||
import type { Config } from "../../utils/types";
|
||||
|
||||
const createCliCoreMock = vi.hoisted(() => vi.fn());
|
||||
const compactInteractiveMessagesMock = vi.hoisted(() => vi.fn());
|
||||
const createRuntimeHooksMock = vi.hoisted(() => vi.fn());
|
||||
const setActiveCliSessionMock = vi.hoisted(() => vi.fn());
|
||||
const loadInteractiveResumeMessagesMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeToAgentEventsMock = vi.hoisted(() => vi.fn());
|
||||
const subscribeToPendingPromptEventsMock = vi.hoisted(() => vi.fn());
|
||||
const markAbortInProgressMock = vi.hoisted(() => vi.fn());
|
||||
const submitAndExitInTerminalMock = vi.hoisted(() => vi.fn());
|
||||
const createInteractiveExitSummaryMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../session/session", () => ({
|
||||
createCliCore: createCliCoreMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/approval", () => ({
|
||||
submitAndExitInTerminal: submitAndExitInTerminalMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/hooks", () => ({
|
||||
createRuntimeHooks: createRuntimeHooksMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/output", () => ({
|
||||
setActiveCliSession: setActiveCliSessionMock,
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/resume", () => ({
|
||||
loadInteractiveResumeMessages: loadInteractiveResumeMessagesMock,
|
||||
}));
|
||||
|
||||
vi.mock("../active-runtime", () => ({
|
||||
markAbortInProgress: markAbortInProgressMock,
|
||||
}));
|
||||
|
||||
vi.mock("../session-events", () => ({
|
||||
subscribeToAgentEvents: subscribeToAgentEventsMock,
|
||||
subscribeToPendingPromptEvents: subscribeToPendingPromptEventsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./compaction", () => ({
|
||||
compactInteractiveMessages: compactInteractiveMessagesMock,
|
||||
}));
|
||||
|
||||
vi.mock("./exit-summary", () => ({
|
||||
createInteractiveExitSummary: createInteractiveExitSummaryMock,
|
||||
}));
|
||||
|
||||
function createConfig(): Config {
|
||||
return {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-test",
|
||||
apiKey: "",
|
||||
cwd: "/tmp/project",
|
||||
workspaceRoot: "/tmp/project",
|
||||
systemPrompt: "system",
|
||||
mode: "act",
|
||||
enableTools: true,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: true,
|
||||
verbose: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
sandbox: false,
|
||||
defaultToolAutoApprove: true,
|
||||
toolPolicies: {
|
||||
"*": { autoApprove: true },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createChatCommandState(config = createConfig()): ChatCommandState {
|
||||
return {
|
||||
enableTools: config.enableTools,
|
||||
autoApproveTools: config.defaultToolAutoApprove,
|
||||
cwd: config.cwd,
|
||||
workspaceRoot: config.workspaceRoot?.trim() || config.cwd,
|
||||
};
|
||||
}
|
||||
|
||||
function createProviderSettingsManager(): ProviderSettingsManager {
|
||||
return {
|
||||
getProviderSettings: vi.fn().mockReturnValue(undefined),
|
||||
} as unknown as ProviderSettingsManager;
|
||||
}
|
||||
|
||||
function createManifest(sessionId: string): SessionManifest {
|
||||
return {
|
||||
version: 1,
|
||||
session_id: sessionId,
|
||||
source: SessionSource.CLI,
|
||||
pid: 1,
|
||||
started_at: "2026-01-01T00:00:00.000Z",
|
||||
status: "running",
|
||||
interactive: true,
|
||||
provider: "anthropic",
|
||||
model: "claude-test",
|
||||
cwd: "/tmp/project",
|
||||
workspace_root: "/tmp/project",
|
||||
enable_tools: true,
|
||||
enable_spawn: true,
|
||||
enable_teams: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function importRuntime() {
|
||||
return await import("./session-runtime");
|
||||
}
|
||||
|
||||
function makeSwitchToActModeTool(): AgentTool {
|
||||
return {
|
||||
name: "switch_to_act_mode",
|
||||
description: "Switch to act mode",
|
||||
inputSchema: { type: "object", properties: {} },
|
||||
execute: () => ({ ok: true }),
|
||||
};
|
||||
}
|
||||
|
||||
function makeManager() {
|
||||
let startCount = 0;
|
||||
const start = vi.fn(async (_input?: unknown) => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
});
|
||||
return {
|
||||
start,
|
||||
stop: vi.fn(async () => {}),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
abort: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
get: vi.fn(),
|
||||
readMessages: vi.fn(async (): Promise<Message[]> => []),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
readTranscript: vi.fn(),
|
||||
ingestHookEvent: vi.fn(),
|
||||
subscribe: vi.fn(),
|
||||
updateSessionModel: vi.fn(),
|
||||
updateSessionConnection: vi.fn(async () => {}),
|
||||
pendingPrompts: {
|
||||
update: vi.fn(),
|
||||
},
|
||||
restore: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeTurnResult() {
|
||||
return {
|
||||
text: "ok",
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "completed" as const,
|
||||
model: { id: "claude-test", provider: "anthropic" },
|
||||
startedAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
endedAt: new Date("2026-01-01T00:00:00.100Z"),
|
||||
durationMs: 100,
|
||||
};
|
||||
}
|
||||
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
async function makeRuntime(
|
||||
manager: ReturnType<typeof makeManager>,
|
||||
options: {
|
||||
config?: Config;
|
||||
resumeSessionId?: string;
|
||||
resolveToolPolicy?: (toolName: string) => Config["toolPolicies"][string];
|
||||
} = {},
|
||||
) {
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const config = options.config ?? createConfig();
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
return createInteractiveSessionRuntime({
|
||||
config,
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
resumeSessionId: options.resumeSessionId,
|
||||
chatCommandState: createChatCommandState(config),
|
||||
requestToolApproval: async (
|
||||
_request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> => ({ approved: true }),
|
||||
resolveToolPolicy:
|
||||
options.resolveToolPolicy ?? (() => ({ autoApprove: true })),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: makeSwitchToActModeTool(),
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("createInteractiveSessionRuntime", () => {
|
||||
beforeEach(() => {
|
||||
createCliCoreMock.mockReset();
|
||||
compactInteractiveMessagesMock.mockReset();
|
||||
createRuntimeHooksMock.mockReset();
|
||||
setActiveCliSessionMock.mockReset();
|
||||
loadInteractiveResumeMessagesMock.mockReset();
|
||||
subscribeToAgentEventsMock.mockReset();
|
||||
subscribeToPendingPromptEventsMock.mockReset();
|
||||
markAbortInProgressMock.mockReset();
|
||||
submitAndExitInTerminalMock.mockReset();
|
||||
createInteractiveExitSummaryMock.mockReset();
|
||||
createRuntimeHooksMock.mockReturnValue({
|
||||
hooks: undefined,
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
loadInteractiveResumeMessagesMock.mockResolvedValue([]);
|
||||
subscribeToAgentEventsMock.mockReturnValue(() => {});
|
||||
subscribeToPendingPromptEventsMock.mockReturnValue(() => {});
|
||||
});
|
||||
|
||||
it("manual compact updates the active session sidecar without restarting", async () => {
|
||||
const sessionId = "sess-active";
|
||||
const messages = [
|
||||
{ id: "u1", role: "user" as const, content: "hello" },
|
||||
{ id: "a1", role: "assistant" as const, content: "world" },
|
||||
];
|
||||
const compactionState = createSessionCompactionState({
|
||||
sourceMessages: messages,
|
||||
compactedMessages: [
|
||||
{ id: "summary", role: "user" as const, content: "summary" },
|
||||
],
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const manager = {
|
||||
start: vi.fn().mockResolvedValue({
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: "/tmp/session.json",
|
||||
messagesPath: "/tmp/session.messages.json",
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue(messages),
|
||||
updateSessionCompactionState: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
compactInteractiveMessagesMock.mockResolvedValue({
|
||||
compacted: true,
|
||||
canonicalMessages: messages,
|
||||
compactionState,
|
||||
});
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
const result = await runtime.compactCurrentSession();
|
||||
|
||||
expect(result).toEqual({
|
||||
messagesBefore: messages.length,
|
||||
messagesAfter: messages.length,
|
||||
workingContextMessagesAfter: compactionState.messages.length,
|
||||
compacted: true,
|
||||
});
|
||||
expect(manager.start).toHaveBeenCalledTimes(1);
|
||||
expect(manager.stop).not.toHaveBeenCalled();
|
||||
expect(manager.readMessages).toHaveBeenCalledWith(sessionId);
|
||||
expect(compactInteractiveMessagesMock).toHaveBeenCalledWith({
|
||||
config: expect.objectContaining({
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-test",
|
||||
}),
|
||||
providerSettingsManager: expect.objectContaining({
|
||||
getProviderSettings: expect.any(Function),
|
||||
}),
|
||||
sessionId,
|
||||
messages,
|
||||
abortSignal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(manager.updateSessionCompactionState).toHaveBeenCalledWith(
|
||||
sessionId,
|
||||
compactionState,
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe(sessionId);
|
||||
});
|
||||
|
||||
it("rejects manual compact while the active session is running", async () => {
|
||||
const sessionId = "sess-running";
|
||||
const messages = [{ role: "user" as const, content: "hello" }];
|
||||
const manager = {
|
||||
start: vi.fn().mockResolvedValue({
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: "/tmp/session.json",
|
||||
messagesPath: "/tmp/session.messages.json",
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue(messages),
|
||||
updateSessionCompactionState: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn().mockResolvedValue({
|
||||
sessionId,
|
||||
status: "running",
|
||||
}),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
await expect(runtime.compactCurrentSession()).rejects.toThrow(
|
||||
"Cannot compact while the current turn is running",
|
||||
);
|
||||
expect(manager.readMessages).toHaveBeenCalledWith(sessionId);
|
||||
expect(compactInteractiveMessagesMock).not.toHaveBeenCalled();
|
||||
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects manual compact when compaction is disabled", async () => {
|
||||
const manager = makeManager();
|
||||
const config = createConfig();
|
||||
config.compaction = { enabled: false };
|
||||
const runtime = await makeRuntime(manager, { config });
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
await expect(runtime.compactCurrentSession()).rejects.toThrow(
|
||||
"compaction is off",
|
||||
);
|
||||
expect(compactInteractiveMessagesMock).not.toHaveBeenCalled();
|
||||
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("carries compacted working context across mode-switch restarts", async () => {
|
||||
const firstSessionId = "sess-mode-before";
|
||||
const secondSessionId = "sess-mode-after";
|
||||
const prefixMessage = {
|
||||
id: "u1",
|
||||
role: "user" as const,
|
||||
content: "large original",
|
||||
};
|
||||
const tailMessage = {
|
||||
id: "u2",
|
||||
role: "user" as const,
|
||||
content: "new canonical tail",
|
||||
};
|
||||
const messages = [prefixMessage, tailMessage];
|
||||
const summaryMessage = {
|
||||
id: "summary",
|
||||
role: "user" as const,
|
||||
content: "summary",
|
||||
};
|
||||
const compactionState = createSessionCompactionState({
|
||||
sourceMessages: [prefixMessage],
|
||||
compactedMessages: [summaryMessage],
|
||||
conversationId: firstSessionId,
|
||||
systemPrompt: "compacted system",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
});
|
||||
const manager = {
|
||||
start: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: firstSessionId,
|
||||
manifest: createManifest(firstSessionId),
|
||||
manifestPath: "/tmp/session-before.json",
|
||||
messagesPath: "/tmp/session-before.messages.json",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
sessionId: secondSessionId,
|
||||
manifest: createManifest(secondSessionId),
|
||||
manifestPath: "/tmp/session-after.json",
|
||||
messagesPath: "/tmp/session-after.messages.json",
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue(messages),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(compactionState),
|
||||
updateSessionCompactionState: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ updated: true }),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.applyMode("plan");
|
||||
|
||||
expect(manager.readMessages).toHaveBeenCalledWith(firstSessionId);
|
||||
expect(manager.readSessionCompactionState).toHaveBeenCalledWith(
|
||||
firstSessionId,
|
||||
);
|
||||
expect(manager.stop).toHaveBeenCalledWith(firstSessionId);
|
||||
const restartInput = manager.start.mock.calls[1]?.[0];
|
||||
expect(restartInput).toMatchObject({
|
||||
initialMessages: messages,
|
||||
initialCompactionState: expect.objectContaining({
|
||||
source_message_count: messages.length,
|
||||
messages: [summaryMessage, tailMessage],
|
||||
system_prompt: "compacted system",
|
||||
}),
|
||||
});
|
||||
expect(restartInput.initialCompactionState).not.toHaveProperty(
|
||||
"conversation_id",
|
||||
);
|
||||
expect(manager.updateSessionCompactionState).not.toHaveBeenCalled();
|
||||
expect(runtime.getActiveSessionId()).toBe(secondSessionId);
|
||||
});
|
||||
|
||||
it("defers creating the replacement session after a new-session reset", async () => {
|
||||
let startCount = 0;
|
||||
const manager = {
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
|
||||
await runtime.resetForNewSession();
|
||||
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledOnce();
|
||||
expect(runtime.getActiveSessionId()).toBe("");
|
||||
expect(setActiveCliSessionMock).toHaveBeenLastCalledWith(undefined);
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("holds concurrent ensureReady during a restart instead of booting an empty session", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
|
||||
// Keep the replacement session's start in flight so the restart window
|
||||
// (old session stopped, no active session yet) stays open.
|
||||
const gate = deferred<void>();
|
||||
manager.start.mockImplementationOnce(async () => {
|
||||
await gate.promise;
|
||||
return {
|
||||
sessionId: "session-restarted",
|
||||
manifest: createManifest("session-restarted"),
|
||||
manifestPath: "/tmp/session-restarted.json",
|
||||
messagesPath: "/tmp/session-restarted.messages.json",
|
||||
};
|
||||
});
|
||||
|
||||
const restart = runtime.restartWithCurrentMessages();
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
// A message submitted mid-restart (e.g. right after a plan/act toggle)
|
||||
// calls ensureReady; it must wait for the restart instead of booting a
|
||||
// blank session that races the replacement for the active slot.
|
||||
const ready = runtime.ensureReady();
|
||||
gate.resolve();
|
||||
await Promise.all([restart, ready]);
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-restarted");
|
||||
});
|
||||
|
||||
it("adds a live interactive approval policy hook to started sessions", async () => {
|
||||
const manager = makeManager();
|
||||
const upstreamBeforeTool = vi.fn(async () => ({
|
||||
input: { text: "updated" },
|
||||
}));
|
||||
createRuntimeHooksMock.mockReturnValueOnce({
|
||||
hooks: {
|
||||
beforeTool: upstreamBeforeTool,
|
||||
},
|
||||
shutdown: vi.fn(async () => {}),
|
||||
});
|
||||
const runtime = await makeRuntime(manager, {
|
||||
resolveToolPolicy: (toolName) => ({
|
||||
autoApprove: toolName === "echo",
|
||||
}),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
const startInput = manager.start.mock.calls[0]?.[0] as
|
||||
| { config?: Config }
|
||||
| undefined;
|
||||
const beforeTool = startInput?.config?.hooks?.beforeTool;
|
||||
expect(beforeTool).toBeTypeOf("function");
|
||||
|
||||
const result = await beforeTool?.({
|
||||
snapshot: {
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
status: "running",
|
||||
iteration: 1,
|
||||
messages: [],
|
||||
pendingToolCalls: [],
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
name: "echo",
|
||||
description: "",
|
||||
inputSchema: {},
|
||||
execute: async () => "ok",
|
||||
},
|
||||
toolCall: {
|
||||
type: "tool-call",
|
||||
toolCallId: "call-1",
|
||||
toolName: "echo",
|
||||
input: { text: "original" },
|
||||
},
|
||||
input: { text: "original" },
|
||||
});
|
||||
|
||||
expect(upstreamBeforeTool).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({
|
||||
input: { text: "updated" },
|
||||
policy: { autoApprove: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("starts fresh after resetting an initially resumed session", async () => {
|
||||
let startCount = 0;
|
||||
const manager = {
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
resumeSessionId: "resumed-session",
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
manager,
|
||||
"resumed-session",
|
||||
);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({ sessionId: "resumed-session" }),
|
||||
}),
|
||||
);
|
||||
|
||||
await runtime.resetForNewSession();
|
||||
await runtime.ensureReady();
|
||||
|
||||
expect(loadInteractiveResumeMessagesMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
manager,
|
||||
undefined,
|
||||
);
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
config: expect.not.objectContaining({
|
||||
sessionId: "resumed-session",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps explicit empty restarts eager for config-driven restarts", async () => {
|
||||
let startCount = 0;
|
||||
const manager = {
|
||||
start: vi.fn().mockImplementation(async () => {
|
||||
startCount += 1;
|
||||
const sessionId = `session-${startCount}`;
|
||||
return {
|
||||
sessionId,
|
||||
manifest: createManifest(sessionId),
|
||||
manifestPath: `/tmp/${sessionId}.json`,
|
||||
messagesPath: `/tmp/${sessionId}.messages.json`,
|
||||
};
|
||||
}),
|
||||
readMessages: vi.fn().mockResolvedValue([]),
|
||||
readSessionCompactionState: vi.fn().mockResolvedValue(undefined),
|
||||
updateSessionCompactionState: vi.fn(),
|
||||
stop: vi.fn().mockResolvedValue(undefined),
|
||||
dispose: vi.fn().mockResolvedValue(undefined),
|
||||
ingestHookEvent: vi.fn().mockResolvedValue(undefined),
|
||||
get: vi.fn(),
|
||||
list: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
};
|
||||
createCliCoreMock.mockResolvedValue(manager);
|
||||
const { createInteractiveSessionRuntime } = await importRuntime();
|
||||
const runtime = createInteractiveSessionRuntime({
|
||||
config: createConfig(),
|
||||
providerSettingsManager: createProviderSettingsManager(),
|
||||
chatCommandState: createChatCommandState(),
|
||||
requestToolApproval: vi.fn(),
|
||||
resolveToolPolicy: () => ({ autoApprove: true }),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: {} as never,
|
||||
onAgentEvent: vi.fn(),
|
||||
onTeamEvent: vi.fn(),
|
||||
onPendingPrompts: vi.fn(),
|
||||
onPendingPromptSubmitted: vi.fn(),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartEmpty();
|
||||
|
||||
expect(manager.stop).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("recovers and retries when the active interactive session disappeared", async () => {
|
||||
const manager = makeManager();
|
||||
const messages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: "hi" }],
|
||||
},
|
||||
];
|
||||
manager.readMessages.mockResolvedValue(messages);
|
||||
manager.send
|
||||
.mockRejectedValueOnce(new SessionNotFoundError("session-1"))
|
||||
.mockResolvedValueOnce(makeTurnResult());
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const result = await runtime.sendCurrentTurn({
|
||||
prompt: "second hi",
|
||||
mode: "act",
|
||||
});
|
||||
|
||||
expect(result?.finishReason).toBe("completed");
|
||||
expect(manager.readMessages).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
initialMessages: messages,
|
||||
}),
|
||||
);
|
||||
expect(manager.send).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ sessionId: "session-1" }),
|
||||
);
|
||||
expect(manager.send).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ sessionId: "session-2" }),
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("preserves the session id and applies changed provider config when restarting with the current messages", async () => {
|
||||
const manager = makeManager();
|
||||
const config = {
|
||||
...createConfig(),
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: "cline-key",
|
||||
};
|
||||
const messages: Message[] = [
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }] },
|
||||
];
|
||||
manager.readMessages.mockResolvedValue(messages);
|
||||
const runtime = await makeRuntime(manager, { config });
|
||||
|
||||
await runtime.ensureReady();
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: "cline-key",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
config.providerId = "openai-compatible";
|
||||
config.modelId = "custom-model";
|
||||
config.apiKey = "new-key";
|
||||
await runtime.restartWithCurrentMessages();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
config: expect.objectContaining({
|
||||
sessionId: "session-1",
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
apiKey: "new-key",
|
||||
}),
|
||||
initialMessages: messages,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("updates the active session connection in place without restarting", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.updateCurrentSessionConnection({
|
||||
providerId: "openai",
|
||||
modelId: "codex-test",
|
||||
});
|
||||
|
||||
expect(manager.updateSessionConnection).toHaveBeenCalledWith("session-1", {
|
||||
providerId: "openai",
|
||||
modelId: "codex-test",
|
||||
});
|
||||
expect(manager.start).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-1");
|
||||
});
|
||||
|
||||
it("does not reuse the session id when restarting empty", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartEmpty();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
const secondStart = manager.start.mock.calls[1]?.[0] as {
|
||||
config?: { sessionId?: string };
|
||||
};
|
||||
expect(secondStart?.config?.sessionId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("recovers empty read-driven restarts when the active interactive session disappeared", async () => {
|
||||
const manager = makeManager();
|
||||
manager.readMessages.mockRejectedValueOnce(
|
||||
new SessionNotFoundError("session-1"),
|
||||
);
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartWithCurrentMessages();
|
||||
|
||||
expect(manager.readMessages).toHaveBeenCalledWith("session-1");
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(manager.start).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
initialMessages: [],
|
||||
}),
|
||||
);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("does not restart with stale messages when another operation changes the active session during a read", async () => {
|
||||
const manager = makeManager();
|
||||
let runtime!: Awaited<ReturnType<typeof makeRuntime>>;
|
||||
manager.readMessages.mockImplementationOnce(async () => {
|
||||
await runtime.restartEmpty();
|
||||
return [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [{ type: "text" as const, text: "stale" }],
|
||||
},
|
||||
];
|
||||
});
|
||||
runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
await runtime.restartWithCurrentMessages();
|
||||
|
||||
expect(manager.start).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("waits for missing-session recovery before cleanup disposes the manager", async () => {
|
||||
const manager = makeManager();
|
||||
const recoveryRead = deferred<Message[]>();
|
||||
manager.readMessages
|
||||
.mockImplementationOnce(() => recoveryRead.promise)
|
||||
.mockResolvedValue([]);
|
||||
manager.get.mockResolvedValue(undefined);
|
||||
manager.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
manager.send.mockRejectedValueOnce(new SessionNotFoundError("session-1"));
|
||||
const runtime = await makeRuntime(manager);
|
||||
|
||||
await runtime.ensureReady();
|
||||
const sendPromise = runtime
|
||||
.sendCurrentTurn({
|
||||
prompt: "second hi",
|
||||
mode: "act",
|
||||
})
|
||||
.catch((error) => error);
|
||||
await vi.waitFor(() => {
|
||||
expect(manager.readMessages).toHaveBeenCalledWith("session-1");
|
||||
});
|
||||
|
||||
let cleanupSettled = false;
|
||||
const cleanupPromise = runtime.cleanup().finally(() => {
|
||||
cleanupSettled = true;
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
expect(cleanupSettled).toBe(false);
|
||||
expect(manager.get).not.toHaveBeenCalled();
|
||||
expect(manager.dispose).not.toHaveBeenCalled();
|
||||
|
||||
recoveryRead.resolve([]);
|
||||
await cleanupPromise;
|
||||
const sendError = await sendPromise;
|
||||
|
||||
expect(sendError).toBeInstanceOf(SessionNotFoundError);
|
||||
expect(manager.dispose).toHaveBeenCalledWith("cli_interactive_shutdown");
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
import {
|
||||
applyInteractiveModelChange,
|
||||
resolveReasoningForModelChange,
|
||||
} from "./run-interactive";
|
||||
|
||||
describe("resolveReasoningForModelChange", () => {
|
||||
it("persists disabled reasoning only when thinking is explicitly false", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: false, reasoningEffort: undefined },
|
||||
{ reasoning: { enabled: true, effort: "high" } },
|
||||
),
|
||||
).toEqual({ enabled: false });
|
||||
});
|
||||
|
||||
it("persists enabled reasoning with the selected effort", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: true, reasoningEffort: "low" },
|
||||
{ reasoning: { enabled: false } },
|
||||
),
|
||||
).toEqual({ enabled: true, effort: "low" });
|
||||
});
|
||||
|
||||
it("persists enabled reasoning when thinking is explicitly true without effort", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: true, reasoningEffort: undefined },
|
||||
{ reasoning: { enabled: false } },
|
||||
),
|
||||
).toEqual({ enabled: true });
|
||||
});
|
||||
|
||||
it("preserves existing reasoning when thinking is unset", () => {
|
||||
expect(
|
||||
resolveReasoningForModelChange(
|
||||
{ thinking: undefined, reasoningEffort: undefined },
|
||||
{ reasoning: { enabled: true, effort: "medium" } },
|
||||
),
|
||||
).toEqual({ enabled: true, effort: "medium" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyInteractiveModelChange", () => {
|
||||
it("restarts with the current transcript so a provider switch reloads its complete configuration", async () => {
|
||||
const config = {
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
apiKey: "new-key",
|
||||
thinking: undefined,
|
||||
reasoningEffort: undefined,
|
||||
} as Config;
|
||||
const getProviderSettings = vi.fn(() => ({
|
||||
provider: "openai-compatible",
|
||||
apiKey: "new-key",
|
||||
baseUrl: "https://example.com/v1",
|
||||
headers: { "X-Custom-Header": "custom-value" },
|
||||
client: "openai-compatible" as const,
|
||||
protocol: "openai-chat" as const,
|
||||
model: "old-model",
|
||||
}));
|
||||
const saveProviderSettings = vi.fn(() => ({
|
||||
version: 1 as const,
|
||||
providers: {},
|
||||
}));
|
||||
const ensureReady = vi.fn(async () => {});
|
||||
const restartWithCurrentMessages = vi.fn(async () => {});
|
||||
const updateCurrentSessionConnection = vi.fn(async () => {});
|
||||
|
||||
await applyInteractiveModelChange({
|
||||
config,
|
||||
providerSettingsManager: {
|
||||
getProviderSettings,
|
||||
saveProviderSettings,
|
||||
},
|
||||
sessionRuntime: {
|
||||
ensureReady,
|
||||
restartWithCurrentMessages,
|
||||
updateCurrentSessionConnection,
|
||||
},
|
||||
});
|
||||
|
||||
expect(saveProviderSettings).toHaveBeenCalledWith({
|
||||
provider: "openai-compatible",
|
||||
apiKey: "new-key",
|
||||
baseUrl: "https://example.com/v1",
|
||||
headers: { "X-Custom-Header": "custom-value" },
|
||||
client: "openai-compatible",
|
||||
protocol: "openai-chat",
|
||||
model: "custom-model",
|
||||
});
|
||||
expect(ensureReady).toHaveBeenCalledOnce();
|
||||
expect(restartWithCurrentMessages).toHaveBeenCalledOnce();
|
||||
expect(updateCurrentSessionConnection).toHaveBeenCalledWith({
|
||||
providerId: "openai-compatible",
|
||||
modelId: "custom-model",
|
||||
});
|
||||
expect(ensureReady.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
expect(saveProviderSettings.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
restartWithCurrentMessages.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
expect(restartWithCurrentMessages.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
updateCurrentSessionConnection.mock.invocationCallOrder[0] ?? 0,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,311 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
const coreMocks = vi.hoisted(() => {
|
||||
const serviceOptions: Array<{
|
||||
apiBaseUrl: string;
|
||||
getAuthToken: () => Promise<string | undefined | null>;
|
||||
}> = [];
|
||||
return {
|
||||
getProviderSettings: vi.fn(),
|
||||
saveProviderSettings: vi.fn(),
|
||||
fetchMe: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
fetchOrganizationBalance: vi.fn(),
|
||||
fetchAvailableSubscriptionPlans: vi.fn(),
|
||||
fetchCurrentUserPlan: vi.fn(),
|
||||
serviceOptions,
|
||||
};
|
||||
});
|
||||
const telemetryMocks = vi.hoisted(() => ({
|
||||
identifyTelemetryAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
ClineAccountService: class {
|
||||
constructor(options: {
|
||||
apiBaseUrl: string;
|
||||
getAuthToken: () => Promise<string | undefined | null>;
|
||||
}) {
|
||||
coreMocks.serviceOptions.push(options);
|
||||
}
|
||||
fetchMe() {
|
||||
return coreMocks.fetchMe();
|
||||
}
|
||||
fetchBalance(userId?: string) {
|
||||
return coreMocks.fetchBalance(userId);
|
||||
}
|
||||
fetchOrganizationBalance(organizationId: string) {
|
||||
return coreMocks.fetchOrganizationBalance(organizationId);
|
||||
}
|
||||
fetchAvailableSubscriptionPlans(input?: {
|
||||
type?: "individual" | "teams";
|
||||
}) {
|
||||
return coreMocks.fetchAvailableSubscriptionPlans(input);
|
||||
}
|
||||
fetchCurrentUserPlan() {
|
||||
return coreMocks.fetchCurrentUserPlan();
|
||||
}
|
||||
},
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings(providerId: string) {
|
||||
return coreMocks.getProviderSettings(providerId);
|
||||
}
|
||||
saveProviderSettings(settings: unknown, options?: unknown) {
|
||||
coreMocks.saveProviderSettings(settings, options);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/telemetry", () => ({
|
||||
identifyTelemetryAccount: telemetryMocks.identifyTelemetryAccount,
|
||||
}));
|
||||
|
||||
function makeConfig(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
providerId: "cline",
|
||||
modelId: "anthropic/claude-sonnet-4.6",
|
||||
apiKey: "",
|
||||
verbose: false,
|
||||
sandbox: false,
|
||||
thinking: false,
|
||||
outputMode: "text",
|
||||
mode: "act",
|
||||
defaultToolAutoApprove: false,
|
||||
toolPolicies: {},
|
||||
enableTools: true,
|
||||
cwd: "/tmp/workspace",
|
||||
logger: {
|
||||
debug: vi.fn(),
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
...overrides,
|
||||
} as unknown as Config;
|
||||
}
|
||||
|
||||
function mockFetchJson(body: unknown, status = 200): void {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
) as unknown as typeof fetch,
|
||||
);
|
||||
}
|
||||
|
||||
describe("createClineAccountService", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("refreshes persisted Cline OAuth credentials before creating the account service", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100_000);
|
||||
mockFetchJson({
|
||||
success: true,
|
||||
data: {
|
||||
accessToken: "new-access",
|
||||
refreshToken: "new-refresh",
|
||||
tokenType: "Bearer",
|
||||
expiresAt: "2096-10-02T07:06:40.000Z",
|
||||
userInfo: {
|
||||
subject: "sub-new",
|
||||
email: "new@example.com",
|
||||
name: "New User",
|
||||
clineUserId: "acct-new",
|
||||
accounts: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "workos:old-access",
|
||||
refreshToken: "refresh-token",
|
||||
accountId: "acct-old",
|
||||
expiresAt: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const { createClineAccountService } = await import("./cline-account");
|
||||
const service = await createClineAccountService({ config: makeConfig() });
|
||||
|
||||
expect(service).toBeDefined();
|
||||
expect(globalThis.fetch).toHaveBeenCalled();
|
||||
expect(coreMocks.saveProviderSettings).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: "cline",
|
||||
auth: expect.objectContaining({
|
||||
accessToken: "workos:new-access",
|
||||
refreshToken: "new-refresh",
|
||||
accountId: "acct-new",
|
||||
expiresAt: 4_000_000_000_000,
|
||||
}),
|
||||
}),
|
||||
{ setLastUsed: false, tokenSource: "oauth" },
|
||||
);
|
||||
expect(await coreMocks.serviceOptions[0]?.getAuthToken()).toBe(
|
||||
"workos:new-access",
|
||||
);
|
||||
});
|
||||
|
||||
it("asks the user to re-authenticate when Cline OAuth credentials cannot refresh", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(100_000);
|
||||
mockFetchJson(
|
||||
{
|
||||
error: "invalid_grant",
|
||||
error_description: "refresh expired",
|
||||
},
|
||||
401,
|
||||
);
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "workos:old-access",
|
||||
refreshToken: "refresh-token",
|
||||
expiresAt: 1,
|
||||
},
|
||||
});
|
||||
|
||||
const { createClineAccountService } = await import("./cline-account");
|
||||
|
||||
await expect(
|
||||
createClineAccountService({ config: makeConfig() }),
|
||||
).rejects.toThrow(
|
||||
"Cline account requires re-authentication. Run cline auth cline.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadClineAccountSnapshot", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("identifies the loaded Cline account for telemetry and feature flags", async () => {
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
apiKey: "account-token",
|
||||
});
|
||||
const { loadClineAccountSnapshot } = await import("./cline-account");
|
||||
coreMocks.fetchMe.mockResolvedValue({
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
displayName: "User One",
|
||||
photoUrl: "",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Acme",
|
||||
organizationId: "org-1",
|
||||
roles: ["member"],
|
||||
},
|
||||
],
|
||||
});
|
||||
coreMocks.fetchBalance.mockResolvedValue({ balance: 10, userId: "user-1" });
|
||||
coreMocks.fetchOrganizationBalance.mockResolvedValue({
|
||||
balance: 20,
|
||||
organizationId: "org-1",
|
||||
});
|
||||
|
||||
await loadClineAccountSnapshot({ config: makeConfig() });
|
||||
|
||||
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
|
||||
{
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
provider: "cline",
|
||||
organizationId: "org-1",
|
||||
organizationName: "Acme",
|
||||
memberId: "member-1",
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadIndividualSubscriptionPlans", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockReset();
|
||||
coreMocks.fetchCurrentUserPlan.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("loads individual subscription plans through the authorized account service", async () => {
|
||||
const plans = [
|
||||
{
|
||||
id: "plan-1",
|
||||
interval: "Monthly",
|
||||
features: { included: ["Major open-weights models"] },
|
||||
},
|
||||
];
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
apiKey: "account-token",
|
||||
});
|
||||
coreMocks.fetchAvailableSubscriptionPlans.mockResolvedValue(plans);
|
||||
|
||||
const { loadIndividualSubscriptionPlans } = await import("./cline-account");
|
||||
const result = await loadIndividualSubscriptionPlans({
|
||||
config: makeConfig(),
|
||||
});
|
||||
|
||||
expect(coreMocks.fetchAvailableSubscriptionPlans).toHaveBeenCalledWith({
|
||||
type: "individual",
|
||||
});
|
||||
expect(result).toEqual(plans);
|
||||
});
|
||||
});
|
||||
@@ -1,319 +0,0 @@
|
||||
import {
|
||||
type ClineAccountBalance,
|
||||
type ClineAccountOrganization,
|
||||
type ClineAccountOrganizationBalance,
|
||||
type ClineSubscriptionPlan,
|
||||
type UserCurrentPlan,
|
||||
ClineAccountService,
|
||||
type ClineAccountUser,
|
||||
formatProviderOAuthApiKey,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
getValidClineCredentials,
|
||||
type ProviderSettings,
|
||||
ProviderSettingsManager,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
|
||||
import { identifyTelemetryAccount } from "../utils/telemetry";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
export const CLINE_CREDITS_DASHBOARD_URL =
|
||||
"https://app.cline.bot/dashboard/account?tab=credits";
|
||||
|
||||
type ClineAccountConfig = Pick<Config, "apiKey" | "logger" | "providerId">;
|
||||
|
||||
const CLINE_PASS_PROVIDER_ID = "cline-pass";
|
||||
|
||||
export interface ClineAccountSnapshot {
|
||||
user: ClineAccountUser;
|
||||
balance: ClineAccountBalance;
|
||||
organizationBalance: ClineAccountOrganizationBalance | null;
|
||||
organizations: ClineAccountOrganization[];
|
||||
activeOrganization: ClineAccountOrganization | null;
|
||||
displayedBalance: number;
|
||||
}
|
||||
|
||||
export function formatClineCredits(value: number): string {
|
||||
return formatCreditBalance(normalizeCreditBalance(value));
|
||||
}
|
||||
|
||||
// FIXME: These message checks are temporary until structured error types are
|
||||
// passed through to the CLI instead of plain error strings.
|
||||
export function isClineAccountAuthErrorMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return (
|
||||
normalized === "no cline account auth token found" ||
|
||||
normalized.includes("requires re-authentication")
|
||||
);
|
||||
}
|
||||
|
||||
export function isClineAccountCreditsErrorMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return (
|
||||
normalized.includes("insufficient balance") &&
|
||||
normalized.includes("cline credits balance")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveAccountApiBaseUrl(input: {
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): string {
|
||||
const settingsBaseUrl = input.clineProviderSettings?.baseUrl?.trim();
|
||||
if (settingsBaseUrl) {
|
||||
return settingsBaseUrl;
|
||||
}
|
||||
const configuredBaseUrl = input.clineApiBaseUrl?.trim();
|
||||
if (configuredBaseUrl) {
|
||||
return configuredBaseUrl;
|
||||
}
|
||||
return getClineEnvironmentConfig().apiBaseUrl;
|
||||
}
|
||||
|
||||
function resolveClineAccountAuthToken(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): string | undefined {
|
||||
const configApiKey =
|
||||
input.config.providerId === "cline" ? input.config.apiKey.trim() : "";
|
||||
return (
|
||||
getPersistedProviderApiKey("cline", input.clineProviderSettings) ||
|
||||
configApiKey ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveValidClineAccountAuthToken(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
manager: ProviderSettingsManager;
|
||||
apiBaseUrl: string;
|
||||
}): Promise<string | undefined> {
|
||||
const settings = input.clineProviderSettings;
|
||||
const credentials = settings
|
||||
? getProviderOAuthCredentialsFromSettings("cline", settings)
|
||||
: null;
|
||||
if (settings && credentials) {
|
||||
const nextCredentials = await getValidClineCredentials(credentials, {
|
||||
apiBaseUrl: input.apiBaseUrl,
|
||||
});
|
||||
if (!nextCredentials) {
|
||||
throw new Error(
|
||||
"Cline account requires re-authentication. Run cline auth cline.",
|
||||
);
|
||||
}
|
||||
const nextAccessToken = formatProviderOAuthApiKey("cline", nextCredentials);
|
||||
if (nextCredentials !== credentials) {
|
||||
saveLocalProviderOAuthCredentials(
|
||||
input.manager,
|
||||
"cline",
|
||||
settings,
|
||||
nextCredentials,
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
}
|
||||
return nextAccessToken;
|
||||
}
|
||||
return resolveClineAccountAuthToken({
|
||||
config: input.config,
|
||||
clineProviderSettings: settings,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createClineAccountService(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
providerSettingsManager?: ProviderSettingsManager;
|
||||
}): Promise<ClineAccountService | undefined> {
|
||||
const manager =
|
||||
input.providerSettingsManager ?? new ProviderSettingsManager();
|
||||
const settings =
|
||||
manager.getProviderSettings("cline") ?? input.clineProviderSettings;
|
||||
const apiBaseUrl = resolveAccountApiBaseUrl({
|
||||
clineApiBaseUrl: input.clineApiBaseUrl,
|
||||
clineProviderSettings: settings,
|
||||
});
|
||||
const authToken = await resolveValidClineAccountAuthToken({
|
||||
config: input.config,
|
||||
clineProviderSettings: settings,
|
||||
manager,
|
||||
apiBaseUrl,
|
||||
});
|
||||
if (!authToken) {
|
||||
return undefined;
|
||||
}
|
||||
return new ClineAccountService({
|
||||
apiBaseUrl,
|
||||
getAuthToken: async () => authToken,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the active organization so headless runs and the hub daemon can
|
||||
* attach it to telemetry identity. Personal account clears stale org fields.
|
||||
*/
|
||||
function persistClineOrganizationContext(
|
||||
activeOrganization: ClineAccountOrganization | null,
|
||||
userId: string,
|
||||
): void {
|
||||
try {
|
||||
const manager = new ProviderSettingsManager();
|
||||
const persisted = manager.getProviderSettings("cline");
|
||||
if (!persisted) {
|
||||
return;
|
||||
}
|
||||
manager.saveProviderSettings(
|
||||
{
|
||||
...persisted,
|
||||
auth: {
|
||||
...persisted.auth,
|
||||
accountId: persisted.auth?.accountId ?? userId,
|
||||
organizationId: activeOrganization?.organizationId,
|
||||
organizationName: activeOrganization?.name,
|
||||
memberId: activeOrganization?.memberId,
|
||||
},
|
||||
},
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadClineAccountSnapshot(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): Promise<ClineAccountSnapshot> {
|
||||
const service = await createClineAccountService(input);
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
|
||||
const user = await service.fetchMe();
|
||||
const organizations = user.organizations ?? [];
|
||||
const activeOrganization =
|
||||
organizations.find((organization) => organization.active) ?? null;
|
||||
const [balance, organizationBalance] = await Promise.all([
|
||||
service.fetchBalance(user.id),
|
||||
activeOrganization
|
||||
? service.fetchOrganizationBalance(activeOrganization.organizationId)
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
const displayedBalance = activeOrganization
|
||||
? (organizationBalance?.balance ?? balance.balance)
|
||||
: balance.balance;
|
||||
const accountContext = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
provider: "cline",
|
||||
organizationId: activeOrganization?.organizationId,
|
||||
organizationName: activeOrganization?.name,
|
||||
memberId: activeOrganization?.memberId,
|
||||
};
|
||||
identifyTelemetryAccount(accountContext, input.config.logger);
|
||||
persistClineOrganizationContext(activeOrganization, user.id);
|
||||
|
||||
return {
|
||||
user,
|
||||
balance,
|
||||
organizationBalance,
|
||||
organizations,
|
||||
activeOrganization,
|
||||
displayedBalance,
|
||||
};
|
||||
}
|
||||
|
||||
export async function switchClineAccount(input: {
|
||||
config: ClineAccountConfig;
|
||||
organizationId?: string | null;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): Promise<void> {
|
||||
const service = await createClineAccountService(input);
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
await service.switchAccount(input.organizationId);
|
||||
}
|
||||
|
||||
export async function loadIndividualSubscriptionPlans(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): Promise<ClineSubscriptionPlan[]> {
|
||||
const service = await createClineAccountService(input);
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
|
||||
}
|
||||
|
||||
export async function loadCurrentUserPlan(input: {
|
||||
config: ClineAccountConfig;
|
||||
clineApiBaseUrl?: string;
|
||||
clineProviderSettings?: ProviderSettings;
|
||||
}): Promise<UserCurrentPlan | undefined> {
|
||||
const service = await createClineAccountService(input);
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchCurrentUserPlan();
|
||||
}
|
||||
|
||||
export async function loadCurrentUserPlanFromProviderSettings(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
clineApiBaseUrl?: string;
|
||||
}): Promise<UserCurrentPlan | undefined> {
|
||||
const service = await createClineAccountService({
|
||||
config: { apiKey: "", logger: undefined, providerId: "cline" },
|
||||
clineApiBaseUrl: input.clineApiBaseUrl,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
});
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchCurrentUserPlan();
|
||||
}
|
||||
|
||||
export async function loadIndividualSubscriptionPlansFromProviderSettings(input: {
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
clineApiBaseUrl?: string;
|
||||
}): Promise<ClineSubscriptionPlan[]> {
|
||||
const service = await createClineAccountService({
|
||||
config: { apiKey: "", logger: undefined, providerId: "cline" },
|
||||
clineApiBaseUrl: input.clineApiBaseUrl,
|
||||
providerSettingsManager: input.providerSettingsManager,
|
||||
});
|
||||
if (!service) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
return service.fetchAvailableSubscriptionPlans({ type: "individual" });
|
||||
}
|
||||
|
||||
async function onChangeToClinePass(config: ClineAccountConfig) {
|
||||
try {
|
||||
await switchClineAccount({
|
||||
config: config,
|
||||
organizationId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
config.logger?.debug("Failed to switch ClinePass to personal account", {
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function onProviderChange(input: {
|
||||
config: ClineAccountConfig;
|
||||
providerId: string;
|
||||
}): Promise<void> {
|
||||
if (input.providerId === CLINE_PASS_PROVIDER_ID) {
|
||||
return onChangeToClinePass(input.config);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import {
|
||||
getProviderAuthStorageId,
|
||||
type ProviderSettingsManager,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { CLI_PROMO_CODE } from "../../../utils/cline-pass-errors";
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_PATH = "/dashboard/subscription";
|
||||
const DEFAULT_APP_BASE_URL = "https://app.cline.bot";
|
||||
|
||||
/**
|
||||
* Persist a manually entered API key for an OAuth-capable provider — the
|
||||
* escape hatch for when OAuth login isn't working. Any stored OAuth tokens
|
||||
* are cleared: the auth handler prefers auth.accessToken over apiKey, so a
|
||||
* stale token would otherwise keep winning over the manual key.
|
||||
*
|
||||
* The key is written both to the provider's auth storage entry (cline-pass
|
||||
* stores credentials under "cline") and to the provider's own entry: settings
|
||||
* resolution lets a direct entry shadow the storage entry, and provider
|
||||
* switching copies merged settings (including auth) into direct entries, so
|
||||
* both must be updated for the manual key to reliably take effect.
|
||||
*/
|
||||
export function saveManualProviderApiKey(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
apiKey: string,
|
||||
): void {
|
||||
// Empty strings delete these keys from the stored auth object.
|
||||
const clearedAuth = { accessToken: "", refreshToken: "", apiKey: "" };
|
||||
const storageProviderId = getProviderAuthStorageId(providerId) ?? providerId;
|
||||
saveLocalProviderSettings(manager, {
|
||||
providerId: storageProviderId,
|
||||
apiKey,
|
||||
auth: clearedAuth,
|
||||
});
|
||||
if (
|
||||
providerId !== storageProviderId &&
|
||||
manager.read().providers[providerId]
|
||||
) {
|
||||
saveLocalProviderSettings(manager, {
|
||||
providerId,
|
||||
apiKey,
|
||||
auth: clearedAuth,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function buildClinePassSubscriptionPageUrl(
|
||||
appBaseUrl: string | undefined,
|
||||
): string {
|
||||
const url = new URL(
|
||||
CLINE_PASS_SUBSCRIPTION_PATH,
|
||||
appBaseUrl || DEFAULT_APP_BASE_URL,
|
||||
);
|
||||
url.searchParams.set("personal", "true");
|
||||
url.searchParams.set("code", CLI_PROMO_CODE);
|
||||
return url.toString();
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { ProviderSettingsManager } from "@cline/core";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
getPersistedProviderApiKey,
|
||||
isProviderConfigured,
|
||||
} from "../../../utils/provider-auth";
|
||||
import {
|
||||
buildClinePassSubscriptionPageUrl,
|
||||
saveManualProviderApiKey,
|
||||
} from "./provider-picker-helpers";
|
||||
|
||||
describe("buildClinePassSubscriptionPageUrl", () => {
|
||||
it("opens the personal subscription page on production by default", () => {
|
||||
expect(buildClinePassSubscriptionPageUrl(undefined)).toBe(
|
||||
"https://app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the configured app base URL", () => {
|
||||
expect(
|
||||
buildClinePassSubscriptionPageUrl("https://staging-app.cline.bot"),
|
||||
).toBe(
|
||||
"https://staging-app.cline.bot/dashboard/subscription?personal=true&code=CLI-8OFF",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveManualProviderApiKey", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function createManager(): ProviderSettingsManager {
|
||||
const dir = mkdtempSync(join(tmpdir(), "cline-cli-provider-picker-"));
|
||||
tempDirs.push(dir);
|
||||
return new ProviderSettingsManager({
|
||||
filePath: join(dir, "providers.json"),
|
||||
});
|
||||
}
|
||||
|
||||
it("clears stored OAuth tokens so the manual key takes effect", () => {
|
||||
const manager = createManager();
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
accountId: "acct_123",
|
||||
},
|
||||
});
|
||||
|
||||
saveManualProviderApiKey(manager, "cline", "manual-api-key");
|
||||
|
||||
const settings = manager.getProviderSettings("cline");
|
||||
expect(settings?.apiKey).toBe("manual-api-key");
|
||||
expect(settings?.auth?.accessToken).toBeUndefined();
|
||||
expect(settings?.auth?.refreshToken).toBeUndefined();
|
||||
expect(settings?.auth?.accountId).toBe("acct_123");
|
||||
expect(getPersistedProviderApiKey("cline", settings)).toBe(
|
||||
"manual-api-key",
|
||||
);
|
||||
expect(isProviderConfigured("cline", settings)).toBe(true);
|
||||
});
|
||||
|
||||
it("saves cline-pass keys to the shared cline auth storage entry", () => {
|
||||
const manager = createManager();
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
},
|
||||
});
|
||||
|
||||
saveManualProviderApiKey(manager, "cline-pass", "manual-api-key");
|
||||
|
||||
// cline-pass inherits auth storage from the "cline" entry, so the key
|
||||
// must land there and the stale tokens must be gone for both providers.
|
||||
const clineSettings = manager.getProviderSettings("cline");
|
||||
expect(clineSettings?.apiKey).toBe("manual-api-key");
|
||||
expect(clineSettings?.auth?.accessToken).toBeUndefined();
|
||||
|
||||
const clinePassSettings = manager.getProviderSettings("cline-pass");
|
||||
expect(getPersistedProviderApiKey("cline-pass", clinePassSettings)).toBe(
|
||||
"manual-api-key",
|
||||
);
|
||||
expect(isProviderConfigured("cline-pass", clinePassSettings)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears stale credentials copied into a direct cline-pass entry", () => {
|
||||
const manager = createManager();
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
},
|
||||
});
|
||||
// Provider switching copies the merged settings (including auth) into
|
||||
// a direct cline-pass entry, which shadows the shared "cline" entry.
|
||||
manager.saveProviderSettings({
|
||||
provider: "cline-pass",
|
||||
apiKey: "stale-copied-key",
|
||||
auth: {
|
||||
accessToken: "stale-access-token",
|
||||
refreshToken: "stale-refresh-token",
|
||||
},
|
||||
});
|
||||
|
||||
saveManualProviderApiKey(manager, "cline-pass", "manual-api-key");
|
||||
|
||||
const clinePassSettings = manager.getProviderSettings("cline-pass");
|
||||
expect(clinePassSettings?.auth?.accessToken).toBeUndefined();
|
||||
expect(getPersistedProviderApiKey("cline-pass", clinePassSettings)).toBe(
|
||||
"manual-api-key",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,106 +0,0 @@
|
||||
import type {
|
||||
ClineRecommendedModel,
|
||||
ClineRecommendedModelsData,
|
||||
} from "@cline/core";
|
||||
|
||||
export type ClineModelPickerTier = "recommended" | "subscribed" | "free";
|
||||
|
||||
export interface ClineModelPickerItem {
|
||||
kind: "model";
|
||||
model: ClineRecommendedModel;
|
||||
tier: ClineModelPickerTier;
|
||||
}
|
||||
|
||||
export interface ClineModelPickerBrowse {
|
||||
kind: "browse";
|
||||
}
|
||||
|
||||
export type ClineModelPickerEntry =
|
||||
| ClineModelPickerItem
|
||||
| ClineModelPickerBrowse;
|
||||
|
||||
export const CLINE_MODEL_PICKER_TIER_LABELS: Record<
|
||||
ClineModelPickerTier,
|
||||
string
|
||||
> = {
|
||||
recommended: "Recommended",
|
||||
subscribed: "Subscribed",
|
||||
free: "Free",
|
||||
};
|
||||
|
||||
// Featured entries for the sectioned picker, keyed by provider: cline gets
|
||||
// Recommended/Free with a browse-all escape into the full catalog; cline-pass
|
||||
// gets Subscribed/Free (see buildClinePassModelEntries for why no browse-all).
|
||||
export function buildFeaturedModelEntries(
|
||||
providerId: string,
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
return providerId === "cline-pass"
|
||||
? buildClinePassModelEntries(data)
|
||||
: buildClineModelEntries(data);
|
||||
}
|
||||
|
||||
function buildClineModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [];
|
||||
for (const m of data.recommended) {
|
||||
entries.push({ kind: "model", model: m, tier: "recommended" });
|
||||
}
|
||||
for (const m of data.free) {
|
||||
entries.push({ kind: "model", model: m, tier: "free" });
|
||||
}
|
||||
entries.push({ kind: "browse" });
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Shown under the Free section header when picking a model for ClinePass
|
||||
export const CLINE_PASS_FREE_SECTION_DESCRIPTION =
|
||||
"Try with limited usage, separate from ClinePass quota.";
|
||||
|
||||
// ClinePass shows the subscription's models plus the Cline free models — both
|
||||
// providers hit the same Cline API, so free models are selectable in place
|
||||
// (they ride usage billing at $0 instead of the subscription quota).
|
||||
// No "browse all" entry when the clinePass bucket is populated: unlike cline,
|
||||
// the ClinePass catalog contains exactly these two buckets, so the sections
|
||||
// already list every selectable model. An empty clinePass bucket means the
|
||||
// fetch fell back to the bundled list (which has no pass models) — without an
|
||||
// escape into the full catalog a subscriber could only pick free models, so
|
||||
// browse-all comes back in that degraded mode.
|
||||
function buildClinePassModelEntries(
|
||||
data: ClineRecommendedModelsData,
|
||||
): ClineModelPickerEntry[] {
|
||||
const entries: ClineModelPickerEntry[] = [];
|
||||
for (const m of data.clinePass) {
|
||||
entries.push({ kind: "model", model: m, tier: "subscribed" });
|
||||
}
|
||||
for (const m of data.free) {
|
||||
entries.push({ kind: "model", model: m, tier: "free" });
|
||||
}
|
||||
if (data.clinePass.length === 0) {
|
||||
entries.push({ kind: "browse" });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
// The quota explainer only makes sense in the ClinePass picker, which is the
|
||||
// only picker that has a "subscribed" section
|
||||
export function freeTierDescriptionFor(
|
||||
entries: ClineModelPickerEntry[],
|
||||
): string | undefined {
|
||||
const isClinePassPicker = entries.some(
|
||||
(entry) => entry.kind === "model" && entry.tier === "subscribed",
|
||||
);
|
||||
return isClinePassPicker ? CLINE_PASS_FREE_SECTION_DESCRIPTION : undefined;
|
||||
}
|
||||
|
||||
// OpenRouter marks free variants with "(free)" in names and ":free" in ids to
|
||||
// disambiguate them from their paid twins. Inside the sectioned pickers the
|
||||
// Free header already says it, so the markers are redundant — but keep them in
|
||||
// flat lists (e.g. browse-all), where both variants appear side by side.
|
||||
export function stripFreeMarker(displayName: string): string {
|
||||
return displayName
|
||||
.replace(/\s*\(free\)\s*$/i, "")
|
||||
.replace(/:free$/i, "")
|
||||
.trim();
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildFeaturedModelEntries,
|
||||
CLINE_PASS_FREE_SECTION_DESCRIPTION,
|
||||
freeTierDescriptionFor,
|
||||
stripFreeMarker,
|
||||
} from "./cline-model-entries";
|
||||
|
||||
const model = (id: string) => ({ id, name: id, description: "", tags: [] });
|
||||
|
||||
describe("cline model picker entries", () => {
|
||||
it("builds Recommended/Free sections for the cline provider", () => {
|
||||
const entries = buildFeaturedModelEntries("cline", {
|
||||
recommended: [model("anthropic/claude-sonnet-5")],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [model("cline-pass/glm-5.1")],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: "model",
|
||||
model: model("anthropic/claude-sonnet-5"),
|
||||
tier: "recommended",
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
model: model("deepseek/deepseek-v4-flash"),
|
||||
tier: "free",
|
||||
},
|
||||
{ kind: "browse" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds Subscribed/Free sections for the cline-pass provider", () => {
|
||||
const entries = buildFeaturedModelEntries("cline-pass", {
|
||||
recommended: [model("anthropic/claude-sonnet-5")],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [model("cline-pass/glm-5.1"), model("cline-pass/kimi-k2.6")],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{ kind: "model", model: model("cline-pass/glm-5.1"), tier: "subscribed" },
|
||||
{
|
||||
kind: "model",
|
||||
model: model("cline-pass/kimi-k2.6"),
|
||||
tier: "subscribed",
|
||||
},
|
||||
{
|
||||
kind: "model",
|
||||
model: model("deepseek/deepseek-v4-flash"),
|
||||
tier: "free",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("adds the browse-all escape when the clinePass bucket is empty", () => {
|
||||
// The fetch fell back to the bundled list (no pass models); the sections
|
||||
// alone would leave a subscriber able to pick only free models.
|
||||
const entries = buildFeaturedModelEntries("cline-pass", {
|
||||
recommended: [],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [],
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
kind: "model",
|
||||
model: model("deepseek/deepseek-v4-flash"),
|
||||
tier: "free",
|
||||
},
|
||||
{ kind: "browse" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("attaches the quota explainer only to the ClinePass picker's free section", () => {
|
||||
const data = {
|
||||
recommended: [model("anthropic/claude-sonnet-5")],
|
||||
free: [model("deepseek/deepseek-v4-flash")],
|
||||
clinePass: [model("cline-pass/glm-5.1")],
|
||||
};
|
||||
|
||||
expect(
|
||||
freeTierDescriptionFor(buildFeaturedModelEntries("cline-pass", data)),
|
||||
).toBe(CLINE_PASS_FREE_SECTION_DESCRIPTION);
|
||||
expect(
|
||||
freeTierDescriptionFor(buildFeaturedModelEntries("cline", data)),
|
||||
).toBe(undefined);
|
||||
});
|
||||
|
||||
it("strips redundant free markers from display names", () => {
|
||||
expect(stripFreeMarker("Laguna M.1 (free)")).toBe("Laguna M.1");
|
||||
expect(stripFreeMarker("Trinity Large Preview (FREE)")).toBe(
|
||||
"Trinity Large Preview",
|
||||
);
|
||||
expect(stripFreeMarker("laguna-m.1:free")).toBe("laguna-m.1");
|
||||
expect(stripFreeMarker("DeepSeek V4 Flash")).toBe("DeepSeek V4 Flash");
|
||||
});
|
||||
});
|
||||
@@ -1,132 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createContextBar,
|
||||
formatStatusBarUsageText,
|
||||
resolveContextBarFilledForeground,
|
||||
resolveModelDisplayName,
|
||||
} from "./status-bar";
|
||||
|
||||
vi.mock("@opentui/react", () => ({
|
||||
useTerminalDimensions: () => ({ width: 80, height: 24 }),
|
||||
}));
|
||||
|
||||
describe("createContextBar", () => {
|
||||
it("keeps a stable width while changing segment lengths", () => {
|
||||
expect(createContextBar(0, 100)).toEqual({
|
||||
filled: "",
|
||||
empty: "\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
});
|
||||
expect(createContextBar(50, 100)).toEqual({
|
||||
filled: "\u2588\u2588\u2588",
|
||||
empty: "\u2588\u2588\u2588",
|
||||
});
|
||||
expect(createContextBar(100, 100)).toEqual({
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
empty: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a non-empty fill when usage is above zero", () => {
|
||||
expect(createContextBar(7_000, 1_000_000)).toEqual({
|
||||
filled: "\u2588",
|
||||
empty: "\u2588\u2588\u2588\u2588\u2588",
|
||||
});
|
||||
});
|
||||
|
||||
it("reserves the final segment for usage at or above the limit", () => {
|
||||
expect(createContextBar(999_999, 1_000_000)).toEqual({
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588",
|
||||
empty: "\u2588",
|
||||
});
|
||||
expect(createContextBar(1_000_000, 1_000_000)).toEqual({
|
||||
filled: "\u2588\u2588\u2588\u2588\u2588\u2588",
|
||||
empty: "",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses explicit white when terminal foreground would inherit gray", () => {
|
||||
expect(resolveContextBarFilledForeground(undefined)).toBe("#ffffff");
|
||||
expect(resolveContextBarFilledForeground("#1a1a1a")).toBe("#1a1a1a");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatStatusBarUsageText", () => {
|
||||
it("includes cost when usage cost is visible", () => {
|
||||
expect(
|
||||
formatStatusBarUsageText({
|
||||
totalTokens: 12_345,
|
||||
totalCost: 0.123,
|
||||
providerId: "cline",
|
||||
}),
|
||||
).toBe("(12,345) $0.12");
|
||||
});
|
||||
|
||||
it("rounds cost to two decimals even when tiny", () => {
|
||||
expect(
|
||||
formatStatusBarUsageText({
|
||||
totalTokens: 12_345,
|
||||
totalCost: 0.0004,
|
||||
providerId: "cline",
|
||||
}),
|
||||
).toBe("(12,345) $0.00");
|
||||
});
|
||||
|
||||
it("hides cost entirely for subscription providers", () => {
|
||||
expect(
|
||||
formatStatusBarUsageText({
|
||||
totalTokens: 12_345,
|
||||
totalCost: 0.123,
|
||||
providerId: "cline-pass",
|
||||
}),
|
||||
).toBe("(12,345)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveModelDisplayName", () => {
|
||||
it("uses the friendly model name with a ClinePass prefix", () => {
|
||||
expect(
|
||||
resolveModelDisplayName({
|
||||
providerId: "cline-pass",
|
||||
modelId: "zai/glm-5.2",
|
||||
knownModels: {
|
||||
"zai/glm-5.2": { name: "GLM 5.2" },
|
||||
},
|
||||
}),
|
||||
).toBe("ClinePass: GLM 5.2");
|
||||
});
|
||||
|
||||
it("falls back to the bare model id with a ClinePass prefix when unknown", () => {
|
||||
expect(
|
||||
resolveModelDisplayName({
|
||||
providerId: "cline-pass",
|
||||
modelId: "zai/glm-5.2",
|
||||
}),
|
||||
).toBe("ClinePass: glm-5.2");
|
||||
});
|
||||
|
||||
it("keeps the reasoning effort next to the model name", () => {
|
||||
expect(
|
||||
resolveModelDisplayName({
|
||||
providerId: "cline-pass",
|
||||
modelId: "zai/glm-5.2",
|
||||
knownModels: {
|
||||
"zai/glm-5.2": { name: "GLM 5.2" },
|
||||
},
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
}),
|
||||
).toBe("ClinePass: GLM 5.2 (high)");
|
||||
});
|
||||
|
||||
it("uses the friendly model name for non-ClinePass providers", () => {
|
||||
expect(
|
||||
resolveModelDisplayName({
|
||||
providerId: "cline",
|
||||
modelId: "zai/glm-5.2",
|
||||
knownModels: {
|
||||
"zai/glm-5.2": { name: "GLM 5.2" },
|
||||
},
|
||||
}),
|
||||
).toBe("GLM 5.2");
|
||||
});
|
||||
});
|
||||
@@ -1,180 +0,0 @@
|
||||
import { Llms } from "@cline/core";
|
||||
import type { ChoiceContext } from "@opentui-ui/dialog";
|
||||
import type { DialogActions } from "@opentui-ui/dialog/react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import type {
|
||||
InteractiveConfigData,
|
||||
InteractiveConfigItem,
|
||||
InteractiveConfigTab,
|
||||
LoadInteractiveConfigDataOptions,
|
||||
} from "../../tui/interactive-config";
|
||||
import type { CliCompactionMode, Config } from "../../utils/types";
|
||||
import {
|
||||
ConfigErrorContent,
|
||||
DeleteConfigItemConfirmContent,
|
||||
ExtDetailContent,
|
||||
} from "../components/dialogs/config-dialogs";
|
||||
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
|
||||
import { ConfigPanelContent } from "../views/config-view";
|
||||
import type { ConfigAction } from "../views/config-view-helpers";
|
||||
import type { OpenModelSelectorOptions } from "./use-model-selector";
|
||||
|
||||
export interface OpenConfigOptions {
|
||||
initialTab?: InteractiveConfigTab;
|
||||
}
|
||||
|
||||
export function useConfigPanel(opts: {
|
||||
dialog: DialogActions;
|
||||
config: Config;
|
||||
sessionUiMode: string;
|
||||
compactionMode: CliCompactionMode;
|
||||
toggleMode: () => void;
|
||||
toggleAutoApprove: () => void;
|
||||
setCompactionMode: (mode: CliCompactionMode) => void;
|
||||
termHeight: number;
|
||||
loadConfigData: (
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData>;
|
||||
onToggleConfigItem?: (
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
onDeleteConfigItem?: (
|
||||
item: InteractiveConfigItem,
|
||||
options?: LoadInteractiveConfigDataOptions,
|
||||
) => Promise<InteractiveConfigData | undefined>;
|
||||
openModelSelector: (options?: OpenModelSelectorOptions) => Promise<void>;
|
||||
openMcpManager: (options?: { refocus?: boolean }) => Promise<boolean>;
|
||||
refocusTextarea: () => void;
|
||||
}) {
|
||||
const emptyConfigData = useMemo(
|
||||
() => ({
|
||||
workflows: [] as InteractiveConfigItem[],
|
||||
rules: [] as InteractiveConfigItem[],
|
||||
skills: [] as InteractiveConfigItem[],
|
||||
hooks: [] as InteractiveConfigItem[],
|
||||
agents: [] as InteractiveConfigItem[],
|
||||
plugins: [] as InteractiveConfigItem[],
|
||||
mcp: [] as InteractiveConfigItem[],
|
||||
tools: [] as InteractiveConfigItem[],
|
||||
workflowSlashCommands: [],
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const openConfig = useCallback(
|
||||
async (options: OpenConfigOptions = {}) => {
|
||||
let keepOpen = true;
|
||||
let activeTab = options.initialTab;
|
||||
while (keepOpen) {
|
||||
const [data, providerInfo] = await withLoadingDialog(
|
||||
opts.dialog,
|
||||
"Loading settings...",
|
||||
async () =>
|
||||
await Promise.all([
|
||||
opts
|
||||
.loadConfigData({ includePluginTools: false })
|
||||
.catch(() => emptyConfigData),
|
||||
Llms.getProvider(opts.config.providerId).catch(() => undefined),
|
||||
]),
|
||||
);
|
||||
const providerDisplayName =
|
||||
providerInfo?.name ?? opts.config.providerId;
|
||||
const action = await opts.dialog.choice<ConfigAction>({
|
||||
size: "large",
|
||||
style: { maxHeight: opts.termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<ConfigAction>) => (
|
||||
<ConfigPanelContent
|
||||
{...ctx}
|
||||
config={opts.config}
|
||||
configData={data}
|
||||
loadConfigData={opts.loadConfigData}
|
||||
providerDisplayName={providerDisplayName}
|
||||
currentMode={opts.sessionUiMode}
|
||||
currentCompactionMode={opts.compactionMode}
|
||||
initialTab={activeTab}
|
||||
onActiveTabChange={(tab) => {
|
||||
activeTab = tab;
|
||||
}}
|
||||
onToggleConfigItem={opts.onToggleConfigItem}
|
||||
onDeleteConfigItem={opts.onDeleteConfigItem}
|
||||
onToggleMode={opts.toggleMode}
|
||||
onToggleAutoApprove={opts.toggleAutoApprove}
|
||||
onSetCompactionMode={opts.setCompactionMode}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
if (!action) {
|
||||
keepOpen = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action.kind === "open-provider") {
|
||||
await opts.openModelSelector({
|
||||
startWithProviderChange: true,
|
||||
onCancel: () => {},
|
||||
});
|
||||
} else if (action.kind === "open-model") {
|
||||
await opts.openModelSelector({ onCancel: () => {} });
|
||||
} else if (action.kind === "toggle-item") {
|
||||
await opts.onToggleConfigItem?.(action.item);
|
||||
} else if (action.kind === "delete-item") {
|
||||
const confirmed = await opts.dialog.choice<boolean>({
|
||||
closeOnEscape: true,
|
||||
content: (ctx: ChoiceContext<boolean>) => (
|
||||
<DeleteConfigItemConfirmContent {...ctx} item={action.item} />
|
||||
),
|
||||
});
|
||||
if (confirmed && opts.onDeleteConfigItem) {
|
||||
try {
|
||||
await withLoadingDialog(
|
||||
opts.dialog,
|
||||
`Deleting ${action.item.name}...`,
|
||||
async () =>
|
||||
await opts.onDeleteConfigItem?.(action.item, {
|
||||
includePluginTools: false,
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
await opts.dialog.choice<void>({
|
||||
closeOnEscape: true,
|
||||
content: (ctx: ChoiceContext<void>) => (
|
||||
<ConfigErrorContent
|
||||
{...ctx}
|
||||
title="Plugin delete failed"
|
||||
message={
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (action.kind === "ext-detail") {
|
||||
await opts.dialog.choice<void>({
|
||||
style: { maxHeight: opts.termHeight - 2 },
|
||||
closeOnEscape: false,
|
||||
content: (ctx: ChoiceContext<void>) => (
|
||||
<ExtDetailContent
|
||||
{...ctx}
|
||||
item={action.item}
|
||||
onToggleConfigItem={opts.onToggleConfigItem}
|
||||
/>
|
||||
),
|
||||
});
|
||||
} else if (action.kind === "open-mcp") {
|
||||
const changed = await opts.openMcpManager({ refocus: false });
|
||||
if (changed) {
|
||||
keepOpen = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
opts.refocusTextarea();
|
||||
},
|
||||
[opts, emptyConfigData],
|
||||
);
|
||||
|
||||
return openConfig;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type TerminalTitleRenderer,
|
||||
useTerminalTitle,
|
||||
} from "./use-terminal-title";
|
||||
|
||||
const reactMock = vi.hoisted(() => {
|
||||
const cleanups: Array<() => void> = [];
|
||||
return {
|
||||
cleanups,
|
||||
// Run effect bodies now, but retain their cleanups so each test can move
|
||||
// the renderer across the native destruction boundary before unmount.
|
||||
useEffect: vi.fn((effect: () => undefined | (() => void)) => {
|
||||
const cleanup = effect();
|
||||
if (cleanup) {
|
||||
cleanups.push(cleanup);
|
||||
}
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("react", () => ({
|
||||
useEffect: reactMock.useEffect,
|
||||
}));
|
||||
|
||||
function createTitleRenderer() {
|
||||
let destroyed = false;
|
||||
const setTerminalTitle = vi.fn(() => {
|
||||
if (destroyed) {
|
||||
throw new Error("setTerminalTitle called after renderer destruction");
|
||||
}
|
||||
});
|
||||
const renderer: TerminalTitleRenderer = {
|
||||
get isDestroyed() {
|
||||
return destroyed;
|
||||
},
|
||||
setTerminalTitle,
|
||||
};
|
||||
|
||||
return {
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
},
|
||||
renderer,
|
||||
setTerminalTitle,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
reactMock.cleanups.length = 0;
|
||||
reactMock.useEffect.mockClear();
|
||||
});
|
||||
|
||||
describe("useTerminalTitle", () => {
|
||||
it("sets and resets the title while the renderer is active", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(1, "Cline");
|
||||
|
||||
for (const cleanup of reactMock.cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledTimes(2);
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenNthCalledWith(2, "");
|
||||
});
|
||||
|
||||
it("does not set the title when its effect runs after renderer destruction", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
titleRenderer.destroy();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not reset the title when cleanup runs after renderer destruction", () => {
|
||||
const titleRenderer = createTitleRenderer();
|
||||
|
||||
useTerminalTitle(titleRenderer.renderer, "Cline");
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
|
||||
|
||||
titleRenderer.destroy();
|
||||
for (const cleanup of reactMock.cleanups) {
|
||||
cleanup();
|
||||
}
|
||||
|
||||
expect(titleRenderer.setTerminalTitle).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
import { useEffect } from "react";
|
||||
|
||||
export interface TerminalTitleRenderer {
|
||||
readonly isDestroyed: boolean;
|
||||
setTerminalTitle(title: string): void;
|
||||
}
|
||||
|
||||
export function useTerminalTitle(
|
||||
renderer: TerminalTitleRenderer,
|
||||
terminalTitle: string,
|
||||
): void {
|
||||
// setTerminalTitle writes into memory owned by the native renderer, so it
|
||||
// must never run after destroy. React can flush passive effects after the
|
||||
// renderer's memory has been freed.
|
||||
useEffect(() => {
|
||||
if (renderer.isDestroyed) {
|
||||
return;
|
||||
}
|
||||
renderer.setTerminalTitle(terminalTitle);
|
||||
}, [renderer, terminalTitle]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (!renderer.isDestroyed) {
|
||||
renderer.setTerminalTitle("");
|
||||
}
|
||||
};
|
||||
}, [renderer]);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCompactionDividerLabel,
|
||||
formatTokenCount,
|
||||
parseCompactionNoticeMetadata,
|
||||
} from "./compaction-status";
|
||||
|
||||
describe("parseCompactionNoticeMetadata", () => {
|
||||
it("extracts a divider entry from a completed auto-compaction notice", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
reason: "auto_compaction",
|
||||
phase: "completed",
|
||||
tokensBefore: 25_101,
|
||||
tokensAfter: 6_300,
|
||||
messagesBefore: 142,
|
||||
messagesAfter: 9,
|
||||
}),
|
||||
).toEqual({
|
||||
compactionMode: "auto",
|
||||
status: "completed",
|
||||
tokensBefore: 25_101,
|
||||
tokensAfter: 6_300,
|
||||
messagesBefore: 142,
|
||||
messagesAfter: 9,
|
||||
});
|
||||
});
|
||||
|
||||
it("extracts a streaming divider entry from a started notice", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
phase: "started",
|
||||
}),
|
||||
).toEqual({ compactionMode: "auto", status: "started" });
|
||||
});
|
||||
|
||||
it("maps manual compaction notices to manual mode", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "manual_compaction",
|
||||
phase: "completed",
|
||||
})?.compactionMode,
|
||||
).toBe("manual");
|
||||
});
|
||||
|
||||
it("maps a benign no-result terminal notice to skipped", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
phase: "skipped",
|
||||
}),
|
||||
).toEqual({ compactionMode: "auto", status: "skipped" });
|
||||
});
|
||||
|
||||
it("ignores non-compaction metadata", () => {
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({ kind: "recovery", phase: "completed" }),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
parseCompactionNoticeMetadata({ kind: "auto_compaction" }),
|
||||
).toBeUndefined();
|
||||
expect(parseCompactionNoticeMetadata(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("drops non-numeric counters instead of rendering garbage", () => {
|
||||
const parsed = parseCompactionNoticeMetadata({
|
||||
kind: "auto_compaction",
|
||||
phase: "completed",
|
||||
tokensBefore: "25000",
|
||||
tokensAfter: Number.NaN,
|
||||
});
|
||||
expect(parsed?.tokensBefore).toBeUndefined();
|
||||
expect(parsed?.tokensAfter).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatTokenCount", () => {
|
||||
it("formats counts into compact units", () => {
|
||||
expect(formatTokenCount(999)).toBe("999");
|
||||
expect(formatTokenCount(6_300)).toBe("6.3k");
|
||||
expect(formatTokenCount(25_000)).toBe("25k");
|
||||
expect(formatTokenCount(1_200_000)).toBe("1.2M");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatCompactionDividerLabel", () => {
|
||||
it("includes token and message deltas when present", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "completed",
|
||||
tokensBefore: 25_101,
|
||||
tokensAfter: 6_300,
|
||||
messagesBefore: 142,
|
||||
messagesAfter: 9,
|
||||
}),
|
||||
).toBe("Context compacted · 25.1k → 6.3k tokens · 142 → 9 messages");
|
||||
});
|
||||
|
||||
it("labels in-progress compaction", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "started",
|
||||
}),
|
||||
).toBe("Auto compacting messages");
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "manual",
|
||||
status: "started",
|
||||
}),
|
||||
).toBe("Compacting messages");
|
||||
});
|
||||
|
||||
it("labels failed and cancelled compaction", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "failed",
|
||||
}),
|
||||
).toBe("Compaction failed");
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "cancelled",
|
||||
}),
|
||||
).toBe("Compaction cancelled");
|
||||
});
|
||||
|
||||
it("labels skipped compaction without calling it cancelled", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "auto",
|
||||
status: "skipped",
|
||||
}),
|
||||
).toBe("Compaction skipped");
|
||||
});
|
||||
|
||||
it("labels inherited working context from forks and restarts", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "inherited",
|
||||
status: "completed",
|
||||
messagesBefore: 60,
|
||||
messagesAfter: 15,
|
||||
}),
|
||||
).toBe("Compacted working context carried over · 60 → 15 messages");
|
||||
});
|
||||
|
||||
it("labels manual compaction and omits missing counters", () => {
|
||||
expect(
|
||||
formatCompactionDividerLabel({
|
||||
kind: "compaction",
|
||||
compactionMode: "manual",
|
||||
status: "completed",
|
||||
}),
|
||||
).toBe("Context compacted (manual)");
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { ChatEntry, InteractiveCompactionResult } from "../types";
|
||||
|
||||
export type CompactionDividerEntry = Extract<ChatEntry, { kind: "compaction" }>;
|
||||
|
||||
function formatMessageCount(count: number): string {
|
||||
return `${count} ${count === 1 ? "message" : "messages"}`;
|
||||
}
|
||||
|
||||
function asFiniteNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value)
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a compaction divider entry from a status notice's metadata.
|
||||
* "started" notices produce a streaming (in-progress) divider; "completed"
|
||||
* notices produce the final divider with counters. Returns undefined for
|
||||
* non-compaction notices.
|
||||
*/
|
||||
export function parseCompactionNoticeMetadata(
|
||||
metadata: Record<string, unknown> | undefined,
|
||||
): Omit<CompactionDividerEntry, "kind"> | undefined {
|
||||
if (
|
||||
!metadata ||
|
||||
(metadata.phase !== "started" &&
|
||||
metadata.phase !== "completed" &&
|
||||
metadata.phase !== "skipped")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const kind = metadata.kind ?? metadata.reason;
|
||||
if (kind !== "auto_compaction" && kind !== "manual_compaction") {
|
||||
return undefined;
|
||||
}
|
||||
const compactionMode = kind === "manual_compaction" ? "manual" : "auto";
|
||||
if (metadata.phase === "started") {
|
||||
return { compactionMode, status: "started" };
|
||||
}
|
||||
if (metadata.phase === "skipped") {
|
||||
return { compactionMode, status: "skipped" };
|
||||
}
|
||||
return {
|
||||
compactionMode,
|
||||
status: "completed",
|
||||
tokensBefore: asFiniteNumber(metadata.tokensBefore),
|
||||
tokensAfter: asFiniteNumber(metadata.tokensAfter),
|
||||
messagesBefore: asFiniteNumber(metadata.messagesBefore),
|
||||
messagesAfter: asFiniteNumber(metadata.messagesAfter),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatTokenCount(count: number): string {
|
||||
if (count < 1_000) {
|
||||
return `${count}`;
|
||||
}
|
||||
if (count < 1_000_000) {
|
||||
return `${(count / 1_000).toFixed(1).replace(/\.0$/, "")}k`;
|
||||
}
|
||||
return `${(count / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
||||
}
|
||||
|
||||
export function formatCompactionDividerLabel(
|
||||
entry: CompactionDividerEntry,
|
||||
): string {
|
||||
if (entry.status === "started") {
|
||||
return entry.compactionMode === "manual"
|
||||
? "Compacting messages"
|
||||
: "Auto compacting messages";
|
||||
}
|
||||
if (entry.status === "failed") {
|
||||
return "Compaction failed";
|
||||
}
|
||||
if (entry.status === "cancelled") {
|
||||
return "Compaction cancelled";
|
||||
}
|
||||
if (entry.status === "skipped") {
|
||||
return "Compaction skipped";
|
||||
}
|
||||
const parts: string[] = [
|
||||
entry.compactionMode === "manual"
|
||||
? "Context compacted (manual)"
|
||||
: entry.compactionMode === "inherited"
|
||||
? "Compacted working context carried over"
|
||||
: "Context compacted",
|
||||
];
|
||||
if (
|
||||
typeof entry.tokensBefore === "number" &&
|
||||
typeof entry.tokensAfter === "number"
|
||||
) {
|
||||
parts.push(
|
||||
`${formatTokenCount(entry.tokensBefore)} → ${formatTokenCount(entry.tokensAfter)} tokens`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
typeof entry.messagesBefore === "number" &&
|
||||
typeof entry.messagesAfter === "number"
|
||||
) {
|
||||
parts.push(`${entry.messagesBefore} → ${entry.messagesAfter} messages`);
|
||||
}
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
export function formatCompactionStatus(
|
||||
result: InteractiveCompactionResult,
|
||||
): string {
|
||||
if (result.messagesBefore === 0) {
|
||||
return "No messages to compact.";
|
||||
}
|
||||
if (!result.compacted) {
|
||||
return "No compaction needed.";
|
||||
}
|
||||
if (typeof result.workingContextMessagesAfter === "number") {
|
||||
return `Compacted working context to ${formatMessageCount(result.workingContextMessagesAfter)}; saved history remains ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
if (result.messagesBefore === result.messagesAfter) {
|
||||
return `Compacted context; message count stayed at ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
return `Compacted ${formatMessageCount(result.messagesBefore)} to ${formatMessageCount(result.messagesAfter)}.`;
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import type { Message } from "@cline/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ACT_MODE_CONTINUATION_PROMPT } from "../../runtime/interactive/mode";
|
||||
import { hydrateSessionMessages } from "./hydrate-messages";
|
||||
|
||||
describe("hydrateSessionMessages", () => {
|
||||
it("renders regular user messages", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: '<user_input mode="plan">lets do it</user_input>',
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "user_submitted", text: "lets do it", mode: "plan" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("hides the synthetic act-mode continuation prompt", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: `<user_input mode="act">${ACT_MODE_CONTINUATION_PROMPT}</user_input>`,
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: `<user_input mode="act">${ACT_MODE_CONTINUATION_PROMPT}</user_input>`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: "On it.",
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "assistant_text", text: "On it.", streaming: false, mode: "act" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("stamps entries with the mode of the user message that produced them", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: '<user_input mode="plan">plan this out</user_input>',
|
||||
},
|
||||
{ role: "assistant", content: "Here is the plan." },
|
||||
{
|
||||
role: "user",
|
||||
content: '<user_input mode="act">do it</user_input>',
|
||||
},
|
||||
{ role: "assistant", content: "Doing it." },
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "user_submitted", text: "plan this out", mode: "plan" },
|
||||
{
|
||||
kind: "assistant_text",
|
||||
text: "Here is the plan.",
|
||||
streaming: false,
|
||||
mode: "plan",
|
||||
},
|
||||
{ kind: "user_submitted", text: "do it", mode: "act" },
|
||||
{
|
||||
kind: "assistant_text",
|
||||
text: "Doing it.",
|
||||
streaming: false,
|
||||
mode: "act",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("switches to act mode after a switch_to_act_mode tool call", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: '<user_input mode="plan">plan then build</user_input>',
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Plan looks good, switching." },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-1",
|
||||
name: "switch_to_act_mode",
|
||||
input: {},
|
||||
},
|
||||
{ type: "text", text: "Building now." },
|
||||
],
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "user_submitted", text: "plan then build", mode: "plan" },
|
||||
{
|
||||
kind: "assistant_text",
|
||||
text: "Plan looks good, switching.",
|
||||
streaming: false,
|
||||
mode: "plan",
|
||||
},
|
||||
{
|
||||
kind: "tool_call",
|
||||
toolName: "switch_to_act_mode",
|
||||
inputSummary: expect.any(String),
|
||||
rawInput: {},
|
||||
streaming: false,
|
||||
mode: "plan",
|
||||
},
|
||||
{
|
||||
kind: "assistant_text",
|
||||
text: "Building now.",
|
||||
streaming: false,
|
||||
mode: "act",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("strips mode switch notices from displayed user text", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
'<user_input mode="plan"><mode_notice>The user switched from act mode to plan mode before sending this message.</mode_notice>\nare you okay?</user_input>',
|
||||
},
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "user_submitted", text: "are you okay?", mode: "plan" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("leaves mode undefined for transcripts without user_input wrappers", () => {
|
||||
const messages = [
|
||||
{ role: "user", content: "plain old message" },
|
||||
{ role: "assistant", content: "reply" },
|
||||
] as Message[];
|
||||
|
||||
expect(hydrateSessionMessages(messages)).toEqual([
|
||||
{ kind: "user_submitted", text: "plain old message", mode: undefined },
|
||||
{
|
||||
kind: "assistant_text",
|
||||
text: "reply",
|
||||
streaming: false,
|
||||
mode: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getCliClinePassLimitMessage,
|
||||
getCliNotSubscribedMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassLimitDetailMessage,
|
||||
getCliSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassLimitErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "./cline-pass-errors";
|
||||
|
||||
describe("cline-pass-errors", () => {
|
||||
it("recognizes both raw and formatted ClinePass subscription messages", () => {
|
||||
expect(
|
||||
isClinePassSubscriptionError(
|
||||
"the user is not subscribed to required model plan",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const sdkFormatted =
|
||||
"No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: https://app.cline.bot/dashboard/subscription?personal=true";
|
||||
const formatted = getCliNotSubscribedMessage();
|
||||
expect(isClinePassSubscriptionError(sdkFormatted)).toBe(true);
|
||||
expect(isClinePassSubscriptionError(formatted)).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(sdkFormatted))).toBe(formatted);
|
||||
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
|
||||
});
|
||||
|
||||
it("formats the ClinePass subscription URL", () => {
|
||||
expect(getCliSubscriptionUrl()).toBe(
|
||||
"https://app.cline.bot/promo?code=CLI-8OFF&personal=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes and formats organization account individual subscription errors", () => {
|
||||
const raw =
|
||||
"403 Error 403: organization accounts cannot use individual model inference subscriptions";
|
||||
const formatted = getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
|
||||
expect(isClineOrgIndividualInferenceSubscriptionErrorMessage(raw)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
new Error(formatted),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
|
||||
});
|
||||
|
||||
it("recognizes and formats ClinePass period limit errors with usage-billing guidance", () => {
|
||||
const raw =
|
||||
"Error: You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
const detail =
|
||||
"You have reached your 5-hour Clinepass limit. The limit resets in 5h, please try again later.";
|
||||
|
||||
expect(isClinePassLimitErrorMessage(raw)).toBe(true);
|
||||
expect(isClinePassLimitErrorMessage(new Error(raw))).toBe(true);
|
||||
expect(getClinePassLimitDetailMessage(raw)).toBe(detail);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(
|
||||
getCliClinePassLimitMessage(raw),
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain(
|
||||
"Switch to Cline usage-based billing",
|
||||
);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toContain("--provider cline");
|
||||
});
|
||||
});
|
||||
@@ -1,133 +0,0 @@
|
||||
import {
|
||||
type ClineSubscriptionPlan,
|
||||
extractClinePassLimitMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
isClinePassLimitError,
|
||||
isClinePassLimitMessage,
|
||||
} from "@cline/core";
|
||||
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export { getClineOrgIndividualInferenceSubscriptionMessage };
|
||||
|
||||
export const CLI_PROMO_CODE = "CLI-8OFF";
|
||||
|
||||
export function getCliSubscriptionUrl(): string {
|
||||
return `${new URL(
|
||||
`/promo?code=${CLI_PROMO_CODE}&personal=true`,
|
||||
getClineEnvironmentConfig().appBaseUrl,
|
||||
).toString()}`;
|
||||
}
|
||||
|
||||
export function getCliNotSubscribedMessage(): string {
|
||||
return `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getCliSubscriptionUrl()}`;
|
||||
}
|
||||
|
||||
export function getCliClinePassLimitMessage(message: string): string {
|
||||
const detail = getClinePassLimitDetailMessage(message) ?? message.trim();
|
||||
const lines = [
|
||||
"ClinePass limit reached",
|
||||
detail,
|
||||
"Switch to Cline usage-based billing and retry with the Cline provider.",
|
||||
"Interactive CLI: open the model selector with /model, choose Cline, then retry.",
|
||||
"Headless CLI: rerun with --provider cline.",
|
||||
];
|
||||
return lines.filter((line) => line.trim().length > 0).join("\n");
|
||||
}
|
||||
|
||||
export function getIndividualPlanFeatures(
|
||||
plans: ClineSubscriptionPlan[],
|
||||
): string[] {
|
||||
const planWithFeatures = plans.find((plan) => plan.interval === "Monthly");
|
||||
|
||||
return planWithFeatures?.features?.included ?? [];
|
||||
}
|
||||
|
||||
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return (
|
||||
normalized.includes("no access to clinepass subscription models yet") &&
|
||||
normalized.includes("subscribe to clinepass")
|
||||
);
|
||||
}
|
||||
|
||||
export function isClinePassSubscriptionError(error: unknown): boolean {
|
||||
if (isClineNotSubscribedError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClineNotSubscribedError" ||
|
||||
isClineNotSubscribedMessage(error.message) ||
|
||||
isFormattedClinePassSubscriptionMessage(error.message)
|
||||
);
|
||||
}
|
||||
return (
|
||||
typeof error === "string" &&
|
||||
(isClineNotSubscribedMessage(error) ||
|
||||
isFormattedClinePassSubscriptionMessage(error))
|
||||
);
|
||||
}
|
||||
|
||||
export function isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
error: unknown,
|
||||
): boolean {
|
||||
if (isClineOrgIndividualInferenceSubscriptionError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError" ||
|
||||
isClineOrgIndividualInferenceSubscriptionMessage(error.message) ||
|
||||
error.message === getClineOrgIndividualInferenceSubscriptionMessage()
|
||||
);
|
||||
}
|
||||
return (
|
||||
typeof error === "string" &&
|
||||
(isClineOrgIndividualInferenceSubscriptionMessage(error) ||
|
||||
error === getClineOrgIndividualInferenceSubscriptionMessage())
|
||||
);
|
||||
}
|
||||
|
||||
export function getClinePassLimitDetailMessage(
|
||||
error: unknown,
|
||||
): string | undefined {
|
||||
return extractClinePassLimitMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
|
||||
export function isClinePassLimitErrorMessage(error: unknown): boolean {
|
||||
if (isClinePassLimitError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClinePassLimitError" ||
|
||||
isClinePassLimitMessage(error.message)
|
||||
);
|
||||
}
|
||||
return typeof error === "string" && isClinePassLimitMessage(error);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClinePassSubscriptionError(error)) {
|
||||
return getCliNotSubscribedMessage();
|
||||
}
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
|
||||
return getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
}
|
||||
if (isClinePassLimitErrorMessage(error)) {
|
||||
return getCliClinePassLimitMessage(
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
disposeCliFeatureFlagsService,
|
||||
getCliFeatureFlagsService,
|
||||
} from "./feature-flags";
|
||||
|
||||
describe("CLI feature flags singleton", () => {
|
||||
afterEach(async () => {
|
||||
await disposeCliFeatureFlagsService();
|
||||
});
|
||||
|
||||
it("recreates the singleton after disposal", async () => {
|
||||
const service = getCliFeatureFlagsService();
|
||||
|
||||
await disposeCliFeatureFlagsService();
|
||||
|
||||
expect(getCliFeatureFlagsService()).not.toBe(service);
|
||||
});
|
||||
});
|
||||
@@ -1,118 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
type BasicLogger,
|
||||
type FeatureFlagsContext,
|
||||
FeatureFlagsService,
|
||||
type ITelemetryService,
|
||||
NoOpFeatureFlagsProvider,
|
||||
registerDisposable,
|
||||
resolveCoreDistinctId,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
buildClinePostHogClient,
|
||||
PostHogFeatureFlagsProvider,
|
||||
} from "@cline/core/services/feature-flags/posthog";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
let cliFeatureFlagsContext: FeatureFlagsContext = { clientName: "cline-cli" };
|
||||
let cliFeatureFlagsService: FeatureFlagsService | undefined;
|
||||
|
||||
const CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function resolveCliFeatureFlagsCachePath(): string {
|
||||
return join(resolveClineDataDir(), "cache", "feature-flags.json");
|
||||
}
|
||||
|
||||
function ensureCliDistinctId(): string {
|
||||
const distinctId = cliFeatureFlagsContext.distinctId?.trim();
|
||||
if (distinctId) {
|
||||
return distinctId;
|
||||
}
|
||||
const resolved = resolveCoreDistinctId();
|
||||
cliFeatureFlagsContext.distinctId = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getCliFeatureFlagsContext(): FeatureFlagsContext {
|
||||
ensureCliDistinctId();
|
||||
return { ...cliFeatureFlagsContext };
|
||||
}
|
||||
|
||||
export function getCliFeatureFlagsService(options?: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
}): FeatureFlagsService {
|
||||
if (!cliFeatureFlagsService) {
|
||||
const apiKey = process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
const provider =
|
||||
apiKey &&
|
||||
process.env.IS_TEST !== "true" &&
|
||||
process.env.E2E_TEST !== "true"
|
||||
? new PostHogFeatureFlagsProvider({
|
||||
client: buildClinePostHogClient(apiKey),
|
||||
config: {
|
||||
logger: options?.logger,
|
||||
},
|
||||
})
|
||||
: new NoOpFeatureFlagsProvider();
|
||||
|
||||
cliFeatureFlagsService = new FeatureFlagsService({
|
||||
provider,
|
||||
telemetry: options?.telemetry,
|
||||
logger: options?.logger,
|
||||
context: getCliFeatureFlagsContext(),
|
||||
cacheFilePath: resolveCliFeatureFlagsCachePath(),
|
||||
persistentCacheMaxAgeMs: CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS,
|
||||
});
|
||||
registerDisposable(disposeCliFeatureFlagsService);
|
||||
}
|
||||
|
||||
return cliFeatureFlagsService;
|
||||
}
|
||||
|
||||
export function refreshCliFeatureFlagsInBackground(logger?: BasicLogger): void {
|
||||
const service = getCliFeatureFlagsService({ logger });
|
||||
void service.poll().catch((error) => {
|
||||
logger?.error?.("Error refreshing CLI feature flags", { error });
|
||||
});
|
||||
}
|
||||
|
||||
export async function disposeCliFeatureFlagsService(): Promise<void> {
|
||||
if (!cliFeatureFlagsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = cliFeatureFlagsService;
|
||||
cliFeatureFlagsService = undefined;
|
||||
await current.dispose();
|
||||
}
|
||||
|
||||
export function setCliFeatureFlagsAccountContext(account: {
|
||||
id?: string;
|
||||
email?: string;
|
||||
}): void {
|
||||
const accountId = account.id?.trim();
|
||||
cliFeatureFlagsContext = {
|
||||
...cliFeatureFlagsContext,
|
||||
...(accountId ? { distinctId: accountId, userId: accountId } : {}),
|
||||
...(account.email?.trim() ? { email: account.email.trim() } : {}),
|
||||
};
|
||||
cliFeatureFlagsService?.setContext(getCliFeatureFlagsContext());
|
||||
}
|
||||
|
||||
export async function identifyFeatureFlagsAccount(
|
||||
account: { id?: string; email?: string },
|
||||
logger?: BasicLogger,
|
||||
): Promise<void> {
|
||||
setCliFeatureFlagsAccountContext(account);
|
||||
|
||||
if (!cliFeatureFlagsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cliFeatureFlagsService.poll();
|
||||
} catch (error) {
|
||||
logger?.error?.("Error polling CLI feature flags", { error });
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearClineFreeModelCostCache,
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "./free-model-cost";
|
||||
|
||||
afterEach(() => {
|
||||
clearClineFreeModelCostCache();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("shouldZeroClineFreeModelCost", () => {
|
||||
it("uses the Cline free model list", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://cline.test/api/v1/ai/cline/recommended-models",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not zero non-Cline providers", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "openrouter",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("zeros cost of free models selected on the cline-pass provider", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline-pass",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
// subscription (cline-pass/...) models are not in the free bucket
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline-pass",
|
||||
modelId: "cline-pass/glm-5.1",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("does not match a paid model by only the final path segment", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "acme/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("retries after a failed free model list fetch", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zeroCliUsageCost", () => {
|
||||
it("zeros total cost while preserving token usage", () => {
|
||||
expect(
|
||||
zeroCliUsageCost(
|
||||
{
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.001,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("zeroCliAgentEventCost", () => {
|
||||
it("zeros usage event cost fields", () => {
|
||||
const event = {
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cost: 0.001,
|
||||
totalCost: 0.001,
|
||||
} as AgentEvent;
|
||||
|
||||
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("zeros done event usage cost", () => {
|
||||
const event = {
|
||||
type: "done",
|
||||
reason: "completed",
|
||||
text: "ok",
|
||||
iterations: 1,
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.001,
|
||||
},
|
||||
} as AgentEvent;
|
||||
|
||||
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
|
||||
usage: { totalCost: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { Config } from "./types";
|
||||
|
||||
const CLINE_RECOMMENDED_MODELS_TIMEOUT_MS = 5_000;
|
||||
const freeModelIdsByBaseUrl = new Map<
|
||||
string,
|
||||
Promise<readonly string[] | undefined>
|
||||
>();
|
||||
|
||||
function normalizeModelId(modelId: string | undefined): string {
|
||||
return modelId?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function modelIdsMatch(selectedModelId: string, freeModelId: string): boolean {
|
||||
const selected = normalizeModelId(selectedModelId);
|
||||
const free = normalizeModelId(freeModelId);
|
||||
if (!selected || !free) return false;
|
||||
return selected === free;
|
||||
}
|
||||
|
||||
function resolveClineRecommendedModelsUrl(baseUrl: string): string {
|
||||
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
|
||||
const apiBaseUrl = normalizedBaseUrl.endsWith("/api/v1")
|
||||
? normalizedBaseUrl.slice(0, -"/api/v1".length)
|
||||
: normalizedBaseUrl;
|
||||
return `${apiBaseUrl}/api/v1/ai/cline/recommended-models`;
|
||||
}
|
||||
|
||||
async function fetchClineFreeModelIds(
|
||||
baseUrl: string,
|
||||
): Promise<readonly string[] | undefined> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
CLINE_RECOMMENDED_MODELS_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetch(resolveClineRecommendedModelsUrl(baseUrl), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const json = (await response.json()) as { free?: unknown };
|
||||
return Array.isArray(json.free)
|
||||
? json.free
|
||||
.map((model) =>
|
||||
model && typeof model === "object"
|
||||
? (model as Record<string, unknown>).id
|
||||
: undefined,
|
||||
)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
: [];
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
|
||||
const cacheKey = baseUrl.trim();
|
||||
let cached = freeModelIdsByBaseUrl.get(cacheKey);
|
||||
if (!cached) {
|
||||
cached = fetchClineFreeModelIds(cacheKey).then((ids) => {
|
||||
if (!ids) freeModelIdsByBaseUrl.delete(cacheKey);
|
||||
return ids;
|
||||
});
|
||||
freeModelIdsByBaseUrl.set(cacheKey, cached);
|
||||
}
|
||||
return cached.then((ids) => ids ?? []);
|
||||
}
|
||||
|
||||
export async function shouldZeroClineFreeModelCost(
|
||||
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
|
||||
): Promise<boolean> {
|
||||
// Free models are also selectable on ClinePass — they ride usage billing at $0
|
||||
if (config.providerId !== "cline" && config.providerId !== "cline-pass")
|
||||
return false;
|
||||
const modelId = normalizeModelId(config.modelId);
|
||||
if (!modelId) return false;
|
||||
|
||||
const baseUrl =
|
||||
config.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
|
||||
const freeModelIds = await getClineFreeModelIds(baseUrl);
|
||||
return freeModelIds.some((freeModelId) =>
|
||||
modelIdsMatch(modelId, freeModelId),
|
||||
);
|
||||
}
|
||||
|
||||
export function zeroCliUsageCost<T extends { totalCost?: number } | undefined>(
|
||||
usage: T,
|
||||
shouldZeroCost: boolean,
|
||||
): T {
|
||||
if (
|
||||
!shouldZeroCost ||
|
||||
!usage ||
|
||||
typeof usage.totalCost !== "number" ||
|
||||
usage.totalCost === 0
|
||||
) {
|
||||
return usage;
|
||||
}
|
||||
return { ...usage, totalCost: 0 } as T;
|
||||
}
|
||||
|
||||
export function zeroCliAgentEventCost(
|
||||
event: AgentEvent,
|
||||
shouldZeroCost: boolean,
|
||||
): AgentEvent {
|
||||
if (!shouldZeroCost) return event;
|
||||
if (event.type === "done" && event.usage) {
|
||||
return {
|
||||
...event,
|
||||
usage: zeroCliUsageCost(event.usage, true),
|
||||
};
|
||||
}
|
||||
if (event.type !== "usage") return event;
|
||||
const next = { ...event } as Record<string, unknown>;
|
||||
if (typeof next.cost === "number") next.cost = 0;
|
||||
if (typeof next.totalCost === "number") next.totalCost = 0;
|
||||
return next as unknown as AgentEvent;
|
||||
}
|
||||
|
||||
export function clearClineFreeModelCostCache(): void {
|
||||
freeModelIdsByBaseUrl.clear();
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildHistoryResumeArgs } from "./history-resume";
|
||||
|
||||
describe("buildHistoryResumeArgs", () => {
|
||||
it("replaces the history subcommand with --id", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["history"],
|
||||
}),
|
||||
).toEqual(["--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("preserves global flags that precede the subcommand", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: [
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"history",
|
||||
"--limit",
|
||||
"5",
|
||||
],
|
||||
remainingArgs: ["history", "--limit", "5"],
|
||||
}),
|
||||
).toEqual([
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"--id",
|
||||
"sess_1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a global flag value that matches the subcommand alias", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["-m", "h", "h"],
|
||||
remainingArgs: ["h"],
|
||||
}),
|
||||
).toEqual(["-m", "h", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("forwards a config dir passed as a subcommand option", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history", "--config", "/tmp/conf"],
|
||||
remainingArgs: ["history", "--config", "/tmp/conf"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("does not duplicate a config dir already in the global flags", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["--config", "/tmp/conf", "history"],
|
||||
remainingArgs: ["history"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("recognizes the --config=<dir> spelling in global flags", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["--config=/tmp/conf", "history"],
|
||||
remainingArgs: ["history"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config=/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("returns undefined when remaining args are not a suffix of argv", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history", "--limit", "5"],
|
||||
remainingArgs: ["history", "--limit", "9"],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["extra", "history"],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -1,115 +0,0 @@
|
||||
import { resolveCliLaunchSpec } from "./internal-launch";
|
||||
|
||||
export interface HistoryResumeCommand {
|
||||
launcher: string;
|
||||
childArgs: string[];
|
||||
}
|
||||
|
||||
export interface BuildHistoryResumeArgsInput {
|
||||
sessionId: string;
|
||||
/** Full normalized CLI args (process.argv.slice(2) after normalization). */
|
||||
normalizedArgs: string[];
|
||||
/**
|
||||
* Commander's `program.args` after parsing: the `history` subcommand token
|
||||
* and everything following it. Must be a suffix of `normalizedArgs`.
|
||||
*/
|
||||
remainingArgs: string[];
|
||||
/**
|
||||
* Config dir resolved from the full argv. Forwarded explicitly because
|
||||
* `--config` may have been passed as a `history` subcommand option, which
|
||||
* would otherwise be dropped with the rest of the subcommand args.
|
||||
*/
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds argv for relaunching the CLI as `cline <globalFlags> --id <sessionId>`
|
||||
* after a session is picked in `cline history`. Returns undefined when the
|
||||
* global-flag prefix cannot be derived safely (caller falls back to resuming
|
||||
* in-process).
|
||||
*/
|
||||
export function buildHistoryResumeArgs(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): string[] | undefined {
|
||||
const { sessionId, normalizedArgs, remainingArgs, configDir } = input;
|
||||
const splitIndex = normalizedArgs.length - remainingArgs.length;
|
||||
if (splitIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
for (let i = 0; i < remainingArgs.length; i++) {
|
||||
if (normalizedArgs[splitIndex + i] !== remainingArgs[i]) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const globalArgs = normalizedArgs.slice(0, splitIndex);
|
||||
const args = [...globalArgs];
|
||||
const hasConfigFlag = globalArgs.some(
|
||||
(arg) => arg === "--config" || arg.startsWith("--config="),
|
||||
);
|
||||
if (configDir && !hasConfigFlag) {
|
||||
args.push("--config", configDir);
|
||||
}
|
||||
args.push("--id", sessionId);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function buildHistoryResumeCommand(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): HistoryResumeCommand | undefined {
|
||||
const childArgs = buildHistoryResumeArgs(input);
|
||||
if (!childArgs) {
|
||||
return undefined;
|
||||
}
|
||||
const spec = resolveCliLaunchSpec();
|
||||
if (!spec) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
launcher: spec.launcher,
|
||||
childArgs: [...spec.childArgsPrefix, ...childArgs],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes a history-picked session in a fresh `cline --id <sessionId>` child
|
||||
* process with inherited stdio, and returns its exit code. Creating a second
|
||||
* OpenTUI renderer in the picker's process can crash natively during teardown
|
||||
* (Bun "panic(main thread): Segmentation fault" on Ctrl+C), so the resumed
|
||||
* interactive TUI must get a process of its own.
|
||||
*
|
||||
* Returns undefined when the child cannot be launched; the caller should fall
|
||||
* back to resuming in-process.
|
||||
*/
|
||||
export async function spawnHistoryResume(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): Promise<number | undefined> {
|
||||
const command = buildHistoryResumeCommand(input);
|
||||
if (!command) {
|
||||
return undefined;
|
||||
}
|
||||
const { spawn } = await import("node:child_process");
|
||||
return await new Promise<number | undefined>((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(command.launcher, command.childArgs, {
|
||||
stdio: "inherit",
|
||||
});
|
||||
} catch {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
// The child shares this foreground process group, so terminal-generated
|
||||
// Ctrl+C already reaches it. Keep the parent alive to reap the child
|
||||
// without re-forwarding a second signal into the TUI teardown path.
|
||||
const suppressParentSignal = () => {};
|
||||
process.on("SIGINT", suppressParentSignal);
|
||||
process.on("SIGTERM", suppressParentSignal);
|
||||
const finish = (value: number | undefined) => {
|
||||
process.off("SIGINT", suppressParentSignal);
|
||||
process.off("SIGTERM", suppressParentSignal);
|
||||
resolve(value);
|
||||
};
|
||||
child.once("error", () => finish(undefined));
|
||||
child.once("exit", (code, signal) => finish(signal ? 1 : (code ?? 0)));
|
||||
});
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listLocalProviders: vi.fn(async () => ({ providers: [], settingsPath: "" })),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
listLocalProviders: mocks.listLocalProviders,
|
||||
};
|
||||
});
|
||||
|
||||
describe("listLocalProviders", () => {
|
||||
it("enables ClinePass when listing the SDK provider list", async () => {
|
||||
const { listLocalProviders } = await import("./provider-catalog");
|
||||
const manager = {} as never;
|
||||
|
||||
await listLocalProviders(manager);
|
||||
|
||||
expect(mocks.listLocalProviders).toHaveBeenCalledWith(manager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import {
|
||||
listLocalProviders as internalListLocalProviders,
|
||||
type ProviderSettingsManager,
|
||||
} from "@cline/core";
|
||||
|
||||
export async function listLocalProviders(
|
||||
manager: ProviderSettingsManager,
|
||||
): ReturnType<typeof internalListLocalProviders> {
|
||||
return await internalListLocalProviders(manager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveCliReasoning } from "./reasoning";
|
||||
|
||||
describe("resolveCliReasoning", () => {
|
||||
it("leaves reasoning unset when neither CLI nor persisted settings specify it", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: undefined,
|
||||
reasoningEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit --thinking none as disabled reasoning", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
thinkingExplicitlySet: true,
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("prefers explicit --thinking over persisted reasoning settings", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: true,
|
||||
thinkingExplicitlySet: true,
|
||||
reasoningEffort: "low",
|
||||
persistedReasoning: { enabled: false },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: true,
|
||||
reasoningEffort: "low",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses persisted disabled reasoning when --thinking is unset", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
persistedReasoning: { enabled: false },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses persisted effort none as disabled reasoning when --thinking is unset", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
persistedReasoning: { effort: "none" },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: false,
|
||||
reasoningEffort: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses persisted active effort when --thinking is unset", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
persistedReasoning: { enabled: true, effort: "high" },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: true,
|
||||
reasoningEffort: "high",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses medium effort when persisted reasoning is enabled without an effort", () => {
|
||||
expect(
|
||||
resolveCliReasoning({
|
||||
thinking: false,
|
||||
persistedReasoning: { enabled: true },
|
||||
}),
|
||||
).toEqual({
|
||||
thinking: true,
|
||||
reasoningEffort: "medium",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { ProviderSettings } from "@cline/core";
|
||||
import type { CliReasoningEffort } from "./types";
|
||||
|
||||
type ActiveCliReasoningEffort = Exclude<CliReasoningEffort, "none">;
|
||||
|
||||
const ACTIVE_REASONING_EFFORTS = new Set<ActiveCliReasoningEffort>([
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
]);
|
||||
|
||||
export interface ResolveCliReasoningInput {
|
||||
thinking: boolean;
|
||||
thinkingExplicitlySet?: boolean;
|
||||
reasoningEffort?: CliReasoningEffort;
|
||||
persistedReasoning?: ProviderSettings["reasoning"];
|
||||
}
|
||||
|
||||
export interface ResolvedCliReasoning {
|
||||
thinking?: boolean;
|
||||
reasoningEffort?: ActiveCliReasoningEffort;
|
||||
}
|
||||
|
||||
function isActiveReasoningEffort(
|
||||
effort: unknown,
|
||||
): effort is ActiveCliReasoningEffort {
|
||||
return (
|
||||
typeof effort === "string" &&
|
||||
ACTIVE_REASONING_EFFORTS.has(effort as ActiveCliReasoningEffort)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveCliReasoning({
|
||||
thinking,
|
||||
thinkingExplicitlySet,
|
||||
reasoningEffort,
|
||||
persistedReasoning,
|
||||
}: ResolveCliReasoningInput): ResolvedCliReasoning {
|
||||
if (thinkingExplicitlySet) {
|
||||
return {
|
||||
thinking,
|
||||
reasoningEffort: isActiveReasoningEffort(reasoningEffort)
|
||||
? reasoningEffort
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
persistedReasoning?.enabled === false ||
|
||||
persistedReasoning?.effort === "none"
|
||||
) {
|
||||
return { thinking: false, reasoningEffort: undefined };
|
||||
}
|
||||
|
||||
if (isActiveReasoningEffort(persistedReasoning?.effort)) {
|
||||
return { thinking: true, reasoningEffort: persistedReasoning.effort };
|
||||
}
|
||||
|
||||
if (persistedReasoning?.enabled === true) {
|
||||
return { thinking: true, reasoningEffort: "medium" };
|
||||
}
|
||||
|
||||
return { thinking: undefined, reasoningEffort: undefined };
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Llms } from "@cline/core";
|
||||
|
||||
export function shouldShowCliUsageCost(providerId: string): boolean {
|
||||
return Llms.shouldShowProviderUsageCost(providerId);
|
||||
}
|
||||
|
||||
export function shouldShowCliUsageCoveredBySubscription(
|
||||
providerId: string,
|
||||
): boolean {
|
||||
return Llms.resolveProviderUsageCostDisplay(providerId) === "subscription";
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PLATFORMS, shouldIncludeField } from "./platforms";
|
||||
|
||||
describe("connect wizard platform security fields", () => {
|
||||
it("does not ask Telegram users to re-enter the bot username", () => {
|
||||
const telegram = PLATFORMS.find((platform) => platform.id === "telegram");
|
||||
|
||||
expect(telegram?.fields.map((field) => field.label)).toEqual(["Bot token"]);
|
||||
expect(telegram?.fields.map((field) => field.flag)).toEqual(["-k"]);
|
||||
});
|
||||
|
||||
it("rejects unsafe Telegram and Slack access restriction identifiers", () => {
|
||||
const telegram = PLATFORMS.find((platform) => platform.id === "telegram");
|
||||
const slack = PLATFORMS.find((platform) => platform.id === "slack");
|
||||
|
||||
const telegramUser = telegram?.security?.fields.find(
|
||||
(field) => field.key === "userId",
|
||||
);
|
||||
const slackTeam = slack?.security?.fields.find(
|
||||
(field) => field.key === "teamId",
|
||||
);
|
||||
const slackUser = slack?.security?.fields.find(
|
||||
(field) => field.key === "userId",
|
||||
);
|
||||
|
||||
expect(telegramUser?.validate?.("123456")).toBeUndefined();
|
||||
expect(telegramUser?.validate?.("123; rm -rf /")).toContain("digits");
|
||||
expect(slackTeam?.validate?.("T01ABC123")).toBeUndefined();
|
||||
expect(slackTeam?.validate?.("T01;bad")).toContain("Slack workspace");
|
||||
expect(slackUser?.validate?.("U01ABC123")).toBeUndefined();
|
||||
expect(slackUser?.validate?.("U01$(bad)")).toContain("Slack member");
|
||||
});
|
||||
|
||||
it("uses the Telegram allowed user ID flag for wizard security", () => {
|
||||
const telegram = PLATFORMS.find((platform) => platform.id === "telegram");
|
||||
|
||||
const args = telegram?.security?.buildArgs({
|
||||
userId: "123456",
|
||||
});
|
||||
|
||||
expect(args).toEqual(["--allowed-user-id", "123456"]);
|
||||
});
|
||||
|
||||
it("builds an exact-match Slack authorization hook", () => {
|
||||
const slack = PLATFORMS.find((platform) => platform.id === "slack");
|
||||
|
||||
const args = slack?.security?.buildArgs({
|
||||
teamId: "T01ABC123",
|
||||
userId: "U01ABC123",
|
||||
});
|
||||
|
||||
expect(args).toEqual([
|
||||
"--hook-command",
|
||||
`jq -r ".payload.actor.participantKey" | grep -qx "slack:team:T01ABC123:user:U01ABC123" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("asks Slack users for mode-specific setup fields", () => {
|
||||
const slack = PLATFORMS.find((platform) => platform.id === "slack");
|
||||
const fields = slack?.fields ?? [];
|
||||
const webhookValues = { "--base-url": "https://example.test" };
|
||||
const socketValues = { "--base-url": "" };
|
||||
|
||||
expect(fields.map((field) => field.flag)).toEqual([
|
||||
"--bot-token",
|
||||
"--base-url",
|
||||
"--signing-secret",
|
||||
"--app-token",
|
||||
]);
|
||||
expect(
|
||||
fields
|
||||
.filter((field) => shouldIncludeField(field, webhookValues))
|
||||
.map((field) => field.flag),
|
||||
).toEqual(["--bot-token", "--base-url", "--signing-secret"]);
|
||||
expect(
|
||||
fields
|
||||
.filter((field) => shouldIncludeField(field, socketValues))
|
||||
.map((field) => field.flag),
|
||||
).toEqual(["--bot-token", "--base-url", "--app-token"]);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +0,0 @@
|
||||
import {
|
||||
CONNECTOR_PLATFORMS,
|
||||
shouldIncludeConnectorField,
|
||||
} from "@cline/shared";
|
||||
|
||||
export type {
|
||||
ConnectorFieldCondition as FieldCondition,
|
||||
ConnectorFieldDef as FieldDef,
|
||||
ConnectorPlatformDef as PlatformDef,
|
||||
ConnectorSecurityDef as SecurityDef,
|
||||
ConnectorSecurityFieldDef as SecurityFieldDef,
|
||||
} from "@cline/shared";
|
||||
export { CONNECTOR_PLATFORMS, shouldIncludeConnectorField };
|
||||
|
||||
export const PLATFORMS = CONNECTOR_PLATFORMS;
|
||||
export const shouldIncludeField = shouldIncludeConnectorField;
|
||||
@@ -1,45 +0,0 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import {
|
||||
authorizeMcpServerOAuth,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
} from "@cline/core";
|
||||
import open from "open";
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.trim();
|
||||
if (message.length > 0) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export async function authorizeMcpServerOAuthWithBrowser(
|
||||
name: string,
|
||||
options: { throwOnError?: boolean } = {},
|
||||
): Promise<void> {
|
||||
p.log.info("Opening browser for MCP OAuth authorization");
|
||||
try {
|
||||
const result = await authorizeMcpServerOAuth({
|
||||
serverName: name,
|
||||
filePath: resolveDefaultMcpSettingsPath(),
|
||||
openUrl: async (url) => {
|
||||
p.log.message(`Authorization URL: ${url}`);
|
||||
await open(url, { wait: false });
|
||||
},
|
||||
onServerListening: (info) => {
|
||||
p.log.message(`Waiting for OAuth callback at ${info.callbackUrl}`);
|
||||
},
|
||||
});
|
||||
p.log.success(result.message);
|
||||
} catch (error) {
|
||||
if (options.throwOnError === true) {
|
||||
throw error instanceof Error ? error : new Error(toErrorMessage(error));
|
||||
}
|
||||
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
|
||||
p.log.warn(
|
||||
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import {
|
||||
type McpServerOAuthState,
|
||||
McpSettingsUpdateSkippedError,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
updateMcpSettingsFileSync,
|
||||
} from "@cline/core";
|
||||
|
||||
export interface McpServerEntry {
|
||||
name: string;
|
||||
transport: McpTransport;
|
||||
disabled?: boolean;
|
||||
oauth?: McpServerOAuthState;
|
||||
}
|
||||
|
||||
export type McpTransport =
|
||||
| {
|
||||
type: "stdio";
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
| { type: "sse"; url: string; headers?: Record<string, string> }
|
||||
| { type: "streamableHttp"; url: string; headers?: Record<string, string> };
|
||||
|
||||
export function getSettingsPath(): string {
|
||||
return resolveDefaultMcpSettingsPath();
|
||||
}
|
||||
|
||||
export function loadServers(): McpServerEntry[] {
|
||||
const path = getSettingsPath();
|
||||
if (!existsSync(path)) return [];
|
||||
try {
|
||||
const raw = readFileSync(path, "utf-8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
const servers = parsed.mcpServers ?? {};
|
||||
return Object.entries(servers).map(([name, value]) => {
|
||||
const entry = value as Record<string, unknown>;
|
||||
const transport = (entry.transport ?? entry) as McpTransport;
|
||||
const oauth =
|
||||
entry.oauth &&
|
||||
typeof entry.oauth === "object" &&
|
||||
!Array.isArray(entry.oauth)
|
||||
? (entry.oauth as McpServerOAuthState)
|
||||
: undefined;
|
||||
return {
|
||||
name,
|
||||
transport,
|
||||
disabled: entry.disabled === true,
|
||||
oauth,
|
||||
};
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function getOwnServerRecord(
|
||||
servers: Record<string, unknown>,
|
||||
name: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (!Object.hasOwn(servers, name)) {
|
||||
return undefined;
|
||||
}
|
||||
const value = servers[name];
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutate the MCP settings file through @cline/core's locked read-update-write
|
||||
* helper. The mutator must be synchronous and pure; the helper may call it more
|
||||
* than once to verify deterministic output. Throw McpSettingsUpdateSkippedError
|
||||
* for normal no-op cases instead of returning a boolean that callers can ignore.
|
||||
*/
|
||||
function mutateServers(
|
||||
mutate: (servers: Record<string, unknown>) => void,
|
||||
): void {
|
||||
updateMcpSettingsFileSync(getSettingsPath(), (settings) => {
|
||||
const serversValue = settings.mcpServers;
|
||||
const servers =
|
||||
serversValue &&
|
||||
typeof serversValue === "object" &&
|
||||
!Array.isArray(serversValue)
|
||||
? { ...(serversValue as Record<string, unknown>) }
|
||||
: {};
|
||||
mutate(servers);
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
}
|
||||
|
||||
export function addServer(name: string, transport: McpTransport): void {
|
||||
mutateServers((servers) => {
|
||||
servers[name] = { transport };
|
||||
});
|
||||
}
|
||||
|
||||
export function removeServer(name: string): boolean {
|
||||
try {
|
||||
mutateServers((servers) => {
|
||||
if (!(name in servers)) {
|
||||
throw new McpSettingsUpdateSkippedError(
|
||||
`MCP server not found: ${name}`,
|
||||
);
|
||||
}
|
||||
delete servers[name];
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error instanceof McpSettingsUpdateSkippedError) {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateServer(name: string, transport: McpTransport): void {
|
||||
mutateServers((servers) => {
|
||||
const existing =
|
||||
servers[name] && typeof servers[name] === "object"
|
||||
? (servers[name] as Record<string, unknown>)
|
||||
: {};
|
||||
servers[name] = { ...existing, transport };
|
||||
});
|
||||
}
|
||||
|
||||
export function clearServerOAuth(name: string): void {
|
||||
try {
|
||||
mutateServers((servers) => {
|
||||
const existing = getOwnServerRecord(servers, name);
|
||||
if (!existing) {
|
||||
throw new McpSettingsUpdateSkippedError(
|
||||
`MCP server not found: ${name}`,
|
||||
);
|
||||
}
|
||||
delete existing.oauth;
|
||||
servers[name] = existing;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof McpSettingsUpdateSkippedError) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleServer(name: string, disabled: boolean): void {
|
||||
mutateServers((servers) => {
|
||||
const existing =
|
||||
servers[name] && typeof servers[name] === "object"
|
||||
? (servers[name] as Record<string, unknown>)
|
||||
: {};
|
||||
if (disabled) {
|
||||
existing.disabled = true;
|
||||
} else {
|
||||
delete existing.disabled;
|
||||
}
|
||||
servers[name] = existing;
|
||||
});
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
import { CORE_BUILD_VERSION } from "@cline/core";
|
||||
import { isNonLocalBindHost } from "./options";
|
||||
import {
|
||||
handleToolApprovalResponse,
|
||||
rejectOrphanedApprovals,
|
||||
} from "./server/approvals";
|
||||
import { isAuthorizedBrowserToDesktopRequest } from "./server/browser-auth";
|
||||
import {
|
||||
browserConfig,
|
||||
host,
|
||||
inviteUrl,
|
||||
port,
|
||||
publicUrl,
|
||||
roomSecret,
|
||||
webviewDistDir,
|
||||
} from "./server/deps";
|
||||
import { handleDesktopCommand } from "./server/desktop-commands";
|
||||
import {
|
||||
createJsonResponse,
|
||||
isWebviewRoute,
|
||||
WebviewAssets,
|
||||
} from "./server/http";
|
||||
import {
|
||||
attachHub,
|
||||
detachHub,
|
||||
restartHub,
|
||||
syncHubClientsAndSessions,
|
||||
syncHubHealth,
|
||||
} from "./server/hub";
|
||||
import { fetchMarketplaceCatalog } from "./server/marketplace";
|
||||
import {
|
||||
loadModels,
|
||||
runProviderOAuthLogin,
|
||||
saveProviderSettings,
|
||||
sendProviderCatalog,
|
||||
} from "./server/providers";
|
||||
import {
|
||||
abortPeerTurn,
|
||||
deleteSession,
|
||||
forkPeerSession,
|
||||
initializePeer,
|
||||
resetPeer,
|
||||
restorePeerSession,
|
||||
selectSession,
|
||||
sendMessage,
|
||||
} from "./server/sessions";
|
||||
import { HubContext } from "./server/state";
|
||||
import { broadcastHubState, hubStatusPayload } from "./server/state-payloads";
|
||||
import type { BrowserFrame, BrowserPeer } from "./server/types";
|
||||
|
||||
export interface ClineHubDashboardServer {
|
||||
listenUrl: string;
|
||||
publicUrl: string;
|
||||
inviteUrl: string;
|
||||
bindHost: string;
|
||||
inviteRequired: boolean;
|
||||
hubUrl: string | undefined;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
const PUBLIC_BROWSER_PATHS = new Set([
|
||||
"/version",
|
||||
"/health",
|
||||
"/config.json",
|
||||
"/api/marketplace/catalog",
|
||||
"/icon.png",
|
||||
"/icon.svg",
|
||||
"/icon.ico",
|
||||
"/32x32.png",
|
||||
"/cline-logo-filled.svg",
|
||||
"/favicon.svg",
|
||||
]);
|
||||
|
||||
function isPublicStaticAssetPath(pathname: string): boolean {
|
||||
return pathname.startsWith("/assets/") || PUBLIC_BROWSER_PATHS.has(pathname);
|
||||
}
|
||||
|
||||
function isPublicBrowserRoute(_req: Request, url: URL): boolean {
|
||||
return isWebviewRoute(url.pathname) || isPublicStaticAssetPath(url.pathname);
|
||||
}
|
||||
|
||||
export async function startClineHubDashboardServer(): Promise<ClineHubDashboardServer> {
|
||||
const ctx = new HubContext();
|
||||
const assets = new WebviewAssets(webviewDistDir);
|
||||
const syncClientsAndSessions = () => syncHubClientsAndSessions(ctx);
|
||||
let stopped = false;
|
||||
|
||||
await attachHub(ctx);
|
||||
const healthInterval = setInterval(() => {
|
||||
void (async () => {
|
||||
await syncHubHealth(ctx);
|
||||
broadcastHubState(ctx);
|
||||
})();
|
||||
}, 5_000);
|
||||
|
||||
const server = Bun.serve<BrowserPeer>({
|
||||
port,
|
||||
hostname: host,
|
||||
async fetch(req, server) {
|
||||
const url = new URL(req.url);
|
||||
if (
|
||||
!isAuthorizedBrowserToDesktopRequest(
|
||||
req,
|
||||
url,
|
||||
{
|
||||
bindHost: host,
|
||||
port,
|
||||
publicUrl,
|
||||
roomSecret,
|
||||
},
|
||||
isPublicBrowserRoute,
|
||||
)
|
||||
) {
|
||||
return createJsonResponse({ error: "unauthorized_browser" }, 403);
|
||||
}
|
||||
if (url.pathname === "/version") {
|
||||
return createJsonResponse({ coreVersion: CORE_BUILD_VERSION });
|
||||
}
|
||||
if (url.pathname === "/health") {
|
||||
await syncHubHealth(ctx);
|
||||
return createJsonResponse(hubStatusPayload(ctx));
|
||||
}
|
||||
if (url.pathname === "/browser") {
|
||||
const displayName = `Browser ${Math.random().toString(36).slice(2, 6)}`;
|
||||
const data = {
|
||||
socket: undefined as never,
|
||||
displayName,
|
||||
sending: false,
|
||||
};
|
||||
if (server.upgrade(req, { data })) return undefined;
|
||||
return new Response("upgrade failed", { status: 400 });
|
||||
}
|
||||
if (url.pathname === "/config.json") {
|
||||
return createJsonResponse(browserConfig);
|
||||
}
|
||||
if (url.pathname === "/api/marketplace/catalog") {
|
||||
try {
|
||||
return createJsonResponse(await fetchMarketplaceCatalog());
|
||||
} catch (error) {
|
||||
return createJsonResponse(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch marketplace catalog",
|
||||
},
|
||||
502,
|
||||
);
|
||||
}
|
||||
}
|
||||
return assets.serve(url.pathname);
|
||||
},
|
||||
websocket: {
|
||||
async open(socket) {
|
||||
const peer = socket.data;
|
||||
peer.socket = socket;
|
||||
ctx.peers.add(peer);
|
||||
},
|
||||
async message(socket, raw) {
|
||||
const peer = socket.data;
|
||||
try {
|
||||
const frame = JSON.parse(String(raw)) as BrowserFrame;
|
||||
if (frame.type === "desktopCommand") {
|
||||
try {
|
||||
const result = await handleDesktopCommand(
|
||||
ctx,
|
||||
frame.command,
|
||||
frame.args,
|
||||
);
|
||||
ctx.send(peer, {
|
||||
type: "desktopCommandResult",
|
||||
id: frame.id,
|
||||
ok: true,
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.send(peer, {
|
||||
type: "desktopCommandResult",
|
||||
id: frame.id,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
} else if (frame.type === "ready") {
|
||||
await initializePeer(ctx, peer, syncClientsAndSessions);
|
||||
} else if (frame.type === "loadModels") {
|
||||
await loadModels(ctx, peer, frame.providerId);
|
||||
} else if (frame.type === "loadProviderCatalog") {
|
||||
await sendProviderCatalog(ctx, peer);
|
||||
} else if (frame.type === "saveProviderSettings") {
|
||||
await saveProviderSettings(ctx, peer, frame);
|
||||
} else if (frame.type === "runProviderOAuthLogin") {
|
||||
await runProviderOAuthLogin(ctx, peer, frame.providerId);
|
||||
} else if (frame.type === "attachSession") {
|
||||
await selectSession(ctx, peer, frame.sessionId);
|
||||
} else if (frame.type === "deleteSession") {
|
||||
await deleteSession(ctx, peer, frame.sessionId);
|
||||
} else if (frame.type === "updateSessionMetadata") {
|
||||
if (!ctx.cline) throw new Error("Hub is not connected.");
|
||||
const session = await ctx.cline.get(frame.sessionId);
|
||||
const metadata =
|
||||
session?.metadata && typeof session.metadata === "object"
|
||||
? (session.metadata as Record<string, unknown>)
|
||||
: {};
|
||||
await ctx.cline.update(frame.sessionId, {
|
||||
metadata: { ...metadata, ...frame.metadata },
|
||||
});
|
||||
await syncHubClientsAndSessions(ctx);
|
||||
broadcastHubState(ctx);
|
||||
} else if (frame.type === "approval_response") {
|
||||
handleToolApprovalResponse(ctx, frame);
|
||||
} else if (frame.type === "abort") {
|
||||
await abortPeerTurn(ctx, peer);
|
||||
} else if (frame.type === "reset") {
|
||||
await resetPeer(ctx, peer);
|
||||
} else if (frame.type === "send") {
|
||||
if (peer.sending) {
|
||||
ctx.send(peer, {
|
||||
type: "status",
|
||||
text: "A turn is already in progress.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
peer.sending = true;
|
||||
try {
|
||||
await sendMessage(
|
||||
ctx,
|
||||
peer,
|
||||
frame.prompt,
|
||||
frame.config,
|
||||
frame.attachments,
|
||||
);
|
||||
} finally {
|
||||
peer.sending = false;
|
||||
}
|
||||
} else if (frame.type === "forkSession") {
|
||||
await forkPeerSession(ctx, peer, syncClientsAndSessions);
|
||||
} else if (frame.type === "restore") {
|
||||
await restorePeerSession(
|
||||
ctx,
|
||||
peer,
|
||||
frame.checkpointRunCount,
|
||||
syncClientsAndSessions,
|
||||
);
|
||||
} else if (frame.type === "restart_hub") {
|
||||
await restartHub(ctx);
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.send(peer, {
|
||||
type: "error",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
},
|
||||
close(socket) {
|
||||
const peer = socket.data;
|
||||
peer.unsubscribeEvents?.();
|
||||
ctx.peers.delete(peer);
|
||||
rejectOrphanedApprovals(ctx);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
listenUrl: server.url.toString(),
|
||||
publicUrl,
|
||||
inviteUrl,
|
||||
bindHost: host,
|
||||
inviteRequired: Boolean(roomSecret),
|
||||
hubUrl: ctx.hubUrl,
|
||||
stop: async () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
clearInterval(healthInterval);
|
||||
try {
|
||||
server.stop(true);
|
||||
} finally {
|
||||
await detachHub(ctx);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function printClineHubDashboardServerInfo(
|
||||
server: ClineHubDashboardServer,
|
||||
): void {
|
||||
console.log(`Cline Hub dashboard listening: ${server.listenUrl}`);
|
||||
console.log(`Cline Hub public URL: ${server.publicUrl}`);
|
||||
console.log(`hub endpoint: ${server.hubUrl}`);
|
||||
if (server.inviteRequired) {
|
||||
console.log(`Cline Hub invite URL: ${server.inviteUrl}`);
|
||||
} else if (isNonLocalBindHost(server.bindHost)) {
|
||||
console.warn("WARNING: non-local bind without ROOM_SECRET is not allowed.");
|
||||
} else {
|
||||
console.log(
|
||||
"ROOM_SECRET is not set; this local-only instance accepts browser connections without an invite token.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const server = await startClineHubDashboardServer();
|
||||
printClineHubDashboardServerInfo(server);
|
||||
}
|
||||
@@ -1,359 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
allowedBrowserHosts,
|
||||
allowedBrowserOrigins,
|
||||
isAuthorizedBrowserRequest,
|
||||
isAuthorizedBrowserToDesktopRequest,
|
||||
requiresBrowserRequestAuth,
|
||||
} from "./browser-auth";
|
||||
|
||||
const defaultOptions = {
|
||||
bindHost: "127.0.0.1",
|
||||
port: 8787,
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
};
|
||||
|
||||
const publicRoute = (_req: Request, url: URL) => url.pathname === "/public";
|
||||
|
||||
function browserRequest(
|
||||
origin?: string,
|
||||
init?: Omit<RequestInit, "headers"> & {
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
): Request {
|
||||
return new Request("http://127.0.0.1:8787/browser", {
|
||||
...init,
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
...(origin === undefined ? {} : { origin }),
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("allowedBrowserOrigins", () => {
|
||||
it("allows the configured public URL origin and local aliases for local binds", () => {
|
||||
expect([...allowedBrowserOrigins(defaultOptions)].sort()).toEqual([
|
||||
"http://127.0.0.1:8787",
|
||||
"http://[::1]:8787",
|
||||
"http://localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the configured public URL scheme for local aliases", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
...defaultOptions,
|
||||
publicUrl: "https://127.0.0.1:8787",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual([
|
||||
"https://127.0.0.1:8787",
|
||||
"https://[::1]:8787",
|
||||
"https://localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits default protocol ports for local alias origins", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 80,
|
||||
publicUrl: "http://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["http://127.0.0.1", "http://[::1]", "http://localhost"]);
|
||||
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 443,
|
||||
publicUrl: "https://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["https://127.0.0.1", "https://[::1]", "https://localhost"]);
|
||||
});
|
||||
|
||||
it("allows the configured public URL origin and explicit bind origin for non-local binds", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserOrigins({
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "https://example.ngrok-free.app",
|
||||
roomSecret: "secret",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["https://0.0.0.0:8787", "https://example.ngrok-free.app"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("allowedBrowserHosts", () => {
|
||||
it("allows the configured public URL host and local aliases for local binds", () => {
|
||||
expect([...allowedBrowserHosts(defaultOptions)].sort()).toEqual([
|
||||
"127.0.0.1:8787",
|
||||
"[::1]:8787",
|
||||
"localhost:8787",
|
||||
]);
|
||||
});
|
||||
|
||||
it("omits default protocol ports for local alias hosts", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 80,
|
||||
publicUrl: "http://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
|
||||
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "127.0.0.1",
|
||||
port: 443,
|
||||
publicUrl: "https://localhost",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["127.0.0.1", "[::1]", "localhost"]);
|
||||
});
|
||||
|
||||
it("allows the configured public URL host and explicit bind host for non-local binds", () => {
|
||||
expect(
|
||||
[
|
||||
...allowedBrowserHosts({
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "https://example.ngrok-free.app",
|
||||
roomSecret: "secret",
|
||||
}),
|
||||
].sort(),
|
||||
).toEqual(["0.0.0.0:8787", "example.ngrok-free.app"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("requiresBrowserRequestAuth", () => {
|
||||
it("does not require browser auth for public GET routes", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/public"),
|
||||
new URL("http://127.0.0.1:8787/public"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("requires browser auth for unknown paths even when they use GET", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-api"),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for privileged paths even when they use GET", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/browser"),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for every WebSocket upgrade path", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-socket", {
|
||||
headers: { upgrade: "websocket" },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-socket"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires browser auth for every unsafe HTTP method", () => {
|
||||
expect(
|
||||
requiresBrowserRequestAuth(
|
||||
new Request("http://127.0.0.1:8787/future-api", { method: "POST" }),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizedBrowserRequest", () => {
|
||||
it.each([
|
||||
"http://127.0.0.1:8787",
|
||||
"http://localhost:8787",
|
||||
"http://[::1]:8787",
|
||||
])("accepts local dashboard origin %s without a room secret", (origin) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest(origin),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"null",
|
||||
"not a url",
|
||||
"http://evil.attacker.example.com",
|
||||
"http://127.0.0.1:9999",
|
||||
"https://127.0.0.1:8787",
|
||||
])("rejects untrusted origin %s", (origin) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest(origin),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"evil.attacker.example.com",
|
||||
"127.0.0.1:9999",
|
||||
"localhost:9999",
|
||||
])("rejects untrusted host %s", (host) => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787", {
|
||||
headers: host === undefined ? { host: "" } : { host },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
defaultOptions,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows explicit wildcard bind host and origin when a room secret is configured", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://0.0.0.0:8787", {
|
||||
headers: { host: "0.0.0.0:8787" },
|
||||
}),
|
||||
new URL("http://0.0.0.0:8787/browser?roomSecret=invite-123"),
|
||||
{
|
||||
bindHost: "0.0.0.0",
|
||||
port: 8787,
|
||||
publicUrl: "http://127.0.0.1:8787",
|
||||
roomSecret: "invite-123",
|
||||
},
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("requires trusted origin, trusted host, and room secret when a room secret is configured", () => {
|
||||
const options = { ...defaultOptions, roomSecret: "invite-123" };
|
||||
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787"),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787"),
|
||||
new URL("http://127.0.0.1:8787/browser"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://evil.attacker.example.com"),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isAuthorizedBrowserRequest(
|
||||
browserRequest("http://127.0.0.1:8787", {
|
||||
headers: { host: "evil.attacker.example.com" },
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/browser?roomSecret=invite-123"),
|
||||
options,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isAuthorizedBrowserToDesktopRequest", () => {
|
||||
it("allows safe public GET routes without an origin", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/public"),
|
||||
new URL("http://127.0.0.1:8787/public"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects future WebSocket paths from untrusted origins by default", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-socket", {
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://evil.attacker.example.com",
|
||||
upgrade: "websocket",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-socket"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects future unsafe HTTP routes from untrusted origins by default", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://evil.attacker.example.com",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("allows future unsafe HTTP routes from trusted origins", () => {
|
||||
expect(
|
||||
isAuthorizedBrowserToDesktopRequest(
|
||||
new Request("http://127.0.0.1:8787/future-api", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "127.0.0.1:8787",
|
||||
origin: "http://127.0.0.1:8787",
|
||||
},
|
||||
}),
|
||||
new URL("http://127.0.0.1:8787/future-api"),
|
||||
defaultOptions,
|
||||
publicRoute,
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import { isNonLocalBindHost } from "../options";
|
||||
|
||||
export interface BrowserRequestAuthOptions {
|
||||
bindHost: string;
|
||||
port: number;
|
||||
publicUrl: string;
|
||||
roomSecret?: string;
|
||||
}
|
||||
|
||||
const SAFE_HTTP_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
export type PublicBrowserRoutePredicate = (req: Request, url: URL) => boolean;
|
||||
|
||||
function isWebSocketUpgrade(req: Request): boolean {
|
||||
return req.headers.get("upgrade")?.toLowerCase() === "websocket";
|
||||
}
|
||||
|
||||
function parseOrigin(value: string | null): string | undefined {
|
||||
const origin = parseHeader(value);
|
||||
try {
|
||||
return new URL(origin ?? "").origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseHeader(value: string | null): string | undefined {
|
||||
const host = value?.trim().toLowerCase();
|
||||
return host || undefined;
|
||||
}
|
||||
|
||||
function formatHostForOrigin(host: string): string {
|
||||
return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
||||
}
|
||||
|
||||
function isDefaultProtocolPort(protocol: string, port: number): boolean {
|
||||
return (
|
||||
(protocol === "http:" && port === 80) ||
|
||||
(protocol === "https:" && port === 443)
|
||||
);
|
||||
}
|
||||
|
||||
function originForHost(protocol: string, host: string, port: number): string {
|
||||
return new URL(`${protocol}//${formatHostForOrigin(host)}:${port}`).origin;
|
||||
}
|
||||
|
||||
function hostHeaderForHost(
|
||||
protocol: string,
|
||||
host: string,
|
||||
port: number,
|
||||
): string {
|
||||
const formattedHost = formatHostForOrigin(host).toLowerCase();
|
||||
return isDefaultProtocolPort(protocol, port)
|
||||
? formattedHost
|
||||
: `${formattedHost}:${port}`;
|
||||
}
|
||||
|
||||
export function allowedBrowserOrigins({
|
||||
bindHost,
|
||||
port,
|
||||
publicUrl,
|
||||
}: BrowserRequestAuthOptions): Set<string> {
|
||||
const publicUrlParts = new URL(publicUrl);
|
||||
const origins = new Set<string>();
|
||||
origins.add(publicUrlParts.origin);
|
||||
|
||||
origins.add(originForHost(publicUrlParts.protocol, bindHost, port));
|
||||
|
||||
if (!isNonLocalBindHost(bindHost)) {
|
||||
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
|
||||
origins.add(originForHost(publicUrlParts.protocol, hostname, port));
|
||||
}
|
||||
}
|
||||
|
||||
return origins;
|
||||
}
|
||||
|
||||
export function allowedBrowserHosts({
|
||||
bindHost,
|
||||
port,
|
||||
publicUrl,
|
||||
}: BrowserRequestAuthOptions): Set<string> {
|
||||
const publicUrlParts = new URL(publicUrl);
|
||||
const hosts = new Set<string>();
|
||||
const publicHost = publicUrlParts.host.toLowerCase();
|
||||
hosts.add(publicHost);
|
||||
|
||||
hosts.add(hostHeaderForHost(publicUrlParts.protocol, bindHost, port));
|
||||
|
||||
if (!isNonLocalBindHost(bindHost)) {
|
||||
for (const hostname of ["127.0.0.1", "localhost", "[::1]"]) {
|
||||
hosts.add(hostHeaderForHost(publicUrlParts.protocol, hostname, port));
|
||||
}
|
||||
}
|
||||
|
||||
return hosts;
|
||||
}
|
||||
|
||||
export function requiresBrowserRequestAuth(
|
||||
req: Request,
|
||||
url: URL,
|
||||
isPublicBrowserRoute: PublicBrowserRoutePredicate,
|
||||
): boolean {
|
||||
if (isWebSocketUpgrade(req)) return true;
|
||||
if (!SAFE_HTTP_METHODS.has(req.method.toUpperCase())) return true;
|
||||
return !isPublicBrowserRoute(req, url);
|
||||
}
|
||||
|
||||
export function isAuthorizedBrowserRequest(
|
||||
req: Request,
|
||||
url: URL,
|
||||
options: BrowserRequestAuthOptions,
|
||||
): boolean {
|
||||
const host = parseHeader(req.headers.get("host"));
|
||||
if (!host || !allowedBrowserHosts(options).has(host)) return false;
|
||||
|
||||
const origin = parseOrigin(req.headers.get("origin"));
|
||||
if (!origin || !allowedBrowserOrigins(options).has(origin)) return false;
|
||||
|
||||
if (!options.roomSecret) return true;
|
||||
return url.searchParams.get("roomSecret") === options.roomSecret;
|
||||
}
|
||||
|
||||
export function isAuthorizedBrowserToDesktopRequest(
|
||||
req: Request,
|
||||
url: URL,
|
||||
options: BrowserRequestAuthOptions,
|
||||
isPublicBrowserRoute: PublicBrowserRoutePredicate,
|
||||
): boolean {
|
||||
return (
|
||||
!requiresBrowserRequestAuth(req, url, isPublicBrowserRoute) ||
|
||||
isAuthorizedBrowserRequest(req, url, options)
|
||||
);
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __test__ } from "./connectors";
|
||||
|
||||
describe("connector launch command", () => {
|
||||
it("uses Bun conditions when launching the source CLI from Bun", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "/Users/test/.bun/bin/bun",
|
||||
cliPath: "/repo/apps/cli/src/index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "/Users/test/.bun/bin/bun",
|
||||
childArgs: [
|
||||
"--conditions=development",
|
||||
"/repo/apps/cli/src/index.ts",
|
||||
"connect",
|
||||
"telegram",
|
||||
"--bot-token",
|
||||
"token",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses compiled CLI subcommands without Bun flags", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "/Applications/Cline/bin/cline",
|
||||
cliPath: "/repo/apps/cli/src/index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "/Applications/Cline/bin/cline",
|
||||
childArgs: ["connect", "telegram", "--bot-token", "token"],
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Bun conditions when launching the source CLI from Node", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "/usr/local/bin/node",
|
||||
cliPath: "/repo/apps/cli/src/index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "bun",
|
||||
childArgs: [
|
||||
"--conditions=development",
|
||||
"/repo/apps/cli/src/index.ts",
|
||||
"connect",
|
||||
"telegram",
|
||||
"--bot-token",
|
||||
"token",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("detects Windows Node when launching the source CLI", () => {
|
||||
expect(
|
||||
__test__.buildCliConnectCommand(["telegram", "--bot-token", "token"], {
|
||||
execPath: "node.exe",
|
||||
cliPath: "C:\\repo\\apps\\cli\\src\\index.ts",
|
||||
exists: () => true,
|
||||
}),
|
||||
).toEqual({
|
||||
launcher: "bun",
|
||||
childArgs: [
|
||||
"--conditions=development",
|
||||
"C:\\repo\\apps\\cli\\src\\index.ts",
|
||||
"connect",
|
||||
"telegram",
|
||||
"--bot-token",
|
||||
"token",
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("strips terminal color codes from connector command failures", () => {
|
||||
expect(
|
||||
__test__.normalizeConnectorError(
|
||||
"\u001B[31merror:\u001B[0m error: unknown option '--conditions=development'",
|
||||
"connector start failed",
|
||||
),
|
||||
).toBe("unknown option '--conditions=development'");
|
||||
});
|
||||
|
||||
it("turns Telegram unauthorized responses into a token validation message", () => {
|
||||
expect(
|
||||
__test__.normalizeConnectorError(
|
||||
"\u001B[31merror:\u001B[0m Telegram getMe failed (401 Unauthorized): Unauthorized",
|
||||
"connector start failed",
|
||||
),
|
||||
).toBe(
|
||||
"Telegram rejected this bot token. Copy the token from @BotFather and try again.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,54 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isWebviewRoute, normalizeWebviewIndexHtml } from "./http";
|
||||
|
||||
describe("isWebviewRoute", () => {
|
||||
it.each([
|
||||
"/",
|
||||
"/chat",
|
||||
"/sessions",
|
||||
"/models",
|
||||
"/customizations",
|
||||
"/rules",
|
||||
"/hooks",
|
||||
"/mcp",
|
||||
"/plugins",
|
||||
"/skills",
|
||||
"/agents",
|
||||
"/tools",
|
||||
"/marketplace",
|
||||
"/marketplace/mcp",
|
||||
"/marketplace/skills",
|
||||
"/marketplace/plugins",
|
||||
"/channels",
|
||||
"/schedules",
|
||||
"/settings",
|
||||
"/settings/providers",
|
||||
])("matches dashboard SPA route %s", (pathname) => {
|
||||
expect(isWebviewRoute(pathname)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat nested marketplace asset requests as SPA routes", () => {
|
||||
expect(isWebviewRoute("/marketplace/assets/index.js")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeWebviewIndexHtml", () => {
|
||||
it("rewrites relative built asset URLs so deep links can refresh", () => {
|
||||
expect(
|
||||
normalizeWebviewIndexHtml(
|
||||
'<script type="module" src="./assets/index.js"></script><link href="./assets/index.css">',
|
||||
),
|
||||
).toBe(
|
||||
'<script type="module" src="/assets/index.js"></script><link href="/assets/index.css">',
|
||||
);
|
||||
});
|
||||
|
||||
it("injects the persisted theme bootstrap once", () => {
|
||||
const normalized = normalizeWebviewIndexHtml(
|
||||
"<html><head></head><body></body></html>",
|
||||
);
|
||||
|
||||
expect(normalized).toContain('id="cline-hub-theme-bootstrap"');
|
||||
expect(normalizeWebviewIndexHtml(normalized)).toBe(normalized);
|
||||
});
|
||||
});
|
||||
@@ -1,954 +0,0 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildMarketplaceMcpInput,
|
||||
fetchMarketplaceCatalog,
|
||||
installMarketplaceEntry,
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
uninstallLocalPrimitive,
|
||||
uninstallMarketplaceEntry,
|
||||
uninstallMarketplaceEntryForDesktopCommand,
|
||||
} from "./marketplace";
|
||||
|
||||
describe("marketplace installer", () => {
|
||||
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
|
||||
const originalClineDir = process.env.CLINE_DIR;
|
||||
const originalHome = process.env.HOME;
|
||||
const originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
|
||||
}
|
||||
if (originalClineDir === undefined) {
|
||||
delete process.env.CLINE_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DIR = originalClineDir;
|
||||
}
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
if (originalMcpSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createInstalledOfficialPlugin(
|
||||
clineDir: string,
|
||||
slug: string,
|
||||
): string {
|
||||
const sourceKey = `official:https://github.com/cline/plugins.git#plugins/${slug}`;
|
||||
const hash = createHash("sha256")
|
||||
.update(sourceKey)
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
const installPath = join(
|
||||
clineDir,
|
||||
"plugins",
|
||||
"_installed",
|
||||
"official",
|
||||
`${slug}-${hash}`,
|
||||
);
|
||||
mkdirSync(join(installPath, "package"), { recursive: true });
|
||||
writeFileSync(
|
||||
join(installPath, "package.json"),
|
||||
JSON.stringify({ name: slug }, null, 2),
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(installPath, "package", "index.ts"),
|
||||
`export default { name: "${slug}", manifest: { capabilities: ["tools"] } };`,
|
||||
"utf8",
|
||||
);
|
||||
return installPath;
|
||||
}
|
||||
|
||||
it("maps remote MCP catalog args to MCP settings shape", () => {
|
||||
expect(
|
||||
buildMarketplaceMcpInput([
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer <token>",
|
||||
]),
|
||||
).toEqual({
|
||||
name: "context7",
|
||||
transportType: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer <token>",
|
||||
},
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps stdio MCP catalog args to command and args", () => {
|
||||
expect(
|
||||
buildMarketplaceMcpInput(["filesystem", "npx", "-y", "server", "/tmp"]),
|
||||
).toEqual({
|
||||
name: "filesystem",
|
||||
transportType: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "server", "/tmp"],
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves server flags after stdio MCP command args begin", () => {
|
||||
expect(
|
||||
buildMarketplaceMcpInput([
|
||||
"search",
|
||||
"npx",
|
||||
"-y",
|
||||
"server",
|
||||
"--transport",
|
||||
"stdio",
|
||||
]),
|
||||
).toEqual({
|
||||
name: "search",
|
||||
transportType: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "server", "--transport", "stdio"],
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("runs skills globally for Cline without prompts", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const spawnCommand = vi.fn(async () => {
|
||||
mkdirSync(join(homeDir, ".agents", "skills", "web-design-guidelines"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(homeDir, ".agents", "skills", "web-design-guidelines", "SKILL.md"),
|
||||
"---\nname: web-design-guidelines\n---\n",
|
||||
);
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "installed",
|
||||
stderr: "",
|
||||
};
|
||||
});
|
||||
|
||||
await installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "web-design-guidelines",
|
||||
type: "skill",
|
||||
name: "Web Design Guidelines",
|
||||
install: {
|
||||
args: [
|
||||
"vercel-labs/agent-skills",
|
||||
"--skill",
|
||||
"web-design-guidelines",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
);
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"add",
|
||||
"vercel-labs/agent-skills",
|
||||
"--skill",
|
||||
"web-design-guidelines",
|
||||
"-g",
|
||||
"-a",
|
||||
"cline",
|
||||
"-y",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips skill install commands when the global skill already exists", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
mkdirSync(join(homeDir, ".agents", "skills", "cline-sdk"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(homeDir, ".agents", "skills", "cline-sdk", "SKILL.md"),
|
||||
"---\nname: cline-sdk\n---\n",
|
||||
);
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "installed",
|
||||
message: "Cline SDK is already installed.",
|
||||
});
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports Cline global skills as marketplace-installed", () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-cline-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
mkdirSync(join(clineDir, "skills", "cline-sdk"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(clineDir, "skills", "cline-sdk", "SKILL.md"),
|
||||
"---\nname: cline-sdk\n---\n",
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ installedKeys: ["skill:cline-sdk"] });
|
||||
});
|
||||
|
||||
it("accepts skill installs that create Cline global skills", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
const clineDir = join(homeDir, ".cline");
|
||||
process.env.HOME = homeDir;
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const spawnCommand = vi.fn(async () => {
|
||||
mkdirSync(join(clineDir, "skills", "cline-sdk"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(clineDir, "skills", "cline-sdk", "SKILL.md"),
|
||||
"---\nname: cline-sdk\n---\n",
|
||||
);
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "installed",
|
||||
stderr: "",
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Cline SDK globally for Cline.",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes Cline global marketplace skills without prompts", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const skillDir = join(homeDir, ".agents", "skills", "cline-sdk");
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: cline-sdk\n---\n");
|
||||
const spawnCommand = vi.fn(async () => {
|
||||
rmSync(skillDir, { recursive: true, force: true });
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "removed",
|
||||
stderr: "",
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
uninstallMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Cline SDK.",
|
||||
});
|
||||
expect(spawnCommand).toHaveBeenCalledWith("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
"cline-sdk",
|
||||
"-g",
|
||||
"-y",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not report project-local skills as marketplace-installed globals", () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries(
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
skills: [
|
||||
{
|
||||
id: "cline-sdk",
|
||||
name: "cline-sdk",
|
||||
path: "/workspace/project/.agents/skills/cline-sdk/SKILL.md",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
).toEqual({ installedKeys: [] });
|
||||
});
|
||||
|
||||
it("rejects skill installs that exit zero but report failure", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "Failed to install 1",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).rejects.toThrow("Skill install failed");
|
||||
});
|
||||
|
||||
it("redacts common secret formats from failed install output", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 1,
|
||||
stdout:
|
||||
"Authorization: Bearer stdout-token\nAuthorization: Basic basic-token\napi key stdout-key\nOPENAI_API_KEY=compound-key",
|
||||
stderr:
|
||||
"TOKEN=stderr-token\npassword is stderr-password\nANTHROPIC_SECRET_KEY=anthropic-secret",
|
||||
}));
|
||||
|
||||
let message = "";
|
||||
try {
|
||||
await installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
);
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
expect(message).toContain("Authorization: Bearer [redacted]");
|
||||
expect(message).toContain("Authorization: [redacted]");
|
||||
expect(message).not.toContain("Authorization: Bearer [redacted]]");
|
||||
expect(message).toContain("api key [redacted]");
|
||||
expect(message).toContain("OPENAI_API_KEY=[redacted]");
|
||||
expect(message).toContain("TOKEN=[redacted]");
|
||||
expect(message).toContain("password is [redacted]");
|
||||
expect(message).toContain("ANTHROPIC_SECRET_KEY=[redacted]");
|
||||
expect(message).not.toContain("stdout-token");
|
||||
expect(message).not.toContain("basic-token");
|
||||
expect(message).not.toContain("stdout-key");
|
||||
expect(message).not.toContain("compound-key");
|
||||
expect(message).not.toContain("stderr-token");
|
||||
expect(message).not.toContain("stderr-password");
|
||||
expect(message).not.toContain("anthropic-secret");
|
||||
});
|
||||
|
||||
it("rejects skill installs before spawning when the global skill directory is not writable", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
mkdirSync(join(homeDir, ".agents"), { recursive: true });
|
||||
writeFileSync(join(homeDir, ".agents", "skills"), "");
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Cannot install skill globally because ~/.agents/skills is not writable",
|
||||
);
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects skill installs that do not create a global skill", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "Installation complete",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).rejects.toThrow("was not found in Cline's global skills directories");
|
||||
});
|
||||
|
||||
it("runs official plugin installs through the current Cline CLI", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({ installPath: "/tmp/plugin" }),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "marketplace-test-plugin",
|
||||
type: "plugin",
|
||||
name: "Marketplace Test Plugin",
|
||||
install: { args: ["marketplace-test-plugin"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
);
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"plugin",
|
||||
"install",
|
||||
"marketplace-test-plugin",
|
||||
"--json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs MCP installs through the current Cline CLI without prompts", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({
|
||||
name: "context7",
|
||||
status: "installed",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
headers: {
|
||||
Authorization: "Bearer token",
|
||||
},
|
||||
},
|
||||
}),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "context7",
|
||||
type: "mcp",
|
||||
name: "Context7",
|
||||
install: {
|
||||
args: [
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Context7.",
|
||||
details: {
|
||||
name: "context7",
|
||||
status: "installed",
|
||||
},
|
||||
});
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"mcp",
|
||||
"install",
|
||||
"--yes",
|
||||
"--json",
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
"--header",
|
||||
"Authorization: Bearer token",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uninstalls official marketplace plugins through the shared core service", async () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-plugin-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const installPath = createInstalledOfficialPlugin(clineDir, "goal");
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
uninstallMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Goal.",
|
||||
});
|
||||
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
expect(existsSync(installPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves desktop installs from the server catalog instead of browser-sent args", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({ installPath: "/tmp/plugin" }),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await installMarketplaceEntryForDesktopCommand(
|
||||
{
|
||||
entry: {
|
||||
id: "marketplace-test-plugin",
|
||||
type: "plugin",
|
||||
name: "Tampered",
|
||||
install: { args: ["malicious-source"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
spawnCommand,
|
||||
loadCatalog: async () => ({
|
||||
entries: [
|
||||
{
|
||||
id: "marketplace-test-plugin",
|
||||
type: "plugin",
|
||||
name: "Marketplace Test Plugin",
|
||||
install: { args: ["marketplace-test-plugin"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"plugin",
|
||||
"install",
|
||||
"marketplace-test-plugin",
|
||||
"--json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves desktop uninstalls from the server catalog instead of browser-sent args", async () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-plugin-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const installPath = createInstalledOfficialPlugin(clineDir, "goal");
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await uninstallMarketplaceEntryForDesktopCommand(
|
||||
{
|
||||
entry: {
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Tampered",
|
||||
install: { args: ["malicious-source"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
spawnCommand,
|
||||
loadCatalog: async () => ({
|
||||
entries: [
|
||||
{
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
expect(existsSync(installPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("uninstalls MCP marketplace entries from Cline MCP settings", async () => {
|
||||
const settingsPath = join(
|
||||
mkdtempSync(join(tmpdir(), "cline-marketplace-mcp-")),
|
||||
"cline_mcp_settings.json",
|
||||
);
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
context7: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
uninstallMarketplaceEntry({
|
||||
entry: {
|
||||
id: "context7",
|
||||
type: "mcp",
|
||||
name: "Context7",
|
||||
install: {
|
||||
args: [
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Context7.",
|
||||
});
|
||||
expect(
|
||||
listMarketplaceInstalledEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "context7",
|
||||
type: "mcp",
|
||||
name: "Context7",
|
||||
install: {
|
||||
args: [
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ installedKeys: [] });
|
||||
});
|
||||
|
||||
it("uninstalls local MCP servers by name", async () => {
|
||||
const settingsPath = join(
|
||||
mkdtempSync(join(tmpdir(), "cline-local-mcp-")),
|
||||
"cline_mcp_settings.json",
|
||||
);
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
context7: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
uninstallLocalPrimitive({
|
||||
type: "mcp",
|
||||
id: "context7",
|
||||
name: "context7",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled context7.",
|
||||
});
|
||||
expect(readFileSync(settingsPath, "utf8")).not.toContain("context7");
|
||||
});
|
||||
|
||||
it("uninstalls local skills by removing their configured skill directory", async () => {
|
||||
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-local-skill-"));
|
||||
const skillDir = join(workspaceRoot, ".cline", "skills", "review");
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
const skillPath = join(skillDir, "SKILL.md");
|
||||
writeFileSync(skillPath, "---\nname: review\n---\nReview changes.");
|
||||
|
||||
await expect(
|
||||
uninstallLocalPrimitive(
|
||||
{
|
||||
type: "skill",
|
||||
id: "review",
|
||||
name: "Review",
|
||||
path: skillPath,
|
||||
},
|
||||
{ workspaceRoot },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Review.",
|
||||
});
|
||||
expect(existsSync(skillDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports official plugin marketplace entries installed from Cline home", () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const sourceKey =
|
||||
"official:https://github.com/cline/plugins.git#plugins/goal";
|
||||
const hash = createHash("sha256")
|
||||
.update(sourceKey)
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
mkdirSync(
|
||||
join(clineDir, "plugins", "_installed", "official", `goal-${hash}`),
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ installedKeys: ["plugin:goal"] });
|
||||
});
|
||||
|
||||
it("does not report plugin inventory substring matches as installed", () => {
|
||||
process.env.CLINE_DIR = mkdtempSync(
|
||||
join(tmpdir(), "cline-marketplace-test-"),
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries(
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
plugins: [
|
||||
{
|
||||
name: "goal-helper",
|
||||
path: "/workspace/.cline/plugins/goal-helper/index.ts",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
).toEqual({ installedKeys: [] });
|
||||
});
|
||||
|
||||
it("skips invalid marketplace entries during installed-status checks", () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const sourceKey =
|
||||
"official:https://github.com/cline/plugins.git#plugins/goal";
|
||||
const hash = createHash("sha256")
|
||||
.update(sourceKey)
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
mkdirSync(
|
||||
join(clineDir, "plugins", "_installed", "official", `goal-${hash}`),
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "broken-mcp",
|
||||
type: "mcp",
|
||||
name: "Broken MCP",
|
||||
install: {
|
||||
args: [
|
||||
"broken-mcp",
|
||||
"--transport",
|
||||
"ws",
|
||||
"https://example.com/mcp",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ installedKeys: ["plugin:goal"] });
|
||||
});
|
||||
|
||||
it("rejects invalid marketplace entries before spawning commands", async () => {
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "bad",
|
||||
type: "skill",
|
||||
install: { args: [] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).rejects.toThrow("marketplace install args are required");
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches the marketplace catalog through the server helper", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response(JSON.stringify({ version: 1, entries: [] }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
await expect(fetchMarketplaceCatalog(fetchImpl)).resolves.toEqual({
|
||||
version: 1,
|
||||
entries: [],
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://cline.github.io/marketplace/catalog.json",
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces marketplace catalog upstream failures", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response("nope", {
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
await expect(fetchMarketplaceCatalog(fetchImpl)).rejects.toThrow(
|
||||
"Failed to fetch marketplace catalog: 503 Service Unavailable",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,998 +0,0 @@
|
||||
import { type SpawnOptions, spawn } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { homedir as osHomedir, platform } from "node:os";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
isAbsolute,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
} from "node:path";
|
||||
import {
|
||||
type MarketplaceActionResult,
|
||||
type MarketplaceEntryInput,
|
||||
resolveSkillsConfigSearchPaths,
|
||||
resolveWorkflowsConfigSearchPaths,
|
||||
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
|
||||
uninstallPlugin as uninstallLocalPlugin,
|
||||
} from "@cline/core";
|
||||
import { resolveClineDir } from "@cline/shared/storage";
|
||||
import { deleteMcpServer, readMcpServersResponse } from "./mcp";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
type MarketplacePrimitiveType = "mcp" | "skill" | "plugin";
|
||||
type LocalPrimitiveType = MarketplacePrimitiveType | "workflow";
|
||||
|
||||
type MarketplaceEnvVar = {
|
||||
name: string;
|
||||
required?: boolean;
|
||||
description?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type MarketplaceInstallInput = {
|
||||
id: string;
|
||||
type: MarketplacePrimitiveType;
|
||||
name?: string;
|
||||
install: {
|
||||
args?: string[];
|
||||
env?: MarketplaceEnvVar[];
|
||||
command?: string;
|
||||
notes?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type MarketplaceInstallResult = {
|
||||
id: string;
|
||||
type: LocalPrimitiveType;
|
||||
status: "installed" | "uninstalled";
|
||||
message: string;
|
||||
details?: JsonRecord;
|
||||
output?: string;
|
||||
};
|
||||
|
||||
type MarketplaceInstallStatusResult = {
|
||||
installedKeys: string[];
|
||||
};
|
||||
|
||||
type SpawnResult = {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
};
|
||||
|
||||
type SpawnCommand = (
|
||||
command: string,
|
||||
args: string[],
|
||||
options?: SpawnOptions,
|
||||
) => Promise<SpawnResult>;
|
||||
type CatalogFetch = (
|
||||
input: string | URL | Request,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
type CatalogLoader = () => Promise<unknown>;
|
||||
|
||||
const MAX_OUTPUT_CHARS = 12_000;
|
||||
const INSTALL_COMMAND_TIMEOUT_MS = 120_000;
|
||||
const OFFICIAL_PLUGINS_REPO = "https://github.com/cline/plugins.git";
|
||||
const MARKETPLACE_CATALOG_URL =
|
||||
process.env.CLINE_MARKETPLACE_CATALOG_URL?.trim() ||
|
||||
"https://cline.github.io/marketplace/catalog.json";
|
||||
const SECRET_PATTERN =
|
||||
/(api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|authorization|credential)/i;
|
||||
const SECRET_KEY_VALUE_PATTERN =
|
||||
/((?:^|[^\w])(?:[a-z0-9_]*?(?:api[_ -]?key|access[_ -]?token|refresh[_ -]?token|auth(?:orization)?[_ -]?token|token|secret|password|credential)[a-z0-9_]*)\s*[:=]\s*)(.+)$/gi;
|
||||
const SECRET_BEARER_VALUE_PATTERN =
|
||||
/((?:^|[^\w])authorization\s*[:=]\s*)bearer\s+([^\s,"'}\]]+)/gi;
|
||||
const SECRET_AUTHORIZATION_VALUE_PATTERN =
|
||||
/((?:^|[^\w])authorization\s*[:=])(?!\s*bearer\b)\s*(.+)$/gi;
|
||||
|
||||
export async function fetchMarketplaceCatalog(
|
||||
fetchImpl: CatalogFetch = fetch,
|
||||
): Promise<unknown> {
|
||||
const response = await fetchImpl(MARKETPLACE_CATALOG_URL, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to fetch marketplace catalog: ${response.status} ${response.statusText}`.trim(),
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function isPrimitiveType(value: unknown): value is MarketplacePrimitiveType {
|
||||
return value === "mcp" || value === "skill" || value === "plugin";
|
||||
}
|
||||
|
||||
function toStringArray(value: unknown): string[] {
|
||||
return Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
function readInstallInput(
|
||||
args?: Record<string, unknown>,
|
||||
): MarketplaceInstallInput {
|
||||
const entry = readInstallRecord(args);
|
||||
const install =
|
||||
entry.install && typeof entry.install === "object"
|
||||
? (entry.install as Record<string, unknown>)
|
||||
: {};
|
||||
const installArgs = toStringArray(install.args);
|
||||
if (installArgs.length === 0) {
|
||||
throw new Error("marketplace install args are required");
|
||||
}
|
||||
const env = Array.isArray(install.env)
|
||||
? install.env
|
||||
.map((item): MarketplaceEnvVar | null => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const candidate = item as Record<string, unknown>;
|
||||
if (typeof candidate.name !== "string") return null;
|
||||
const parsed: MarketplaceEnvVar = {
|
||||
name: candidate.name,
|
||||
};
|
||||
if (typeof candidate.required === "boolean") {
|
||||
parsed.required = candidate.required;
|
||||
}
|
||||
if (typeof candidate.description === "string") {
|
||||
parsed.description = candidate.description;
|
||||
}
|
||||
if (typeof candidate.url === "string") {
|
||||
parsed.url = candidate.url;
|
||||
}
|
||||
return parsed;
|
||||
})
|
||||
.filter((item): item is MarketplaceEnvVar => item !== null)
|
||||
: undefined;
|
||||
return {
|
||||
id: entry.id.trim(),
|
||||
type: entry.type,
|
||||
name: typeof entry.name === "string" ? entry.name : undefined,
|
||||
install: {
|
||||
args: installArgs,
|
||||
command:
|
||||
typeof install.command === "string" ? install.command : undefined,
|
||||
env,
|
||||
notes: typeof install.notes === "string" ? install.notes : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function readInstallRecord(
|
||||
args?: Record<string, unknown>,
|
||||
): Record<string, unknown> & { id: string; type: MarketplacePrimitiveType } {
|
||||
const entry =
|
||||
args?.entry && typeof args.entry === "object"
|
||||
? (args.entry as Record<string, unknown>)
|
||||
: (args ?? {});
|
||||
if (typeof entry.id !== "string" || entry.id.trim().length === 0) {
|
||||
throw new Error("marketplace entry id is required");
|
||||
}
|
||||
if (!isPrimitiveType(entry.type)) {
|
||||
throw new Error("marketplace entry type must be mcp, skill, or plugin");
|
||||
}
|
||||
return entry as Record<string, unknown> & {
|
||||
id: string;
|
||||
type: MarketplacePrimitiveType;
|
||||
};
|
||||
}
|
||||
|
||||
function readInstallRequest(args?: Record<string, unknown>) {
|
||||
const entry = readInstallRecord(args);
|
||||
return {
|
||||
id: entry.id.trim(),
|
||||
type: entry.type,
|
||||
};
|
||||
}
|
||||
|
||||
function readLocalUninstallInput(args?: Record<string, unknown>): {
|
||||
id: string;
|
||||
type: LocalPrimitiveType;
|
||||
name?: string;
|
||||
path?: string;
|
||||
} {
|
||||
const type = typeof args?.type === "string" ? args.type.trim() : "";
|
||||
if (
|
||||
type !== "mcp" &&
|
||||
type !== "skill" &&
|
||||
type !== "workflow" &&
|
||||
type !== "plugin"
|
||||
) {
|
||||
throw new Error(
|
||||
"local uninstall type must be mcp, skill, workflow, or plugin",
|
||||
);
|
||||
}
|
||||
const id =
|
||||
typeof args?.id === "string" && args.id.trim().length > 0
|
||||
? args.id.trim()
|
||||
: typeof args?.name === "string" && args.name.trim().length > 0
|
||||
? args.name.trim()
|
||||
: typeof args?.path === "string" && args.path.trim().length > 0
|
||||
? args.path.trim()
|
||||
: "";
|
||||
if (!id) {
|
||||
throw new Error("local uninstall id, name, or path is required");
|
||||
}
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
name: typeof args?.name === "string" ? args.name.trim() : undefined,
|
||||
path: typeof args?.path === "string" ? args.path.trim() : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function readInstallInputList(
|
||||
args?: Record<string, unknown>,
|
||||
): MarketplaceInstallInput[] {
|
||||
const rawEntries = Array.isArray(args?.entries) ? args.entries : [];
|
||||
return rawEntries
|
||||
.map((entry) => {
|
||||
try {
|
||||
return readInstallInput({ entry });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is MarketplaceInstallInput => entry !== null);
|
||||
}
|
||||
|
||||
function readCatalogEntries(catalog: unknown): MarketplaceInstallInput[] {
|
||||
const catalogEntries =
|
||||
catalog && typeof catalog === "object"
|
||||
? (catalog as Record<string, unknown>).entries
|
||||
: undefined;
|
||||
if (!Array.isArray(catalogEntries)) {
|
||||
throw new Error("marketplace catalog entries are required");
|
||||
}
|
||||
return catalogEntries
|
||||
.map((entry) => {
|
||||
try {
|
||||
return readInstallInput({ entry });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((entry): entry is MarketplaceInstallInput => entry !== null);
|
||||
}
|
||||
|
||||
function marketplaceEntryKey(
|
||||
entry: Pick<MarketplaceInstallInput, "id" | "type">,
|
||||
) {
|
||||
return `${entry.type}:${entry.id}`;
|
||||
}
|
||||
|
||||
function redactOutput(value: string): string {
|
||||
const lines = value.split(/\r?\n/).map((line) => {
|
||||
if (!SECRET_PATTERN.test(line)) return line;
|
||||
return line
|
||||
.replace(SECRET_KEY_VALUE_PATTERN, "$1[redacted]")
|
||||
.replace(SECRET_BEARER_VALUE_PATTERN, "$1Bearer [redacted]")
|
||||
.replace(/\b(Bearer)\s+(?!\[redacted\])([^\s,"'}\]]+)/gi, "$1 [redacted]")
|
||||
.replace(SECRET_AUTHORIZATION_VALUE_PATTERN, "$1 [redacted]")
|
||||
.replace(
|
||||
/((?:^|[^\w])(?:api\s+key|access\s+token|refresh\s+token|auth(?:orization)?\s+token|secret|password|credential)\s+(?:is\s+)?)(\S+)/gi,
|
||||
"$1[redacted]",
|
||||
);
|
||||
});
|
||||
return lines.join("\n").slice(-MAX_OUTPUT_CHARS);
|
||||
}
|
||||
|
||||
const defaultSpawnCommand: SpawnCommand = async (command, args, options = {}) =>
|
||||
new Promise<SpawnResult>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
const child = spawn(command, args, {
|
||||
...options,
|
||||
env: options.env ?? process.env,
|
||||
shell: options.shell ?? platform() === "win32",
|
||||
stdio: options.stdio ?? ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
});
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
const forceKillTimeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}, INSTALL_COMMAND_TIMEOUT_MS + 5_000);
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
stderr += `\nTimed out after ${INSTALL_COMMAND_TIMEOUT_MS / 1000}s.`;
|
||||
child.kill("SIGTERM");
|
||||
}, INSTALL_COMMAND_TIMEOUT_MS);
|
||||
forceKillTimeout.unref?.();
|
||||
timeout.unref?.();
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
if (stdout.length > MAX_OUTPUT_CHARS * 2) {
|
||||
stdout = stdout.slice(-MAX_OUTPUT_CHARS);
|
||||
}
|
||||
});
|
||||
child.stderr?.on("data", (chunk) => {
|
||||
stderr += String(chunk);
|
||||
if (stderr.length > MAX_OUTPUT_CHARS * 2) {
|
||||
stderr = stderr.slice(-MAX_OUTPUT_CHARS);
|
||||
}
|
||||
});
|
||||
child.once("error", (error) => {
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(forceKillTimeout);
|
||||
reject(error);
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
clearTimeout(forceKillTimeout);
|
||||
const result = {
|
||||
exitCode: timedOut ? 124 : (code ?? (signal === "SIGINT" ? 130 : 1)),
|
||||
stdout,
|
||||
stderr,
|
||||
};
|
||||
resolve(result);
|
||||
});
|
||||
});
|
||||
|
||||
function normalizeTransport(value: string | undefined): string {
|
||||
const normalized = (value ?? "stdio").trim();
|
||||
if (normalized === "http" || normalized === "streamable-http") {
|
||||
return "streamableHttp";
|
||||
}
|
||||
if (
|
||||
normalized === "stdio" ||
|
||||
normalized === "sse" ||
|
||||
normalized === "streamableHttp"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertUrl(value: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(value);
|
||||
} catch {
|
||||
throw new Error(`Invalid MCP server URL: ${value}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(`Invalid MCP server URL: ${value}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildMarketplaceMcpInput(args: string[]): JsonRecord {
|
||||
const [rawName, ...rest] = args;
|
||||
const name = rawName?.trim();
|
||||
if (!name) {
|
||||
throw new Error("MCP marketplace install requires a server name");
|
||||
}
|
||||
let transportType = "stdio";
|
||||
const headers: Record<string, string> = {};
|
||||
const targetArgs: string[] = [];
|
||||
let parsingMarketplaceOptions = true;
|
||||
for (let index = 0; index < rest.length; index++) {
|
||||
const arg = rest[index];
|
||||
if (parsingMarketplaceOptions && arg === "--") {
|
||||
targetArgs.push(...rest.slice(index + 1));
|
||||
break;
|
||||
}
|
||||
if (parsingMarketplaceOptions && (arg === "--transport" || arg === "-t")) {
|
||||
const next = rest[index + 1]?.trim();
|
||||
if (!next) throw new Error("--transport requires a value");
|
||||
transportType = normalizeTransport(next);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
const shouldParseHeader =
|
||||
parsingMarketplaceOptions ||
|
||||
normalizeTransport(transportType) !== "stdio";
|
||||
if (
|
||||
shouldParseHeader &&
|
||||
(arg === "--header" || arg?.startsWith("--header="))
|
||||
) {
|
||||
const rawHeader =
|
||||
arg === "--header" ? rest[++index] : arg.slice("--header=".length);
|
||||
if (!rawHeader) throw new Error("--header requires a value");
|
||||
const separatorIndex = rawHeader.indexOf(":");
|
||||
if (separatorIndex <= 0) {
|
||||
throw new Error(
|
||||
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
|
||||
);
|
||||
}
|
||||
const headerName = rawHeader.slice(0, separatorIndex).trim();
|
||||
const headerValue = rawHeader.slice(separatorIndex + 1).trim();
|
||||
if (!headerName || !headerValue) {
|
||||
throw new Error(
|
||||
`Invalid MCP header "${rawHeader}". Expected "Header-Name: header value".`,
|
||||
);
|
||||
}
|
||||
headers[headerName] = headerValue;
|
||||
continue;
|
||||
}
|
||||
parsingMarketplaceOptions = false;
|
||||
targetArgs.push(arg);
|
||||
}
|
||||
transportType = normalizeTransport(transportType);
|
||||
if (transportType === "stdio") {
|
||||
if (Object.keys(headers).length > 0) {
|
||||
throw new Error("Stdio MCP installs do not support request headers.");
|
||||
}
|
||||
const [command, ...commandArgs] = targetArgs;
|
||||
if (!command?.trim()) {
|
||||
throw new Error("Stdio MCP install requires a command");
|
||||
}
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
command,
|
||||
args: commandArgs.length > 0 ? commandArgs : undefined,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
if (targetArgs.length !== 1) {
|
||||
throw new Error("Remote MCP install requires exactly one URL");
|
||||
}
|
||||
const url = targetArgs[0]?.trim() ?? "";
|
||||
assertUrl(url);
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
url,
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
disabled: false,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveClineInvocation(): { command: string; argsPrefix: string[] } {
|
||||
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
|
||||
if (wrapperPath) {
|
||||
return { command: wrapperPath, argsPrefix: [] };
|
||||
}
|
||||
const entry = process.argv[1]?.trim();
|
||||
if (entry && /(?:^|[/\\])apps[/\\]cli[/\\]src[/\\]index\.ts$/.test(entry)) {
|
||||
return { command: process.execPath, argsPrefix: [entry] };
|
||||
}
|
||||
return { command: "cline", argsPrefix: [] };
|
||||
}
|
||||
|
||||
function isInsidePath(childPath: string, parentPath: string): boolean {
|
||||
const relativePath = relative(resolve(parentPath), resolve(childPath));
|
||||
return (
|
||||
relativePath === "" ||
|
||||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
|
||||
);
|
||||
}
|
||||
|
||||
function resolveUserInstructionRemovalTarget(input: {
|
||||
type: "skill" | "workflow";
|
||||
path: string;
|
||||
workspaceRoot?: string;
|
||||
}): string {
|
||||
const filePath = resolve(input.path);
|
||||
const searchPaths =
|
||||
input.type === "skill"
|
||||
? resolveSkillsConfigSearchPaths(input.workspaceRoot)
|
||||
: resolveWorkflowsConfigSearchPaths(input.workspaceRoot);
|
||||
const containingRoot = searchPaths.find((root) =>
|
||||
isInsidePath(filePath, root),
|
||||
);
|
||||
if (!containingRoot) {
|
||||
throw new Error(
|
||||
`${input.type} uninstall requires a file inside a configured ${input.type} directory.`,
|
||||
);
|
||||
}
|
||||
const stats = statSync(filePath, { throwIfNoEntry: false });
|
||||
if (!stats?.isFile()) {
|
||||
throw new Error(`${input.type} file does not exist: ${filePath}`);
|
||||
}
|
||||
if (input.type === "workflow") {
|
||||
return filePath;
|
||||
}
|
||||
const skillDir = dirname(filePath);
|
||||
return resolve(skillDir) === resolve(containingRoot) ? filePath : skillDir;
|
||||
}
|
||||
|
||||
export async function uninstallLocalPrimitive(
|
||||
args?: Record<string, unknown>,
|
||||
options: { workspaceRoot?: string } = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const input = readLocalUninstallInput(args);
|
||||
if (input.type === "mcp") {
|
||||
const name = input.name ?? input.id;
|
||||
const response = deleteMcpServer(name);
|
||||
return {
|
||||
id: input.id,
|
||||
type: input.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${name}.`,
|
||||
details: { mcp: response },
|
||||
};
|
||||
}
|
||||
if (input.type === "plugin") {
|
||||
const result = await uninstallLocalPlugin({
|
||||
name: input.path ? undefined : (input.name ?? input.id),
|
||||
path: input.path,
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
});
|
||||
return {
|
||||
id: input.id,
|
||||
type: input.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${result.name}.`,
|
||||
details: result as unknown as JsonRecord,
|
||||
};
|
||||
}
|
||||
if (input.type === "skill" || input.type === "workflow") {
|
||||
if (!input.path) {
|
||||
throw new Error(`${input.type} uninstall requires a path.`);
|
||||
}
|
||||
const target = resolveUserInstructionRemovalTarget({
|
||||
type: input.type,
|
||||
path: input.path,
|
||||
workspaceRoot: options.workspaceRoot,
|
||||
});
|
||||
const stats = statSync(target, { throwIfNoEntry: false });
|
||||
if (!stats) {
|
||||
throw new Error(`${input.type} target does not exist: ${target}`);
|
||||
}
|
||||
rmSync(target, { recursive: stats.isDirectory(), force: true });
|
||||
return {
|
||||
id: input.id,
|
||||
type: input.type,
|
||||
status: "uninstalled",
|
||||
message: `Uninstalled ${input.name ?? basename(target)}.`,
|
||||
details: { path: target },
|
||||
};
|
||||
}
|
||||
throw new Error(`Unsupported local uninstall type: ${input.type}`);
|
||||
}
|
||||
|
||||
function hashSource(source: string): string {
|
||||
return createHash("sha256").update(source).digest("hex").slice(0, 12);
|
||||
}
|
||||
|
||||
function sanitizeSegment(value: string): string {
|
||||
const sanitized = value
|
||||
.replace(/^@/, "")
|
||||
.replace(/[^a-zA-Z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80);
|
||||
return sanitized || "plugin";
|
||||
}
|
||||
|
||||
function sanitizeSkillSegment(value: string): string {
|
||||
const sanitized = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._]+/g, "-")
|
||||
.replace(/^[.-]+|[.-]+$/g, "")
|
||||
.slice(0, 255);
|
||||
return sanitized || "skill";
|
||||
}
|
||||
|
||||
function isOfficialPluginSlug(source: string): boolean {
|
||||
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
|
||||
}
|
||||
|
||||
function getOfficialPluginInstallPath(source: string): string | undefined {
|
||||
const slug = source.trim();
|
||||
if (!isOfficialPluginSlug(slug)) return undefined;
|
||||
const sourceKey = `official:${OFFICIAL_PLUGINS_REPO}#plugins/${slug}`;
|
||||
return join(
|
||||
resolveClineDir(),
|
||||
"plugins",
|
||||
"_installed",
|
||||
"official",
|
||||
`${sanitizeSegment(slug)}-${hashSource(sourceKey)}`,
|
||||
);
|
||||
}
|
||||
|
||||
function isOfficialPluginInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
if (entry.type !== "plugin") return false;
|
||||
const [source] = entry.install.args ?? [];
|
||||
if (!source) return false;
|
||||
const installPath = getOfficialPluginInstallPath(source);
|
||||
return Boolean(installPath && existsSync(installPath));
|
||||
}
|
||||
|
||||
function resolveHomeDir(): string {
|
||||
return (
|
||||
process.env.HOME?.trim() || process.env.USERPROFILE?.trim() || osHomedir()
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeMatchValue(value: string | undefined): string {
|
||||
return (value ?? "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
}
|
||||
|
||||
function getSkillInstallCandidates(entry: MarketplaceInstallInput): string[] {
|
||||
const candidates = new Set<string>();
|
||||
const addCandidate = (value: string | undefined) => {
|
||||
const normalized = sanitizeSkillSegment(value ?? "");
|
||||
if (normalized && normalized !== "skill") {
|
||||
candidates.add(normalized);
|
||||
}
|
||||
};
|
||||
addCandidate(entry.id);
|
||||
addCandidate(entry.name);
|
||||
const installArgs = entry.install.args ?? [];
|
||||
for (let index = 0; index < installArgs.length; index++) {
|
||||
const arg = installArgs[index];
|
||||
if ((arg === "--skill" || arg === "-s") && installArgs[index + 1]) {
|
||||
addCandidate(installArgs[index + 1]);
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
const skillFilter = arg.split("@").at(1);
|
||||
if (skillFilter) {
|
||||
addCandidate(skillFilter);
|
||||
}
|
||||
}
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
function getGlobalSkillPaths(skillName: string): string[] {
|
||||
return [
|
||||
join(resolveClineDir(), "skills", skillName, "SKILL.md"),
|
||||
join(resolveHomeDir(), ".agents", "skills", skillName, "SKILL.md"),
|
||||
].filter((path, index, paths) => paths.indexOf(path) === index);
|
||||
}
|
||||
|
||||
function ensureGlobalSkillsDirWritable(): void {
|
||||
const skillsDir = join(resolveHomeDir(), ".agents", "skills");
|
||||
try {
|
||||
mkdirSync(skillsDir, { recursive: true });
|
||||
const probePath = join(
|
||||
skillsDir,
|
||||
`.cline-marketplace-write-test-${process.pid}-${Date.now()}`,
|
||||
);
|
||||
writeFileSync(probePath, "", { flag: "wx" });
|
||||
unlinkSync(probePath);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Cannot install skill globally because ~/.agents/skills is not writable: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isGlobalSkillInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
return findInstalledGlobalSkillName(entry) !== undefined;
|
||||
}
|
||||
|
||||
function findInstalledGlobalSkillName(
|
||||
entry: MarketplaceInstallInput,
|
||||
): string | undefined {
|
||||
if (entry.type !== "skill") return undefined;
|
||||
const candidates = getSkillInstallCandidates(entry);
|
||||
return candidates.find((candidate) =>
|
||||
getGlobalSkillPaths(candidate).some((path) => existsSync(path)),
|
||||
);
|
||||
}
|
||||
|
||||
function hasMatchingInventoryItem(
|
||||
items: unknown,
|
||||
entry: MarketplaceInstallInput,
|
||||
): boolean {
|
||||
if (!Array.isArray(items)) return false;
|
||||
const candidates = new Set([
|
||||
normalizeMatchValue(entry.id),
|
||||
normalizeMatchValue(entry.name),
|
||||
...(entry.install.args ?? []).map(normalizeMatchValue),
|
||||
]);
|
||||
candidates.delete("");
|
||||
return items.some((item) => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const record = item as JsonRecord;
|
||||
const values = [
|
||||
typeof record.name === "string" ? record.name : undefined,
|
||||
typeof record.id === "string" ? record.id : undefined,
|
||||
typeof record.path === "string" ? record.path : undefined,
|
||||
]
|
||||
.map(normalizeMatchValue)
|
||||
.filter(Boolean);
|
||||
return values.some((value) => candidates.has(value));
|
||||
});
|
||||
}
|
||||
|
||||
function isMcpEntryInstalled(entry: MarketplaceInstallInput): boolean {
|
||||
if (entry.type !== "mcp") return false;
|
||||
const input = buildMarketplaceMcpInput(entry.install.args ?? []);
|
||||
const response = readMcpServersResponse();
|
||||
const servers = Array.isArray(response.servers) ? response.servers : [];
|
||||
return servers.some((server) => {
|
||||
if (!server || typeof server !== "object") return false;
|
||||
const record = server as JsonRecord;
|
||||
return record.name === input.name;
|
||||
});
|
||||
}
|
||||
|
||||
function isMarketplaceEntryInstalled(
|
||||
entry: MarketplaceInstallInput,
|
||||
inventory?: JsonRecord,
|
||||
): boolean {
|
||||
try {
|
||||
if (entry.type === "mcp") return isMcpEntryInstalled(entry);
|
||||
if (entry.type === "plugin") {
|
||||
return (
|
||||
isOfficialPluginInstalled(entry) ||
|
||||
hasMatchingInventoryItem(inventory?.plugins, entry)
|
||||
);
|
||||
}
|
||||
if (entry.type === "skill") {
|
||||
return isGlobalSkillInstalled(entry);
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function commandOutput(result: SpawnResult): string | undefined {
|
||||
const output = redactOutput(
|
||||
[result.stdout, result.stderr].filter(Boolean).join("\n"),
|
||||
);
|
||||
return output.trim().length > 0 ? output.trim() : undefined;
|
||||
}
|
||||
|
||||
async function installSkill(
|
||||
entry: MarketplaceInstallInput,
|
||||
spawnCommand: SpawnCommand,
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
if (isGlobalSkillInstalled(entry)) {
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `${entry.name ?? entry.id} is already installed.`,
|
||||
};
|
||||
}
|
||||
ensureGlobalSkillsDirWritable();
|
||||
const result = await spawnCommand("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"add",
|
||||
...(entry.install.args ?? []),
|
||||
"-g",
|
||||
"-a",
|
||||
"cline",
|
||||
"-y",
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`Skill install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
const output = commandOutput(result);
|
||||
if (/\bFailed to install\b/i.test(output ?? "")) {
|
||||
throw new Error(`Skill install failed${output ? `:\n${output}` : ""}`);
|
||||
}
|
||||
if (!isGlobalSkillInstalled(entry)) {
|
||||
throw new Error(
|
||||
`Skill install completed, but ${entry.name ?? entry.id} was not found in Cline's global skills directories.`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id} globally for Cline.`,
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
async function installPlugin(
|
||||
entry: MarketplaceInstallInput,
|
||||
spawnCommand: SpawnCommand,
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const installArgs = entry.install.args ?? [];
|
||||
if (installArgs.length !== 1) {
|
||||
throw new Error(
|
||||
"Plugin marketplace installs currently support exactly one source argument.",
|
||||
);
|
||||
}
|
||||
if (isOfficialPluginInstalled(entry)) {
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `${entry.name ?? entry.id} is already installed.`,
|
||||
};
|
||||
}
|
||||
const { command, argsPrefix } = resolveClineInvocation();
|
||||
const result = await spawnCommand(command, [
|
||||
...argsPrefix,
|
||||
"plugin",
|
||||
"install",
|
||||
installArgs[0] ?? "",
|
||||
"--json",
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`Plugin install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
let details: JsonRecord | undefined;
|
||||
try {
|
||||
details = result.stdout.trim()
|
||||
? (JSON.parse(result.stdout.trim()) as JsonRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
details = undefined;
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id}.`,
|
||||
details,
|
||||
output: commandOutput(result),
|
||||
};
|
||||
}
|
||||
|
||||
export async function installMarketplaceEntry(
|
||||
args?: Record<string, unknown>,
|
||||
options: { spawnCommand?: SpawnCommand } = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const entry = readInstallInput(args);
|
||||
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
|
||||
if (entry.type === "mcp") {
|
||||
// Validate marketplace args before handing them to the CLI-backed installer.
|
||||
buildMarketplaceMcpInput(entry.install.args ?? []);
|
||||
const { command, argsPrefix } = resolveClineInvocation();
|
||||
const result = await spawnCommand(command, [
|
||||
...argsPrefix,
|
||||
"mcp",
|
||||
"install",
|
||||
"--yes",
|
||||
"--json",
|
||||
...(entry.install.args ?? []),
|
||||
]);
|
||||
if (result.exitCode !== 0) {
|
||||
const output = commandOutput(result);
|
||||
throw new Error(
|
||||
`MCP install failed with exit code ${result.exitCode}${output ? `:\n${output}` : ""}`,
|
||||
);
|
||||
}
|
||||
let details: JsonRecord | undefined;
|
||||
try {
|
||||
details = result.stdout.trim()
|
||||
? (JSON.parse(result.stdout.trim()) as JsonRecord)
|
||||
: undefined;
|
||||
} catch {
|
||||
details = undefined;
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
type: entry.type,
|
||||
status: "installed",
|
||||
message: `Installed ${entry.name ?? entry.id}.`,
|
||||
details,
|
||||
output: commandOutput(result),
|
||||
};
|
||||
}
|
||||
if (entry.type === "skill") {
|
||||
return installSkill(entry, spawnCommand);
|
||||
}
|
||||
if (entry.type === "plugin") {
|
||||
return installPlugin(entry, spawnCommand);
|
||||
}
|
||||
throw new Error(`Unsupported marketplace entry type: ${entry.type}`);
|
||||
}
|
||||
|
||||
export async function uninstallMarketplaceEntry(
|
||||
args?: Record<string, unknown>,
|
||||
options: { spawnCommand?: SpawnCommand } = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const entry = readInstallInput(args);
|
||||
const spawnCommand = options.spawnCommand ?? defaultSpawnCommand;
|
||||
let mcpDetails: JsonRecord | undefined;
|
||||
const result = await uninstallCoreMarketplaceEntry(
|
||||
entry satisfies MarketplaceEntryInput,
|
||||
{
|
||||
deleteMcpServer: (name) => {
|
||||
mcpDetails = deleteMcpServer(name);
|
||||
},
|
||||
spawnCommand: (command, commandArgs) =>
|
||||
spawnCommand(command, commandArgs),
|
||||
},
|
||||
);
|
||||
return {
|
||||
...(result satisfies MarketplaceActionResult),
|
||||
details: mcpDetails ? { mcp: mcpDetails } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export async function installMarketplaceEntryFromCatalog(
|
||||
args?: Record<string, unknown>,
|
||||
options: {
|
||||
spawnCommand?: SpawnCommand;
|
||||
loadCatalog?: CatalogLoader;
|
||||
} = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const requested = readInstallRequest(args);
|
||||
const catalog = await (options.loadCatalog ?? fetchMarketplaceCatalog)();
|
||||
const entry = readCatalogEntries(catalog).find(
|
||||
(candidate) =>
|
||||
candidate.id === requested.id && candidate.type === requested.type,
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Marketplace entry ${requested.type}:${requested.id} was not found in the catalog.`,
|
||||
);
|
||||
}
|
||||
return installMarketplaceEntry(
|
||||
{ entry },
|
||||
{ spawnCommand: options.spawnCommand },
|
||||
);
|
||||
}
|
||||
|
||||
export async function uninstallMarketplaceEntryFromCatalog(
|
||||
args?: Record<string, unknown>,
|
||||
options: {
|
||||
spawnCommand?: SpawnCommand;
|
||||
loadCatalog?: CatalogLoader;
|
||||
} = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
const requested = readInstallRequest(args);
|
||||
const catalog = await (options.loadCatalog ?? fetchMarketplaceCatalog)();
|
||||
const entry = readCatalogEntries(catalog).find(
|
||||
(candidate) =>
|
||||
candidate.id === requested.id && candidate.type === requested.type,
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(
|
||||
`Marketplace entry ${requested.type}:${requested.id} was not found in the catalog.`,
|
||||
);
|
||||
}
|
||||
return uninstallMarketplaceEntry(
|
||||
{ entry },
|
||||
{ spawnCommand: options.spawnCommand },
|
||||
);
|
||||
}
|
||||
|
||||
export function listMarketplaceInstalledEntries(
|
||||
args?: Record<string, unknown>,
|
||||
inventory?: JsonRecord,
|
||||
): MarketplaceInstallStatusResult {
|
||||
const entries = readInstallInputList(args);
|
||||
const installedKeys = entries
|
||||
.filter((entry) => isMarketplaceEntryInstalled(entry, inventory))
|
||||
.map(marketplaceEntryKey);
|
||||
return { installedKeys };
|
||||
}
|
||||
|
||||
export async function installMarketplaceEntryForDesktopCommand(
|
||||
args?: Record<string, unknown>,
|
||||
options: {
|
||||
spawnCommand?: SpawnCommand;
|
||||
loadCatalog?: CatalogLoader;
|
||||
} = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
return installMarketplaceEntryFromCatalog(args, options);
|
||||
}
|
||||
|
||||
export async function uninstallMarketplaceEntryForDesktopCommand(
|
||||
args?: Record<string, unknown>,
|
||||
options: {
|
||||
spawnCommand?: SpawnCommand;
|
||||
loadCatalog?: CatalogLoader;
|
||||
} = {},
|
||||
): Promise<MarketplaceInstallResult> {
|
||||
return uninstallMarketplaceEntryFromCatalog(args, options);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { updateMcpSettingsFileSync } from "@cline/core";
|
||||
import { resolveMcpSettingsPath } from "@cline/shared/storage";
|
||||
import type { JsonRecord } from "./types";
|
||||
|
||||
export function readMcpServersResponse(): JsonRecord {
|
||||
const settingsPath = resolveMcpSettingsPath();
|
||||
if (!existsSync(settingsPath)) {
|
||||
return { settingsPath, hasSettingsFile: false, servers: [] };
|
||||
}
|
||||
const parsed = JSON.parse(readFileSync(settingsPath, "utf8")) as JsonRecord;
|
||||
const servers = parsed.mcpServers as JsonRecord | undefined;
|
||||
const entries = Object.entries(servers ?? {}).map(([name, body]) => {
|
||||
const record = body as JsonRecord;
|
||||
const transport =
|
||||
record.transport && typeof record.transport === "object"
|
||||
? (record.transport as JsonRecord)
|
||||
: undefined;
|
||||
const transportType = String(
|
||||
transport?.type ?? record.transportType ?? record.type ?? "stdio",
|
||||
).trim();
|
||||
return {
|
||||
name,
|
||||
transportType,
|
||||
disabled: record.disabled === true,
|
||||
command:
|
||||
typeof transport?.command === "string"
|
||||
? transport.command
|
||||
: typeof record.command === "string"
|
||||
? record.command
|
||||
: undefined,
|
||||
args: Array.isArray(transport?.args)
|
||||
? transport.args
|
||||
: Array.isArray(record.args)
|
||||
? record.args
|
||||
: undefined,
|
||||
cwd:
|
||||
typeof transport?.cwd === "string"
|
||||
? transport.cwd
|
||||
: typeof record.cwd === "string"
|
||||
? record.cwd
|
||||
: undefined,
|
||||
env:
|
||||
transport?.env && typeof transport.env === "object"
|
||||
? transport.env
|
||||
: record.env && typeof record.env === "object"
|
||||
? record.env
|
||||
: undefined,
|
||||
url:
|
||||
typeof transport?.url === "string"
|
||||
? transport.url
|
||||
: typeof record.url === "string"
|
||||
? record.url
|
||||
: undefined,
|
||||
headers:
|
||||
transport?.headers && typeof transport.headers === "object"
|
||||
? transport.headers
|
||||
: record.headers && typeof record.headers === "object"
|
||||
? record.headers
|
||||
: undefined,
|
||||
metadata: record.metadata,
|
||||
};
|
||||
});
|
||||
return { settingsPath, hasSettingsFile: true, servers: entries };
|
||||
}
|
||||
|
||||
export function writeMcpServersMap(servers: JsonRecord): void {
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
}
|
||||
|
||||
export function ensureMcpSettingsFile(): string {
|
||||
const path = resolveMcpSettingsPath();
|
||||
if (!existsSync(path)) {
|
||||
writeMcpServersMap({});
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
export function setMcpServerDisabled(
|
||||
name: string,
|
||||
disabled: boolean,
|
||||
): JsonRecord {
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// (the extension, the CLI) cannot clobber this change.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
const current = servers[name];
|
||||
if (!current || typeof current !== "object") {
|
||||
throw new Error(`unknown MCP server: ${name}`);
|
||||
}
|
||||
servers[name] = { ...(current as JsonRecord), disabled };
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
|
||||
export function upsertMcpServer(input: JsonRecord): JsonRecord {
|
||||
const name = String(input.name ?? "").trim();
|
||||
if (!name) throw new Error("server name is required");
|
||||
const previousName = String(
|
||||
input.previousName ?? input.previous_name ?? "",
|
||||
).trim();
|
||||
const transportType = String(
|
||||
input.transportType ?? input.transport_type ?? "",
|
||||
).trim();
|
||||
const next: JsonRecord =
|
||||
transportType === "stdio"
|
||||
? {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: input.command,
|
||||
args: input.args,
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
},
|
||||
disabled: input.disabled === true,
|
||||
}
|
||||
: {
|
||||
transport: {
|
||||
type: transportType === "sse" ? "sse" : "streamableHttp",
|
||||
url: input.url,
|
||||
headers: input.headers,
|
||||
},
|
||||
disabled: input.disabled === true,
|
||||
};
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot clobber this upsert.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
if (previousName && previousName !== name) {
|
||||
delete servers[previousName];
|
||||
}
|
||||
servers[name] = next;
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
|
||||
export function deleteMcpServer(name: string): JsonRecord {
|
||||
if (!name) throw new Error("server name is required");
|
||||
// Hold the cross-process lock across read-modify-write so a concurrent writer
|
||||
// cannot resurrect the deleted server from a stale snapshot.
|
||||
updateMcpSettingsFileSync(resolveMcpSettingsPath(), (settings) => {
|
||||
const servers = ((settings.mcpServers as JsonRecord | undefined) ??
|
||||
{}) as JsonRecord;
|
||||
delete servers[name];
|
||||
settings.mcpServers = servers;
|
||||
});
|
||||
return readMcpServersResponse();
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mapHistoryToWebviewMessages } from "./session-mapping";
|
||||
|
||||
describe("mapHistoryToWebviewMessages", () => {
|
||||
it("hydrates assistant tool uses with following user tool results", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "I'll inspect the file." },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "read_file",
|
||||
input: { path: "src/index.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
id: "result-block-1",
|
||||
tool_use_id: "toolu_1",
|
||||
name: "read_file",
|
||||
content: "export const value = 1;",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
text: "I'll inspect the file.",
|
||||
toolEvents: [
|
||||
{
|
||||
toolCallId: "toolu_1",
|
||||
name: "read_file",
|
||||
state: "output-available",
|
||||
input: { path: "src/index.ts" },
|
||||
output: "export const value = 1;",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(messages[0].blocks).toEqual([
|
||||
{
|
||||
id: "assistant-1:text:0",
|
||||
type: "text",
|
||||
text: "I'll inspect the file.",
|
||||
},
|
||||
{
|
||||
id: "assistant-1:tool:toolu_1",
|
||||
type: "tool",
|
||||
toolEvent: expect.objectContaining({
|
||||
toolCallId: "toolu_1",
|
||||
name: "read_file",
|
||||
state: "output-available",
|
||||
output: "export const value = 1;",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates error tool results", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_1",
|
||||
name: "read_file",
|
||||
input: { path: "missing.ts" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_1",
|
||||
name: "read_file",
|
||||
content: "File not found",
|
||||
is_error: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].toolEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
toolCallId: "toolu_1",
|
||||
name: "read_file",
|
||||
state: "output-error",
|
||||
output: "File not found",
|
||||
error: "File not found",
|
||||
}),
|
||||
]);
|
||||
expect(messages[0].blocks?.[0]).toMatchObject({
|
||||
type: "tool",
|
||||
toolEvent: {
|
||||
toolCallId: "toolu_1",
|
||||
state: "output-error",
|
||||
error: "File not found",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("hydrates orphan tool results as standalone meta tool blocks", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "user-1",
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_orphan",
|
||||
name: "read_file",
|
||||
content: "orphan output",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toMatchObject({
|
||||
id: "user-1",
|
||||
role: "meta",
|
||||
text: "",
|
||||
toolEvents: [
|
||||
{
|
||||
toolCallId: "toolu_orphan",
|
||||
name: "read_file",
|
||||
state: "output-available",
|
||||
input: undefined,
|
||||
output: "orphan output",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(messages[0].blocks).toEqual([
|
||||
{
|
||||
id: "user-1:tool:toolu_orphan",
|
||||
type: "tool",
|
||||
toolEvent: expect.objectContaining({
|
||||
toolCallId: "toolu_orphan",
|
||||
input: undefined,
|
||||
output: "orphan output",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates plain string content as a text block", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: "Plain response",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toEqual([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
text: "Plain response",
|
||||
reasoning: undefined,
|
||||
reasoningRedacted: undefined,
|
||||
toolEvents: undefined,
|
||||
blocks: [
|
||||
{
|
||||
id: "assistant-1:text:0",
|
||||
type: "text",
|
||||
text: "Plain response",
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("hydrates same-message tool-call and tool-result blocks", () => {
|
||||
const messages = mapHistoryToWebviewMessages([
|
||||
{
|
||||
id: "assistant-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool-call",
|
||||
toolCallId: "call_1",
|
||||
toolName: "search",
|
||||
input: { query: "cline" },
|
||||
},
|
||||
{
|
||||
type: "tool-result",
|
||||
toolCallId: "call_1",
|
||||
toolName: "search",
|
||||
output: [{ query: "cline", result: "found", success: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].toolEvents).toEqual([
|
||||
expect.objectContaining({
|
||||
toolCallId: "call_1",
|
||||
name: "search",
|
||||
state: "output-available",
|
||||
input: { query: "cline" },
|
||||
output: [{ query: "cline", result: "found", success: true }],
|
||||
}),
|
||||
]);
|
||||
expect(messages[0].blocks).toHaveLength(1);
|
||||
expect(messages[0].blocks?.[0]).toMatchObject({
|
||||
type: "tool",
|
||||
toolEvent: {
|
||||
toolCallId: "call_1",
|
||||
state: "output-available",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { listUserInstructionConfigs } from "./user-instructions";
|
||||
|
||||
describe("listUserInstructionConfigs", () => {
|
||||
const tempRoots: string[] = [];
|
||||
const envSnapshot = {
|
||||
CLINE_GLOBAL_SETTINGS_PATH: process.env.CLINE_GLOBAL_SETTINGS_PATH,
|
||||
CLINE_MCP_SETTINGS_PATH: process.env.CLINE_MCP_SETTINGS_PATH,
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
if (envSnapshot.CLINE_GLOBAL_SETTINGS_PATH === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH =
|
||||
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
}
|
||||
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
|
||||
}
|
||||
await Promise.all(
|
||||
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
tempRoots.length = 0;
|
||||
});
|
||||
|
||||
it("uses the package name for package-backed plugin entries", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cline-hub-config-"));
|
||||
tempRoots.push(tempRoot);
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(tempRoot, "settings.json");
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(tempRoot, "mcp.json");
|
||||
const packageDir = join(
|
||||
tempRoot,
|
||||
".cline",
|
||||
"plugins",
|
||||
"_installed",
|
||||
"git",
|
||||
"github.com",
|
||||
"demo",
|
||||
"package",
|
||||
);
|
||||
await mkdir(packageDir, { recursive: true });
|
||||
const pluginPath = join(packageDir, "index.ts");
|
||||
await writeFile(
|
||||
join(packageDir, "package.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "cline-sdk-portable-agents",
|
||||
cline: {
|
||||
plugins: [{ paths: ["./index.ts"] }],
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
await writeFile(pluginPath, "export default {};\n");
|
||||
|
||||
const data = await listUserInstructionConfigs(tempRoot);
|
||||
const plugins = data.plugins as Array<{ name: string; path: string }>;
|
||||
const plugin = plugins.find((item) => item.path === pluginPath);
|
||||
|
||||
expect(plugin?.name).toBe("cline-sdk-portable-agents");
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Ebene_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 466.73 487.04">
|
||||
<!-- Generator: Adobe Illustrator 29.8.1, SVG Export Plug-In . SVG Version: 2.1.1 Build 2) -->
|
||||
<path d="M463.6,275.08l-29.26-58.75v-33.83c0-56.08-45.01-101.5-100.53-101.5h-50.01c3.62-7.43,5.61-15.79,5.61-24.61,0-31.17-25.08-56.39-56.07-56.39s-56.07,25.22-56.07,56.39c0,8.82,1.99,17.17,5.61,24.61h-50.01c-55.51,0-100.52,45.42-100.52,101.5v33.83l-29.87,58.59c-3.01,5.9-3.01,12.92,0,18.81l29.87,57.93v33.83c0,56.08,45.01,101.5,100.52,101.5h200.95c55.51,0,100.53-45.42,100.53-101.5v-33.83l29.21-58.13c2.9-5.79,2.9-12.61.05-18.46ZM202.75,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02ZM350.58,322.96c0,25.48-20.54,46.14-45.88,46.14s-45.88-20.66-45.88-46.14v-82.02c0-25.48,20.54-46.14,45.88-46.14s45.88,20.66,45.88,46.14v82.02Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 957 B |
File diff suppressed because it is too large
Load Diff
@@ -1,174 +0,0 @@
|
||||
import { cjk } from "@streamdown/cjk";
|
||||
import type { MermaidConfig } from "mermaid";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { isValidElement, memo } from "react";
|
||||
import {
|
||||
type Components,
|
||||
type DiagramPlugin,
|
||||
Streamdown,
|
||||
type StreamdownProps,
|
||||
} from "streamdown";
|
||||
import {
|
||||
CodeBlock,
|
||||
CodeBlockActions,
|
||||
CodeBlockCopyButton,
|
||||
CodeBlockFilename,
|
||||
CodeBlockHeader,
|
||||
CodeBlockTitle,
|
||||
} from "@/components/ai-elements/code-block";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type MarkdownCodeProps = ComponentProps<"code"> & {
|
||||
"data-block"?: boolean | string;
|
||||
// react-markdown/streamdown pass the hast `Element` here, whose
|
||||
// `properties` is a broad `Record`. Keep this assignable from that type
|
||||
// (rather than a narrow `{ metastring?: string }`) so the component stays
|
||||
// compatible with `Components` regardless of how strict the resolved
|
||||
// hast/streamdown types are; the metastring value is validated at read time.
|
||||
node?: {
|
||||
properties?: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
const LANGUAGE_CLASS_PATTERN = /(?:^|\s)language-([^\s]+)/;
|
||||
const START_LINE_PATTERN = /startLine=(\d+)/;
|
||||
const NO_LINE_NUMBERS_PATTERN = /\bnoLineNumbers\b/;
|
||||
|
||||
function codeText(children: ReactNode): string {
|
||||
if (typeof children === "string" || typeof children === "number") {
|
||||
return String(children);
|
||||
}
|
||||
if (Array.isArray(children)) {
|
||||
return children.map(codeText).join("");
|
||||
}
|
||||
if (isValidElement<{ children?: ReactNode }>(children)) {
|
||||
return codeText(children.props.children);
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
const MarkdownCode = ({
|
||||
children,
|
||||
className,
|
||||
node,
|
||||
"data-block": dataBlock,
|
||||
...props
|
||||
}: MarkdownCodeProps) => {
|
||||
const language = className?.match(LANGUAGE_CLASS_PATTERN)?.[1] ?? "text";
|
||||
|
||||
if (!dataBlock) {
|
||||
return (
|
||||
<code
|
||||
className={cn(
|
||||
"rounded bg-muted px-1.5 py-0.5 font-mono text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
const metaValue = node?.properties?.metastring;
|
||||
const meta = typeof metaValue === "string" ? metaValue : undefined;
|
||||
const startLineMatch = meta?.match(START_LINE_PATTERN);
|
||||
const startLine = startLineMatch ? Number.parseInt(startLineMatch[1], 10) : 1;
|
||||
const showLineNumbers = meta ? !NO_LINE_NUMBERS_PATTERN.test(meta) : true;
|
||||
|
||||
return (
|
||||
<CodeBlock
|
||||
code={codeText(children)}
|
||||
data-start-line={startLine > 1 ? startLine : undefined}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
>
|
||||
<CodeBlockHeader>
|
||||
<CodeBlockTitle>
|
||||
<CodeBlockFilename>{language}</CodeBlockFilename>
|
||||
</CodeBlockTitle>
|
||||
<CodeBlockActions>
|
||||
<CodeBlockCopyButton />
|
||||
</CodeBlockActions>
|
||||
</CodeBlockHeader>
|
||||
</CodeBlock>
|
||||
);
|
||||
};
|
||||
|
||||
const markdownComponents = {
|
||||
code: MarkdownCode,
|
||||
} satisfies Components;
|
||||
|
||||
const DEFAULT_MERMAID_CONFIG = {
|
||||
fontFamily: "monospace",
|
||||
securityLevel: "strict",
|
||||
startOnLoad: false,
|
||||
suppressErrorRendering: true,
|
||||
theme: "default",
|
||||
} satisfies MermaidConfig;
|
||||
|
||||
interface LazyMermaidInstance {
|
||||
initialize: (config: MermaidConfig) => void;
|
||||
render: (
|
||||
id: string,
|
||||
source: string,
|
||||
) => Promise<{
|
||||
svg: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function createLazyMermaidPlugin(): DiagramPlugin {
|
||||
let config: MermaidConfig = DEFAULT_MERMAID_CONFIG;
|
||||
let initialized = false;
|
||||
|
||||
const instance: LazyMermaidInstance = {
|
||||
initialize(nextConfig: MermaidConfig) {
|
||||
config = { ...DEFAULT_MERMAID_CONFIG, ...config, ...nextConfig };
|
||||
initialized = false;
|
||||
},
|
||||
async render(id: string, source: string) {
|
||||
const mermaidModule = await import("mermaid");
|
||||
const mermaid = mermaidModule.default;
|
||||
if (!initialized) {
|
||||
mermaid.initialize(config);
|
||||
initialized = true;
|
||||
}
|
||||
return mermaid.render(id, source);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
getMermaid(nextConfig?: MermaidConfig) {
|
||||
if (nextConfig) {
|
||||
instance.initialize(nextConfig);
|
||||
}
|
||||
return instance;
|
||||
},
|
||||
language: "mermaid",
|
||||
name: "mermaid",
|
||||
type: "diagram",
|
||||
};
|
||||
}
|
||||
|
||||
const streamdownPlugins = { cjk, mermaid: createLazyMermaidPlugin() };
|
||||
|
||||
export type HubStreamdownProps = StreamdownProps;
|
||||
|
||||
export const HubStreamdown = memo(
|
||||
({ className, components, ...props }: HubStreamdownProps) => {
|
||||
const mergedComponents = components
|
||||
? { ...markdownComponents, ...components }
|
||||
: markdownComponents;
|
||||
|
||||
return (
|
||||
<Streamdown
|
||||
className={className}
|
||||
components={mergedComponents}
|
||||
plugins={streamdownPlugins}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
HubStreamdown.displayName = "HubStreamdown";
|
||||
@@ -1,27 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox";
|
||||
import { CheckIcon } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 cursor-pointer items-center justify-center rounded-lg border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export { Checkbox };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,111 +0,0 @@
|
||||
import type { ComponentType, ReactNode } from "react";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PageFrameProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
export function PageFrame({
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}: PageFrameProps) {
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div
|
||||
className={cn(
|
||||
"px-18 py-10 max-[1200px]:px-8 max-[720px]:px-4 max-[720px]:py-5",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className={cn("max-w-344", contentClassName)}>{children}</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
type PageHeaderProps = {
|
||||
actions?: ReactNode;
|
||||
className?: string;
|
||||
description?: ReactNode;
|
||||
icon?: ComponentType<{ className?: string }>;
|
||||
meta?: ReactNode;
|
||||
title: ReactNode;
|
||||
};
|
||||
|
||||
export function PageHeader({
|
||||
actions,
|
||||
className,
|
||||
description,
|
||||
icon: Icon,
|
||||
meta,
|
||||
title,
|
||||
}: PageHeaderProps) {
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
{Icon ? <Icon className="size-8 shrink-0 text-primary" /> : null}
|
||||
<h1 className="truncate text-[32px] font-semibold leading-[1.15] tracking-normal text-foreground">
|
||||
{title}
|
||||
</h1>
|
||||
{meta}
|
||||
</div>
|
||||
{description ? (
|
||||
<p className="mt-3 max-w-2xl text-[15px] leading-6 text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2 max-[860px]:justify-start">
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
type PageEmptyStateProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function PageEmptyState({ children, className }: PageEmptyStateProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg border border-dashed border-border bg-card px-5 py-4 text-sm leading-6 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type CommandBadgeProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CommandBadge({ children, className }: CommandBadgeProps) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"rounded-md border border-border bg-background px-2 py-0.5 font-mono text-xs text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,711 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type {
|
||||
ClineAccountBalance,
|
||||
ClineAccountOrganization,
|
||||
ClineAccountOrganizationBalance,
|
||||
ClineAccountOrganizationUsageTransaction,
|
||||
ClineAccountPaymentTransaction,
|
||||
ClineAccountUsageTransaction,
|
||||
ClineAccountUser,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
Building,
|
||||
CreditCard,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
LogIn,
|
||||
LogOut,
|
||||
Plus,
|
||||
Receipt,
|
||||
RefreshCw,
|
||||
UserCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
|
||||
function normalizeAccountViewError(error: unknown): Error {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes("unsupported desktop command: cline_account")) {
|
||||
return new Error(
|
||||
"The desktop sidecar is running an older build that does not support account commands. Restart the sidecar or reload the app, then try again.",
|
||||
);
|
||||
}
|
||||
return error instanceof Error ? error : new Error(message);
|
||||
}
|
||||
|
||||
function isAccountAuthError(message: string): boolean {
|
||||
const normalized = message.toLowerCase();
|
||||
return (
|
||||
normalized.includes("no cline account auth token found") ||
|
||||
normalized.includes("requires re-authentication") ||
|
||||
normalized.includes("auth token") ||
|
||||
normalized.includes("unauthorized")
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data fetching helpers via sidecar command
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function fetchAccountUser(): Promise<ClineAccountUser> {
|
||||
return await desktopClient.invoke<ClineAccountUser>("cline_account", {
|
||||
action: "clineAccount",
|
||||
operation: "fetchMe",
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAccountBalance(): Promise<ClineAccountBalance> {
|
||||
return await desktopClient.invoke<ClineAccountBalance>("cline_account", {
|
||||
action: "clineAccount",
|
||||
operation: "fetchBalance",
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchAccountOrganizations(): Promise<
|
||||
ClineAccountOrganization[]
|
||||
> {
|
||||
return await desktopClient.invoke<ClineAccountOrganization[]>(
|
||||
"cline_account",
|
||||
{
|
||||
action: "clineAccount",
|
||||
operation: "fetchUserOrganizations",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchOrganizationBalance(
|
||||
organizationId: string,
|
||||
): Promise<ClineAccountOrganizationBalance> {
|
||||
return await desktopClient.invoke<ClineAccountOrganizationBalance>(
|
||||
"cline_account",
|
||||
{
|
||||
action: "clineAccount",
|
||||
operation: "fetchOrganizationBalance",
|
||||
organizationId,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchUsageTransactions(): Promise<
|
||||
ClineAccountUsageTransaction[]
|
||||
> {
|
||||
return await desktopClient.invoke<ClineAccountUsageTransaction[]>(
|
||||
"cline_account",
|
||||
{
|
||||
action: "clineAccount",
|
||||
operation: "fetchUsageTransactions",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchOrganizationUsageTransactions(
|
||||
organizationId: string,
|
||||
memberId?: string,
|
||||
): Promise<ClineAccountOrganizationUsageTransaction[]> {
|
||||
return await desktopClient.invoke<ClineAccountOrganizationUsageTransaction[]>(
|
||||
"cline_account",
|
||||
{
|
||||
action: "clineAccount",
|
||||
operation: "fetchOrganizationUsageTransactions",
|
||||
organizationId,
|
||||
...(memberId?.trim() ? { memberId: memberId.trim() } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchPaymentTransactions(): Promise<
|
||||
ClineAccountPaymentTransaction[]
|
||||
> {
|
||||
return await desktopClient.invoke<ClineAccountPaymentTransaction[]>(
|
||||
"cline_account",
|
||||
{
|
||||
action: "clineAccount",
|
||||
operation: "fetchPaymentTransactions",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AccountView() {
|
||||
const [activeTab, setActiveTab] = useState<"overview" | "usage" | "billing">(
|
||||
"overview",
|
||||
);
|
||||
const [accountActionPending, setAccountActionPending] = useState<
|
||||
"sign-in" | "sign-out" | null
|
||||
>(null);
|
||||
|
||||
// Overview data
|
||||
const [user, setUser] = useState<ClineAccountUser | null>(null);
|
||||
const [balance, setBalance] = useState<ClineAccountBalance | null>(null);
|
||||
const [organizationBalance, setOrganizationBalance] =
|
||||
useState<ClineAccountOrganizationBalance | null>(null);
|
||||
const [organizations, setOrganizations] = useState<
|
||||
ClineAccountOrganization[]
|
||||
>([]);
|
||||
const [overviewLoading, setOverviewLoading] = useState(true);
|
||||
const [overviewError, setOverviewError] = useState<string | null>(null);
|
||||
|
||||
// Usage data
|
||||
const [usageTransactions, setUsageTransactions] = useState<
|
||||
ClineAccountUsageTransaction[]
|
||||
>([]);
|
||||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
const [usageError, setUsageError] = useState<string | null>(null);
|
||||
const [usageLoaded, setUsageLoaded] = useState(false);
|
||||
const usageGenerationRef = useRef(0);
|
||||
|
||||
// Billing data
|
||||
const [paymentTransactions, setPaymentTransactions] = useState<
|
||||
ClineAccountPaymentTransaction[]
|
||||
>([]);
|
||||
const [billingLoading, setBillingLoading] = useState(false);
|
||||
const [billingError, setBillingError] = useState<string | null>(null);
|
||||
const [billingLoaded, setBillingLoaded] = useState(false);
|
||||
const activeOrganization = organizations.find((org) => org.active) ?? null;
|
||||
|
||||
const resetAccountData = useCallback(() => {
|
||||
setUser(null);
|
||||
setBalance(null);
|
||||
setOrganizationBalance(null);
|
||||
setOrganizations([]);
|
||||
setUsageTransactions([]);
|
||||
setUsageLoaded(false);
|
||||
setUsageError(null);
|
||||
setPaymentTransactions([]);
|
||||
setBillingLoaded(false);
|
||||
setBillingError(null);
|
||||
}, []);
|
||||
|
||||
// -- Overview fetch --
|
||||
const loadOverview = useCallback(async () => {
|
||||
setOverviewLoading(true);
|
||||
setOverviewError(null);
|
||||
try {
|
||||
const [userData, balanceData, orgsData] = await Promise.all([
|
||||
fetchAccountUser(),
|
||||
fetchAccountBalance(),
|
||||
fetchAccountOrganizations(),
|
||||
]);
|
||||
const nextActiveOrganization =
|
||||
orgsData.find((organization) => organization.active) ?? null;
|
||||
const organizationBalanceData = nextActiveOrganization
|
||||
? await fetchOrganizationBalance(nextActiveOrganization.organizationId)
|
||||
: null;
|
||||
setUser(userData);
|
||||
setBalance(balanceData);
|
||||
setOrganizationBalance(organizationBalanceData);
|
||||
setOrganizations(orgsData);
|
||||
} catch (err) {
|
||||
resetAccountData();
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setOverviewError(message);
|
||||
} finally {
|
||||
setOverviewLoading(false);
|
||||
}
|
||||
}, [resetAccountData]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadOverview();
|
||||
}, [loadOverview]);
|
||||
|
||||
const signIn = async () => {
|
||||
setAccountActionPending("sign-in");
|
||||
setOverviewError(null);
|
||||
try {
|
||||
await desktopClient.invoke("run_provider_oauth_login", {
|
||||
provider: "cline",
|
||||
});
|
||||
await loadOverview();
|
||||
setActiveTab("overview");
|
||||
} catch (err) {
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setOverviewError(message);
|
||||
resetAccountData();
|
||||
} finally {
|
||||
setAccountActionPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
const signOut = async () => {
|
||||
setAccountActionPending("sign-out");
|
||||
try {
|
||||
await desktopClient.invoke("save_provider_settings", {
|
||||
provider: "cline",
|
||||
api_key: "",
|
||||
settings: {
|
||||
auth: {
|
||||
accessToken: "",
|
||||
refreshToken: "",
|
||||
accountId: "",
|
||||
},
|
||||
},
|
||||
});
|
||||
resetAccountData();
|
||||
setActiveTab("overview");
|
||||
setOverviewError("No Cline account auth token found");
|
||||
} catch (err) {
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setOverviewError(message);
|
||||
} finally {
|
||||
setAccountActionPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
// -- Usage fetch (lazy on tab switch) --
|
||||
const loadUsage = useCallback(async () => {
|
||||
const generation = usageGenerationRef.current;
|
||||
setUsageLoading(true);
|
||||
setUsageError(null);
|
||||
try {
|
||||
const data = activeOrganization
|
||||
? await fetchOrganizationUsageTransactions(
|
||||
activeOrganization.organizationId,
|
||||
activeOrganization.memberId,
|
||||
)
|
||||
: await fetchUsageTransactions();
|
||||
if (usageGenerationRef.current !== generation) return;
|
||||
setUsageTransactions(data);
|
||||
setUsageLoaded(true);
|
||||
} catch (err) {
|
||||
if (usageGenerationRef.current !== generation) return;
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setUsageError(message);
|
||||
} finally {
|
||||
if (usageGenerationRef.current === generation) {
|
||||
setUsageLoading(false);
|
||||
}
|
||||
}
|
||||
}, [activeOrganization]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: we need to reset usage state when the organization changes
|
||||
useEffect(() => {
|
||||
usageGenerationRef.current += 1;
|
||||
setUsageTransactions([]);
|
||||
setUsageLoaded(false);
|
||||
setUsageError(null);
|
||||
}, [activeOrganization?.organizationId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "usage" && !usageLoaded) {
|
||||
void loadUsage();
|
||||
}
|
||||
}, [activeTab, usageLoaded, loadUsage]);
|
||||
|
||||
// -- Billing fetch (lazy on tab switch) --
|
||||
const loadBilling = useCallback(async () => {
|
||||
setBillingLoading(true);
|
||||
setBillingError(null);
|
||||
try {
|
||||
const data = await fetchPaymentTransactions();
|
||||
setPaymentTransactions(data);
|
||||
setBillingLoaded(true);
|
||||
} catch (err) {
|
||||
const message = normalizeAccountViewError(err).message;
|
||||
setBillingError(message);
|
||||
} finally {
|
||||
setBillingLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "billing" && !billingLoaded) {
|
||||
void loadBilling();
|
||||
}
|
||||
}, [activeTab, billingLoaded, loadBilling]);
|
||||
|
||||
// -- Formatters --
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleTimeString("en-US", {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
const formatCreditBalance = (value: number, decimalPlaces = 2) => {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
minimumFractionDigits: decimalPlaces,
|
||||
maximumFractionDigits: decimalPlaces,
|
||||
}).format(value / 1_000_000);
|
||||
};
|
||||
|
||||
const displayedBalance = activeOrganization
|
||||
? (organizationBalance?.balance ?? balance?.balance ?? null)
|
||||
: (balance?.balance ?? null);
|
||||
|
||||
const tabs = ["overview", "usage", "billing"] as const;
|
||||
|
||||
// -- Shared error / loading UI --
|
||||
|
||||
const renderError = (message: string, onRetry: () => void) => (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm text-muted-foreground max-w-md">{message}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="flex items-center gap-2 rounded-lg border border-border px-3.5 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSignedOut = () => (
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<div className="mx-auto flex max-w-xl flex-col items-center gap-4 py-8 text-center">
|
||||
<div className="flex size-12 items-center justify-center rounded-lg bg-primary/10 text-primary">
|
||||
<UserCircleIcon className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-foreground">
|
||||
Sign in to Cline
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Connect your Cline account to review credits, usage, billing, and
|
||||
organization details from Cline Hub.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
<Button
|
||||
disabled={accountActionPending !== null}
|
||||
onClick={() => void signIn()}
|
||||
type="button"
|
||||
>
|
||||
{accountActionPending === "sign-in" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<LogIn className="h-4 w-4" />
|
||||
)}
|
||||
{accountActionPending === "sign-in" ? "Signing in" : "Sign in"}
|
||||
</Button>
|
||||
<a
|
||||
className="inline-flex h-9 items-center gap-2 rounded-lg border border-border px-3.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
href="https://app.cline.bot"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Create account
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderLoading = () => (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageFrame>
|
||||
<PageHeader
|
||||
description="Review account, usage, billing, and organization details."
|
||||
title="Account"
|
||||
/>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex items-center gap-0 border-b border-border">
|
||||
{tabs.map((tab) => {
|
||||
const disabled = !user && tab !== "overview";
|
||||
return (
|
||||
<button
|
||||
disabled={disabled}
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={cn(
|
||||
"relative px-4 py-2.5 text-sm font-medium capitalize transition-colors",
|
||||
activeTab === tab
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
disabled &&
|
||||
"cursor-not-allowed opacity-45 hover:text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tab}
|
||||
{activeTab === tab && (
|
||||
<span className="absolute inset-x-0 -bottom-px h-0.5 bg-foreground" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Overview Tab */}
|
||||
{activeTab === "overview" && (
|
||||
<div className="flex flex-col gap-6">
|
||||
{overviewLoading && renderLoading()}
|
||||
{overviewError &&
|
||||
(isAccountAuthError(overviewError)
|
||||
? renderSignedOut()
|
||||
: renderError(overviewError, loadOverview))}
|
||||
{!overviewLoading && !overviewError && user && (
|
||||
<>
|
||||
{/* User Profile Card */}
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-full bg-primary/20 text-2xl font-bold text-primary">
|
||||
{user.displayName?.charAt(0) ??
|
||||
user.email?.charAt(0) ??
|
||||
"?"}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold text-foreground">
|
||||
{user.displayName || user.email}
|
||||
</h3>
|
||||
<p className="mt-0.5 text-sm text-muted-foreground">
|
||||
{user.email}
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Member since {formatDate(user.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<a
|
||||
href="https://app.cline.bot/dashboard"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Open dashboard
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<Button
|
||||
className="h-8 rounded-md px-2.5 text-xs"
|
||||
disabled={accountActionPending !== null}
|
||||
onClick={() => void signOut()}
|
||||
type="button"
|
||||
variant="outline"
|
||||
>
|
||||
{accountActionPending === "sign-out" ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<LogOut className="h-3.5 w-3.5" />
|
||||
)}
|
||||
{accountActionPending === "sign-out"
|
||||
? "Signing out"
|
||||
: "Sign out"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Balance Card */}
|
||||
{displayedBalance !== null && (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-5 w-5 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
{activeOrganization
|
||||
? `${activeOrganization.name} Balance`
|
||||
: "Credits Balance"}
|
||||
</h3>
|
||||
</div>
|
||||
<a
|
||||
href="https://app.cline.bot/dashboard/organization?tab=credits&redirect=true"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Credit
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-3xl font-bold text-foreground">
|
||||
${formatCreditBalance(displayedBalance)}
|
||||
</span>
|
||||
</div>
|
||||
{activeOrganization && balance && (
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
Personal account: {formatCreditBalance(balance.balance)}{" "}
|
||||
credits
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organizations */}
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Building className="h-5 w-5 text-muted-foreground" />
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
Organizations
|
||||
</h3>
|
||||
</div>
|
||||
<a
|
||||
href="https://app.cline.bot/onboarding?step=1"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Create
|
||||
</a>
|
||||
</div>
|
||||
{organizations.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No organizations yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{organizations.map((org) => (
|
||||
<div
|
||||
key={org.organizationId}
|
||||
className="flex items-center gap-3 rounded-lg border border-border px-4 py-3 transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-sm font-bold text-foreground">
|
||||
{org.name.charAt(0)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
{org.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{org.roles.join(", ")}
|
||||
</p>
|
||||
</div>
|
||||
{org.active && (
|
||||
<span className="rounded-full bg-primary/20 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Usage Tab */}
|
||||
{activeTab === "usage" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
{activeOrganization
|
||||
? `Recent API usage and token consumption for ${activeOrganization.name}.`
|
||||
: "Recent API usage and token consumption across all providers."}
|
||||
</p>
|
||||
{usageLoading && renderLoading()}
|
||||
{usageError && renderError(usageError, loadUsage)}
|
||||
{!usageLoading &&
|
||||
!usageError &&
|
||||
usageLoaded &&
|
||||
(usageTransactions.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No usage transactions yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Model</span>
|
||||
<span className="text-right">Tokens</span>
|
||||
<span className="text-right">Credits</span>
|
||||
<span className="text-right">Time</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{usageTransactions.map((tx) => (
|
||||
<div
|
||||
key={tx.id}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-foreground truncate">
|
||||
{tx.aiModelName}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tx.aiInferenceProviderName}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-muted-foreground">
|
||||
{tx.totalTokens.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-right text-foreground font-medium">
|
||||
{formatCreditBalance(tx.creditsUsed)}
|
||||
</div>
|
||||
<div className="text-right text-xs text-muted-foreground">
|
||||
<p>{formatDate(tx.createdAt)}</p>
|
||||
<p>{formatTime(tx.createdAt)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Billing Tab */}
|
||||
{activeTab === "billing" && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm text-muted-foreground">
|
||||
Payment history and credit purchases.
|
||||
</p>
|
||||
{billingLoading && renderLoading()}
|
||||
{billingError && renderError(billingError, loadBilling)}
|
||||
{!billingLoading &&
|
||||
!billingError &&
|
||||
billingLoaded &&
|
||||
(paymentTransactions.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||
No payment transactions yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<div className="grid grid-cols-[1fr_auto_auto] gap-4 border-b border-border bg-secondary/50 px-4 py-2.5 text-xs font-medium text-muted-foreground">
|
||||
<span>Date</span>
|
||||
<span className="text-right">Amount</span>
|
||||
<span className="text-right">Credits</span>
|
||||
</div>
|
||||
<div className="divide-y divide-border">
|
||||
{paymentTransactions.map((tx) => (
|
||||
<div
|
||||
key={`${tx.paidAt}-${tx.amountCents}-${tx.credits}`}
|
||||
className="grid grid-cols-[1fr_auto_auto] gap-4 px-4 py-3 text-sm transition-colors hover:bg-accent/20"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Receipt className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-foreground">
|
||||
{formatDate(tx.paidAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-right text-foreground font-medium">
|
||||
${(tx.amountCents / 100).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-right text-primary font-medium">
|
||||
+{formatCreditBalance(tx.credits)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
@@ -1,529 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
|
||||
const CAPABILITY_OPTIONS = [
|
||||
"streaming",
|
||||
"tools",
|
||||
"reasoning",
|
||||
"vision",
|
||||
"prompt-cache",
|
||||
] as const;
|
||||
|
||||
type Capability = (typeof CAPABILITY_OPTIONS)[number];
|
||||
|
||||
export interface AddProviderPayload {
|
||||
providerId: string;
|
||||
name: string;
|
||||
baseUrl: string;
|
||||
apiKey?: string;
|
||||
headers?: Record<string, string>;
|
||||
timeoutMs?: number;
|
||||
models: string[];
|
||||
defaultModelId?: string;
|
||||
modelsSourceUrl?: string;
|
||||
capabilities?: Capability[];
|
||||
}
|
||||
|
||||
interface NewProviderForm {
|
||||
providerId: string;
|
||||
name: string;
|
||||
models: string[];
|
||||
defaultModel: string;
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
modelsSourceUrl: string;
|
||||
headers: Record<string, string>;
|
||||
timeoutMs: string;
|
||||
capabilities: Capability[];
|
||||
}
|
||||
|
||||
export function AddProviderContent({
|
||||
onBack,
|
||||
onSave,
|
||||
existingProviderIds,
|
||||
}: {
|
||||
onBack: () => void;
|
||||
onSave: (payload: AddProviderPayload) => Promise<void>;
|
||||
existingProviderIds: string[];
|
||||
}) {
|
||||
const [form, setForm] = useState<NewProviderForm>({
|
||||
providerId: "",
|
||||
name: "",
|
||||
models: [],
|
||||
defaultModel: "",
|
||||
apiKey: "",
|
||||
baseUrl: "",
|
||||
modelsSourceUrl: "",
|
||||
headers: {},
|
||||
timeoutMs: "",
|
||||
capabilities: ["streaming", "tools"],
|
||||
});
|
||||
const [modelInput, setModelInput] = useState("");
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const normalizedProviderId = useMemo(
|
||||
() => form.providerId.trim().toLowerCase().replace(/\s+/g, "-"),
|
||||
[form.providerId],
|
||||
);
|
||||
|
||||
const duplicateProviderId =
|
||||
existingProviderIds.includes(normalizedProviderId);
|
||||
const hasManualModels = form.models.length > 0;
|
||||
const hasModelsSource = form.modelsSourceUrl.trim().length > 0;
|
||||
const canSave =
|
||||
normalizedProviderId.length > 0 &&
|
||||
form.name.trim().length > 0 &&
|
||||
form.baseUrl.trim().length > 0 &&
|
||||
(hasManualModels || hasModelsSource) &&
|
||||
!duplicateProviderId;
|
||||
|
||||
const handleAddModel = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if ((e.key === "Enter" || e.key === ",") && modelInput.trim()) {
|
||||
e.preventDefault();
|
||||
const value = modelInput.trim().replace(/,/g, "");
|
||||
if (value && !form.models.includes(value)) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
models: [...prev.models, value],
|
||||
defaultModel: prev.defaultModel || value,
|
||||
}));
|
||||
}
|
||||
setModelInput("");
|
||||
} else if (e.key === "Backspace" && !modelInput && form.models.length > 0) {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
models: prev.models.slice(0, -1),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const removeModel = (model: string) => {
|
||||
setForm((prev) => {
|
||||
const nextModels = prev.models.filter((m) => m !== model);
|
||||
return {
|
||||
...prev,
|
||||
models: nextModels,
|
||||
defaultModel:
|
||||
prev.defaultModel === model
|
||||
? (nextModels[0] ?? "")
|
||||
: prev.defaultModel,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const toggleCapability = (cap: Capability) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
capabilities: prev.capabilities.includes(cap)
|
||||
? prev.capabilities.filter((c) => c !== cap)
|
||||
: [...prev.capabilities, cap],
|
||||
}));
|
||||
};
|
||||
|
||||
const addHeader = () => {
|
||||
setForm((prev) => ({ ...prev, headers: { ...prev.headers, "": "" } }));
|
||||
};
|
||||
|
||||
const updateHeaderKey = (oldKey: string, newKey: string, idx: number) => {
|
||||
const entries = Object.entries(form.headers);
|
||||
const next: Record<string, string> = {};
|
||||
entries.forEach(([key, value], index) => {
|
||||
next[index === idx ? newKey : key] = value;
|
||||
});
|
||||
if (oldKey !== newKey) {
|
||||
delete next[oldKey];
|
||||
}
|
||||
setForm((prev) => ({ ...prev, headers: next }));
|
||||
};
|
||||
|
||||
const updateHeaderValue = (key: string, value: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
headers: { ...prev.headers, [key]: value },
|
||||
}));
|
||||
};
|
||||
|
||||
const removeHeader = (key: string) => {
|
||||
const next = { ...form.headers };
|
||||
delete next[key];
|
||||
setForm((prev) => ({ ...prev, headers: next }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!canSave || saving) {
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave({
|
||||
providerId: normalizedProviderId,
|
||||
name: form.name.trim(),
|
||||
baseUrl: form.baseUrl.trim(),
|
||||
apiKey: form.apiKey.trim() || undefined,
|
||||
headers: Object.fromEntries(
|
||||
Object.entries(form.headers)
|
||||
.map(([key, value]) => [key.trim(), value])
|
||||
.filter(([key]) => key.length > 0),
|
||||
),
|
||||
timeoutMs:
|
||||
form.timeoutMs.trim().length > 0
|
||||
? Number.parseInt(form.timeoutMs.trim(), 10)
|
||||
: undefined,
|
||||
models: form.models,
|
||||
defaultModelId: form.defaultModel || form.models[0],
|
||||
modelsSourceUrl: form.modelsSourceUrl.trim() || undefined,
|
||||
capabilities:
|
||||
form.capabilities.length > 0 ? form.capabilities : undefined,
|
||||
});
|
||||
} catch (saveError) {
|
||||
setError(
|
||||
saveError instanceof Error ? saveError.message : String(saveError),
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageFrame contentClassName="max-w-4xl">
|
||||
<PageHeader
|
||||
description="Add an OpenAI-compatible provider and choose its available models."
|
||||
title="Add Provider"
|
||||
actions={
|
||||
<Button
|
||||
onClick={onBack}
|
||||
variant="secondary"
|
||||
className="rounded-md p-1.5 transition-colors"
|
||||
aria-label="Back to providers"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Providers
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<h3 className="mb-4 text-sm font-semibold text-foreground">
|
||||
OpenAI-Compatible Provider
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider ID
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.providerId}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, providerId: e.target.value }))
|
||||
}
|
||||
placeholder="my-provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Lowercase ID used in provider registry.
|
||||
</p>
|
||||
{duplicateProviderId ? (
|
||||
<p className="mt-1 text-xs text-destructive">
|
||||
This provider ID already exists.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Provider Name
|
||||
</Label>
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, name: e.target.value }))
|
||||
}
|
||||
placeholder="My Provider"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Base URL
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.baseUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, baseUrl: e.target.value }))
|
||||
}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Model Source URL (Optional)
|
||||
</Label>
|
||||
<input
|
||||
type="url"
|
||||
value={form.modelsSourceUrl}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
modelsSourceUrl: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="https://api.example.com/v1/models"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Supported JSON: OpenAI `/models` shape with a `data` array, or a
|
||||
direct model array.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Models
|
||||
</Label>
|
||||
<div className="flex min-h-11 flex-wrap content-start gap-1.5 rounded-lg border border-border bg-input px-3 py-2 focus-within:ring-1 focus-within:ring-ring">
|
||||
{form.models.map((model) => (
|
||||
<span
|
||||
key={model}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
|
||||
>
|
||||
<span className="font-mono">{model}</span>
|
||||
<Button
|
||||
onClick={() => removeModel(model)}
|
||||
className="text-primary/60 hover:text-primary transition-colors"
|
||||
aria-label={`Remove ${model}`}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={modelInput}
|
||||
onChange={(e) => setModelInput(e.target.value)}
|
||||
onKeyDown={handleAddModel}
|
||||
placeholder={
|
||||
form.models.length === 0 ? "Type model ID and press Enter" : ""
|
||||
}
|
||||
className="min-w-35 flex-1 bg-transparent text-sm text-foreground placeholder:text-muted-foreground/50 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-muted-foreground">
|
||||
Add at least one model or set a Model Source URL.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.models.length > 1 ? (
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Default Model
|
||||
</Label>
|
||||
<select
|
||||
value={form.defaultModel}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, defaultModel: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
{form.models.map((model) => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
API Key (Optional)
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showApiKey ? "text" : "password"}
|
||||
value={form.apiKey}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, apiKey: e.target.value }))
|
||||
}
|
||||
placeholder="sk-..."
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 pr-20 font-mono text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
||||
<Button
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label={showApiKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigator.clipboard.writeText(form.apiKey)}
|
||||
variant="ghost"
|
||||
className="rounded-md p-1 transition-colors"
|
||||
aria-label="Copy API key"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border p-5">
|
||||
<Label className="mb-3 block text-xs font-medium text-muted-foreground">
|
||||
Capabilities
|
||||
</Label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CAPABILITY_OPTIONS.map((cap) => (
|
||||
<Button
|
||||
key={cap}
|
||||
onClick={() => toggleCapability(cap)}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-1.5 text-xs font-medium transition-all",
|
||||
form.capabilities.includes(cap)
|
||||
? "border-primary/40 bg-primary/10 text-primary"
|
||||
: "border-border bg-card text-muted-foreground hover:border-muted-foreground/50 hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{cap.replace(/-/g, " ")}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border overflow-hidden">
|
||||
<Button
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex w-full items-center justify-between px-5 py-4 text-sm font-medium transition-colors text-foreground/40"
|
||||
variant="ghost"
|
||||
>
|
||||
Advanced Settings
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"h-4 w-4 text-muted-foreground transition-transform",
|
||||
showAdvanced && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{showAdvanced ? (
|
||||
<div className="border-t border-border px-5 py-5 flex flex-col gap-5">
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Timeout (ms)
|
||||
</Label>
|
||||
<input
|
||||
type="number"
|
||||
value={form.timeoutMs}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
timeoutMs: e.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="30000"
|
||||
className="w-full rounded-lg border border-border bg-input px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="mb-2 block text-xs font-medium text-muted-foreground">
|
||||
Custom Headers
|
||||
</Label>
|
||||
<div className="flex flex-col gap-2">
|
||||
{Object.entries(form.headers).map(([key, value], idx) => (
|
||||
<div key={key} className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={key}
|
||||
onChange={(e) =>
|
||||
updateHeaderKey(key, e.target.value, idx)
|
||||
}
|
||||
placeholder="Header name"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => updateHeaderValue(key, e.target.value)}
|
||||
placeholder="Value"
|
||||
className="flex-1 rounded-lg border border-border bg-input px-3 py-2 font-mono text-xs text-foreground placeholder:text-muted-foreground/50 outline-none focus:ring-1 focus:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
onClick={() => removeHeader(key)}
|
||||
className="rounded-md p-2 text-muted-foreground hover:text-destructive transition-colors"
|
||||
aria-label="Remove header"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
onClick={addHeader}
|
||||
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground transition-colors w-fit"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add Header
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<Button
|
||||
onClick={onBack}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => void handleSave()}
|
||||
disabled={!canSave || saving}
|
||||
className={cn(
|
||||
"rounded-lg px-4 py-2 text-sm font-medium transition-colors",
|
||||
canSave && !saving
|
||||
? "bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
: "bg-muted text-muted-foreground cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{saving ? "Saving..." : "Add Provider"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PageFrame>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,641 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronRight,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileIcon,
|
||||
ImageIcon,
|
||||
Link as LinkIcon,
|
||||
Loader2,
|
||||
PlusCircle,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Star,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import type {
|
||||
Provider,
|
||||
ProviderConfigField,
|
||||
ProviderConfigFieldPrimitive,
|
||||
ProviderSettingsUpdate,
|
||||
} from "@/lib/provider-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
// -----------------------------------------------------------
|
||||
// Provider LIST content (the grid of all providers)
|
||||
// -----------------------------------------------------------
|
||||
|
||||
function getInitialConfigValues(
|
||||
provider: Provider,
|
||||
): Record<string, ProviderConfigFieldPrimitive> {
|
||||
const values: Record<string, ProviderConfigFieldPrimitive> = {
|
||||
...(provider.configValues ?? {}),
|
||||
};
|
||||
if (provider.apiKey !== undefined && values.apiKey === undefined) {
|
||||
values.apiKey = provider.apiKey;
|
||||
}
|
||||
if (provider.baseUrl !== undefined && values.baseUrl === undefined) {
|
||||
values.baseUrl = provider.baseUrl;
|
||||
}
|
||||
for (const field of provider.configFields ?? []) {
|
||||
if (values[field.path] === undefined && field.defaultValue !== undefined) {
|
||||
values[field.path] = field.defaultValue;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function fieldValueToString(value: ProviderConfigFieldPrimitive | undefined) {
|
||||
if (value === undefined || value === null) return "";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function coerceFieldValue(
|
||||
field: ProviderConfigField,
|
||||
value: string | boolean,
|
||||
): ProviderConfigFieldPrimitive {
|
||||
if (field.type === "boolean") {
|
||||
return Boolean(value);
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (field.type === "select") {
|
||||
const option = field.options?.find((item) => String(item.value) === value);
|
||||
if (option) {
|
||||
return option.value;
|
||||
}
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (field.type === "number") {
|
||||
const parsed = Number(trimmed);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function ProviderListContent({
|
||||
providers,
|
||||
onToggle,
|
||||
onConfigure,
|
||||
onAddProvider,
|
||||
selectedProviderId,
|
||||
variant = "page",
|
||||
}: {
|
||||
providers: Provider[];
|
||||
onToggle: (id: string) => void;
|
||||
onConfigure: (id: string) => void;
|
||||
onAddProvider: () => void;
|
||||
selectedProviderId?: string | null;
|
||||
variant?: "page" | "panel";
|
||||
}) {
|
||||
const [providerSearchOpen, setProviderSearchOpen] = useState(false);
|
||||
const [providerSearch, setProviderSearch] = useState("");
|
||||
const enabledProviderCount = providers.filter(
|
||||
(provider) => provider.enabled,
|
||||
).length;
|
||||
const providerSearchQuery = providerSearch.trim().toLowerCase();
|
||||
const filteredProviders = providerSearchQuery
|
||||
? providers.filter((provider) =>
|
||||
provider.name.toLowerCase().includes(providerSearchQuery),
|
||||
)
|
||||
: providers;
|
||||
const isPanel = variant === "panel";
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div
|
||||
className={cn(
|
||||
"py-10 max-[720px]:px-4 max-[720px]:py-5",
|
||||
isPanel ? "px-8" : "px-18 max-[1200px]:px-8",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mb-8 flex items-start justify-between gap-6 max-[860px]:flex-col max-[860px]:items-stretch",
|
||||
isPanel ? "max-w-none" : "max-w-[42rem]",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<h1
|
||||
className={cn(
|
||||
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
|
||||
isPanel ? "text-[24px]" : "text-[32px]",
|
||||
)}
|
||||
>
|
||||
Model Providers
|
||||
</h1>
|
||||
<p className="mt-3 text-[15px] leading-6 text-muted-foreground">
|
||||
Configure model providers and choose which ones are available.{" "}
|
||||
{providers.length} available · {enabledProviderCount}{" "}
|
||||
enabled
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2 max-[860px]:justify-start">
|
||||
<Button
|
||||
aria-label="Search providers"
|
||||
className="size-8 rounded-md"
|
||||
onClick={() => setProviderSearchOpen((open) => !open)}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant={providerSearchOpen ? "default" : "secondary"}
|
||||
>
|
||||
<Search className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
className="h-8 rounded-md bg-foreground px-3 text-sm text-background hover:bg-foreground/90"
|
||||
onClick={onAddProvider}
|
||||
type="button"
|
||||
>
|
||||
<PlusCircle className="size-4" />
|
||||
Add provider
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{providerSearchOpen ? (
|
||||
<div className={cn("mb-4", isPanel ? "max-w-none" : "max-w-[42rem]")}>
|
||||
<div className="flex h-9 items-center gap-2 rounded border bg-background px-3">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search model providers"
|
||||
autoFocus
|
||||
className="h-7 border-0 bg-transparent px-0 text-sm"
|
||||
onChange={(event) => setProviderSearch(event.target.value)}
|
||||
placeholder="Search providers"
|
||||
value={providerSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden",
|
||||
isPanel ? "max-w-none" : "max-w-[42rem]",
|
||||
)}
|
||||
>
|
||||
{filteredProviders.length === 0 ? (
|
||||
<div className="border-b px-2 py-6 text-[15px] text-muted-foreground">
|
||||
No providers match "{providerSearch.trim()}".
|
||||
</div>
|
||||
) : null}
|
||||
{filteredProviders.map((prov) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-h-11 items-center gap-4 border-b px-2 py-2 transition-colors hover:bg-accent/30",
|
||||
selectedProviderId === prov.id && "bg-accent/45",
|
||||
)}
|
||||
key={prov.id}
|
||||
>
|
||||
<button
|
||||
className="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onConfigure(prov.id)}
|
||||
type="button"
|
||||
>
|
||||
<p className="min-w-0 flex-1 truncate text-[17px] font-semibold text-foreground">
|
||||
{prov.name}
|
||||
</p>
|
||||
<p className="shrink-0 text-[15px] text-muted-foreground">
|
||||
{prov.models === null
|
||||
? "Models load on demand"
|
||||
: `${prov.models} model${prov.models !== 1 ? "s" : ""}`}
|
||||
</p>
|
||||
</button>
|
||||
<Switch
|
||||
aria-label={`Toggle ${prov.name}`}
|
||||
checked={prov.enabled}
|
||||
onCheckedChange={() => onToggle(prov.id)}
|
||||
/>
|
||||
<button
|
||||
aria-label={`Configure ${prov.name}`}
|
||||
className="grid size-7 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onConfigure(prov.id)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProviderDetailContent({
|
||||
provider,
|
||||
onBack,
|
||||
onUpdate,
|
||||
onLoadModels,
|
||||
modelsLoading = false,
|
||||
modelsError,
|
||||
onOAuthLogin,
|
||||
oauthLoginPending = false,
|
||||
variant = "page",
|
||||
}: {
|
||||
provider: Provider;
|
||||
onBack: () => void;
|
||||
onUpdate: (updates: ProviderSettingsUpdate) => void;
|
||||
onLoadModels?: () => void;
|
||||
modelsLoading?: boolean;
|
||||
modelsError?: string | null;
|
||||
onOAuthLogin?: () => void;
|
||||
oauthLoginPending?: boolean;
|
||||
variant?: "page" | "panel";
|
||||
}) {
|
||||
const [shownSecrets, setShownSecrets] = useState<Record<string, boolean>>({});
|
||||
const [localConfigValues, setLocalConfigValues] = useState<
|
||||
Record<string, ProviderConfigFieldPrimitive>
|
||||
>(() => getInitialConfigValues(provider));
|
||||
const [modelSearchState, setModelSearchState] = useState<{
|
||||
providerId: string;
|
||||
value: string;
|
||||
} | null>(null);
|
||||
const [copiedModelState, setCopiedModelState] = useState<{
|
||||
modelId: string;
|
||||
providerId: string;
|
||||
} | null>(null);
|
||||
const copiedModelTimeoutRef = useRef<number | undefined>(undefined);
|
||||
|
||||
const configFields = provider.configFields ?? [];
|
||||
const apiKeyValue = fieldValueToString(localConfigValues.apiKey);
|
||||
const modelList = provider.modelList ?? [];
|
||||
const modelSearch =
|
||||
modelSearchState?.providerId === provider.id ? modelSearchState.value : "";
|
||||
const copiedModelId =
|
||||
copiedModelState?.providerId === provider.id
|
||||
? copiedModelState.modelId
|
||||
: null;
|
||||
const modelSearchQuery = modelSearch.trim().toLowerCase();
|
||||
const filteredModelList = modelSearchQuery
|
||||
? modelList.filter(
|
||||
(model) =>
|
||||
model.name.toLowerCase().includes(modelSearchQuery) ||
|
||||
model.id.toLowerCase().includes(modelSearchQuery),
|
||||
)
|
||||
: modelList;
|
||||
const isPanel = variant === "panel";
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (copiedModelTimeoutRef.current !== undefined) {
|
||||
window.clearTimeout(copiedModelTimeoutRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const commitField = (
|
||||
field: ProviderConfigField,
|
||||
rawValue: string | boolean,
|
||||
) => {
|
||||
const value = coerceFieldValue(field, rawValue);
|
||||
const nextConfigValues = {
|
||||
...localConfigValues,
|
||||
[field.path]: value,
|
||||
};
|
||||
setLocalConfigValues(nextConfigValues);
|
||||
|
||||
const updates: ProviderSettingsUpdate = {
|
||||
configValues: { [field.path]: value },
|
||||
};
|
||||
if (field.path === "apiKey") {
|
||||
updates.apiKey = fieldValueToString(value);
|
||||
}
|
||||
if (field.path === "baseUrl") {
|
||||
updates.baseUrl = fieldValueToString(value);
|
||||
}
|
||||
onUpdate(updates);
|
||||
};
|
||||
|
||||
const copyModelId = (modelId: string) => {
|
||||
if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) {
|
||||
return;
|
||||
}
|
||||
void navigator.clipboard.writeText(modelId).then(() => {
|
||||
setCopiedModelState({ modelId, providerId: provider.id });
|
||||
if (copiedModelTimeoutRef.current !== undefined) {
|
||||
window.clearTimeout(copiedModelTimeoutRef.current);
|
||||
}
|
||||
copiedModelTimeoutRef.current = window.setTimeout(
|
||||
() => setCopiedModelState(null),
|
||||
1600,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<div
|
||||
className={cn(
|
||||
"py-10 max-[720px]:px-4 max-[720px]:py-5",
|
||||
isPanel ? "px-6" : "px-18 max-[1200px]:px-8",
|
||||
)}
|
||||
>
|
||||
{/* Back + title */}
|
||||
<div className="mb-8 flex items-center gap-3">
|
||||
<Button
|
||||
aria-label={
|
||||
isPanel ? "Close provider details" : "Back to providers"
|
||||
}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
onClick={onBack}
|
||||
variant="ghost"
|
||||
>
|
||||
{isPanel ? (
|
||||
<X className="h-4 w-4" />
|
||||
) : (
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<h1
|
||||
className={cn(
|
||||
"truncate font-semibold leading-[1.15] tracking-normal text-foreground",
|
||||
isPanel ? "text-[24px]" : "text-[32px]",
|
||||
)}
|
||||
>
|
||||
{provider.name}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{configFields.length > 0 ? (
|
||||
<section
|
||||
className={cn("mb-8", isPanel ? "max-w-none" : "max-w-[86rem]")}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
{configFields.map((field) => {
|
||||
const value = localConfigValues[field.path];
|
||||
const valueText = fieldValueToString(value);
|
||||
const isSecret = field.type === "password" || field.secret;
|
||||
const isShown = shownSecrets[field.path] ?? false;
|
||||
return (
|
||||
<div
|
||||
className="grid min-h-18 grid-cols-[minmax(12rem,0.55fr)_minmax(16rem,0.45fr)] items-center gap-6 border-b py-4 max-[900px]:grid-cols-1 max-[900px]:gap-3"
|
||||
key={field.path}
|
||||
>
|
||||
<header>
|
||||
<h3 className="text-[17px] font-semibold text-foreground">
|
||||
{field.label}
|
||||
</h3>
|
||||
{field.description ? (
|
||||
<p className="mt-1 text-[15px] leading-relaxed text-muted-foreground">
|
||||
{field.description}
|
||||
</p>
|
||||
) : null}
|
||||
</header>
|
||||
{field.type === "boolean" ? (
|
||||
<div className="flex items-center justify-end">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{field.label}
|
||||
</span>
|
||||
<Switch
|
||||
checked={Boolean(value)}
|
||||
onCheckedChange={(checked) =>
|
||||
commitField(field, checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : field.type === "select" ? (
|
||||
<select
|
||||
className="h-9 w-full rounded border border-border bg-background px-3 text-sm text-foreground outline-none focus:ring-1 focus:ring-ring"
|
||||
onChange={(event) =>
|
||||
commitField(field, event.target.value)
|
||||
}
|
||||
value={valueText}
|
||||
>
|
||||
<option value="">Not set</option>
|
||||
{field.options?.map((option) => (
|
||||
<option
|
||||
key={String(option.value)}
|
||||
value={String(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="flex h-9 items-center gap-2 rounded border border-border bg-background px-3">
|
||||
{field.type === "url" ? (
|
||||
<LinkIcon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<Input
|
||||
className="h-7 flex-1 border-0 bg-transparent px-0 text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||
onBlur={() => commitField(field, valueText)}
|
||||
onChange={(event) =>
|
||||
setLocalConfigValues((current) => ({
|
||||
...current,
|
||||
[field.path]: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder={field.placeholder}
|
||||
spellCheck={false}
|
||||
type={
|
||||
isSecret && !isShown
|
||||
? "password"
|
||||
: field.type === "number"
|
||||
? "number"
|
||||
: field.type === "url"
|
||||
? "url"
|
||||
: "text"
|
||||
}
|
||||
value={valueText}
|
||||
/>
|
||||
{isSecret ? (
|
||||
<>
|
||||
<Button
|
||||
aria-label={
|
||||
isShown ? "Hide secret" : "Show secret"
|
||||
}
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() =>
|
||||
setShownSecrets((current) => ({
|
||||
...current,
|
||||
[field.path]: !isShown,
|
||||
}))
|
||||
}
|
||||
variant="ghost"
|
||||
>
|
||||
{isShown ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
aria-label={`Copy ${field.label}`}
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={() =>
|
||||
navigator.clipboard.writeText(valueText)
|
||||
}
|
||||
variant="ghost"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{!apiKeyValue && !provider.oauthAccessTokenPresent && onOAuthLogin ? (
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
className="inline-flex items-center gap-2 w-full"
|
||||
disabled={oauthLoginPending}
|
||||
onClick={onOAuthLogin}
|
||||
variant="default"
|
||||
>
|
||||
{oauthLoginPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
<span>Login via Browser</span>
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{provider.oauthAccessTokenPresent ? (
|
||||
<p className="mb-8 text-xs text-muted-foreground">
|
||||
OAuth is connected. Manual credentials remain available when this
|
||||
provider supports them.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{/* Models section */}
|
||||
<section
|
||||
className={cn(
|
||||
"overflow-hidden rounded-lg border",
|
||||
isPanel ? "max-w-none" : "max-w-[46rem]",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-12 items-center justify-between bg-muted/40 px-4">
|
||||
<h2 className="text-[17px] font-medium text-muted-foreground">
|
||||
Models
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
<Button
|
||||
aria-label="Refresh models"
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||
disabled={modelsLoading}
|
||||
onClick={onLoadModels}
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn("size-3", modelsLoading && "animate-spin")}
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modelsError ? (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center">
|
||||
<p className="text-sm text-destructive">{modelsError}</p>
|
||||
</div>
|
||||
) : modelList.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
<div className="mx-4 mt-4 flex items-center gap-2 rounded border border-border bg-background px-3 py-2">
|
||||
<Search className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="Search models"
|
||||
className="h-7 flex-1 border-0 text-sm text-foreground placeholder:text-muted-foreground"
|
||||
onChange={(event) =>
|
||||
setModelSearchState({
|
||||
providerId: provider.id,
|
||||
value: event.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Search models by name or ID"
|
||||
spellCheck={false}
|
||||
value={modelSearch}
|
||||
/>
|
||||
</div>
|
||||
{filteredModelList.length > 0 ? (
|
||||
<div className="max-h-125 overflow-y-scroll border-t">
|
||||
{filteredModelList.map((model) => (
|
||||
<div
|
||||
className="group flex min-h-16 items-center gap-3 border-b px-4 py-3 transition-colors hover:bg-accent/30"
|
||||
key={model.id}
|
||||
>
|
||||
<div className="min-w-0 flex-1 font-mono">
|
||||
<div className="flex min-w-0 items-center gap-1.5 px-1 text-sm text-foreground">
|
||||
<span className="truncate">{model.name}</span>
|
||||
{/* Capability icons */}
|
||||
{model.supportsAttachments && (
|
||||
<div title="File Support">
|
||||
<FileIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
{model.supportsVision && (
|
||||
<div title="Image Support">
|
||||
<ImageIcon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
aria-label={`Copy model ID ${model.id}`}
|
||||
className="mt-1 flex max-w-full items-center gap-1.5 px-1 text-left text-xs text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => copyModelId(model.id)}
|
||||
title="Copy model ID"
|
||||
type="button"
|
||||
>
|
||||
<span className="min-w-0 truncate">{model.id}</span>
|
||||
<Copy className="size-3 shrink-0" />
|
||||
{copiedModelId === model.id ? (
|
||||
<span className="shrink-0 text-foreground">
|
||||
Copied
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Action icons */}
|
||||
<div className="flex items-center gap-0.5 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
aria-label={`Favorite ${model.name}`}
|
||||
className="rounded-md p-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
variant="ghost"
|
||||
>
|
||||
<Star className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No models match "{modelSearch.trim()}".
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border border-border px-4 py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{modelsLoading
|
||||
? "Loading models..."
|
||||
: "No models available. Click refresh to load models."}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { WebviewInboundMessage } from "../../../webview-protocol";
|
||||
import { HubDesktopClient, isBrowserTransportFailure } from "./desktop-client";
|
||||
|
||||
function createClient() {
|
||||
const postToHost = vi.fn<(message: WebviewInboundMessage) => void>();
|
||||
const client = new HubDesktopClient({ postToHost, listen: false });
|
||||
return { client, postToHost };
|
||||
}
|
||||
|
||||
function lastDesktopCommand(postToHost: ReturnType<typeof vi.fn>) {
|
||||
const message = postToHost.mock.lastCall?.[0] as
|
||||
| Extract<WebviewInboundMessage, { type: "desktopCommand" }>
|
||||
| undefined;
|
||||
if (message?.type !== "desktopCommand") {
|
||||
throw new Error("Expected a desktop command to be posted");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
describe("HubDesktopClient", () => {
|
||||
it("does not reject pending desktop commands for unrelated hub errors", async () => {
|
||||
const { client, postToHost } = createClient();
|
||||
const pending = client.invoke<{ installedKeys: string[] }>(
|
||||
"list_marketplace_installed_entries",
|
||||
);
|
||||
const command = lastDesktopCommand(postToHost);
|
||||
|
||||
client.handleMessage({
|
||||
data: { type: "error", text: "Failed to restore previous session." },
|
||||
});
|
||||
client.handleMessage({
|
||||
data: {
|
||||
type: "desktopCommandResult",
|
||||
id: command.id,
|
||||
ok: true,
|
||||
result: { installedKeys: ["plugin:goal"] },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(pending).resolves.toEqual({ installedKeys: ["plugin:goal"] });
|
||||
});
|
||||
|
||||
it("rejects pending desktop commands for browser transport failures", async () => {
|
||||
const { client } = createClient();
|
||||
const pending = client.invoke("list_marketplace_installed_entries");
|
||||
|
||||
client.handleMessage({
|
||||
data: { type: "status", text: "Disconnected from the Cline Hub server." },
|
||||
});
|
||||
|
||||
await expect(pending).rejects.toThrow(
|
||||
"Disconnected from the Cline Hub server.",
|
||||
);
|
||||
});
|
||||
|
||||
it("only treats exact browser lifecycle messages as transport failures", () => {
|
||||
expect(
|
||||
isBrowserTransportFailure({
|
||||
type: "error",
|
||||
text: "Failed to connect to the Cline Hub server.",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isBrowserTransportFailure({
|
||||
type: "error",
|
||||
text: "Failed to restore previous session.",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,105 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { WebviewOutboundMessage } from "../../../webview-protocol";
|
||||
import { postToHost } from "../vscode";
|
||||
|
||||
type PostToHost = typeof postToHost;
|
||||
|
||||
type PendingRequest = {
|
||||
command: string;
|
||||
resolve: (value: unknown) => void;
|
||||
reject: (error: Error) => void;
|
||||
timeoutId: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 120_000;
|
||||
const BROWSER_TRANSPORT_FAILURE_MESSAGES = new Set([
|
||||
"Disconnected from the Cline Hub server.",
|
||||
"Failed to connect to the Cline Hub server.",
|
||||
"Received an invalid message from the Cline Hub server.",
|
||||
]);
|
||||
|
||||
export function isBrowserTransportFailure(
|
||||
message: WebviewOutboundMessage,
|
||||
): boolean {
|
||||
if (message.type !== "status" && message.type !== "error") {
|
||||
return false;
|
||||
}
|
||||
return BROWSER_TRANSPORT_FAILURE_MESSAGES.has(message.text);
|
||||
}
|
||||
|
||||
export class HubDesktopClient {
|
||||
private requestCounter = 0;
|
||||
private readonly pending = new Map<string, PendingRequest>();
|
||||
private readonly postToHost: PostToHost;
|
||||
|
||||
constructor(options: { postToHost?: PostToHost; listen?: boolean } = {}) {
|
||||
this.postToHost = options.postToHost ?? postToHost;
|
||||
if ((options.listen ?? true) && typeof window !== "undefined") {
|
||||
window.addEventListener("message", (event) => {
|
||||
this.handleMessage(event as MessageEvent<WebviewOutboundMessage>);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(event: Pick<MessageEvent<WebviewOutboundMessage>, "data">) {
|
||||
const message = event.data;
|
||||
if (
|
||||
message &&
|
||||
typeof message === "object" &&
|
||||
(message.type === "status" || message.type === "error")
|
||||
) {
|
||||
if (isBrowserTransportFailure(message) && this.pending.size > 0) {
|
||||
const error = new Error(message.text);
|
||||
for (const pending of this.pending.values()) {
|
||||
clearTimeout(pending.timeoutId);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.pending.clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!message ||
|
||||
typeof message !== "object" ||
|
||||
message.type !== "desktopCommandResult"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(pending.timeoutId);
|
||||
this.pending.delete(message.id);
|
||||
if (message.ok) {
|
||||
pending.resolve(message.result);
|
||||
return;
|
||||
}
|
||||
pending.reject(new Error(message.error));
|
||||
}
|
||||
|
||||
async invoke<T>(
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
options?: { timeoutMs?: number },
|
||||
): Promise<T> {
|
||||
const id = `desktop_${Date.now()}_${this.requestCounter++}`;
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.pending.delete(id);
|
||||
reject(new Error(`Timed out waiting for desktop command: ${command}`));
|
||||
}, options?.timeoutMs ?? REQUEST_TIMEOUT_MS);
|
||||
this.pending.set(id, {
|
||||
command,
|
||||
resolve: (value) => resolve(value as T),
|
||||
reject,
|
||||
timeoutId,
|
||||
});
|
||||
this.postToHost({ type: "desktopCommand", id, command, args });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const desktopClient = new HubDesktopClient();
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user