mirror of
https://github.com/cline/cline.git
synced 2026-09-17 17:45:33 +08:00
Compare commits
83
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92f6e28f13 | ||
|
|
27a78f0248 | ||
|
|
2c4980f42a | ||
|
|
64c5e48edb | ||
|
|
3c23f80a94 | ||
|
|
4be362892f | ||
|
|
cdff084652 | ||
|
|
299a4a9520 | ||
|
|
c497698beb | ||
|
|
292133989b | ||
|
|
f5aa035a6a | ||
|
|
bbcae25542 | ||
|
|
9b914912ac | ||
|
|
8c369eeed9 | ||
|
|
85223a07cf | ||
|
|
eb2687677c | ||
|
|
6fe5acb2a4 | ||
|
|
99fb5101e5 | ||
|
|
58db9887ee | ||
|
|
b16a28cd6f | ||
|
|
114aa3f161 | ||
|
|
64743d2911 | ||
|
|
d7292993e9 | ||
|
|
8110cc46d8 | ||
|
|
a3cd39da14 | ||
|
|
a51b156383 | ||
|
|
7966bb9f21 | ||
|
|
d92e4797ed | ||
|
|
0de0c94555 | ||
|
|
94b82fca3c | ||
|
|
ba2f2284e8 | ||
|
|
b7f955c78b | ||
|
|
8433549327 | ||
|
|
860f544ab8 | ||
|
|
0761de984b | ||
|
|
940b43f72f | ||
|
|
a466060f79 | ||
|
|
2fd944cb84 | ||
|
|
7d1abb25a7 | ||
|
|
628aaa0675 | ||
|
|
af7fda87c0 | ||
|
|
9af6ced896 | ||
|
|
a3258dd79a | ||
|
|
ebb05ed963 | ||
|
|
8d03d176f2 | ||
|
|
32b3cfc081 | ||
|
|
a6500a07b4 | ||
|
|
81384089c4 | ||
|
|
6364792c47 | ||
|
|
40fc8879f1 | ||
|
|
e91cba4045 | ||
|
|
e5e1aa3455 | ||
|
|
d2893d2e93 | ||
|
|
4fc366df5f | ||
|
|
d8eb06318b | ||
|
|
fe4eb44c6b | ||
|
|
6c52bdc177 | ||
|
|
5260595472 | ||
|
|
a279388451 | ||
|
|
7810a81efe | ||
|
|
2a54e2a76e | ||
|
|
b7c38f76c9 | ||
|
|
d20e517831 | ||
|
|
fa3630da47 | ||
|
|
8229d0c9be | ||
|
|
efa14b6cab | ||
|
|
c10b417b78 | ||
|
|
9958e3f354 | ||
|
|
ec75291d5b | ||
|
|
a3a31da37d | ||
|
|
a69d650838 | ||
|
|
7934d367a9 | ||
|
|
7f9d5461f1 | ||
|
|
e1bdeeff68 | ||
|
|
49897830bb | ||
|
|
1f316a2734 | ||
|
|
9c1f9133c7 | ||
|
|
2faef2b40d | ||
|
|
64829bca8c | ||
|
|
4c9ba6b091 | ||
|
|
6138bdfe40 | ||
|
|
7d119351b1 | ||
|
|
de987a5246 |
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## [3.89.2]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Complete the fix for the Anthropic provider on VS Code 1.123 and later by upgrading the bundled Anthropic SDK to a release compatible with the Node 24 runtime.
|
||||
- Update the Vertex AI provider to a compatible Anthropic Vertex SDK release so it works with the upgraded Anthropic SDK.
|
||||
|
||||
## [3.89.1]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Restore the Anthropic provider on VS Code 1.123 and later, where the updated Node 24 runtime broke the bundled Anthropic SDK.
|
||||
- Handle the DeepSeek V4 reasoning format.
|
||||
|
||||
## [3.89.0]
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.28
|
||||
|
||||
- Added a ClinePass onboarding flow with selectable ClinePass models, plus improved ClinePass error handling
|
||||
- Added hub primitive catalogs and refreshed the hub dashboard design with a dedicated customizations breakout
|
||||
- Auto-approve toggles now apply immediately when changed
|
||||
- Feature flags now resolve using your user ID on startup
|
||||
- Fixed Cline model display names so they resolve by model name
|
||||
- Truncate large tool results by default (including MCP and custom tool output) to keep requests within context budget
|
||||
- Hardened parallel tool-call guidance for faster, more reliable multi-tool execution
|
||||
|
||||
## 3.0.27
|
||||
|
||||
- Added a `cline skill` command to install and manage skills, matching `cline plugin install` and `cline mcp` (installs default to the Cline agent directory)
|
||||
- Added a prefilled MCP install wizard command for quicker MCP server setup
|
||||
- Improved error handling and messaging when plugin MCP OAuth authorization fails
|
||||
- The CLI now rejects unknown commands and unquoted multi-word input with a clear error instead of silently treating bad arguments as a prompt
|
||||
|
||||
## 3.0.26
|
||||
|
||||
- Reverted the expandable model picker sections and ClinePass models, restoring the previous model-selection UI
|
||||
|
||||
## 3.0.25
|
||||
|
||||
- Added ClinePass support, with selectable ClinePass models in the model picker
|
||||
- Made model picker sections expandable
|
||||
- Added MCP server support to plugins, including authorizing plugin MCP OAuth during install
|
||||
- Encouraged parallel tool calls for faster task execution
|
||||
- Capped tool output for bash commands and file reads to keep large output within context limits
|
||||
- Allowed ranged reads on large files
|
||||
- Fixed apply_patch to fail when a hunk is skipped
|
||||
- Fixed run_commands to return captured stdout on failure and handle split heredocs
|
||||
- Fixed search tools to treat zero results as success
|
||||
- Fixed disabled-reasoning handling for StepFun flash
|
||||
- Fixed history resume rendering isolation
|
||||
- Fixed the Hugging Face URL
|
||||
- Fixed Cline OAuth token formatting in provider config
|
||||
|
||||
## 3.0.24
|
||||
|
||||
- Plugin commands can now submit prompts to the agent
|
||||
- Added support for overriding the API base URL
|
||||
- Open the verification URL automatically when starting device authentication
|
||||
- Enforced a single shared Cline Hub, so a stale hub is respawned after an upgrade
|
||||
- Suppressed flickering console windows on Windows
|
||||
- Fixed truncation of structured tool operation result strings so oversized tool output stays within limits
|
||||
- Stopped echoing the full command text in run_commands tool results
|
||||
|
||||
## 3.0.23
|
||||
|
||||
- Fixed Vertex AI GCP settings configuration
|
||||
- Fixed the Azure Foundry API version
|
||||
- Added support for configured agents as subagent tools
|
||||
- Centralized OAuth management into the SDK
|
||||
- Fixed an error caused by disabled reasoning on Fable 5
|
||||
|
||||
## 3.0.22
|
||||
|
||||
- Added support for the Claude Fable 5 model
|
||||
|
||||
@@ -163,6 +163,30 @@ cline auth --provider anthropic --apikey sk-... --modelid claude-sonnet-4-6
|
||||
cline auth --provider openai-native --apikey sk-... --modelid gpt-5 --baseurl https://api.example.com/v1
|
||||
```
|
||||
|
||||
### MCP servers
|
||||
|
||||
Manage MCP servers with the interactive wizard:
|
||||
|
||||
```sh
|
||||
cline mcp
|
||||
cline config mcp
|
||||
```
|
||||
|
||||
Open the add-server wizard with the name, transport, and command or URL already filled in with `cline mcp install` (`cline mcp add` also works). Stdio servers use everything after `--` as the command and arguments:
|
||||
|
||||
```sh
|
||||
cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp
|
||||
```
|
||||
|
||||
Remote HTTP and SSE servers take a name, transport, and URL. The wizard still asks for auth details before saving:
|
||||
|
||||
```sh
|
||||
cline mcp install ctx7 --transport http https://mcp.context7.com/mcp
|
||||
cline mcp install events --transport sse https://example.com/sse
|
||||
```
|
||||
|
||||
Because this command opens the wizard, it requires a TTY.
|
||||
|
||||
### Connectors
|
||||
|
||||
Bridge a chat surface into RPC-backed Cline sessions. Each conversation thread maps to a session with full context. Supported platforms: Telegram, Slack, Google Chat, WhatsApp, and Linear.
|
||||
|
||||
@@ -85,6 +85,20 @@ const result = await Bun.build({
|
||||
],
|
||||
define: {
|
||||
"process.env.NODE_ENV": '"production"',
|
||||
...(process.env.TELEMETRY_SERVICE_API_KEY
|
||||
? {
|
||||
"process.env.TELEMETRY_SERVICE_API_KEY": defineProcessEnv(
|
||||
"TELEMETRY_SERVICE_API_KEY",
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(process.env.ERROR_SERVICE_API_KEY
|
||||
? {
|
||||
"process.env.ERROR_SERVICE_API_KEY": defineProcessEnv(
|
||||
"ERROR_SERVICE_API_KEY",
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
"process.env.OTEL_TELEMETRY_ENABLED": defineProcessEnv(
|
||||
"OTEL_TELEMETRY_ENABLED",
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@cline/cli",
|
||||
"displayName": "cline",
|
||||
"version": "3.0.22",
|
||||
"version": "3.0.28",
|
||||
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
|
||||
"type": "module",
|
||||
"publishConfig": {
|
||||
@@ -87,6 +87,7 @@
|
||||
"open": "^10.2.0",
|
||||
"opentui-spinner": "^0.0.6",
|
||||
"pino": "^10.3.1",
|
||||
"posthog-node": "^5.8.0",
|
||||
"react": "19.2.4",
|
||||
"react-devtools-core": "^7.0.1",
|
||||
"react-reconciler": "0.32.0",
|
||||
|
||||
@@ -746,6 +746,27 @@ Break work into clear steps.`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("routes mcp install and requires a TTY for the prefilled wizard", () => {
|
||||
const result = runCli(
|
||||
[
|
||||
"mcp",
|
||||
"install",
|
||||
"fs",
|
||||
"--",
|
||||
"npx",
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp",
|
||||
],
|
||||
{ env: createIsolatedEnv() },
|
||||
);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(asText(result.stderr)).toContain(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY.",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists available tools", () => {
|
||||
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-home-"));
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-e2e-data-"));
|
||||
|
||||
@@ -18,6 +18,8 @@ interface KeyStep {
|
||||
const INITIAL_RENDER_DELAY_SECONDS = 2.5;
|
||||
const POST_ACTION_SETTLE_SECONDS = 1.0;
|
||||
const INTERACTIVE_TEST_TIMEOUT_MS = 40_000;
|
||||
const HISTORY_PICKER_READY_DELAY_SECONDS = 8.0;
|
||||
const HISTORY_RESUME_READY_DELAY_SECONDS = 15.0;
|
||||
|
||||
function normalizeTerminalOutput(output: string): string {
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: this regex intentionally strips ANSI escape sequences
|
||||
@@ -51,16 +53,40 @@ function buildScriptCommand(scriptedInput: string, launchArgs: string): string {
|
||||
return `(${scriptedInput}) | script ${quietFlag} /dev/null ${toShellSingleQuotedLiteral(bunExec)} ${launchArgs}`;
|
||||
}
|
||||
|
||||
function runInteractiveCli(
|
||||
steps: KeyStep[],
|
||||
options?: { launchConfigView?: boolean },
|
||||
): CliResult {
|
||||
function createCliEnv(): NodeJS.ProcessEnv {
|
||||
const homeDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-home-"));
|
||||
const dataDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-data-"));
|
||||
const sessionDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-sessions-"));
|
||||
const teamDir = mkdtempSync(path.join(os.tmpdir(), "cli-int-teams-"));
|
||||
tempDirs.push(homeDir, dataDir, sessionDir, teamDir);
|
||||
|
||||
return {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
|
||||
CLINE_SESSION_DATA_DIR: sessionDir,
|
||||
CLINE_TEAM_DATA_DIR: teamDir,
|
||||
CLINE_SESSION_BACKEND_MODE: "local",
|
||||
CLINE_PROVIDER_SETTINGS_PATH: path.join(
|
||||
dataDir,
|
||||
"settings",
|
||||
"providers.json",
|
||||
),
|
||||
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
|
||||
};
|
||||
}
|
||||
|
||||
function runInteractiveCli(
|
||||
steps: KeyStep[],
|
||||
options?: {
|
||||
launchConfigView?: boolean;
|
||||
launchArgs?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
},
|
||||
): CliResult {
|
||||
const env = options?.env ?? createCliEnv();
|
||||
|
||||
const scriptedInput = [
|
||||
...steps,
|
||||
// Exit each interactive run explicitly so tests do not idle until timeout.
|
||||
@@ -80,9 +106,13 @@ function runInteractiveCli(
|
||||
"-k",
|
||||
"test-key",
|
||||
];
|
||||
const launchArgs = [
|
||||
...(options?.launchConfigView ? [...baseArgs, "config"] : baseArgs),
|
||||
]
|
||||
const launchArgs = (
|
||||
options?.launchArgs
|
||||
? [cliEntry, ...options.launchArgs]
|
||||
: options?.launchConfigView
|
||||
? [...baseArgs, "config"]
|
||||
: baseArgs
|
||||
)
|
||||
.map((arg) => toShellSingleQuotedLiteral(arg))
|
||||
.join(" ");
|
||||
const command = buildScriptCommand(scriptedInput, launchArgs);
|
||||
@@ -90,21 +120,7 @@ function runInteractiveCli(
|
||||
return spawnSync("bash", ["-lc", command], {
|
||||
cwd: cliRoot,
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: homeDir,
|
||||
CLINE_DATA_DIR: dataDir,
|
||||
CLINE_DB_DATA_DIR: path.join(dataDir, "db"),
|
||||
CLINE_SESSION_DATA_DIR: sessionDir,
|
||||
CLINE_TEAM_DATA_DIR: teamDir,
|
||||
CLINE_SESSION_BACKEND_MODE: "local",
|
||||
CLINE_PROVIDER_SETTINGS_PATH: path.join(
|
||||
dataDir,
|
||||
"settings",
|
||||
"providers.json",
|
||||
),
|
||||
CLINE_HOOKS_LOG_PATH: path.join(dataDir, "logs", "hooks.jsonl"),
|
||||
},
|
||||
env,
|
||||
timeout: INTERACTIVE_TEST_TIMEOUT_MS,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
@@ -188,6 +204,62 @@ describe("cli interactive e2e", () => {
|
||||
expect(output).toContain("/ for commands · @ for files");
|
||||
});
|
||||
|
||||
it("resumes a history-picked session and survives Ctrl+C without a native crash", {
|
||||
timeout: 120_000,
|
||||
}, () => {
|
||||
const env = createCliEnv();
|
||||
// Seed one session; the invalid key makes the run fail fast while
|
||||
// still persisting a resumable session record.
|
||||
const seed = spawnSync(
|
||||
bunExec,
|
||||
[
|
||||
cliEntry,
|
||||
"--provider",
|
||||
"anthropic",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"-k",
|
||||
"test-key",
|
||||
"hello",
|
||||
],
|
||||
{ cwd: cliRoot, encoding: "utf8", env, timeout: 60_000 },
|
||||
);
|
||||
expect(seed.error).toBeUndefined();
|
||||
const history = spawnSync(bunExec, [cliEntry, "history", "--json"], {
|
||||
cwd: cliRoot,
|
||||
encoding: "utf8",
|
||||
env,
|
||||
timeout: 60_000,
|
||||
});
|
||||
expect(history.error).toBeUndefined();
|
||||
expect(history.status).toBe(0);
|
||||
const historyRows = JSON.parse(history.stdout) as unknown[];
|
||||
expect(historyRows.length).toBeGreaterThan(0);
|
||||
|
||||
// history picker -> Enter resumes the seeded session in the
|
||||
// interactive TUI -> double Ctrl+C exits it. Regression guard for
|
||||
// the Bun "panic(main thread): Segmentation fault" that occurred
|
||||
// when the resumed TUI shared the picker's process (a second
|
||||
// OpenTUI renderer in one process crashes natively on teardown).
|
||||
const result = runInteractiveCli(
|
||||
[
|
||||
// Select the seeded session in the picker.
|
||||
{ delaySeconds: HISTORY_PICKER_READY_DELAY_SECONDS, input: "\r" },
|
||||
// Give the resumed TUI time to start, then double-press
|
||||
// Ctrl+C; the harness appends the final press 0.2s later.
|
||||
{ delaySeconds: HISTORY_RESUME_READY_DELAY_SECONDS, input: "\u0003" },
|
||||
],
|
||||
{ launchArgs: ["history"], env },
|
||||
);
|
||||
const output = outputOf(result);
|
||||
// The exit summary only prints after the resumed interactive TUI ran
|
||||
// and shut down cleanly; the history picker alone never prints it.
|
||||
expect(output).toContain("Session Summary");
|
||||
expect(output).not.toContain("panic(");
|
||||
expect(output).not.toContain("Segmentation fault");
|
||||
expect(result.status).toBe(0);
|
||||
});
|
||||
|
||||
it("launches config view directly with `cline config`", () => {
|
||||
const result = runInteractiveCli(
|
||||
[{ delaySeconds: INITIAL_RENDER_DELAY_SECONDS, input: "" }],
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
createOAuthClientCallbacks,
|
||||
ensureCustomProvidersLoaded,
|
||||
getProviderAuthHandler,
|
||||
listLocalProviders,
|
||||
loginAndSaveProviderOAuthCredentials,
|
||||
type ProviderSettings,
|
||||
type ProviderSettingsManager,
|
||||
@@ -22,6 +21,8 @@ import {
|
||||
type OAuthCredentials,
|
||||
toProviderApiKey,
|
||||
} from "../utils/provider-auth";
|
||||
import { listLocalProviders } from "../utils/provider-catalog";
|
||||
import { identifyTelemetryAccount } from "../utils/telemetry";
|
||||
|
||||
export {
|
||||
getPersistedProviderApiKey,
|
||||
@@ -434,11 +435,15 @@ export async function runAuthProviderCommand(
|
||||
return 1;
|
||||
}
|
||||
try {
|
||||
await loginAndSaveProviderOAuthCredentials(
|
||||
const settings = await loginAndSaveProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
providerId,
|
||||
{ callbacks: createOAuthCallbacks(io) },
|
||||
);
|
||||
identifyTelemetryAccount({
|
||||
id: settings.auth?.accountId,
|
||||
provider: providerId,
|
||||
});
|
||||
io.writeln(
|
||||
`${c.green}You are now logged in to ${c.cyan}${providerId}${c.reset}`,
|
||||
);
|
||||
|
||||
@@ -6,6 +6,15 @@ import { runDashboardCommand, waitForProcessShutdown } from "./dashboard";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"WORKSPACE_ROOT",
|
||||
"CLINE_DIR",
|
||||
"CLINE_SANDBOX",
|
||||
"CLINE_SANDBOX_DATA_DIR",
|
||||
"CLINE_DATA_DIR",
|
||||
"CLINE_DB_DATA_DIR",
|
||||
"CLINE_SESSION_DATA_DIR",
|
||||
"CLINE_TEAM_DATA_DIR",
|
||||
"CLINE_PROVIDER_SETTINGS_PATH",
|
||||
"CLINE_HOOKS_LOG_PATH",
|
||||
"HOST",
|
||||
"CLINE_HUB_DASHBOARD_PORT",
|
||||
"PUBLIC_URL",
|
||||
@@ -38,6 +47,9 @@ describe("runDashboardCommand", () => {
|
||||
let observedEnv:
|
||||
| {
|
||||
workspaceRoot: string | undefined;
|
||||
clineDir: string | undefined;
|
||||
clineDataDir: string | undefined;
|
||||
providerSettingsPath: string | undefined;
|
||||
host: string | undefined;
|
||||
port: string | undefined;
|
||||
publicUrl: string | undefined;
|
||||
@@ -50,7 +62,9 @@ describe("runDashboardCommand", () => {
|
||||
process.env.CLINE_HUB_WEBVIEW_DIST_DIR = webviewDistDir;
|
||||
|
||||
const exitCode = await runDashboardCommand({
|
||||
configDir: "/tmp/cline-config",
|
||||
cwd: "sdk",
|
||||
dataDir: ".cline-dashboard-data",
|
||||
host: "127.0.0.1",
|
||||
port: "9090",
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
@@ -62,6 +76,9 @@ describe("runDashboardCommand", () => {
|
||||
startServer: async () => {
|
||||
observedEnv = {
|
||||
workspaceRoot: process.env.WORKSPACE_ROOT,
|
||||
clineDir: process.env.CLINE_DIR,
|
||||
clineDataDir: process.env.CLINE_DATA_DIR,
|
||||
providerSettingsPath: process.env.CLINE_PROVIDER_SETTINGS_PATH,
|
||||
host: process.env.HOST,
|
||||
port: process.env.CLINE_HUB_DASHBOARD_PORT,
|
||||
publicUrl: process.env.PUBLIC_URL,
|
||||
@@ -87,6 +104,13 @@ describe("runDashboardCommand", () => {
|
||||
expect(exitCode).toBe(0);
|
||||
expect(observedEnv).toEqual({
|
||||
workspaceRoot: resolve("sdk"),
|
||||
clineDir: "/tmp/cline-config",
|
||||
clineDataDir: resolve("sdk", ".cline-dashboard-data"),
|
||||
providerSettingsPath: join(
|
||||
resolve("sdk", ".cline-dashboard-data"),
|
||||
"settings",
|
||||
"providers.json",
|
||||
),
|
||||
host: "127.0.0.1",
|
||||
port: "9090",
|
||||
publicUrl: "http://127.0.0.1:9090",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { arch, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import open from "open";
|
||||
import { configureSandboxEnvironment } from "../utils/helpers";
|
||||
import { c } from "../utils/output";
|
||||
|
||||
export interface DashboardServerHandle {
|
||||
@@ -19,7 +20,9 @@ interface DashboardCommandIo {
|
||||
}
|
||||
|
||||
export interface RunDashboardCommandOptions {
|
||||
configDir?: string;
|
||||
cwd?: string;
|
||||
dataDir?: string;
|
||||
host?: string;
|
||||
port?: string;
|
||||
publicUrl?: string;
|
||||
@@ -36,10 +39,9 @@ const WEBVIEW_DIST_ENV = "CLINE_HUB_WEBVIEW_DIST_DIR";
|
||||
|
||||
function setEnvValue(name: string, value: string | undefined): () => void {
|
||||
const previous = process.env[name];
|
||||
if (value === undefined) {
|
||||
return () => {};
|
||||
if (value !== undefined) {
|
||||
process.env[name] = value;
|
||||
}
|
||||
process.env[name] = value;
|
||||
return () => {
|
||||
if (previous === undefined) {
|
||||
delete process.env[name];
|
||||
@@ -49,21 +51,39 @@ function setEnvValue(name: string, value: string | undefined): () => void {
|
||||
};
|
||||
}
|
||||
|
||||
const SANDBOX_ENV_KEYS = [
|
||||
"CLINE_SANDBOX",
|
||||
"CLINE_SANDBOX_DATA_DIR",
|
||||
"CLINE_DATA_DIR",
|
||||
"CLINE_DB_DATA_DIR",
|
||||
"CLINE_SESSION_DATA_DIR",
|
||||
"CLINE_TEAM_DATA_DIR",
|
||||
"CLINE_PROVIDER_SETTINGS_PATH",
|
||||
"CLINE_HOOKS_LOG_PATH",
|
||||
] as const;
|
||||
|
||||
async function withDashboardEnvironment<T>(
|
||||
options: RunDashboardCommandOptions,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const cwd = options.cwd ? resolve(options.cwd) : process.cwd();
|
||||
const restore = [
|
||||
setEnvValue(
|
||||
"WORKSPACE_ROOT",
|
||||
options.cwd ? resolve(options.cwd) : undefined,
|
||||
),
|
||||
setEnvValue("WORKSPACE_ROOT", options.cwd ? cwd : undefined),
|
||||
setEnvValue("CLINE_DIR", options.configDir?.trim() || undefined),
|
||||
setEnvValue("HOST", options.host),
|
||||
setEnvValue(DASHBOARD_PORT_ENV, options.port),
|
||||
setEnvValue("PUBLIC_URL", options.publicUrl),
|
||||
setEnvValue("ROOM_SECRET", options.roomSecret),
|
||||
setEnvValue(WEBVIEW_DIST_ENV, resolveDefaultWebviewDistDir()),
|
||||
...SANDBOX_ENV_KEYS.map((key) => setEnvValue(key, undefined)),
|
||||
];
|
||||
if (options.dataDir || process.env.CLINE_SANDBOX?.trim() === "1") {
|
||||
configureSandboxEnvironment({
|
||||
enabled: true,
|
||||
cwd,
|
||||
explicitDir: options.dataDir,
|
||||
});
|
||||
}
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getCliBuildInfo } from "../utils/common";
|
||||
const {
|
||||
mockSpawnSync,
|
||||
mockResolveClineDataDir,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockReadHubDiscovery,
|
||||
mockProbeHubServer,
|
||||
@@ -24,6 +25,15 @@ const {
|
||||
} = vi.hoisted(() => ({
|
||||
mockSpawnSync: vi.fn(),
|
||||
mockResolveClineDataDir: vi.fn(() => "/tmp/cline-data"),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: path.join(
|
||||
"/tmp/cline-data",
|
||||
"locks",
|
||||
"hub",
|
||||
"production.json",
|
||||
),
|
||||
})),
|
||||
mockResolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: path.join(
|
||||
@@ -52,6 +62,7 @@ vi.mock("node:child_process", () => ({
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
resolveClineDataDir: mockResolveClineDataDir,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
@@ -76,6 +87,15 @@ describe("runDoctorCommand", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockResolveClineDataDir.mockReturnValue("/tmp/cline-data");
|
||||
mockResolveProductionHubOwnerContext.mockReturnValue({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: path.join(
|
||||
"/tmp/cline-data",
|
||||
"locks",
|
||||
"hub",
|
||||
"production.json",
|
||||
),
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
mockStopAllConnectors.mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
@@ -110,7 +130,8 @@ describe("runDoctorCommand", () => {
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "/apps/cli/src/index.ts"
|
||||
args[1] === "--" &&
|
||||
args[2] === "/apps/cli/src/index.ts"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
@@ -261,7 +282,8 @@ describe("runDoctorCommand", () => {
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "/src-tauri/bin/code-sidecar"
|
||||
args[1] === "--" &&
|
||||
args[2] === "/src-tauri/bin/code-sidecar"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
|
||||
@@ -7,10 +7,11 @@ import {
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveClineDataDir,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime } from "@cline/shared";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
@@ -54,6 +55,7 @@ type DoctorStatus = {
|
||||
hubStartedAt?: string;
|
||||
hubUptime?: string;
|
||||
listeningPids: number[];
|
||||
staleHubPids: number[];
|
||||
hubStartupLocks: StartupArtifact[];
|
||||
staleCliPids: number[];
|
||||
staleSidecarPids: number[];
|
||||
@@ -77,7 +79,11 @@ function listMatchingProcesses(pattern: string): ProcessRecord[] {
|
||||
if (process.platform === "win32") {
|
||||
return [];
|
||||
}
|
||||
const result = spawnSync("pgrep", ["-fal", pattern], { encoding: "utf8" });
|
||||
// "--" stops pgrep's option parsing so patterns that start with dashes
|
||||
// (e.g. the "--cline-hub-daemon" marker) are treated as patterns.
|
||||
const result = spawnSync("pgrep", ["-fal", "--", pattern], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
if (result.status !== 0 && result.status !== 1) {
|
||||
return [];
|
||||
}
|
||||
@@ -148,6 +154,25 @@ function listStaleCliPids(): number[] {
|
||||
.map((record) => record.pid);
|
||||
}
|
||||
|
||||
function listStaleHubPids(currentHubPids: number[]): number[] {
|
||||
const current = new Set(currentHubPids.filter((pid) => pid > 0));
|
||||
const patterns = [
|
||||
"/sdk/packages/core/src/hub/daemon/entry.ts",
|
||||
"/sdk/packages/core/dist/hub/daemon/entry.js",
|
||||
"--cline-hub-daemon",
|
||||
];
|
||||
const records = new Map<number, ProcessRecord>();
|
||||
for (const pattern of patterns) {
|
||||
for (const record of listMatchingProcesses(pattern)) {
|
||||
if (current.has(record.pid) || /\bpgrep\s+-fal\b/.test(record.command)) {
|
||||
continue;
|
||||
}
|
||||
records.set(record.pid, record);
|
||||
}
|
||||
}
|
||||
return [...records.values()].map((record) => record.pid);
|
||||
}
|
||||
|
||||
function listStaleSidecarPids(): number[] {
|
||||
const patterns = [
|
||||
"/apps/examples/desktop-app/sidecar/index.ts",
|
||||
@@ -235,7 +260,7 @@ function readStartupArtifact(path: string): StartupArtifact | undefined {
|
||||
}
|
||||
|
||||
function listHubStartupLocks(_cwd: string): StartupArtifact[] {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const ownerPath = join(`${owner.discoveryPath}.lock`, "owner.json");
|
||||
if (!existsSync(ownerPath)) {
|
||||
return [];
|
||||
@@ -259,7 +284,7 @@ async function clearHubStartupArtifacts(
|
||||
_cwd: string,
|
||||
options?: { clearDiscovery?: boolean },
|
||||
): Promise<{ startupLocks: number; discovery: number }> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const startupLocks = listHubStartupLocks(_cwd);
|
||||
let clearedStartupLocks = 0;
|
||||
for (const artifact of startupLocks) {
|
||||
@@ -291,14 +316,25 @@ function formatHubUptimeFromStartedAt(
|
||||
return formatUptime(Date.now() - timestamp);
|
||||
}
|
||||
|
||||
function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url)
|
||||
? await probeHubServer(discovery.url, { authToken: discovery.authToken })
|
||||
: undefined;
|
||||
const current = health ?? discovery;
|
||||
const hubUptime = formatHubUptimeFromStartedAt(health?.startedAt);
|
||||
const listeningPids = listListeningPids(current?.port);
|
||||
const currentHubPids = [
|
||||
...(current?.pid ? [current.pid] : []),
|
||||
...listeningPids,
|
||||
];
|
||||
return {
|
||||
cwd,
|
||||
hubUrl: current?.url,
|
||||
@@ -306,7 +342,8 @@ async function collectDoctorStatus(cwd: string): Promise<DoctorStatus> {
|
||||
hubPid: current?.pid,
|
||||
hubStartedAt: health?.startedAt,
|
||||
hubUptime,
|
||||
listeningPids: listListeningPids(current?.port),
|
||||
listeningPids,
|
||||
staleHubPids: listStaleHubPids(currentHubPids),
|
||||
hubStartupLocks: listHubStartupLocks(cwd),
|
||||
staleCliPids: listStaleCliPids(),
|
||||
staleSidecarPids: listStaleSidecarPids(),
|
||||
@@ -388,6 +425,7 @@ export async function runDoctorCommand(
|
||||
);
|
||||
writeln(`hub uptime ${c.dim}${before.hubUptime ?? "n/a"}${c.reset}`);
|
||||
writeln(formatPidList("hub listeners", before.listeningPids));
|
||||
writeln(formatPidList("stale hub daemons", before.staleHubPids));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"hub startup locks",
|
||||
@@ -412,6 +450,7 @@ export async function runDoctorCommand(
|
||||
}
|
||||
if (
|
||||
before.listeningPids.length > 0 ||
|
||||
before.staleHubPids.length > 0 ||
|
||||
before.staleCliPids.length > 0 ||
|
||||
before.staleSidecarPids.length > 0
|
||||
) {
|
||||
@@ -423,7 +462,9 @@ export async function runDoctorCommand(
|
||||
}
|
||||
|
||||
const gracefullyStoppedHub = before.hubHealthy
|
||||
? await stopLocalHubServerGracefully().catch(() => false)
|
||||
? await stopLocalHubServerGracefully(resolveCliHubOwnerContext()).catch(
|
||||
() => false,
|
||||
)
|
||||
: false;
|
||||
const refreshedAfterGracefulStop = gracefullyStoppedHub
|
||||
? await collectDoctorStatus(opts.cwd)
|
||||
@@ -431,13 +472,20 @@ export async function runDoctorCommand(
|
||||
const killedHub = gracefullyStoppedHub
|
||||
? 0
|
||||
: killPids(refreshedAfterGracefulStop.listeningPids);
|
||||
const staleCliTargets = before.staleCliPids.filter(
|
||||
const staleHubTargets = before.staleHubPids.filter(
|
||||
(pid) => !refreshedAfterGracefulStop.listeningPids.includes(pid),
|
||||
);
|
||||
const killedStaleHubs = killPids(staleHubTargets);
|
||||
const staleCliTargets = before.staleCliPids.filter(
|
||||
(pid) =>
|
||||
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
|
||||
!staleHubTargets.includes(pid),
|
||||
);
|
||||
const killedCli = killPids(staleCliTargets);
|
||||
const staleSidecarTargets = before.staleSidecarPids.filter(
|
||||
(pid) =>
|
||||
!refreshedAfterGracefulStop.listeningPids.includes(pid) &&
|
||||
!staleHubTargets.includes(pid) &&
|
||||
!staleCliTargets.includes(pid),
|
||||
);
|
||||
const killedSidecars = killPids(staleSidecarTargets);
|
||||
@@ -459,6 +507,7 @@ export async function runDoctorCommand(
|
||||
after,
|
||||
killed: {
|
||||
hubListeners: killedHub,
|
||||
staleHubDaemons: killedStaleHubs,
|
||||
cliProcesses: killedCli,
|
||||
sidecarProcesses: killedSidecars,
|
||||
connectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
@@ -471,6 +520,7 @@ export async function runDoctorCommand(
|
||||
return 0;
|
||||
}
|
||||
writeln(`killed hub listeners ${c.dim}${killedHub}${c.reset}`);
|
||||
writeln(`killed stale hub daemons ${c.dim}${killedStaleHubs}${c.reset}`);
|
||||
writeln(`killed cli processes ${c.dim}${killedCli}${c.reset}`);
|
||||
writeln(`killed sidecar processes ${c.dim}${killedSidecars}${c.reset}`);
|
||||
writeln(
|
||||
@@ -487,6 +537,7 @@ export async function runDoctorCommand(
|
||||
);
|
||||
writeln(`hub healthy after fix: ${after.hubHealthy ? "yes" : "no"}`);
|
||||
writeln(formatPidList("remaining hub listeners", after.listeningPids));
|
||||
writeln(formatPidList("remaining stale hub daemons", after.staleHubPids));
|
||||
writeln(
|
||||
formatPidList(
|
||||
"remaining hub startup locks",
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockClearHubDiscovery,
|
||||
mockEnsureDetachedHubServer,
|
||||
mockProbeHubServer,
|
||||
mockReadHubDiscovery,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockStopLocalHubServerGracefully,
|
||||
} = vi.hoisted(() => ({
|
||||
@@ -12,6 +13,10 @@ const {
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockResolveProductionHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-production",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/production.json",
|
||||
})),
|
||||
mockResolveSharedHubOwnerContext: vi.fn(() => ({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
@@ -24,13 +29,25 @@ vi.mock("@cline/core", () => ({
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
}));
|
||||
|
||||
import { createHubCommand } from "./hub";
|
||||
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
|
||||
describe("createHubCommand", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
});
|
||||
|
||||
it("includes uptime in hub status output", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(
|
||||
new Date("2026-01-01T00:01:05.000Z").getTime(),
|
||||
@@ -73,4 +90,37 @@ describe("createHubCommand", () => {
|
||||
uptime: "1m 5s",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes the selected owner to graceful stop", async () => {
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 50174,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
|
||||
const output: string[] = [];
|
||||
let exitCode = 0;
|
||||
const cmd = createHubCommand(
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
(code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
);
|
||||
|
||||
await cmd.parseAsync(["stop"], { from: "user" });
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockStopLocalHubServerGracefully).toHaveBeenCalledWith({
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
});
|
||||
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,10 +3,11 @@ import {
|
||||
ensureDetachedHubServer,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { formatUptime } from "@cline/shared";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
|
||||
interface HubCommandIo {
|
||||
@@ -15,9 +16,9 @@ interface HubCommandIo {
|
||||
}
|
||||
|
||||
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
if (await stopLocalHubServerGracefully()) {
|
||||
if (await stopLocalHubServerGracefully(owner)) {
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return true;
|
||||
}
|
||||
@@ -46,6 +47,12 @@ function formatHubUptimeFromStartedAt(
|
||||
return formatUptime(Date.now() - timestamp);
|
||||
}
|
||||
|
||||
function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
export function createHubCommand(
|
||||
io: HubCommandIo,
|
||||
setExitCode: (code: number) => void,
|
||||
@@ -112,10 +119,12 @@ export function createHubCommand(
|
||||
|
||||
hub.command("status").action(
|
||||
action(async () => {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url)
|
||||
? await probeHubServer(discovery.url, {
|
||||
authToken: discovery.authToken,
|
||||
})
|
||||
: undefined;
|
||||
const uptime = formatHubUptimeFromStartedAt(health?.startedAt);
|
||||
io.writeln(
|
||||
|
||||
@@ -168,6 +168,8 @@ export function buildKanbanSpawnOptions(
|
||||
detached: shouldDetachKanbanProcess(platform),
|
||||
...(platform === "win32" ? { shell: true } : {}),
|
||||
...options,
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -178,6 +180,8 @@ function buildKanbanInstallSpawnOptions(
|
||||
return {
|
||||
detached: false,
|
||||
stdio: "inherit",
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
...(platform === "win32" ? { shell: true } : {}),
|
||||
...options,
|
||||
};
|
||||
@@ -203,6 +207,8 @@ export function getInstalledKanbanVersion(): string | null {
|
||||
const result = spawnSync(getKanbanCommand(), ["--version"], {
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { buildMcpInstallDefaults, runMcpInstallCommand } from "./mcp";
|
||||
|
||||
describe("mcp install command", () => {
|
||||
it("builds stdio wizard defaults from command args", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "fs",
|
||||
targetArgs: [
|
||||
"npx",
|
||||
"-y",
|
||||
"@modelcontextprotocol/server-filesystem",
|
||||
"/tmp/my dir",
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "fs",
|
||||
type: "stdio",
|
||||
command: 'npx -y @modelcontextprotocol/server-filesystem "/tmp/my dir"',
|
||||
});
|
||||
});
|
||||
|
||||
it("builds remote wizard defaults and normalizes http transport", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "ctx7",
|
||||
transport: "http",
|
||||
targetArgs: ["https://mcp.context7.com/mcp"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "ctx7",
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes streamable-http transport", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "docs",
|
||||
transport: "streamable-http",
|
||||
targetArgs: ["https://example.com/mcp"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "docs",
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("builds SSE wizard defaults", () => {
|
||||
expect(
|
||||
buildMcpInstallDefaults({
|
||||
name: "events",
|
||||
transport: "sse",
|
||||
targetArgs: ["https://example.com/sse"],
|
||||
}),
|
||||
).toEqual({
|
||||
name: "events",
|
||||
type: "sse",
|
||||
url: "https://example.com/sse",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects missing stdio command and invalid remote URL", () => {
|
||||
expect(() =>
|
||||
buildMcpInstallDefaults({
|
||||
name: "fs",
|
||||
}),
|
||||
).toThrow(/requires a command/);
|
||||
|
||||
expect(() =>
|
||||
buildMcpInstallDefaults({
|
||||
name: "bad",
|
||||
transport: "http",
|
||||
targetArgs: ["not-a-url"],
|
||||
}),
|
||||
).toThrow(/Invalid MCP server URL/);
|
||||
});
|
||||
|
||||
it("rejects remote URL schemes other than http and https", () => {
|
||||
expect(() =>
|
||||
buildMcpInstallDefaults({
|
||||
name: "bad",
|
||||
transport: "http",
|
||||
targetArgs: ["file:///etc/passwd"],
|
||||
}),
|
||||
).toThrow(/only http and https are supported/);
|
||||
});
|
||||
|
||||
it("opens the add wizard with prefilled defaults", async () => {
|
||||
const runWizard = vi.fn(async () => 0);
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "ctx7",
|
||||
transport: "http",
|
||||
targetArgs: ["https://mcp.context7.com/mcp"],
|
||||
isTty: true,
|
||||
runWizard,
|
||||
io: { writeErr: vi.fn() },
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(runWizard).toHaveBeenCalledWith({
|
||||
name: "ctx7",
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a TTY because it opens the wizard", async () => {
|
||||
const writeErr = vi.fn();
|
||||
const runWizard = vi.fn(async () => 0);
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "ctx7",
|
||||
transport: "http",
|
||||
targetArgs: ["https://mcp.context7.com/mcp"],
|
||||
isTty: false,
|
||||
runWizard,
|
||||
io: { writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(runWizard).not.toHaveBeenCalled();
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY.",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks for TTY before validating install arguments", async () => {
|
||||
const writeErr = vi.fn();
|
||||
|
||||
const code = await runMcpInstallCommand({
|
||||
name: "fs",
|
||||
isTty: false,
|
||||
io: { writeErr },
|
||||
});
|
||||
|
||||
expect(code).toBe(1);
|
||||
expect(writeErr).toHaveBeenCalledWith(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
import type { McpAddDefaults } from "../wizards/mcp";
|
||||
|
||||
export interface McpCommandIo {
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
export interface McpInstallOptions {
|
||||
name: string;
|
||||
targetArgs?: string[];
|
||||
transport?: string;
|
||||
io?: McpCommandIo;
|
||||
isTty?: boolean;
|
||||
runWizard?: (defaults: McpAddDefaults) => Promise<number>;
|
||||
}
|
||||
|
||||
function normalizeTransportType(
|
||||
value: string | undefined,
|
||||
): McpAddDefaults["type"] {
|
||||
const normalized = (value ?? "stdio").trim();
|
||||
if (normalized === "http" || normalized === "streamable-http") {
|
||||
return "streamableHttp";
|
||||
}
|
||||
if (
|
||||
normalized === "stdio" ||
|
||||
normalized === "sse" ||
|
||||
normalized === "streamableHttp"
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported MCP transport "${normalized}". Expected stdio, sse, http, streamable-http, or streamableHttp.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertValidUrl(url: string): void {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
throw new Error(`Invalid MCP server URL: ${url}`);
|
||||
}
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
throw new Error(
|
||||
`Invalid MCP server URL: ${url} (only http and https are supported)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function quoteCommandArg(arg: string): string {
|
||||
if (/^[^\s"'\\]+$/.test(arg)) {
|
||||
return arg;
|
||||
}
|
||||
return `"${arg.replace(/(["\\])/g, "\\$1")}"`;
|
||||
}
|
||||
|
||||
export function buildMcpInstallDefaults(options: {
|
||||
name: string;
|
||||
targetArgs?: string[];
|
||||
transport?: string;
|
||||
}): McpAddDefaults {
|
||||
const name = options.name.trim();
|
||||
if (!name) {
|
||||
throw new Error("MCP server name is required");
|
||||
}
|
||||
const type = normalizeTransportType(options.transport);
|
||||
const targetArgs = options.targetArgs ?? [];
|
||||
if (type === "stdio") {
|
||||
if (targetArgs.length === 0) {
|
||||
throw new Error(
|
||||
"Stdio MCP install requires a command after the server name, for example: cline mcp install fs -- npx -y @modelcontextprotocol/server-filesystem /tmp",
|
||||
);
|
||||
}
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
command: targetArgs.map(quoteCommandArg).join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
if (targetArgs.length !== 1) {
|
||||
throw new Error(
|
||||
"Remote MCP install requires exactly one URL argument after the server name.",
|
||||
);
|
||||
}
|
||||
const url = targetArgs[0]?.trim() ?? "";
|
||||
assertValidUrl(url);
|
||||
return {
|
||||
name,
|
||||
type,
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
async function runPrefilledWizard(defaults: McpAddDefaults): Promise<number> {
|
||||
const { runMcpWizard } = await import("../wizards/mcp");
|
||||
return runMcpWizard({
|
||||
initialAction: "add",
|
||||
addDefaults: defaults,
|
||||
exitAfterInitialAction: true,
|
||||
});
|
||||
}
|
||||
|
||||
export async function runMcpInstallCommand(
|
||||
options: McpInstallOptions,
|
||||
): Promise<number> {
|
||||
try {
|
||||
const isTty =
|
||||
options.isTty ?? (process.stdin.isTTY && process.stdout.isTTY);
|
||||
if (!isTty) {
|
||||
throw new Error(
|
||||
"cline mcp install opens the MCP wizard and requires a TTY.",
|
||||
);
|
||||
}
|
||||
const defaults = buildMcpInstallDefaults(options);
|
||||
return await (options.runWizard ?? runPrefilledWizard)(defaults);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(message);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@cline/shared/storage";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
collectPluginMcpOAuthCandidates,
|
||||
installPlugin,
|
||||
isOfficialPluginSlug,
|
||||
parsePluginSource,
|
||||
@@ -35,6 +36,7 @@ describe("plugin install command", () => {
|
||||
let originalHome: string | undefined;
|
||||
let originalClineDir: string | undefined;
|
||||
let originalClineDataDir: string | undefined;
|
||||
let originalMcpSettingsPath: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), "cli-plugin-install-"));
|
||||
@@ -43,6 +45,7 @@ describe("plugin install command", () => {
|
||||
originalHome = process.env.HOME;
|
||||
originalClineDir = process.env.CLINE_DIR;
|
||||
originalClineDataDir = process.env.CLINE_DATA_DIR;
|
||||
originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
process.env.HOME = home;
|
||||
process.env.CLINE_DIR = join(home, ".cline");
|
||||
process.env.CLINE_DATA_DIR = join(home, ".cline", "data");
|
||||
@@ -91,6 +94,11 @@ describe("plugin install command", () => {
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalClineDataDir;
|
||||
}
|
||||
if (originalMcpSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath;
|
||||
}
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -676,11 +684,341 @@ describe("plugin install command", () => {
|
||||
expect(code).toBe(0);
|
||||
const parsed = JSON.parse(stdout.join("")) as { installPath: string };
|
||||
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
|
||||
expect("mcpOAuthCandidates" in parsed).toBe(false);
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not run MCP OAuth follow-up for JSON plugin installs", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "json-oauth-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "json-oauth-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "json-oauth-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const stdout: string[] = [];
|
||||
const originalWrite = process.stdout.write;
|
||||
const authorize = vi.fn();
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => {
|
||||
stdout.push(String(chunk));
|
||||
return true;
|
||||
}) as typeof process.stdout.write;
|
||||
try {
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
json: true,
|
||||
io: {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
mcpOAuth: {
|
||||
interactive: true,
|
||||
selectCandidates: async (candidates) => candidates,
|
||||
authorize,
|
||||
},
|
||||
});
|
||||
expect(code).toBe(0);
|
||||
expect(authorize).not.toHaveBeenCalled();
|
||||
const parsed = JSON.parse(stdout.join("")) as {
|
||||
installPath: string;
|
||||
mcpOAuthCandidates?: unknown;
|
||||
};
|
||||
expect(parsed.installPath).toContain(join(home, ".cline", "plugins"));
|
||||
expect(parsed.mcpOAuthCandidates).toBeUndefined();
|
||||
} finally {
|
||||
process.stdout.write = originalWrite;
|
||||
}
|
||||
});
|
||||
|
||||
it("warns when plugin MCP settings sync fails after install", async () => {
|
||||
const source = join(root, "mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "mcp-plugin",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const blockedDirectory = join(root, "not-a-directory");
|
||||
writeFileSync(blockedDirectory, "file", "utf8");
|
||||
const originalSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(
|
||||
blockedDirectory,
|
||||
"cline_mcp_settings.json",
|
||||
);
|
||||
const output: string[] = [];
|
||||
try {
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(output.join("\n")).toContain("Installed plugin from");
|
||||
expect(output.join("\n")).toContain(
|
||||
"Warning: failed to sync plugin MCP servers",
|
||||
);
|
||||
expect(output.join("\n")).toContain("mcp-plugin");
|
||||
} finally {
|
||||
if (originalSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = originalSettingsPath;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("detects plugin-owned remote MCP servers as OAuth candidates", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "oauth-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "oauth-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "oauth-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await installPlugin({ source });
|
||||
|
||||
expect(result.mcpOAuthCandidates).toEqual([
|
||||
expect.objectContaining({
|
||||
name: "oauth-docs",
|
||||
pluginName: "oauth-mcp-plugin",
|
||||
transportType: "streamableHttp",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat remote MCP servers with static headers as OAuth candidates", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "headers-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "headers-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "headers-docs",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://example.com/mcp",
|
||||
headers: { Authorization: "Bearer token" },
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const result = await installPlugin({ source });
|
||||
|
||||
expect(result.mcpOAuthCandidates).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips plugin MCP OAuth candidates that already have tokens", async () => {
|
||||
const settingsPath = join(root, "mcp-settings.json");
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
const source = join(root, "authorized-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "authorized-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "authorized-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const result = await installPlugin({ source });
|
||||
const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<string, { oauth?: unknown }>;
|
||||
};
|
||||
const server = settings.mcpServers?.["authorized-docs"];
|
||||
if (!server) {
|
||||
throw new Error("Expected authorized-docs MCP server to be written");
|
||||
}
|
||||
server.oauth = { tokens: { access_token: "oauth-token" } };
|
||||
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), "utf8");
|
||||
|
||||
expect(
|
||||
collectPluginMcpOAuthCandidates({
|
||||
pluginPaths: result.entryPaths,
|
||||
settingsPath,
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("authorizes selected plugin MCP OAuth candidates during interactive installs", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "interactive-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "interactive-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "interactive-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const authorized: string[] = [];
|
||||
const output: string[] = [];
|
||||
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
mcpOAuth: {
|
||||
interactive: true,
|
||||
selectCandidates: async (candidates) => candidates,
|
||||
authorize: async (candidate) => {
|
||||
authorized.push(candidate.name);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(authorized).toEqual(["interactive-docs"]);
|
||||
expect(output.join("\n")).toContain("Installed plugin from");
|
||||
});
|
||||
|
||||
it("keeps plugin install successful when MCP OAuth authorization fails", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "failing-oauth-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "failing-oauth-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "failing-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const output: string[] = [];
|
||||
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
mcpOAuth: {
|
||||
interactive: true,
|
||||
selectCandidates: async (candidates) => candidates,
|
||||
authorize: async () => {
|
||||
throw new Error("oauth unavailable");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(output.join("\n")).toContain(
|
||||
"Warning: failed to authorize MCP server failing-docs: oauth unavailable",
|
||||
);
|
||||
});
|
||||
|
||||
it("prints guidance for plugin MCP OAuth candidates in non-interactive installs", async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(root, "mcp-settings.json");
|
||||
const source = join(root, "non-interactive-mcp-plugin.js");
|
||||
writeFileSync(
|
||||
source,
|
||||
`
|
||||
export default {
|
||||
name: "non-interactive-mcp-plugin",
|
||||
manifest: { capabilities: ["mcp"] },
|
||||
setup(api) {
|
||||
api.registerMcpServer({
|
||||
name: "non-interactive-docs",
|
||||
transport: { type: "streamableHttp", url: "https://example.com/mcp" },
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
const output: string[] = [];
|
||||
const authorize = vi.fn();
|
||||
|
||||
const code = await runPluginInstallCommand({
|
||||
source,
|
||||
io: {
|
||||
writeln: (text = "") => output.push(text),
|
||||
writeErr: (text) => output.push(text),
|
||||
},
|
||||
mcpOAuth: {
|
||||
interactive: false,
|
||||
authorize,
|
||||
},
|
||||
});
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(authorize).not.toHaveBeenCalled();
|
||||
expect(output.join("\n")).toContain(
|
||||
"Plugin MCP servers may require OAuth authorization",
|
||||
);
|
||||
expect(output.join("\n")).toContain("non-interactive-docs");
|
||||
expect(output.join("\n")).toContain('Run "cline mcp"');
|
||||
});
|
||||
|
||||
it("prints JSON output for official plugin installs", async () => {
|
||||
const officialPluginsRepo = await createOfficialPluginsRepo({
|
||||
"json-plugin": {
|
||||
|
||||
@@ -21,7 +21,15 @@ import {
|
||||
resolve,
|
||||
sep,
|
||||
} from "node:path";
|
||||
import { type PluginUninstallOptions, uninstallPlugin } from "@cline/core";
|
||||
import {
|
||||
type McpServerRegistration,
|
||||
type PluginMcpSettingsSyncResult,
|
||||
type PluginUninstallOptions,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
resolveMcpServerRegistrations,
|
||||
syncPluginMcpServersToSettings,
|
||||
uninstallPlugin,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
isPluginModulePath,
|
||||
resolveClineDir,
|
||||
@@ -36,12 +44,31 @@ export interface PluginInstallOptions {
|
||||
npmCommand?: string;
|
||||
officialPluginsRepo?: string;
|
||||
io?: PluginInstallIo;
|
||||
mcpOAuth?: PluginInstallMcpOAuthOptions;
|
||||
}
|
||||
|
||||
export interface PluginInstallResult {
|
||||
source: string;
|
||||
installPath: string;
|
||||
entryPaths: string[];
|
||||
mcpSyncFailures: PluginMcpSettingsSyncResult["failures"];
|
||||
mcpOAuthCandidates: PluginMcpOAuthCandidate[];
|
||||
}
|
||||
|
||||
export interface PluginMcpOAuthCandidate {
|
||||
name: string;
|
||||
pluginName: string;
|
||||
pluginPath: string;
|
||||
transportType: "sse" | "streamableHttp";
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
export interface PluginInstallMcpOAuthOptions {
|
||||
interactive?: boolean;
|
||||
selectCandidates?: (
|
||||
candidates: PluginMcpOAuthCandidate[],
|
||||
) => Promise<PluginMcpOAuthCandidate[]>;
|
||||
authorize?: (candidate: PluginMcpOAuthCandidate) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface PluginInstallIo {
|
||||
@@ -506,6 +533,8 @@ async function runCommand(
|
||||
cwd: options.cwd,
|
||||
stdio: ["ignore", "ignore", "pipe"],
|
||||
env: process.env,
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
@@ -1003,6 +1032,81 @@ function replaceInstallPath(
|
||||
}
|
||||
}
|
||||
|
||||
function hasStaticHeaders(registration: McpServerRegistration): boolean {
|
||||
const transport = registration.transport;
|
||||
if (transport.type === "stdio") {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
transport.headers !== undefined && Object.keys(transport.headers).length > 0
|
||||
);
|
||||
}
|
||||
|
||||
function hasOAuthAccessToken(registration: McpServerRegistration): boolean {
|
||||
const accessToken = registration.oauth?.tokens?.access_token;
|
||||
return typeof accessToken === "string" && accessToken.trim().length > 0;
|
||||
}
|
||||
|
||||
function getPluginOwner(
|
||||
registration: McpServerRegistration,
|
||||
): { pluginName: string; pluginPath: string } | undefined {
|
||||
const metadata = registration.metadata;
|
||||
if (
|
||||
!metadata ||
|
||||
metadata.source !== "plugin" ||
|
||||
typeof metadata.pluginName !== "string" ||
|
||||
typeof metadata.pluginPath !== "string"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
pluginName: metadata.pluginName,
|
||||
pluginPath: metadata.pluginPath,
|
||||
};
|
||||
}
|
||||
|
||||
export function collectPluginMcpOAuthCandidates(input: {
|
||||
pluginPaths: readonly string[];
|
||||
settingsPath?: string;
|
||||
}): PluginMcpOAuthCandidate[] {
|
||||
const pluginPaths = new Set(input.pluginPaths.map((path) => resolve(path)));
|
||||
if (pluginPaths.size === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let registrations: McpServerRegistration[];
|
||||
try {
|
||||
registrations = resolveMcpServerRegistrations({
|
||||
filePath: input.settingsPath ?? resolveDefaultMcpSettingsPath(),
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidates: PluginMcpOAuthCandidate[] = [];
|
||||
for (const registration of registrations) {
|
||||
const owner = getPluginOwner(registration);
|
||||
if (!owner || !pluginPaths.has(resolve(owner.pluginPath))) {
|
||||
continue;
|
||||
}
|
||||
const transportType = registration.transport.type;
|
||||
if (transportType === "stdio") {
|
||||
continue;
|
||||
}
|
||||
if (hasStaticHeaders(registration) || hasOAuthAccessToken(registration)) {
|
||||
continue;
|
||||
}
|
||||
candidates.push({
|
||||
name: registration.name,
|
||||
pluginName: owner.pluginName,
|
||||
pluginPath: owner.pluginPath,
|
||||
transportType,
|
||||
lastError: registration.oauth?.lastError,
|
||||
});
|
||||
}
|
||||
return candidates.sort((left, right) => left.name.localeCompare(right.name));
|
||||
}
|
||||
|
||||
export async function installPlugin(
|
||||
options: PluginInstallOptions,
|
||||
): Promise<PluginInstallResult> {
|
||||
@@ -1069,28 +1173,161 @@ export async function installPlugin(
|
||||
}
|
||||
|
||||
replaceInstallPath(stagingRoot, installPath, force);
|
||||
return {
|
||||
const result = {
|
||||
source,
|
||||
installPath,
|
||||
entryPaths: entryPaths.map((entry) => resolve(installPath, entry)),
|
||||
mcpSyncFailures: [] as PluginMcpSettingsSyncResult["failures"],
|
||||
mcpOAuthCandidates: [] as PluginMcpOAuthCandidate[],
|
||||
};
|
||||
const syncResult = await syncPluginMcpServersToSettings({
|
||||
pluginPaths: result.entryPaths,
|
||||
cwd,
|
||||
workspacePath: cwd,
|
||||
});
|
||||
result.mcpSyncFailures = syncResult.failures;
|
||||
result.mcpOAuthCandidates = collectPluginMcpOAuthCandidates({
|
||||
pluginPaths: result.entryPaths,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
rmSync(stagingRoot, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function serializePluginInstallResult(
|
||||
result: PluginInstallResult,
|
||||
): Omit<PluginInstallResult, "mcpOAuthCandidates"> {
|
||||
return {
|
||||
source: result.source,
|
||||
installPath: result.installPath,
|
||||
entryPaths: result.entryPaths,
|
||||
mcpSyncFailures: result.mcpSyncFailures,
|
||||
};
|
||||
}
|
||||
|
||||
function isInteractivePluginInstall(options: PluginInstallOptions): boolean {
|
||||
return (
|
||||
options.mcpOAuth?.interactive ??
|
||||
(process.stdin.isTTY && process.stdout.isTTY)
|
||||
);
|
||||
}
|
||||
|
||||
async function selectMcpOAuthCandidatesWithClack(
|
||||
candidates: PluginMcpOAuthCandidate[],
|
||||
): Promise<PluginMcpOAuthCandidate[]> {
|
||||
const p = await import("@clack/prompts");
|
||||
const action = await p.select({
|
||||
message: "Authorize plugin MCP servers now?",
|
||||
options: [
|
||||
{
|
||||
value: "all",
|
||||
label: "Authorize all",
|
||||
hint: "open browser authorization for each server",
|
||||
},
|
||||
{
|
||||
value: "choose",
|
||||
label: "Choose servers",
|
||||
hint: "select which servers to authorize",
|
||||
},
|
||||
{
|
||||
value: "skip",
|
||||
label: "Skip",
|
||||
},
|
||||
],
|
||||
});
|
||||
if (p.isCancel(action) || action === "skip") {
|
||||
return [];
|
||||
}
|
||||
if (action === "all") {
|
||||
return candidates;
|
||||
}
|
||||
|
||||
const selectedNames = await p.multiselect({
|
||||
message: "Select MCP servers to authorize",
|
||||
options: candidates.map((candidate) => ({
|
||||
value: candidate.name,
|
||||
label: candidate.name,
|
||||
hint: `${candidate.transportType} [${candidate.pluginName}]`,
|
||||
})),
|
||||
required: false,
|
||||
});
|
||||
if (p.isCancel(selectedNames) || !Array.isArray(selectedNames)) {
|
||||
return [];
|
||||
}
|
||||
const selected = new Set(selectedNames);
|
||||
return candidates.filter((candidate) => selected.has(candidate.name));
|
||||
}
|
||||
|
||||
async function authorizeMcpOAuthCandidate(
|
||||
candidate: PluginMcpOAuthCandidate,
|
||||
): Promise<void> {
|
||||
const { authorizeMcpServerOAuthWithBrowser } = await import(
|
||||
"../wizards/mcp/oauth"
|
||||
);
|
||||
await authorizeMcpServerOAuthWithBrowser(candidate.name, {
|
||||
throwOnError: true,
|
||||
});
|
||||
}
|
||||
|
||||
async function runPluginMcpOAuthFollowup(
|
||||
candidates: PluginMcpOAuthCandidate[],
|
||||
options: PluginInstallOptions,
|
||||
): Promise<void> {
|
||||
if (candidates.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isInteractivePluginInstall(options)) {
|
||||
options.io?.writeln("Plugin MCP servers may require OAuth authorization:");
|
||||
for (const candidate of candidates) {
|
||||
options.io?.writeln(
|
||||
` ${candidate.name} (${candidate.transportType}, plugin: ${candidate.pluginName})`,
|
||||
);
|
||||
}
|
||||
options.io?.writeln(
|
||||
'Run "cline mcp" and choose "Authorize OAuth" to authorize them.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const selected =
|
||||
options.mcpOAuth?.selectCandidates !== undefined
|
||||
? await options.mcpOAuth.selectCandidates(candidates)
|
||||
: await selectMcpOAuthCandidatesWithClack(candidates);
|
||||
const authorize = options.mcpOAuth?.authorize ?? authorizeMcpOAuthCandidate;
|
||||
for (const candidate of selected) {
|
||||
try {
|
||||
await authorize(candidate);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.io?.writeErr(
|
||||
`Warning: failed to authorize MCP server ${candidate.name}: ${message}. Run "cline mcp" and choose "Authorize OAuth" to retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPluginInstallCommand(
|
||||
options: PluginInstallOptions & { json?: boolean },
|
||||
): Promise<number> {
|
||||
try {
|
||||
const result = await installPlugin(options);
|
||||
if (options.json) {
|
||||
process.stdout.write(JSON.stringify(result));
|
||||
process.stdout.write(
|
||||
JSON.stringify(serializePluginInstallResult(result)),
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
options.io?.writeln(`Installed plugin from ${result.source}`);
|
||||
options.io?.writeln(` Path: ${result.installPath}`);
|
||||
for (const failure of result.mcpSyncFailures) {
|
||||
options.io?.writeErr(
|
||||
`Warning: failed to sync plugin MCP servers for ${failure.pluginName ?? failure.pluginPath}: ${failure.message}`,
|
||||
);
|
||||
}
|
||||
await runPluginMcpOAuthFollowup(result.mcpOAuthCandidates, options);
|
||||
return 0;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -116,7 +116,6 @@ export function createProgram(): Command {
|
||||
writeOut: () => {}, // suppress by default; main.ts re-enables for routing
|
||||
writeErr: () => {},
|
||||
})
|
||||
.allowUnknownOption()
|
||||
.allowExcessArguments()
|
||||
.enablePositionalOptions()
|
||||
.argument(
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildSkillsArgs } from "./skill";
|
||||
|
||||
describe("buildSkillsArgs", () => {
|
||||
it("runs the skills package through npx with -y", () => {
|
||||
expect(buildSkillsArgs(["list"])).toEqual(["-y", "skills@latest", "list"]);
|
||||
});
|
||||
|
||||
it("injects --agent cline for install-style subcommands", () => {
|
||||
expect(buildSkillsArgs(["install", "owner/repo"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"add",
|
||||
"owner/repo",
|
||||
"--agent",
|
||||
"cline",
|
||||
]);
|
||||
expect(buildSkillsArgs(["add", "owner/repo"])).toContain("cline");
|
||||
expect(buildSkillsArgs(["i", "owner/repo"])).toContain("cline");
|
||||
expect(buildSkillsArgs(["update", "owner/repo"])).toContain("cline");
|
||||
});
|
||||
|
||||
it("aliases uninstall to the skills remove subcommand", () => {
|
||||
expect(buildSkillsArgs(["uninstall", "my-skill"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
"my-skill",
|
||||
"--agent",
|
||||
"cline",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not inject when the user already targeted an agent", () => {
|
||||
expect(
|
||||
buildSkillsArgs(["install", "owner/repo", "--agent", "cursor"]),
|
||||
).not.toContain("cline");
|
||||
expect(
|
||||
buildSkillsArgs(["install", "owner/repo", "-a", "cursor"]),
|
||||
).not.toContain("cline");
|
||||
expect(
|
||||
buildSkillsArgs(["install", "owner/repo", "--agent=cursor"]),
|
||||
).not.toContain("cline");
|
||||
});
|
||||
|
||||
it("aliases install and uninstall when agent options come before the subcommand", () => {
|
||||
expect(
|
||||
buildSkillsArgs(["--agent", "cursor", "install", "owner/repo"]),
|
||||
).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"--agent",
|
||||
"cursor",
|
||||
"add",
|
||||
"owner/repo",
|
||||
]);
|
||||
expect(
|
||||
buildSkillsArgs(["--agent=cursor", "uninstall", "my-skill"]),
|
||||
).toEqual(["-y", "skills@latest", "--agent=cursor", "remove", "my-skill"]);
|
||||
});
|
||||
|
||||
it("does not scope non-install subcommands to cline", () => {
|
||||
expect(buildSkillsArgs(["use", "owner/repo"])).not.toContain("--agent");
|
||||
expect(buildSkillsArgs(["list"])).not.toContain("--agent");
|
||||
});
|
||||
|
||||
it("scopes remove-style subcommands to cline", () => {
|
||||
expect(buildSkillsArgs(["remove"])).toEqual([
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
"--agent",
|
||||
"cline",
|
||||
]);
|
||||
expect(buildSkillsArgs(["rm", "my-skill"])).toContain("cline");
|
||||
expect(buildSkillsArgs(["r", "my-skill"])).toContain("cline");
|
||||
});
|
||||
|
||||
it("ignores leading flags when detecting the subcommand", () => {
|
||||
expect(buildSkillsArgs(["--global", "install", "owner/repo"])).toContain(
|
||||
"cline",
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards an empty arg list unchanged", () => {
|
||||
expect(buildSkillsArgs([])).toEqual(["-y", "skills@latest"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { type SpawnOptions, spawn } from "node:child_process";
|
||||
|
||||
export interface SkillCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
// `cline skill` is a thin wrapper around the open skills CLI
|
||||
// (https://www.npmjs.com/package/skills). We run it through `npx` so users
|
||||
// don't need a separate global install. Pin the version here if we ever need to
|
||||
// lock behavior to a known-good release.
|
||||
const SKILLS_PACKAGE = "skills@latest";
|
||||
|
||||
// Subcommands that write skill files into an agent's skills directory. For a
|
||||
// `cline skill` command we default these to Cline unless the user picked their
|
||||
// own agent. `use` is intentionally excluded: without --agent it prints the
|
||||
// generated prompt to stdout, whereas adding --agent would launch that agent
|
||||
// interactively instead — not what someone scoping to Cline would expect.
|
||||
const CLINE_SCOPED_SUBCOMMANDS = new Set([
|
||||
"add",
|
||||
"install",
|
||||
"i",
|
||||
"update",
|
||||
"remove",
|
||||
"rm",
|
||||
"r",
|
||||
"uninstall",
|
||||
]);
|
||||
|
||||
const SKILLS_SUBCOMMAND_ALIASES = new Map([
|
||||
["install", "add"],
|
||||
["uninstall", "remove"],
|
||||
]);
|
||||
|
||||
function hasAgentFlag(args: readonly string[]): boolean {
|
||||
return args.some(
|
||||
(arg) => arg === "-a" || arg === "--agent" || arg.startsWith("--agent="),
|
||||
);
|
||||
}
|
||||
|
||||
function optionConsumesNextValue(arg: string): boolean {
|
||||
return arg === "-a" || arg === "--agent";
|
||||
}
|
||||
|
||||
function findSubcommandIndex(args: readonly string[]): number {
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index];
|
||||
if (arg.startsWith("-")) {
|
||||
if (optionConsumesNextValue(arg)) {
|
||||
index++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function findSubcommand(args: readonly string[]): string | undefined {
|
||||
const index = findSubcommandIndex(args);
|
||||
return index >= 0 ? args[index] : undefined;
|
||||
}
|
||||
|
||||
function normalizeSkillsSubcommandAliases(args: string[]): void {
|
||||
const index = findSubcommandIndex(args);
|
||||
if (index < 0) return;
|
||||
const alias = SKILLS_SUBCOMMAND_ALIASES.get(args[index]);
|
||||
if (alias) {
|
||||
args[index] = alias;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the argument list passed to `npx`, injecting `--agent cline` for
|
||||
* install-style subcommands unless the user already targeted an agent.
|
||||
*/
|
||||
export function buildSkillsArgs(userArgs: readonly string[]): string[] {
|
||||
const args = [...userArgs];
|
||||
const subcommand = findSubcommand(args);
|
||||
normalizeSkillsSubcommandAliases(args);
|
||||
if (
|
||||
subcommand &&
|
||||
CLINE_SCOPED_SUBCOMMANDS.has(subcommand) &&
|
||||
!hasAgentFlag(args)
|
||||
) {
|
||||
args.push("--agent", "cline");
|
||||
}
|
||||
return ["-y", SKILLS_PACKAGE, ...args];
|
||||
}
|
||||
|
||||
function resolveExitCode(
|
||||
code: number | null,
|
||||
signal: NodeJS.Signals | null,
|
||||
): number {
|
||||
if (code !== null) {
|
||||
return code;
|
||||
}
|
||||
switch (signal) {
|
||||
case "SIGINT":
|
||||
return 130;
|
||||
case "SIGTERM":
|
||||
return 143;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Forward all arguments to the open skills CLI via `npx skills`.
|
||||
*
|
||||
* Returns the child process exit code, or 1 if npx is unavailable or fails to
|
||||
* spawn. stdio is inherited so the skills CLI's interactive prompts and output
|
||||
* pass straight through to the user's terminal.
|
||||
*/
|
||||
export async function runSkillCommand(
|
||||
userArgs: readonly string[],
|
||||
io: SkillCommandIo,
|
||||
): Promise<number> {
|
||||
const args = buildSkillsArgs(userArgs);
|
||||
const isWindows = process.platform === "win32";
|
||||
const options: SpawnOptions = {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
...(isWindows ? { shell: true } : {}),
|
||||
};
|
||||
|
||||
return new Promise<number>((resolve) => {
|
||||
const child = spawn("npx", args, options);
|
||||
|
||||
const forward = (signal: NodeJS.Signals) => {
|
||||
child.kill(signal);
|
||||
};
|
||||
const handleSigint = () => forward("SIGINT");
|
||||
const handleSigterm = () => forward("SIGTERM");
|
||||
process.on("SIGINT", handleSigint);
|
||||
process.on("SIGTERM", handleSigterm);
|
||||
const cleanup = () => {
|
||||
process.off("SIGINT", handleSigint);
|
||||
process.off("SIGTERM", handleSigterm);
|
||||
};
|
||||
|
||||
child.once("error", (error: NodeJS.ErrnoException) => {
|
||||
cleanup();
|
||||
if (error.code === "ENOENT") {
|
||||
io.writeErr(
|
||||
'npx was not found. Install Node.js (which includes npx) to use "cline skill".',
|
||||
);
|
||||
} else {
|
||||
io.writeErr(`Failed to run npx ${SKILLS_PACKAGE}: ${error.message}`);
|
||||
}
|
||||
resolve(1);
|
||||
});
|
||||
child.once("close", (code, signal) => {
|
||||
cleanup();
|
||||
resolve(resolveExitCode(code, signal));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -7,10 +7,14 @@ import {
|
||||
checkForUpdates,
|
||||
getInstallationInfo,
|
||||
PackageManager,
|
||||
resolveCliHubOwnerContext,
|
||||
withMinimumReleaseAgeBypass,
|
||||
} from "./update";
|
||||
|
||||
const originalArgv = [...process.argv];
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
const originalDataDir = process.env.CLINE_DATA_DIR;
|
||||
const originalHubDiscoveryPath = process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
|
||||
const originalGlobalSettingsPath = process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
const originalIsDev = process.env.IS_DEV;
|
||||
@@ -32,6 +36,21 @@ function createTempFile(pathSuffix: string): string {
|
||||
describe("getInstallationInfo", () => {
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
@@ -96,6 +115,21 @@ describe("getInstallationInfo", () => {
|
||||
describe("auto update settings", () => {
|
||||
afterEach(() => {
|
||||
process.argv = [...originalArgv];
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
@@ -153,6 +187,39 @@ describe("auto update settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("hub restart owner selection", () => {
|
||||
afterEach(() => {
|
||||
if (originalBuildEnv === undefined) {
|
||||
delete process.env.CLINE_BUILD_ENV;
|
||||
} else {
|
||||
process.env.CLINE_BUILD_ENV = originalBuildEnv;
|
||||
}
|
||||
if (originalDataDir === undefined) {
|
||||
delete process.env.CLINE_DATA_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DATA_DIR = originalDataDir;
|
||||
}
|
||||
if (originalHubDiscoveryPath === undefined) {
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
} else {
|
||||
process.env.CLINE_HUB_DISCOVERY_PATH = originalHubDiscoveryPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the shared hub owner outside production builds", () => {
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-update-test-data";
|
||||
delete process.env.CLINE_HUB_DISCOVERY_PATH;
|
||||
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
|
||||
expect(owner.discoveryPath).toContain("/locks/hub/owners/");
|
||||
expect(owner.discoveryPath).not.toBe(
|
||||
"/tmp/cline-update-test-data/locks/hub/production.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withMinimumReleaseAgeBypass", () => {
|
||||
it("adds the package-manager-specific cooldown bypass", () => {
|
||||
expect(
|
||||
|
||||
@@ -5,9 +5,11 @@ import {
|
||||
isAutoUpdateEnabledGlobally,
|
||||
probeHubServer,
|
||||
readHubDiscovery,
|
||||
resolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext,
|
||||
stopLocalHubServerGracefully,
|
||||
} from "@cline/core";
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { version } from "../../package.json";
|
||||
import { ensureCliHubServer } from "../utils/hub-runtime";
|
||||
import { c, writeErr, writeln } from "../utils/output";
|
||||
@@ -269,13 +271,22 @@ export function getPreferredKanbanInstaller(
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
||||
|
||||
export function resolveCliHubOwnerContext() {
|
||||
return resolveClineBuildEnv() === "production"
|
||||
? resolveProductionHubOwnerContext()
|
||||
: resolveSharedHubOwnerContext();
|
||||
}
|
||||
|
||||
async function waitForHubToStop(
|
||||
url: string,
|
||||
authToken: string | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const check = await probeHubServer(url).catch(() => undefined);
|
||||
const check = await probeHubServer(url, { authToken }).catch(
|
||||
() => undefined,
|
||||
);
|
||||
if (!check?.url) return true;
|
||||
await sleep(100);
|
||||
}
|
||||
@@ -288,20 +299,22 @@ async function waitForHubToStop(
|
||||
* clears stale discovery, then re-ensures a fresh instance is spawned.
|
||||
*/
|
||||
async function restartHubServerIfRunning(): Promise<void> {
|
||||
const owner = resolveSharedHubOwnerContext();
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath).catch(
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
const health = discovery?.url
|
||||
? await probeHubServer(discovery.url).catch(() => undefined)
|
||||
? await probeHubServer(discovery.url, {
|
||||
authToken: discovery.authToken,
|
||||
}).catch(() => undefined)
|
||||
: undefined;
|
||||
if (!health?.url) return;
|
||||
if (!discovery || !health?.url) return;
|
||||
|
||||
const pid = discovery?.pid;
|
||||
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
|
||||
|
||||
let stopped = await stopLocalHubServerGracefully().catch(() => false);
|
||||
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGTERM");
|
||||
@@ -310,14 +323,14 @@ async function restartHubServerIfRunning(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
stopped = await waitForHubToStop(health.url, 3_000);
|
||||
stopped = await waitForHubToStop(health.url, discovery.authToken, 3_000);
|
||||
if (!stopped && pid) {
|
||||
try {
|
||||
process.kill(pid, "SIGKILL");
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
stopped = await waitForHubToStop(health.url, 2_000);
|
||||
stopped = await waitForHubToStop(health.url, discovery.authToken, 2_000);
|
||||
}
|
||||
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
@@ -362,6 +375,9 @@ export function autoUpdateOnStartup(): void {
|
||||
env: autoUpdateCommand.env
|
||||
? { ...process.env, ...autoUpdateCommand.env }
|
||||
: process.env,
|
||||
// Prevent a console window from flashing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
});
|
||||
const exitCode = await waitForProcessExit(child);
|
||||
if (exitCode === 0) {
|
||||
|
||||
@@ -194,6 +194,9 @@ export function spawnDetachedConnector(
|
||||
...withResolvedClineBuildEnv(process.env),
|
||||
[childEnvKey]: "1",
|
||||
},
|
||||
// Prevent a console window from appearing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
});
|
||||
logSpawnedProcess({
|
||||
component: options?.component ?? "connectors",
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockGetLastUsedProviderSettings,
|
||||
mockGetProviderSettings,
|
||||
mockResolveSystemPrompt,
|
||||
mockGetProviderCollection,
|
||||
mockGetBooleanFlagEnabled,
|
||||
} = vi.hoisted(() => ({
|
||||
mockGetLastUsedProviderSettings: vi.fn(),
|
||||
mockGetProviderSettings: vi.fn(),
|
||||
mockResolveSystemPrompt: vi.fn(),
|
||||
mockGetProviderCollection: vi.fn(),
|
||||
mockGetBooleanFlagEnabled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
@@ -18,8 +20,8 @@ vi.mock("@cline/core", async () => {
|
||||
return {
|
||||
...actual,
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings() {
|
||||
return mockGetLastUsedProviderSettings();
|
||||
getLastUsedProviderSettings(options?: unknown) {
|
||||
return mockGetLastUsedProviderSettings(options);
|
||||
}
|
||||
|
||||
getProviderSettings(providerId: string) {
|
||||
@@ -43,6 +45,12 @@ vi.mock("../utils/helpers", () => ({
|
||||
resolveWorkspaceRoot: vi.fn((cwd: string) => cwd),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: mockGetBooleanFlagEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../commands/auth", async () => {
|
||||
const actual =
|
||||
await vi.importActual<typeof import("../commands/auth")>(
|
||||
@@ -57,6 +65,10 @@ vi.mock("../commands/auth", async () => {
|
||||
import { buildConnectorStartRequest } from "./session-runtime";
|
||||
|
||||
describe("buildConnectorStartRequest", () => {
|
||||
beforeEach(() => {
|
||||
mockGetBooleanFlagEnabled.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
delete process.env.OPENROUTER_API_KEY;
|
||||
@@ -88,5 +100,64 @@ describe("buildConnectorStartRequest", () => {
|
||||
expect(request.provider).toBe("openrouter");
|
||||
expect(request.apiKey).toBe("env-openrouter-key");
|
||||
expect(request.model).toBe("anthropic/claude-sonnet-4.6");
|
||||
expect(mockGetLastUsedProviderSettings).toHaveBeenCalledWith({
|
||||
isClinePassEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
|
||||
mockGetProviderSettings.mockReturnValue({
|
||||
provider: "cline-pass",
|
||||
auth: { accessToken: "workos:resolved-token" },
|
||||
});
|
||||
mockGetProviderCollection.mockReturnValue({
|
||||
provider: { env: ["CLINE_API_KEY"] },
|
||||
});
|
||||
mockResolveSystemPrompt.mockResolvedValue("system");
|
||||
|
||||
const request = await buildConnectorStartRequest({
|
||||
options: {
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
},
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
});
|
||||
|
||||
it("uses auth material resolved by provider settings manager", async () => {
|
||||
mockGetLastUsedProviderSettings.mockReturnValue({ provider: "cline-pass" });
|
||||
mockGetProviderSettings.mockReturnValue({
|
||||
provider: "cline-pass",
|
||||
auth: { accessToken: "workos:resolved-token" },
|
||||
});
|
||||
mockGetProviderCollection.mockReturnValue({
|
||||
provider: { env: ["CLINE_API_KEY"] },
|
||||
});
|
||||
mockResolveSystemPrompt.mockResolvedValue("system");
|
||||
|
||||
const request = await buildConnectorStartRequest({
|
||||
options: {
|
||||
cwd: "/tmp/work",
|
||||
mode: "act",
|
||||
enableTools: false,
|
||||
},
|
||||
io: { writeln: vi.fn(), writeErr: vi.fn() },
|
||||
loggerConfig: { enabled: false, level: "info", destination: "stdout" },
|
||||
systemRules: "Rules",
|
||||
defaultModel: "cline-pass/glm-5.1",
|
||||
});
|
||||
|
||||
expect(request.provider).toBe("cline-pass");
|
||||
expect(request.apiKey).toBe("workos:resolved-token");
|
||||
expect(request.model).toBe("cline-pass/glm-5.1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import type { CliLoggerAdapter } from "../logging/adapter";
|
||||
import { resolveSystemPrompt } from "../runtime/prompt";
|
||||
import { resolveCliSessionMetadata } from "../utils/enterprise";
|
||||
import { getCliFeatureFlagsService } from "../utils/feature-flags";
|
||||
import { resolveWorkspaceRoot } from "../utils/helpers";
|
||||
import {
|
||||
parseLocalRowMetadata,
|
||||
@@ -62,7 +63,10 @@ export async function buildConnectorStartRequest(input: {
|
||||
}): Promise<ChatStartSessionRequest> {
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings();
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
const provider = normalizeProviderId(
|
||||
input.options.provider?.trim() ||
|
||||
lastUsedProviderSettings?.provider ||
|
||||
|
||||
+157
-7
@@ -29,7 +29,9 @@ const authMocks = vi.hoisted(() => ({
|
||||
runAuthCommand: vi.fn(),
|
||||
}));
|
||||
const providerSettingsMocks = vi.hoisted(() => ({
|
||||
getLastUsedProviderSettings: vi.fn<() => unknown>(() => undefined),
|
||||
getLastUsedProviderSettings: vi.fn<(options?: unknown) => unknown>(
|
||||
() => undefined,
|
||||
),
|
||||
getProviderConfig: vi.fn<(providerId: string, options?: unknown) => unknown>(
|
||||
() => undefined,
|
||||
),
|
||||
@@ -82,6 +84,11 @@ const historyMocks = vi.hoisted(() => ({
|
||||
runHistoryExport: vi.fn(async () => 0),
|
||||
runHistoryUpdate: vi.fn(async () => 0),
|
||||
}));
|
||||
const historyResumeMocks = vi.hoisted(() => ({
|
||||
spawnHistoryResume: vi.fn<() => Promise<number | undefined>>(
|
||||
async () => undefined,
|
||||
),
|
||||
}));
|
||||
const loggingMocks = vi.hoisted(() => ({
|
||||
createCliLoggerAdapter: vi.fn(() => ({
|
||||
core: {
|
||||
@@ -101,10 +108,14 @@ const hubRuntimeMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
const telemetryMocks = vi.hoisted(() => ({
|
||||
captureCliExtensionActivated: vi.fn(),
|
||||
identifyCliTelemetryAccount: vi.fn(),
|
||||
identifyTelemetryAccount: vi.fn(),
|
||||
getCliTelemetryService: vi.fn(),
|
||||
disposeCliTelemetryService: vi.fn(async () => {}),
|
||||
}));
|
||||
const featureFlagMocks = vi.hoisted(() => ({
|
||||
getBooleanFlagEnabled: vi.fn(() => false),
|
||||
setCliFeatureFlagsAccountContext: vi.fn(),
|
||||
}));
|
||||
|
||||
function forcePromptModeInput() {
|
||||
Object.defineProperty(process.stdin, "isTTY", {
|
||||
@@ -148,8 +159,8 @@ vi.mock("@cline/core", () => {
|
||||
stop: vi.fn(),
|
||||
})),
|
||||
ProviderSettingsManager: class {
|
||||
getLastUsedProviderSettings() {
|
||||
return providerSettingsMocks.getLastUsedProviderSettings();
|
||||
getLastUsedProviderSettings(options?: unknown) {
|
||||
return providerSettingsMocks.getLastUsedProviderSettings(options);
|
||||
}
|
||||
getProviderSettings(providerId: string) {
|
||||
return providerSettingsMocks.getProviderSettings(providerId);
|
||||
@@ -164,6 +175,14 @@ vi.mock("@cline/core", () => {
|
||||
};
|
||||
});
|
||||
vi.mock("./utils/provider-auth", () => authMocks);
|
||||
vi.mock("./utils/feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
refreshCliFeatureFlagsInBackground: vi.fn(),
|
||||
setCliFeatureFlagsAccountContext:
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext,
|
||||
}));
|
||||
vi.mock("./runtime/prompt", () => ({
|
||||
resolveSystemPrompt: promptMocks.resolveSystemPrompt,
|
||||
}));
|
||||
@@ -172,6 +191,7 @@ vi.mock("./commands/dashboard", () => dashboardMocks);
|
||||
vi.mock("./kanban-migration/notice", () => migrationNoticeMocks);
|
||||
vi.mock("./commands/update", () => updateMocks);
|
||||
vi.mock("./commands/history", () => historyMocks);
|
||||
vi.mock("./utils/history-resume", () => historyResumeMocks);
|
||||
vi.mock("./logging/adapter", () => loggingMocks);
|
||||
vi.mock("./utils/hub-runtime", () => hubRuntimeMocks);
|
||||
vi.mock("./utils/telemetry", () => telemetryMocks);
|
||||
@@ -191,6 +211,8 @@ describe("runCli lightweight command dispatch", () => {
|
||||
historyMocks.runHistoryExport.mockResolvedValue(0);
|
||||
historyMocks.runHistoryUpdate.mockReset();
|
||||
historyMocks.runHistoryUpdate.mockResolvedValue(0);
|
||||
historyResumeMocks.spawnHistoryResume.mockReset();
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValue(undefined);
|
||||
sessionMocks.getSessionRow.mockReset();
|
||||
sessionMocks.getSessionRow.mockResolvedValue({
|
||||
sessionId: "sess_123",
|
||||
@@ -233,6 +255,9 @@ describe("runCli lightweight command dispatch", () => {
|
||||
providerSettingsMocks.getProviderSettings.mockReset();
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue(undefined);
|
||||
providerSettingsMocks.saveProviderSettings.mockReset();
|
||||
featureFlagMocks.getBooleanFlagEnabled.mockReset();
|
||||
featureFlagMocks.getBooleanFlagEnabled.mockReturnValue(false);
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext.mockReset();
|
||||
kanbanMocks.launchKanban.mockReset();
|
||||
kanbanMocks.launchKanban.mockResolvedValue(0);
|
||||
dashboardMocks.runDashboardCommand.mockReset();
|
||||
@@ -246,7 +271,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
updateMocks.getPreferredKanbanInstaller.mockReset();
|
||||
updateMocks.getPreferredKanbanInstaller.mockReturnValue(undefined);
|
||||
telemetryMocks.captureCliExtensionActivated.mockReset();
|
||||
telemetryMocks.identifyCliTelemetryAccount.mockReset();
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
telemetryMocks.getCliTelemetryService.mockReset();
|
||||
telemetryMocks.disposeCliTelemetryService.mockReset();
|
||||
telemetryMocks.disposeCliTelemetryService.mockResolvedValue(undefined);
|
||||
@@ -392,6 +417,61 @@ describe("runCli lightweight command dispatch", () => {
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects multiple bare positional prompt tokens", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "hello", "world"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"Unknown command or extra arguments: hello world",
|
||||
),
|
||||
);
|
||||
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
||||
expect(mockState.runAgentImports).toBe(0);
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
|
||||
it("runs quoted positional prompt text", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "hello world"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
|
||||
"hello world",
|
||||
expect.any(Object),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects unknown root flags before loading runtime modules", async () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--made-up-flag"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(consoleError).toHaveBeenCalledWith(
|
||||
expect.stringContaining("unknown option '--made-up-flag'"),
|
||||
);
|
||||
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
|
||||
expect(mockState.runAgentImports).toBe(0);
|
||||
expect(mockState.runInteractiveImports).toBe(0);
|
||||
});
|
||||
|
||||
it("creates a worktree and runs prompt sessions from it", async () => {
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "--worktree", "hello"];
|
||||
@@ -719,10 +799,47 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("forces chat view when resuming from history picker", async () => {
|
||||
it("resumes a history-picked session in a child process", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(0);
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledTimes(1);
|
||||
expect(historyResumeMocks.spawnHistoryResume).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
sessionId: "sess_from_history",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["history"],
|
||||
}),
|
||||
);
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
expect(process.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it("propagates the child exit code when resuming from history picker", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(3);
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(process.exitCode).toBe(3);
|
||||
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forces chat view when the history-picker child cannot launch", async () => {
|
||||
historyMocks.runHistoryList.mockImplementationOnce(
|
||||
async () => "sess_from_history",
|
||||
);
|
||||
historyResumeMocks.spawnHistoryResume.mockResolvedValueOnce(undefined);
|
||||
process.argv = ["bun", "src/index.ts", "history"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
@@ -801,6 +918,33 @@ describe("runCli lightweight command dispatch", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("seeds feature flag identity from persisted Cline account id before checking flags", async () => {
|
||||
const clineSettings = {
|
||||
provider: "cline",
|
||||
model: "anthropic/claude-sonnet-4.6",
|
||||
auth: {
|
||||
accountId: "acct-startup",
|
||||
accessToken: "workos:token",
|
||||
refreshToken: "refresh-token",
|
||||
},
|
||||
};
|
||||
providerSettingsMocks.getProviderSettings.mockReturnValue(clineSettings);
|
||||
process.argv = ["bun", "src/index.ts"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext,
|
||||
).toHaveBeenCalledWith({ id: "acct-startup" });
|
||||
expect(
|
||||
featureFlagMocks.setCliFeatureFlagsAccountContext.mock
|
||||
.invocationCallOrder[0],
|
||||
).toBeLessThan(
|
||||
featureFlagMocks.getBooleanFlagEnabled.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it("runs kanban before loading runtime modules", async () => {
|
||||
process.argv = ["bun", "src/index.ts", "kanban"];
|
||||
|
||||
@@ -818,6 +962,10 @@ describe("runCli lightweight command dispatch", () => {
|
||||
"bun",
|
||||
"src/index.ts",
|
||||
"dashboard",
|
||||
"--config",
|
||||
"/tmp/cline-config",
|
||||
"--data-dir",
|
||||
".cline-dashboard-data",
|
||||
"--port",
|
||||
"9090",
|
||||
"--no-open",
|
||||
@@ -828,6 +976,8 @@ describe("runCli lightweight command dispatch", () => {
|
||||
await expect(runCli()).resolves.toBeUndefined();
|
||||
expect(dashboardMocks.runDashboardCommand).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
configDir: "/tmp/cline-config",
|
||||
dataDir: ".cline-dashboard-data",
|
||||
port: "9090",
|
||||
openBrowser: false,
|
||||
io: expect.any(Object),
|
||||
@@ -876,7 +1026,7 @@ describe("runCli lightweight command dispatch", () => {
|
||||
runtimeMocks.runAgent.mockClear();
|
||||
|
||||
forcePromptModeInput();
|
||||
process.argv = ["bun", "src/index.ts", "/team", "find", "the", "bug"];
|
||||
process.argv = ["bun", "src/index.ts", "/team find the bug"];
|
||||
|
||||
const { runCli } = await import("./main");
|
||||
|
||||
|
||||
+97
-3
@@ -19,6 +19,11 @@ import {
|
||||
buildCliCompactionConfig,
|
||||
CLI_COMPACTION_MODE_EXPECTED_TEXT,
|
||||
} from "./utils/compaction-mode";
|
||||
import {
|
||||
getCliFeatureFlagsService,
|
||||
refreshCliFeatureFlagsInBackground,
|
||||
setCliFeatureFlagsAccountContext,
|
||||
} from "./utils/feature-flags";
|
||||
import {
|
||||
configureSandboxEnvironment,
|
||||
normalizeAutoApproveArgs,
|
||||
@@ -135,7 +140,7 @@ export async function runCli(): Promise<void> {
|
||||
// Re-enable built-in help/version output for the routing program
|
||||
program.configureOutput({
|
||||
writeOut: (str: string) => process.stdout.write(str),
|
||||
writeErr: (str: string) => process.stderr.write(str),
|
||||
writeErr: () => {},
|
||||
});
|
||||
// Default action handles non-subcommand args (e.g. prompt text)
|
||||
program.action(() => {});
|
||||
@@ -311,6 +316,28 @@ export async function runCli(): Promise<void> {
|
||||
io,
|
||||
});
|
||||
});
|
||||
const skillCmd = program
|
||||
.command("skill")
|
||||
.description("Manage Cline Skills via the open skills CLI (npx skills)")
|
||||
.allowUnknownOption()
|
||||
.passThroughOptions()
|
||||
.argument("[args...]", "arguments forwarded to the skills CLI")
|
||||
.addHelpText(
|
||||
"after",
|
||||
"\nForwards to the open skills CLI via npx. Examples:\n" +
|
||||
" cline skill add <owner/repo> Add a skill into Cline\n" +
|
||||
" cline skill install <owner/repo> Alias for add\n" +
|
||||
" cline skill list List installed skills\n" +
|
||||
" cline skill remove Remove installed skills\n" +
|
||||
" cline skill uninstall Alias for remove\n" +
|
||||
"\nadd/install and remove/uninstall default to '--agent cline' unless you pass your own --agent.\n" +
|
||||
"Run 'npx skills --help' for the full command reference.",
|
||||
)
|
||||
.action(async () => {
|
||||
const { runSkillCommand } = await import("./commands/skill");
|
||||
ctx.exitCode = await runSkillCommand(skillCmd.args, io);
|
||||
});
|
||||
|
||||
const connectCmd = program
|
||||
.command("connect")
|
||||
.description("Connect to an external channel")
|
||||
@@ -352,7 +379,7 @@ export async function runCli(): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
program
|
||||
const mcpCmd = program
|
||||
.command("mcp")
|
||||
.description("Manage MCP servers")
|
||||
.action(async () => {
|
||||
@@ -364,6 +391,31 @@ export async function runCli(): Promise<void> {
|
||||
);
|
||||
}
|
||||
});
|
||||
const mcpInstallCmd = mcpCmd
|
||||
.command("install")
|
||||
.alias("add")
|
||||
.description("Open the MCP add wizard with server fields prefilled")
|
||||
.argument("<name>", "MCP server name")
|
||||
.argument(
|
||||
"[targetArgs...]",
|
||||
"URL for remote transports, or command and args after -- for stdio",
|
||||
)
|
||||
.option(
|
||||
"--transport <transport>",
|
||||
"stdio, sse, http, streamable-http, or streamableHttp (default: stdio)",
|
||||
)
|
||||
.action(async (name: string, targetArgs: string[]) => {
|
||||
const opts = mcpInstallCmd.opts<{
|
||||
transport?: string;
|
||||
}>();
|
||||
const { runMcpInstallCommand } = await import("./commands/mcp");
|
||||
ctx.exitCode = await runMcpInstallCommand({
|
||||
name,
|
||||
targetArgs,
|
||||
transport: opts.transport,
|
||||
io,
|
||||
});
|
||||
});
|
||||
|
||||
const createDoctorRuntimeCommand = async () => {
|
||||
const { createDoctorCommand } = await import("./commands/doctor");
|
||||
@@ -544,7 +596,12 @@ export async function runCli(): Promise<void> {
|
||||
const dashboardCmd = program
|
||||
.command("dashboard")
|
||||
.description("Start the Cline Hub dashboard and open it in a browser")
|
||||
.option("--config <dir>", "configuration directory")
|
||||
.option("-c, --cwd <path>", "Workspace root", process.cwd())
|
||||
.option(
|
||||
"--data-dir <dir>",
|
||||
"Use isolated local state at <dir> instead of ~/.cline (enables sandbox mode)",
|
||||
)
|
||||
.option("--host <host>", "Dashboard bind host")
|
||||
.option("--port <port>", "Dashboard HTTP/WebSocket port")
|
||||
.option("--public-url <url>", "Public dashboard URL")
|
||||
@@ -552,7 +609,9 @@ export async function runCli(): Promise<void> {
|
||||
.option("--no-open", "Start the dashboard without opening a browser")
|
||||
.action(async () => {
|
||||
const opts = dashboardCmd.opts<{
|
||||
config?: string;
|
||||
cwd?: string;
|
||||
dataDir?: string;
|
||||
host?: string;
|
||||
port?: string;
|
||||
publicUrl?: string;
|
||||
@@ -561,7 +620,9 @@ export async function runCli(): Promise<void> {
|
||||
}>();
|
||||
const { runDashboardCommand } = await import("./commands/dashboard");
|
||||
ctx.exitCode = await runDashboardCommand({
|
||||
configDir: opts.config,
|
||||
cwd: opts.cwd,
|
||||
dataDir: opts.dataDir,
|
||||
host: opts.host,
|
||||
port: opts.port,
|
||||
publicUrl: opts.publicUrl,
|
||||
@@ -610,6 +671,7 @@ export async function runCli(): Promise<void> {
|
||||
if (err instanceof CommanderError) {
|
||||
if (err.exitCode !== 0) {
|
||||
writeErr(err.message);
|
||||
process.exitCode = err.exitCode;
|
||||
return;
|
||||
}
|
||||
return;
|
||||
@@ -660,9 +722,31 @@ export async function runCli(): Promise<void> {
|
||||
|
||||
// Default flow: no subcommand matched, or fall-through from config/history.
|
||||
let args = commanderToParsedArgs(program);
|
||||
if (program.args.length > 1) {
|
||||
writeErr(
|
||||
`Unknown command or extra arguments: ${program.args.join(" ")}\nPrompt text with spaces must be quoted as a single argument, for example: cline "fix the tests". Use "cline --help" to see available commands and flags.`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
let resumeSessionId: string | undefined = ctx.resumeSessionId;
|
||||
if (resumeSessionId) {
|
||||
// The history picker already created (and tore down) an OpenTUI renderer
|
||||
// in this process; starting the interactive TUI here would create a
|
||||
// second one, which can crash natively during teardown. Resume in a
|
||||
// fresh `cline --id <session-id>` child process instead.
|
||||
const { spawnHistoryResume } = await import("./utils/history-resume");
|
||||
const childExitCode = await spawnHistoryResume({
|
||||
sessionId: resumeSessionId,
|
||||
normalizedArgs,
|
||||
remainingArgs: program.args,
|
||||
configDir,
|
||||
});
|
||||
if (childExitCode !== undefined) {
|
||||
process.exitCode = childExitCode;
|
||||
return;
|
||||
}
|
||||
args = {
|
||||
...args,
|
||||
interactive: true,
|
||||
@@ -836,8 +920,18 @@ export async function runCli(): Promise<void> {
|
||||
};
|
||||
registerDisposable(stopUserInstructionService);
|
||||
try {
|
||||
const persistedClineAccountId = providerSettingsManager
|
||||
.getProviderSettings("cline")
|
||||
?.auth?.accountId?.trim();
|
||||
if (persistedClineAccountId) {
|
||||
setCliFeatureFlagsAccountContext({ id: persistedClineAccountId });
|
||||
}
|
||||
refreshCliFeatureFlagsInBackground();
|
||||
const lastUsedProviderSettings =
|
||||
providerSettingsManager.getLastUsedProviderSettings();
|
||||
providerSettingsManager.getLastUsedProviderSettings({
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
const provider = normalizeProviderId(
|
||||
args.provider?.trim() || lastUsedProviderSettings?.provider || "cline",
|
||||
);
|
||||
|
||||
@@ -61,6 +61,20 @@ describe("createInteractiveApprovalController", () => {
|
||||
).resolves.toEqual({ approved: false, reason: "no" });
|
||||
});
|
||||
|
||||
it("approves stale required-approval requests after auto-approve is enabled", async () => {
|
||||
const controller = createInteractiveApprovalController(makeConfig(false));
|
||||
controller.tuiToolApprover.current = async () => ({
|
||||
approved: false,
|
||||
reason: "stale prompt",
|
||||
});
|
||||
|
||||
controller.setInteractiveAutoApprove(true);
|
||||
|
||||
await expect(
|
||||
controller.requestToolApproval(makeRequest({ autoApprove: false })),
|
||||
).resolves.toEqual({ approved: true });
|
||||
});
|
||||
|
||||
it("denies approval-required requests when no TUI approver is available", async () => {
|
||||
const controller = createInteractiveApprovalController(makeConfig(false));
|
||||
|
||||
@@ -77,6 +91,7 @@ describe("createInteractiveApprovalController", () => {
|
||||
|
||||
expect(controller.autoApproveAllRef.current).toBe(true);
|
||||
expect(config.defaultToolAutoApprove).toBe(false);
|
||||
expect(config.toolPolicies["*"]?.autoApprove).toBe(false);
|
||||
expect(config.toolPolicies["*"]?.autoApprove).toBe(true);
|
||||
expect(controller.resolveToolPolicy("run_commands").autoApprove).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Config } from "../../utils/types";
|
||||
import {
|
||||
applyInteractiveAutoApproveOverride,
|
||||
cloneToolPolicies,
|
||||
resolveInteractiveAutoApprovePolicy,
|
||||
} from "../tool-policies";
|
||||
|
||||
export interface InteractiveRuntimeRefs {
|
||||
@@ -38,10 +39,10 @@ export function createInteractiveApprovalController(config: Config) {
|
||||
const requestToolApproval = async (
|
||||
request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> => {
|
||||
if (request.policy?.autoApprove === true) {
|
||||
if (autoApproveAllRef.current) {
|
||||
return { approved: true };
|
||||
}
|
||||
if (autoApproveAllRef.current && request.policy?.autoApprove !== false) {
|
||||
if (request.policy?.autoApprove === true) {
|
||||
return { approved: true };
|
||||
}
|
||||
if (refs.tuiToolApprover.current) {
|
||||
@@ -54,6 +55,12 @@ export function createInteractiveApprovalController(config: Config) {
|
||||
autoApproveAllRef,
|
||||
setInteractiveAutoApprove,
|
||||
requestToolApproval,
|
||||
resolveToolPolicy: (toolName: string) =>
|
||||
resolveInteractiveAutoApprovePolicy({
|
||||
toolName,
|
||||
baselinePolicies: baselineToolPolicies,
|
||||
enabled: autoApproveAllRef.current,
|
||||
}),
|
||||
...refs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
type ChatCommandState,
|
||||
chatCommandHost,
|
||||
createChatCommandHost,
|
||||
} from "../../utils/chat-commands";
|
||||
import type { Config } from "../../utils/types";
|
||||
import {
|
||||
@@ -162,4 +163,37 @@ describe("runInteractiveChatCommand", () => {
|
||||
expect(state.autoApproveTools).toBe(true);
|
||||
expect(setInteractiveAutoApprove).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("returns plugin command submit prompts as model input", async () => {
|
||||
const config = makeConfig();
|
||||
const runtime = makeRuntime();
|
||||
const onCommandOutput = vi.fn();
|
||||
const host = createChatCommandHost().register("command", {
|
||||
names: ["/goal"],
|
||||
run: async ({ args }, context) => {
|
||||
await context.reply(`Goal guard set: ${args.join(" ")}`);
|
||||
await context.submitPrompt?.(args.join(" "));
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runInteractiveChatCommand({
|
||||
prompt: "/goal fix tests",
|
||||
enabled: true,
|
||||
config,
|
||||
host,
|
||||
chatCommandState: makeState(config),
|
||||
autoApproveAllRef: { current: false },
|
||||
setInteractiveAutoApprove: () => {},
|
||||
sessionRuntime: runtime,
|
||||
stop: () => {},
|
||||
onCommandOutput,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
handled: false,
|
||||
input: "fix tests",
|
||||
commandOutput: "Goal guard set: fix tests",
|
||||
});
|
||||
expect(onCommandOutput).toHaveBeenCalledWith("Goal guard set: fix tests");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ export type InteractiveChatCommandRuntime = Pick<
|
||||
|
||||
export type InteractiveChatCommandResult =
|
||||
| { handled: true; turnResult: InteractiveTurnResult }
|
||||
| { handled: false; input: string };
|
||||
| { handled: false; input: string; commandOutput?: string };
|
||||
|
||||
function commandTurnResult(commandOutput?: string): InteractiveTurnResult {
|
||||
return {
|
||||
@@ -46,6 +46,7 @@ export async function runInteractiveChatCommand(input: {
|
||||
setInteractiveAutoApprove: (enabled: boolean) => void;
|
||||
sessionRuntime: InteractiveChatCommandRuntime;
|
||||
stop: () => void;
|
||||
onCommandOutput?: (text: string) => void;
|
||||
}): Promise<InteractiveChatCommandResult> {
|
||||
let prompt = input.prompt;
|
||||
const rewrittenTeamPrompt = rewriteTeamPrompt(prompt);
|
||||
@@ -64,6 +65,7 @@ export async function runInteractiveChatCommand(input: {
|
||||
}
|
||||
|
||||
let commandOutput: string | undefined;
|
||||
let submitPrompt: string | undefined;
|
||||
const handled = await maybeHandleChatCommand(prompt, {
|
||||
enabled: input.enabled,
|
||||
host: input.host,
|
||||
@@ -80,6 +82,13 @@ export async function runInteractiveChatCommand(input: {
|
||||
},
|
||||
reply: async (text) => {
|
||||
commandOutput = text;
|
||||
input.onCommandOutput?.(text);
|
||||
},
|
||||
submitPrompt: async (text) => {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed) {
|
||||
submitPrompt = trimmed;
|
||||
}
|
||||
},
|
||||
reset: async () => {
|
||||
await input.sessionRuntime.resetForNewSession();
|
||||
@@ -98,6 +107,13 @@ export async function runInteractiveChatCommand(input: {
|
||||
fork: input.sessionRuntime.forkCurrentSession,
|
||||
});
|
||||
if (handled) {
|
||||
if (submitPrompt) {
|
||||
return {
|
||||
handled: false,
|
||||
input: submitPrompt,
|
||||
...(commandOutput ? { commandOutput } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
handled: true,
|
||||
turnResult: commandTurnResult(commandOutput),
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { UserInstructionConfigService } from "@cline/core";
|
||||
@@ -43,9 +50,17 @@ describe("interactive config data loader", () => {
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH =
|
||||
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
|
||||
if (envSnapshot.CLINE_GLOBAL_SETTINGS_PATH === undefined) {
|
||||
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH =
|
||||
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
|
||||
}
|
||||
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
|
||||
}
|
||||
await Promise.all(
|
||||
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
|
||||
);
|
||||
@@ -76,6 +91,28 @@ describe("interactive config data loader", () => {
|
||||
return pluginPath;
|
||||
}
|
||||
|
||||
async function writeMcpSettingsPlugin(tempRoot: string): Promise<string> {
|
||||
const pluginsDir = join(tempRoot, ".cline", "plugins");
|
||||
await mkdir(pluginsDir, { recursive: true });
|
||||
const pluginPath = join(pluginsDir, "settings-mcp-plugin.js");
|
||||
await writeFile(
|
||||
pluginPath,
|
||||
[
|
||||
"export default {",
|
||||
" name: 'settings-mcp-plugin',",
|
||||
" manifest: { capabilities: ['mcp'] },",
|
||||
" setup(api) {",
|
||||
" api.registerMcpServer({",
|
||||
" name: 'smoke',",
|
||||
" transport: { type: 'stdio', command: process.execPath, args: ['-e', 'process.exit(0)'] },",
|
||||
" });",
|
||||
" },",
|
||||
"};",
|
||||
].join("\n"),
|
||||
);
|
||||
return pluginPath;
|
||||
}
|
||||
|
||||
it("toggles a skill item to the opposite enabled state and refreshes before reload", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
@@ -311,6 +348,70 @@ Find installable skills.`,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("loads plugin-owned MCP servers from settings", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
smoke: {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.exit(0)"],
|
||||
},
|
||||
metadata: {
|
||||
source: "plugin",
|
||||
pluginName: "settings-mcp-plugin",
|
||||
pluginPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
});
|
||||
|
||||
const data = await loader.loadConfigData({ includePluginTools: true });
|
||||
|
||||
expect(
|
||||
data.mcp.some(
|
||||
(item) =>
|
||||
item.name === "smoke" &&
|
||||
item.pluginName === "settings-mcp-plugin" &&
|
||||
item.pluginPath === pluginPath &&
|
||||
item.kind === "mcp",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not load plugin MCP rows directly from plugin diagnostics", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
|
||||
tempRoot,
|
||||
"global-settings.json",
|
||||
);
|
||||
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
});
|
||||
|
||||
const data = await loader.loadConfigData({ includePluginTools: true });
|
||||
|
||||
expect(data.plugins.some((item) => item.path === pluginPath)).toBe(true);
|
||||
expect(data.mcp.some((item) => item.pluginPath === pluginPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps failed plugins visible with their load error", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
@@ -731,6 +832,142 @@ Review with the bundled skill.`,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("disables and re-syncs plugin-owned MCP servers when toggling plugins", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
|
||||
tempRoot,
|
||||
"global-settings.json",
|
||||
);
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
smoke: {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.exit(0)"],
|
||||
},
|
||||
oauth: {
|
||||
tokens: {
|
||||
access_token: "token",
|
||||
},
|
||||
},
|
||||
metadata: {
|
||||
source: "plugin",
|
||||
pluginName: "settings-mcp-plugin",
|
||||
pluginPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
});
|
||||
const item: InteractiveConfigItem = {
|
||||
id: pluginPath,
|
||||
name: "settings-mcp-plugin",
|
||||
path: pluginPath,
|
||||
enabled: true,
|
||||
source: "workspace-plugin",
|
||||
kind: "plugin",
|
||||
};
|
||||
|
||||
await loader.onToggleConfigItem(item);
|
||||
let settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<
|
||||
string,
|
||||
{ disabled?: boolean; oauth?: { tokens?: Record<string, string> } }
|
||||
>;
|
||||
};
|
||||
expect(settings.mcpServers?.smoke?.disabled).toBe(true);
|
||||
expect(settings.mcpServers?.smoke?.oauth?.tokens?.access_token).toBe(
|
||||
"token",
|
||||
);
|
||||
|
||||
await loader.onToggleConfigItem({ ...item, enabled: false });
|
||||
settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<
|
||||
string,
|
||||
{ disabled?: boolean; oauth?: { tokens?: Record<string, string> } }
|
||||
>;
|
||||
};
|
||||
expect(settings.mcpServers?.smoke?.disabled).toBeUndefined();
|
||||
expect(settings.mcpServers?.smoke?.oauth?.tokens?.access_token).toBe(
|
||||
"token",
|
||||
);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"does not mark plugin disabled when MCP disable write fails",
|
||||
async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
|
||||
const globalSettingsPath = join(tempRoot, "global-settings.json");
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = globalSettingsPath;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
const pluginPath = await writeMcpSettingsPlugin(tempRoot);
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
smoke: {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: process.execPath,
|
||||
args: ["-e", "process.exit(0)"],
|
||||
},
|
||||
metadata: {
|
||||
source: "plugin",
|
||||
pluginName: "settings-mcp-plugin",
|
||||
pluginPath,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
await chmod(settingsPath, 0o444);
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
loader.onToggleConfigItem({
|
||||
id: pluginPath,
|
||||
name: "settings-mcp-plugin",
|
||||
path: pluginPath,
|
||||
enabled: true,
|
||||
source: "workspace-plugin",
|
||||
kind: "plugin",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
} finally {
|
||||
await chmod(settingsPath, 0o644);
|
||||
}
|
||||
|
||||
await expect(readFile(globalSettingsPath, "utf8")).rejects.toThrow();
|
||||
const settings = JSON.parse(await readFile(settingsPath, "utf8")) as {
|
||||
mcpServers?: Record<string, { disabled?: boolean }>;
|
||||
};
|
||||
expect(settings.mcpServers?.smoke?.disabled).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("surfaces MCP OAuth status and errors", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import {
|
||||
createCoreSettingsService,
|
||||
disablePluginMcpServersInSettings,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
syncPluginMcpServersToSettings,
|
||||
type UserInstructionConfigService,
|
||||
uninstallPlugin,
|
||||
} from "@cline/core";
|
||||
@@ -70,7 +72,32 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
}
|
||||
|
||||
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
|
||||
setDisabledPlugin(item.path, item.enabled);
|
||||
if (item.enabled) {
|
||||
disablePluginMcpServersInSettings({ pluginPaths: [item.path] });
|
||||
setDisabledPlugin(item.path, true);
|
||||
} else {
|
||||
const ownedMcpMutations = disablePluginMcpServersInSettings({
|
||||
pluginPaths: [item.path],
|
||||
});
|
||||
const result = await syncPluginMcpServersToSettings({
|
||||
pluginPaths: [item.path],
|
||||
cwd: input.config.cwd,
|
||||
workspacePath: workspaceRoot(),
|
||||
providerId: input.config.providerId,
|
||||
modelId: input.config.modelId,
|
||||
});
|
||||
if (ownedMcpMutations.length > 0 && result.failures.length > 0) {
|
||||
throw new Error(
|
||||
`Failed to sync plugin MCP servers: ${result.failures
|
||||
.map((failure) => {
|
||||
const plugin = failure.pluginName ?? failure.pluginPath;
|
||||
return `${plugin}: ${failure.message}`;
|
||||
})
|
||||
.join("; ")}`,
|
||||
);
|
||||
}
|
||||
setDisabledPlugin(item.path, false);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -152,7 +152,10 @@ function deferred<T>() {
|
||||
|
||||
function makeRuntime(
|
||||
manager: ReturnType<typeof makeManager>,
|
||||
options: { resumeSessionId?: string } = {},
|
||||
options: {
|
||||
resumeSessionId?: string;
|
||||
resolveToolPolicy?: (toolName: string) => Config["toolPolicies"][string];
|
||||
} = {},
|
||||
) {
|
||||
mockCreateCliCore.mockResolvedValue(manager);
|
||||
const config = makeConfig();
|
||||
@@ -164,6 +167,8 @@ function makeRuntime(
|
||||
requestToolApproval: async (
|
||||
_request: ToolApprovalRequest,
|
||||
): Promise<ToolApprovalResult> => ({ approved: true }),
|
||||
resolveToolPolicy:
|
||||
options.resolveToolPolicy ?? (() => ({ autoApprove: true })),
|
||||
askQuestionRef: { current: null },
|
||||
resolveMistakeLimitDecision: undefined,
|
||||
switchToActModeTool: makeSwitchToActModeTool(),
|
||||
@@ -205,6 +210,68 @@ describe("createInteractiveSessionRuntime", () => {
|
||||
expect(runtime.getActiveSessionId()).toBe("session-2");
|
||||
});
|
||||
|
||||
it("adds a live interactive approval policy hook to started sessions", async () => {
|
||||
const manager = makeManager();
|
||||
const upstreamBeforeTool = vi.fn(async () => ({
|
||||
input: { text: "updated" },
|
||||
}));
|
||||
mockCreateRuntimeHooks.mockReturnValueOnce({
|
||||
hooks: {
|
||||
beforeTool: upstreamBeforeTool,
|
||||
},
|
||||
shutdown: vi.fn(async () => {}),
|
||||
});
|
||||
const runtime = makeRuntime(manager, {
|
||||
resolveToolPolicy: (toolName) => ({
|
||||
autoApprove: toolName === "echo",
|
||||
}),
|
||||
});
|
||||
|
||||
await runtime.ensureReady();
|
||||
|
||||
const startInput = manager.start.mock.calls[0]?.[0] as
|
||||
| { config?: Config }
|
||||
| undefined;
|
||||
const beforeTool = startInput?.config?.hooks?.beforeTool;
|
||||
expect(beforeTool).toBeTypeOf("function");
|
||||
|
||||
const result = await beforeTool?.({
|
||||
snapshot: {
|
||||
agentId: "agent-1",
|
||||
conversationId: "conversation-1",
|
||||
status: "running",
|
||||
iteration: 1,
|
||||
messages: [],
|
||||
pendingToolCalls: [],
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
},
|
||||
},
|
||||
tool: {
|
||||
name: "echo",
|
||||
description: "",
|
||||
inputSchema: {},
|
||||
execute: async () => "ok",
|
||||
},
|
||||
toolCall: {
|
||||
type: "tool-call",
|
||||
toolCallId: "call-1",
|
||||
toolName: "echo",
|
||||
input: { text: "original" },
|
||||
},
|
||||
input: { text: "original" },
|
||||
});
|
||||
|
||||
expect(upstreamBeforeTool).toHaveBeenCalledOnce();
|
||||
expect(result).toEqual({
|
||||
input: { text: "updated" },
|
||||
policy: { autoApprove: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("starts fresh after resetting an initially resumed session", async () => {
|
||||
const manager = makeManager();
|
||||
const runtime = makeRuntime(manager, {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
type AgentEvent,
|
||||
type AgentHooks,
|
||||
type CheckpointEntry,
|
||||
isSessionNotFoundError,
|
||||
type PendingPromptMutationResult,
|
||||
@@ -48,6 +49,32 @@ type CurrentTurnResult = Awaited<ReturnType<CliCore["send"]>>;
|
||||
type AskQuestionRef = {
|
||||
current: ((question: string, options: string[]) => Promise<string>) | null;
|
||||
};
|
||||
type ToolPolicyResolver = (
|
||||
toolName: string,
|
||||
) => NonNullable<Config["toolPolicies"]>[string];
|
||||
|
||||
function withInteractiveApprovalPolicyHook(
|
||||
hooks: AgentHooks | undefined,
|
||||
resolveToolPolicy: ToolPolicyResolver,
|
||||
): AgentHooks {
|
||||
return {
|
||||
...hooks,
|
||||
beforeTool: async (ctx) => {
|
||||
const result = await hooks?.beforeTool?.(ctx);
|
||||
if (result?.stop || result?.skip) {
|
||||
return result;
|
||||
}
|
||||
const policy = resolveToolPolicy(ctx.toolCall.toolName);
|
||||
return {
|
||||
...result,
|
||||
policy: {
|
||||
...result?.policy,
|
||||
autoApprove: policy.autoApprove,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createInteractiveSessionRuntime(input: {
|
||||
config: Config;
|
||||
@@ -58,6 +85,7 @@ export function createInteractiveSessionRuntime(input: {
|
||||
requestToolApproval: (
|
||||
request: ToolApprovalRequest,
|
||||
) => Promise<ToolApprovalResult>;
|
||||
resolveToolPolicy: ToolPolicyResolver;
|
||||
askQuestionRef: AskQuestionRef;
|
||||
resolveMistakeLimitDecision: Config["onConsecutiveMistakeLimitReached"];
|
||||
switchToActModeTool: NonNullable<Config["extraTools"]>[number];
|
||||
@@ -152,10 +180,14 @@ export function createInteractiveSessionRuntime(input: {
|
||||
if (!runtimeHooks) {
|
||||
throw new Error("interactive runtime hooks are unavailable");
|
||||
}
|
||||
const hooks = withInteractiveApprovalPolicyHook(
|
||||
runtimeHooks.hooks,
|
||||
input.resolveToolPolicy,
|
||||
);
|
||||
return buildInteractiveSessionConfig({
|
||||
config: input.config,
|
||||
chatCommandState: input.chatCommandState,
|
||||
runtimeHooks,
|
||||
runtimeHooks: { hooks },
|
||||
onTeamEvent: input.onTeamEvent,
|
||||
resolveMistakeLimitDecision: input.resolveMistakeLimitDecision,
|
||||
});
|
||||
|
||||
@@ -27,7 +27,28 @@ const outputMocks = vi.hoisted(() => ({
|
||||
c: { dim: "", reset: "" },
|
||||
}));
|
||||
|
||||
const sessionEventsMocks = vi.hoisted(() => ({
|
||||
listener: undefined as ((event: unknown) => void) | undefined,
|
||||
subscribeToAgentEvents: vi.fn(
|
||||
(_: unknown, listener: (event: unknown) => void) => {
|
||||
sessionEventsMocks.listener = listener;
|
||||
return () => {};
|
||||
},
|
||||
),
|
||||
}));
|
||||
|
||||
const CLINE_PASS_SUBSCRIPTION_URL =
|
||||
"https://app.cline.bot/dashboard/subscription/";
|
||||
const CLINE_PASS_SUBSCRIPTION_MESSAGE = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${CLINE_PASS_SUBSCRIPTION_URL}`;
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
getClinePassSubscriptionUrl: () => CLINE_PASS_SUBSCRIPTION_URL,
|
||||
isClineNotSubscribedError: (error: unknown) =>
|
||||
error instanceof Error && error.name === "ClineNotSubscribedError",
|
||||
isClineNotSubscribedMessage: (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes("the user is not subscribed to required model plan"),
|
||||
prewarmFileIndex: vi.fn(async () => undefined),
|
||||
SessionSource: {
|
||||
CLI: "cli",
|
||||
@@ -77,7 +98,7 @@ vi.mock("./prompt", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./session-events", () => ({
|
||||
subscribeToAgentEvents: vi.fn(() => () => {}),
|
||||
subscribeToAgentEvents: sessionEventsMocks.subscribeToAgentEvents,
|
||||
}));
|
||||
|
||||
describe("runAgent", () => {
|
||||
@@ -101,6 +122,9 @@ describe("runAgent", () => {
|
||||
outputMocks.writeln.mockReset();
|
||||
outputMocks.emitJsonLine.mockReset();
|
||||
outputMocks.setActiveCliSession.mockReset();
|
||||
sessionEventsMocks.listener = undefined;
|
||||
sessionEventsMocks.subscribeToAgentEvents.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -511,6 +535,39 @@ describe("runAgent", () => {
|
||||
expect(outputMocks.writeErr).toHaveBeenCalledWith("Missing API key");
|
||||
});
|
||||
|
||||
it("renders ClinePass subscription errors with friendly copy when startup throws", async () => {
|
||||
const error = new Error(CLINE_PASS_SUBSCRIPTION_MESSAGE);
|
||||
error.name = "ClineNotSubscribedError";
|
||||
sessionManagerMocks.start.mockRejectedValue(error);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).toHaveBeenCalledWith(
|
||||
CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it("emits JSON error lines for non-completed results", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
@@ -576,6 +633,63 @@ describe("runAgent", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("renders ClinePass subscription errors with friendly copy for failed results", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
sessionManagerMocks.start.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: { session_id: "session-1" },
|
||||
result: {
|
||||
text: CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
usage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "error",
|
||||
model: { id: "premium-model", provider: "cline-pass", info: {} },
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue(undefined);
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: { maxConsecutiveMistakes: 3 },
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "premium-model",
|
||||
outputMode: "text",
|
||||
providerId: "cline-pass",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(outputMocks.writeErr).toHaveBeenCalledWith(
|
||||
CLINE_PASS_SUBSCRIPTION_MESSAGE,
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces post-run bookkeeping failures after a completed result", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
@@ -837,4 +951,121 @@ describe("runAgent", () => {
|
||||
expect.stringContaining("est. cost"),
|
||||
);
|
||||
});
|
||||
|
||||
it("zeros Cline free model costs in JSON results and agent events", async () => {
|
||||
const startedAt = new Date("2026-03-22T00:00:00.000Z");
|
||||
const endedAt = new Date("2026-03-22T00:00:01.000Z");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
sessionManagerMocks.start.mockResolvedValue({
|
||||
sessionId: "session-1",
|
||||
manifestPath: "/tmp/manifest.json",
|
||||
messagesPath: "/tmp/messages.json",
|
||||
manifest: {
|
||||
session_id: "session-1",
|
||||
},
|
||||
result: {
|
||||
text: "completed text",
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0.25,
|
||||
},
|
||||
messages: [],
|
||||
toolCalls: [],
|
||||
iterations: 1,
|
||||
finishReason: "completed",
|
||||
model: {
|
||||
id: "deepseek/deepseek-v4-flash",
|
||||
provider: "cline",
|
||||
info: {},
|
||||
},
|
||||
startedAt,
|
||||
endedAt,
|
||||
durationMs: 1000,
|
||||
},
|
||||
});
|
||||
sessionManagerMocks.getAccumulatedUsage.mockResolvedValue({
|
||||
usage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0.25,
|
||||
},
|
||||
aggregateUsage: {
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheReadTokens: 0,
|
||||
cacheWriteTokens: 0,
|
||||
totalCost: 0.25,
|
||||
},
|
||||
});
|
||||
|
||||
const { runAgent } = await import("./run-agent");
|
||||
const { handleEvent } = await import("../utils/events");
|
||||
|
||||
await expect(
|
||||
runAgent("test prompt", {
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
cwd: process.cwd(),
|
||||
enableAgentTeams: false,
|
||||
enableSpawnAgent: false,
|
||||
enableTools: [],
|
||||
execution: {
|
||||
maxConsecutiveMistakes: 3,
|
||||
},
|
||||
logger: undefined,
|
||||
mode: "yolo",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
outputMode: "json",
|
||||
providerId: "cline",
|
||||
systemPrompt: "system",
|
||||
thinking: false,
|
||||
toolPolicies: { "*": { autoApprove: true } },
|
||||
verbose: false,
|
||||
workspaceRoot: process.cwd(),
|
||||
} as never),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const runResult = outputMocks.emitJsonLine.mock.calls.find(
|
||||
([, payload]) =>
|
||||
(payload as { type?: string } | undefined)?.type === "run_result",
|
||||
)?.[1] as
|
||||
| {
|
||||
usage?: { totalCost?: number };
|
||||
aggregateUsage?: { totalCost?: number };
|
||||
}
|
||||
| undefined;
|
||||
expect(runResult?.usage?.totalCost).toBe(0);
|
||||
expect(runResult?.aggregateUsage?.totalCost).toBe(0);
|
||||
|
||||
sessionEventsMocks.listener?.({
|
||||
type: "usage",
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cost: 0.25,
|
||||
totalCost: 0.25,
|
||||
});
|
||||
|
||||
expect(handleEvent).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
type: "usage",
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,13 @@ import {
|
||||
requestToolApproval,
|
||||
submitAndExitInTerminal,
|
||||
} from "../utils/approval";
|
||||
import { formatCliErrorMessage } from "../utils/cline-pass-errors";
|
||||
import { handleEvent, handleTeamEvent } from "../utils/events";
|
||||
import {
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "../utils/free-model-cost";
|
||||
import { createRuntimeHooks } from "../utils/hooks";
|
||||
import {
|
||||
c,
|
||||
@@ -183,8 +189,10 @@ export async function runAgent(
|
||||
let reasoningChunkCount = 0;
|
||||
let redactedReasoningChunkCount = 0;
|
||||
const displayedErrorMessages = new Set<string>();
|
||||
const shouldZeroCost = await shouldZeroClineFreeModelCost(config);
|
||||
|
||||
const onAgentEvent = (event: AgentEvent): void => {
|
||||
const onAgentEvent = (rawEvent: AgentEvent): void => {
|
||||
const event = zeroCliAgentEventCost(rawEvent, shouldZeroCost);
|
||||
if (event.type === "content_start" && event.contentType === "reasoning") {
|
||||
reasoningChunkCount += 1;
|
||||
if (event.redacted) {
|
||||
@@ -338,8 +346,14 @@ export async function runAgent(
|
||||
const usageSummary = await sessionManager.getAccumulatedUsage(
|
||||
started.sessionId,
|
||||
);
|
||||
const aggregateUsage = usageSummary?.aggregateUsage;
|
||||
const usage = aggregateUsage ?? usageSummary?.usage ?? result.usage;
|
||||
const aggregateUsage = zeroCliUsageCost(
|
||||
usageSummary?.aggregateUsage,
|
||||
shouldZeroCost,
|
||||
);
|
||||
const usage = zeroCliUsageCost(
|
||||
aggregateUsage ?? usageSummary?.usage ?? result.usage,
|
||||
shouldZeroCost,
|
||||
);
|
||||
|
||||
if (config.outputMode === "json") {
|
||||
emitJsonLine("stdout", {
|
||||
@@ -374,7 +388,7 @@ export async function runAgent(
|
||||
}
|
||||
|
||||
if (result.finishReason !== "completed") {
|
||||
const errorText = result.text.trim();
|
||||
const errorText = formatCliErrorMessage(result.text).trim();
|
||||
if (
|
||||
errorText &&
|
||||
(config.outputMode === "json" || !displayedErrorMessages.has(errorText))
|
||||
@@ -395,7 +409,7 @@ export async function runAgent(
|
||||
);
|
||||
process.exitCode = 0;
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const message = formatCliErrorMessage(err);
|
||||
logCliError(config.logger, "CLI task run failed", { error: err });
|
||||
writeErr(message);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { CliMigrationNotice } from "../kanban-migration/notice";
|
||||
import { logCliError } from "../logging/errors";
|
||||
import {
|
||||
loadClineAccountSnapshot,
|
||||
onProviderChange,
|
||||
switchClineAccount,
|
||||
} from "../tui/cline-account";
|
||||
import type {
|
||||
@@ -23,6 +24,11 @@ import { disableOpenTuiGraphicsProbe } from "../tui/opentui-env";
|
||||
import type { QueuedPromptItem } from "../tui/types";
|
||||
import { type ChatCommandState, chatCommandHost } from "../utils/chat-commands";
|
||||
import { applyCliCompactionMode } from "../utils/compaction-mode";
|
||||
import {
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "../utils/free-model-cost";
|
||||
import {
|
||||
prepareTerminalForPostTuiOutput,
|
||||
writeErr,
|
||||
@@ -120,6 +126,7 @@ export async function runInteractive(
|
||||
autoApproveAllRef,
|
||||
setInteractiveAutoApprove,
|
||||
requestToolApproval,
|
||||
resolveToolPolicy,
|
||||
tuiToolApprover,
|
||||
tuiAskQuestion,
|
||||
} = createInteractiveApprovalController(config);
|
||||
@@ -151,6 +158,7 @@ export async function runInteractive(
|
||||
askQuestionRef: tuiAskQuestion,
|
||||
});
|
||||
const providerSettingsManager = new ProviderSettingsManager();
|
||||
let zeroCurrentTurnCost = false;
|
||||
|
||||
const sessionRuntime = createInteractiveSessionRuntime({
|
||||
config,
|
||||
@@ -159,11 +167,12 @@ export async function runInteractive(
|
||||
resumeSessionId,
|
||||
chatCommandState,
|
||||
requestToolApproval,
|
||||
resolveToolPolicy,
|
||||
askQuestionRef: tuiAskQuestion,
|
||||
resolveMistakeLimitDecision,
|
||||
switchToActModeTool,
|
||||
onAgentEvent: (event) => {
|
||||
uiEvents.emit("agent", event);
|
||||
uiEvents.emit("agent", zeroCliAgentEventCost(event, zeroCurrentTurnCost));
|
||||
},
|
||||
onTeamEvent: (event) => {
|
||||
uiEvents.emit("team", event);
|
||||
@@ -427,7 +436,9 @@ export async function runInteractive(
|
||||
uiEvents.off("pending-prompt-submitted", onPendingPromptSubmitted);
|
||||
};
|
||||
},
|
||||
onSubmit: async (input, mode, delivery, attachments) => {
|
||||
onSubmit: async (input, mode, delivery, attachments, onCommandOutput) => {
|
||||
let commandOutput: string | undefined;
|
||||
let zeroTurnCost = false;
|
||||
try {
|
||||
await sessionRuntime.ensureReady();
|
||||
await waitForSubmittedMode(mode);
|
||||
@@ -446,6 +457,7 @@ export async function runInteractive(
|
||||
setInteractiveAutoApprove,
|
||||
sessionRuntime,
|
||||
stop: () => tuiApp?.destroy(),
|
||||
onCommandOutput,
|
||||
});
|
||||
if (chatCommandResult.handled) {
|
||||
return chatCommandResult.turnResult;
|
||||
@@ -465,12 +477,16 @@ export async function runInteractive(
|
||||
setInteractiveAutoApprove,
|
||||
sessionRuntime,
|
||||
stop: () => tuiApp?.destroy(),
|
||||
onCommandOutput,
|
||||
});
|
||||
if (chatCommandResult.handled) {
|
||||
return chatCommandResult.turnResult;
|
||||
}
|
||||
}
|
||||
input = chatCommandResult.input;
|
||||
commandOutput = chatCommandResult.commandOutput;
|
||||
zeroTurnCost = await shouldZeroClineFreeModelCost(config);
|
||||
zeroCurrentTurnCost = zeroTurnCost;
|
||||
const {
|
||||
prompt: userInput,
|
||||
userImages,
|
||||
@@ -507,18 +523,21 @@ export async function runInteractive(
|
||||
iterations: 0,
|
||||
finishReason: "queued",
|
||||
queued: delivery === "queue" || delivery === "steer",
|
||||
commandOutput,
|
||||
};
|
||||
}
|
||||
if (result.finishReason !== "completed") {
|
||||
if (result.finishReason === "aborted" || isAbortInProgress()) {
|
||||
const usage = await sessionRuntime.getAccumulatedUsage(
|
||||
result.usage,
|
||||
const usage = zeroCliUsageCost(
|
||||
await sessionRuntime.getAccumulatedUsage(result.usage),
|
||||
zeroTurnCost,
|
||||
);
|
||||
return {
|
||||
usage,
|
||||
currentContextSize: getCurrentContextSize(result.messages),
|
||||
iterations: result.iterations,
|
||||
finishReason: "aborted",
|
||||
commandOutput,
|
||||
};
|
||||
}
|
||||
const errorText = result.text.trim();
|
||||
@@ -526,12 +545,16 @@ export async function runInteractive(
|
||||
errorText || `Turn finished with ${result.finishReason}`,
|
||||
);
|
||||
}
|
||||
const usage = await sessionRuntime.getAccumulatedUsage(result.usage);
|
||||
const usage = zeroCliUsageCost(
|
||||
await sessionRuntime.getAccumulatedUsage(result.usage),
|
||||
zeroTurnCost,
|
||||
);
|
||||
return {
|
||||
usage,
|
||||
currentContextSize: getCurrentContextSize(result.messages),
|
||||
iterations: result.iterations,
|
||||
finishReason: result.finishReason,
|
||||
commandOutput,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbortInProgress()) {
|
||||
@@ -539,6 +562,7 @@ export async function runInteractive(
|
||||
usage: { inputTokens: 0, outputTokens: 0 },
|
||||
iterations: 0,
|
||||
finishReason: "aborted",
|
||||
commandOutput,
|
||||
};
|
||||
}
|
||||
logCliError(config.logger, "Interactive turn failed", {
|
||||
@@ -548,6 +572,7 @@ export async function runInteractive(
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
zeroCurrentTurnCost = false;
|
||||
if (!delivery) {
|
||||
isRunning = false;
|
||||
clearAbortInProgress();
|
||||
@@ -603,6 +628,10 @@ export async function runInteractive(
|
||||
},
|
||||
onModelChange: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await onProviderChange({
|
||||
config,
|
||||
providerId: config.providerId,
|
||||
});
|
||||
const existing = providerSettingsManager.getProviderSettings(
|
||||
config.providerId,
|
||||
) ?? {
|
||||
@@ -623,6 +652,16 @@ export async function runInteractive(
|
||||
},
|
||||
onAccountChange: async () => {
|
||||
await sessionRuntime.ensureReady();
|
||||
await loadClineAccountSnapshot({
|
||||
config,
|
||||
clineApiBaseUrl: options?.clineApiBaseUrl,
|
||||
}).catch((error) => {
|
||||
logCliError(
|
||||
config.logger,
|
||||
"Cline account refresh after account change failed",
|
||||
{ error },
|
||||
);
|
||||
});
|
||||
await sessionRuntime.restartWithCurrentMessages();
|
||||
},
|
||||
onResumeSession: async (sessionId: string) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
applyInteractiveAutoApproveOverride,
|
||||
cloneToolPolicies,
|
||||
resolveInteractiveAutoApprovePolicy,
|
||||
} from "./tool-policies";
|
||||
|
||||
describe("tool policy helpers", () => {
|
||||
@@ -53,9 +54,9 @@ describe("tool policy helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("restores the baseline policies when toggled back on", () => {
|
||||
it("forces all baseline policies to auto-approve when toggled back on", () => {
|
||||
const baseline = {
|
||||
"*": { autoApprove: true },
|
||||
"*": { autoApprove: false },
|
||||
run_commands: { autoApprove: true, enabled: true },
|
||||
editor: { autoApprove: false, enabled: true },
|
||||
};
|
||||
@@ -72,6 +73,40 @@ describe("tool policy helpers", () => {
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(target).toEqual(baseline);
|
||||
expect(target).toEqual({
|
||||
"*": { autoApprove: true },
|
||||
run_commands: { autoApprove: true, enabled: true },
|
||||
editor: { autoApprove: true, enabled: true },
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves live per-tool policies from the interactive auto-approve state", () => {
|
||||
const baseline = {
|
||||
"*": { autoApprove: false },
|
||||
read_files: { enabled: true },
|
||||
editor: { autoApprove: false, enabled: true },
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveInteractiveAutoApprovePolicy({
|
||||
toolName: "editor",
|
||||
baselinePolicies: baseline,
|
||||
enabled: true,
|
||||
}),
|
||||
).toEqual({ autoApprove: true, enabled: true });
|
||||
expect(
|
||||
resolveInteractiveAutoApprovePolicy({
|
||||
toolName: "run_commands",
|
||||
baselinePolicies: baseline,
|
||||
enabled: false,
|
||||
}),
|
||||
).toEqual({ autoApprove: false });
|
||||
expect(
|
||||
resolveInteractiveAutoApprovePolicy({
|
||||
toolName: "read_files",
|
||||
baselinePolicies: baseline,
|
||||
enabled: false,
|
||||
}),
|
||||
).toEqual({ autoApprove: true, enabled: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,21 +27,51 @@ export function cloneToolPolicies(
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveInteractiveAutoApprovePolicy(input: {
|
||||
toolName: string;
|
||||
baselinePolicies: Record<string, ToolPolicy>;
|
||||
enabled: boolean;
|
||||
}): ToolPolicy {
|
||||
const toolPolicy = input.baselinePolicies[input.toolName] ?? {};
|
||||
const baselinePolicy = {
|
||||
...(input.baselinePolicies["*"] ?? {}),
|
||||
...toolPolicy,
|
||||
};
|
||||
return {
|
||||
...baselinePolicy,
|
||||
autoApprove: input.enabled
|
||||
? true
|
||||
: SAFE_AUTO_APPROVE_TOOLS.has(input.toolName)
|
||||
? (toolPolicy.autoApprove ?? true)
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyInteractiveAutoApproveOverride(input: {
|
||||
targetPolicies: Record<string, ToolPolicy>;
|
||||
baselinePolicies: Record<string, ToolPolicy>;
|
||||
enabled: boolean;
|
||||
}): void {
|
||||
const nextPolicies: Record<string, ToolPolicy> = input.enabled
|
||||
? cloneToolPolicies(input.baselinePolicies)
|
||||
? Object.fromEntries(
|
||||
Object.entries(input.baselinePolicies).map(([name, policy]) => [
|
||||
name,
|
||||
{
|
||||
...policy,
|
||||
autoApprove: true,
|
||||
},
|
||||
]),
|
||||
)
|
||||
: Object.fromEntries(
|
||||
Object.entries(input.baselinePolicies).map(([name, policy]) => [
|
||||
name,
|
||||
{
|
||||
...policy,
|
||||
autoApprove: SAFE_AUTO_APPROVE_TOOLS.has(name)
|
||||
? (policy.autoApprove ?? true)
|
||||
: false,
|
||||
autoApprove: resolveInteractiveAutoApprovePolicy({
|
||||
toolName: name,
|
||||
baselinePolicies: input.baselinePolicies,
|
||||
enabled: false,
|
||||
}).autoApprove,
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -53,9 +83,7 @@ export function applyInteractiveAutoApproveOverride(input: {
|
||||
}
|
||||
|
||||
const globalPolicy = clonePolicy(nextPolicies["*"]);
|
||||
globalPolicy.autoApprove = input.enabled
|
||||
? (input.baselinePolicies["*"]?.autoApprove ?? true)
|
||||
: false;
|
||||
globalPolicy.autoApprove = input.enabled;
|
||||
nextPolicies["*"] = globalPolicy;
|
||||
|
||||
for (const key of Object.keys(input.targetPolicies)) {
|
||||
|
||||
@@ -12,6 +12,8 @@ const createCore = vi.fn();
|
||||
const getCliTelemetryService = vi.fn(() => undefined);
|
||||
const resolveSessionBackend = vi.fn();
|
||||
const listSessionHistoryFromBackend = vi.fn();
|
||||
const featureFlagsPoll = vi.fn(async () => {});
|
||||
const featureFlagsDispose = vi.fn(async () => {});
|
||||
|
||||
vi.mock("@cline/core", async () => {
|
||||
const actual =
|
||||
@@ -49,6 +51,10 @@ describe("createCliCore", () => {
|
||||
listSessionHistoryFromBackend.mockReset();
|
||||
createCore.mockResolvedValue({
|
||||
runtimeAddress: "127.0.0.1:25463",
|
||||
featureFlags: {
|
||||
poll: featureFlagsPoll,
|
||||
dispose: featureFlagsDispose,
|
||||
},
|
||||
start: vi.fn(),
|
||||
send: vi.fn(),
|
||||
getAccumulatedUsage: vi.fn(),
|
||||
@@ -68,6 +74,8 @@ describe("createCliCore", () => {
|
||||
delete process.env.CLINE_RPC_ADDRESS;
|
||||
delete process.env.CLINE_SESSION_BACKEND_MODE;
|
||||
delete process.env.CLINE_VCR;
|
||||
featureFlagsPoll.mockClear();
|
||||
featureFlagsDispose.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -108,6 +116,7 @@ describe("createCliCore", () => {
|
||||
backendMode: expect.anything(),
|
||||
}),
|
||||
);
|
||||
expect(featureFlagsPoll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forces the local backend when requested by the caller", async () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
createCliMessagesArtifactUploader,
|
||||
prepareCliEnterpriseIntegration,
|
||||
} from "../utils/enterprise";
|
||||
import { getCliFeatureFlagsService } from "../utils/feature-flags";
|
||||
import { resolveWorkspaceRoot } from "../utils/helpers";
|
||||
import { getCliTelemetryService } from "../utils/telemetry";
|
||||
import type { ConversationHistory } from "./export";
|
||||
@@ -40,6 +41,11 @@ export async function createCliCore(options?: {
|
||||
const cwd = options?.cwd?.trim() || process.cwd();
|
||||
const workspaceRoot =
|
||||
options?.workspaceRoot?.trim() || resolveWorkspaceRoot(cwd);
|
||||
const telemetry = getCliTelemetryService(options?.logger);
|
||||
const featureFlags = getCliFeatureFlagsService({
|
||||
logger: options?.logger,
|
||||
telemetry,
|
||||
});
|
||||
const core = await ClineCore.create({
|
||||
...(explicitBackendMode ? { backendMode: explicitBackendMode } : {}),
|
||||
...(options?.forceLocalBackend !== true
|
||||
@@ -53,12 +59,18 @@ export async function createCliCore(options?: {
|
||||
}
|
||||
: {}),
|
||||
capabilities: options?.capabilities,
|
||||
telemetry: getCliTelemetryService(options?.logger),
|
||||
telemetry,
|
||||
featureFlags,
|
||||
logger: options?.logger,
|
||||
toolPolicies: options?.toolPolicies,
|
||||
messagesArtifactUploader: createCliMessagesArtifactUploader(),
|
||||
prepare: prepareCliEnterpriseIntegration,
|
||||
});
|
||||
try {
|
||||
await core.featureFlags.poll();
|
||||
} catch (error) {
|
||||
options?.logger?.error?.("Error polling CLI feature flags", { error });
|
||||
}
|
||||
options?.logger?.log("CLI core runtime routing selected", {
|
||||
backendMode: explicitBackendMode ?? "env-managed",
|
||||
rpcAddress: core.runtimeAddress,
|
||||
|
||||
@@ -9,9 +9,15 @@ const coreMocks = vi.hoisted(() => {
|
||||
return {
|
||||
getProviderSettings: vi.fn(),
|
||||
saveProviderSettings: vi.fn(),
|
||||
fetchMe: vi.fn(),
|
||||
fetchBalance: vi.fn(),
|
||||
fetchOrganizationBalance: vi.fn(),
|
||||
serviceOptions,
|
||||
};
|
||||
});
|
||||
const telemetryMocks = vi.hoisted(() => ({
|
||||
identifyTelemetryAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
@@ -24,6 +30,15 @@ vi.mock("@cline/core", async (importOriginal) => {
|
||||
}) {
|
||||
coreMocks.serviceOptions.push(options);
|
||||
}
|
||||
fetchMe() {
|
||||
return coreMocks.fetchMe();
|
||||
}
|
||||
fetchBalance(userId?: string) {
|
||||
return coreMocks.fetchBalance(userId);
|
||||
}
|
||||
fetchOrganizationBalance(organizationId: string) {
|
||||
return coreMocks.fetchOrganizationBalance(organizationId);
|
||||
}
|
||||
},
|
||||
ProviderSettingsManager: class {
|
||||
getProviderSettings(providerId: string) {
|
||||
@@ -36,6 +51,10 @@ vi.mock("@cline/core", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../utils/telemetry", () => ({
|
||||
identifyTelemetryAccount: telemetryMocks.identifyTelemetryAccount,
|
||||
}));
|
||||
|
||||
function makeConfig(overrides: Partial<Config> = {}): Config {
|
||||
return {
|
||||
providerId: "cline",
|
||||
@@ -78,7 +97,11 @@ describe("createClineAccountService", () => {
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -163,3 +186,66 @@ describe("createClineAccountService", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadClineAccountSnapshot", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
coreMocks.getProviderSettings.mockReset();
|
||||
coreMocks.saveProviderSettings.mockReset();
|
||||
coreMocks.fetchMe.mockReset();
|
||||
coreMocks.fetchBalance.mockReset();
|
||||
coreMocks.fetchOrganizationBalance.mockReset();
|
||||
coreMocks.serviceOptions.length = 0;
|
||||
telemetryMocks.identifyTelemetryAccount.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("identifies the loaded Cline account for telemetry and feature flags", async () => {
|
||||
coreMocks.getProviderSettings.mockReturnValue({
|
||||
provider: "cline",
|
||||
apiKey: "account-token",
|
||||
});
|
||||
const { loadClineAccountSnapshot } = await import("./cline-account");
|
||||
coreMocks.fetchMe.mockResolvedValue({
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
displayName: "User One",
|
||||
photoUrl: "",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
organizations: [
|
||||
{
|
||||
active: true,
|
||||
memberId: "member-1",
|
||||
name: "Acme",
|
||||
organizationId: "org-1",
|
||||
roles: ["member"],
|
||||
},
|
||||
],
|
||||
});
|
||||
coreMocks.fetchBalance.mockResolvedValue({ balance: 10, userId: "user-1" });
|
||||
coreMocks.fetchOrganizationBalance.mockResolvedValue({
|
||||
balance: 20,
|
||||
organizationId: "org-1",
|
||||
});
|
||||
|
||||
await loadClineAccountSnapshot({ config: makeConfig() });
|
||||
|
||||
expect(telemetryMocks.identifyTelemetryAccount).toHaveBeenCalledWith(
|
||||
{
|
||||
id: "user-1",
|
||||
email: "user@example.com",
|
||||
provider: "cline",
|
||||
organizationId: "org-1",
|
||||
organizationName: "Acme",
|
||||
memberId: "member-1",
|
||||
},
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,12 +14,15 @@ import {
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import { formatCreditBalance, normalizeCreditBalance } from "../utils/output";
|
||||
import { identifyTelemetryAccount } from "../utils/telemetry";
|
||||
import type { Config } from "../utils/types";
|
||||
|
||||
export const CLINE_CREDITS_DASHBOARD_URL =
|
||||
"https://app.cline.bot/dashboard/account?tab=credits";
|
||||
|
||||
type ClineAccountConfig = Pick<Config, "apiKey" | "providerId">;
|
||||
type ClineAccountConfig = Pick<Config, "apiKey" | "logger" | "providerId">;
|
||||
|
||||
const CLINE_PASS_PROVIDER_ID = "cline-pass";
|
||||
|
||||
export interface ClineAccountSnapshot {
|
||||
user: ClineAccountUser;
|
||||
@@ -167,6 +170,15 @@ export async function loadClineAccountSnapshot(input: {
|
||||
const displayedBalance = activeOrganization
|
||||
? (organizationBalance?.balance ?? balance.balance)
|
||||
: balance.balance;
|
||||
const accountContext = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
provider: "cline",
|
||||
organizationId: activeOrganization?.organizationId,
|
||||
organizationName: activeOrganization?.name,
|
||||
memberId: activeOrganization?.memberId,
|
||||
};
|
||||
identifyTelemetryAccount(accountContext, input.config.logger);
|
||||
|
||||
return {
|
||||
user,
|
||||
@@ -190,3 +202,27 @@ export async function switchClineAccount(input: {
|
||||
}
|
||||
await service.switchAccount(input.organizationId);
|
||||
}
|
||||
|
||||
async function onChangeToClinePass(config: ClineAccountConfig) {
|
||||
try {
|
||||
await switchClineAccount({
|
||||
config: config,
|
||||
organizationId: null,
|
||||
});
|
||||
} catch (error) {
|
||||
config.logger?.debug("Failed to switch ClinePass to personal account", {
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function onProviderChange(input: {
|
||||
config: ClineAccountConfig;
|
||||
providerId: string;
|
||||
}): Promise<void> {
|
||||
if (input.providerId === CLINE_PASS_PROVIDER_ID) {
|
||||
return onChangeToClinePass(input.config);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import { useTerminalDimensions } from "@opentui/react";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
getClinePassSubscriptionUrl,
|
||||
isClinePassSubscriptionError,
|
||||
} from "../../utils/cline-pass-errors";
|
||||
import {
|
||||
CLINE_CREDITS_DASHBOARD_URL,
|
||||
isClineAccountCreditsErrorMessage,
|
||||
@@ -290,6 +294,41 @@ function ClineCreditsErrorView(props: { defaultFg?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ClinePassSubscriptionErrorView(props: { defaultFg?: string }) {
|
||||
const subscriptionUrl = getClinePassSubscriptionUrl();
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="yellow" content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor="yellow"
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="yellow">ClinePass subscription required</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content="No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan."
|
||||
/>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">Subscribe: </text>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={subscriptionUrl}>Open subscription page</a>
|
||||
</text>
|
||||
</box>
|
||||
<box flexDirection="row">
|
||||
<text fg="gray">URL: </text>
|
||||
<text fg="cyan" selectable>
|
||||
<a href={subscriptionUrl}>{subscriptionUrl}</a>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatEntryView(props: {
|
||||
entry: ChatEntry;
|
||||
accent?: string;
|
||||
@@ -388,6 +427,9 @@ export function ChatEntryView(props: {
|
||||
if (isClineAccountCreditsErrorMessage(entry.text)) {
|
||||
return <ClineCreditsErrorView defaultFg={defaultFg} />;
|
||||
}
|
||||
if (isClinePassSubscriptionError(entry.text)) {
|
||||
return <ClinePassSubscriptionErrorView defaultFg={defaultFg} />;
|
||||
}
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="red" content="* " />
|
||||
|
||||
@@ -58,7 +58,11 @@ describe("mcp manager dialog helpers", () => {
|
||||
};
|
||||
|
||||
afterEach(async () => {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
|
||||
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
|
||||
}
|
||||
await Promise.all(
|
||||
tempRoots.map((directory) =>
|
||||
rm(directory, { recursive: true, force: true }),
|
||||
@@ -107,6 +111,44 @@ describe("mcp manager dialog helpers", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not toggle plugin-owned servers", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-mcp-manager-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const settingsPath = join(tempRoot, "cline_mcp_settings.json");
|
||||
await writeFile(
|
||||
settingsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
docs: {
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const result = toggleMcpServer({
|
||||
name: "docs",
|
||||
path: settingsPath,
|
||||
enabled: true,
|
||||
pluginName: "repo-docs",
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.message).toContain('managed by plugin "repo-docs"');
|
||||
}
|
||||
expect((await readSettings(settingsPath)).mcpServers?.docs?.disabled).toBe(
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a visible error message when toggling fails", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-mcp-manager-"));
|
||||
tempRoots.push(tempRoot);
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface McpEntry {
|
||||
enabled?: boolean;
|
||||
description?: string;
|
||||
lastError?: string;
|
||||
pluginName?: string;
|
||||
}
|
||||
|
||||
export type McpServerToggleResult =
|
||||
@@ -36,6 +37,12 @@ export function getMcpManagerEntryStatus(
|
||||
}
|
||||
|
||||
export function toggleMcpServer(server: McpEntry): McpServerToggleResult {
|
||||
if (server.pluginName) {
|
||||
return {
|
||||
ok: false,
|
||||
message: `MCP server "${server.name}" is managed by plugin "${server.pluginName}". Disable the plugin to disable this server.`,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const currentlyEnabled = server.enabled !== false;
|
||||
setMcpServerDisabled({
|
||||
@@ -71,6 +78,7 @@ export function McpManagerContent(
|
||||
const settingsPath = servers[0]?.path ?? resolveDefaultMcpSettingsPath();
|
||||
const itemCount = servers.length;
|
||||
const selectedServer = servers[selected];
|
||||
const hasPluginOwnedServers = servers.some((server) => server.pluginName);
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
if (key.name === "escape") {
|
||||
@@ -150,6 +158,7 @@ export function McpManagerContent(
|
||||
{isSel ? "\u25b8 " : " "}
|
||||
{enabledIcon}
|
||||
{srv.name}
|
||||
{srv.pluginName ? " *" : ""}
|
||||
</text>
|
||||
{status && (
|
||||
<text fg={srv.lastError ? palette.error : "gray"}>
|
||||
@@ -184,6 +193,12 @@ export function McpManagerContent(
|
||||
</box>
|
||||
)}
|
||||
|
||||
{hasPluginOwnedServers && (
|
||||
<text fg="gray" marginTop={1}>
|
||||
* managed by plugin; disable the plugin to disable the server.
|
||||
</text>
|
||||
)}
|
||||
|
||||
<text fg="gray" marginTop={1}>
|
||||
<em>{getMcpManagerFooterText(servers.length > 0)}</em>
|
||||
</text>
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
completeClineDeviceAuth,
|
||||
getProviderConfigFields,
|
||||
isOAuthProvider,
|
||||
listLocalProviders,
|
||||
loginLocalProvider,
|
||||
type ProviderConfigFieldKey,
|
||||
type ProviderConfigFieldRequirement,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { palette } from "../../palette";
|
||||
import {
|
||||
getDefaultAwsRegion,
|
||||
|
||||
@@ -134,6 +134,7 @@ export function ModelSelectorContent(
|
||||
currentModel: string;
|
||||
currentProviderName: string;
|
||||
models: ModelOption[];
|
||||
showCustomModelId?: boolean;
|
||||
},
|
||||
) {
|
||||
const {
|
||||
@@ -143,6 +144,7 @@ export function ModelSelectorContent(
|
||||
currentModel,
|
||||
currentProviderName,
|
||||
models,
|
||||
showCustomModelId = true,
|
||||
} = props;
|
||||
const [search, setSearch] = useState("");
|
||||
const [selected, setSelected] = useState(() => {
|
||||
@@ -164,7 +166,7 @@ export function ModelSelectorContent(
|
||||
return scored.map((r) => r.model);
|
||||
}, [models, search]);
|
||||
|
||||
const optionCount = filtered.length + 1;
|
||||
const optionCount = filtered.length + (showCustomModelId ? 1 : 0);
|
||||
const safeSelected = Math.min(selected, Math.max(0, optionCount - 1));
|
||||
|
||||
useDialogKeyboard((key) => {
|
||||
@@ -188,7 +190,7 @@ export function ModelSelectorContent(
|
||||
resolve(model.key);
|
||||
return;
|
||||
}
|
||||
if (safeSelected === filtered.length) {
|
||||
if (showCustomModelId && safeSelected === filtered.length) {
|
||||
setIsCreatingCustomModel(true);
|
||||
setCustomModelId("");
|
||||
setCustomModelError("");
|
||||
@@ -290,6 +292,7 @@ export function ModelSelectorContent(
|
||||
dimmed={onProvider}
|
||||
currentModel={currentModel}
|
||||
onSelect={resolve}
|
||||
showCustomModelId={showCustomModelId}
|
||||
onCreateCustomModel={() => {
|
||||
setIsCreatingCustomModel(true);
|
||||
setCustomModelId("");
|
||||
@@ -408,6 +411,7 @@ function ModelList(props: {
|
||||
dimmed?: boolean;
|
||||
currentModel: string;
|
||||
onSelect: (key: string) => void;
|
||||
showCustomModelId: boolean;
|
||||
onCreateCustomModel: () => void;
|
||||
}) {
|
||||
const {
|
||||
@@ -416,11 +420,12 @@ function ModelList(props: {
|
||||
dimmed,
|
||||
currentModel,
|
||||
onSelect,
|
||||
showCustomModelId,
|
||||
onCreateCustomModel,
|
||||
} = props;
|
||||
const rows: ({ type: "model"; model: ModelOption } | { type: "custom" })[] = [
|
||||
...items.map((model) => ({ type: "model" as const, model })),
|
||||
{ type: "custom" as const },
|
||||
...(showCustomModelId ? ([{ type: "custom" as const }] as const) : []),
|
||||
];
|
||||
|
||||
if (rows.length <= MAX_VISIBLE) {
|
||||
|
||||
@@ -106,9 +106,9 @@ export function SessionProvider(props: {
|
||||
const [uiMode, setUiMode] = useState<AgentMode>(
|
||||
config.mode === "plan" ? "plan" : "act",
|
||||
);
|
||||
const [autoApproveAll, _setAutoApproveAll] = useState(
|
||||
config.toolPolicies["*"]?.autoApprove !== false,
|
||||
);
|
||||
const initialAutoApproveAll = config.toolPolicies["*"]?.autoApprove !== false;
|
||||
const autoApproveAllRef = useRef(initialAutoApproveAll);
|
||||
const [autoApproveAll, _setAutoApproveAll] = useState(initialAutoApproveAll);
|
||||
const [compactionMode, _setCompactionMode] = useState<CliCompactionMode>(() =>
|
||||
getCliCompactionMode(config),
|
||||
);
|
||||
@@ -192,11 +192,10 @@ export function SessionProvider(props: {
|
||||
}, []);
|
||||
|
||||
const toggleAutoApprove = useCallback(() => {
|
||||
_setAutoApproveAll((prev) => {
|
||||
const next = !prev;
|
||||
onAutoApproveChange(next);
|
||||
return next;
|
||||
});
|
||||
const next = !autoApproveAllRef.current;
|
||||
autoApproveAllRef.current = next;
|
||||
onAutoApproveChange(next);
|
||||
_setAutoApproveAll(next);
|
||||
}, [onAutoApproveChange]);
|
||||
|
||||
const setCompactionMode = useCallback(
|
||||
|
||||
@@ -17,7 +17,9 @@ export async function renderHistoryStandalone(input: {
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let result: number | string = 0;
|
||||
let resolved = false;
|
||||
let destroyStarted = false;
|
||||
let unmounted = false;
|
||||
const root = createRoot(renderer);
|
||||
|
||||
@@ -29,24 +31,29 @@ export async function renderHistoryStandalone(input: {
|
||||
root.unmount();
|
||||
};
|
||||
|
||||
const settle = (value: number | string) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
unmountRoot();
|
||||
renderer.destroy();
|
||||
resolve(value);
|
||||
};
|
||||
|
||||
// Resolve only once teardown has finished, so callers never run while
|
||||
// the renderer is still restoring the terminal.
|
||||
renderer.on("destroy", () => {
|
||||
unmountRoot();
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
resolve(0);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
resolve(result);
|
||||
}
|
||||
});
|
||||
|
||||
const settle = (value: number | string) => {
|
||||
if (destroyStarted) {
|
||||
return;
|
||||
}
|
||||
destroyStarted = true;
|
||||
result = value;
|
||||
unmountRoot();
|
||||
// Let OpenTUI finish parsing the current stdin batch before teardown.
|
||||
queueMicrotask(() => {
|
||||
renderer.destroy();
|
||||
});
|
||||
};
|
||||
|
||||
root.render(
|
||||
React.createElement(HistoryStandaloneContent, {
|
||||
rows: input.rows,
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
PendingPromptSnapshot,
|
||||
PendingPromptSubmittedEvent,
|
||||
} from "../../runtime/session-events";
|
||||
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
|
||||
import { resolveStatusNoticeLabel } from "../../utils/events";
|
||||
import {
|
||||
formatToolInput,
|
||||
@@ -171,7 +172,10 @@ export function useAgentEventHandlers(deps: AgentEventDeps) {
|
||||
turnErrorReportedRef.current = true;
|
||||
onTurnErrorReported(true);
|
||||
if (!event.recoverable || verbose) {
|
||||
appendEntry({ kind: "error", text: event.error.message });
|
||||
appendEntry({
|
||||
kind: "error",
|
||||
text: formatCliErrorMessage(event.error),
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "notice":
|
||||
|
||||
@@ -17,6 +17,7 @@ function toMcpEntries(items: InteractiveConfigItem[]): McpEntry[] {
|
||||
enabled: item.enabled,
|
||||
description: item.description,
|
||||
lastError: item.loadError,
|
||||
pluginName: item.pluginName,
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -179,7 +179,6 @@ async function runProviderChange(
|
||||
|
||||
config.providerId = newProviderId;
|
||||
config.apiKey = newApiKey;
|
||||
|
||||
const resolved = await resolveProviderConfig(
|
||||
newProviderId,
|
||||
{
|
||||
@@ -324,6 +323,7 @@ export function useModelSelector(opts: {
|
||||
currentModel={config.modelId}
|
||||
currentProviderName={providerDisplayName}
|
||||
models={modelOptions}
|
||||
showCustomModelId={config.providerId !== "cline-pass"}
|
||||
/>
|
||||
),
|
||||
});
|
||||
@@ -414,6 +414,7 @@ export function useModelSelector(opts: {
|
||||
currentModel={config.modelId}
|
||||
currentProviderName={providerDisplayName}
|
||||
models={modelOptions}
|
||||
showCustomModelId={config.providerId !== "cline-pass"}
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { formatCliErrorMessage } from "../../utils/cline-pass-errors";
|
||||
import { shouldShowCliUsageCost } from "../../utils/usage-cost-display";
|
||||
import type { SlashCommandRegistry } from "../commands/slash-command-registry";
|
||||
import {
|
||||
@@ -327,6 +328,14 @@ export function usePromptInputController(input: {
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
let commandOutputAppended = false;
|
||||
const appendCommandOutput = (text: string) => {
|
||||
commandOutputAppended = true;
|
||||
session.appendEntry({
|
||||
kind: "status",
|
||||
text,
|
||||
});
|
||||
};
|
||||
try {
|
||||
const result = await onSubmit(
|
||||
promptForSubmit,
|
||||
@@ -335,8 +344,9 @@ export function usePromptInputController(input: {
|
||||
activeUserImages.length > 0
|
||||
? { userImages: activeUserImages }
|
||||
: undefined,
|
||||
appendCommandOutput,
|
||||
);
|
||||
if (result.commandOutput) {
|
||||
if (result.commandOutput && !commandOutputAppended) {
|
||||
session.appendEntry({
|
||||
kind: "status",
|
||||
text: result.commandOutput,
|
||||
@@ -367,7 +377,7 @@ export function usePromptInputController(input: {
|
||||
if (!turnErrorReportedRef.current) {
|
||||
session.appendEntry({
|
||||
kind: "error",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
text: formatCliErrorMessage(error),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
|
||||
@@ -86,6 +86,7 @@ export interface InteractiveConfigData {
|
||||
mcp: InteractiveConfigItem[];
|
||||
tools: InteractiveConfigItem[];
|
||||
workflowSlashCommands: InteractiveSlashCommand[];
|
||||
pluginDiagnosticsLoaded?: boolean;
|
||||
}
|
||||
|
||||
export interface LoadInteractiveConfigDataOptions {
|
||||
@@ -93,12 +94,14 @@ export interface LoadInteractiveConfigDataOptions {
|
||||
}
|
||||
|
||||
export function isToggleableInteractiveConfigItem(
|
||||
item: Pick<InteractiveConfigItem, "kind" | "source">,
|
||||
item: Pick<InteractiveConfigItem, "kind" | "source" | "pluginName">,
|
||||
): boolean {
|
||||
if (item.kind === "mcp") {
|
||||
return !item.pluginName;
|
||||
}
|
||||
return (
|
||||
item.kind === "skill" ||
|
||||
item.kind === "plugin" ||
|
||||
item.kind === "mcp" ||
|
||||
item.source === "builtin" ||
|
||||
item.source === "workspace-plugin" ||
|
||||
item.source === "global-plugin"
|
||||
@@ -242,9 +245,10 @@ function readPackageName(packageJsonPath: string): string | undefined {
|
||||
}
|
||||
}
|
||||
|
||||
function getPluginDisplayName(filePath: string): string {
|
||||
function getPluginDisplayName(filePath: string, searchRoot: string): string {
|
||||
let current = dirname(filePath);
|
||||
for (let depth = 0; depth < 4; depth++) {
|
||||
const root = resolve(searchRoot);
|
||||
while (isPathWithin(root, current)) {
|
||||
const packageJsonPath = join(current, "package.json");
|
||||
if (existsSync(packageJsonPath)) {
|
||||
const packageName = readPackageName(packageJsonPath);
|
||||
@@ -384,7 +388,7 @@ export async function loadInteractiveConfigData(input: {
|
||||
for (const filePath of discoverPluginModulePaths(directory)) {
|
||||
plugins.push({
|
||||
id: filePath,
|
||||
name: getPluginDisplayName(filePath),
|
||||
name: getPluginDisplayName(filePath, directory),
|
||||
path: filePath,
|
||||
enabled: !disabledPlugins.has(filePath),
|
||||
kind: "plugin",
|
||||
@@ -458,6 +462,16 @@ export async function loadInteractiveConfigData(input: {
|
||||
for (const registration of resolveMcpServerRegistrations({
|
||||
filePath: mcpSettingsPath,
|
||||
})) {
|
||||
const pluginName =
|
||||
registration.metadata?.source === "plugin" &&
|
||||
typeof registration.metadata.pluginName === "string"
|
||||
? registration.metadata.pluginName
|
||||
: undefined;
|
||||
const pluginPath =
|
||||
registration.metadata?.source === "plugin" &&
|
||||
typeof registration.metadata.pluginPath === "string"
|
||||
? registration.metadata.pluginPath
|
||||
: undefined;
|
||||
mcp.push({
|
||||
id: registration.name,
|
||||
name: registration.name,
|
||||
@@ -467,6 +481,8 @@ export async function loadInteractiveConfigData(input: {
|
||||
source: detectSource(mcpSettingsPath, input.workspaceRoot),
|
||||
description: getMcpDescription(registration),
|
||||
loadError: registration.oauth?.lastError,
|
||||
pluginName,
|
||||
pluginPath,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -514,6 +530,7 @@ export async function loadInteractiveConfigData(input: {
|
||||
toolNames: [pluginTool.name],
|
||||
configKind: "tool",
|
||||
pluginName: pluginTool.pluginName,
|
||||
pluginPath: pluginTool.path,
|
||||
source: pluginTool.source,
|
||||
description: pluginTool.description,
|
||||
});
|
||||
@@ -533,5 +550,6 @@ export async function loadInteractiveConfigData(input: {
|
||||
mcp: toSorted(mcp.filter((item) => existsSync(item.path))),
|
||||
tools: toSorted(tools),
|
||||
workflowSlashCommands,
|
||||
pluginDiagnosticsLoaded: input.includePluginTools !== false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -921,6 +921,7 @@ function App(props: TuiProps) {
|
||||
if (result.reasoningEffort !== undefined) {
|
||||
props.config.reasoningEffort = result.reasoningEffort;
|
||||
}
|
||||
|
||||
handleModelChange().then(() => setAppView("home"));
|
||||
}}
|
||||
onExit={() => {
|
||||
|
||||
@@ -152,6 +152,7 @@ export interface TuiProps {
|
||||
mode: AgentMode,
|
||||
delivery?: "queue" | "steer",
|
||||
attachments?: UserInputAttachments,
|
||||
onCommandOutput?: (text: string) => void,
|
||||
) => Promise<InteractiveTurnResult>;
|
||||
onUpdatePendingPrompt: (input: {
|
||||
promptId: string;
|
||||
|
||||
@@ -107,12 +107,13 @@ describe("copyTextToSystemClipboard", () => {
|
||||
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(1, "wl-copy", [], {
|
||||
stdio: ["pipe", "ignore", "ignore"],
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(spawnMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"xclip",
|
||||
["-selection", "clipboard"],
|
||||
{ stdio: ["pipe", "ignore", "ignore"] },
|
||||
{ stdio: ["pipe", "ignore", "ignore"], windowsHide: true },
|
||||
);
|
||||
expect(failed.getInput()).toBe("selected text");
|
||||
expect(succeeded.getInput()).toBe("selected text");
|
||||
@@ -134,6 +135,7 @@ describe("copyTextToSystemClipboard", () => {
|
||||
expect(spawnMock).toHaveBeenCalledTimes(1);
|
||||
expect(spawnMock).toHaveBeenCalledWith("wl-copy", [], {
|
||||
stdio: ["pipe", "ignore", "ignore"],
|
||||
windowsHide: true,
|
||||
});
|
||||
expect(wlcopy.getInput()).toBe("plain linux");
|
||||
});
|
||||
|
||||
@@ -142,6 +142,8 @@ function runClipboardCommand(
|
||||
const child = spawn(command.command, command.args, {
|
||||
stdio: ["pipe", "ignore", "ignore"],
|
||||
...(command.env ? { env: command.env } : {}),
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
let settled = false;
|
||||
|
||||
|
||||
@@ -115,6 +115,8 @@ async function runCommand(
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
});
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
|
||||
@@ -229,3 +229,15 @@ export function getConfigFooterText({
|
||||
export function getConfigItemDisplayName(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
export function getPluginDiagnosticsLoadingText(
|
||||
tab: InteractiveConfigTab,
|
||||
): string | undefined {
|
||||
if (tab === "tools") {
|
||||
return "Loading plugin tools...";
|
||||
}
|
||||
if (tab === "plugins") {
|
||||
return "Loading plugin diagnostics...";
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -59,6 +59,18 @@ describe("config view helpers", () => {
|
||||
expect(isToggleableConfigItem(createItem({ kind: "mcp" }))).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat plugin MCP rows as toggleable", () => {
|
||||
expect(
|
||||
isToggleableConfigItem(
|
||||
createItem({
|
||||
kind: "mcp",
|
||||
pluginName: "plugin",
|
||||
source: "workspace-plugin",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves Enter/Tab on a skill row to details", () => {
|
||||
const skill = createItem({
|
||||
kind: "skill",
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
getConfigFooterText,
|
||||
getConfigItemDisplayName,
|
||||
getConfigTabs,
|
||||
getPluginDiagnosticsLoadingText,
|
||||
isInlineConfigAction,
|
||||
isToggleableConfigItem,
|
||||
resolveActiveConfigItems,
|
||||
@@ -198,6 +199,7 @@ function appendToolGroupRows(
|
||||
rightLabel: `${enabledCount}/${groupItems.length} tools enabled`,
|
||||
indent: 2,
|
||||
});
|
||||
|
||||
for (const item of sortBySourceThenName(groupItems)) {
|
||||
rows.push({
|
||||
kind: "ext",
|
||||
@@ -245,17 +247,24 @@ function appendToolRows(
|
||||
appendExtRows(rows, builtinTools);
|
||||
}
|
||||
|
||||
const pluginGroups = groupToolItems(items.filter((item) => item.pluginName));
|
||||
const pluginToolItems = items.filter((item) => item.pluginName);
|
||||
const pluginGroups = groupToolItems(pluginToolItems);
|
||||
if (pluginGroups.length > 0) {
|
||||
rows.push({ kind: "head", label: "Plugins" });
|
||||
appendToolGroupRows(
|
||||
rows,
|
||||
pluginGroups,
|
||||
getSharedToolNames(items.filter((item) => item.pluginName)),
|
||||
getSharedToolNames(pluginToolItems),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function hasPluginDiagnostics(data: InteractiveConfigData): boolean {
|
||||
return (
|
||||
data.pluginDiagnosticsLoaded || data.tools.some((item) => item.pluginName)
|
||||
);
|
||||
}
|
||||
|
||||
function appendSkillRows(
|
||||
rows: ConfigRow[],
|
||||
items: InteractiveConfigItem[],
|
||||
@@ -305,11 +314,18 @@ function withOptimisticToggle(
|
||||
).filter(Boolean),
|
||||
);
|
||||
const updateItems = (items: InteractiveConfigItem[]) =>
|
||||
items.map((candidate) =>
|
||||
matchesItem(candidate)
|
||||
? { ...candidate, enabled: nextEnabled }
|
||||
: candidate,
|
||||
);
|
||||
items.map((candidate) => {
|
||||
if (matchesItem(candidate)) {
|
||||
return { ...candidate, enabled: nextEnabled };
|
||||
}
|
||||
if (
|
||||
item.kind === "plugin" &&
|
||||
(candidate.path === item.path || candidate.pluginPath === item.path)
|
||||
) {
|
||||
return { ...candidate, enabled: nextEnabled };
|
||||
}
|
||||
return candidate;
|
||||
});
|
||||
const updateTools = (items: InteractiveConfigItem[]) =>
|
||||
items.map((candidate) => {
|
||||
if (matchesItem(candidate)) {
|
||||
@@ -381,7 +397,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
);
|
||||
const [configData, setConfigData] = useState(props.configData);
|
||||
const [pluginToolsLoaded, setPluginToolsLoaded] = useState(
|
||||
props.configData.tools.some((item) => item.pluginName),
|
||||
hasPluginDiagnostics(props.configData),
|
||||
);
|
||||
const [pluginToolsLoading, setPluginToolsLoading] = useState(false);
|
||||
const [pluginToolsError, setPluginToolsError] = useState<
|
||||
@@ -465,10 +481,11 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
});
|
||||
} else if (activeTab === "tools") {
|
||||
appendToolRows(r, activeItems);
|
||||
if (pluginToolsLoading) {
|
||||
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
|
||||
if (pluginToolsLoading && loadingText) {
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: "Loading plugin tools...",
|
||||
text: loadingText,
|
||||
});
|
||||
}
|
||||
if (pluginToolsError) {
|
||||
@@ -499,9 +516,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
});
|
||||
}
|
||||
if (activeTab === "plugins" && pluginToolsLoading) {
|
||||
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: "Loading plugin diagnostics...",
|
||||
text: loadingText ?? "Loading plugin diagnostics...",
|
||||
});
|
||||
}
|
||||
if (activeTab === "plugins" && pluginToolsError) {
|
||||
@@ -549,15 +567,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
});
|
||||
if (nextData) {
|
||||
setConfigData(nextData);
|
||||
setPluginToolsLoaded(nextData.tools.some((tool) => tool.pluginName));
|
||||
setPluginToolsLoaded(hasPluginDiagnostics(nextData));
|
||||
} else if (item.kind === "plugin" && loadConfigData) {
|
||||
const refreshedData = await loadConfigData({
|
||||
includePluginTools: true,
|
||||
});
|
||||
setConfigData(refreshedData);
|
||||
setPluginToolsLoaded(
|
||||
refreshedData.tools.some((tool) => tool.pluginName),
|
||||
);
|
||||
setPluginToolsLoaded(hasPluginDiagnostics(refreshedData));
|
||||
setPluginToolsError(undefined);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ const hoisted = vi.hoisted(() => ({
|
||||
startClineDeviceAuth: vi.fn(),
|
||||
completeClineDeviceAuth: vi.fn(),
|
||||
saveLocalProviderOAuthCredentials: vi.fn(),
|
||||
identifyFeatureFlagsAccount: vi.fn(async () => {}),
|
||||
openMock: vi.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
@@ -22,6 +23,10 @@ vi.mock("@cline/shared", () => ({
|
||||
|
||||
vi.mock("open", () => ({ default: hoisted.openMock }));
|
||||
|
||||
vi.mock("../../../utils/feature-flags", () => ({
|
||||
identifyFeatureFlagsAccount: hoisted.identifyFeatureFlagsAccount,
|
||||
}));
|
||||
|
||||
import { runDeviceCodeAuthFlow, runOAuthAuthFlow } from "./auth";
|
||||
|
||||
// Minimal stand-in for a telemetry service. The auth helpers must forward this
|
||||
@@ -45,6 +50,8 @@ describe("onboarding auth telemetry forwarding", () => {
|
||||
hoisted.startClineDeviceAuth.mockReset();
|
||||
hoisted.completeClineDeviceAuth.mockReset();
|
||||
hoisted.saveLocalProviderOAuthCredentials.mockReset();
|
||||
hoisted.identifyFeatureFlagsAccount.mockReset();
|
||||
hoisted.identifyFeatureFlagsAccount.mockResolvedValue(undefined);
|
||||
hoisted.openMock.mockReset();
|
||||
hoisted.openMock.mockResolvedValue(undefined);
|
||||
});
|
||||
@@ -85,6 +92,7 @@ describe("onboarding auth telemetry forwarding", () => {
|
||||
// Identity, not deep-equal — we are validating the exact reference flows
|
||||
// through so opt-out / common metadata stays consistent.
|
||||
expect(telemetryArg).toBe(fakeTelemetry);
|
||||
expect(hoisted.identifyFeatureFlagsAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not pass telemetry when none is provided (back-compat)", () => {
|
||||
@@ -121,6 +129,8 @@ describe("onboarding auth telemetry forwarding", () => {
|
||||
access: "a",
|
||||
refresh: "r",
|
||||
expires: 0,
|
||||
accountId: "acct-1",
|
||||
email: "user@example.com",
|
||||
});
|
||||
|
||||
runDeviceCodeAuthFlow({
|
||||
@@ -146,5 +156,54 @@ describe("onboarding auth telemetry forwarding", () => {
|
||||
// emitted by completeClineDeviceAuth, so passing telemetry to the start
|
||||
// helper would double-emit the event.
|
||||
expect(hoisted.startClineDeviceAuth).toHaveBeenCalledWith();
|
||||
expect(hoisted.identifyFeatureFlagsAccount).toHaveBeenCalledWith({
|
||||
id: "acct-1",
|
||||
email: "user@example.com",
|
||||
});
|
||||
expect(hoisted.openMock).toHaveBeenCalledWith(
|
||||
"https://verify?user_code=uc",
|
||||
{ wait: false },
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to displaying the device auth URL when browser open fails", async () => {
|
||||
hoisted.openMock.mockRejectedValueOnce(new Error("no browser"));
|
||||
hoisted.startClineDeviceAuth.mockResolvedValueOnce({
|
||||
deviceCode: "dc",
|
||||
userCode: "uc",
|
||||
verificationUri: "https://verify",
|
||||
verificationUriComplete: "https://verify?user_code=uc",
|
||||
expiresInSeconds: 600,
|
||||
pollIntervalSeconds: 5,
|
||||
});
|
||||
hoisted.completeClineDeviceAuth.mockResolvedValueOnce({
|
||||
access: "a",
|
||||
refresh: "r",
|
||||
expires: 0,
|
||||
});
|
||||
const setStatus = vi.fn();
|
||||
|
||||
runDeviceCodeAuthFlow({
|
||||
providerId: "cline",
|
||||
providerSettingsManager: makeManager(),
|
||||
isAborted: () => false,
|
||||
setUserCode: vi.fn(),
|
||||
setVerifyUrl: vi.fn(),
|
||||
setStatus,
|
||||
setError: vi.fn(),
|
||||
onComplete: vi.fn(),
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(hoisted.openMock).toHaveBeenCalledWith(
|
||||
"https://verify?user_code=uc",
|
||||
{ wait: false },
|
||||
);
|
||||
expect(setStatus).toHaveBeenCalledWith(
|
||||
"Could not open browser. Visit the URL below.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import open from "open";
|
||||
import { identifyFeatureFlagsAccount } from "../../../utils/feature-flags";
|
||||
|
||||
export type OnboardingOAuthProviderId = string;
|
||||
|
||||
@@ -18,6 +19,10 @@ export function isOnboardingOAuthProviderId(
|
||||
return isOAuthProvider(providerId);
|
||||
}
|
||||
|
||||
function isClineAccountOAuthProvider(providerId: string): boolean {
|
||||
return providerId === "cline" || providerId === "cline-pass";
|
||||
}
|
||||
|
||||
export function runOAuthAuthFlow(input: {
|
||||
providerId: OnboardingOAuthProviderId;
|
||||
providerSettingsManager: ProviderSettingsManager;
|
||||
@@ -56,6 +61,12 @@ export function runOAuthAuthFlow(input: {
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
if (isClineAccountOAuthProvider(input.providerId)) {
|
||||
void identifyFeatureFlagsAccount({
|
||||
id: credentials.accountId,
|
||||
email: credentials.email,
|
||||
}).catch(() => {});
|
||||
}
|
||||
input.onComplete(input.providerId);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
@@ -89,11 +100,18 @@ export function runDeviceCodeAuthFlow(input: {
|
||||
startClineDeviceAuth()
|
||||
.then((result) => {
|
||||
if (input.isAborted()) return;
|
||||
const verifyUrl =
|
||||
result.verificationUriComplete || result.verificationUri;
|
||||
input.setUserCode(result.userCode);
|
||||
input.setVerifyUrl(
|
||||
result.verificationUriComplete || result.verificationUri,
|
||||
);
|
||||
input.setVerifyUrl(verifyUrl);
|
||||
input.setStatus("Enter the code at the URL below");
|
||||
try {
|
||||
void open(verifyUrl, { wait: false }).catch(() => {
|
||||
input.setStatus("Could not open browser. Visit the URL below.");
|
||||
});
|
||||
} catch {
|
||||
input.setStatus("Could not open browser. Visit the URL below.");
|
||||
}
|
||||
|
||||
completeClineDeviceAuth({
|
||||
deviceCode: result.deviceCode,
|
||||
@@ -111,6 +129,12 @@ export function runDeviceCodeAuthFlow(input: {
|
||||
existing,
|
||||
credentials,
|
||||
);
|
||||
if (isClineAccountOAuthProvider(input.providerId)) {
|
||||
void identifyFeatureFlagsAccount({
|
||||
id: credentials.accountId,
|
||||
email: credentials.email,
|
||||
}).catch(() => {});
|
||||
}
|
||||
input.onComplete(input.providerId);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
captureProviderConfigured,
|
||||
getLocalProviderModels,
|
||||
getProviderConfigFields,
|
||||
listLocalProviders,
|
||||
type ProviderConfigFieldKey,
|
||||
type ProviderConfigFields,
|
||||
ProviderSettingsManager,
|
||||
@@ -10,13 +9,16 @@ import {
|
||||
resolveProviderConfig,
|
||||
saveLocalProviderSettings,
|
||||
} from "@cline/core";
|
||||
import { isClineProvider } from "@cline/shared";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
type CodexCliStatus,
|
||||
checkCodexCliInstalled,
|
||||
isOpenAICodexCliProvider,
|
||||
} from "../../../utils/codex-cli";
|
||||
import { getCliFeatureFlagsService } from "../../../utils/feature-flags";
|
||||
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
|
||||
import { listLocalProviders } from "../../../utils/provider-catalog";
|
||||
import { getCliTelemetryService } from "../../../utils/telemetry";
|
||||
import {
|
||||
buildClineModelEntries,
|
||||
@@ -46,11 +48,13 @@ import {
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import { useOnboardingKeyboard } from "./keyboard";
|
||||
import {
|
||||
getMainMenuOptions,
|
||||
type ModelEntry,
|
||||
type OnboardingResult,
|
||||
type OnboardingStep,
|
||||
type ProviderEntry,
|
||||
type ReasoningEffort,
|
||||
shouldUseFeaturedClineModelPicker,
|
||||
type ThinkingLevel,
|
||||
toModelEntriesFromKnownModels,
|
||||
toModelEntry,
|
||||
@@ -71,6 +75,14 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
() => props.providerSettingsManager ?? new ProviderSettingsManager(),
|
||||
[props.providerSettingsManager],
|
||||
);
|
||||
const menuOptions = useMemo(
|
||||
() =>
|
||||
getMainMenuOptions({
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const [step, setStep] = useState<OnboardingStep>("menu");
|
||||
const [menuSelected, setMenuSelected] = useState(0);
|
||||
const [oauthProvider, setOauthProvider] = useState("");
|
||||
@@ -153,6 +165,9 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
|
||||
const createCustomModelItem = useCallback(
|
||||
(_search: string, filteredItems: SearchableItem[]) => {
|
||||
if (activeProviderId === "cline-pass") {
|
||||
return undefined;
|
||||
}
|
||||
if (filteredItems.some((item) => item.key === CUSTOM_MODEL_ID_ACTION)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -163,7 +178,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
searchText: "create custom model id manual entry",
|
||||
} satisfies SearchableItem;
|
||||
},
|
||||
[],
|
||||
[activeProviderId],
|
||||
);
|
||||
|
||||
const modelList = useSearchableList(modelItems, createCustomModelItem);
|
||||
@@ -256,7 +271,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
const provider = providers.find((p) => p.id === providerId);
|
||||
setActiveProviderName(provider?.name ?? providerId);
|
||||
setModelsDefaultId(provider?.defaultModelId ?? "");
|
||||
if (providerId === "cline") {
|
||||
if (shouldUseFeaturedClineModelPicker(providerId)) {
|
||||
setClineModelSelected(0);
|
||||
setStep("cline_model");
|
||||
} else if (providerId === "openai-compatible") {
|
||||
@@ -307,7 +322,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
|
||||
const startOAuthFlow = useCallback(
|
||||
(providerId: OnboardingOAuthProviderId) => {
|
||||
if (providerId === "cline") {
|
||||
if (isClineProvider(providerId)) {
|
||||
startDeviceCodeFlow(providerId);
|
||||
return;
|
||||
}
|
||||
@@ -611,6 +626,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
onExit: props.onExit,
|
||||
oauthProvider,
|
||||
activeProviderId,
|
||||
menuOptions,
|
||||
menuSelected,
|
||||
providerList,
|
||||
modelList,
|
||||
@@ -685,6 +701,7 @@ export function useOnboardingController(props: OnboardingControllerProps) {
|
||||
},
|
||||
handleModelItemSelect: selectModelItem,
|
||||
menuSelected,
|
||||
menuOptions,
|
||||
modelItems,
|
||||
modelList,
|
||||
modelsLoading,
|
||||
|
||||
@@ -3,10 +3,13 @@ import { useKeyboard } from "@opentui/react";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import type { ClineModelPickerEntry } from "../../components/model-selector/cline-model-picker";
|
||||
import type { SearchableListState } from "../../components/searchable-list";
|
||||
import type { OnboardingOAuthProviderId } from "./auth";
|
||||
import {
|
||||
isOnboardingOAuthProviderId,
|
||||
type OnboardingOAuthProviderId,
|
||||
} from "./auth";
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import {
|
||||
MAIN_MENU,
|
||||
type MenuOption,
|
||||
type OnboardingStep,
|
||||
THINKING_LEVELS,
|
||||
type ThinkingLevel,
|
||||
@@ -17,6 +20,7 @@ export function useOnboardingKeyboard(input: {
|
||||
onExit: () => void;
|
||||
oauthProvider: string;
|
||||
activeProviderId: string;
|
||||
menuOptions: MenuOption[];
|
||||
menuSelected: number;
|
||||
providerList: SearchableListState;
|
||||
modelList: SearchableListState;
|
||||
@@ -133,17 +137,21 @@ export function useOnboardingKeyboard(input: {
|
||||
|
||||
if (input.step === "menu") {
|
||||
if (key.name === "up") {
|
||||
input.setMenuSelected((s) => (s <= 0 ? MAIN_MENU.length - 1 : s - 1));
|
||||
input.setMenuSelected((s) =>
|
||||
s <= 0 ? input.menuOptions.length - 1 : s - 1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (key.name === "down") {
|
||||
input.setMenuSelected((s) => (s >= MAIN_MENU.length - 1 ? 0 : s + 1));
|
||||
input.setMenuSelected((s) =>
|
||||
s >= input.menuOptions.length - 1 ? 0 : s + 1,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (key.name === "return") {
|
||||
const option = MAIN_MENU[input.menuSelected];
|
||||
const option = input.menuOptions[input.menuSelected];
|
||||
if (!option) return;
|
||||
if (option.value === "cline" || option.value === "openai-codex") {
|
||||
if (isOnboardingOAuthProviderId(option.value)) {
|
||||
input.startOAuthFlow(option.value);
|
||||
} else {
|
||||
input.setStep("byo_provider");
|
||||
|
||||
@@ -1,12 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getMainMenuOptions,
|
||||
getOAuthProviderLabel,
|
||||
shouldUseFeaturedClineModelPicker,
|
||||
toModelEntriesFromKnownModels,
|
||||
toModelEntry,
|
||||
toProviderEntry,
|
||||
} from "./model";
|
||||
|
||||
describe("onboarding model helpers", () => {
|
||||
it("hides ClinePass from the main menu unless its feature flag is enabled", () => {
|
||||
expect(
|
||||
getMainMenuOptions().some((option) => option.value === "cline-pass"),
|
||||
).toBe(false);
|
||||
expect(
|
||||
getMainMenuOptions({ isClinePassEnabled: false }).some(
|
||||
(option) => option.value === "cline-pass",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
getMainMenuOptions({ isClinePassEnabled: true }).some(
|
||||
(option) => option.value === "cline-pass",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("maps provider catalog entries into onboarding provider entries", () => {
|
||||
expect(
|
||||
toProviderEntry({
|
||||
@@ -112,7 +130,14 @@ describe("onboarding model helpers", () => {
|
||||
|
||||
it("formats OAuth provider labels for onboarding status views", () => {
|
||||
expect(getOAuthProviderLabel("cline")).toBe("Cline");
|
||||
expect(getOAuthProviderLabel("cline-pass")).toBe("ClinePass");
|
||||
expect(getOAuthProviderLabel("openai-codex")).toBe("ChatGPT");
|
||||
expect(getOAuthProviderLabel("oca")).toBe("oca");
|
||||
});
|
||||
|
||||
it("uses the featured Cline model picker only for the Cline provider", () => {
|
||||
expect(shouldUseFeaturedClineModelPicker("cline")).toBe(true);
|
||||
expect(shouldUseFeaturedClineModelPicker("cline-pass")).toBe(false);
|
||||
expect(shouldUseFeaturedClineModelPicker("anthropic")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,12 @@ export const MAIN_MENU: MenuOption[] = [
|
||||
detail: "Latest models with regular free promos",
|
||||
icon: "\u263a",
|
||||
},
|
||||
{
|
||||
label: "Sign in with ClinePass",
|
||||
value: "cline-pass",
|
||||
detail: "Low cost subscription for everyone",
|
||||
icon: "\u2726",
|
||||
},
|
||||
{
|
||||
label: "Sign in with ChatGPT",
|
||||
value: "openai-codex",
|
||||
@@ -57,6 +63,14 @@ export const MAIN_MENU: MenuOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export function getMainMenuOptions(options?: {
|
||||
isClinePassEnabled?: boolean;
|
||||
}): MenuOption[] {
|
||||
return MAIN_MENU.filter(
|
||||
(option) => option.value !== "cline-pass" || options?.isClinePassEnabled,
|
||||
);
|
||||
}
|
||||
|
||||
export interface OnboardingResult {
|
||||
providerId: string;
|
||||
modelId: string;
|
||||
@@ -139,6 +153,9 @@ export function toModelEntriesFromKnownModels(
|
||||
}
|
||||
|
||||
export function getOAuthProviderLabel(providerId: string): string {
|
||||
if (providerId === "cline-pass") {
|
||||
return "ClinePass";
|
||||
}
|
||||
if (providerId === "cline") {
|
||||
return "Cline";
|
||||
}
|
||||
@@ -147,3 +164,7 @@ export function getOAuthProviderLabel(providerId: string): string {
|
||||
}
|
||||
return providerId;
|
||||
}
|
||||
|
||||
export function shouldUseFeaturedClineModelPicker(providerId: string): boolean {
|
||||
return providerId === "cline";
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { useTerminalBackground } from "../../hooks/use-terminal-background";
|
||||
import { getDefaultForeground, palette } from "../../palette";
|
||||
import { FIELD_ORDER } from "./fields";
|
||||
import { MAIN_MENU, THINKING_LEVELS } from "./model";
|
||||
import { type MenuOption, THINKING_LEVELS } from "./model";
|
||||
|
||||
type MouseTrackerState = ReturnType<typeof useMouseTracker>;
|
||||
|
||||
@@ -633,6 +633,7 @@ export function OnboardingThinkingLevelScreen(props: {
|
||||
|
||||
export function OnboardingMainMenuScreen(props: {
|
||||
contentWidth: number;
|
||||
menuOptions: MenuOption[];
|
||||
menuSelected: number;
|
||||
mouse: MouseTrackerState;
|
||||
}) {
|
||||
@@ -671,7 +672,7 @@ export function OnboardingMainMenuScreen(props: {
|
||||
marginTop={1}
|
||||
gap={0}
|
||||
>
|
||||
{MAIN_MENU.map((option, i) => {
|
||||
{props.menuOptions.map((option, i) => {
|
||||
const isSel = i === props.menuSelected;
|
||||
return (
|
||||
<box
|
||||
|
||||
@@ -166,6 +166,7 @@ export function OnboardingView(props: OnboardingViewProps) {
|
||||
return (
|
||||
<OnboardingMainMenuScreen
|
||||
contentWidth={contentWidth}
|
||||
menuOptions={state.menuOptions}
|
||||
menuSelected={state.menuSelected}
|
||||
mouse={mouse}
|
||||
/>
|
||||
|
||||
@@ -28,6 +28,7 @@ export type ChatCommandContext = {
|
||||
getState: () => Promise<ChatCommandState> | ChatCommandState;
|
||||
setState: (next: ChatCommandState) => Promise<void> | void;
|
||||
reply: (text: string) => Promise<void> | void;
|
||||
submitPrompt?: (prompt: string) => Promise<void> | void;
|
||||
reset?: () => Promise<void> | void;
|
||||
abort?: () => Promise<void> | void;
|
||||
stop?: () => Promise<void> | void;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClinePassSubscriptionError,
|
||||
} from "./cline-pass-errors";
|
||||
|
||||
describe("cline-pass-errors", () => {
|
||||
it("recognizes both raw and formatted ClinePass subscription messages", () => {
|
||||
expect(
|
||||
isClinePassSubscriptionError(
|
||||
"the user is not subscribed to required model plan",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
const formatted = `No access to ClinePass subscription models yet. Subscribe to ClinePass, the low cost open weights model coding plan: ${getClinePassSubscriptionUrl()}`;
|
||||
expect(isClinePassSubscriptionError(formatted)).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(formatted))).toBe(formatted);
|
||||
});
|
||||
|
||||
it("formats the ClinePass subscription URL", () => {
|
||||
expect(getClinePassSubscriptionUrl()).toBe(
|
||||
"https://app.cline.bot/dashboard/subscription/",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
} from "@cline/core";
|
||||
|
||||
export { getClinePassSubscriptionUrl };
|
||||
|
||||
function isFormattedClinePassSubscriptionMessage(message: string): boolean {
|
||||
const normalized = message.trim().toLowerCase();
|
||||
return (
|
||||
normalized.includes("no access to clinepass subscription models yet") &&
|
||||
normalized.includes("subscribe to clinepass")
|
||||
);
|
||||
}
|
||||
|
||||
export function isClinePassSubscriptionError(error: unknown): boolean {
|
||||
if (isClineNotSubscribedError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClineNotSubscribedError" ||
|
||||
isClineNotSubscribedMessage(error.message) ||
|
||||
isFormattedClinePassSubscriptionMessage(error.message)
|
||||
);
|
||||
}
|
||||
return (
|
||||
typeof error === "string" &&
|
||||
(isClineNotSubscribedMessage(error) ||
|
||||
isFormattedClinePassSubscriptionMessage(error))
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
disposeCliFeatureFlagsService,
|
||||
getCliFeatureFlagsService,
|
||||
} from "./feature-flags";
|
||||
|
||||
describe("CLI feature flags singleton", () => {
|
||||
afterEach(async () => {
|
||||
await disposeCliFeatureFlagsService();
|
||||
});
|
||||
|
||||
it("recreates the singleton after disposal", async () => {
|
||||
const service = getCliFeatureFlagsService();
|
||||
|
||||
await disposeCliFeatureFlagsService();
|
||||
|
||||
expect(getCliFeatureFlagsService()).not.toBe(service);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
type BasicLogger,
|
||||
type FeatureFlagsContext,
|
||||
FeatureFlagsService,
|
||||
type ITelemetryService,
|
||||
NoOpFeatureFlagsProvider,
|
||||
registerDisposable,
|
||||
resolveCoreDistinctId,
|
||||
} from "@cline/core";
|
||||
import {
|
||||
buildClinePostHogClient,
|
||||
PostHogFeatureFlagsProvider,
|
||||
} from "@cline/core/services/feature-flags/posthog";
|
||||
import { resolveClineDataDir } from "@cline/shared/storage";
|
||||
|
||||
let cliFeatureFlagsContext: FeatureFlagsContext = { clientName: "cline-cli" };
|
||||
let cliFeatureFlagsService: FeatureFlagsService | undefined;
|
||||
|
||||
const CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function resolveCliFeatureFlagsCachePath(): string {
|
||||
return join(resolveClineDataDir(), "cache", "feature-flags.json");
|
||||
}
|
||||
|
||||
function ensureCliDistinctId(): string {
|
||||
const distinctId = cliFeatureFlagsContext.distinctId?.trim();
|
||||
if (distinctId) {
|
||||
return distinctId;
|
||||
}
|
||||
const resolved = resolveCoreDistinctId();
|
||||
cliFeatureFlagsContext.distinctId = resolved;
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function getCliFeatureFlagsContext(): FeatureFlagsContext {
|
||||
ensureCliDistinctId();
|
||||
return { ...cliFeatureFlagsContext };
|
||||
}
|
||||
|
||||
export function getCliFeatureFlagsService(options?: {
|
||||
logger?: BasicLogger;
|
||||
telemetry?: ITelemetryService;
|
||||
}): FeatureFlagsService {
|
||||
if (!cliFeatureFlagsService) {
|
||||
const apiKey = process.env.TELEMETRY_SERVICE_API_KEY;
|
||||
const provider =
|
||||
apiKey &&
|
||||
process.env.IS_TEST !== "true" &&
|
||||
process.env.E2E_TEST !== "true"
|
||||
? new PostHogFeatureFlagsProvider({
|
||||
client: buildClinePostHogClient(apiKey),
|
||||
config: {
|
||||
logger: options?.logger,
|
||||
},
|
||||
})
|
||||
: new NoOpFeatureFlagsProvider();
|
||||
|
||||
cliFeatureFlagsService = new FeatureFlagsService({
|
||||
provider,
|
||||
telemetry: options?.telemetry,
|
||||
logger: options?.logger,
|
||||
context: getCliFeatureFlagsContext(),
|
||||
cacheFilePath: resolveCliFeatureFlagsCachePath(),
|
||||
persistentCacheMaxAgeMs: CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS,
|
||||
});
|
||||
registerDisposable(disposeCliFeatureFlagsService);
|
||||
}
|
||||
|
||||
return cliFeatureFlagsService;
|
||||
}
|
||||
|
||||
export function refreshCliFeatureFlagsInBackground(logger?: BasicLogger): void {
|
||||
const service = getCliFeatureFlagsService({ logger });
|
||||
void service.poll().catch((error) => {
|
||||
logger?.error?.("Error refreshing CLI feature flags", { error });
|
||||
});
|
||||
}
|
||||
|
||||
export async function disposeCliFeatureFlagsService(): Promise<void> {
|
||||
if (!cliFeatureFlagsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = cliFeatureFlagsService;
|
||||
cliFeatureFlagsService = undefined;
|
||||
await current.dispose();
|
||||
}
|
||||
|
||||
export function setCliFeatureFlagsAccountContext(account: {
|
||||
id?: string;
|
||||
email?: string;
|
||||
}): void {
|
||||
const accountId = account.id?.trim();
|
||||
cliFeatureFlagsContext = {
|
||||
...cliFeatureFlagsContext,
|
||||
...(accountId ? { distinctId: accountId, userId: accountId } : {}),
|
||||
...(account.email?.trim() ? { email: account.email.trim() } : {}),
|
||||
};
|
||||
cliFeatureFlagsService?.setContext(getCliFeatureFlagsContext());
|
||||
}
|
||||
|
||||
export async function identifyFeatureFlagsAccount(
|
||||
account: { id?: string; email?: string },
|
||||
logger?: BasicLogger,
|
||||
): Promise<void> {
|
||||
setCliFeatureFlagsAccountContext(account);
|
||||
|
||||
if (!cliFeatureFlagsService) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await cliFeatureFlagsService.poll();
|
||||
} catch (error) {
|
||||
logger?.error?.("Error polling CLI feature flags", { error });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearClineFreeModelCostCache,
|
||||
shouldZeroClineFreeModelCost,
|
||||
zeroCliAgentEventCost,
|
||||
zeroCliUsageCost,
|
||||
} from "./free-model-cost";
|
||||
|
||||
afterEach(() => {
|
||||
clearClineFreeModelCostCache();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("shouldZeroClineFreeModelCost", () => {
|
||||
it("uses the Cline free model list", async () => {
|
||||
const fetchMock = vi.fn(
|
||||
async (_input: Parameters<typeof fetch>[0], _init?: RequestInit) => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toBe(
|
||||
"https://cline.test/api/v1/ai/cline/recommended-models",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not zero non-Cline providers", async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "openrouter",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not match a paid model by only the final path segment", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "acme/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("retries after a failed free model list fetch", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response("unavailable", { status: 503 }))
|
||||
.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
free: [{ id: "deepseek/deepseek-v4-flash" }],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(
|
||||
shouldZeroClineFreeModelCost({
|
||||
providerId: "cline",
|
||||
modelId: "deepseek/deepseek-v4-flash",
|
||||
baseUrl: "https://cline.test/api/v1",
|
||||
}),
|
||||
).resolves.toBe(true);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("zeroCliUsageCost", () => {
|
||||
it("zeros total cost while preserving token usage", () => {
|
||||
expect(
|
||||
zeroCliUsageCost(
|
||||
{
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.001,
|
||||
},
|
||||
true,
|
||||
),
|
||||
).toEqual({
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("zeroCliAgentEventCost", () => {
|
||||
it("zeros usage event cost fields", () => {
|
||||
const event = {
|
||||
type: "usage",
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
cost: 0.001,
|
||||
totalCost: 0.001,
|
||||
} as AgentEvent;
|
||||
|
||||
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
|
||||
cost: 0,
|
||||
totalCost: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("zeros done event usage cost", () => {
|
||||
const event = {
|
||||
type: "done",
|
||||
reason: "completed",
|
||||
text: "ok",
|
||||
iterations: 1,
|
||||
usage: {
|
||||
inputTokens: 10,
|
||||
outputTokens: 5,
|
||||
totalCost: 0.001,
|
||||
},
|
||||
} as AgentEvent;
|
||||
|
||||
expect(zeroCliAgentEventCost(event, true)).toMatchObject({
|
||||
usage: { totalCost: 0 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { AgentEvent } from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
import type { Config } from "./types";
|
||||
|
||||
const CLINE_RECOMMENDED_MODELS_TIMEOUT_MS = 5_000;
|
||||
const freeModelIdsByBaseUrl = new Map<
|
||||
string,
|
||||
Promise<readonly string[] | undefined>
|
||||
>();
|
||||
|
||||
function normalizeModelId(modelId: string | undefined): string {
|
||||
return modelId?.trim().toLowerCase() ?? "";
|
||||
}
|
||||
|
||||
function modelIdsMatch(selectedModelId: string, freeModelId: string): boolean {
|
||||
const selected = normalizeModelId(selectedModelId);
|
||||
const free = normalizeModelId(freeModelId);
|
||||
if (!selected || !free) return false;
|
||||
return selected === free;
|
||||
}
|
||||
|
||||
function resolveClineRecommendedModelsUrl(baseUrl: string): string {
|
||||
const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, "");
|
||||
const apiBaseUrl = normalizedBaseUrl.endsWith("/api/v1")
|
||||
? normalizedBaseUrl.slice(0, -"/api/v1".length)
|
||||
: normalizedBaseUrl;
|
||||
return `${apiBaseUrl}/api/v1/ai/cline/recommended-models`;
|
||||
}
|
||||
|
||||
async function fetchClineFreeModelIds(
|
||||
baseUrl: string,
|
||||
): Promise<readonly string[] | undefined> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(
|
||||
() => controller.abort(),
|
||||
CLINE_RECOMMENDED_MODELS_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const response = await fetch(resolveClineRecommendedModelsUrl(baseUrl), {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return undefined;
|
||||
const json = (await response.json()) as { free?: unknown };
|
||||
return Array.isArray(json.free)
|
||||
? json.free
|
||||
.map((model) =>
|
||||
model && typeof model === "object"
|
||||
? (model as Record<string, unknown>).id
|
||||
: undefined,
|
||||
)
|
||||
.filter((id): id is string => typeof id === "string" && id.length > 0)
|
||||
: [];
|
||||
} catch {
|
||||
return undefined;
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function getClineFreeModelIds(baseUrl: string): Promise<readonly string[]> {
|
||||
const cacheKey = baseUrl.trim();
|
||||
let cached = freeModelIdsByBaseUrl.get(cacheKey);
|
||||
if (!cached) {
|
||||
cached = fetchClineFreeModelIds(cacheKey).then((ids) => {
|
||||
if (!ids) freeModelIdsByBaseUrl.delete(cacheKey);
|
||||
return ids;
|
||||
});
|
||||
freeModelIdsByBaseUrl.set(cacheKey, cached);
|
||||
}
|
||||
return cached.then((ids) => ids ?? []);
|
||||
}
|
||||
|
||||
export async function shouldZeroClineFreeModelCost(
|
||||
config: Pick<Config, "providerId" | "modelId" | "baseUrl">,
|
||||
): Promise<boolean> {
|
||||
if (config.providerId !== "cline") return false;
|
||||
const modelId = normalizeModelId(config.modelId);
|
||||
if (!modelId) return false;
|
||||
|
||||
const baseUrl =
|
||||
config.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
|
||||
const freeModelIds = await getClineFreeModelIds(baseUrl);
|
||||
return freeModelIds.some((freeModelId) =>
|
||||
modelIdsMatch(modelId, freeModelId),
|
||||
);
|
||||
}
|
||||
|
||||
export function zeroCliUsageCost<T extends { totalCost?: number } | undefined>(
|
||||
usage: T,
|
||||
shouldZeroCost: boolean,
|
||||
): T {
|
||||
if (
|
||||
!shouldZeroCost ||
|
||||
!usage ||
|
||||
typeof usage.totalCost !== "number" ||
|
||||
usage.totalCost === 0
|
||||
) {
|
||||
return usage;
|
||||
}
|
||||
return { ...usage, totalCost: 0 } as T;
|
||||
}
|
||||
|
||||
export function zeroCliAgentEventCost(
|
||||
event: AgentEvent,
|
||||
shouldZeroCost: boolean,
|
||||
): AgentEvent {
|
||||
if (!shouldZeroCost) return event;
|
||||
if (event.type === "done" && event.usage) {
|
||||
return {
|
||||
...event,
|
||||
usage: zeroCliUsageCost(event.usage, true),
|
||||
};
|
||||
}
|
||||
if (event.type !== "usage") return event;
|
||||
const next = { ...event } as Record<string, unknown>;
|
||||
if (typeof next.cost === "number") next.cost = 0;
|
||||
if (typeof next.totalCost === "number") next.totalCost = 0;
|
||||
return next as unknown as AgentEvent;
|
||||
}
|
||||
|
||||
export function clearClineFreeModelCostCache(): void {
|
||||
freeModelIdsByBaseUrl.clear();
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildHistoryResumeArgs } from "./history-resume";
|
||||
|
||||
describe("buildHistoryResumeArgs", () => {
|
||||
it("replaces the history subcommand with --id", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["history"],
|
||||
}),
|
||||
).toEqual(["--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("preserves global flags that precede the subcommand", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: [
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"history",
|
||||
"--limit",
|
||||
"5",
|
||||
],
|
||||
remainingArgs: ["history", "--limit", "5"],
|
||||
}),
|
||||
).toEqual([
|
||||
"--data-dir",
|
||||
"/tmp/data",
|
||||
"-m",
|
||||
"claude-sonnet-4-6",
|
||||
"--id",
|
||||
"sess_1",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a global flag value that matches the subcommand alias", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["-m", "h", "h"],
|
||||
remainingArgs: ["h"],
|
||||
}),
|
||||
).toEqual(["-m", "h", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("forwards a config dir passed as a subcommand option", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history", "--config", "/tmp/conf"],
|
||||
remainingArgs: ["history", "--config", "/tmp/conf"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("does not duplicate a config dir already in the global flags", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["--config", "/tmp/conf", "history"],
|
||||
remainingArgs: ["history"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("recognizes the --config=<dir> spelling in global flags", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["--config=/tmp/conf", "history"],
|
||||
remainingArgs: ["history"],
|
||||
configDir: "/tmp/conf",
|
||||
}),
|
||||
).toEqual(["--config=/tmp/conf", "--id", "sess_1"]);
|
||||
});
|
||||
|
||||
it("returns undefined when remaining args are not a suffix of argv", () => {
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history", "--limit", "5"],
|
||||
remainingArgs: ["history", "--limit", "9"],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
buildHistoryResumeArgs({
|
||||
sessionId: "sess_1",
|
||||
normalizedArgs: ["history"],
|
||||
remainingArgs: ["extra", "history"],
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,115 @@
|
||||
import { resolveCliLaunchSpec } from "./internal-launch";
|
||||
|
||||
export interface HistoryResumeCommand {
|
||||
launcher: string;
|
||||
childArgs: string[];
|
||||
}
|
||||
|
||||
export interface BuildHistoryResumeArgsInput {
|
||||
sessionId: string;
|
||||
/** Full normalized CLI args (process.argv.slice(2) after normalization). */
|
||||
normalizedArgs: string[];
|
||||
/**
|
||||
* Commander's `program.args` after parsing: the `history` subcommand token
|
||||
* and everything following it. Must be a suffix of `normalizedArgs`.
|
||||
*/
|
||||
remainingArgs: string[];
|
||||
/**
|
||||
* Config dir resolved from the full argv. Forwarded explicitly because
|
||||
* `--config` may have been passed as a `history` subcommand option, which
|
||||
* would otherwise be dropped with the rest of the subcommand args.
|
||||
*/
|
||||
configDir?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds argv for relaunching the CLI as `cline <globalFlags> --id <sessionId>`
|
||||
* after a session is picked in `cline history`. Returns undefined when the
|
||||
* global-flag prefix cannot be derived safely (caller falls back to resuming
|
||||
* in-process).
|
||||
*/
|
||||
export function buildHistoryResumeArgs(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): string[] | undefined {
|
||||
const { sessionId, normalizedArgs, remainingArgs, configDir } = input;
|
||||
const splitIndex = normalizedArgs.length - remainingArgs.length;
|
||||
if (splitIndex < 0) {
|
||||
return undefined;
|
||||
}
|
||||
for (let i = 0; i < remainingArgs.length; i++) {
|
||||
if (normalizedArgs[splitIndex + i] !== remainingArgs[i]) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
const globalArgs = normalizedArgs.slice(0, splitIndex);
|
||||
const args = [...globalArgs];
|
||||
const hasConfigFlag = globalArgs.some(
|
||||
(arg) => arg === "--config" || arg.startsWith("--config="),
|
||||
);
|
||||
if (configDir && !hasConfigFlag) {
|
||||
args.push("--config", configDir);
|
||||
}
|
||||
args.push("--id", sessionId);
|
||||
return args;
|
||||
}
|
||||
|
||||
export function buildHistoryResumeCommand(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): HistoryResumeCommand | undefined {
|
||||
const childArgs = buildHistoryResumeArgs(input);
|
||||
if (!childArgs) {
|
||||
return undefined;
|
||||
}
|
||||
const spec = resolveCliLaunchSpec();
|
||||
if (!spec) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
launcher: spec.launcher,
|
||||
childArgs: [...spec.childArgsPrefix, ...childArgs],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes a history-picked session in a fresh `cline --id <sessionId>` child
|
||||
* process with inherited stdio, and returns its exit code. Creating a second
|
||||
* OpenTUI renderer in the picker's process can crash natively during teardown
|
||||
* (Bun "panic(main thread): Segmentation fault" on Ctrl+C), so the resumed
|
||||
* interactive TUI must get a process of its own.
|
||||
*
|
||||
* Returns undefined when the child cannot be launched; the caller should fall
|
||||
* back to resuming in-process.
|
||||
*/
|
||||
export async function spawnHistoryResume(
|
||||
input: BuildHistoryResumeArgsInput,
|
||||
): Promise<number | undefined> {
|
||||
const command = buildHistoryResumeCommand(input);
|
||||
if (!command) {
|
||||
return undefined;
|
||||
}
|
||||
const { spawn } = await import("node:child_process");
|
||||
return await new Promise<number | undefined>((resolve) => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
try {
|
||||
child = spawn(command.launcher, command.childArgs, {
|
||||
stdio: "inherit",
|
||||
});
|
||||
} catch {
|
||||
resolve(undefined);
|
||||
return;
|
||||
}
|
||||
// The child shares this foreground process group, so terminal-generated
|
||||
// Ctrl+C already reaches it. Keep the parent alive to reap the child
|
||||
// without re-forwarding a second signal into the TUI teardown path.
|
||||
const suppressParentSignal = () => {};
|
||||
process.on("SIGINT", suppressParentSignal);
|
||||
process.on("SIGTERM", suppressParentSignal);
|
||||
const finish = (value: number | undefined) => {
|
||||
process.off("SIGINT", suppressParentSignal);
|
||||
process.off("SIGTERM", suppressParentSignal);
|
||||
resolve(value);
|
||||
};
|
||||
child.once("error", () => finish(undefined));
|
||||
child.once("exit", (code, signal) => finish(signal ? 1 : (code ?? 0)));
|
||||
});
|
||||
}
|
||||
@@ -65,4 +65,55 @@ describe("plugin chat commands", () => {
|
||||
expect(reply).toHaveBeenCalledWith("echo:hello plugin");
|
||||
await shutdown?.();
|
||||
});
|
||||
|
||||
it("bridges plugin command submit prompts onto the chat command context", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-plugin-commands-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const pluginsDir = join(tempRoot, ".cline", "plugins");
|
||||
await mkdir(pluginsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(pluginsDir, "submit.js"),
|
||||
[
|
||||
"export default {",
|
||||
" name: 'submit-plugin',",
|
||||
" manifest: { capabilities: ['commands'] },",
|
||||
" setup(api) {",
|
||||
" api.registerCommand({",
|
||||
" name: 'goal',",
|
||||
" description: 'Set a goal and submit it',",
|
||||
" handler: async (input) => ({",
|
||||
" reply: 'goal:' + input,",
|
||||
" submitPrompt: input",
|
||||
" })",
|
||||
" });",
|
||||
" },",
|
||||
"};",
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const { host, shutdown } = await createWorkspaceChatCommandHost({
|
||||
cwd: tempRoot,
|
||||
workspaceRoot: tempRoot,
|
||||
});
|
||||
const reply = vi.fn(async () => undefined);
|
||||
const submitPrompt = vi.fn(async () => undefined);
|
||||
|
||||
const handled = await host.handle("/goal fix tests", {
|
||||
enabled: true,
|
||||
getState: async () => ({
|
||||
enableTools: false,
|
||||
autoApproveTools: false,
|
||||
cwd: tempRoot,
|
||||
workspaceRoot: tempRoot,
|
||||
}),
|
||||
setState: async () => undefined,
|
||||
reply,
|
||||
submitPrompt,
|
||||
});
|
||||
|
||||
expect(handled).toBe(true);
|
||||
expect(reply).toHaveBeenCalledWith("goal:fix tests");
|
||||
expect(submitPrompt).toHaveBeenCalledWith("fix tests");
|
||||
await shutdown?.();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
type AgentExtensionCommand,
|
||||
type AgentExtensionCommandResult,
|
||||
type BasicLogger,
|
||||
createContributionRegistry,
|
||||
resolveAndLoadAgentPlugins,
|
||||
@@ -45,13 +46,41 @@ function createPluginCommandDefinition(
|
||||
names: [normalizedName.toLowerCase()],
|
||||
run: async ({ args }, context) => {
|
||||
const result = await command.handler?.(args.join(" "));
|
||||
if (typeof result === "string" && result.trim()) {
|
||||
await context.reply(result);
|
||||
const { reply, submitPrompt } = normalizeCommandResult(result);
|
||||
if (reply) {
|
||||
await context.reply(reply);
|
||||
}
|
||||
if (submitPrompt) {
|
||||
await context.submitPrompt?.(submitPrompt);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCommandResult(
|
||||
result: AgentExtensionCommandResult | undefined,
|
||||
): { reply?: string; submitPrompt?: string } {
|
||||
if (typeof result === "string") {
|
||||
const reply = result.trim();
|
||||
return reply ? { reply } : {};
|
||||
}
|
||||
if (!result || typeof result !== "object") {
|
||||
return {};
|
||||
}
|
||||
const reply =
|
||||
typeof result.reply === "string" && result.reply.trim()
|
||||
? result.reply.trim()
|
||||
: undefined;
|
||||
const submitPrompt =
|
||||
typeof result.submitPrompt === "string" && result.submitPrompt.trim()
|
||||
? result.submitPrompt.trim()
|
||||
: undefined;
|
||||
return {
|
||||
...(reply ? { reply } : {}),
|
||||
...(submitPrompt ? { submitPrompt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createWorkspaceChatCommandHost(input: {
|
||||
cwd: string;
|
||||
workspaceRoot?: string;
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listLocalProviders: vi.fn(async () => ({ providers: [], settingsPath: "" })),
|
||||
getBooleanFlagEnabled: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@cline/core")>();
|
||||
return {
|
||||
...actual,
|
||||
listLocalProviders: mocks.listLocalProviders,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./feature-flags", () => ({
|
||||
getCliFeatureFlagsService: () => ({
|
||||
getBooleanFlagEnabled: mocks.getBooleanFlagEnabled,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("listLocalProviders", () => {
|
||||
it("passes the ClinePass feature flag into the SDK provider list", async () => {
|
||||
const { listLocalProviders } = await import("./provider-catalog");
|
||||
const manager = {} as never;
|
||||
|
||||
await listLocalProviders(manager);
|
||||
|
||||
expect(mocks.getBooleanFlagEnabled).toHaveBeenCalledWith("ext-cline-pass");
|
||||
expect(mocks.listLocalProviders).toHaveBeenCalledWith(manager, {
|
||||
isClinePassEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
listLocalProviders as internalListLocalProviders,
|
||||
type ProviderSettingsManager,
|
||||
} from "@cline/core";
|
||||
import { getCliFeatureFlagsService } from "./feature-flags";
|
||||
|
||||
export async function listLocalProviders(
|
||||
manager: ProviderSettingsManager,
|
||||
): ReturnType<typeof internalListLocalProviders> {
|
||||
return await internalListLocalProviders(manager, {
|
||||
isClinePassEnabled:
|
||||
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
|
||||
});
|
||||
}
|
||||
@@ -18,9 +18,12 @@ export async function readRepoStatus(cwd: string): Promise<RepoStatus> {
|
||||
const [branchResult, diffResult] = await Promise.allSettled([
|
||||
execFileAsync("git", ["-C", cwd, "rev-parse", "--abbrev-ref", "HEAD"], {
|
||||
encoding: "utf8",
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
}),
|
||||
execFileAsync("git", ["-C", cwd, "diff", "--shortstat"], {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ vi.mock("./telemetry", async (importOriginal) => {
|
||||
|
||||
import {
|
||||
captureCliExtensionActivated,
|
||||
identifyCliTelemetryAccount,
|
||||
identifyTelemetryAccount,
|
||||
} from "./telemetry";
|
||||
import { resetCliExtensionActivationForTests } from "./telemetry.test-helpers";
|
||||
|
||||
@@ -93,7 +93,7 @@ describe("captureCliExtensionActivated", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("identifyCliTelemetryAccount", () => {
|
||||
describe("identifyTelemetryAccount", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.identifyAccount.mockClear();
|
||||
hoisted.getCliTelemetryService.mockClear();
|
||||
@@ -107,7 +107,7 @@ describe("identifyCliTelemetryAccount", () => {
|
||||
memberId: "member-7",
|
||||
provider: "cline",
|
||||
};
|
||||
identifyCliTelemetryAccount(account);
|
||||
identifyTelemetryAccount(account);
|
||||
expect(hoisted.identifyAccount).toHaveBeenCalledWith(undefined, account);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
TelemetryLoggerSink,
|
||||
} from "@cline/core";
|
||||
import { getCliBuildInfo } from "./common";
|
||||
import { identifyFeatureFlagsAccount } from "./feature-flags";
|
||||
import {
|
||||
markActivationCaptured,
|
||||
wasActivationCaptured,
|
||||
@@ -102,11 +103,12 @@ export interface CliTelemetryAccountContext {
|
||||
* Safe to call multiple times; the latest values win, mirroring the legacy
|
||||
* singleton-based behavior.
|
||||
*/
|
||||
export function identifyCliTelemetryAccount(
|
||||
export function identifyTelemetryAccount(
|
||||
account: CliTelemetryAccountContext,
|
||||
logger?: BasicLogger,
|
||||
): void {
|
||||
identifyAccount(getCliTelemetryService(logger), account);
|
||||
void identifyFeatureFlagsAccount(account, logger);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -134,6 +136,7 @@ export function captureCliExtensionActivated(
|
||||
const telemetry = getCliTelemetryService(logger);
|
||||
if (account) {
|
||||
identifyAccount(telemetry, account);
|
||||
void identifyFeatureFlagsAccount(account, logger);
|
||||
}
|
||||
captureExtensionActivated(telemetry);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import { authorizeMcpServerOAuth } from "@cline/core";
|
||||
import open from "open";
|
||||
import { authorizeMcpServerOAuthWithBrowser as authorizeOAuth } from "./oauth";
|
||||
import {
|
||||
addServer,
|
||||
clearServerOAuth,
|
||||
@@ -17,16 +16,6 @@ function isCancel(value: unknown): value is symbol {
|
||||
return p.isCancel(value);
|
||||
}
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.trim();
|
||||
if (message.length > 0) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
function transportLabel(t: McpTransport): string {
|
||||
if (t.type === "stdio") return `stdio: ${t.command}`;
|
||||
return `${t.type}: ${t.url}`;
|
||||
@@ -62,6 +51,19 @@ interface UrlServerConfig {
|
||||
authMode: RemoteAuthMode;
|
||||
}
|
||||
|
||||
export interface McpAddDefaults {
|
||||
name?: string;
|
||||
type?: McpTransport["type"];
|
||||
command?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface RunMcpWizardOptions {
|
||||
initialAction?: "add";
|
||||
addDefaults?: McpAddDefaults;
|
||||
exitAfterInitialAction?: boolean;
|
||||
}
|
||||
|
||||
export function parseStdioCommand(input: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
@@ -107,12 +109,15 @@ export function parseStdioCommand(input: string): string[] {
|
||||
return tokens;
|
||||
}
|
||||
|
||||
async function collectStdioTransport(): Promise<McpTransport | null> {
|
||||
async function collectStdioTransport(
|
||||
defaultCommand?: string,
|
||||
): Promise<McpTransport | null> {
|
||||
p.log.info("Quoted arguments and escaped spaces are supported");
|
||||
|
||||
const command = await p.text({
|
||||
message: "Command to run",
|
||||
placeholder: "npx -y @modelcontextprotocol/server-filesystem",
|
||||
initialValue: defaultCommand,
|
||||
validate: (v) => {
|
||||
if (!v?.trim()) return "Command is required";
|
||||
return undefined;
|
||||
@@ -152,10 +157,12 @@ async function collectStdioTransport(): Promise<McpTransport | null> {
|
||||
|
||||
async function collectUrlTransport(
|
||||
type: "sse" | "streamableHttp",
|
||||
defaultUrl?: string,
|
||||
): Promise<UrlServerConfig | null> {
|
||||
const url = await p.text({
|
||||
message: "Server URL",
|
||||
placeholder: "https://example.com/mcp",
|
||||
initialValue: defaultUrl,
|
||||
validate: (v) => {
|
||||
if (!v?.trim()) return "URL is required";
|
||||
try {
|
||||
@@ -222,33 +229,11 @@ async function collectUrlTransport(
|
||||
};
|
||||
}
|
||||
|
||||
async function authorizeOAuth(name: string): Promise<void> {
|
||||
p.log.info("Opening browser for MCP OAuth authorization");
|
||||
try {
|
||||
const result = await authorizeMcpServerOAuth({
|
||||
serverName: name,
|
||||
filePath: getSettingsPath(),
|
||||
openUrl: async (url) => {
|
||||
p.log.message(`Authorization URL: ${url}`);
|
||||
await open(url, { wait: false });
|
||||
},
|
||||
onServerListening: (info) => {
|
||||
p.log.message(`Waiting for OAuth callback at ${info.callbackUrl}`);
|
||||
},
|
||||
});
|
||||
p.log.success(result.message);
|
||||
} catch (error) {
|
||||
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
|
||||
p.log.warn(
|
||||
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function actionAdd(): Promise<void> {
|
||||
async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
|
||||
const name = await p.text({
|
||||
message: "Server name",
|
||||
placeholder: "my-mcp-server",
|
||||
initialValue: defaults?.name,
|
||||
validate: (v) => {
|
||||
if (!v?.trim()) return "Name is required";
|
||||
const existing = loadServers();
|
||||
@@ -262,6 +247,7 @@ async function actionAdd(): Promise<void> {
|
||||
|
||||
const type = await p.select({
|
||||
message: "Server type",
|
||||
initialValue: defaults?.type,
|
||||
options: [
|
||||
{
|
||||
value: "stdio",
|
||||
@@ -285,9 +271,12 @@ async function actionAdd(): Promise<void> {
|
||||
let transport: McpTransport | null;
|
||||
let authMode: RemoteAuthMode = "none";
|
||||
if (type === "stdio") {
|
||||
transport = await collectStdioTransport();
|
||||
transport = await collectStdioTransport(defaults?.command);
|
||||
} else {
|
||||
const config = await collectUrlTransport(type as "sse" | "streamableHttp");
|
||||
const config = await collectUrlTransport(
|
||||
type as "sse" | "streamableHttp",
|
||||
defaults?.url,
|
||||
);
|
||||
transport = config?.transport ?? null;
|
||||
authMode = config?.authMode ?? "none";
|
||||
}
|
||||
@@ -441,9 +430,25 @@ async function actionAuthorizeOAuth(): Promise<void> {
|
||||
await authorizeOAuth(name);
|
||||
}
|
||||
|
||||
export async function runMcpWizard(): Promise<number> {
|
||||
export async function runMcpWizard(
|
||||
options: RunMcpWizardOptions = {},
|
||||
): Promise<number> {
|
||||
p.intro("MCP Servers");
|
||||
|
||||
if (options.initialAction === "add") {
|
||||
let initialActionExitCode = 0;
|
||||
try {
|
||||
await actionAdd(options.addDefaults);
|
||||
} catch (err) {
|
||||
initialActionExitCode = 1;
|
||||
p.log.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
if (options.exitAfterInitialAction === true) {
|
||||
p.outro("Done");
|
||||
return initialActionExitCode;
|
||||
}
|
||||
}
|
||||
|
||||
let keepGoing = true;
|
||||
while (keepGoing) {
|
||||
const action = await p.select({
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import * as p from "@clack/prompts";
|
||||
import {
|
||||
authorizeMcpServerOAuth,
|
||||
resolveDefaultMcpSettingsPath,
|
||||
} from "@cline/core";
|
||||
import open from "open";
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
const message = error.message.trim();
|
||||
if (message.length > 0) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export async function authorizeMcpServerOAuthWithBrowser(
|
||||
name: string,
|
||||
options: { throwOnError?: boolean } = {},
|
||||
): Promise<void> {
|
||||
p.log.info("Opening browser for MCP OAuth authorization");
|
||||
try {
|
||||
const result = await authorizeMcpServerOAuth({
|
||||
serverName: name,
|
||||
filePath: resolveDefaultMcpSettingsPath(),
|
||||
openUrl: async (url) => {
|
||||
p.log.message(`Authorization URL: ${url}`);
|
||||
await open(url, { wait: false });
|
||||
},
|
||||
onServerListening: (info) => {
|
||||
p.log.message(`Waiting for OAuth callback at ${info.callbackUrl}`);
|
||||
},
|
||||
});
|
||||
p.log.success(result.message);
|
||||
} catch (error) {
|
||||
if (options.throwOnError === true) {
|
||||
throw error instanceof Error ? error : new Error(toErrorMessage(error));
|
||||
}
|
||||
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
|
||||
p.log.warn(
|
||||
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"dev": "bun run src/dev.ts",
|
||||
"start": "bun run src/server.ts",
|
||||
"smoke:options": "bun run src/validate-options.ts",
|
||||
"test": "bunx vitest run --config vitest.config.ts",
|
||||
"typecheck": "bun tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
syncHubClientsAndSessions,
|
||||
syncHubHealth,
|
||||
} from "./server/hub";
|
||||
import { fetchMarketplaceCatalog } from "./server/marketplace";
|
||||
import {
|
||||
loadModels,
|
||||
runProviderOAuthLogin,
|
||||
@@ -99,6 +100,21 @@ export async function startClineHubDashboardServer(): Promise<ClineHubDashboardS
|
||||
if (url.pathname === "/config.json") {
|
||||
return createJsonResponse(browserConfig);
|
||||
}
|
||||
if (url.pathname === "/api/marketplace/catalog") {
|
||||
try {
|
||||
return createJsonResponse(await fetchMarketplaceCatalog());
|
||||
} catch (error) {
|
||||
return createJsonResponse(
|
||||
{
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to fetch marketplace catalog",
|
||||
},
|
||||
502,
|
||||
);
|
||||
}
|
||||
}
|
||||
return assets.serve(url.pathname);
|
||||
},
|
||||
websocket: {
|
||||
|
||||
@@ -68,6 +68,8 @@ async function runCliConnectCommand(args: string[]): Promise<{
|
||||
CLINE_BUILD_ENV: process.env.CLINE_BUILD_ENV ?? "development",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
// Prevent a console window from flashing on Windows.
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
let stdout = "";
|
||||
|
||||
@@ -4,15 +4,20 @@ import {
|
||||
ClineAccountService,
|
||||
ensureCustomProvidersLoaded,
|
||||
executeClineAccountAction,
|
||||
formatProviderOAuthApiKey,
|
||||
getLocalProviderModels,
|
||||
getPersistedProviderApiKey,
|
||||
getProviderOAuthCredentialsFromSettings,
|
||||
getValidClineCredentials,
|
||||
listLocalProviders,
|
||||
loginAndSaveLocalProviderOAuthCredentials,
|
||||
normalizeOAuthProvider,
|
||||
type ProviderCapability,
|
||||
type ProviderClient,
|
||||
type ProviderProtocol,
|
||||
type ProviderSettings,
|
||||
readGlobalSettings,
|
||||
resolveLocalClineAuthToken,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
saveLocalProviderSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setDisabledPlugin,
|
||||
@@ -27,6 +32,12 @@ import {
|
||||
stopConnectorChannel,
|
||||
} from "./connectors";
|
||||
import { providerSettingsManager, workspaceRoot } from "./deps";
|
||||
import {
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
uninstallLocalPrimitive,
|
||||
uninstallMarketplaceEntryForDesktopCommand,
|
||||
} from "./marketplace";
|
||||
import {
|
||||
deleteMcpServer,
|
||||
ensureMcpSettingsFile,
|
||||
@@ -52,6 +63,39 @@ const ROUTINE_SCHEDULE_COMMANDS = new Set([
|
||||
"delete_routine_schedule",
|
||||
]);
|
||||
|
||||
async function resolveHubClineAccountAuthToken(input: {
|
||||
settings?: ProviderSettings;
|
||||
apiBaseUrl: string;
|
||||
}): Promise<string | undefined> {
|
||||
const credentials = input.settings
|
||||
? getProviderOAuthCredentialsFromSettings("cline", input.settings)
|
||||
: null;
|
||||
if (!credentials || !input.settings) {
|
||||
return getPersistedProviderApiKey("cline", input.settings);
|
||||
}
|
||||
|
||||
const nextCredentials = await getValidClineCredentials(credentials, {
|
||||
apiBaseUrl: input.apiBaseUrl,
|
||||
});
|
||||
if (!nextCredentials) {
|
||||
throw new Error(
|
||||
"Cline account requires re-authentication. Run cline auth cline.",
|
||||
);
|
||||
}
|
||||
|
||||
if (nextCredentials !== credentials) {
|
||||
saveLocalProviderOAuthCredentials(
|
||||
providerSettingsManager,
|
||||
"cline",
|
||||
input.settings,
|
||||
nextCredentials,
|
||||
{ setLastUsed: false },
|
||||
);
|
||||
}
|
||||
|
||||
return formatProviderOAuthApiKey("cline", nextCredentials);
|
||||
}
|
||||
|
||||
export async function handleDesktopCommand(
|
||||
ctx: HubContext,
|
||||
command: string,
|
||||
@@ -128,10 +172,18 @@ export async function handleDesktopCommand(
|
||||
}
|
||||
if (command === "cline_account") {
|
||||
const settings = providerSettingsManager.getProviderSettings("cline");
|
||||
const apiBaseUrl =
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl;
|
||||
const authToken = await resolveHubClineAccountAuthToken({
|
||||
settings,
|
||||
apiBaseUrl,
|
||||
});
|
||||
if (!authToken) {
|
||||
throw new Error("No Cline account auth token found");
|
||||
}
|
||||
const accountService = new ClineAccountService({
|
||||
apiBaseUrl:
|
||||
settings?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
getAuthToken: async () => resolveLocalClineAuthToken(settings),
|
||||
apiBaseUrl,
|
||||
getAuthToken: async () => authToken,
|
||||
});
|
||||
return await executeClineAccountAction(
|
||||
args as ClineAccountActionRequest,
|
||||
@@ -213,6 +265,27 @@ export async function handleDesktopCommand(
|
||||
if (command === "list_user_instruction_configs") {
|
||||
return await listUserInstructionConfigs(workspaceRoot);
|
||||
}
|
||||
if (command === "list_marketplace_installed_entries") {
|
||||
return listMarketplaceInstalledEntries(
|
||||
args,
|
||||
await listUserInstructionConfigs(workspaceRoot),
|
||||
);
|
||||
}
|
||||
if (command === "install_marketplace_entry") {
|
||||
const result = await installMarketplaceEntryForDesktopCommand(args);
|
||||
broadcastHubState(ctx);
|
||||
return result;
|
||||
}
|
||||
if (command === "uninstall_marketplace_entry") {
|
||||
const result = await uninstallMarketplaceEntryForDesktopCommand(args);
|
||||
broadcastHubState(ctx);
|
||||
return result;
|
||||
}
|
||||
if (command === "uninstall_local_primitive") {
|
||||
const result = await uninstallLocalPrimitive(args, { workspaceRoot });
|
||||
broadcastHubState(ctx);
|
||||
return result;
|
||||
}
|
||||
if (command === "toggle_disabled_plugin_tool") {
|
||||
const toolName = String(args?.name ?? "").trim();
|
||||
if (!toolName) throw new Error("tool name is required");
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isWebviewRoute, normalizeWebviewIndexHtml } from "./http";
|
||||
|
||||
describe("isWebviewRoute", () => {
|
||||
it.each([
|
||||
"/",
|
||||
"/chat",
|
||||
"/sessions",
|
||||
"/models",
|
||||
"/customizations",
|
||||
"/rules",
|
||||
"/hooks",
|
||||
"/mcp",
|
||||
"/plugins",
|
||||
"/skills",
|
||||
"/agents",
|
||||
"/tools",
|
||||
"/marketplace",
|
||||
"/marketplace/mcp",
|
||||
"/marketplace/skills",
|
||||
"/marketplace/plugins",
|
||||
"/channels",
|
||||
"/schedules",
|
||||
"/settings",
|
||||
"/settings/providers",
|
||||
])("matches dashboard SPA route %s", (pathname) => {
|
||||
expect(isWebviewRoute(pathname)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat nested marketplace asset requests as SPA routes", () => {
|
||||
expect(isWebviewRoute("/marketplace/assets/index.js")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeWebviewIndexHtml", () => {
|
||||
it("rewrites relative built asset URLs so deep links can refresh", () => {
|
||||
expect(
|
||||
normalizeWebviewIndexHtml(
|
||||
'<script type="module" src="./assets/index.js"></script><link href="./assets/index.css">',
|
||||
),
|
||||
).toBe(
|
||||
'<script type="module" src="/assets/index.js"></script><link href="/assets/index.css">',
|
||||
);
|
||||
});
|
||||
|
||||
it("injects the persisted theme bootstrap once", () => {
|
||||
const normalized = normalizeWebviewIndexHtml(
|
||||
"<html><head></head><body></body></html>",
|
||||
);
|
||||
|
||||
expect(normalized).toContain('id="cline-hub-theme-bootstrap"');
|
||||
expect(normalizeWebviewIndexHtml(normalized)).toBe(normalized);
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,25 @@ export function createTextResponse(text: string, status = 200): Response {
|
||||
});
|
||||
}
|
||||
|
||||
const NO_STORE_HEADERS = {
|
||||
"cache-control": "no-store, no-cache, must-revalidate, proxy-revalidate",
|
||||
pragma: "no-cache",
|
||||
expires: "0",
|
||||
};
|
||||
|
||||
const IMMUTABLE_ASSET_CACHE = "public, max-age=31536000, immutable";
|
||||
const THEME_BOOTSTRAP_SCRIPT = `<script id="cline-hub-theme-bootstrap">
|
||||
(() => {
|
||||
try {
|
||||
const theme = window.localStorage.getItem("cline-hub-theme");
|
||||
if (theme === "dark" || theme === "light") {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
document.documentElement.dataset.clineHubTheme = theme;
|
||||
}
|
||||
} catch {}
|
||||
})();
|
||||
</script>`;
|
||||
|
||||
function contentTypeFor(path: string): string {
|
||||
switch (extname(path)) {
|
||||
case ".html":
|
||||
@@ -36,20 +55,47 @@ function contentTypeFor(path: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
function isWebviewRoute(pathname: string): boolean {
|
||||
export function isWebviewRoute(pathname: string): boolean {
|
||||
return (
|
||||
pathname === "/" ||
|
||||
pathname === "/index.html" ||
|
||||
pathname === "/chat" ||
|
||||
pathname === "/sessions" ||
|
||||
pathname === "/models" ||
|
||||
pathname === "/customizations" ||
|
||||
pathname === "/rules" ||
|
||||
pathname === "/hooks" ||
|
||||
pathname === "/mcp" ||
|
||||
pathname === "/plugins" ||
|
||||
pathname === "/skills" ||
|
||||
pathname === "/agents" ||
|
||||
pathname === "/tools" ||
|
||||
pathname === "/marketplace" ||
|
||||
pathname === "/marketplace/mcp" ||
|
||||
pathname === "/marketplace/skills" ||
|
||||
pathname === "/marketplace/plugins" ||
|
||||
pathname === "/channels" ||
|
||||
pathname === "/schedules" ||
|
||||
pathname === "/settings" ||
|
||||
pathname.startsWith("/settings/")
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeWebviewIndexHtml(html: string): string {
|
||||
const normalized = html
|
||||
.replaceAll('src="./', 'src="/')
|
||||
.replaceAll('href="./', 'href="/');
|
||||
if (normalized.includes('id="cline-hub-theme-bootstrap"')) {
|
||||
return normalized;
|
||||
}
|
||||
return normalized.replace("<head>", `<head>\n${THEME_BOOTSTRAP_SCRIPT}`);
|
||||
}
|
||||
|
||||
function renderDevIndexHtml(devServerUrl: string): string {
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
${THEME_BOOTSTRAP_SCRIPT}
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<script type="module">
|
||||
@@ -60,7 +106,7 @@ function renderDevIndexHtml(devServerUrl: string): string {
|
||||
window.__vite_plugin_react_preamble_installed__ = true;
|
||||
</script>
|
||||
<script type="module" src="${devServerUrl}/@vite/client"></script>
|
||||
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/favicon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="${devServerUrl}/cline-logo-filled.svg" />
|
||||
<title>Cline Hub</title>
|
||||
</head>
|
||||
<body>
|
||||
@@ -74,6 +120,14 @@ function renderDevIndexHtml(devServerUrl: string): string {
|
||||
export class WebviewAssets {
|
||||
constructor(private readonly webviewDistDir: string) {}
|
||||
|
||||
private async resolveCurrentMainAssetPath(): Promise<string | undefined> {
|
||||
const indexFile = Bun.file(join(this.webviewDistDir, "index.html"));
|
||||
if (!(await indexFile.exists())) return undefined;
|
||||
const html = await indexFile.text();
|
||||
const match = html.match(/src="\.\/(assets\/index-[^"]+\.js)"/);
|
||||
return match?.[1] ? join(this.webviewDistDir, match[1]) : undefined;
|
||||
}
|
||||
|
||||
private resolveStaticPath(pathname: string): string | undefined {
|
||||
const decoded = decodeURIComponent(pathname);
|
||||
const requested = decoded === "/" ? "/index.html" : decoded;
|
||||
@@ -89,8 +143,11 @@ export class WebviewAssets {
|
||||
private async serveIndex(): Promise<Response> {
|
||||
const indexFile = Bun.file(join(this.webviewDistDir, "index.html"));
|
||||
if (await indexFile.exists()) {
|
||||
return new Response(indexFile, {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
return new Response(normalizeWebviewIndexHtml(await indexFile.text()), {
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
...NO_STORE_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
return createTextResponse(
|
||||
@@ -103,7 +160,10 @@ export class WebviewAssets {
|
||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
|
||||
if (devServerUrl && isWebviewRoute(pathname)) {
|
||||
return new Response(renderDevIndexHtml(devServerUrl), {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
headers: {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
...NO_STORE_HEADERS,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (isWebviewRoute(pathname)) {
|
||||
@@ -112,12 +172,37 @@ export class WebviewAssets {
|
||||
|
||||
const filePath = this.resolveStaticPath(pathname);
|
||||
if (!filePath) return createTextResponse("not found", 404);
|
||||
const file = Bun.file(filePath);
|
||||
let responsePath = filePath;
|
||||
let file = Bun.file(responsePath);
|
||||
if (
|
||||
!(await file.exists()) &&
|
||||
/^\/assets\/index-[A-Za-z0-9_-]+\.js$/.test(pathname)
|
||||
) {
|
||||
const currentMainAssetPath = await this.resolveCurrentMainAssetPath();
|
||||
if (currentMainAssetPath) {
|
||||
responsePath = currentMainAssetPath;
|
||||
file = Bun.file(responsePath);
|
||||
}
|
||||
}
|
||||
if (!(await file.exists())) {
|
||||
return createTextResponse("not found", 404);
|
||||
}
|
||||
const isHashedAsset = /^\/assets\/.+-[A-Za-z0-9_-]+\.[A-Za-z0-9]+$/.test(
|
||||
pathname,
|
||||
);
|
||||
return new Response(file, {
|
||||
headers: { "content-type": contentTypeFor(filePath) },
|
||||
headers: {
|
||||
"content-type": contentTypeFor(responsePath),
|
||||
"cache-control": isHashedAsset
|
||||
? IMMUTABLE_ASSET_CACHE
|
||||
: NO_STORE_HEADERS["cache-control"],
|
||||
...(isHashedAsset
|
||||
? {}
|
||||
: {
|
||||
pragma: NO_STORE_HEADERS.pragma,
|
||||
expires: NO_STORE_HEADERS.expires,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,870 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildMarketplaceMcpInput,
|
||||
fetchMarketplaceCatalog,
|
||||
installMarketplaceEntry,
|
||||
installMarketplaceEntryForDesktopCommand,
|
||||
listMarketplaceInstalledEntries,
|
||||
uninstallLocalPrimitive,
|
||||
uninstallMarketplaceEntry,
|
||||
uninstallMarketplaceEntryForDesktopCommand,
|
||||
} from "./marketplace";
|
||||
|
||||
describe("marketplace installer", () => {
|
||||
const originalWrapperPath = process.env.CLINE_WRAPPER_PATH;
|
||||
const originalClineDir = process.env.CLINE_DIR;
|
||||
const originalHome = process.env.HOME;
|
||||
const originalMcpSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWrapperPath === undefined) {
|
||||
delete process.env.CLINE_WRAPPER_PATH;
|
||||
} else {
|
||||
process.env.CLINE_WRAPPER_PATH = originalWrapperPath;
|
||||
}
|
||||
if (originalClineDir === undefined) {
|
||||
delete process.env.CLINE_DIR;
|
||||
} else {
|
||||
process.env.CLINE_DIR = originalClineDir;
|
||||
}
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
if (originalMcpSettingsPath === undefined) {
|
||||
delete process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
} else {
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = originalMcpSettingsPath;
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("maps remote MCP catalog args to the hub MCP upsert shape", () => {
|
||||
expect(
|
||||
buildMarketplaceMcpInput([
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
]),
|
||||
).toEqual({
|
||||
name: "context7",
|
||||
transportType: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps stdio MCP catalog args to command and args", () => {
|
||||
expect(
|
||||
buildMarketplaceMcpInput(["filesystem", "npx", "-y", "server", "/tmp"]),
|
||||
).toEqual({
|
||||
name: "filesystem",
|
||||
transportType: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "server", "/tmp"],
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves server flags after stdio MCP command args begin", () => {
|
||||
expect(
|
||||
buildMarketplaceMcpInput([
|
||||
"search",
|
||||
"npx",
|
||||
"-y",
|
||||
"server",
|
||||
"--transport",
|
||||
"stdio",
|
||||
]),
|
||||
).toEqual({
|
||||
name: "search",
|
||||
transportType: "stdio",
|
||||
command: "npx",
|
||||
args: ["-y", "server", "--transport", "stdio"],
|
||||
disabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("runs skills globally for Cline without prompts", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const spawnCommand = vi.fn(async () => {
|
||||
mkdirSync(join(homeDir, ".agents", "skills", "web-design-guidelines"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(homeDir, ".agents", "skills", "web-design-guidelines", "SKILL.md"),
|
||||
"---\nname: web-design-guidelines\n---\n",
|
||||
);
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "installed",
|
||||
stderr: "",
|
||||
};
|
||||
});
|
||||
|
||||
await installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "web-design-guidelines",
|
||||
type: "skill",
|
||||
name: "Web Design Guidelines",
|
||||
install: {
|
||||
args: [
|
||||
"vercel-labs/agent-skills",
|
||||
"--skill",
|
||||
"web-design-guidelines",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
);
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"add",
|
||||
"vercel-labs/agent-skills",
|
||||
"--skill",
|
||||
"web-design-guidelines",
|
||||
"-g",
|
||||
"-a",
|
||||
"cline",
|
||||
"-y",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips skill install commands when the global skill already exists", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
mkdirSync(join(homeDir, ".agents", "skills", "cline-sdk"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(homeDir, ".agents", "skills", "cline-sdk", "SKILL.md"),
|
||||
"---\nname: cline-sdk\n---\n",
|
||||
);
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "installed",
|
||||
message: "Cline SDK is already installed.",
|
||||
});
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports Cline global skills as marketplace-installed", () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-cline-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
mkdirSync(join(clineDir, "skills", "cline-sdk"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(clineDir, "skills", "cline-sdk", "SKILL.md"),
|
||||
"---\nname: cline-sdk\n---\n",
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ installedKeys: ["skill:cline-sdk"] });
|
||||
});
|
||||
|
||||
it("accepts skill installs that create Cline global skills", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
const clineDir = join(homeDir, ".cline");
|
||||
process.env.HOME = homeDir;
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const spawnCommand = vi.fn(async () => {
|
||||
mkdirSync(join(clineDir, "skills", "cline-sdk"), {
|
||||
recursive: true,
|
||||
});
|
||||
writeFileSync(
|
||||
join(clineDir, "skills", "cline-sdk", "SKILL.md"),
|
||||
"---\nname: cline-sdk\n---\n",
|
||||
);
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "installed",
|
||||
stderr: "",
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "installed",
|
||||
message: "Installed Cline SDK globally for Cline.",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes Cline global marketplace skills without prompts", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const skillDir = join(homeDir, ".agents", "skills", "cline-sdk");
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(join(skillDir, "SKILL.md"), "---\nname: cline-sdk\n---\n");
|
||||
const spawnCommand = vi.fn(async () => {
|
||||
rmSync(skillDir, { recursive: true, force: true });
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: "removed",
|
||||
stderr: "",
|
||||
};
|
||||
});
|
||||
|
||||
await expect(
|
||||
uninstallMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Cline SDK.",
|
||||
});
|
||||
expect(spawnCommand).toHaveBeenCalledWith("npx", [
|
||||
"-y",
|
||||
"skills@latest",
|
||||
"remove",
|
||||
"cline-sdk",
|
||||
"-g",
|
||||
"-a",
|
||||
"cline",
|
||||
"-y",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not report project-local skills as marketplace-installed globals", () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries(
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
skills: [
|
||||
{
|
||||
id: "cline-sdk",
|
||||
name: "cline-sdk",
|
||||
path: "/workspace/project/.agents/skills/cline-sdk/SKILL.md",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
).toEqual({ installedKeys: [] });
|
||||
});
|
||||
|
||||
it("rejects skill installs that exit zero but report failure", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "Failed to install 1",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).rejects.toThrow("Skill install failed");
|
||||
});
|
||||
|
||||
it("redacts common secret formats from failed install output", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 1,
|
||||
stdout:
|
||||
"Authorization: Bearer stdout-token\napi key stdout-key\nOPENAI_API_KEY=compound-key",
|
||||
stderr:
|
||||
"TOKEN=stderr-token\npassword is stderr-password\nANTHROPIC_SECRET_KEY=anthropic-secret",
|
||||
}));
|
||||
|
||||
let message = "";
|
||||
try {
|
||||
await installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
);
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
expect(message).toContain("Authorization: [redacted]");
|
||||
expect(message).toContain("api key [redacted]");
|
||||
expect(message).toContain("OPENAI_API_KEY=[redacted]");
|
||||
expect(message).toContain("TOKEN=[redacted]");
|
||||
expect(message).toContain("password is [redacted]");
|
||||
expect(message).toContain("ANTHROPIC_SECRET_KEY=[redacted]");
|
||||
expect(message).not.toContain("stdout-token");
|
||||
expect(message).not.toContain("stdout-key");
|
||||
expect(message).not.toContain("compound-key");
|
||||
expect(message).not.toContain("stderr-token");
|
||||
expect(message).not.toContain("stderr-password");
|
||||
expect(message).not.toContain("anthropic-secret");
|
||||
});
|
||||
|
||||
it("rejects skill installs before spawning when the global skill directory is not writable", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
mkdirSync(join(homeDir, ".agents"), { recursive: true });
|
||||
writeFileSync(join(homeDir, ".agents", "skills"), "");
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).rejects.toThrow(
|
||||
"Cannot install skill globally because ~/.agents/skills is not writable",
|
||||
);
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects skill installs that do not create a global skill", async () => {
|
||||
const homeDir = mkdtempSync(join(tmpdir(), "cline-marketplace-home-"));
|
||||
process.env.HOME = homeDir;
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "Installation complete",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "cline-sdk",
|
||||
type: "skill",
|
||||
name: "Cline SDK",
|
||||
install: { args: ["cline/sdk-skill"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).rejects.toThrow("was not found in Cline's global skills directories");
|
||||
});
|
||||
|
||||
it("runs official plugin installs through the current Cline CLI", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({ installPath: "/tmp/plugin" }),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "marketplace-test-plugin",
|
||||
type: "plugin",
|
||||
name: "Marketplace Test Plugin",
|
||||
install: { args: ["marketplace-test-plugin"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
);
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"plugin",
|
||||
"install",
|
||||
"marketplace-test-plugin",
|
||||
"--json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("runs official plugin uninstalls through the current Cline CLI", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({
|
||||
name: "goal",
|
||||
installPath: "/tmp/plugin",
|
||||
removedPaths: ["/tmp/plugin"],
|
||||
entryPaths: [],
|
||||
}),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
uninstallMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Goal.",
|
||||
});
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"plugin",
|
||||
"uninstall",
|
||||
"goal",
|
||||
"--json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves desktop installs from the server catalog instead of browser-sent args", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({ installPath: "/tmp/plugin" }),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await installMarketplaceEntryForDesktopCommand(
|
||||
{
|
||||
entry: {
|
||||
id: "marketplace-test-plugin",
|
||||
type: "plugin",
|
||||
name: "Tampered",
|
||||
install: { args: ["malicious-source"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
spawnCommand,
|
||||
loadCatalog: async () => ({
|
||||
entries: [
|
||||
{
|
||||
id: "marketplace-test-plugin",
|
||||
type: "plugin",
|
||||
name: "Marketplace Test Plugin",
|
||||
install: { args: ["marketplace-test-plugin"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"plugin",
|
||||
"install",
|
||||
"marketplace-test-plugin",
|
||||
"--json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves desktop uninstalls from the server catalog instead of browser-sent args", async () => {
|
||||
process.env.CLINE_WRAPPER_PATH = "/usr/local/bin/cline";
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: JSON.stringify({
|
||||
name: "goal",
|
||||
installPath: "/tmp/plugin",
|
||||
removedPaths: ["/tmp/plugin"],
|
||||
entryPaths: [],
|
||||
}),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await uninstallMarketplaceEntryForDesktopCommand(
|
||||
{
|
||||
entry: {
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Tampered",
|
||||
install: { args: ["malicious-source"] },
|
||||
},
|
||||
},
|
||||
{
|
||||
spawnCommand,
|
||||
loadCatalog: async () => ({
|
||||
entries: [
|
||||
{
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(spawnCommand).toHaveBeenCalledWith("/usr/local/bin/cline", [
|
||||
"plugin",
|
||||
"uninstall",
|
||||
"goal",
|
||||
"--json",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uninstalls MCP marketplace entries from Cline MCP settings", async () => {
|
||||
const settingsPath = join(
|
||||
mkdtempSync(join(tmpdir(), "cline-marketplace-mcp-")),
|
||||
"cline_mcp_settings.json",
|
||||
);
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
context7: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
uninstallMarketplaceEntry({
|
||||
entry: {
|
||||
id: "context7",
|
||||
type: "mcp",
|
||||
name: "Context7",
|
||||
install: {
|
||||
args: [
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Context7.",
|
||||
});
|
||||
expect(
|
||||
listMarketplaceInstalledEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "context7",
|
||||
type: "mcp",
|
||||
name: "Context7",
|
||||
install: {
|
||||
args: [
|
||||
"context7",
|
||||
"--transport",
|
||||
"http",
|
||||
"https://mcp.context7.com/mcp",
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ installedKeys: [] });
|
||||
});
|
||||
|
||||
it("uninstalls local MCP servers by name", async () => {
|
||||
const settingsPath = join(
|
||||
mkdtempSync(join(tmpdir(), "cline-local-mcp-")),
|
||||
"cline_mcp_settings.json",
|
||||
);
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = settingsPath;
|
||||
writeFileSync(
|
||||
settingsPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
context7: {
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
uninstallLocalPrimitive({
|
||||
type: "mcp",
|
||||
id: "context7",
|
||||
name: "context7",
|
||||
}),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled context7.",
|
||||
});
|
||||
expect(readFileSync(settingsPath, "utf8")).not.toContain("context7");
|
||||
});
|
||||
|
||||
it("uninstalls local skills by removing their configured skill directory", async () => {
|
||||
const workspaceRoot = mkdtempSync(join(tmpdir(), "cline-local-skill-"));
|
||||
const skillDir = join(workspaceRoot, ".cline", "skills", "review");
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
const skillPath = join(skillDir, "SKILL.md");
|
||||
writeFileSync(skillPath, "---\nname: review\n---\nReview changes.");
|
||||
|
||||
await expect(
|
||||
uninstallLocalPrimitive(
|
||||
{
|
||||
type: "skill",
|
||||
id: "review",
|
||||
name: "Review",
|
||||
path: skillPath,
|
||||
},
|
||||
{ workspaceRoot },
|
||||
),
|
||||
).resolves.toMatchObject({
|
||||
status: "uninstalled",
|
||||
message: "Uninstalled Review.",
|
||||
});
|
||||
expect(existsSync(skillDir)).toBe(false);
|
||||
});
|
||||
|
||||
it("reports official plugin marketplace entries installed from Cline home", () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const sourceKey =
|
||||
"official:https://github.com/cline/plugins.git#plugins/goal";
|
||||
const hash = createHash("sha256")
|
||||
.update(sourceKey)
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
mkdirSync(
|
||||
join(clineDir, "plugins", "_installed", "official", `goal-${hash}`),
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ installedKeys: ["plugin:goal"] });
|
||||
});
|
||||
|
||||
it("does not report plugin inventory substring matches as installed", () => {
|
||||
process.env.CLINE_DIR = mkdtempSync(
|
||||
join(tmpdir(), "cline-marketplace-test-"),
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries(
|
||||
{
|
||||
entries: [
|
||||
{
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
plugins: [
|
||||
{
|
||||
name: "goal-helper",
|
||||
path: "/workspace/.cline/plugins/goal-helper/index.ts",
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
).toEqual({ installedKeys: [] });
|
||||
});
|
||||
|
||||
it("skips invalid marketplace entries during installed-status checks", () => {
|
||||
const clineDir = mkdtempSync(join(tmpdir(), "cline-marketplace-test-"));
|
||||
process.env.CLINE_DIR = clineDir;
|
||||
const sourceKey =
|
||||
"official:https://github.com/cline/plugins.git#plugins/goal";
|
||||
const hash = createHash("sha256")
|
||||
.update(sourceKey)
|
||||
.digest("hex")
|
||||
.slice(0, 12);
|
||||
mkdirSync(
|
||||
join(clineDir, "plugins", "_installed", "official", `goal-${hash}`),
|
||||
{
|
||||
recursive: true,
|
||||
},
|
||||
);
|
||||
|
||||
expect(
|
||||
listMarketplaceInstalledEntries({
|
||||
entries: [
|
||||
{
|
||||
id: "broken-mcp",
|
||||
type: "mcp",
|
||||
name: "Broken MCP",
|
||||
install: {
|
||||
args: [
|
||||
"broken-mcp",
|
||||
"--transport",
|
||||
"ws",
|
||||
"https://example.com/mcp",
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "goal",
|
||||
type: "plugin",
|
||||
name: "Goal",
|
||||
install: { args: ["goal"] },
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({ installedKeys: ["plugin:goal"] });
|
||||
});
|
||||
|
||||
it("rejects invalid marketplace entries before spawning commands", async () => {
|
||||
const spawnCommand = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await expect(
|
||||
installMarketplaceEntry(
|
||||
{
|
||||
entry: {
|
||||
id: "bad",
|
||||
type: "skill",
|
||||
install: { args: [] },
|
||||
},
|
||||
},
|
||||
{ spawnCommand },
|
||||
),
|
||||
).rejects.toThrow("marketplace install args are required");
|
||||
expect(spawnCommand).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fetches the marketplace catalog through the server helper", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response(JSON.stringify({ version: 1, entries: [] }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
await expect(fetchMarketplaceCatalog(fetchImpl)).resolves.toEqual({
|
||||
version: 1,
|
||||
entries: [],
|
||||
});
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
"https://cline.github.io/marketplace/catalog.json",
|
||||
{ headers: { Accept: "application/json" } },
|
||||
);
|
||||
});
|
||||
|
||||
it("surfaces marketplace catalog upstream failures", async () => {
|
||||
const fetchImpl = vi.fn(async () => {
|
||||
return new Response("nope", {
|
||||
status: 503,
|
||||
statusText: "Service Unavailable",
|
||||
});
|
||||
});
|
||||
|
||||
await expect(fetchMarketplaceCatalog(fetchImpl)).rejects.toThrow(
|
||||
"Failed to fetch marketplace catalog: 503 Service Unavailable",
|
||||
);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user