Merge remote-tracking branch 'origin/main' into 20261016a-vmp-next-desktop

# Conflicts:
#	bun.lock
This commit is contained in:
TheRealSpencer
2026-10-20 22:09:55 -05:00
232 changed files with 13099 additions and 2470 deletions
+11
View File
@@ -34,3 +34,14 @@ updates:
- "version-update:semver-major"
- "version-update:semver-minor"
- "version-update:semver-patch"
# Pinned skills CLI release (devDependency of @cline/core). `cline skill`
# and the marketplace skill fallback run this exact version through npx,
# so version updates are wanted here, unlike the entries above: each bump
# goes through CI and review before it reaches users.
- package-ecosystem: "bun"
directory: "/"
schedule:
interval: "weekly"
allow:
- dependency-name: "skills"
+30
View File
@@ -312,6 +312,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
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 }}
@@ -346,6 +348,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
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 }}
@@ -482,6 +486,12 @@ jobs:
echo "OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
if ! printf '%s' "$SELFCHECK" | grep -q '"traces_exporter":"otlp"'; then
echo "Packaged $COMPONENT has no OTLP traces exporter inlined, so"
echo "cline-provider spans would never reach the collector (and"
echo "Langfuse). Check OTEL_TRACES_EXPORTER on the build step."
exit 1
fi
done
# Boot the Cline CLI and desktop backend that will actually ship — built
@@ -681,6 +691,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
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 }}
@@ -743,6 +755,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
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 }}
@@ -828,6 +842,12 @@ jobs:
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
if ! printf '%s' "$SELFCHECK" | grep -q '"traces_exporter":"otlp"'; then
echo "Packaged $COMPONENT has no OTLP traces exporter inlined, so"
echo "cline-provider spans would never reach the collector (and"
echo "Langfuse). Check OTEL_TRACES_EXPORTER on the build step."
exit 1
fi
done
# Same release-time gate as the macOS job; see the comment there.
@@ -1008,6 +1028,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
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 }}
@@ -1027,6 +1049,8 @@ jobs:
OTEL_TELEMETRY_ENABLED: ${{ secrets.OTEL_TELEMETRY_ENABLED }}
OTEL_LOGS_EXPORTER: otlp
OTEL_METRICS_EXPORTER: otlp
OTEL_TRACES_EXPORTER: otlp
CLINE_TRACE_RECORD_CONTENT: "true"
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 }}
@@ -1099,6 +1123,12 @@ jobs:
echo "Check the OTEL_EXPORTER_OTLP_ENDPOINT secret."
exit 1
fi
if ! printf '%s' "$SELFCHECK" | grep -q '"traces_exporter":"otlp"'; then
echo "Packaged $COMPONENT has no OTLP traces exporter inlined, so"
echo "cline-provider spans would never reach the collector (and"
echo "Langfuse). Check OTEL_TRACES_EXPORTER on the build step."
exit 1
fi
done
# Same release-time gate as the macOS job; see the comment there.
+4
View File
@@ -104,6 +104,10 @@ jobs:
bun -F @cline/code test:sidecar
bun -F @cline/code test:sidecar-startup
- name: Run Windows compiled plugin resource tests
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' && matrix.os == 'windows-latest' }}
run: bun -F @cline/code test:sidecar-startup --test-name-pattern "standalone CLI"
- name: Run Desktop Settings UI Tests
if: ${{ !cancelled() && steps.build_sdk_step.outcome == 'success' }}
run: bun -F @cline/code test:settings-ui
+1 -1
View File
@@ -126,7 +126,7 @@ npm install @cline/sdk
|---------|------------|--------------|--------------|
| **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) |
| **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) |
| **VS Code Extension** | The Marketplace extension and extension host integration. | [`apps/vscode/`](https://github.com/cline/cline/tree/main/apps/vscode) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/CHANGELOG.md) |
| **Desktop App** | Native macOS and Windows app (Tauri shell, Bun sidecar, Next.js UI). | [`apps/examples/desktop-app/`](https://github.com/cline/cline/tree/main/apps/examples/desktop-app) | [CHANGELOG.md](https://github.com/cline/cline/blob/main/apps/examples/desktop-app/CHANGELOG.md) |
| **JetBrains Plugin** | JetBrains-hosted client that talks to the shared agent core. | Currently we are not open-sourcing JetBrains plugins | - |
| **Docs site** | Public documentation pages. | [`docs/`](https://docs.cline.bot/) | - |
+6
View File
@@ -1,5 +1,11 @@
# Cline CLI Changelog
## 3.0.73
- GPT-6 models (such as `gpt-6-sol` and `gpt-6.1-sol`) work on OpenAI and Azure OpenAI-compatible endpoints. Before, every request was rejected because Cline sent `max_tokens` instead of `max_completion_tokens`
- With no model chosen, the Cline provider uses Claude Sonnet 5.5 again, which is back at the top of Cline's recommended list
- Refreshed the model catalog. Adds the CommandCode and Reflection providers. Default models change for 302.AI (Gemini 4 Argon), Eden AI and Prime Inference (Muse Spark Open), CoreWeave (Reflection Beam 1.0), NanoGPT, Pioneer (Nemotron 3.5 Super VL), and Scaleway (DeepSeek V4.1 Flash)
## 3.0.72
- Plugins now run in a worker instead of directly inside the hub. If this build can't run plugins, Cline says so in `cline config tools`, `cline hub plugins`, and the session-start warnings instead of quietly running them inside the hub. `CLINE_PLUGIN_MODE=in_process` is no longer supported; use `worker` (the default) or `sandbox`
+6
View File
@@ -291,6 +291,12 @@ Schedules can route results back to chat surfaces with `--delivery-adapter`, `--
- `cline hub` - Manage the local hub daemon
- `cline kanban` - Run the external `kanban` app, installing it first when needed
Compiled CLI binaries embed the plugin bootstrap, SDK modules, and dashboard
assets. They can start the Hub, load SDK plugins, and serve the dashboard without
a separate Node/Bun installation or adjacent runtime files. Resources are read directly from the binary’s read-only Bun filesystem; no
runtime cache is extracted. `--plugin-sandbox` is an internal subprocess dispatch
flag, not a user command.
## Hub dashboard
Run `cline hub` to list hub subcommands. To start the browser dashboard:
+6 -9
View File
@@ -1,9 +1,9 @@
import {
copyFileSync,
cpSync,
existsSync,
mkdirSync,
readdirSync,
rmSync,
statSync,
} from "node:fs";
import { dirname, join } from "node:path";
@@ -136,16 +136,13 @@ if (result.logs.length > 0) {
}
}
const coreBootstrapPath = join(
const corePluginRuntimePath = join(
rootDir,
"../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js",
"../../sdk/packages/core/dist/plugin-runtime",
);
const cliBootstrapPath = join(
rootDir,
"./dist/extensions/plugin-sandbox-bootstrap.js",
);
mkdirSync(dirname(cliBootstrapPath), { recursive: true });
copyFileSync(coreBootstrapPath, cliBootstrapPath);
const cliPluginRuntimePath = join(rootDir, "dist/plugin-runtime");
rmSync(cliPluginRuntimePath, { recursive: true, force: true });
cpSync(corePluginRuntimePath, cliPluginRuntimePath, { recursive: true });
if (existsSync(hubWebviewDistPath)) {
mkdirSync(dirname(cliHubWebviewDistPath), { recursive: true });
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.72",
"version": "3.0.73",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
+15 -74
View File
@@ -1,15 +1,6 @@
#!/usr/bin/env bun
import {
cpSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
statSync,
} from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { type AddressInfo, createServer } from "node:net";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
@@ -65,7 +56,7 @@ if (optionsError) {
process.exit(1);
}
await $`rm -rf dist`;
rmSync(join(cliDir, "dist"), { recursive: true, force: true });
// Pre-install all platform variants of native packages so cross-compilation
// can resolve them. Without this, Bun only has the host platform's native
@@ -83,48 +74,6 @@ if (!buildOptions.skipSdkBuild) {
await $`bun -F @cline/cli build`.cwd(rootDir);
}
const hubWebviewSource = join(cliDir, "../cline-hub/src/webview");
const hubWebviewDist = join(cliDir, "../cline-hub/dist/webview");
const hubWebviewIndex = join(hubWebviewDist, "index.html");
function newestFileMtimeMs(dir: string): number {
let newest = 0;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (
entry.name === "node_modules" ||
entry.name === "dist" ||
entry.name === ".turbo"
) {
continue;
}
const path = join(dir, entry.name);
if (entry.isDirectory()) {
newest = Math.max(newest, newestFileMtimeMs(path));
} else if (entry.isFile()) {
newest = Math.max(newest, statSync(path).mtimeMs);
}
}
return newest;
}
function shouldBuildHubWebview(): boolean {
if (!existsSync(hubWebviewIndex)) {
return true;
}
try {
return (
newestFileMtimeMs(hubWebviewSource) > statSync(hubWebviewIndex).mtimeMs
);
} catch {
return true;
}
}
if (shouldBuildHubWebview()) {
console.log("Building Cline Hub webview...");
await $`bun -F @cline/cline-hub build:webview`.cwd(rootDir);
}
const binaries: Record<string, string> = {};
function findFreePort(): Promise<number> {
@@ -211,7 +160,19 @@ for (const item of targets) {
console.log(` Passed: hub core ${status.coreVersion}`);
} finally {
await $`${outfile} hub stop`.env(smokeEnv).quiet().nothrow();
rmSync(smokeHome, { recursive: true, force: true });
try {
rmSync(smokeHome, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 100,
});
} catch (cleanupError) {
console.warn(
` Warning: failed to remove smoke test dir ${smokeHome}:`,
cleanupError,
);
}
}
} catch (e) {
console.error(` Smoke test FAILED for ${name}:`, e);
@@ -219,26 +180,6 @@ for (const item of targets) {
}
}
// Copy plugin sandbox bootstrap if it exists
const bootstrapSrc = join(
rootDir,
"sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js",
);
if (existsSync(bootstrapSrc)) {
const bootstrapDir = join(cliDir, `dist/${dirName}/extensions`);
mkdirSync(bootstrapDir, { recursive: true });
const content = readFileSync(bootstrapSrc);
await Bun.write(join(bootstrapDir, "plugin-sandbox-bootstrap.js"), content);
}
if (existsSync(hubWebviewDist)) {
const hubWebviewDest = join(cliDir, `dist/${dirName}/cline-hub/webview`);
mkdirSync(join(cliDir, `dist/${dirName}/cline-hub`), {
recursive: true,
});
cpSync(hubWebviewDist, hubWebviewDest, { recursive: true });
}
// Generate platform package.json
await Bun.write(
join(cliDir, `dist/${dirName}/package.json`),
+26 -17
View File
@@ -3,6 +3,7 @@ import {
copyFileSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
@@ -10,6 +11,7 @@ import {
import { tmpdir } from "node:os";
import { dirname, join, relative, resolve } from "node:path";
import { $ } from "bun";
import { buildDashboardRuntimeDirectory } from "./dashboard-runtime";
const cliDir = resolve(import.meta.dir, "..");
const rootDir = resolve(cliDir, "../..");
@@ -94,6 +96,8 @@ export interface CompileCliBinaryOptions {
export async function compileCliBinary(
input: CompileCliBinaryOptions,
): Promise<void> {
const pluginRuntime = join(rootDir, "sdk/packages/core/dist/plugin-runtime");
const dashboardRuntime = await buildDashboardRuntimeDirectory();
const parserWorker = findOpenTuiParserWorker();
const targetOs = input.bunTarget.includes("windows") ? "windows" : "posix";
const bunfsRoot = targetOs === "windows" ? "B:/~BUN/root/" : "/$bunfs/root/";
@@ -106,15 +110,13 @@ export async function compileCliBinary(
// layer in containerized environments (virtiofs, overlayfs).
const entrypoint = join(cliDir, "src/index.ts");
const scratchRoot = process.platform === "win32" ? tmpdir() : "/tmp";
const tmpDir = join(
scratchRoot,
`cline-build-${input.bunTarget}-${process.pid}`,
const tmpDir = mkdtempSync(
join(scratchRoot, `cline-build-${input.bunTarget}-`),
);
const tmpOutfile = join(
tmpDir,
input.outfile.endsWith(".exe") ? "cline.exe" : "cline",
);
mkdirSync(tmpDir, { recursive: true });
const previousCwd = process.cwd();
process.chdir(scratchRoot);
@@ -123,11 +125,13 @@ export async function compileCliBinary(
const autoload = input.autoloadLaunchDirectoryConfig !== false;
result = await Bun.build({
entrypoints: [entrypoint, parserWorker],
root: rootDir,
splitting: true,
compile: {
target: input.bunTarget,
outfile: tmpOutfile,
// Plugins import SDK packages from disk at runtime; let Bun resolve
assets: [pluginRuntime, dashboardRuntime],
// Plugins import SDK packages from the runtime tree; let Bun resolve
// their package exports without falling back to transpiling the SDK.
autoloadPackageJson: true,
...(input.execArgv?.length ? { execArgv: input.execArgv } : {}),
@@ -137,26 +141,31 @@ export async function compileCliBinary(
external: ["@anthropic-ai/vertex-sdk"],
define: {
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + parserWorkerPath,
// compile.assets keeps each directory tree under its basename.
__CLINE_PLUGIN_RUNTIME_DIR__: JSON.stringify(
`${bunfsRoot}plugin-runtime`,
),
__CLINE_DASHBOARD_RUNTIME_DIR__: JSON.stringify(`${bunfsRoot}webview`),
// Inline telemetry/OTEL env vars at build time so the compiled
// binary ships with production telemetry configuration baked in.
...buildInlinedEnvDefines(),
},
throw: false,
});
if (!result.success) {
for (const log of result.logs) {
console.error(log);
}
throw new Error(`CLI build failed for ${input.bunTarget}`);
}
mkdirSync(dirname(input.outfile), { recursive: true });
copyFileSync(tmpOutfile, input.outfile);
if (process.platform !== "win32") {
chmodSync(input.outfile, 0o755);
}
} finally {
process.chdir(previousCwd);
}
if (!result.success) {
for (const log of result.logs) {
console.error(log);
}
rmSync(tmpDir, { recursive: true, force: true });
throw new Error(`CLI build failed for ${input.bunTarget}`);
}
mkdirSync(dirname(input.outfile), { recursive: true });
copyFileSync(tmpOutfile, input.outfile);
chmodSync(input.outfile, 0o755);
rmSync(tmpDir, { recursive: true, force: true });
}
+20
View File
@@ -0,0 +1,20 @@
import { existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { $ } from "bun";
let directory: Promise<string> | undefined;
/** Build once per platform-build invocation; the compiler embeds this tree unchanged. */
export function buildDashboardRuntimeDirectory(): Promise<string> {
directory ??= buildDirectory();
return directory;
}
async function buildDirectory(): Promise<string> {
const root = resolve(import.meta.dir, "../../..");
await $`bun -F @cline/cline-hub build:webview`.cwd(root).quiet();
const directory = join(root, "apps/cline-hub/dist/webview");
if (!existsSync(join(directory, "index.html")))
throw new Error("Dashboard build produced no index.html");
return directory;
}
+1 -44
View File
@@ -1,5 +1,5 @@
import { mkdirSync, mkdtempSync } from "node:fs";
import { arch, platform, tmpdir } from "node:os";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { runDashboardCommand, waitForProcessShutdown } from "./dashboard";
@@ -20,7 +20,6 @@ const ENV_KEYS = [
"PUBLIC_URL",
"ROOM_SECRET",
"CLINE_HUB_WEBVIEW_DIST_DIR",
"CLINE_WRAPPER_PATH",
] as const;
const originalEnv = Object.fromEntries(
@@ -149,48 +148,6 @@ describe("runDashboardCommand", () => {
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/",
+5 -32
View File
@@ -1,7 +1,7 @@
import { existsSync } from "node:fs";
import { arch, platform } from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { resolveEmbeddedDashboard } from "../utils/embedded-dashboard";
import { configureSandboxEnvironment } from "../utils/helpers";
import open from "../utils/open";
import { c } from "../utils/output";
@@ -97,49 +97,22 @@ function resolveDefaultWebviewDistDir(): string | undefined {
if (process.env[WEBVIEW_DIST_ENV]?.trim()) {
return undefined;
}
const embedded = resolveEmbeddedDashboard();
if (embedded) {
return embedded;
}
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();
+18 -7
View File
@@ -1,15 +1,20 @@
import { SKILLS_CLI_PACKAGE } from "@cline/core";
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"]);
expect(buildSkillsArgs(["list"])).toEqual([
"-y",
SKILLS_CLI_PACKAGE,
"list",
]);
});
it("injects --agent cline for install-style subcommands", () => {
expect(buildSkillsArgs(["install", "owner/repo"])).toEqual([
"-y",
"skills@latest",
SKILLS_CLI_PACKAGE,
"add",
"owner/repo",
"--agent",
@@ -23,7 +28,7 @@ describe("buildSkillsArgs", () => {
it("aliases uninstall to the skills remove subcommand", () => {
expect(buildSkillsArgs(["uninstall", "my-skill"])).toEqual([
"-y",
"skills@latest",
SKILLS_CLI_PACKAGE,
"remove",
"my-skill",
"--agent",
@@ -48,7 +53,7 @@ describe("buildSkillsArgs", () => {
buildSkillsArgs(["--agent", "cursor", "install", "owner/repo"]),
).toEqual([
"-y",
"skills@latest",
SKILLS_CLI_PACKAGE,
"--agent",
"cursor",
"add",
@@ -56,7 +61,13 @@ describe("buildSkillsArgs", () => {
]);
expect(
buildSkillsArgs(["--agent=cursor", "uninstall", "my-skill"]),
).toEqual(["-y", "skills@latest", "--agent=cursor", "remove", "my-skill"]);
).toEqual([
"-y",
SKILLS_CLI_PACKAGE,
"--agent=cursor",
"remove",
"my-skill",
]);
});
it("does not scope non-install subcommands to cline", () => {
@@ -67,7 +78,7 @@ describe("buildSkillsArgs", () => {
it("scopes remove-style subcommands to cline", () => {
expect(buildSkillsArgs(["remove"])).toEqual([
"-y",
"skills@latest",
SKILLS_CLI_PACKAGE,
"remove",
"--agent",
"cline",
@@ -83,6 +94,6 @@ describe("buildSkillsArgs", () => {
});
it("forwards an empty arg list unchanged", () => {
expect(buildSkillsArgs([])).toEqual(["-y", "skills@latest"]);
expect(buildSkillsArgs([])).toEqual(["-y", SKILLS_CLI_PACKAGE]);
});
});
+4 -3
View File
@@ -1,4 +1,5 @@
import { type SpawnOptions, spawn } from "node:child_process";
import { SKILLS_CLI_PACKAGE } from "@cline/core";
export interface SkillCommandIo {
writeln: (text?: string) => void;
@@ -7,9 +8,9 @@ export interface SkillCommandIo {
// `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";
// don't need a separate global install. The release is pinned in @cline/core
// (sdk/packages/core/package.json) and bumped by Dependabot.
const SKILLS_PACKAGE = SKILLS_CLI_PACKAGE;
// 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
+2
View File
@@ -7,6 +7,7 @@ import {
resolveProductionHubOwnerContext,
resolveSharedHubOwnerContext,
} from "@cline/core";
import { hasActiveHubSessions } from "@cline/core/hub";
import { resolveClineBuildEnv } from "@cline/shared";
import { version } from "../../package.json";
import { c, writeErr, writeln } from "../utils/output";
@@ -381,6 +382,7 @@ async function otherCliClientsAttached(): Promise<boolean> {
undefined,
{ timeoutMs: CLIENT_COUNT_EXIT_TIMEOUT_MS },
);
if (hasActiveHubSessions(sessions.payload)) return true;
const sessionRecords =
(sessions.payload as { sessions?: Array<{ participants?: unknown }> })
.sessions ?? [];
+6 -4
View File
@@ -1071,10 +1071,7 @@ class DiscordConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -1097,6 +1094,11 @@ class DiscordConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
userName: options.userName,
applicationId: options.applicationId,
+6 -4
View File
@@ -577,10 +577,7 @@ class GoogleChatConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -602,6 +599,11 @@ class GoogleChatConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
userName: options.userName,
pid: process.pid,
+6 -4
View File
@@ -600,10 +600,7 @@ class LinearConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -625,6 +622,11 @@ class LinearConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
userName: options.userName,
pid: process.pid,
+6 -4
View File
@@ -874,10 +874,7 @@ class SlackConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -899,6 +896,11 @@ class SlackConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
claimId: claim.claimId,
userName: options.userName,
+6 -4
View File
@@ -804,10 +804,7 @@ class TelegramConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -829,6 +826,11 @@ class TelegramConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
botUsername: options.botUsername,
botId: readTelegramBotId(options.botToken),
+6 -4
View File
@@ -569,10 +569,7 @@ class WhatsAppConnector extends ConnectorBase<
});
await userInstructionService.start().catch(() => undefined);
const commandCwd = startRequest.cwd || process.cwd();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
const { url: rpcAddress, authToken: rpcAuthToken } =
await ensureCliHubServer(
startRequest.workspaceRoot || startRequest.cwd || process.cwd(),
@@ -597,6 +594,11 @@ class WhatsAppConnector extends ConnectorBase<
},
});
await client.connect();
const { host: chatCommandHost } = await createWorkspaceChatCommandHost({
commands: client.pluginCommands,
cwd: commandCwd,
workspaceRoot: startRequest.workspaceRoot || commandCwd,
});
this.writeConnectorState(statePath, {
instanceKey,
userName: options.userName,
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import type { SentMessage } from "chat";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createWorkspaceChatCommandHost } from "../utils/plugin-chat-commands";
import { enqueueThreadTurn } from "./chat-runtime";
import { handleConnectorUserTurn } from "./connector-host";
@@ -178,6 +179,64 @@ describe("handleConnectorUserTurn", () => {
}
});
it.each([
"discord",
"gchat",
"linear",
"slack",
"telegram",
"whatsapp",
])("routes %s plugin commands with each thread's current session and workspace", async (transport) => {
const dir = mkdtempSync(join(tmpdir(), "connector-command-context-"));
tempDirs.push(dir);
const run = vi.fn(async () => ({ reply: "handled" }));
const { host } = await createWorkspaceChatCommandHost({
cwd: dir,
commands: {
run,
list: vi
.fn()
.mockResolvedValue({ status: "ready", commands: [{ name: "echo" }] }),
},
});
await Promise.all(
["one", "two"].map(async (id) => {
const workspace = join(dir, id);
const { thread, posts } = createThread({
sessionId: id,
cwd: workspace,
workspaceRoot: workspace,
});
await handleConnectorUserTurn({
thread: thread as never,
client: {} as never,
pendingApprovals: new Map(),
baseStartRequest: baseStartRequest() as never,
clientId: "client",
explicitSystemPrompt: undefined,
logger: {
core: { debug: vi.fn(), log: vi.fn(), error: vi.fn() },
} as never,
transport,
botUserName: "cline",
requestStop: vi.fn(),
bindingsPath: join(dir, `${id}.json`),
systemRules: "rules",
errorLabel: transport,
getSessionMetadata: () => ({}),
reusedLogMessage: "reused",
text: "/echo hello",
chatCommandHost: host,
});
expect(run).toHaveBeenCalledWith({
sessionId: id,
workspacePath: workspace,
prompt: "/echo hello",
});
expect(messageText(posts.at(-1))).toContain("handled");
}),
);
});
it("posts no greeting when the adapter configures none", async () => {
// Slack deliberately configures no first-contact message: the greeting is
// gated on per-thread state, so a restart or a cleared history replayed it
@@ -628,6 +628,7 @@ export async function handleConnectorUserTurn<
input.forceDisableTools,
);
return {
sessionId: effectiveCurrent.sessionId,
enableTools:
effectiveCurrent.enableTools ?? input.baseStartRequest.enableTools,
autoApproveTools:
+13
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bun
import { isMainThread } from "node:worker_threads";
import { setPluginSandboxLauncher } from "@cline/core";
import {
buildTelemetrySelfcheckReport,
claimHubDaemonProcess,
@@ -32,8 +33,20 @@ disableCurrentDirectoryExecutableSearch();
// Before any personality probes the local hub over 127.0.0.1.
ensureLoopbackProxyBypass();
if (Bun.isStandaloneExecutable) {
setPluginSandboxLauncher({
command: process.execPath,
argsPrefix: ["--plugin-sandbox"],
});
}
if (!isMainThread) {
// Worker imports of the bundled CLI entrypoint should not start the CLI.
} else if (process.argv.includes("--plugin-sandbox")) {
// Boot as the compiled host so the child mounts the same read-only resources.
const bootstrap = process.argv[process.argv.indexOf("--plugin-sandbox") + 1];
if (!bootstrap) throw new Error("Missing plugin sandbox bootstrap path");
await import(bootstrap);
} else if (process.argv.includes("--telemetry-selfcheck")) {
// Inspect the config embedded in this executable without starting a hub,
// loading user settings, or initializing telemetry exporters.
@@ -144,6 +144,7 @@ function makeManager() {
});
return {
start,
pluginCommands: { list: vi.fn(), run: vi.fn() },
stop: vi.fn(async () => {}),
send: vi.fn(),
getAccumulatedUsage: vi.fn(),
@@ -242,6 +243,65 @@ describe("createInteractiveSessionRuntime", () => {
subscribeToPendingPromptEventsMock.mockReturnValue(() => {});
});
it.each([
undefined,
"resumed-session",
])("shares initialization between plugin catalog discovery and session startup (%s)", async (resumeSessionId) => {
const manager = makeManager();
manager.get.mockResolvedValue(undefined);
manager.getAccumulatedUsage.mockResolvedValue(undefined);
const runtime = await makeRuntime(manager, { resumeSessionId });
if (resumeSessionId)
loadInteractiveResumeMessagesMock.mockResolvedValue([
{ role: "user", content: "resume" },
]);
const gate = deferred<typeof manager>();
createCliCoreMock.mockReturnValue(gate.promise);
const commands = runtime.getPluginCommandsApi();
const ready = runtime.ensureReady();
expect(createCliCoreMock).toHaveBeenCalledTimes(1);
gate.resolve(manager);
expect(await commands).toBe(manager.pluginCommands);
await ready;
expect(manager.start).toHaveBeenCalledTimes(1);
expect(subscribeToAgentEventsMock).toHaveBeenCalledTimes(1);
await runtime.cleanup();
expect(manager.dispose).toHaveBeenCalledTimes(1);
});
it("retries failed shared initialization", async () => {
const manager = makeManager();
const runtime = await makeRuntime(manager);
createCliCoreMock.mockRejectedValueOnce(new Error("startup failed"));
await expect(runtime.getPluginCommandsApi()).rejects.toThrow(
"startup failed",
);
expect(await runtime.getPluginCommandsApi()).toBe(manager.pluginCommands);
expect(createCliCoreMock).toHaveBeenCalledTimes(2);
await runtime.cleanup();
});
it("awaits and disposes plugin-only initialization during cleanup", async () => {
const manager = makeManager();
const runtime = await makeRuntime(manager);
const gate = deferred<typeof manager>();
createCliCoreMock.mockReturnValue(gate.promise);
const commands = expect(runtime.getPluginCommandsApi()).rejects.toThrow(
"shutdown requested",
);
const cleanup = runtime.cleanup();
gate.resolve(manager);
await commands;
await cleanup;
expect(manager.dispose).toHaveBeenCalledExactlyOnceWith(
"cli_interactive_startup_cancelled",
);
await expect(runtime.getPluginCommandsApi()).rejects.toThrow(
"shutdown requested",
);
expect(createCliCoreMock).toHaveBeenCalledTimes(1);
});
it("manual compact updates the active session sidecar without restarting", async () => {
const sessionId = "sess-active";
const messages = [
@@ -518,6 +578,7 @@ describe("createInteractiveSessionRuntime", () => {
getAccumulatedUsage: vi.fn(),
};
createCliCoreMock.mockResolvedValue(manager);
const onActiveSessionChange = vi.fn();
const { createInteractiveSessionRuntime } = await importRuntime();
const runtime = createInteractiveSessionRuntime({
config: createConfig(),
@@ -532,11 +593,13 @@ describe("createInteractiveSessionRuntime", () => {
onTeamEvent: vi.fn(),
onPendingPrompts: vi.fn(),
onPendingPromptSubmitted: vi.fn(),
onActiveSessionChange,
});
await runtime.ensureReady();
expect(manager.start).toHaveBeenCalledOnce();
expect(runtime.getActiveSessionId()).toBe("session-1");
expect(onActiveSessionChange).toHaveBeenCalledTimes(1);
await runtime.resetForNewSession();
@@ -544,11 +607,13 @@ describe("createInteractiveSessionRuntime", () => {
expect(manager.start).toHaveBeenCalledOnce();
expect(runtime.getActiveSessionId()).toBe("");
expect(setActiveCliSessionMock).toHaveBeenLastCalledWith(undefined);
expect(onActiveSessionChange).toHaveBeenCalledTimes(2);
await runtime.ensureReady();
expect(manager.start).toHaveBeenCalledTimes(2);
expect(runtime.getActiveSessionId()).toBe("session-2");
expect(onActiveSessionChange).toHaveBeenCalledTimes(3);
});
it("holds concurrent ensureReady during a restart instead of booting an empty session", async () => {
@@ -111,8 +111,10 @@ export function createInteractiveSessionRuntime(input: {
onTeamEvent: (event: TeamEvent) => void;
onPendingPrompts: (event: PendingPromptSnapshot) => void;
onPendingPromptSubmitted: (event: PendingPromptSubmittedEvent) => void;
onActiveSessionChange?: () => void;
}) {
let sessionManager: CliCore | undefined;
let sessionManagerPromise: Promise<CliCore> | undefined;
let runtimeHooks: RuntimeHooks | undefined;
let unsubscribeAgent = () => {};
let unsubscribePendingPrompts = () => {};
@@ -134,6 +136,7 @@ export function createInteractiveSessionRuntime(input: {
const clearActiveSession = (): void => {
activeSessionId = "";
setActiveCliSession(undefined);
input.onActiveSessionChange?.();
};
const applyStartedSession = (started: StartedSession): void => {
@@ -141,6 +144,7 @@ export function createInteractiveSessionRuntime(input: {
manifest: started.manifest,
});
activeSessionId = started.sessionId;
input.onActiveSessionChange?.();
for (const message of formatPluginIssueWarnings(started.pluginIssues)) {
input.onAgentEvent({
type: "notice",
@@ -151,10 +155,7 @@ export function createInteractiveSessionRuntime(input: {
}
};
const ensureSessionManager = async (): Promise<CliCore> => {
if (sessionManager) {
return sessionManager;
}
const createSessionManager = async (): Promise<CliCore> => {
const manager = await createCliCore({
// Interactive startup must never wait for a detached hub daemon to boot.
// `auto` uses an already-compatible hub when one is immediately available,
@@ -205,6 +206,20 @@ export function createInteractiveSessionRuntime(input: {
return manager;
};
const ensureSessionManager = (): Promise<CliCore> => {
if (sessionManager) return Promise.resolve(sessionManager);
if (shutdownRequested)
return Promise.reject(
new Error("interactive runtime shutdown requested"),
);
if (!sessionManagerPromise) {
sessionManagerPromise = createSessionManager().finally(() => {
sessionManagerPromise = undefined;
});
}
return sessionManagerPromise;
};
const buildSessionConfig = (): Config => {
if (!runtimeHooks) {
throw new Error("interactive runtime hooks are unavailable");
@@ -885,6 +900,7 @@ export function createInteractiveSessionRuntime(input: {
shutdownRequested = true;
let exitSummary: InteractiveExitSummary | undefined;
try {
await sessionManagerPromise?.catch(() => {});
await startupPromise?.catch(() => {});
await missingSessionRecoveryPromise?.catch(() => {});
} finally {
@@ -899,7 +915,11 @@ export function createInteractiveSessionRuntime(input: {
await stopCurrentSession();
} finally {
if (sessionManager) {
await sessionManager.dispose("cli_interactive_shutdown");
try {
await sessionManager.dispose("cli_interactive_shutdown");
} finally {
sessionManager = undefined;
}
}
}
return exitSummary;
@@ -908,6 +928,8 @@ export function createInteractiveSessionRuntime(input: {
};
return {
getPluginCommandsApi: async () =>
(await ensureSessionManager()).pluginCommands,
ensureReady,
listCoreSettings,
toggleCoreSettings,
+44 -59
View File
@@ -194,44 +194,49 @@ export async function runInteractive(
userInstructionService,
);
let interactiveChatCommandHost = chatCommandHost;
let pluginChatCommandHostLoaded = false;
let pluginChatSlashCommands: InteractiveSlashCommand[] = [];
let pluginChatCommandHostShutdown: (() => Promise<void>) | undefined;
let pluginChatCommandHostPromise:
| Promise<InteractiveSlashCommand[]>
let pluginHostPromise:
| ReturnType<typeof createWorkspaceChatCommandHost>
| undefined;
const ensurePluginChatCommandHost = async (): Promise<
InteractiveSlashCommand[]
> => {
if (pluginChatCommandHostLoaded) {
return pluginChatSlashCommands;
}
pluginChatCommandHostPromise ??= createWorkspaceChatCommandHost({
cwd: config.cwd,
workspaceRoot: config.workspaceRoot,
logger: config.logger,
})
.then(({ host, pluginSlashCommands, shutdown }) => {
interactiveChatCommandHost = host;
pluginChatCommandHostShutdown = shutdown;
pluginChatSlashCommands = pluginSlashCommands.map((cmd) => ({
name: cmd.name,
instructions: "",
description: cmd.description ?? "Plugin command",
}));
return pluginChatSlashCommands;
const ensurePluginHost = () => {
pluginHostPromise ??= sessionRuntime
.getPluginCommandsApi()
.then((commands) =>
createWorkspaceChatCommandHost({
cwd: config.cwd,
workspaceRoot: config.workspaceRoot,
logger: config.logger,
commands,
getSessionId: () => sessionRuntime.getActiveSessionId(),
}),
)
.then((host) => {
interactiveChatCommandHost = host.host;
return host;
})
.finally(() => {
pluginChatCommandHostLoaded = true;
pluginChatCommandHostPromise = undefined;
.catch((error) => {
pluginHostPromise = undefined;
throw error;
});
return await pluginChatCommandHostPromise;
return pluginHostPromise;
};
const loadAdditionalSlashCommands = async (): Promise<
InteractiveSlashCommand[]
> => await ensurePluginChatCommandHost();
const shouldTryPluginChatCommands = (prompt: string): boolean => {
return prompt.trimStart().startsWith("/");
> => {
const host = await ensurePluginHost();
return (await host.listCommands()).map((command) => ({
...command,
description: command.description ?? "Plugin command",
instructions: "",
}));
};
// Session commands come from the active session's plugins, so a session
// change can change the catalog.
const activeSessionListeners = new Set<() => void>();
const subscribeAdditionalSlashCommands = (listener: () => void) => {
activeSessionListeners.add(listener);
return () => {
activeSessionListeners.delete(listener);
};
};
const enableChatCommands = true;
@@ -297,6 +302,9 @@ export async function runInteractive(
onPendingPromptSubmitted: (event) => {
uiEvents.emit("pending-prompt-submitted", event);
},
onActiveSessionChange: () => {
for (const listener of activeSessionListeners) listener();
},
});
const configDataLoader = createInteractiveConfigDataLoader({
config,
@@ -385,11 +393,6 @@ export async function runInteractive(
try {
exitSummary = await sessionRuntime.cleanup();
} finally {
await pluginChatCommandHostPromise?.catch(() => []);
await pluginChatCommandHostShutdown?.().catch(() => {
// Best effort cleanup for plugin command discovery sandbox.
});
pluginChatCommandHostShutdown = undefined;
setActiveRuntimeAbort(undefined);
setActiveRuntimeCleanup(undefined);
}
@@ -525,6 +528,7 @@ export async function runInteractive(
initialRepoStatus,
workflowSlashCommands,
loadAdditionalSlashCommands,
subscribeAdditionalSlashCommands,
loadWelcomeLine: async () =>
await resolveClineWelcomeLine({
config,
@@ -579,7 +583,8 @@ export async function runInteractive(
isRunning = true;
}
let chatCommandResult = await runInteractiveChatCommand({
if (input.trimStart().startsWith("/")) await ensurePluginHost();
const chatCommandResult = await runInteractiveChatCommand({
prompt: input,
enabled: enableChatCommands,
config,
@@ -594,27 +599,7 @@ export async function runInteractive(
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
}
if (
shouldTryPluginChatCommands(input) &&
!pluginChatCommandHostLoaded
) {
await ensurePluginChatCommandHost();
chatCommandResult = await runInteractiveChatCommand({
prompt: input,
enabled: enableChatCommands,
config,
host: interactiveChatCommandHost,
chatCommandState,
autoApproveAllRef,
setInteractiveAutoApprove,
sessionRuntime,
stop: () => tuiApp?.destroy(),
onCommandOutput,
});
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
}
}
input = chatCommandResult.input;
commandOutput = chatCommandResult.commandOutput;
zeroTurnCost = await shouldZeroClineFreeModelCost(config);
@@ -206,6 +206,13 @@ export function buildSlashCommandRegistry(input: {
});
}
for (const command of input.additionalSlashCommands ?? []) {
const entry = entryFromRuntimeCommand(command, "plugin");
if (entry) {
addEntry(byName, entry);
}
}
for (const command of input.workflowSlashCommands ?? []) {
const source =
command.kind === "skill" || command.kind === "workflow"
@@ -217,13 +224,6 @@ export function buildSlashCommandRegistry(input: {
}
}
for (const command of input.additionalSlashCommands ?? []) {
const entry = entryFromRuntimeCommand(command, "plugin");
if (entry) {
addEntry(byName, entry);
}
}
return {
entries: [...byName.values()],
byName,
+2 -4
View File
@@ -201,10 +201,8 @@ function formatToolParams(
case "skills": {
if (rawInput && typeof rawInput === "object" && "skill" in rawInput) {
const s = String((rawInput as { skill: unknown }).skill);
const args =
"args" in rawInput
? ` ${String((rawInput as { args: unknown }).args)}`
: "";
const rawArgs = (rawInput as { args?: unknown }).args;
const args = rawArgs == null || rawArgs === "" ? "" : ` ${rawArgs}`;
const full = `${s}${args}`;
return full.length > 70 ? `${full.slice(0, 70)}...` : full;
}
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useSlashCommands } from "./use-slash-commands";
const reactMock = vi.hoisted(() => ({
cleanups: [] as Array<() => void>,
setCommands: vi.fn(),
}));
vi.mock("react", () => ({
useState: (initial: unknown) => [initial, reactMock.setCommands],
useMemo: (compute: () => unknown) => compute(),
useEffect: (effect: () => (() => void) | undefined) => {
const cleanup = effect();
if (cleanup) reactMock.cleanups.push(cleanup);
},
}));
const first = [{ name: "first", description: "First", instructions: "" }];
const next = [{ name: "next", description: "Next", instructions: "" }];
function useTestSlashCommands(
load: NonNullable<
Parameters<typeof useSlashCommands>[0]["loadAdditionalSlashCommands"]
>,
subscribe?: Parameters<
typeof useSlashCommands
>[0]["subscribeAdditionalSlashCommands"],
) {
useSlashCommands({
workflowSlashCommands: [],
loadAdditionalSlashCommands: load,
subscribeAdditionalSlashCommands: subscribe,
canFork: false,
});
}
function unmount() {
for (const cleanup of reactMock.cleanups.splice(0)) cleanup();
}
beforeEach(() => {
reactMock.setCommands.mockClear();
});
afterEach(() => {
unmount();
});
describe("plugin slash command catalog lifecycle", () => {
it("reloads when notified and keeps the last catalog when a reload fails", async () => {
let notify = () => {};
const stop = vi.fn();
const load = vi
.fn()
.mockResolvedValueOnce(first)
.mockRejectedValueOnce(new Error("hub unavailable"))
.mockResolvedValueOnce(next);
useTestSlashCommands(load, (listener) => {
notify = listener;
return stop;
});
await vi.waitFor(() =>
expect(reactMock.setCommands).toHaveBeenLastCalledWith(first),
);
notify();
await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(2));
await Promise.resolve();
expect(reactMock.setCommands).toHaveBeenCalledTimes(1);
notify();
await vi.waitFor(() =>
expect(reactMock.setCommands).toHaveBeenLastCalledWith(next),
);
unmount();
expect(stop).toHaveBeenCalledTimes(1);
});
it("ignores an older load that resolves after a newer one", async () => {
let notify = () => {};
let resolveOlder!: (commands: typeof first) => void;
const load = vi
.fn()
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveOlder = resolve;
}),
)
.mockResolvedValueOnce(next);
useTestSlashCommands(load, (listener) => {
notify = listener;
return () => {};
});
notify();
await vi.waitFor(() =>
expect(reactMock.setCommands).toHaveBeenLastCalledWith(next),
);
resolveOlder(first);
await Promise.resolve();
expect(reactMock.setCommands).toHaveBeenCalledTimes(1);
});
});
+22 -13
View File
@@ -10,9 +10,15 @@ import type { TuiProps } from "../types";
export function useSlashCommands(input: {
workflowSlashCommands: TuiProps["workflowSlashCommands"];
loadAdditionalSlashCommands: TuiProps["loadAdditionalSlashCommands"];
subscribeAdditionalSlashCommands: TuiProps["subscribeAdditionalSlashCommands"];
canFork: boolean;
}) {
const { workflowSlashCommands, loadAdditionalSlashCommands, canFork } = input;
const {
workflowSlashCommands,
loadAdditionalSlashCommands,
subscribeAdditionalSlashCommands,
canFork,
} = input;
const [additionalSlashCommands, setAdditionalSlashCommands] = useState<
TuiProps["workflowSlashCommands"] | undefined
>(loadAdditionalSlashCommands ? [] : undefined);
@@ -23,21 +29,24 @@ export function useSlashCommands(input: {
return;
}
let cancelled = false;
void loadAdditionalSlashCommands()
.then((commands) => {
if (!cancelled) {
setAdditionalSlashCommands(commands);
}
})
.catch(() => {
if (!cancelled) {
setAdditionalSlashCommands([]);
}
});
let requestId = 0;
const refresh = () => {
const id = ++requestId;
void loadAdditionalSlashCommands()
.then((commands) => {
if (!cancelled && id === requestId)
setAdditionalSlashCommands(commands);
})
// Keep the last catalog; the next refresh tries again.
.catch(() => {});
};
refresh();
const unsubscribe = subscribeAdditionalSlashCommands?.(refresh);
return () => {
cancelled = true;
unsubscribe?.();
};
}, [loadAdditionalSlashCommands]);
}, [loadAdditionalSlashCommands, subscribeAdditionalSlashCommands]);
const registry = useMemo(() => {
return buildSlashCommandRegistry({
+1
View File
@@ -146,6 +146,7 @@ function App(props: TuiProps) {
} = useSlashCommands({
workflowSlashCommands,
loadAdditionalSlashCommands: props.loadAdditionalSlashCommands,
subscribeAdditionalSlashCommands: props.subscribeAdditionalSlashCommands,
canFork: canForkSession,
});
+2
View File
@@ -157,6 +157,8 @@ export interface TuiProps {
initialRepoStatus?: RepoStatus;
workflowSlashCommands?: InteractiveSlashCommand[];
loadAdditionalSlashCommands?: () => Promise<InteractiveSlashCommand[]>;
/** Notifies when additional commands may have changed (the active session changed). */
subscribeAdditionalSlashCommands?: (listener: () => void) => () => void;
loadWelcomeLine?: () => Promise<string | undefined>;
loadClineAccount: () => Promise<ClineAccountSnapshot>;
loadIndividualSubscriptionPlans?: () => Promise<ClineSubscriptionPlan[]>;
+20 -2
View File
@@ -3,6 +3,7 @@ import { resolve } from "node:path";
import { resolveWorkspaceRoot } from "./helpers";
export type ChatCommandState = {
sessionId?: string;
enableTools: boolean;
autoApproveTools: boolean;
cwd: string;
@@ -69,6 +70,7 @@ type ParsedChatCommand = {
trimmed: string;
command: string;
args: string[];
argumentsText: string;
state: ChatCommandState;
};
@@ -83,6 +85,19 @@ export type ChatCommandDefinition = {
export class ChatCommandHost {
private readonly definitions: ChatCommandDefinition[];
private fallback?: (
parsed: ParsedChatCommand,
context: ChatCommandContext,
) => Promise<boolean>;
setFallback(
handler: (
parsed: ParsedChatCommand,
context: ChatCommandContext,
) => Promise<boolean>,
): this {
this.fallback = handler;
return this;
}
constructor(definitions: ChatCommandDefinition[] = []) {
this.definitions = [...definitions];
@@ -101,7 +116,9 @@ export class ChatCommandHost {
}
clone(): ChatCommandHost {
return new ChatCommandHost(this.definitions);
const host = new ChatCommandHost(this.definitions);
host.fallback = this.fallback;
return host;
}
async handle(input: string, context: ChatCommandContext): Promise<boolean> {
@@ -130,13 +147,14 @@ export class ChatCommandHost {
trimmed,
command,
args,
argumentsText: trimmed.slice(commandRaw.length),
state: await context.getState(),
};
const matched = this.definitions.find((definition) =>
definition.names.includes(parsed.command),
);
if (!matched) {
return false;
return (await this.fallback?.(parsed, context)) ?? false;
}
if (matched.isAvailable && !matched.isAvailable(context)) {
return false;
+7
View File
@@ -0,0 +1,7 @@
declare const __CLINE_DASHBOARD_RUNTIME_DIR__: string | undefined;
export function resolveEmbeddedDashboard(): string | undefined {
return typeof __CLINE_DASHBOARD_RUNTIME_DIR__ === "undefined"
? undefined
: __CLINE_DASHBOARD_RUNTIME_DIR__;
}
+105 -11
View File
@@ -1,13 +1,16 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { ClineCore, type PluginCommandsApi } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createWorkspaceChatCommandHost } from "./plugin-chat-commands";
describe("plugin chat commands", () => {
const tempRoots: string[] = [];
const cores: ClineCore[] = [];
afterEach(async () => {
await Promise.all(cores.splice(0).map((core) => core.dispose()));
await Promise.all(
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
);
@@ -36,20 +39,22 @@ describe("plugin chat commands", () => {
].join("\n"),
);
const { host, pluginSlashCommands, shutdown } =
await createWorkspaceChatCommandHost({
cwd: tempRoot,
workspaceRoot: tempRoot,
});
const core = await ClineCore.create({ backendMode: "local" });
cores.push(core);
const { host, listCommands } = await createWorkspaceChatCommandHost({
commands: core.pluginCommands,
cwd: tempRoot,
workspaceRoot: tempRoot,
});
const reply = vi.fn(async () => undefined);
// Filter to only our test plugin to ignore any discovered system plugins
const testCommands = pluginSlashCommands.filter(
const testCommands = (await listCommands()).filter(
(cmd) => cmd.name === "echo",
);
expect(testCommands).toEqual([{ name: "echo", description: "Echo input" }]);
const handled = await host.handle("/echo hello plugin", {
const handled = await host.handle("/echo hello plugin\nnext line", {
enabled: true,
getState: async () => ({
enableTools: false,
@@ -62,8 +67,7 @@ describe("plugin chat commands", () => {
});
expect(handled).toBe(true);
expect(reply).toHaveBeenCalledWith("echo:hello plugin");
await shutdown?.();
expect(reply).toHaveBeenCalledWith("echo:hello plugin\nnext line");
});
it("bridges plugin command submit prompts onto the chat command context", async () => {
@@ -91,7 +95,10 @@ describe("plugin chat commands", () => {
].join("\n"),
);
const { host, shutdown } = await createWorkspaceChatCommandHost({
const core = await ClineCore.create({ backendMode: "local" });
cores.push(core);
const { host } = await createWorkspaceChatCommandHost({
commands: core.pluginCommands,
cwd: tempRoot,
workspaceRoot: tempRoot,
});
@@ -114,6 +121,93 @@ describe("plugin chat commands", () => {
expect(handled).toBe(true);
expect(reply).toHaveBeenCalledWith("goal:fix tests");
expect(submitPrompt).toHaveBeenCalledWith("fix tests");
await shutdown?.();
});
it.each([
"/skill explain",
"/workflow run",
])("lets %s reach instruction expansion when plugin discovery fails", async (prompt) => {
const commands: PluginCommandsApi = {
list: vi.fn().mockRejectedValue(new Error("hub unavailable")),
run: vi.fn().mockRejectedValue(new Error("must not execute")),
};
const logger = { log: vi.fn(), debug: vi.fn() };
const { host } = await createWorkspaceChatCommandHost({
commands,
cwd: "/tmp/project",
logger,
});
const handled = await host.handle(prompt, {
enabled: true,
getState: () => ({
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: false,
autoApproveTools: false,
}),
setState: vi.fn(),
reply: vi.fn(),
});
expect(handled).toBe(false);
expect(commands.run).not.toHaveBeenCalled();
expect(logger.log).toHaveBeenCalled();
});
it("does not execute commands absent from a partial catalog", async () => {
const commands: PluginCommandsApi = {
list: vi.fn().mockResolvedValue({
status: "error",
commands: [{ name: "echo" }],
error: "sibling failed",
}),
run: vi.fn(),
};
const { host } = await createWorkspaceChatCommandHost({
commands,
cwd: "/tmp/project",
});
expect(
await host.handle("/workflow run", {
enabled: true,
getState: () => ({
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: false,
autoApproveTools: false,
}),
setState: vi.fn(),
reply: vi.fn(),
}),
).toBe(false);
expect(commands.run).not.toHaveBeenCalled();
});
it("preserves execution errors for a command owned by a healthy plugin", async () => {
const commands: PluginCommandsApi = {
list: vi.fn().mockResolvedValue({
status: "error",
commands: [{ name: "echo" }],
error: "sibling failed",
}),
run: vi.fn().mockRejectedValue(new Error("handler failed")),
};
const { host } = await createWorkspaceChatCommandHost({
commands,
cwd: "/tmp/project",
});
await expect(
host.handle("/echo input", {
enabled: true,
getState: () => ({
cwd: "/tmp/project",
workspaceRoot: "/tmp/project",
enableTools: false,
autoApproveTools: false,
}),
setState: vi.fn(),
reply: vi.fn(),
}),
).rejects.toThrow("handler failed");
expect(commands.run).toHaveBeenCalledTimes(1);
});
});
+49 -40
View File
@@ -1,50 +1,59 @@
import {
type BasicLogger,
createPluginCommandService,
type PluginSlashCommand,
import type {
BasicLogger,
PluginCommandCatalog,
PluginCommandsApi,
} from "@cline/core";
import { type ChatCommandHost, chatCommandHost } from "./chat-commands";
export type { PluginSlashCommand };
export interface WorkspaceChatCommandHostResult {
host: ChatCommandHost;
// Plugin-registered commands surfaced as slash commands for TUI autocomplete.
pluginSlashCommands: PluginSlashCommand[];
shutdown?: () => Promise<void>;
}
import { chatCommandHost } from "./chat-commands";
export async function createWorkspaceChatCommandHost(input: {
cwd: string;
workspaceRoot?: string;
logger?: BasicLogger;
}): Promise<WorkspaceChatCommandHostResult> {
const service = createPluginCommandService({
cwd: input.cwd,
commands: PluginCommandsApi;
getSessionId?: () => string | undefined;
}) {
const commands = input.commands;
const target = () => ({
workspacePath: input.workspaceRoot?.trim() || input.cwd,
logger: input.logger,
sessionId: input.getSessionId?.() || undefined,
});
// Load failures are logged by the service and yield no commands.
const pluginSlashCommands = await service.listCommands();
if (pluginSlashCommands.length === 0) {
await service.shutdown();
return { host: chatCommandHost, pluginSlashCommands: [] };
}
const host = chatCommandHost.clone();
for (const command of pluginSlashCommands) {
host.register("command", {
names: [`/${command.name}`],
run: async ({ args }, context) => {
const result = await service.run(command.name, args.join(" "));
if (result?.reply) {
await context.reply(result.reply);
}
if (result?.submitPrompt) {
await context.submitPrompt?.(result.submitPrompt);
}
},
const host = chatCommandHost.clone().setFallback(async (parsed, context) => {
const commandTarget = {
workspacePath: parsed.state.workspaceRoot || parsed.state.cwd,
sessionId: parsed.state.sessionId ?? input.getSessionId?.(),
};
let catalog: PluginCommandCatalog;
try {
catalog = await commands.list(commandTarget);
} catch (error) {
input.logger?.log("Plugin command discovery failed", {
error,
severity: "warn",
});
return false;
}
if (
!catalog.commands.some(
(command) => command.name === parsed.command.slice(1),
)
)
return false;
const result = await commands.run({
...commandTarget,
prompt: `${parsed.command}${parsed.argumentsText}`,
});
}
return { host, pluginSlashCommands, shutdown: service.shutdown };
if (!result) return false;
if (result.reply) await context.reply(result.reply);
if (result.submitPrompt) await context.submitPrompt?.(result.submitPrompt);
return true;
});
return {
host,
async listCommands() {
const catalog = await commands.list(target());
if (catalog.status === "error")
input.logger?.log(`Plugin commands unavailable: ${catalog.error}`);
return catalog.commands;
},
};
}
@@ -9,6 +9,7 @@ import {
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { SKILLS_CLI_PACKAGE } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildMarketplaceMcpInput,
@@ -171,7 +172,7 @@ describe("marketplace installer", () => {
expect(spawnCommand).toHaveBeenCalledWith("npx", [
"-y",
"skills@latest",
SKILLS_CLI_PACKAGE,
"add",
"vercel-labs/agent-skills",
"--skill",
@@ -314,7 +315,7 @@ describe("marketplace installer", () => {
});
expect(spawnCommand).toHaveBeenCalledWith("npx", [
"-y",
"skills@latest",
SKILLS_CLI_PACKAGE,
"remove",
"cline-sdk",
"-g",
+2 -1
View File
@@ -22,6 +22,7 @@ import {
type MarketplaceEntryInput,
resolveSkillsConfigSearchPaths,
resolveWorkflowsConfigSearchPaths,
SKILLS_CLI_PACKAGE,
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
uninstallPlugin as uninstallLocalPlugin,
} from "@cline/core";
@@ -763,7 +764,7 @@ async function installSkill(
ensureGlobalSkillsDirWritable();
const result = await spawnCommand("npx", [
"-y",
"skills@latest",
SKILLS_CLI_PACKAGE,
"add",
...(entry.install.args ?? []),
"-g",
+6
View File
@@ -1,5 +1,11 @@
# Cline Desktop Changelog
## 0.0.48
- GPT-6 models (such as `gpt-6-sol` and `gpt-6.1-sol`) work on OpenAI and Azure OpenAI-compatible endpoints. Before, every request was rejected because Cline sent `max_tokens` instead of `max_completion_tokens`
- The Cline provider's default model is Claude Sonnet 5.5 again, which is back at the top of Cline's recommended list
- Refreshed the model catalog. Adds the CommandCode and Reflection providers. Default models change for 302.AI (Gemini 4 Argon), Eden AI and Prime Inference (Muse Spark Open), CoreWeave (Reflection Beam 1.0), NanoGPT, Pioneer (Nemotron 3.5 Super VL), and Scaleway (DeepSeek V4.1 Flash)
## 0.0.47
- Marketplace plugins such as goal load again. In 0.0.46 they failed with `Cannot find module '@cline/sdk'` because the packaged app didn't include the SDK packages plugins import
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@cline/code",
"version": "0.0.47",
"version": "0.0.48",
"private": true,
"scripts": {
"build:ui": "bun -F @cline/ui build",
@@ -108,6 +108,7 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@cline/sdk": "workspace:*",
"@tailwindcss/postcss": "^4.2.0",
"@tauri-apps/cli": "^2.0.0",
"@types/bun": "^1.4.2",
@@ -1,4 +1,4 @@
import { existsSync } from "node:fs";
import { cpSync, existsSync, rmSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { $ } from "bun";
import {
@@ -95,6 +95,13 @@ const bundleDesktopBackend = async (): Promise<void> => {
// from Finder/the Dock has no OTEL_* env at runtime, so without this the
// backend silently ships with telemetry disabled. Verify with
// `BUN_BE_BUN=1 cline-cli run <bundle> --telemetry-selfcheck`.
// The script runner does not mount the CLI's bunfs. Ship the same runtime
// artifact beside the backend, just as npm and VS Code hosts do.
const runtime = `${APP_ROOT}/${BIN_DIR}/desktop-backend/plugin-runtime`;
rmSync(runtime, { recursive: true, force: true });
cpSync(`${REPO_ROOT}/sdk/packages/core/dist/plugin-runtime`, runtime, {
recursive: true,
});
const defines = telemetryDefineArgs();
await $`bun build ./sidecar/index.ts --target bun --minify ${defines} --outfile ${DESKTOP_BACKEND_BUNDLE}`;
};
@@ -141,53 +148,6 @@ const buildUniversalMacCli = async (): Promise<void> => {
await $`lipo -info ${outfile}`;
};
// Plugin installs drop their @cline/* dependencies because the host provides
// them, and the plugin worker resolves those from node_modules beside its
// bootstrap. Nothing like that exists next to the compiled CLI, so ship
// self-contained builds there. Splitting keeps one shared copy of the SDK,
// placed in core so every chunk sits inside a `"type": "module"` package.
const bundlePluginSdkPackages = async (): Promise<void> => {
const outdir = `${BIN_DIR}/extensions/node_modules/@cline`;
const packagesRoot = "../../../sdk/packages";
const packages = await Promise.all(
["sdk", "core", "agents", "llms", "shared"].map(async (name) => {
const root = `${packagesRoot}/${name}`;
const manifest = (await Bun.file(`${root}/package.json`).json()) as {
exports: Record<string, { import: string }>;
};
// Type-only exports (e.g. @cline/shared/types) have no JS to ship.
const exports = Object.fromEntries(
Object.entries(manifest.exports)
.map(([path, entry]) => [path, entry.import] as const)
.filter(([, file]) => existsSync(`${root}/${file}`)),
);
return { root, name, exports };
}),
);
await $`rm -rf ${outdir}`;
const result = await Bun.build({
entrypoints: packages.flatMap(({ root, exports }) =>
Object.values(exports).map((file) => `${root}/${file}`),
),
root: packagesRoot,
outdir,
naming: { chunk: "core/dist/[name]-[hash].[ext]" },
target: "node",
format: "esm",
splitting: true,
minify: true,
});
if (!result.success) {
throw new AggregateError(result.logs, "plugin SDK bundle failed");
}
for (const { name, exports } of packages) {
await Bun.write(
`${outdir}/${name}/package.json`,
JSON.stringify({ name: `@cline/${name}`, type: "module", exports }),
);
}
};
const hasOpenTuiNativeVariant = (os: string, arch: string): boolean =>
existsSync(`${REPO_ROOT}/node_modules/.bun`) &&
[
@@ -203,10 +163,7 @@ const main = async () => {
await $`bun run build:sdk`.cwd(REPO_ROOT);
process.chdir(APP_ROOT);
const targetTriple = await resolveTargetTriple();
await $`mkdir -p ${BIN_DIR}/desktop-backend ${REMOTE_CLI_DIR} ${BIN_DIR}/extensions`;
// Plugin workers load this bootstrap from disk in the backend and Hub.
await $`cp ../../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js ${BIN_DIR}/extensions/plugin-sandbox-bootstrap.js`;
await bundlePluginSdkPackages();
await $`mkdir -p ${BIN_DIR}/desktop-backend ${REMOTE_CLI_DIR}`;
// Every build cross-compiles the Linux SSH copies (and macOS builds both
// architectures), which needs the target platforms' OpenTUI native
// packages in addition to the host's.
@@ -2,9 +2,13 @@ import { afterAll, expect, test } from "bun:test";
import { spawnSync } from "node:child_process";
import {
closeSync,
copyFileSync,
cpSync,
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
@@ -68,6 +72,16 @@ function resolveBackendBundle(): string {
{ encoding: "utf8", timeout: 120_000 },
);
expect(build.status, build.stderr || String(build.error)).toBe(0);
cpSync(
fileURLToPath(
new URL(
"../../../../sdk/packages/core/dist/plugin-runtime",
import.meta.url,
),
),
join(outdir, "plugin-runtime"),
{ recursive: true },
);
bundledBackend = join(outdir, "index.js");
}
return bundledBackend;
@@ -107,6 +121,7 @@ test("the shipped CLI and backend report telemetry without starting a hub", asyn
otlp_endpoint_host: expect.any(String),
logs_exporter: expect.any(String),
metrics_exporter: expect.any(String),
traces_exporter: expect.any(String),
});
}
}, 120_000);
@@ -331,3 +346,233 @@ for (const discoveryContents of ["{ invalid json", "{}"]) {
);
}, 120_000);
}
test.each([
"worker",
"sandbox",
])("standalone CLI loads an SDK plugin without Node (%s)", async (mode) => {
const cli = await resolveCliBinary();
const root = mkdtempSync(join(tmpdir(), "cline-plugin-installed-"));
try {
const directory = join(root, ".cline", "plugins");
mkdirSync(directory, { recursive: true });
// Occupy the old cache path: startup must succeed without extraction.
writeFileSync(join(root, ".cline", "runtime"), "no extraction");
const marker = join(root, "plugin-result.json");
// Copy the raw TypeScript fixture so SDK imports must resolve from the
// packaged runtime, outside the monorepo and without prebundling.
copyFileSync(
fileURLToPath(
new URL("./fixtures/embedded-plugin/smoke.ts", import.meta.url),
),
join(directory, "smoke.ts"),
);
const env = Object.fromEntries(
Object.entries(process.env).filter(
([key]) =>
!/^(CLINE_|BUN_BE_BUN|NODE_PATH|npm_node_execpath|NODE$|BUN_EXEC_PATH)/.test(
key,
),
),
);
const result = spawnSync(cli, ["config", "tools", "--json"], {
cwd: root,
encoding: "utf8",
timeout: 30000,
env: {
...env,
HOME: root,
USERPROFILE: root,
CLINE_DIR: join(root, ".cline"),
PATH: root,
JITI_FS_CACHE: "false",
CLINE_PLUGIN_MODE: mode,
CLINE_TEST_PLUGIN_RESULT_PATH: marker,
TMPDIR: root,
CLINE_NO_AUTO_UPDATE: "1",
},
});
expect(result.status, result.stderr || String(result.error)).toBe(0);
expect(
JSON.parse(result.stdout).some(
(tool: { name: string }) => tool.name === "embedded_ping",
),
).toBe(true);
expect(readFileSync(join(root, ".cline", "runtime"), "utf8")).toBe(
"no extraction",
);
expect(JSON.parse(readFileSync(marker, "utf8"))).toEqual({
pong: true,
// The native resolver expands Windows 8.3 short names (RUNNER~1), as
// the child's process.execPath does.
execPath: realpathSync.native(cli),
isMainThread: mode === "sandbox",
bunRuntimeFlag: null,
});
} finally {
rmSync(root, { recursive: true, force: true });
}
}, 120000);
test.each([
"worker",
"sandbox",
])("packaged desktop backend loads its on-disk SDK runtime (%s)", async (mode) => {
const cli = await resolveCliBinary();
const outdir = dirname(resolveBackendBundle());
const probe = join(outdir, "plugin-runtime-probe.js");
const built = await Bun.build({
entrypoints: [
fileURLToPath(
new URL("./fixtures/desktop-plugin-runtime.ts", import.meta.url),
),
],
target: "bun",
outdir,
naming: "plugin-runtime-probe.js",
});
expect(built.success, built.logs.map(String).join("\n")).toBe(true);
const root = mkdtempSync(join(tmpdir(), "cline-desktop-plugin-"));
try {
const plugins = join(root, ".cline", "plugins");
mkdirSync(plugins, { recursive: true });
copyFileSync(
fileURLToPath(
new URL("./fixtures/embedded-plugin/desktop.ts", import.meta.url),
),
join(plugins, "desktop.ts"),
);
writeFileSync(join(root, ".cline", "runtime"), "no extraction");
const env = Object.fromEntries(
Object.entries(process.env).filter(
([key]) =>
!/^(CLINE_|BUN_BE_BUN|NODE_PATH|npm_node_execpath|NODE$|BUN_EXEC_PATH)/.test(
key,
),
),
);
const result = spawnSync(
cli,
["run", "--use-system-ca", "--no-env-file", probe],
{
cwd: root,
encoding: "utf8",
timeout: 30000,
env: {
...env,
PATH: root,
HOME: root,
USERPROFILE: root,
CLINE_DIR: join(root, ".cline"),
BUN_BE_BUN: "1",
CLINE_DESKTOP_CLI_PATH: cli,
CLINE_PLUGIN_MODE: mode,
JITI_FS_CACHE: "false",
},
},
);
expect(result.status, result.stderr || String(result.error)).toBe(0);
expect(JSON.parse(result.stdout)).toEqual({
commands: [{ name: "packaged-ping" }],
result: { reply: "pong" },
});
expect(readFileSync(join(root, ".cline", "runtime"), "utf8")).toBe(
"no extraction",
);
} finally {
rmSync(root, { recursive: true, force: true });
}
}, 120000);
test("standalone CLI serves the dashboard and its assets without external files or Node", async () => {
const cli = await resolveCliBinary();
const root = mkdtempSync(join(tmpdir(), "cline-standalone-dashboard-"));
mkdirSync(join(root, ".cline"));
writeFileSync(join(root, ".cline", "runtime"), "no extraction");
const env = Object.fromEntries(
Object.entries(process.env).filter(
([key]) =>
!/^(CLINE_|BUN_BE_BUN|NODE_PATH|HOST$|PORT$|ROOM_SECRET$|PUBLIC_URL$)/.test(
key,
),
),
);
const port = await reserveLoopbackPort();
const isolatedEnv = {
...env,
HOME: root,
USERPROFILE: root,
CLINE_DIR: join(root, ".cline"),
CLINE_HUB_DISCOVERY_PATH: join(root, "hub.json"),
CLINE_HUB_PORT: String(await reserveLoopbackPort()),
CLINE_NO_AUTO_UPDATE: "1",
PATH: root,
};
const child = Bun.spawn(
[
cli,
"dashboard",
"--no-open",
"--host",
"127.0.0.1",
"--port",
String(port),
"--cwd",
root,
],
{ cwd: root, env: isolatedEnv, stdout: "ignore", stderr: "pipe" },
);
try {
const url = `http://127.0.0.1:${port}`;
let response: Response | undefined;
const deadline = Date.now() + 20000;
while (Date.now() < deadline) {
try {
response = await fetch(url);
if (response.status === 200) break;
} catch {}
if (child.exitCode !== null)
throw new Error(await new Response(child.stderr).text());
await Bun.sleep(100);
}
expect(response?.status).toBe(200);
if (!response) throw new Error("Dashboard did not start");
expect(readFileSync(join(root, ".cline", "runtime"), "utf8")).toBe(
"no extraction",
);
const html = await response.text();
expect(html).toContain("<html");
const assets = [
...html.matchAll(/(?:src|href)="([^"]+\.(?:js|css))"/g),
].map((match) => match[1]);
expect(assets.length).toBeGreaterThan(0);
for (const asset of assets) {
if (!asset) throw new Error("Missing dashboard asset URL");
const resource = await fetch(new URL(asset, url));
expect(resource.status).toBe(200);
expect(resource.headers.get("content-type")).toMatch(
asset.endsWith(".css") ? /text\/css/ : /(?:javascript|ecmascript)/,
);
expect((await resource.arrayBuffer()).byteLength).toBeGreaterThan(0);
}
} finally {
child.kill("SIGTERM");
await child.exited;
spawnSync(cli, ["hub", "stop"], {
cwd: root,
env: isolatedEnv,
timeout: 10000,
});
// The stopped Hub can hold its cwd briefly on Windows (EBUSY).
try {
rmSync(root, {
recursive: true,
force: true,
maxRetries: 20,
retryDelay: 100,
});
} catch {
/* The runner discards its temp directory anyway. */
}
}
}, 120000);
@@ -0,0 +1,26 @@
import { ClineCore, resolvePluginRuntimeDirectory } from "@cline/core";
import { adoptDesktopCliRuntime } from "../../sidecar/cli-runtime";
async function main() {
adoptDesktopCliRuntime();
const runtime = resolvePluginRuntimeDirectory();
if (!runtime?.includes("desktop-backend"))
throw new Error(`Expected the packaged backend runtime, got ${runtime}`);
const core = await ClineCore.create({ backendMode: "local" });
try {
const workspacePath = process.cwd();
const { commands } = await core.pluginCommands.list({ workspacePath });
const result = await core.pluginCommands.run({
workspacePath,
prompt: "/packaged-ping",
});
process.stdout.write(JSON.stringify({ commands, result }));
} finally {
await core.dispose();
}
}
void main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,12 @@
import type { AgentPlugin } from "@cline/sdk";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser";
export default {
name: "packaged-backend-smoke",
manifest: { capabilities: ["commands"] },
setup(api) {
if (typeof CLINE_DEFAULT_MODEL_ID !== "string")
throw new Error("Missing SDK browser exports");
api.registerCommand({ name: "packaged-ping", handler: () => "pong" });
},
} satisfies AgentPlugin;
@@ -0,0 +1,68 @@
import { spawnSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import { isMainThread } from "node:worker_threads";
import { getAllProviders } from "@cline/llms/browser";
import {
type AgentPlugin,
createTool,
resolvePluginRuntimeDirectory,
} from "@cline/sdk";
import { CLINE_DEFAULT_MODEL_ID } from "@cline/shared/browser";
import { AUTH_ERROR_PATTERNS, isLikelyAuthError } from "@cline/shared/types";
export default {
name: "embedded-smoke",
manifest: { capabilities: ["tools"] },
async setup(api) {
if (
typeof CLINE_DEFAULT_MODEL_ID !== "string" ||
typeof getAllProviders !== "function"
) {
throw new Error("Missing browser SDK exports");
}
if (
!AUTH_ERROR_PATTERNS.includes("unauthorized") ||
!isLikelyAuthError(new Error("401 Unauthorized"))
) {
throw new Error("Missing shared/types runtime exports");
}
if (!process.execArgv.includes("--use-system-ca")) {
throw new Error("Plugin runtime lost compiled system CA trust");
}
const runtime = resolvePluginRuntimeDirectory();
const bunfsRoot =
process.platform === "win32" ? "B:/~BUN/root/" : "/$bunfs/root/";
if (!runtime?.replaceAll("\\", "/").startsWith(bunfsRoot)) {
throw new Error(`Expected the embedded runtime, got ${runtime}`);
}
const marker = process.env.CLINE_TEST_PLUGIN_RESULT_PATH;
if (!marker) {
throw new Error("Missing CLINE_TEST_PLUGIN_RESULT_PATH");
}
const nested = spawnSync(process.execPath, ["--telemetry-selfcheck"], {
env: process.env,
encoding: "utf8",
timeout: 10000,
});
if (nested.status !== 0) throw new Error(nested.stderr);
JSON.parse(nested.stdout);
const tool = createTool({
name: "embedded_ping",
description: "Packaged SDK tool",
inputSchema: { type: "object", properties: {} },
execute: async () => ({
pong: true,
execPath: process.execPath,
isMainThread,
bunRuntimeFlag: process.env.BUN_BE_BUN ?? null,
}),
});
writeFileSync(
marker,
JSON.stringify(
await tool.execute({}, { agentId: "embedded-smoke", iteration: 0 }),
),
);
api.registerTool(tool);
},
} satisfies AgentPlugin;
@@ -46,6 +46,21 @@ describe("telemetryDefineArgs", () => {
const defines = defineMap(telemetryDefineArgs({}));
expect(defines["process.env.OTEL_TELEMETRY_ENABLED"]).toBe('""');
expect(defines["process.env.OTEL_EXPORTER_OTLP_ENDPOINT"]).toBe('""');
expect(defines["process.env.OTEL_TRACES_EXPORTER"]).toBe('""');
});
it("inlines the trace policy vars the Langfuse relay reads at runtime", () => {
const defines = defineMap(
telemetryDefineArgs({
CLINE_TRACE_SAMPLE_PERCENT: "25",
CLINE_TRACE_RECORD_CONTENT: "true",
}),
);
expect(defines["process.env.CLINE_TRACE_SAMPLE_PERCENT"]).toBe('"25"');
expect(defines["process.env.CLINE_TRACE_RECORD_CONTENT"]).toBe('"true"');
const unset = defineMap(telemetryDefineArgs({}));
expect(unset["process.env.CLINE_TRACE_SAMPLE_PERCENT"]).toBe('""');
expect(unset["process.env.CLINE_TRACE_RECORD_CONTENT"]).toBe('""');
});
it("includes API key defines only when the vars are set", () => {
@@ -20,14 +20,20 @@ const OPTIONAL_FEATURE_ENV_VARS = ["CLINE_CODE_CLOUD_AGENTS"] as const;
/**
* Every env var `getTelemetryBuildTimeConfig` reads
* (sdk/packages/shared/src/services/telemetry-config.ts). Always inlined,
* (sdk/packages/shared/src/services/telemetry-config.ts), plus the trace
* policy vars the Langfuse relay reads literally
* (sdk/packages/llms/src/services/langfuse-telemetry.ts). Always inlined,
* defaulting to "", so a packaged binary never falls back to runtime env.
* Mirrors BUILD_TIME_INLINED_ENV_VARS in apps/cli/script/compile-binary.ts,
* which bakes the same set into the bundled Cline CLI (the Hub daemon).
*/
const OTEL_ENV_VARS = [
"OTEL_TELEMETRY_ENABLED",
"OTEL_METRICS_EXPORTER",
"OTEL_LOGS_EXPORTER",
"OTEL_TRACES_EXPORTER",
"CLINE_TRACE_SAMPLE_PERCENT",
"CLINE_TRACE_RECORD_CONTENT",
"OTEL_EXPORTER_OTLP_PROTOCOL",
"OTEL_EXPORTER_OTLP_ENDPOINT",
"OTEL_EXPORTER_OTLP_HEADERS",
@@ -87,6 +87,11 @@ Hub daemon is that same CLI binary. Any Hub the backend itself has to start
later goes through the same binary (`setHubDaemonLauncher`). Startup discovery
and locking ensure concurrent clients converge on one Hub.
The backend ships Core’s complete `plugin-runtime` artifact beside its script.
The standalone CLI embeds the same artifact in its read-only Bun filesystem.
The script runner does not mount that filesystem, so Desktop uses its packaged
copy without extracting resources at startup.
Every create, restart, fork, and restore also attaches the serializable Desktop
`ExtensionContext.client` and current `ExtensionContext.user`. Core forwards
that context across the Hub transport and scopes the daemon-owned telemetry
@@ -9,7 +9,8 @@ import {
import { tmpdir } from "node:os";
import { join } from "node:path";
import { SessionNotFoundError } from "@cline/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveClineDir, setClineDir } from "@cline/shared/storage";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { materializeUserFiles } from "./attachments";
import {
buildSessionConnectionUpdate,
@@ -116,7 +117,7 @@ function localRuntimeContext(
workspaceRoot,
sessionManager,
hubClient: {
command: vi.fn(async () => undefined),
command: vi.fn(async () => ({ ok: true, payload: {} })),
},
unsubscribeSessionEvents: () => {},
},
@@ -2233,14 +2234,30 @@ describe("workspace metadata prewarming", () => {
describe("runtime slash command expansion on send", () => {
const tempRoots: string[] = [];
let originalClineDir: string;
beforeEach(() => {
originalClineDir = resolveClineDir();
const settingsRoot = mkdtempSync(
join(tmpdir(), "desktop-command-settings-"),
);
tempRoots.push(settingsRoot);
setClineDir(settingsRoot);
});
afterEach(() => {
setClineDir(originalClineDir);
for (const dir of tempRoots) {
rmSync(dir, { recursive: true, force: true });
}
tempRoots.length = 0;
});
function goalCatalog(workspacePath: string) {
return {
catalog: { workspacePath, status: "ready", commands: [{ name: "goal" }] },
};
}
function createWorkspaceWithSkill(): string {
const workspace = mkdtempSync(join(tmpdir(), "desktop-slash-send-"));
tempRoots.push(workspace);
@@ -2328,15 +2345,95 @@ Follow the desktop send workflow instructions.`,
expect(session.prompt).toBe("/desktop-send-skill write the docs");
});
it("still sends a skill command when a workspace plugin fails to load", async () => {
it.each([
"local",
"ssh",
] as const)("dispatches plugin commands through the owning %s hub", async (kind) => {
const workspace = createWorkspaceWithSkill();
const pluginsDir = join(workspace, ".cline", "plugins");
mkdirSync(pluginsDir, { recursive: true });
writeFileSync(
join(pluginsDir, "broken.js"),
"export default { name: 'broken', manifest: { capabilities: ['bogus'] }, setup() {} };",
);
const { ctx, send, sessionId } = createContext(workspace);
const binding = ctx.runtimeBindings.get("local");
if (!binding) throw new Error("Missing test binding");
binding.kind = kind;
const liveSession = ctx.liveSessions.get(sessionId);
if (liveSession) liveSession.config.apiKey = "test-key";
binding.sessionManager.updateSessionConnection = vi.fn(async () => {});
const command = vi.fn(async (name: string) => ({
version: "v1" as const,
ok: true,
payload:
name === "plugins.commands.list"
? goalCatalog(workspace)
: {
result: {
reply: "Goal set",
submitPrompt: "expanded goal\nnext line",
},
},
}));
binding.hubClient.command = command;
const result = await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "/goal original",
});
expect(result).toMatchObject({
result: { finishReason: "completed", text: "done" },
});
expect(command).toHaveBeenCalledWith(
"plugins.commands.run",
expect.objectContaining({
workspacePath: workspace,
sessionId,
prompt: "/goal original",
}),
sessionId,
{ timeoutMs: null, retryAfterDispatch: false },
);
expect(send).toHaveBeenCalledWith(
expect.objectContaining({ prompt: "expanded goal\nnext line" }),
);
});
it("targets the session's workspace root when its cwd is a subdirectory", async () => {
const workspace = createWorkspaceWithSkill();
const { ctx, session, sessionId } = createContext(workspace);
Object.assign(session.config, {
cwd: join(workspace, "packages", "app"),
workspaceRoot: workspace,
});
const binding = ctx.runtimeBindings.get("local");
if (!binding) throw new Error("Missing test binding");
const command = vi.fn(async (name: string) => ({
version: "v1" as const,
ok: true,
payload:
name === "plugins.commands.list"
? goalCatalog(workspace)
: { result: { reply: "Goal set" } },
}));
binding.hubClient.command = command;
await handleChatSessionCommand(ctx, {
action: "send",
sessionId,
prompt: "/goal original",
});
expect(command).toHaveBeenCalledWith(
"plugins.commands.run",
expect.objectContaining({ workspacePath: workspace, sessionId }),
sessionId,
{ timeoutMs: null, retryAfterDispatch: false },
);
});
it("still sends a skill command when the plugin catalog cannot be read", async () => {
const workspace = createWorkspaceWithSkill();
const { ctx, send, sessionId } = createContext(workspace);
const binding = ctx.runtimeBindings.get("local");
if (!binding) throw new Error("Missing test binding");
const command = vi.fn(async () => {
throw new Error("hub connection closed");
});
binding.hubClient.command = command;
await handleChatSessionCommand(ctx, {
action: "send",
@@ -2344,6 +2441,12 @@ Follow the desktop send workflow instructions.`,
prompt: "/desktop-send-skill write the docs",
});
expect(command).not.toHaveBeenCalledWith(
"plugins.commands.run",
expect.anything(),
expect.anything(),
expect.anything(),
);
expect(send).toHaveBeenCalledWith(
expect.objectContaining({
prompt: "/desktop-send-skill write the docs",
@@ -1658,8 +1658,8 @@ async function handleSend(
const workspacePath =
readWorkspacePath(session?.config ?? request.config) ??
ctx.localWorkspaceRoot;
// Plugin slash commands (`api.registerCommand`) run here in the sidecar,
// as in the CLI: the handler's reply goes to the webview as a toast and
// Plugin slash commands execute in the session runtime.
// The handler's reply goes to the webview as a toast and
// only its `submitPrompt` (if any) reaches the model.
const commandName = prompt.match(/^\/(\S+)/)?.[1]?.toLowerCase();
if (commandName === "compact") {
@@ -1686,10 +1686,13 @@ async function handleSend(
return { sessionId, ok: true, commandHandled: true };
}
const pluginCommand =
commandName &&
!BUILTIN_SLASH_COMMAND_NAMES.has(commandName) &&
binding.kind !== "ssh"
? await runPluginSlashCommand(ctx, { workspacePath, prompt })
commandName && !BUILTIN_SLASH_COMMAND_NAMES.has(commandName)
? await runPluginSlashCommand(ctx, {
workspacePath,
prompt,
sessionId,
environmentId: binding.environmentId,
})
: undefined;
if (pluginCommand) {
if (pluginCommand.reply) {
@@ -1706,15 +1709,15 @@ async function handleSend(
// Dispatch the expanded or rewritten instructions, but keep the raw
// `/command` token as the session's display prompt.
const runtimePrompt =
binding.kind === "ssh"
pluginCommand?.submitPrompt ??
(binding.kind === "ssh"
? prompt
: (pluginCommand?.submitPrompt ??
(await resolveDesktopRuntimePrompt(
: await resolveDesktopRuntimePrompt(
ctx,
workspacePath,
prompt,
request.config?.mode ?? session?.config?.mode,
)));
));
let delivery = request.delivery;
if (!delivery && session?.busy) {
delivery = "queue";
@@ -19,7 +19,13 @@ describe("ensureHubWithDesktopCli", () => {
["hub", "ensure", "--json", "--cwd", "/work"],
{
cwd: "/work",
env: { PATH: "/usr/bin", CLINE_NO_AUTO_UPDATE: "1" },
env: {
PATH: "/usr/bin",
CLINE_NO_AUTO_UPDATE: "1",
CLINE_HUB_LAUNCHER_CLIENT: "cline-desktop",
CLINE_HUB_LAUNCHER_VERSION: expect.stringMatching(/^\d+\.\d+\.\d+/),
CLINE_HUB_LAUNCHER_TRANSPORT: "local",
},
timeoutMs: 60_000,
},
);
@@ -45,8 +51,11 @@ describe("adoptDesktopCliRuntime", () => {
};
const chdir = vi.fn();
const setLauncher = vi.fn();
const setPluginLauncher = vi.fn();
expect(adoptDesktopCliRuntime({ env, chdir, setLauncher })).toEqual({
expect(
adoptDesktopCliRuntime({ env, chdir, setLauncher, setPluginLauncher }),
).toEqual({
cliPath: "/Applications/Cline.app/Contents/MacOS/cline-cli",
});
@@ -56,6 +65,10 @@ describe("adoptDesktopCliRuntime", () => {
"/Applications/Cline.app/Contents/MacOS/cline-cli",
);
expect(chdir).toHaveBeenCalledWith("/Users/dev/project");
expect(setPluginLauncher).toHaveBeenCalledWith({
command: env.CLINE_DESKTOP_CLI_PATH,
argsPrefix: ["--plugin-sandbox"],
});
expect(setLauncher).toHaveBeenCalledWith({
command: "/Applications/Cline.app/Contents/MacOS/cline-cli",
});
@@ -64,11 +77,20 @@ describe("adoptDesktopCliRuntime", () => {
it("keeps the default Hub launcher when no bundled CLI is provided", () => {
const chdir = vi.fn();
const setLauncher = vi.fn();
const setPluginLauncher = vi.fn();
expect(adoptDesktopCliRuntime({ env: {}, chdir, setLauncher })).toEqual({
expect(
adoptDesktopCliRuntime({
env: {},
chdir,
setLauncher,
setPluginLauncher,
}),
).toEqual({
cliPath: undefined,
});
expect(chdir).not.toHaveBeenCalled();
expect(setLauncher).not.toHaveBeenCalled();
expect(setPluginLauncher).not.toHaveBeenCalled();
});
});
@@ -1,5 +1,7 @@
import { execFile } from "node:child_process";
import { setHubDaemonLauncher } from "@cline/core";
import { setHubDaemonLauncher, setPluginSandboxLauncher } from "@cline/core";
import { withHubLauncherIdentity } from "@cline/shared";
import { DESKTOP_CLIENT_CONTEXT } from "./client-context";
/** Absolute path of the Cline CLI binary the desktop shell bundles and launched us with. */
export const DESKTOP_CLI_PATH_ENV = "CLINE_DESKTOP_CLI_PATH";
@@ -25,6 +27,7 @@ export function adoptDesktopCliRuntime(
env?: NodeJS.ProcessEnv;
chdir?: (directory: string) => void;
setLauncher?: typeof setHubDaemonLauncher;
setPluginLauncher?: typeof setPluginSandboxLauncher;
} = {},
): { cliPath?: string } {
const env = options.env ?? process.env;
@@ -41,6 +44,10 @@ export function adoptDesktopCliRuntime(
const cliPath = resolveDesktopCliPath(env);
if (cliPath) {
setLauncher({ command: cliPath });
(options.setPluginLauncher ?? setPluginSandboxLauncher)({
command: cliPath,
argsPrefix: ["--plugin-sandbox"],
});
}
return { cliPath };
}
@@ -94,7 +101,16 @@ export async function ensureHubWithDesktopCli(
["hub", "ensure", "--json", "--cwd", workspaceRoot],
{
cwd: workspaceRoot,
env: { ...(options.env ?? process.env), CLINE_NO_AUTO_UPDATE: "1" },
// The CLI spawns the daemon, so stamp the desktop as its launcher
// here or the hub's telemetry would say the CLI started it.
env: withHubLauncherIdentity(
{ ...(options.env ?? process.env), CLINE_NO_AUTO_UPDATE: "1" },
{
client: DESKTOP_CLIENT_CONTEXT.name,
version: DESKTOP_CLIENT_CONTEXT.version,
transport: "local",
},
),
timeoutMs: HUB_ENSURE_TIMEOUT_MS,
},
);
@@ -1,4 +1,8 @@
import { HubCommandError, HubTransportError } from "@cline/core";
import {
HubCommandError,
HubTransportError,
type NodeHubClient,
} from "@cline/core";
import type { HubEventEnvelope } from "@cline/shared";
import { describe, expect, it, vi } from "vitest";
import {
@@ -55,8 +59,11 @@ async function createFixture() {
name: string,
_payload?: unknown,
_sessionId?: string,
_options?: unknown,
) => reply(name),
options?: Parameters<NodeHubClient["command"]>[3],
) => {
options?.beforeDispatch?.();
return reply(name);
},
);
const connection: Awaited<
ReturnType<CloudSessionManager["ensureConnection"]>
@@ -106,6 +113,51 @@ const transportError = () =>
new HubTransportError("hub_connection_closed", "socket closed");
describe("CloudSessionManager state", () => {
it("honors the dispatch hook before sending through the desktop adapter", async () => {
const { manager, command, reply, updateTitle, live } =
await createFixture();
const dispatch = vi.fn();
command.mockImplementation(async (name, _payload, _sessionId, options) => {
options?.beforeDispatch?.();
if (name === "session.send_input") dispatch();
return reply(name);
});
const beforeDispatch = vi.fn<() => void>(() => {
throw new Error("Could not save continuation");
});
await expect(
manager.send(
"ses-outer",
"Continue the task",
undefined,
undefined,
undefined,
{
beforeDispatch,
},
),
).rejects.toThrow("Could not save continuation");
expect(beforeDispatch).toHaveBeenCalledOnce();
expect(dispatch).not.toHaveBeenCalled();
expect(updateTitle).not.toHaveBeenCalled();
expect(live.messages).toEqual([]);
beforeDispatch.mockImplementation(() => {});
await manager.send(
"ses-outer",
"Continue the task",
undefined,
undefined,
undefined,
{
beforeDispatch,
},
);
expect(beforeDispatch).toHaveBeenCalledTimes(2);
expect(dispatch).toHaveBeenCalledOnce();
});
it("keeps a pending send cancelled when deletion clears its abort token", async () => {
const { manager, connection, ensureAttached, command } =
await createFixture();
@@ -1,6 +1,7 @@
import { ClineAccountService, ProviderSettingsManager } from "@cline/core";
import {
type CloudCreationOptions,
type CloudSendLifecycle,
CloudSessionApi,
CloudSessionController,
type CloudSessionControllerOptions,
@@ -178,6 +179,7 @@ export class CloudSessionManager extends CloudSessionController {
delivery?: "queue" | "steer",
modelId?: string,
images?: string[],
lifecycle?: CloudSendLifecycle,
) {
// Existing desktop commands update this local preference before lazy inner creation.
const config = this.ctx.liveSessions.get(id)?.config;
@@ -197,7 +199,7 @@ export class CloudSessionManager extends CloudSessionController {
: {}),
});
try {
return await super.send(id, prompt, delivery, modelId, images);
return await super.send(id, prompt, delivery, modelId, images, lifecycle);
} finally {
const snapshot = this.getSnapshot(id);
if (snapshot)
@@ -13,7 +13,12 @@ import {
getDesktopFeatureFlagsService,
resetDesktopFeatureFlagsForTesting,
} from "./feature-flags";
import type { SidecarContext } from "./types";
import { recordHubActivity } from "./hub-admin";
import {
LOCAL_ENVIRONMENT_ID,
type SessionRuntimeBinding,
type SidecarContext,
} from "./types";
function createContext(): {
ctx: SidecarContext;
@@ -46,6 +51,42 @@ afterEach(() => {
});
describe("desktop settings commands", () => {
it("only returns hub notification content to trusted desktop connections", async () => {
const { ctx } = createContext();
const command = vi
.fn()
.mockResolvedValue({ ok: true, payload: { clients: [] } });
ctx.runtimeBindings.set(LOCAL_ENVIRONMENT_ID, {
hubClient: { command, getUrl: () => "ws://127.0.0.1:1234/hub" },
} as unknown as SessionRuntimeBinding);
recordHubActivity(ctx, {
event: "ui.notify",
payload: { title: "Task completed", body: "Private task reply" },
});
await expect(handleCommand(ctx, "get_hub_status")).rejects.toThrow(
"hub status requires a trusted desktop connection",
);
await expect(
handleCommand(ctx, "get_hub_status", undefined, {
connection: { send: vi.fn(), data: { canApproveTools: false } },
}),
).rejects.toThrow("hub status requires a trusted desktop connection");
expect(command).not.toHaveBeenCalled();
const result = await handleCommand(ctx, "get_hub_status", undefined, {
connection: { send: vi.fn(), data: { canApproveTools: true } },
});
expect(command).toHaveBeenCalledWith("client.list");
expect(result).toMatchObject({
events: [{ title: "Task completed", detail: "Private task reply" }],
});
});
it("rejects hub restarts from untrusted connections", async () => {
const { ctx } = createContext();
await expect(handleCommand(ctx, "restart_hub")).rejects.toThrow(
"trusted desktop connection",
);
});
it("lists only current gateway transcription models for voice input", async () => {
const { ctx } = createContext();
vi.stubGlobal(
+22 -15
View File
@@ -132,6 +132,7 @@ import {
isCloudAgentsEnabled,
refreshDesktopFeatureFlags,
} from "./feature-flags";
import { getHubStatus, restartHub } from "./hub-admin";
import { clearLegacyProviderCredentials } from "./legacy-provider-credentials";
import {
installMarketplaceEntryForDesktopCommand,
@@ -161,10 +162,7 @@ import {
sessionLogPath,
sharedSessionDataDir,
} from "./paths";
import {
getPluginCommandService,
warmPluginCommandService,
} from "./plugin-commands";
import { listPluginCommands } from "./plugin-commands";
import { getPullRequestStatus } from "./pull-request";
import { capturePullRequestEvent } from "./pull-request-telemetry";
import { resolveDesktopRemoteHelper } from "./remote-helper";
@@ -2231,6 +2229,20 @@ export async function handleCommand(
return "";
}
if (command === "get_hub_status") {
if (!options?.connection?.data?.canApproveTools) {
throw new Error("hub status requires a trusted desktop connection");
}
return await getHubStatus(ctx, await ensureSharedHubClient(ctx));
}
if (command === "restart_hub") {
if (!options?.connection?.data?.canApproveTools) {
throw new Error("hub restart requires a trusted desktop connection");
}
await restartHub(ctx, await ensureSharedHubClient(ctx));
return await getHubStatus(ctx, await ensureSharedHubClient(ctx));
}
// ── Managed hub upgrade ───────────────────────────────────────────
if (command === "hub_upgrade") {
// Replacing the shared Hub interrupts other clients' sessions, so it
@@ -3542,22 +3554,17 @@ export async function handleCommand(
String(args?.workspacePath ?? "").trim() || ctx.localWorkspaceRoot,
);
}
if (command === "warm_plugin_commands") {
// The webview reports whichever local workspace it has adopted so the
// plugin sandbox is loaded before the slash menu first needs it.
const binding = getCommandRuntimeBinding(ctx, args);
const workspacePath = String(args?.workspacePath ?? "").trim();
if (binding.kind === "local" && workspacePath) {
warmPluginCommandService(ctx, workspacePath);
}
return { environmentId: binding.environmentId };
}
if (command === "list_plugin_commands") {
// Same workspace the session will execute in (handleSend), so the menu
// only offers commands that can actually run there.
const workspacePath =
String(args?.workspacePath ?? "").trim() || ctx.localWorkspaceRoot;
return await getPluginCommandService(ctx, workspacePath).listCommands();
return await listPluginCommands(
ctx,
workspacePath,
typeof args?.sessionId === "string" ? args.sessionId : undefined,
requestedEnvironmentId(args),
);
}
if (command === "list_marketplace_installed_entries") {
return listMarketplaceInstalledEntries(
@@ -242,6 +242,33 @@ describe("Code sidecar runtime capabilities", () => {
expect(connectMock).toHaveBeenCalledOnce();
});
it("records only local observer events, excluding cloud events projected through a local context", async () => {
const { createSidecarContext, ensureSharedHubClient, handleHubLiveEvent } =
await import("./context");
const { getHubStatus } = await import("./hub-admin");
const ctx = createSidecarContext("/workspace/project");
const client = await ensureSharedHubClient(ctx);
const observer = subscribeMock.mock.calls[0][0];
observer({
event: "hub.client.registered",
payload: { displayName: "Local client" },
});
// CloudSessionManager projects cloud hub events through this same handler
// with a local environment context; context identity is not event origin.
for (let i = 0; i < 105; i++) {
handleHubLiveEvent(ctx, {
event: "ui.notify",
payload: { title: "Cloud run", body: "Cloud content" },
});
}
const status = await getHubStatus(ctx, client);
expect(status.events).toHaveLength(1);
expect(status.events[0]).toMatchObject({
title: "Client connected",
detail: "Local client",
});
});
it("returns indexed search results without listing every session", async () => {
const { handleCommand } = await import("./commands");
const { createSidecarContext } = await import("./context");
@@ -30,6 +30,7 @@ import {
disposeDesktopFeatureFlagsService,
getDesktopFeatureFlagsService,
} from "./feature-flags";
import { recordHubActivity } from "./hub-admin";
import { sessionLogPath } from "./paths";
import type {
LiveSession,
@@ -1528,6 +1529,7 @@ export async function ensureSharedHubClient(
try {
await client.connect();
client.subscribe((event) => {
recordHubActivity(getSidecarContextOwner(ctx), event);
handleHubLiveEvent(
getEnvironmentContext(ctx, LOCAL_ENVIRONMENT_ID),
event,
@@ -0,0 +1,134 @@
import type { NodeHubClient } from "@cline/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SidecarContext } from "./types";
const mocks = vi.hoisted(() => ({
ensure: vi.fn(),
probe: vi.fn(),
shutdown: vi.fn(),
}));
vi.mock("@cline/core", () => ({
ensureDetachedHubServer: mocks.ensure,
probeHubServer: mocks.probe,
requestHubShutdown: mocks.shutdown,
}));
import { getHubStatus, recordHubActivity, restartHub } from "./hub-admin";
const ctx = { localWorkspaceRoot: "/workspace" } as SidecarContext;
const command = vi.fn();
const client = {
command,
getUrl: () => "ws://127.0.0.1:1234",
} as unknown as NodeHubClient;
beforeEach(() => {
vi.resetAllMocks();
command.mockResolvedValue({ ok: true, payload: { clients: [] } });
mocks.shutdown.mockResolvedValue(true);
mocks.probe
.mockResolvedValue(undefined)
.mockResolvedValueOnce({ hubId: "original" });
mocks.ensure.mockResolvedValue({ url: "ws://127.0.0.1:5678" });
});
afterEach(() => vi.useRealTimers());
describe("hub administration", () => {
it("bounds activity and excludes streamed content and client metadata", async () => {
const context = {} as SidecarContext;
for (let i = 0; i < 105; i++)
recordHubActivity(context, {
event: "hub.client.registered",
payload: { displayName: `Client ${i}` },
});
recordHubActivity(context, {
event: "message.delta",
payload: { text: "private content" },
});
command.mockResolvedValue({
ok: true,
payload: {
clients: [
{
clientId: "one",
clientType: "cli",
connectedAt: 1,
metadata: { secret: "hidden" },
},
],
},
});
const status = await getHubStatus(context, client);
expect(status.events).toHaveLength(100);
expect(status.events[0].detail).toBe("Client 104");
expect(status.clients[0]).not.toHaveProperty("metadata");
expect((await getHubStatus({} as SidecarContext, client)).events).toEqual(
[],
);
});
it("shares concurrent restarts and reconnects after starting the replacement", async () => {
const order: string[] = [];
mocks.shutdown.mockImplementation(async () => {
order.push("stop");
return true;
});
mocks.ensure.mockImplementation(async () => {
order.push("start");
});
command.mockImplementation(async () => {
order.push("reconnect");
return { ok: true };
});
await Promise.all([restartHub(ctx, client), restartHub(ctx, client)]);
expect(order).toEqual(["stop", "start", "reconnect"]);
expect(mocks.ensure).toHaveBeenCalledWith("/workspace");
});
it("does not spawn a replacement when shutdown is rejected, and allows retry", async () => {
mocks.shutdown.mockResolvedValueOnce(false);
await expect(restartHub(ctx, client)).rejects.toThrow("did not accept");
expect(mocks.ensure).not.toHaveBeenCalled();
mocks.probe.mockResolvedValueOnce({ hubId: "original" });
await expect(restartHub(ctx, client)).resolves.toBeUndefined();
});
it("accepts a replacement started by another client at the same address", async () => {
mocks.probe.mockResolvedValue({ hubId: "replacement" });
await expect(restartHub(ctx, client)).resolves.toBeUndefined();
expect(mocks.probe).toHaveBeenCalledTimes(2);
expect(mocks.shutdown).toHaveBeenCalledTimes(1);
expect(mocks.ensure).toHaveBeenCalledOnce();
expect(command).toHaveBeenCalledWith("client.list");
});
it("waits for the original hub to retire before ensuring a replacement", async () => {
vi.useFakeTimers();
mocks.probe.mockResolvedValueOnce({ hubId: "original" });
const pending = restartHub(ctx, client);
await vi.advanceTimersByTimeAsync(0);
expect(mocks.ensure).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(100);
await expect(pending).resolves.toBeUndefined();
expect(mocks.ensure).toHaveBeenCalledOnce();
});
it("times out if the original hub stays alive", async () => {
vi.useFakeTimers();
mocks.probe.mockResolvedValue({ hubId: "original" });
const pending = expect(restartHub(ctx, client)).rejects.toThrow(
"did not stop in time",
);
await vi.advanceTimersByTimeAsync(15_000);
await pending;
expect(mocks.ensure).not.toHaveBeenCalled();
});
it("does not stop a hub whose instance cannot be identified", async () => {
mocks.probe.mockReset().mockResolvedValue({});
await expect(restartHub(ctx, client)).rejects.toThrow("Unable to identify");
expect(mocks.shutdown).not.toHaveBeenCalled();
});
it("reports client-list errors", async () => {
command.mockResolvedValue({
ok: false,
error: { message: "Disconnected" },
});
await expect(getHubStatus(ctx, client)).rejects.toThrow("Disconnected");
});
});
@@ -0,0 +1,117 @@
import {
ensureDetachedHubServer,
type NodeHubClient,
probeHubServer,
requestHubShutdown,
} from "@cline/core";
import type { HubClientRecord } from "@cline/shared";
import type { HubActivity, HubStatus } from "../webview/lib/hub-status";
import type { SidecarContext } from "./types";
const activity = new WeakMap<SidecarContext, HubActivity[]>();
let nextId = 0;
let restartPending: Promise<void> | undefined;
export function recordHubActivity(
ctx: SidecarContext,
event: {
event: string;
sessionId?: string;
payload?: Record<string, unknown>;
},
): void {
const titles: Record<string, string> = {
"hub.client.registered": "Client connected",
"hub.client.disconnected": "Client disconnected",
"session.created": "Session started",
"session.detached": "Session detached",
"run.started": "Run started",
"run.completed": "Run completed",
"run.failed": "Run failed",
"run.aborted": "Run stopped",
"ui.notify": "Notification",
};
const title = titles[event.event];
if (!title) return;
const payload = event.payload ?? {};
const detail = [
payload.displayName,
payload.clientType,
payload.clientId,
event.sessionId,
].find((value) => typeof value === "string") as string | undefined;
const entry: HubActivity = {
id: ++nextId,
timestamp: Date.now(),
title:
event.event === "ui.notify" && typeof payload.title === "string"
? payload.title
: title,
detail:
event.event === "ui.notify" && typeof payload.body === "string"
? payload.body
: (detail ?? ""),
};
activity.set(ctx, [entry, ...(activity.get(ctx) ?? [])].slice(0, 100));
}
export async function getHubStatus(
ctx: SidecarContext,
client: NodeHubClient,
): Promise<HubStatus> {
const reply = await client.command("client.list");
if (!reply.ok)
throw new Error(reply.error?.message ?? "Unable to list hub clients");
const clients = (reply.payload?.clients ?? []) as HubClientRecord[];
return {
url: client.getUrl(),
clients: clients.map(
({ clientId, clientType, displayName, connectedAt }) => ({
clientId,
clientType,
displayName,
connectedAt,
}),
),
events: activity.get(ctx) ?? [],
};
}
export function restartHub(
ctx: SidecarContext,
client: NodeHubClient,
): Promise<void> {
if (restartPending) return restartPending;
restartPending = (async () => {
const url = client.getUrl();
const original = await probeHubServer(url, {
signal: AbortSignal.timeout(2000),
});
if (!original?.hubId)
throw new Error("Unable to identify the hub to restart. Try again.");
if (!(await requestHubShutdown(url)))
throw new Error("The hub did not accept the restart request.");
const deadline = Date.now() + 15_000;
while (true) {
const current = await probeHubServer(url, {
signal: AbortSignal.timeout(2000),
});
// Reconnecting clients may have already started a replacement here.
if (!current || current.hubId !== original.hubId) break;
if (Date.now() >= deadline)
throw new Error("The hub did not stop in time. Try again.");
await new Promise((resolve) => setTimeout(resolve, 100));
}
await ensureDetachedHubServer(ctx.localWorkspaceRoot);
// Existing desktop clients rediscover the daemon on their next command.
await client.command("client.list").then((reply) => {
if (!reply.ok)
throw new Error(
reply.error?.message ?? "Unable to reconnect to the hub",
);
});
})().finally(() => {
restartPending = undefined;
});
return restartPending;
}
@@ -26,7 +26,6 @@ import {
initializeSessionManager,
} from "./context";
import { createDesktopObservability } from "./observability";
import { configurePluginBootstrap } from "./plugin-bootstrap";
import { resolveWorkspaceRoot } from "./paths";
import { startServer } from "./server";
import { BunRuntime, SIDECAR_HOST, SIDECAR_MODE, SIDECAR_PORT } from "./types";
@@ -255,8 +254,6 @@ async function runEntrypoint(): Promise<void> {
setClineClientIdentity(DESKTOP_CLIENT_CONTEXT);
disableCurrentDirectoryExecutableSearch();
// Before the bundled CLI starts the Hub daemon and loads any plugin.
configurePluginBootstrap();
// Before the Hub daemon and agent-spawned processes inherit this env.
ensureLoopbackProxyBypass();
await main(cliPath);
@@ -1,7 +1,7 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { installPlugin } from "@cline/core";
import { installPlugin, SKILLS_CLI_PACKAGE } from "@cline/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
getOfficialPluginInstallPath,
@@ -229,7 +229,7 @@ describe("skill install failures", () => {
);
expect(spawnCommand).toHaveBeenCalledWith("npx", [
"-y",
"skills@latest",
SKILLS_CLI_PACKAGE,
"add",
"https://github.com/cline/skills.git",
"-g",
@@ -25,6 +25,7 @@ import {
parseMcpInstallArgs,
resolveSkillsConfigSearchPaths,
resolveWorkflowsConfigSearchPaths,
SKILLS_CLI_PACKAGE,
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
uninstallPlugin as uninstallLocalPlugin,
} from "@cline/core";
@@ -781,7 +782,7 @@ async function installSkill(
ensureGlobalSkillsDirWritable();
const result = await spawnCommand("npx", [
"-y",
"skills@latest",
SKILLS_CLI_PACKAGE,
"add",
...(entry.install.args ?? []),
"-g",
@@ -1,65 +0,0 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { CLINE_PLUGIN_BOOTSTRAP_PATH_ENV } from "@cline/core";
import { expect, it } from "vitest";
import {
configurePluginBootstrap,
resolveDesktopPluginBootstrap,
} from "./plugin-bootstrap";
function withLayout(
resourceDirectory: (root: string) => string,
execPath: (root: string) => string,
check: (bootstrap: string, execPath: string) => void,
): void {
const root = mkdtempSync(join(tmpdir(), "cline-plugin-bootstrap-"));
try {
const directory = join(resourceDirectory(root), "bin", "extensions");
mkdirSync(directory, { recursive: true });
const bootstrap = join(directory, "plugin-sandbox-bootstrap.js");
writeFileSync(bootstrap, "// bootstrap");
check(bootstrap, execPath(root));
} finally {
rmSync(root, { recursive: true, force: true });
}
}
it("finds the bootstrap in the macOS app bundle resources", () => {
withLayout(
(root) => join(root, "Cline.app", "Contents", "Resources"),
(root) => join(root, "Cline.app", "Contents", "MacOS", "cline-cli"),
(bootstrap, execPath) => {
expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe(
bootstrap,
);
},
);
});
it("finds the bootstrap in the Linux and Windows resource layouts", () => {
withLayout(
(root) => join(root, "usr", "lib", "Cline"),
(root) => join(root, "usr", "bin", "cline-cli"),
(bootstrap, execPath) => {
expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe(
bootstrap,
);
},
);
withLayout(
(root) => root,
(root) => join(root, "cline-cli.exe"),
(bootstrap, execPath) => {
expect(resolveDesktopPluginBootstrap({ execPath, cwd: tmpdir() })).toBe(
bootstrap,
);
},
);
});
it("keeps an explicitly configured bootstrap path", () => {
const env = { [CLINE_PLUGIN_BOOTSTRAP_PATH_ENV]: "/custom/bootstrap.js" };
configurePluginBootstrap(env);
expect(env[CLINE_PLUGIN_BOOTSTRAP_PATH_ENV]).toBe("/custom/bootstrap.js");
});
@@ -1,31 +0,0 @@
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { CLINE_PLUGIN_BOOTSTRAP_PATH_ENV } from "@cline/core";
import { linuxResourceCandidates } from "./remote-helper";
const BUNDLED_BOOTSTRAP = join("bin", "extensions", "plugin-sandbox-bootstrap.js");
/**
* Finds the plugin worker bootstrap the app bundle ships as a Tauri resource.
* The backend and CLI-managed Hub need an on-disk bootstrap to run plugins
* in workers; without it they would fall back to loading plugins in-process.
*/
export function resolveDesktopPluginBootstrap(
options: { execPath?: string; cwd?: string } = {},
): string | undefined {
const executableDirectory = dirname(options.execPath ?? process.execPath);
const cwd = options.cwd ?? process.cwd();
return [
join(executableDirectory, BUNDLED_BOOTSTRAP),
join(executableDirectory, "..", "Resources", BUNDLED_BOOTSTRAP),
...linuxResourceCandidates(executableDirectory, BUNDLED_BOOTSTRAP),
join(cwd, "src-tauri", BUNDLED_BOOTSTRAP),
].find(existsSync);
}
/** Exposes the bundled bootstrap to core and to the Hub daemon this spawns. */
export function configurePluginBootstrap(env = process.env): void {
if (env[CLINE_PLUGIN_BOOTSTRAP_PATH_ENV]) return;
const bootstrap = resolveDesktopPluginBootstrap();
if (bootstrap) env[CLINE_PLUGIN_BOOTSTRAP_PATH_ENV] = bootstrap;
}
@@ -1,178 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const { listCommandsMock, runMock, createPluginCommandServiceMock } =
vi.hoisted(() => {
const listCommandsMock = vi.fn(
async (): Promise<Array<{ name: string; description?: string }>> => [],
);
const runMock = vi.fn(
async (
_name: string,
_input: string,
): Promise<{ reply?: string; submitPrompt?: string } | undefined> =>
undefined,
);
return {
listCommandsMock,
runMock,
createPluginCommandServiceMock: vi.fn(() => ({
listCommands: listCommandsMock,
run: runMock,
shutdown: vi.fn(),
})),
};
});
vi.mock("@cline/core", async () => {
const actual =
await vi.importActual<typeof import("@cline/core")>("@cline/core");
return {
...actual,
createPluginCommandService: createPluginCommandServiceMock,
};
});
import { handleCommand } from "./commands";
import {
resetPluginCommandServicesForTests,
runPluginSlashCommand,
WARMUP_MAX_ATTEMPTS,
WARMUP_RETRY_DELAY_MS,
} from "./plugin-commands";
import type { SidecarContext } from "./types";
const WORKSPACE = "/projects/app";
function createContext(kind: "local" | "ssh" = "local"): SidecarContext {
return {
activeEnvironmentId: "env",
localWorkspaceRoot: "/local/launch-root",
runtimeBindings: new Map([
[
"env",
{
kind,
environmentId: "env",
workspaceRoot: "/local/launch-root",
},
],
]),
} as unknown as SidecarContext;
}
async function flushPromises(): Promise<void> {
for (let i = 0; i < 5; i += 1) {
await Promise.resolve();
}
}
describe("plugin command warmup", () => {
beforeEach(() => {
vi.useFakeTimers();
resetPluginCommandServicesForTests();
listCommandsMock.mockReset();
listCommandsMock.mockResolvedValue([]);
runMock.mockReset();
createPluginCommandServiceMock.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it("loads the workspace's plugin commands once ahead of the slash menu", async () => {
listCommandsMock.mockResolvedValue([{ name: "goal" }]);
const ctx = createContext();
await expect(
handleCommand(ctx, "warm_plugin_commands", { workspacePath: WORKSPACE }),
).resolves.toEqual({ environmentId: "env" });
await flushPromises();
// The service is keyed on the reported workspace, not the launch root.
expect(createPluginCommandServiceMock).toHaveBeenCalledTimes(1);
expect(createPluginCommandServiceMock).toHaveBeenCalledWith(
expect.objectContaining({ cwd: WORKSPACE, workspacePath: WORKSPACE }),
);
expect(listCommandsMock).toHaveBeenCalledTimes(1);
// Re-reporting the same workspace does not reload a warmed host.
await handleCommand(ctx, "warm_plugin_commands", {
workspacePath: WORKSPACE,
});
await flushPromises();
expect(listCommandsMock).toHaveBeenCalledTimes(1);
// The slash menu and the command runner reuse the warmed service.
await expect(
handleCommand(ctx, "list_plugin_commands", { workspacePath: WORKSPACE }),
).resolves.toEqual([{ name: "goal" }]);
expect(createPluginCommandServiceMock).toHaveBeenCalledTimes(1);
});
it("recovers from a failed cold load and makes the command available", async () => {
// First load times out inside the service (which reports it as an
// empty command set), the retry succeeds.
listCommandsMock
.mockResolvedValueOnce([])
.mockResolvedValue([{ name: "goal", description: "Set a goal" }]);
runMock.mockResolvedValue({ reply: "Goal set: ship it" });
const ctx = createContext();
await handleCommand(ctx, "warm_plugin_commands", {
workspacePath: WORKSPACE,
});
await flushPromises();
expect(listCommandsMock).toHaveBeenCalledTimes(1);
// Re-reporting the workspace while the retry is pending does not stack
// extra loads.
await handleCommand(ctx, "warm_plugin_commands", {
workspacePath: WORKSPACE,
});
await flushPromises();
expect(listCommandsMock).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(WARMUP_RETRY_DELAY_MS);
await flushPromises();
expect(listCommandsMock).toHaveBeenCalledTimes(2);
// Discovery and execution now work without the user reopening the menu.
await expect(
handleCommand(ctx, "list_plugin_commands", { workspacePath: WORKSPACE }),
).resolves.toEqual([{ name: "goal", description: "Set a goal" }]);
await expect(
runPluginSlashCommand(ctx, {
workspacePath: WORKSPACE,
prompt: "/goal ship it",
}),
).resolves.toEqual({ reply: "Goal set: ship it" });
expect(runMock).toHaveBeenCalledWith("goal", " ship it");
// Once warmed, no further retries are scheduled.
await vi.advanceTimersByTimeAsync(WARMUP_RETRY_DELAY_MS * 2);
expect(listCommandsMock).toHaveBeenCalledTimes(3);
});
it("stops retrying after a bounded number of attempts", async () => {
listCommandsMock.mockRejectedValue(new Error("sandbox boom"));
const ctx = createContext();
await handleCommand(ctx, "warm_plugin_commands", {
workspacePath: WORKSPACE,
});
await flushPromises();
for (let i = 0; i < WARMUP_MAX_ATTEMPTS + 2; i += 1) {
await vi.advanceTimersByTimeAsync(WARMUP_RETRY_DELAY_MS);
await flushPromises();
}
expect(listCommandsMock).toHaveBeenCalledTimes(WARMUP_MAX_ATTEMPTS);
});
it("does not spawn a sandbox for remote workspaces", async () => {
const ctx = createContext("ssh");
await handleCommand(ctx, "warm_plugin_commands", {
workspacePath: "/srv/code",
});
await flushPromises();
expect(createPluginCommandServiceMock).not.toHaveBeenCalled();
});
});
@@ -1,120 +1,80 @@
import {
createPluginCommandService,
createHubPluginCommandsApi,
type PluginCommandResult,
type PluginCommandService,
parsePluginCommand,
} from "@cline/core";
import { getSessionRuntimeBinding } from "./context";
import type { SidecarContext } from "./types";
// One service per workspace; it keeps the plugin sandboxes alive between
// commands and reloads them itself when the plugin set changes.
const servicesByWorkspace = new Map<string, PluginCommandService>();
/**
* The runtime rejects a session command whose workspace differs from the
* session's `workspaceRoot ?? cwd`, so target the live session the same way.
*/
function sessionWorkspacePath(
ctx: SidecarContext,
sessionId: string | undefined,
fallback: string,
): string {
const config = sessionId
? ctx.liveSessions.get(sessionId)?.config
: undefined;
for (const value of [config?.workspaceRoot, config?.cwd]) {
if (typeof value === "string" && value.trim()) return value.trim();
}
return fallback;
}
export function getPluginCommandService(
export async function listPluginCommands(
ctx: SidecarContext,
workspacePath: string,
): PluginCommandService {
let service = servicesByWorkspace.get(workspacePath);
if (!service) {
service = createPluginCommandService({
cwd: workspacePath,
workspacePath,
logger: ctx.logger,
});
servicesByWorkspace.set(workspacePath, service);
}
return service;
sessionId?: string,
environmentId?: string,
) {
const client = getSessionRuntimeBinding(
ctx,
sessionId,
environmentId,
).hubClient;
return createHubPluginCommandsApi(client).list({
workspacePath: sessionWorkspacePath(ctx, sessionId, workspacePath),
sessionId,
});
}
// A failed load (sandbox startup timeout, I/O hiccup) yields an empty command
// list and is cached by the service for 30s, so space retries past that
// window. Legitimately empty results make these retries a cheap no-op since
// the service keeps its loaded host.
export const WARMUP_RETRY_DELAY_MS = 35_000;
export const WARMUP_MAX_ATTEMPTS = 4;
type WarmupState = {
attempts: number;
done: boolean;
inFlight: boolean;
retry?: ReturnType<typeof setTimeout>;
};
const warmups = new Map<string, WarmupState>();
/**
* Spawn the plugin sandbox for a workspace ahead of the first slash menu
* open (the CLI does the same at TUI mount), so the menu lists plugin
* commands immediately instead of paying the cold spawn on the first `/`.
* The webview calls this for whichever workspace it has adopted. A load that
* comes back empty is retried in the background a few times so a slow cold
* start recovers without the user reopening the menu.
*/
export function warmPluginCommandService(
ctx: SidecarContext,
workspacePath: string,
): void {
let state = warmups.get(workspacePath);
if (!state) {
state = { attempts: 0, done: false, inFlight: false };
warmups.set(workspacePath, state);
}
if (
state.done ||
state.inFlight ||
state.retry ||
state.attempts >= WARMUP_MAX_ATTEMPTS
) {
return;
}
state.attempts += 1;
state.inFlight = true;
void getPluginCommandService(ctx, workspacePath)
.listCommands()
.then(
(commands) => commands.length > 0,
(error) => {
ctx.logger?.debug?.("plugin command warmup failed", { error });
return false;
},
)
.then((loaded) => {
state.inFlight = false;
if (loaded) {
state.done = true;
return;
}
if (state.attempts >= WARMUP_MAX_ATTEMPTS) {
return;
}
state.retry = setTimeout(() => {
state.retry = undefined;
warmPluginCommandService(ctx, workspacePath);
}, WARMUP_RETRY_DELAY_MS);
state.retry.unref?.();
});
}
/** Test hook: forget warm-up progress and cached services. */
export function resetPluginCommandServicesForTests(): void {
for (const state of warmups.values()) {
clearTimeout(state.retry);
}
warmups.clear();
servicesByWorkspace.clear();
}
/**
* Run a plugin-registered slash command for the leading `/name` token of a
* prompt. Returns undefined when no enabled plugin declares the command so
* the prompt continues through the normal path.
*/
export async function runPluginSlashCommand(
ctx: SidecarContext,
input: { workspacePath: string; prompt: string },
input: {
workspacePath: string;
prompt: string;
sessionId: string;
environmentId?: string;
},
): Promise<PluginCommandResult | undefined> {
const match = input.prompt.match(/^\/(\S+)([\s\S]*)$/);
if (!match?.[1]) return undefined;
return await getPluginCommandService(ctx, input.workspacePath).run(
match[1],
match[2] ?? "",
const commands = createHubPluginCommandsApi(
getSessionRuntimeBinding(ctx, input.sessionId, input.environmentId)
.hubClient,
);
const target = {
workspacePath: sessionWorkspacePath(
ctx,
input.sessionId,
input.workspacePath,
),
sessionId: input.sessionId,
};
// A failed catalog read must not block skills and workflows sharing the
// slash syntax. Only a listed plugin command may fail the send.
try {
const name = parsePluginCommand(input.prompt)?.name;
const catalog = await commands.list(target);
if (!catalog.commands.some((command) => command.name === name))
return undefined;
} catch (error) {
ctx.logger?.log("Plugin command discovery failed", {
error,
severity: "warn",
});
return undefined;
}
return commands.run({ ...target, prompt: input.prompt });
}
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Cline",
"version": "0.0.47",
"version": "0.0.48",
"identifier": "bot.cline.app",
"build": {
"beforeDevCommand": "bun run build:runtime && bun run dev:web",
@@ -46,8 +46,7 @@
"icons/app/*.png",
"icons/app/macos/*.png",
"bin/remote-helpers/*",
"bin/desktop-backend/*",
"bin/extensions/**/*"
"bin/desktop-backend/**/*"
],
"icon": [
"icons/32x32.png",
+1 -16
View File
@@ -1756,22 +1756,6 @@ function ChatThreadPane({
[environmentId, onPickRemoteWorkspaceDirectory, remoteEnvironment],
);
// Let the sidecar load the workspace's plugin sandbox now so the slash
// menu lists plugin commands without paying the cold spawn on first open.
useEffect(() => {
if (!activeWorkspaceCwd) {
return;
}
void desktopClient
.invoke("warm_plugin_commands", {
workspacePath: activeWorkspaceCwd,
environmentId,
})
.catch(() => {
// Best effort; the menu falls back to loading on open.
});
}, [activeWorkspaceCwd, environmentId]);
useEffect(() => {
void refreshGitBranch();
if (!activeWorkspaceCwd) {
@@ -2521,6 +2505,7 @@ function ChatThreadPane({
const composer = (
<ChatInputBar
sessionId={sessionId}
readOnly={isCloudSessionExpired}
attachments={attachmentList}
environmentId={environmentId}
@@ -10,6 +10,7 @@ import {
ArrowLeft,
ArrowRight,
Blocks,
Bug,
ChevronDown,
CircleUserRound,
Clock3,
@@ -160,6 +161,7 @@ const SETTINGS_SECTION_ICONS = {
Import: Import,
Remote: Network,
Account: CircleUserRound,
Debug: Bug,
About: Info,
Customize: Blocks,
Marketplace: Store,
@@ -7,6 +7,7 @@ import { WorkspaceProvider } from "@/contexts/workspace-context";
import { getInitialChatConfig } from "@/hooks/chat-session/constants";
import { usePromptDraft } from "@/hooks/use-prompt-draft";
import type { ChatSessionStatus } from "@/lib/chat-schema";
import { desktopClient } from "@/lib/desktop-client";
import {
MODEL_SELECTION_STORAGE_KEY,
parseModelSelectionStorage,
@@ -198,6 +199,7 @@ async function renderVoiceComposer({
readOnly = false,
executionTarget,
onAttachFiles = vi.fn(),
sessionId,
}: {
attachments?: Parameters<typeof ChatInputBar>[0]["attachments"];
model?: string;
@@ -211,13 +213,17 @@ async function renderVoiceComposer({
readOnly?: boolean;
executionTarget?: "cloud" | "local";
onAttachFiles?: Parameters<typeof ChatInputBar>[0]["onAttachFiles"];
sessionId?: string;
} = {}) {
await act(async () => {
root.render(
<WorkspaceProvider value={workspaceValue}>
<ChatInputBar
environmentId="local"
workspaceRoot="/workspace/cline"
readOnly={readOnly}
executionTarget={executionTarget}
sessionId={sessionId}
attachments={attachments}
gitBranch="main"
hasRunningAgents={hasRunningAgents}
@@ -376,6 +382,162 @@ describe("ChatInputBar draft navigation", () => {
});
describe("ChatInputBar", () => {
it("retains skills and workflows when plugin discovery rejects", async () => {
vi.spyOn(desktopClient, "invoke").mockImplementation(async (name) => {
if (name === "list_plugin_commands") throw new Error("hub unavailable");
return {
runtimeCommands: [
{
name: "publish-skill",
kind: "skill",
description: "Publish skill",
},
{
name: "publish-workflow",
kind: "workflow",
description: "Publish workflow",
},
],
};
});
await renderVoiceComposer({ prompt: "/publish", executionTarget: "local" });
expect(container.textContent).toContain("Publish skill");
expect(container.textContent).toContain("Publish workflow");
});
it("re-reads plugin commands when the slash menu reopens after a failure", async () => {
let available = false;
vi.spyOn(desktopClient, "invoke").mockImplementation(async (name) => {
if (name !== "list_plugin_commands") return { runtimeCommands: [] };
if (!available) throw new Error("hub connection lost");
return {
workspacePath: "/workspace/cline",
status: "ready",
commands: [{ name: "restored", description: "Recovered command" }],
};
});
await renderVoiceComposer({
prompt: "/rest",
promptVersion: 1,
executionTarget: "local",
});
expect(container.textContent).toContain("Plugin commands unavailable");
available = true;
await renderVoiceComposer({
prompt: "ordinary text",
promptVersion: 2,
executionTarget: "local",
});
await renderVoiceComposer({
prompt: "/rest",
promptVersion: 3,
executionTarget: "local",
});
expect(container.textContent).toContain("Recovered command");
expect(container.textContent).not.toContain("Plugin commands unavailable");
});
it("does not show another session's plugin commands", async () => {
vi.spyOn(desktopClient, "invoke").mockImplementation(
async (name, payload) => {
if (name !== "list_plugin_commands") return {};
if ((payload as { sessionId?: string }).sessionId === "session-b")
throw new Error("hub connection closed");
return {
status: "ready",
commands: [{ name: "publish-a", description: "Session A plugin" }],
};
},
);
await renderVoiceComposer({
prompt: "/publish",
promptVersion: 1,
executionTarget: "local",
sessionId: "session-a",
});
expect(container.textContent).toContain("Session A plugin");
await renderVoiceComposer({
prompt: "/publish",
promptVersion: 1,
executionTarget: "local",
sessionId: "session-b",
});
expect(container.textContent).not.toContain("Session A plugin");
});
it("refreshes commands on reopen without resetting cached suggestions", async () => {
const updated = deferred<{
runtimeCommands: Array<{
name: string;
kind: "workflow";
description: string;
}>;
}>();
let instructionRequests = 0;
const invoke = vi
.spyOn(desktopClient, "invoke")
.mockImplementation(async (name) => {
if (name === "list_plugin_commands")
return {
status: "ready",
commands: [
{ name: "publish-plugin", description: "Plugin suggestion" },
],
};
if (name === "list_user_instruction_configs") {
instructionRequests++;
return instructionRequests === 1
? {
runtimeCommands: [
{
name: "publish-old",
kind: "skill",
description: "Old skill",
},
],
}
: updated.promise;
}
return {};
});
await renderVoiceComposer({
prompt: "/publish",
promptVersion: 1,
executionTarget: "local",
});
expect(container.textContent).toContain("Old skill");
await renderVoiceComposer({
prompt: "ordinary text",
promptVersion: 2,
executionTarget: "local",
});
await renderVoiceComposer({
prompt: "/publish",
promptVersion: 3,
executionTarget: "local",
});
expect(container.textContent).toContain("Old skill");
expect(container.textContent).toContain("Plugin suggestion");
expect(instructionRequests).toBe(2);
expect(
invoke.mock.calls.filter(([name]) => name === "list_plugin_commands"),
).toHaveLength(2);
await act(async () =>
updated.resolve({
runtimeCommands: [
{
name: "publish-new",
kind: "workflow",
description: "New workflow",
},
],
}),
);
expect(container.textContent).not.toContain("Old skill");
expect(container.textContent).toContain("New workflow");
expect(container.textContent).toContain("Plugin suggestion");
});
it("prevents sending from a read-only session", async () => {
const onSend = vi.fn();
await renderVoiceComposer({ prompt: "Test", readOnly: true, onSend });
@@ -634,7 +796,7 @@ describe("ChatInputBar", () => {
]);
});
it("appends plugin commands after skills and workflows", () => {
it("gives plugin commands the same precedence as execution", () => {
expect(
buildUserInstructionSlashCommands(
{
@@ -647,7 +809,7 @@ describe("ChatInputBar", () => {
],
),
).toEqual([
{ name: "goal", description: "Skill command" },
{ name: "goal", description: "Set or clear a goal" },
{ name: "goal-status", description: "Plugin command" },
]);
});
@@ -1,5 +1,7 @@
"use client";
import type { PluginCommandCatalog } from "@cline/core";
import {
CLINE_DEFAULT_MODEL_ID,
formatDisplayUserInput,
@@ -120,11 +122,6 @@ const BUILTIN_SLASH_COMMANDS: SlashCommand[] = [
{ name: "team", description: "Start the task with an agent team" },
];
// Last known user commands, kept across composer instances so reopening the
// slash menu paints instantly (stale-while-revalidate); the fetch that
// follows still picks up newly installed skills and workflows.
let cachedSlashCommands: SlashCommand[] | null = null;
export function buildUserInstructionSlashCommands(
response: UserInstructionConfigResponse,
// Plugin commands (`api.registerCommand`) as listed by the sidecar's
@@ -135,21 +132,7 @@ export function buildUserInstructionSlashCommands(
? response.runtimeCommands
: [];
const seen = new Set(BUILTIN_SLASH_COMMANDS.map((command) => command.name));
const result = commands.flatMap((command) => {
const name = command.name;
if (!name || seen.has(name)) {
return [];
}
seen.add(name);
return [
{
name,
description:
command.description?.trim() ||
`${command.kind === "skill" ? "Skill" : "Workflow"} command`,
},
];
});
const result: SlashCommand[] = [];
for (const command of pluginCommands) {
if (!command.name || seen.has(command.name)) continue;
seen.add(command.name);
@@ -158,6 +141,16 @@ export function buildUserInstructionSlashCommands(
description: command.description?.trim() || "Plugin command",
});
}
for (const command of commands) {
if (!command.name || seen.has(command.name)) continue;
seen.add(command.name);
result.push({
name: command.name,
description:
command.description?.trim() ||
`${command.kind === "skill" ? "Skill" : "Workflow"} command`,
});
}
return result;
}
@@ -305,6 +298,7 @@ export function buildWorkspaceFileSearchKey(
}
type ChatInputBarProps = {
sessionId?: string | null;
environmentId: string;
variant?: "conversation" | "welcome";
readOnly?: boolean;
@@ -355,6 +349,7 @@ type ChatInputBarProps = {
};
function ChatInputBarImpl({
sessionId,
environmentId,
variant = "conversation",
readOnly = false,
@@ -655,10 +650,50 @@ function ChatInputBarImpl({
executionTarget === "local" &&
slashKey !== null &&
dismissedSlashKey !== slashKey;
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>(
() => cachedSlashCommands ?? BUILTIN_SLASH_COMMANDS,
);
const [slashLoading, setSlashLoading] = useState(false);
const [instructionResponse, setInstructionResponse] = useState<{
workspaceRoot: string;
environmentId: string;
response: UserInstructionConfigResponse;
}>();
const [pluginResponse, setPluginResponse] = useState<{
workspaceRoot: string;
environmentId: string;
sessionId?: string;
commands: SlashCommand[];
}>();
const slashCommands = useMemo(() => {
const current = <
T extends { workspaceRoot: string; environmentId: string },
>(
target: T | undefined,
) =>
target?.workspaceRoot === workspaceRoot &&
target.environmentId === environmentId
? target
: undefined;
return [
...BUILTIN_SLASH_COMMANDS,
...buildUserInstructionSlashCommands(
current(instructionResponse)?.response ?? {},
// A session's plugins can differ from the workspace catalog.
pluginResponse?.sessionId === (sessionId ?? undefined)
? (current(pluginResponse)?.commands ?? [])
: [],
),
];
}, [
instructionResponse,
pluginResponse,
workspaceRoot,
environmentId,
sessionId,
]);
const [pluginLoading, setPluginLoading] = useState(false);
const [instructionLoading, setInstructionLoading] = useState(false);
const [pluginError, setPluginError] = useState<string>();
const [instructionError, setInstructionError] = useState<string>();
const slashLoading = pluginLoading || instructionLoading;
const slashError = pluginError ?? instructionError;
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
useEffect(() => {
@@ -1012,45 +1047,64 @@ function ChatInputBarImpl({
?.scrollIntoView({ block: "nearest" });
}, [mentionOpen, mentionSelectedIndex]);
// Reload user commands whenever the slash menu opens so newly installed or
// edited skills and workflows are reflected without remounting the chat UI.
// Refresh user and plugin commands whenever the slash menu opens, keeping
// the last results while fetching so suggestions do not flash away. Each
// request settles independently so one failure cannot hide the other.
useEffect(() => {
if (!slashOpen) {
setInstructionLoading(false);
setPluginLoading(false);
return;
}
let cancelled = false;
// Only show the loading row when there is nothing cached to show.
setSlashLoading(cachedSlashCommands === null);
Promise.all([
desktopClient.invoke<UserInstructionConfigResponse>(
"list_user_instruction_configs",
{ workspacePath: workspaceRoot },
),
desktopClient
.invoke<SlashCommand[]>("list_plugin_commands", {
workspacePath: workspaceRoot,
})
.catch((): SlashCommand[] => []),
])
.then(([response, pluginCommands]) => {
setInstructionLoading(true);
setPluginLoading(true);
desktopClient
.invoke<UserInstructionConfigResponse>("list_user_instruction_configs", {
workspacePath: workspaceRoot,
environmentId,
})
.then((response) => {
if (cancelled) return;
const next = [
...BUILTIN_SLASH_COMMANDS,
...buildUserInstructionSlashCommands(response, pluginCommands),
];
cachedSlashCommands = next;
setSlashCommands(next);
setInstructionResponse({ workspaceRoot, environmentId, response });
setInstructionError(undefined);
})
.catch(() => {
// Keep built-in commands on error.
if (!cancelled) setInstructionError("Instruction commands unavailable");
})
.finally(() => {
if (!cancelled) setSlashLoading(false);
if (!cancelled) setInstructionLoading(false);
});
desktopClient
.invoke<PluginCommandCatalog>("list_plugin_commands", {
workspacePath: workspaceRoot,
environmentId,
sessionId: sessionId ?? undefined,
})
.then((catalog) => {
if (cancelled) return;
setPluginResponse({
workspaceRoot,
environmentId,
sessionId: sessionId ?? undefined,
commands: catalog.commands,
});
setPluginError(
catalog.status === "error"
? "Plugin commands unavailable"
: undefined,
);
})
.catch(() => {
if (!cancelled) setPluginError("Plugin commands unavailable");
})
.finally(() => {
if (!cancelled) setPluginLoading(false);
});
return () => {
cancelled = true;
};
}, [slashOpen, workspaceRoot]);
}, [slashOpen, workspaceRoot, environmentId, sessionId]);
// Filtered slash commands based on the current query.
const filteredSlashCommands = useMemo(() => {
@@ -1128,7 +1182,7 @@ function ChatInputBarImpl({
<div className="px-3 py-2 text-sm text-muted-foreground">
{slashLoading
? "Loading commands..."
: "No matching commands"}
: (slashError ?? "No matching commands")}
</div>
) : (
<>
@@ -1,6 +1,6 @@
"use client";
import { Bug, ExternalLink, Loader2, RefreshCw, Sparkles } from "lucide-react";
import { Bug, ExternalLink, Loader2, Rss, Sparkles } from "lucide-react";
import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -49,7 +49,7 @@ function useAppVersion(): string | null {
return appVersion;
}
function UpdateRow() {
function UpdateAction() {
const status = useAppUpdateStatus();
const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState<
@@ -67,17 +67,13 @@ function UpdateRow() {
: status.state === "error" && status.error
? `The last check failed: ${status.error}`
: checkResult === "up-to-date"
? "You're up to date. Cline also checks on its own shortly after launch and every two hours."
? "You're up to date."
: checkResult === "unavailable"
? "Update checks are only available in the desktop app."
: "Cline checks for updates shortly after launch and every two hours, and installs them when it restarts.";
: null;
return (
<div className="flex items-center justify-between gap-5 border-b py-4 max-[720px]:flex-col max-[720px]:items-stretch">
<div className="flex flex-col gap-1">
<p className="text-base font-semibold text-foreground">Updates</p>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
<div className="flex max-w-sm flex-col items-end gap-2">
{status.state === "ready" ? (
<Button
className="shrink-0"
@@ -112,11 +108,16 @@ function UpdateRow() {
{busy ? (
<Loader2 className="size-3 animate-spin" />
) : (
<RefreshCw className="size-3" />
<Rss className="size-3" />
)}
Check for updates
</Button>
)}
{description ? (
<output className="text-right text-xs text-muted-foreground">
{description}
</output>
) : null}
</div>
);
}
@@ -202,6 +203,7 @@ export function AboutContent({
return (
<PageFrame>
<PageHeader
actions={<UpdateAction />}
meta={
<span className="flex items-center gap-2">
{appVersion ? (
@@ -219,7 +221,6 @@ export function AboutContent({
title={productNameForVersion(appVersion)}
/>
<section className="max-w-344">
<UpdateRow />
{whatsNew ? (
<div className="flex items-center justify-between gap-5 border-b py-4 max-[720px]:flex-col max-[720px]:items-stretch">
<div className="flex flex-col gap-1">
@@ -0,0 +1,41 @@
import { Download } from "lucide-react";
import { Button } from "@/components/ui/button";
import { PageFrame, PageHeader } from "../page-layout";
export function DebugContent({
onExportDiagnostics,
}: {
onExportDiagnostics: () => void;
}) {
return (
<PageFrame>
<PageHeader
title="Debug"
description="Tools for troubleshooting Cline."
/>
<section className="max-w-344">
<div className="flex py-4 items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div className="flex flex-col gap-1">
<p className="text-base font-semibold text-foreground">
Diagnostics
</p>
<p className="text-sm text-muted-foreground">
Export app info, recent logs, and the metadata of sessions you
choose as a file you can attach when reporting a problem.
</p>
</div>
<Button
className="w-24 shrink-0"
onClick={onExportDiagnostics}
size="sm"
type="button"
variant="outline"
>
<Download className="size-3" />
Export…
</Button>
</div>
</section>
</PageFrame>
);
}
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
import { HubPanel } from "./hub-panel";
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
vi.mock("@/lib/desktop-client", () => ({ desktopClient: { invoke } }));
let root: Root;
let container: HTMLDivElement;
const status = {
url: "ws://127.0.0.1:1234",
clients: [
{
clientId: "cli-1",
clientType: "cli",
displayName: "Terminal",
connectedAt: 1,
},
],
events: [
{ id: 1, timestamp: 1, title: "Client connected", detail: "Terminal" },
],
};
beforeEach(() => {
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
invoke.mockReset().mockResolvedValue(status);
container = document.createElement("div");
document.body.append(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.useRealTimers();
});
async function click(label: string) {
const button = [...document.querySelectorAll("button")].find(
(button) => button.textContent === label,
);
if (!button) throw new Error(`Missing button: ${label}`);
await act(async () => button.click());
}
it("shows clients and events, and refreshes while mounted", async () => {
vi.useFakeTimers();
await act(async () => root.render(<HubPanel />));
expect(container.querySelector("h3")?.textContent).toContain(
"Connected clients",
);
expect(container.querySelector('h3 [data-slot="badge"]')?.textContent).toBe(
"1",
);
expect(container.textContent).toContain("Client connected");
invoke.mockResolvedValue({ ...status, clients: [] });
await act(async () => vi.advanceTimersByTimeAsync(2000));
expect(container.textContent).toContain("No connected clients.");
});
it("requires confirmation before restarting and reports restart failures", async () => {
await act(async () => root.render(<HubPanel />));
await click("Restart Hub");
expect(invoke).not.toHaveBeenCalledWith("restart_hub");
expect(document.body.textContent).toContain("active sessions");
await click("Cancel");
expect(invoke).not.toHaveBeenCalledWith("restart_hub");
await click("Restart Hub");
invoke.mockRejectedValueOnce(new Error("Shutdown refused"));
const dialog = document.querySelector('[role="alertdialog"]');
if (!dialog) throw new Error("Missing restart confirmation");
const confirm = [...dialog.querySelectorAll("button")].find(
(button) => button.textContent === "Restart Hub",
);
if (!confirm) throw new Error("Missing restart button");
await act(async () => confirm.click());
expect(invoke).toHaveBeenCalledWith("restart_hub");
expect(container.querySelector('[role="alert"]')?.textContent).toContain(
"Shutdown refused",
);
});
it("shows initial connection failures", async () => {
invoke.mockRejectedValue(new Error("Hub unavailable"));
await act(async () => root.render(<HubPanel />));
expect(container.querySelector('[role="alert"]')?.textContent).toBe(
"Hub unavailable",
);
});
@@ -0,0 +1,197 @@
"use client";
import { RefreshCw } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { desktopClient } from "@/lib/desktop-client";
import type { HubStatus } from "@/lib/hub-status";
export function HubPanel() {
const [status, setStatus] = useState<HubStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [restarting, setRestarting] = useState(false);
const restartingRef = useRef(false);
const generation = useRef(0);
useEffect(() => {
let cancelled = false;
let timer: ReturnType<typeof setTimeout>;
async function refresh() {
const requestGeneration = generation.current;
try {
if (!restartingRef.current) {
const next = await desktopClient.invoke<HubStatus>("get_hub_status");
if (!cancelled && requestGeneration === generation.current) {
setStatus(next);
setError(null);
}
}
} catch (cause) {
if (!cancelled && requestGeneration === generation.current) {
setError(cause instanceof Error ? cause.message : String(cause));
}
} finally {
if (!cancelled) timer = setTimeout(refresh, 2000);
}
}
void refresh();
return () => {
cancelled = true;
clearTimeout(timer);
};
}, []);
async function restart() {
if (restartingRef.current) return;
restartingRef.current = true;
generation.current += 1;
setRestarting(true);
setError(null);
try {
setStatus(await desktopClient.invoke<HubStatus>("restart_hub"));
setConfirmOpen(false);
} catch (cause) {
setError(cause instanceof Error ? cause.message : String(cause));
setConfirmOpen(false);
} finally {
restartingRef.current = false;
setRestarting(false);
}
}
return (
<section className="border-b py-6" aria-labelledby="hub-heading">
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h2 id="hub-heading" className="text-lg font-semibold">
Cline Hub
</h2>
{status ? (
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
{status.url}
</p>
) : null}
<p className="mt-1 text-sm text-muted-foreground">
Restart the local hub to stop active sessions and reconnect clients.
</p>
</div>
<Button
variant="outline"
size="sm"
disabled={!status || restarting}
onClick={() => setConfirmOpen(true)}
>
<RefreshCw
className={restarting ? "size-3 animate-spin" : "size-3"}
/>
{restarting ? "Restarting…" : "Restart Hub"}
</Button>
</div>
{error ? (
<p className="mt-3 text-sm text-destructive" role="alert">
{error}
</p>
) : null}
{!status && !error ? (
<p className="mt-4 text-sm text-muted-foreground">Loading hub…</p>
) : null}
{status ? (
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<div className="min-w-0 rounded-lg border">
<h3 className="flex items-center gap-2 border-b px-4 py-3 text-sm font-semibold">
Connected clients
<Badge variant="secondary">{status.clients.length}</Badge>
</h3>
<ul className="max-h-72 overflow-auto divide-y">
{status.clients.map((client) => (
<li className="px-4 py-3" key={client.clientId}>
<p className="break-words text-sm font-medium">
{client.displayName || client.clientType}
</p>
<p className="break-all text-xs text-muted-foreground">
{client.clientType} · {client.clientId}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Connected {new Date(client.connectedAt).toLocaleString()}
</p>
</li>
))}
</ul>
{status.clients.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">
No connected clients.
</p>
) : null}
</div>
<div className="min-w-0 rounded-lg border">
<h3 className="border-b px-4 py-3 text-sm font-semibold">
Recent events
</h3>
<ol className="max-h-72 overflow-auto divide-y">
{status.events.map((event) => (
<li className="px-4 py-3" key={event.id}>
<div className="flex justify-between gap-3 text-sm">
<span>{event.title}</span>
<time
className="shrink-0 text-xs text-muted-foreground"
dateTime={new Date(event.timestamp).toISOString()}
>
{new Date(event.timestamp).toLocaleTimeString()}
</time>
</div>
<p className="break-words text-xs text-muted-foreground">
{event.detail}
</p>
</li>
))}
</ol>
{status.events.length === 0 ? (
<p className="p-4 text-sm text-muted-foreground">
No recent events. Activity is recorded while the desktop app is
running.
</p>
) : null}
</div>
</div>
) : null}
<AlertDialog
open={confirmOpen}
onOpenChange={(open) => {
if (!restarting) setConfirmOpen(open);
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restart Cline Hub?</AlertDialogTitle>
<AlertDialogDescription>
This stops the local hub and its active sessions, then starts a
fresh hub. Connected desktop, CLI, and VS Code clients will
disconnect and reconnect.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={restarting}>Cancel</AlertDialogCancel>
<Button
variant="destructive"
disabled={restarting}
onClick={() => void restart()}
>
{restarting ? "Restarting…" : "Restart Hub"}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</section>
);
}
@@ -14,6 +14,7 @@ const ALL_SETTINGS_SECTIONS = [
"Import",
"Remote",
"Account",
"Debug",
"About",
] as const;
@@ -40,9 +40,11 @@ beforeEach(() => {
document.documentElement.style.removeProperty("font-size");
delete document.documentElement.dataset.clineFontSize;
invoke.mockReset();
invoke.mockResolvedValue({
telemetryOptOut: false,
autoUpdateEnabled: true,
invoke.mockImplementation(async (command: string) => {
if (command === "get_hub_status") {
return { url: "ws://127.0.0.1:1234", clients: [], events: [] };
}
return { telemetryOptOut: false, autoUpdateEnabled: true };
});
container = document.createElement("div");
document.body.appendChild(container);
@@ -1,5 +1,5 @@
import { Switch } from "@cline/ui";
import { Download, Minus, Plus, RotateCcw } from "lucide-react";
import { Minus, Plus, RotateCcw } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import {
@@ -66,6 +66,8 @@ import { AccountView } from "./account-view";
import { AddProviderContent, type AddProviderPayload } from "./add-provider";
import { ChannelsContent } from "./channels-view";
import { CustomizeView } from "./customize-view";
import { DebugContent } from "./debug-view";
import { HubPanel } from "./hub-panel";
import { ImportContent } from "./import-view";
import { NotificationSettings } from "./notification-settings";
import {
@@ -635,7 +637,9 @@ export function SettingsView({
) : activeNav === "About" ? (
<AboutContent onOpenConnectors={() => onNavigateSection("Customize")} />
) : activeNav === "General" ? (
<GeneralSettingsContent onExportDiagnostics={onExportDiagnostics} />
<GeneralSettingsContent />
) : activeNav === "Debug" ? (
<DebugContent onExportDiagnostics={onExportDiagnostics} />
) : (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">
@@ -665,11 +669,7 @@ const ACCENT_OPTIONS: { id: HubAccent; label: string; swatch: string }[] = [
{ id: "ember", label: "Ember", swatch: "oklch(0.6 0.19 33)" },
];
function GeneralSettingsContent({
onExportDiagnostics,
}: {
onExportDiagnostics: () => void;
}) {
function GeneralSettingsContent() {
const [theme, setTheme] = useState<HubTheme>(() => {
if (typeof window === "undefined") return "light";
return readStoredHubTheme() ?? readSystemHubTheme();
@@ -1021,27 +1021,7 @@ function GeneralSettingsContent({
Replay
</Button>
</div>
<div className="flex py-4 items-center justify-between gap-5 max-[720px]:flex-col max-[720px]:items-stretch max-[720px]:py-4">
<div className="flex flex-col gap-1">
<p className="text-base font-semibold text-foreground">
Diagnostics
</p>
<p className="text-sm text-muted-foreground">
Export app info, recent logs, and the metadata of sessions you
choose as a file you can attach when reporting a problem.
</p>
</div>
<Button
className="w-24 shrink-0"
onClick={onExportDiagnostics}
size="sm"
type="button"
variant="outline"
>
<Download className="size-3" />
Export…
</Button>
</div>
<HubPanel />
</section>
</PageFrame>
);
@@ -117,6 +117,7 @@ function showUpdateReadyToast(version: string) {
description:
"The new version has been downloaded. Restart now, or later from the update button next to the Cline logo.",
duration: Number.POSITIVE_INFINITY,
className: "flex-col items-start gap-3 space-x-0",
action: (
<ToastAction
altText="Restart now"
@@ -0,0 +1,17 @@
export interface HubActivity {
id: number;
timestamp: number;
title: string;
detail: string;
}
export interface HubStatus {
url: string;
clients: Array<{
clientId: string;
clientType: string;
displayName?: string;
connectedAt: number;
}>;
events: HubActivity[];
}
+16 -2
View File
@@ -1,7 +1,21 @@
import { defineConfig } from "@vscode/test-cli"
import { createHash } from "crypto"
import os from "os"
import path from "path"
const vscodeTestVersion = process.env.VSCODE_TEST_VERSION ?? "stable"
const extensionDevelopmentPath = path.resolve("./")
// VS Code puts its IPC socket (e.g. `1.14-main.sock`) in the user data dir, and
// macOS caps unix socket paths at 103 chars. The default `.vscode-test/user-data`
// overflows that from deep checkouts such as worktrees (`listen EINVAL`), so on
// macOS use a short dir in the temp folder, keyed by checkout so concurrent runs
// from different worktrees don't hand off to each other's instance.
const launchArgs = ["--disable-extensions"]
if (process.platform === "darwin") {
const checkoutHash = createHash("sha256").update(extensionDevelopmentPath).digest("hex").slice(0, 8)
launchArgs.push(`--user-data-dir=${path.join(os.tmpdir(), `cline-vscode-test-${checkoutHash}`)}`)
}
export default defineConfig({
files: [
@@ -25,6 +39,6 @@ export default defineConfig({
},
workspaceFolder: "test-workspace",
version: vscodeTestVersion,
extensionDevelopmentPath: path.resolve("./"),
launchArgs: ["--disable-extensions"],
extensionDevelopmentPath,
launchArgs,
})
+9 -14
View File
@@ -191,25 +191,20 @@ const e2eBuildConfig = {
plugins: [aliasResolverPlugin, esbuildProblemMatcherPlugin],
}
/**
* Plugins run in worker threads that load this self-contained bootstrap from
* disk, next to the bundle (core looks for it beside dist/extension.js).
* Shipped as .mjs because this package is not `"type": "module"`. Without it,
* core falls back to loading plugins inside the extension host.
*/
function copyPluginBootstrap() {
const source = path.resolve(__dirname, "../../sdk/packages/core/dist/extensions/plugin-sandbox-bootstrap.js")
if (!fs.existsSync(source)) {
console.warn(`[esbuild] ${source} not found; plugins will load in-process`)
return
/** Copy core's complete plugin runtime; its own package.json provides ESM scope. */
function copyPluginRuntime() {
const source = path.resolve(__dirname, "../../sdk/packages/core/dist/plugin-runtime")
if (!fs.existsSync(path.join(source, "plugin-sandbox-bootstrap.js"))) {
throw new Error(`[esbuild] ${source} not found; run bun run build:sdk first`)
}
fs.mkdirSync(destDir, { recursive: true })
fs.copyFileSync(source, path.join(destDir, "plugin-sandbox-bootstrap.mjs"))
const destination = path.join(destDir, "plugin-runtime")
fs.rmSync(destination, { recursive: true, force: true })
fs.cpSync(source, destination, { recursive: true })
}
async function main() {
const config = standalone ? standaloneConfig : e2eBuild ? e2eBuildConfig : extensionConfig
if (!e2eBuild) copyPluginBootstrap()
if (!e2eBuild) copyPluginRuntime()
const extensionCtx = await esbuild.context(config)
if (watch) {
await extensionCtx.watch()
+2 -1
View File
@@ -433,7 +433,7 @@
"@types/vscode": "1.101.0",
"@types/ws": "^8.18.1",
"@vscode/test-cli": "^0.0.12",
"@vscode/test-electron": "^2.5.2",
"@vscode/test-electron": "^3.1.0",
"@vscode/vsce": "^3.6.0",
"c8": "^10.1.3",
"chai": "^4.3.10",
@@ -548,6 +548,7 @@
"undici": "^7.29.0",
"uuid": "^11.1.0",
"vscode-uri": "^3.1.0",
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
"c8": {
@@ -19,6 +19,7 @@ function makeControllerStub(initial: {
const controller = {
stateManager: {
getGlobalSettingsKey: (key: string) => globalState.get(key) ?? {},
getGlobalStateKey: (key: string) => globalState.get(key),
getWorkspaceStateKey: (key: string) => workspaceState.get(key) ?? {},
setGlobalState: (key: string, value: unknown) => globalState.set(key, value),
setWorkspaceState: (key: string, value: unknown) => workspaceState.set(key, value),
@@ -75,8 +76,10 @@ describe("refreshClineRulesToggles workspace layouts", () => {
await fs.mkdir(clineRulesDir, { recursive: true })
const legacyRule = path.join(legacyDir, "a.md")
const newRule = path.join(clineRulesDir, "b.md")
await fs.writeFile(legacyRule, "A")
await fs.writeFile(newRule, "B")
// Disabled rules carry `disabled: true`, which is what the SDK loader
// and the panel read.
await fs.writeFile(legacyRule, "---\ndisabled: true\n---\nA")
await fs.writeFile(newRule, "---\ndisabled: true\n---\nB")
const controller = makeControllerStub({
localClineRulesToggles: {
@@ -0,0 +1,229 @@
import { afterEach, describe, it } from "bun:test"
import { expect } from "chai"
import fs from "fs/promises"
import os from "os"
import path from "path"
import type { Controller } from "@/core/controller"
import {
refreshClineRulesToggles,
resolveWritableRuleFile,
setRuleDisabledInFrontmatter,
syncRuleTogglesFromFrontmatter,
} from "../cline-rules"
import { parseYamlFrontmatter, readSdkEnabledState } from "../frontmatter"
const temporaryDirectories: string[] = []
async function makeTempDir(): Promise<string> {
// realpath: on macOS os.tmpdir() is itself a symlink (/var -> /private/var).
const dir = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "cline-rules-write-test-")))
temporaryDirectories.push(dir)
return dir
}
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })))
})
describe("setRuleDisabledInFrontmatter", () => {
it("round-trips a rule document inside an allowed root", async () => {
const rulesDir = await makeTempDir()
const rulePath = path.join(rulesDir, "project-rule.md")
await fs.writeFile(rulePath, "Follow this rule")
expect(await setRuleDisabledInFrontmatter(rulePath, false, [rulesDir])).to.equal("written")
const disabled = parseYamlFrontmatter(await fs.readFile(rulePath, "utf-8"))
expect(disabled.data.disabled).to.equal(true)
expect(disabled.body).to.equal("Follow this rule")
expect(await setRuleDisabledInFrontmatter(rulePath, true, [rulesDir])).to.equal("written")
expect(await fs.readFile(rulePath, "utf-8")).to.equal("Follow this rule")
})
it("accepts the legacy single .clinerules file as its own root", async () => {
const workspace = await makeTempDir()
const rulePath = path.join(workspace, ".clinerules")
await fs.writeFile(rulePath, "Single-file rules")
expect(await setRuleDisabledInFrontmatter(rulePath, false, [rulePath])).to.equal("written")
expect(parseYamlFrontmatter(await fs.readFile(rulePath, "utf-8")).data.disabled).to.equal(true)
})
it("skips files the SDK loader does not read", async () => {
const rulesDir = await makeTempDir()
const jsonPath = path.join(rulesDir, "settings.json")
await fs.writeFile(jsonPath, '{"a":1}')
expect(await setRuleDisabledInFrontmatter(jsonPath, false, [rulesDir])).to.equal("skipped")
expect(await fs.readFile(jsonPath, "utf-8")).to.equal('{"a":1}')
})
it("skips paths outside the allowed roots, including through symlinks", async () => {
const rulesDir = await makeTempDir()
const elsewhere = await makeTempDir()
const targetPath = path.join(elsewhere, "not-a-rule.md")
await fs.writeFile(targetPath, "Do not touch")
const linkPath = path.join(rulesDir, "linked.md")
await fs.symlink(targetPath, linkPath)
expect(await setRuleDisabledInFrontmatter(targetPath, false, [rulesDir])).to.equal("skipped")
expect(await setRuleDisabledInFrontmatter(linkPath, false, [rulesDir])).to.equal("skipped")
expect(await resolveWritableRuleFile(linkPath, [rulesDir])).to.equal(null)
expect(await fs.readFile(targetPath, "utf-8")).to.equal("Do not touch")
})
it("skips files nested below a rules root, which the SDK loader never reads", async () => {
const rulesDir = await makeTempDir()
const nestedPath = path.join(rulesDir, "nested", "deep.md")
await fs.mkdir(path.dirname(nestedPath), { recursive: true })
await fs.writeFile(nestedPath, "Nested")
expect(await setRuleDisabledInFrontmatter(nestedPath, false, [rulesDir])).to.equal("skipped")
expect(await fs.readFile(nestedPath, "utf-8")).to.equal("Nested")
})
it("reports a failure when the rule file cannot be written", async () => {
const rulesDir = await makeTempDir()
const rulePath = path.join(rulesDir, "read-only.md")
await fs.writeFile(rulePath, "Locked rule")
await fs.chmod(rulePath, 0o444)
try {
expect(await setRuleDisabledInFrontmatter(rulePath, false, [rulesDir])).to.equal("failed")
expect(await fs.readFile(rulePath, "utf-8")).to.equal("Locked rule")
} finally {
await fs.chmod(rulePath, 0o644)
}
})
it("reports a failure when malformed frontmatter prevents a safe edit", async () => {
const rulesDir = await makeTempDir()
const rulePath = path.join(rulesDir, "broken.md")
const content = "---\npaths: [invalid\n---\nBody"
await fs.writeFile(rulePath, content)
expect(await setRuleDisabledInFrontmatter(rulePath, false, [rulesDir])).to.equal("failed")
expect(await fs.readFile(rulePath, "utf-8")).to.equal(content)
})
it("reports success without writing when the file already carries the requested state", async () => {
const rulesDir = await makeTempDir()
const rulePath = path.join(rulesDir, "already-off.md")
const content = "---\ndisabled: true\n---\nBody"
await fs.writeFile(rulePath, content)
const before = (await fs.stat(rulePath)).mtimeMs
expect(await setRuleDisabledInFrontmatter(rulePath, false, [rulesDir])).to.equal("written")
expect((await fs.stat(rulePath)).mtimeMs).to.equal(before)
})
it("rejects relative and missing paths", async () => {
const rulesDir = await makeTempDir()
expect(await setRuleDisabledInFrontmatter("relative.md", false, [rulesDir])).to.equal("skipped")
expect(await setRuleDisabledInFrontmatter(path.join(rulesDir, "missing.md"), false, [rulesDir])).to.equal("skipped")
})
})
describe("syncRuleTogglesFromFrontmatter", () => {
it("shows a toggle saved before the fix as on and leaves its file untouched", async () => {
const rulesDir = await makeTempDir()
const offPath = path.join(rulesDir, "off.md")
const onPath = path.join(rulesDir, "on.md")
await fs.writeFile(offPath, "Was toggled off in state only")
await fs.writeFile(onPath, "Still on")
const toggles = await syncRuleTogglesFromFrontmatter({ [offPath]: false, [onPath]: true }, [rulesDir])
expect(toggles).to.deep.equal({ [offPath]: true, [onPath]: true })
expect(await fs.readFile(offPath, "utf-8")).to.equal("Was toggled off in state only")
})
it("shows a rule as off when its frontmatter disables it", async () => {
const rulesDir = await makeTempDir()
const rulePath = path.join(rulesDir, "hand-edited.md")
const content = "---\ndisabled: true\n---\nDisabled by hand"
await fs.writeFile(rulePath, content)
const toggles = await syncRuleTogglesFromFrontmatter({ [rulePath]: true }, [rulesDir])
expect(toggles[rulePath]).to.equal(false)
expect(await fs.readFile(rulePath, "utf-8")).to.equal(content)
})
it("matches the SDK precedence: disabled: false beats enabled: false", async () => {
const rulesDir = await makeTempDir()
const rulePath = path.join(rulesDir, "both-flags.md")
await fs.writeFile(rulePath, "---\ndisabled: false\nenabled: false\n---\nInjected by the SDK")
const toggles = await syncRuleTogglesFromFrontmatter({ [rulePath]: false }, [rulesDir])
expect(toggles[rulePath]).to.equal(true)
})
it("shows a rule the SDK cannot load as off, whatever the stored toggle says", async () => {
const rulesDir = await makeTempDir()
const malformed = path.join(rulesDir, "malformed.md")
const emptyBody = path.join(rulesDir, "empty-body.md")
const nonBoolean = path.join(rulesDir, "non-boolean.md")
await fs.writeFile(malformed, "---\npaths: [invalid\n---\nBody")
await fs.writeFile(emptyBody, "---\nname: x\n---\n")
await fs.writeFile(nonBoolean, "---\ndisabled: [true]\n---\nBody")
const toggles = await syncRuleTogglesFromFrontmatter({ [malformed]: true, [emptyBody]: true, [nonBoolean]: true }, [
rulesDir,
])
expect(toggles).to.deep.equal({ [malformed]: false, [emptyBody]: false, [nonBoolean]: false })
})
it("shows a rule with non-mapping frontmatter as on, since the SDK loads it", async () => {
const rulesDir = await makeTempDir()
const rulePath = path.join(rulesDir, "divider.md")
await fs.writeFile(rulePath, "---\nJust a divider line\n---\nBody")
const toggles = await syncRuleTogglesFromFrontmatter({ [rulePath]: false }, [rulesDir])
expect(toggles[rulePath]).to.equal(true)
expect(await setRuleDisabledInFrontmatter(rulePath, false, [rulesDir])).to.equal("written")
expect(readSdkEnabledState(await fs.readFile(rulePath, "utf-8"))).to.equal(false)
})
it("keeps the stored toggle for files it cannot read as rules", async () => {
const rulesDir = await makeTempDir()
const elsewhere = await makeTempDir()
const outside = path.join(elsewhere, "outside.md")
const json = path.join(rulesDir, "data.json")
const broken = path.join(rulesDir, "broken.md")
await fs.writeFile(outside, "Outside")
await fs.writeFile(json, "{}")
await fs.writeFile(broken, "---\npaths: [invalid\n---\nBody")
const toggles = await syncRuleTogglesFromFrontmatter({ [outside]: false, [json]: false, [broken]: false }, [rulesDir])
expect(toggles).to.deep.equal({ [outside]: false, [json]: false, [broken]: false })
})
})
describe("refreshClineRulesToggles", () => {
it("derives workspace toggles from the rule files", async () => {
const workspace = await makeTempDir()
const offPath = path.join(workspace, ".clinerules", "off.md")
const stalePath = path.join(workspace, ".clinerules", "stale.md")
await fs.mkdir(path.dirname(offPath), { recursive: true })
await fs.writeFile(offPath, "---\ndisabled: true\n---\nOff")
await fs.writeFile(stalePath, "Toggled off before the fix")
const workspaceState = new Map<string, unknown>([["localClineRulesToggles", { [stalePath]: false }]])
const controller = {
stateManager: {
getGlobalSettingsKey: () => ({}),
getWorkspaceStateKey: (key: string) => workspaceState.get(key) ?? {},
setGlobalState: () => undefined,
setWorkspaceState: (key: string, value: unknown) => workspaceState.set(key, value),
},
} as unknown as Controller
const { localToggles } = await refreshClineRulesToggles(controller, workspace)
expect(localToggles).to.deep.equal({ [offPath]: false, [stalePath]: true })
expect(await fs.readFile(stalePath, "utf-8")).to.equal("Toggled off before the fix")
})
})
@@ -1,6 +1,12 @@
import { describe, it } from "bun:test"
import { expect } from "chai"
import { parseYamlFrontmatter } from "../frontmatter"
import { parseDocument } from "yaml"
import {
isFrontmatterDisabled,
parseYamlFrontmatter,
readSdkEnabledState,
updateUserInstructionMarkdownDisabledState,
} from "../frontmatter"
describe("parseYamlFrontmatter", () => {
it("returns original content when no frontmatter", () => {
@@ -69,3 +75,215 @@ describe("parseYamlFrontmatter", () => {
expect(result.body.trim()).to.equal("# my-skill\nThis is a test skill.")
})
})
describe("updateUserInstructionMarkdownDisabledState", () => {
it("adds disabled frontmatter when a rule is toggled off", () => {
const output = updateUserInstructionMarkdownDisabledState("Follow this rule", false)
expect(output).to.equal("---\ndisabled: true\n---\nFollow this rule")
})
it("preserves existing frontmatter fields when disabling", () => {
const input = ["---", "paths:", " - src/**", "---", "Scoped rule"].join("\n")
const output = updateUserInstructionMarkdownDisabledState(input, false)
expect(output).to.contain("disabled: true")
expect(output).to.contain("paths:")
expect(output).to.contain("Scoped rule")
})
it("removes disabled frontmatter when a rule is toggled back on", () => {
const input = ["---", "disabled: true", "---", "Follow this rule"].join("\n")
expect(updateUserInstructionMarkdownDisabledState(input, true)).to.equal("Follow this rule")
})
it("leaves malformed frontmatter untouched", () => {
const input = ["---", "paths: [invalid", "---", "Rule body"].join("\n")
expect(updateUserInstructionMarkdownDisabledState(input, false)).to.equal(input)
})
})
const lines = (...parts: string[]) => parts.join("\n")
/** Every top-level value except the enablement flags, as the SDK parser reads it. */
function otherValues(content: string): Record<string, unknown> {
const block = content.replace(/^\uFEFF/, "").match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? ""
const data = (parseDocument(block).toJS() ?? {}) as Record<string, unknown>
const { disabled: _disabled, enabled: _enabled, ...rest } = data
return rest
}
function bodyOf(content: string): string {
return parseYamlFrontmatter(content).body
}
describe("updateUserInstructionMarkdownDisabledState preserves authored frontmatter", () => {
it("keeps comments, key order, and quoting when disabling", () => {
const input = lines("---", "# scope this rule", "paths:", " - 'src/**'", 'name: "My rule"', "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, false)).to.equal(
lines("---", "# scope this rule", "paths:", " - 'src/**'", 'name: "My rule"', "disabled: true", "---", "Body"),
)
})
it("sets an existing disabled value in place, keeping its comment", () => {
const input = lines("---", "disabled: false # important explanation", "paths:", " - src/**", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, false)).to.equal(
lines("---", "disabled: true # important explanation", "paths:", " - src/**", "---", "Body"),
)
})
it("keeps a commented disabled entry on re-enable and only flips its value", () => {
const input = lines("---", "disabled: true # important explanation", "paths:", " - src/**", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, true)).to.equal(
lines("---", "disabled: false # important explanation", "paths:", " - src/**", "---", "Body"),
)
})
it("keeps a comment placed before a split value", () => {
const input = lines("---", "disabled:", " # why this is off", " true", "name: x", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, true)).to.equal(
lines("---", "disabled:", " # why this is off", " false", "name: x", "---", "Body"),
)
})
it("keeps an indented comment that follows an inline disabled value", () => {
const input = lines("---", "disabled: true", " # keep this note", "paths:", " - src/**", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, true)).to.equal(
lines("---", "disabled: false", " # keep this note", "paths:", " - src/**", "---", "Body"),
)
})
it("removes uncommented disabled and enabled: false entries when re-enabling", () => {
const input = lines("---", "# keep me", "enabled: false", "paths:", " - src/**", "disabled: true", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, true)).to.equal(
lines("---", "# keep me", "paths:", " - src/**", "---", "Body"),
)
})
it("removes a legacy enabled: false whose value is on the next line", () => {
const input = lines("---", "disabled: true", "enabled:", " false", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, true)).to.equal("Body")
})
it("leaves matching text inside other values untouched", () => {
const input = lines("---", 'description: "first', " disabled: true", ' last"', "disabled: true", "---", "Body")
const output = updateUserInstructionMarkdownDisabledState(input, true)
expect(output).to.equal(lines("---", 'description: "first', " disabled: true", ' last"', "---", "Body"))
expect(otherValues(output)).to.deep.equal(otherValues(input))
})
it("refuses frontmatter the SDK parser rejects instead of editing it", () => {
const input = lines("---", 'description: "first', "disabled: true", 'last"', "disabled: true", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, true)).to.equal(input)
const duplicate = lines("---", "disabled: true", "disabled: false", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(duplicate, true)).to.equal(duplicate)
})
it("refuses a non-boolean flag, which makes the SDK reject the file", () => {
const flow = lines("---", "disabled: [", " true,", "]", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(flow, false)).to.equal(flow)
expect(updateUserInstructionMarkdownDisabledState(flow, true)).to.equal(flow)
const block = lines("---", "disabled: |", " multi", " line", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(block, false)).to.equal(block)
})
it("disables a document whose frontmatter is not a mapping by adding a block above it", () => {
// The SDK loads such a document as enabled with no metadata (the block
// parses as a plain scalar), so the markdown divider lines are user text.
const input = lines("---", "Just a divider line", "---", "Body")
const disabled = updateUserInstructionMarkdownDisabledState(input, false)
expect(disabled).to.equal(lines("---", "disabled: true", "---", "---", "Just a divider line", "---", "Body"))
expect(readSdkEnabledState(disabled)).to.equal(false)
expect(updateUserInstructionMarkdownDisabledState(disabled, true)).to.equal(input)
})
it("appends the flag inside a comment-only block and keeps the comment", () => {
const input = lines("---", "# top comment", "---", "Body")
const disabled = updateUserInstructionMarkdownDisabledState(input, false)
expect(disabled).to.equal(lines("---", "# top comment", "disabled: true", "---", "Body"))
expect(readSdkEnabledState(disabled)).to.equal(false)
expect(updateUserInstructionMarkdownDisabledState(disabled, true)).to.equal(input)
})
it("leaves a document the SDK does not load untouched", () => {
const emptyBody = lines("---", "name: x", "---", "")
expect(readSdkEnabledState(emptyBody)).to.equal(undefined)
expect(updateUserInstructionMarkdownDisabledState(emptyBody, false)).to.equal(emptyBody)
expect(updateUserInstructionMarkdownDisabledState(emptyBody, true)).to.equal(emptyBody)
})
it("does not touch a nested disabled key", () => {
const input = lines("---", "meta:", " disabled: true", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, false)).to.equal(
lines("---", "meta:", " disabled: true", "disabled: true", "---", "Body"),
)
})
it("recognizes quoted keys and uppercase booleans", () => {
expect(updateUserInstructionMarkdownDisabledState(lines("---", '"disabled": true', "---", "Body"), true)).to.equal("Body")
expect(
updateUserInstructionMarkdownDisabledState(lines("---", "'disabled': false", "name: x", "---", "Body"), false),
).to.equal(lines("---", "'disabled': true", "name: x", "---", "Body"))
for (const spelling of ["False", "FALSE"]) {
const input = lines("---", `enabled: ${spelling}`, "paths:", " - src/**", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(input, true)).to.equal(
lines("---", "paths:", " - src/**", "---", "Body"),
)
}
})
it("preserves CRLF line endings", () => {
const input = "---\r\npaths:\r\n - src/**\r\n---\r\nBody\r\n"
const disabled = updateUserInstructionMarkdownDisabledState(input, false)
expect(disabled).to.equal("---\r\npaths:\r\n - src/**\r\ndisabled: true\r\n---\r\nBody\r\n")
expect(updateUserInstructionMarkdownDisabledState(disabled, true)).to.equal(input)
expect(updateUserInstructionMarkdownDisabledState("Body\r\n", false)).to.equal("---\r\ndisabled: true\r\n---\r\nBody\r\n")
})
it("preserves a UTF-8 BOM", () => {
const input = "\uFEFFFollow this rule"
const disabled = updateUserInstructionMarkdownDisabledState(input, false)
expect(disabled).to.equal("\uFEFF---\ndisabled: true\n---\nFollow this rule")
expect(updateUserInstructionMarkdownDisabledState(disabled, true)).to.equal(input)
})
it("is a no-op when the document already has the requested state", () => {
const disabled = lines("---", "disabled: true", "---", "Body")
expect(updateUserInstructionMarkdownDisabledState(disabled, false)).to.equal(disabled)
expect(updateUserInstructionMarkdownDisabledState("Body", true)).to.equal("Body")
})
it("never changes another value or the body, in either direction", () => {
const inputs = [
lines(
"---",
"name: n",
"description: |",
" disabled: true",
" enabled: false",
"paths:",
" - src/**",
"---",
"Body",
),
lines("---", "# c", "disabled: false # keep", "tags: [a, b]", "---", "Body", "disabled: true"),
lines("---", 'title: "x: y"', "enabled:", " # legacy", " false", "---", "", "Body"),
]
for (const input of inputs) {
for (const enabled of [true, false]) {
const output = updateUserInstructionMarkdownDisabledState(input, enabled)
expect(otherValues(output), input).to.deep.equal(otherValues(input))
expect(bodyOf(output), input).to.equal(bodyOf(input))
expect(readSdkEnabledState(output), input).to.equal(enabled)
}
}
})
})
describe("isFrontmatterDisabled", () => {
it("uses the SDK precedence: a boolean disabled wins over enabled", () => {
expect(isFrontmatterDisabled({ disabled: true })).to.equal(true)
expect(isFrontmatterDisabled({ enabled: false })).to.equal(true)
expect(isFrontmatterDisabled({ disabled: false, enabled: false })).to.equal(false)
expect(isFrontmatterDisabled({ disabled: true, enabled: true })).to.equal(true)
expect(isFrontmatterDisabled({})).to.equal(false)
})
})
@@ -814,6 +814,38 @@ describe("updateSkillMarkdownDisabledState", () => {
})
})
describe("updateSkillMarkdownDisabledState with the shared source-range editor", () => {
const lines = (...parts: string[]) => parts.join("\n")
it("re-enables a skill whose legacy enabled: false value is on the next line", () => {
const input = lines("---", "name: s", "description: d", "disabled: true", "enabled:", " false", "---", "Body")
expect(updateSkillMarkdownDisabledState(input, true)).to.equal(lines("---", "name: s", "description: d", "---", "Body"))
})
it("leaves matching text inside the description untouched", () => {
const input = lines(
"---",
"name: s",
'description: "first',
" disabled: true",
' last"',
"disabled: true",
"---",
"Body",
)
const output = updateSkillMarkdownDisabledState(input, true)
expect(output).to.equal(lines("---", "name: s", 'description: "first', " disabled: true", ' last"', "---", "Body"))
expect(parseYamlFrontmatter(output).data.description).to.equal("first disabled: true last")
})
it("keeps a comment on the disabled entry", () => {
const input = lines("---", "name: s", "description: d", "disabled: true # paused for review", "---", "Body")
expect(updateSkillMarkdownDisabledState(input, true)).to.equal(
lines("---", "name: s", "description: d", "disabled: false # paused for review", "---", "Body"),
)
})
})
describe("setSkillDisabledInFrontmatter", () => {
let sandbox: sinon.SinonSandbox
let readFileStub: sinon.SinonStub
@@ -833,6 +865,23 @@ describe("setSkillDisabledInFrontmatter", () => {
afterEach(() => sandbox.restore())
it("does not report success when the edit leaves the skill disabled", async () => {
const skillPath = path.join("/home", "user", ".cline", "skills", "s", "SKILL.md")
readFileStub
.withArgs(skillPath, "utf-8")
.resolves(["---", "name: s", "description: d", "disabled: true", "enabled:", " false", "---", "Body"].join("\n"))
const ok = await setSkillDisabledInFrontmatter(skillPath, true)
if (ok) {
const written = String(writeFileStub.firstCall.args[1])
const data = parseYamlFrontmatter(written).data
expect(data.disabled !== true && data.enabled !== false).to.be.true
} else {
expect(writeFileStub.called).to.be.false
}
})
it("writes disabled: true to the SKILL.md when disabling a disk skill", async () => {
const skillPath = path.join("/home", "user", ".cline", "skills", "s", "SKILL.md")
readFileStub.withArgs(skillPath, "utf-8").resolves(["---", "name: s", "description: d", "---", "Body"].join("\n"))
@@ -1,8 +1,20 @@
import { resolveGlobalRulesConfigPaths, resolveWorkspaceRulesConfigPaths } from "@cline/shared/storage"
import { combineRuleToggles, synchronizeRuleToggles } from "@core/context/instructions/user-instructions/rule-helpers"
import { ensureRulesDirectoryExists } from "@core/storage/disk"
import { ensureRulesDirectoryExists, GlobalFileNames } from "@core/storage/disk"
import { ClineRulesToggles } from "@shared/cline-rules"
import { getCwd, getDesktopDir } from "@utils/path"
import * as fs from "fs/promises"
import path from "path"
import { Controller } from "@/core/controller"
import { Logger } from "@/shared/services/Logger"
import { hasRequestedEnabledState, readSdkEnabledState, updateUserInstructionMarkdownDisabledState } from "./frontmatter"
/**
* File types the SDK rule loader actually reads. Anything else that happens to
* live in a rules directory (images, JSON, editor scratch files) is never
* injected, so writing frontmatter into it would only corrupt it.
*/
const RULE_FILE_EXTENSIONS = new Set([".md", ".markdown", ".txt"])
/**
* Sub-directories of `.clinerules` that hold non-rule config and must not be
@@ -14,6 +26,158 @@ const CLINERULES_EXCLUDED_SUBDIRECTORIES: string[][] = [
[".clinerules", "skills"],
]
/**
* The SDK loader reads only the files directly inside a rules directory (or
* the root itself when it is the single legacy `.clinerules` file); nested
* files never reach the model, so they are never written either.
*/
function isRuleFileOfRoot(rootPath: string, filePath: string): boolean {
return filePath === rootPath || path.dirname(filePath) === rootPath
}
/**
* Every global location a rule may live in: the Documents-based directory the
* Rules tab creates files in, plus the locations the shared SDK resolver loads
* from (e.g. ~/.cline/rules).
*/
async function resolveGlobalRuleDirectories(): Promise<string[]> {
return [...new Set([await ensureRulesDirectoryExists(), ...resolveGlobalRulesConfigPaths()])]
}
/**
* Roots a rule toggle is allowed to write into for the given scope: the global
* rule directories, or this window's workspace rule locations.
*/
export async function resolveRuleWriteRoots(scope: "global" | "local"): Promise<string[]> {
if (scope === "global") {
return resolveGlobalRuleDirectories()
}
return resolveWorkspaceRulesConfigPaths(await getCwd(getDesktopDir()))
}
/**
* Resolve a toggle's rule path to the real file we may write, or `null` when
* the path is not a rule document the SDK loads or does not resolve (through
* symlinks) to a direct child of one of the allowed roots.
*/
export async function resolveWritableRuleFile(rulePath: string, allowedRoots: ReadonlyArray<string>): Promise<string | null> {
if (!rulePath || !path.isAbsolute(rulePath)) {
return null
}
const fileName = path.basename(rulePath)
if (fileName !== GlobalFileNames.clineRules && !RULE_FILE_EXTENSIONS.has(path.extname(fileName).toLowerCase())) {
return null
}
let realFilePath: string
try {
realFilePath = await fs.realpath(rulePath)
if (!(await fs.stat(realFilePath)).isFile()) {
return null
}
} catch {
return null
}
for (const root of allowedRoots) {
try {
if (isRuleFileOfRoot(await fs.realpath(root), realFilePath)) {
return realFilePath
}
} catch {
// Root does not exist; nothing under it can be written.
}
}
return null
}
/**
* Outcome of persisting a toggle to a rule file:
* - `written`: the file now carries the requested state (or already did);
* - `skipped`: the path is not a rule document the SDK loads, so there was
* nothing to write;
* - `failed`: the file is a rule document but could not be read, written, or
* safely edited (malformed frontmatter), so the SDK will keep loading its
* previous state.
*/
export type RuleFrontmatterWriteResult = "written" | "skipped" | "failed"
/**
* Persist a rule's UI toggle in the frontmatter consumed by the SDK rules
* loader, which reads `disabled` from the rule document itself rather than
* from the extension's toggle state. Only rule documents directly inside
* `allowedRoots` are written.
*/
export async function setRuleDisabledInFrontmatter(
rulePath: string,
enabled: boolean,
allowedRoots: ReadonlyArray<string>,
): Promise<RuleFrontmatterWriteResult> {
const filePath = await resolveWritableRuleFile(rulePath, allowedRoots)
if (!filePath) {
return "skipped"
}
try {
const content = await fs.readFile(filePath, "utf-8")
const updated = updateUserInstructionMarkdownDisabledState(content, enabled)
// The editor returns the document unchanged when it already has the
// requested state and when it cannot edit it safely, and an edit can
// leave a disabling flag it did not recognize. Only a document that ends
// up in the requested state counts as written.
if (!hasRequestedEnabledState(updated, enabled)) {
Logger.warn(`Rule frontmatter at ${filePath} could not be updated; leaving the document untouched`)
return "failed"
}
if (updated !== content) {
await fs.writeFile(filePath, updated)
}
return "written"
} catch (error) {
Logger.warn(`Failed to update rule frontmatter at ${filePath}:`, error)
return "failed"
}
}
/**
* Make the panel's toggles show what the SDK will load. The rule file is the
* only source of truth: frontmatter that disables a rule shows it as off, and
* a file without it shows the rule as on, whatever extension state says.
*
* Toggles saved before the toggle wrote frontmatter (cline/cline#13695) are
* deliberately not written back into their files. Telling such a toggle apart
* from a rule the user has since re-enabled by editing its file would need
* per-rule history that survives multiple windows and hand edits; a stale
* toggle shows as on instead, which is what the SDK has been loading.
*
* A file the SDK cannot load at all shows as off, since nothing from it
* reaches the model. Files outside `allowedRoots` and unreadable files keep
* their stored toggle.
*/
export async function syncRuleTogglesFromFrontmatter(
toggles: ClineRulesToggles,
allowedRoots: ReadonlyArray<string>,
): Promise<ClineRulesToggles> {
const updated: ClineRulesToggles = { ...toggles }
for (const rulePath of Object.keys(toggles)) {
const filePath = await resolveWritableRuleFile(rulePath, allowedRoots)
if (!filePath) {
continue
}
let content: string
try {
content = await fs.readFile(filePath, "utf-8")
} catch {
continue
}
const sdkEnabled = readSdkEnabledState(content)
if (sdkEnabled === undefined) {
Logger.warn(`Rule ${filePath} cannot be loaded by the SDK (unparseable frontmatter or empty body); showing it as off`)
}
updated[rulePath] = sdkEnabled === true
}
return updated
}
/**
* Synchronizes rule toggles across every directory a rule may live in.
* `synchronizeRuleToggles` prunes toggles for files outside the directory it
@@ -46,19 +210,25 @@ export async function refreshClineRulesToggles(
// plus every global location the shared SDK resolver loads rules from
// (e.g. ~/.cline/rules), so the panel shows what actually reaches the model.
const globalClineRulesToggles = controller.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
const globalClineRulesFilePath = await ensureRulesDirectoryExists()
const globalRuleDirectories = [...new Set([globalClineRulesFilePath, ...resolveGlobalRulesConfigPaths()])]
const updatedGlobalToggles = await synchronizeRuleTogglesAcrossDirectories(globalRuleDirectories, globalClineRulesToggles)
const globalRuleDirectories = await resolveGlobalRuleDirectories()
const updatedGlobalToggles = await syncRuleTogglesFromFrontmatter(
await synchronizeRuleTogglesAcrossDirectories(globalRuleDirectories, globalClineRulesToggles),
globalRuleDirectories,
)
controller.stateManager.setGlobalState("globalClineRulesToggles", updatedGlobalToggles)
// Local toggles: both supported workspace layouts — the legacy
// `.clinerules` directory (or single file) and `.cline/rules` — via the
// same shared resolver the SDK runtime loads rules with (cline/cline#14186).
const localClineRulesToggles = controller.stateManager.getWorkspaceStateKey("localClineRulesToggles")
const updatedLocalToggles = await synchronizeRuleTogglesAcrossDirectories(
resolveWorkspaceRulesConfigPaths(workingDirectory),
localClineRulesToggles,
CLINERULES_EXCLUDED_SUBDIRECTORIES,
const localRuleDirectories = resolveWorkspaceRulesConfigPaths(workingDirectory)
const updatedLocalToggles = await syncRuleTogglesFromFrontmatter(
await synchronizeRuleTogglesAcrossDirectories(
localRuleDirectories,
localClineRulesToggles,
CLINERULES_EXCLUDED_SUBDIRECTORIES,
),
localRuleDirectories,
)
controller.stateManager.setWorkspaceState("localClineRulesToggles", updatedLocalToggles)
@@ -1,5 +1,8 @@
import { isDeepStrictEqual } from "node:util"
import { parseRuleConfigFromMarkdown, parseSkillConfigFromMarkdown } from "@cline/core"
import { stripUtf8Bom } from "@cline/shared"
import * as yaml from "js-yaml"
import { isMap, isScalar, parseDocument } from "yaml"
export type FrontmatterParseResult = {
data: Record<string, unknown>
@@ -28,6 +31,15 @@ export type FrontmatterParseResult = {
parseError?: string
}
/**
* Matches a leading `---` fenced YAML block. Group 1 is the YAML text, group 2
* the remaining document body. Mirrors the SDK's rule/skill loader regex so both
* sides agree on what counts as frontmatter.
*/
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const UTF8_BOM = "\uFEFF"
/**
* Parse YAML frontmatter from markdown content.
*
@@ -41,8 +53,7 @@ export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
// regex below never matches a file that starts with "\uFEFF---" (see cline/cline#12151).
const normalizedMarkdown = stripUtf8Bom(markdown)
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/
const match = normalizedMarkdown.match(frontmatterRegex)
const match = normalizedMarkdown.match(FRONTMATTER_REGEX)
if (!match) {
return { data: {}, body: normalizedMarkdown, hadFrontmatter: false }
@@ -57,3 +68,196 @@ export function parseYamlFrontmatter(markdown: string): FrontmatterParseResult {
return { data: {}, body: normalizedMarkdown, hadFrontmatter: true, parseError: message }
}
}
/**
* True when the frontmatter marks the document as disabled, with the same
* precedence as the SDK loader: a boolean `disabled` wins, and the legacy
* `enabled: false` only counts when `disabled` is absent. Used by the legacy
* rule loader; the SDK-facing paths below ask the SDK parser instead.
*/
export function isFrontmatterDisabled(data: Record<string, unknown>): boolean {
if (typeof data.disabled === "boolean") {
return data.disabled
}
return data.enabled === false
}
export type UserInstructionKind = "rule" | "skill"
const ENABLEMENT_KEYS: ReadonlyArray<string> = ["disabled", "enabled"]
type SourceRange = [number, number, number]
/**
* Whether the SDK would load this document as enabled (`true`), as disabled
* (`false`), or not at all (`undefined`: frontmatter it cannot parse, a
* non-boolean flag, or an empty body).
*
* This is the SDK's own parser, not a reimplementation of its rules, so the
* Rules panel, the write paths, and the editor below can never disagree with
* what the SDK loads.
*/
export function readSdkEnabledState(content: string, kind: UserInstructionKind = "rule"): boolean | undefined {
try {
const config =
kind === "skill" ? parseSkillConfigFromMarkdown(content, "skill") : parseRuleConfigFromMarkdown(content, "rule")
return config.disabled !== true
} catch {
return undefined
}
}
/**
* True when the SDK would load the document in the requested state. Write
* paths check this on the edited document before writing or reporting
* success, so a toggle never claims a state the SDK will not load.
*/
export function hasRequestedEnabledState(content: string, enabled: boolean, kind: UserInstructionKind = "rule"): boolean {
return readSdkEnabledState(content, kind) === enabled
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {}
}
/** The top-level values other than the enablement flags, as the SDK reads them. */
function otherFrontmatterValues(content: string, kind: UserInstructionKind): Record<string, unknown> | undefined {
try {
const config =
kind === "skill" ? parseSkillConfigFromMarkdown(content, "skill") : parseRuleConfigFromMarkdown(content, "rule")
const values = { ...asRecord(config.frontmatter) }
for (const key of ENABLEMENT_KEYS) {
delete values[key]
}
return values
} catch {
return undefined
}
}
/**
* Update the `disabled` frontmatter flag shared by SDK-backed user
* instructions (rules and skills).
*
* The SDK's parser decides every state question (see readSdkEnabledState).
* The frontmatter is then parsed as a YAML document only to find the source
* ranges of the top-level `disabled` and `enabled` entries, and just those
* characters change. Everything else the author wrote, including other
* values, comments, key order, quoting, line endings, and a UTF-8 BOM, stays
* byte for byte.
*
* - Disabling sets an existing `disabled` value to `true`, or appends a
* `disabled: true` entry. A document with no frontmatter, or with
* frontmatter that is not a mapping (which the SDK loads as having no
* metadata), gets a new `disabled: true` block above its existing text.
* - Enabling removes the `disabled` entry and a legacy `enabled: false`; an
* entry that carries a comment keeps its line and has its value set to
* `false` (or `true` for `enabled`) instead, so the comment survives.
* Frontmatter left empty is removed entirely.
* - A document the SDK does not load, or an edit whose result the SDK would
* not load in the requested state or that would change any other value,
* is returned unchanged.
*/
export function updateUserInstructionMarkdownDisabledState(
content: string,
enabled: boolean,
kind: UserInstructionKind = "rule",
): string {
const bom = content.startsWith(UTF8_BOM) ? UTF8_BOM : ""
const text = bom ? content.slice(UTF8_BOM.length) : content
const eol = text.includes("\r\n") ? "\r\n" : "\n"
// Nothing to do for a document the SDK does not load, or one already in
// the requested state.
const currentState = readSdkEnabledState(text, kind)
if (currentState === undefined || currentState === enabled) {
return content
}
const prependDisabledBlock = () => `${bom}---${eol}disabled: true${eol}---${eol}${text}`
const match = text.match(FRONTMATTER_REGEX)
if (!match) {
return enabled ? content : prependDisabledBlock()
}
const [, block, body] = match
const blockStart = text.indexOf("\n") + 1
const document = parseDocument(block)
if (document.errors.length > 0) {
return content
}
// An empty block (whitespace or comments only) is a mapping with no
// entries; the flag is appended inside it so the comments stay in place.
if (document.contents !== null && !isMap(document.contents)) {
// The SDK treats non-mapping frontmatter as no metadata, so the rule is
// enabled and nothing in the block can be edited to disable it.
return enabled ? content : prependDisabledBlock()
}
const before = otherFrontmatterValues(text, kind)
if (!before) {
return content
}
const pairs = isMap(document.contents) ? document.contents.items : []
const rangesFor = (key: string): { key: SourceRange; value: SourceRange } | undefined => {
const pair = pairs.find((item) => isScalar(item.key) && item.key.value === key)
const keyRange = (pair?.key as { range?: SourceRange } | undefined)?.range
const valueRange = (pair?.value as { range?: SourceRange } | null | undefined)?.range
return keyRange && valueRange ? { key: keyRange, value: valueRange } : undefined
}
const edits: Array<{ start: number; end: number; text: string }> = []
const setValue = (key: string, value: string): boolean => {
const ranges = rangesFor(key)
if (!ranges) {
return false
}
edits.push({ start: ranges.value[0], end: ranges.value[1], text: value })
return true
}
const removeEntry = (key: string, fallbackValue: string): boolean => {
const ranges = rangesFor(key)
if (!ranges) {
return false
}
const start = ranges.key[0]
const end = ranges.value[2]
const startsLine = start === 0 || block[start - 1] === "\n"
if (!startsLine || block.slice(start, end).includes("#")) {
return setValue(key, fallbackValue)
}
edits.push({ start, end, text: "" })
return true
}
const flags = asRecord(document.toJS())
if (enabled) {
if (rangesFor("disabled") && flags.disabled !== false && !removeEntry("disabled", "false")) {
return content
}
if (flags.enabled === false && !removeEntry("enabled", "true")) {
return content
}
} else if (rangesFor("disabled")) {
if (!setValue("disabled", "true")) {
return content
}
} else {
edits.push({ start: block.length, end: block.length, text: `${block.length > 0 ? eol : ""}disabled: true` })
}
let nextBlock = block
for (const edit of [...edits].sort((a, b) => b.start - a.start)) {
nextBlock = nextBlock.slice(0, edit.start) + edit.text + nextBlock.slice(edit.end)
}
// Removing the last entry leaves the newline that ended the entry before it.
if (!/\r?\n$/.test(block)) {
nextBlock = nextBlock.replace(/\r?\n$/, "")
}
const nextText =
nextBlock.trim() === "" ? body : `${text.slice(0, blockStart)}${nextBlock}${text.slice(blockStart + block.length)}`
if (readSdkEnabledState(nextText, kind) !== enabled || !isDeepStrictEqual(before, otherFrontmatterValues(nextText, kind))) {
return content
}
return `${bom}${nextText}`
}
@@ -3,10 +3,9 @@ import type { GlobalInstructionsFile } from "@shared/remote-config/schema"
import type { SkillContent, SkillMetadata } from "@shared/skills"
import { fileExistsAtPath, isDirectory } from "@utils/fs"
import * as fs from "fs/promises"
import * as yaml from "js-yaml"
import * as path from "path"
import { Logger } from "@/shared/services/Logger"
import { parseYamlFrontmatter } from "./frontmatter"
import { hasRequestedEnabledState, parseYamlFrontmatter, updateUserInstructionMarkdownDisabledState } from "./frontmatter"
/**
* Update the `disabled` frontmatter flag of a SKILL.md document.
@@ -15,49 +14,12 @@ import { parseYamlFrontmatter } from "./frontmatter"
* skill's enabled state from the SKILL.md frontmatter `disabled` field, not from
* the extension's UI toggle state. Toggling a skill in the VS Code sidebar must
* therefore also write this flag so the change is reflected for the model
* (ENG-1995). This mirrors the SDK's updateSkillMarkdownEnabledState but lives in
* the extension and uses js-yaml (the extension's frontmatter parser).
*
* - enabled=false → sets `disabled: true`.
* - enabled=true → removes `disabled` (and a stale `enabled: false`), dropping
* the frontmatter block entirely if it becomes empty.
*
* Returns the original content unchanged when enabling a document that has no
* frontmatter (nothing to clear).
* (ENG-1995). Rules and skills share one editor; see
* updateUserInstructionMarkdownDisabledState for what it changes and what it
* refuses to edit.
*/
export function updateSkillMarkdownDisabledState(content: string, enabled: boolean): string {
const { data, body, hadFrontmatter, parseError } = parseYamlFrontmatter(content)
if (!hadFrontmatter && enabled) {
return content
}
// parseYamlFrontmatter fails open on malformed YAML: it returns data={} and
// body=<full original content> (frontmatter block included). Serializing here
// would prepend a second `---` block and corrupt the file, so leave it
// untouched and let the user fix the frontmatter.
if (parseError) {
return content
}
if (enabled) {
delete data.disabled
if (data.enabled === false) {
delete data.enabled
}
if (Object.keys(data).length === 0) {
return body
}
return serializeSkillFrontmatter(data, body)
}
data.disabled = true
return serializeSkillFrontmatter(data, body)
}
function serializeSkillFrontmatter(data: Record<string, unknown>, body: string): string {
const yamlText = yaml.dump(data, { schema: yaml.JSON_SCHEMA }).trimEnd()
return `---\n${yamlText}\n---\n${body}`
return updateUserInstructionMarkdownDisabledState(content, enabled, "skill")
}
/**
@@ -71,6 +33,10 @@ export async function setSkillDisabledInFrontmatter(skillMdPath: string, enabled
try {
const content = await fs.readFile(skillMdPath, "utf-8")
const updated = updateSkillMarkdownDisabledState(content, enabled)
if (!hasRequestedEnabledState(updated, enabled, "skill")) {
Logger.warn(`Skill frontmatter at ${skillMdPath} could not be updated; leaving the document untouched`)
return false
}
if (updated !== content) {
await fs.writeFile(skillMdPath, updated)
}
@@ -0,0 +1,183 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { RuleScope, ToggleClineRuleRequest } from "@shared/proto/cline/file"
import fs from "fs/promises"
import os from "os"
import path from "path"
import sinon from "sinon"
import { parseYamlFrontmatter, readSdkEnabledState } from "@/core/context/instructions/user-instructions/frontmatter"
import { HostProvider } from "@/hosts/host-provider"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import { toggleClineRule } from "../toggleClineRule"
let sandbox: sinon.SinonSandbox
let workspace: string
beforeEach(async () => {
sandbox = sinon.createSandbox()
workspace = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "cline-toggle-rule-test-")))
await fs.mkdir(path.join(workspace, ".clinerules"), { recursive: true })
setVscodeHostProviderMock()
// A fresh array per call: getCwd() consumes the array it receives with shift().
sandbox.stub(HostProvider, "workspace").get(() => ({
getWorkspacePaths: async () => ({ paths: [workspace] }),
}))
})
afterEach(async () => {
sandbox.restore()
HostProvider.reset()
await fs.rm(workspace, { recursive: true, force: true })
})
function createController() {
const localToggles: Record<string, boolean> = {}
const globalState = new Map<string, unknown>([
["globalClineRulesToggles", {}],
["remoteRulesToggles", {}],
])
const workspaceState = new Map<string, unknown>([["localClineRulesToggles", localToggles]])
return {
controller: {
stateManager: {
getGlobalSettingsKey: (key: string) => globalState.get(key) ?? {},
getWorkspaceStateKey: (key: string) => workspaceState.get(key) ?? {},
getGlobalStateKey: (key: string) => globalState.get(key) ?? {},
setGlobalState: (key: string, value: unknown) => globalState.set(key, value),
setWorkspaceState: (key: string, value: unknown) => workspaceState.set(key, value),
},
},
localToggles,
}
}
describe("toggleClineRule", () => {
it("persists a workspace rule toggle in both extension state and the rule file the SDK reads", async () => {
const rulePath = path.join(workspace, ".clinerules", "project-rule.md")
await fs.writeFile(rulePath, "Follow this rule")
const { controller, localToggles } = createController()
await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: false }),
)
expect(localToggles[rulePath]).toBe(false)
expect(parseYamlFrontmatter(await fs.readFile(rulePath, "utf-8")).data.disabled).toBe(true)
await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: true }),
)
expect(localToggles[rulePath]).toBe(true)
expect(await fs.readFile(rulePath, "utf-8")).toBe("Follow this rule")
})
it("also writes rules under the .cline/rules layout", async () => {
const rulePath = path.join(workspace, ".cline", "rules", "new-layout.md")
await fs.mkdir(path.dirname(rulePath), { recursive: true })
await fs.writeFile(rulePath, "New layout rule")
const { controller } = createController()
await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: false }),
)
expect(parseYamlFrontmatter(await fs.readFile(rulePath, "utf-8")).data.disabled).toBe(true)
})
it("updates state but leaves the file alone when the path is outside the workspace rule directories", async () => {
const rulePath = path.join(workspace, "README.md")
await fs.writeFile(rulePath, "Not a rule")
const { controller, localToggles } = createController()
await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: false }),
)
expect(localToggles[rulePath]).toBe(false)
expect(await fs.readFile(rulePath, "utf-8")).toBe("Not a rule")
})
it("reverts the state toggle when the rule file cannot be written", async () => {
const rulePath = path.join(workspace, ".clinerules", "read-only.md")
await fs.writeFile(rulePath, "Locked rule")
await fs.chmod(rulePath, 0o444)
const { controller, localToggles } = createController()
try {
const response = await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: false }),
)
expect(localToggles[rulePath]).toBe(true)
expect(response.localClineRulesToggles?.toggles[rulePath]).toBe(true)
expect(await fs.readFile(rulePath, "utf-8")).toBe("Locked rule")
} finally {
await fs.chmod(rulePath, 0o644)
}
})
it("reverts the state toggle when malformed frontmatter prevents a safe edit", async () => {
const rulePath = path.join(workspace, ".clinerules", "broken.md")
const content = "---\npaths: [invalid\n---\nBody"
await fs.writeFile(rulePath, content)
const { controller, localToggles } = createController()
const response = await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: false }),
)
expect(localToggles[rulePath]).toBe(true)
expect(response.localClineRulesToggles?.toggles[rulePath]).toBe(true)
expect(await fs.readFile(rulePath, "utf-8")).toBe(content)
})
it("re-enables a rule whose legacy enabled: false value is on the next line", async () => {
const rulePath = path.join(workspace, ".clinerules", "split-enabled.md")
await fs.writeFile(rulePath, ["---", "disabled: true", "enabled:", " false", "---", "Body"].join("\n"))
const { controller, localToggles } = createController()
localToggles[rulePath] = false
const response = await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: true }),
)
expect(response.localClineRulesToggles?.toggles[rulePath]).toBe(true)
expect(readSdkEnabledState(await fs.readFile(rulePath, "utf-8"))).toBe(true)
})
it("reverts instead of reporting success when the SDK would still not load the rule as requested", async () => {
const rulePath = path.join(workspace, ".clinerules", "non-boolean.md")
const content = ["---", "disabled: [", " true,", "]", "---", "Body"].join("\n")
await fs.writeFile(rulePath, content)
const { controller, localToggles } = createController()
const response = await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: false }),
)
expect(response.localClineRulesToggles?.toggles[rulePath]).toBe(true)
expect(localToggles[rulePath]).toBe(true)
expect(await fs.readFile(rulePath, "utf-8")).toBe(content)
})
it("does not write frontmatter into non-rule files that happen to live in .clinerules", async () => {
const rulePath = path.join(workspace, ".clinerules", "notes.json")
await fs.writeFile(rulePath, "{}")
const { controller } = createController()
await toggleClineRule(
controller as never,
ToggleClineRuleRequest.create({ scope: RuleScope.LOCAL, rulePath, enabled: false }),
)
expect(await fs.readFile(rulePath, "utf-8")).toBe("{}")
})
})
@@ -1,3 +1,8 @@
import {
type RuleFrontmatterWriteResult,
resolveRuleWriteRoots,
setRuleDisabledInFrontmatter,
} from "@core/context/instructions/user-instructions/cline-rules"
import { getWorkspaceBasename } from "@core/workspace"
import type { ToggleClineRuleRequest } from "@shared/proto/cline/file"
import { RuleScope, ToggleClineRules } from "@shared/proto/cline/file"
@@ -47,6 +52,34 @@ export async function toggleClineRule(controller: Controller, request: ToggleCli
throw new Error(`Invalid scope: ${scope}`)
}
// The SDK rule loader reads the document's `disabled` frontmatter flag, not
// the extension's toggle state, so file-backed global and workspace rules
// are written as well. The write is confined to rule documents directly
// inside the scope's rule roots; anything else keeps only the state toggle.
// If a rule file cannot be written, the state toggle is reverted so the
// panel keeps showing what the SDK will actually load.
if (scope !== RuleScope.REMOTE) {
let result: RuleFrontmatterWriteResult = "failed"
try {
const allowedRoots = await resolveRuleWriteRoots(scope === RuleScope.GLOBAL ? "global" : "local")
result = await setRuleDisabledInFrontmatter(rulePath, enabled, allowedRoots)
} catch (error) {
Logger.warn(`toggleClineRule: could not persist frontmatter for ${rulePath}`, error)
}
if (result === "failed") {
Logger.warn(`toggleClineRule: reverting toggle for ${rulePath}; the rule file could not be updated`)
if (scope === RuleScope.GLOBAL) {
const toggles = controller.stateManager.getGlobalSettingsKey("globalClineRulesToggles")
toggles[rulePath] = !enabled
controller.stateManager.setGlobalState("globalClineRulesToggles", toggles)
} else {
const toggles = controller.stateManager.getWorkspaceStateKey("localClineRulesToggles")
toggles[rulePath] = !enabled
controller.stateManager.setWorkspaceState("localClineRulesToggles", toggles)
}
}
}
// Track rule toggle telemetry with current task context
if (controller.task?.ulid) {
// Extract just the filename for privacy (no full paths)
@@ -16,6 +16,7 @@ import {
parseMcpInstallArgs,
readGlobalSettings,
resolvePluginConfigSearchPaths,
SKILLS_CLI_PACKAGE,
setDisabledPlugin,
syncPluginMcpServersToSettings,
uninstallMarketplaceEntry as uninstallCoreMarketplaceEntry,
@@ -366,7 +367,7 @@ async function installPluginMarketplaceEntry(entry: MarketplaceEntry, args: stri
async function installSkillMarketplaceEntry(entry: MarketplaceEntry, args: string[]): Promise<MarketplaceInstallResult> {
const command = "npx"
const commandArgs = ["-y", "skills@latest", "add", ...args, "-g", "-a", "cline", "-y"]
const commandArgs = ["-y", SKILLS_CLI_PACKAGE, "add", ...args, "-g", "-a", "cline", "-y"]
const displayCommand = formatCommand(command, commandArgs)
let result: SpawnResult
try {
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, it } from "mocha"
import { strict as assert } from "assert"
import * as fs from "fs/promises"
import { afterEach, beforeEach, describe, it } from "mocha"
import * as os from "os"
import pWaitFor from "p-wait-for"
import * as path from "path"
@@ -9,23 +9,16 @@ import { getOpenTabs } from "@/hosts/vscode/hostbridge/window/getOpenTabs"
import { GetOpenTabsRequest } from "@/shared/proto/host/window"
describe("Hostbridge - Window - getOpenTabs", () => {
async function createAndOpenTestDocument(name: string, column: vscode.ViewColumn): Promise<void> {
const content = `// Test file ${name}\nconsole.log('Hello from file ${name}');`
// Create an untitled document with a custom name
const uri = vscode.Uri.parse(`untitled:test-file-${name}.js`)
const doc = await vscode.workspace.openTextDocument(uri)
// Set the content
const edit = new vscode.WorkspaceEdit()
edit.insert(uri, new vscode.Position(0, 0), content)
await vscode.workspace.applyEdit(edit)
await vscode.window.showTextDocument(doc, {
viewColumn: column,
preview: false,
async function createAndOpenTestDocument(name: string, column: vscode.ViewColumn): Promise<vscode.TextDocument> {
// Populate an unnamed untitled document before showing it. Applying a
// WorkspaceEdit to a named untitled URI can open it in the active group
// before showTextDocument opens a second copy in the requested group.
const doc = await vscode.workspace.openTextDocument({
language: "javascript",
content: `// Test file ${name}\nconsole.log('Hello from file ${name}');`,
})
await vscode.window.showTextDocument(doc, { viewColumn: column, preview: false })
return doc
}
async function waitForAllTabsClosed(): Promise<void> {
@@ -68,8 +61,8 @@ describe("Hostbridge - Window - getOpenTabs", () => {
it("should return paths of open text document tabs", async () => {
// Open the documents in editors (this creates the tabs)
await createAndOpenTestDocument("open-tabs-1", vscode.ViewColumn.One)
await createAndOpenTestDocument("open-tabs-2", vscode.ViewColumn.Two)
const first = await createAndOpenTestDocument("open-tabs-1", vscode.ViewColumn.One)
const second = await createAndOpenTestDocument("open-tabs-2", vscode.ViewColumn.Two)
// Wait for tabs to be fully created
await pWaitFor(
@@ -90,6 +83,8 @@ describe("Hostbridge - Window - getOpenTabs", () => {
const request = GetOpenTabsRequest.create({})
const response = await getOpenTabs(request)
assert.deepStrictEqual([...response.paths].sort(), [first.uri.fsPath, second.uri.fsPath].sort())
// Should have 2 tabs open
assert.strictEqual(
response.paths.length,
@@ -98,6 +93,15 @@ describe("Hostbridge - Window - getOpenTabs", () => {
)
})
it("should return a tab for each group when the same document is open twice", async () => {
const doc = await createAndOpenTestDocument("two-groups", vscode.ViewColumn.One)
await vscode.window.showTextDocument(doc, { viewColumn: vscode.ViewColumn.Two, preview: false })
const request = GetOpenTabsRequest.create({})
await pWaitFor(async () => (await getOpenTabs(request)).paths.length === 2, { timeout: 8000, interval: 50 })
const response = await getOpenTabs(request)
assert.deepStrictEqual(response.paths, [doc.uri.fsPath, doc.uri.fsPath])
})
it("should return all open tabs even when multiple files are opened in the same ViewColumn", async () => {
// Open all documents in the same column (only the last one will be visible, but all are open as tabs)
await createAndOpenTestDocument("same-column-1", vscode.ViewColumn.One)
@@ -4,6 +4,7 @@ import * as sinon from "sinon"
import * as vscode from "vscode"
import { setVscodeHostProviderMock } from "@/test/host-provider-test-utils"
import { VscodeTerminalManager } from "./VscodeTerminalManager"
import { VscodeTerminalProcess } from "./VscodeTerminalProcess"
import { TerminalInfo, TerminalRegistry } from "./VscodeTerminalRegistry"
function createNeverEndingStream(): AsyncIterable<string> {
@@ -128,6 +129,28 @@ describe("VscodeTerminalManager", () => {
assert.equal(terminalInfo.busy, true)
assert.equal(terminalInfo.pendingCwdChange, undefined)
assert.equal(terminalInfo.cwdResolved, undefined)
sinon.assert.calledOnceWithExactly(terminalInfo.terminal.show as sinon.SinonStub, true)
})
it("reveals the terminal without taking focus when running a command", async () => {
// Reveal-only test: stub the process so no command stream or VS Code
// event listeners outlive the test.
sandbox.stub(VscodeTerminalProcess.prototype, "run").resolves()
const showStub = sandbox.stub()
const terminalInfo: TerminalInfo = {
id: 1,
busy: false,
lastCommand: "",
lastActive: Date.now(),
terminal: {
shellIntegration: {},
show: showStub,
} as unknown as vscode.Terminal,
}
manager.runCommand(terminalInfo as unknown as Parameters<VscodeTerminalManager["runCommand"]>[0], "echo hi")
sinon.assert.calledOnceWithExactly(showStub, true)
})
it("releases a reused terminal reservation when showing it fails", async () => {
@@ -210,7 +210,10 @@ export class VscodeTerminalManager {
Logger.log(`[TerminalManager] Terminal ${vscodeTerminalInfo.id} busy state before: ${vscodeTerminalInfo.busy}`)
try {
vscodeTerminalInfo.terminal.show()
// Reveal the terminal but keep keyboard focus where it is: focusing the
// terminal mid-command would send the user's in-progress chat input to
// the shell.
vscodeTerminalInfo.terminal.show(true)
} catch (error) {
vscodeTerminalInfo.busy = false
throw error
@@ -379,7 +382,8 @@ export class VscodeTerminalManager {
})
// Showing the reused terminal gives VS Code a chance to initialize shell integration.
// runCommand() below waits up to shellIntegrationTimeout for executeCommand before falling back.
availableTerminal.terminal.show()
// preserveFocus keeps the user's keyboard focus (e.g. the chat input) where it was.
availableTerminal.terminal.show(true)
let cwdChangeResult: CwdChangeResult | undefined
try {
+74 -5
View File
@@ -562,9 +562,11 @@ describe("hasWorkspaceCheckpointForMessage", () => {
const readLiveMessages = vi.fn().mockResolvedValue(sdkMessages)
const readMessages = vi.fn().mockResolvedValue(sdkMessages.slice(0, 2))
const restore = vi.fn().mockRejectedValue(new Error("stop at restore"))
const controller = {
// The prototype supplies the private implementation the public method delegates to.
const controller = Object.assign(Object.create(SdkController.prototype), {
task: { taskId: "task-a", messageStateHandler: { getClineMessages: () => messages } },
sessions: {
trackSessionStart: (operation: () => Promise<unknown>) => operation(),
getActiveSession: () => ({
sessionId: "task-a",
isRunning: false,
@@ -584,7 +586,7 @@ describe("hasWorkspaceCheckpointForMessage", () => {
stateManager: { getGlobalSettingsKey: () => "act" },
sessionConfigBuilder: { build: async () => ({ providerId: "anthropic", apiKey: "key", modelId: "model" }) },
resolveContextMentions: async (text: string) => text,
}
})
await expect(SdkController.prototype.hasWorkspaceCheckpointForMessage.call(controller as never, 3)).resolves.toBe(true)
await expect(
@@ -598,6 +600,68 @@ describe("hasWorkspaceCheckpointForMessage", () => {
expect(readMessages).not.toHaveBeenCalled()
})
it("runs message edits and checkpoint restores inside a tracked session start", async () => {
// Both read the mode setting and swap the session outside the rebuild
// lock, so a mode toggle must be able to wait for them to finish.
let tracking = false
const trackedRestores: boolean[] = []
const restore = vi.fn(async () => {
trackedRestores.push(tracking)
throw new Error("stop at restore")
})
const restoreActiveSession = vi.fn(async () => {
trackedRestores.push(tracking)
return {}
})
const controller = Object.assign(Object.create(SdkController.prototype), {
task: { taskId: "task-a", messageStateHandler: { getClineMessages: () => messages } },
sessions: {
trackSessionStart: async (operation: () => Promise<unknown>) => {
tracking = true
try {
return await operation()
} finally {
tracking = false
}
},
restoreActiveSession,
getActiveSession: () => ({
sessionId: "task-a",
isRunning: false,
sdkHost: {
get: async () => ({
cwd: "C:/work",
metadata: { checkpoint: { history: [{ ref: "checkpoint-b", createdAt: 1, runCount: 2 }] } },
}),
readLiveMessages: vi.fn().mockResolvedValue(sdkMessages),
readMessages: vi.fn().mockResolvedValue(sdkMessages),
restore,
},
}),
},
taskHistory: { findHistoryItem: async () => undefined },
getWorkspaceRoot: async () => "C:/work",
stateManager: { getGlobalSettingsKey: () => "act" },
sessionConfigBuilder: { build: async () => ({ providerId: "anthropic", apiKey: "key", modelId: "model" }) },
resolveContextMentions: async (text: string) => text,
postStateToWebview: async () => undefined,
})
await expect(
SdkController.prototype.editMessageAndRegenerate.call(controller as never, {
messageTs: 3,
text: "continue, edited",
restoreWorkspace: true,
}),
).rejects.toThrow("stop at restore")
await SdkController.prototype.restoreCheckpoint.call(controller as never, {
checkpointRunCount: 2,
restoreType: "workspace",
})
expect(trackedRestores).toEqual([true, true])
})
it("uses and disposes a temporary host for a history task", async () => {
const tempHost = {
get: vi.fn().mockResolvedValue({
@@ -799,11 +863,16 @@ describe("cloud tasks stay in the cloud", () => {
it("refuses to rebuild a cloud task's conversation as a local session", async () => {
const startNewSession = vi.fn()
const controller = {
// The prototype supplies the private implementation the public method delegates to.
const controller = Object.assign(Object.create(SdkController.prototype), {
task: { taskId: "ses-cloud", messageStateHandler: { getClineMessages: () => [] } },
sessions: { getActiveSession: () => undefined, startNewSession },
sessions: {
getActiveSession: () => undefined,
startNewSession,
trackSessionStart: (operation: () => Promise<unknown>) => operation(),
},
stateManager,
}
})
await expect(
SdkController.prototype.editMessageAndRegenerate.call(controller as never, { messageTs: 1, text: "edited" }),

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