mirror of
https://github.com/cline/cline.git
synced 2026-10-25 10:26:36 +08:00
Merge remote-tracking branch 'origin/main' into 20261016a-vmp-next-desktop
# Conflicts: # bun.lock
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/) | - |
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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`),
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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,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/",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
@@ -146,6 +146,7 @@ function App(props: TuiProps) {
|
||||
} = useSlashCommands({
|
||||
workflowSlashCommands,
|
||||
loadAdditionalSlashCommands: props.loadAdditionalSlashCommands,
|
||||
subscribeAdditionalSlashCommands: props.subscribeAdditionalSlashCommands,
|
||||
canFork: canForkSession,
|
||||
});
|
||||
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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__;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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": {
|
||||
|
||||
+5
-2
@@ -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: {
|
||||
|
||||
+229
@@ -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")
|
||||
})
|
||||
})
|
||||
+219
-1
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user