mirror of
https://github.com/cline/cline.git
synced 2026-09-11 16:42:40 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fb7619a18 | ||
|
|
f81afb51b5 | ||
|
|
864419d8b0 | ||
|
|
218db38544 | ||
|
|
a3b3295461 | ||
|
|
d09270940f | ||
|
|
23c9bf1c05 | ||
|
|
52531d935a | ||
|
|
19d4248381 | ||
|
|
b2b29678a2 | ||
|
|
ad30714608 | ||
|
|
9eebc44f24 | ||
|
|
6ddf48d227 | ||
|
|
ee59f81706 | ||
|
|
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 |
@@ -1,5 +1,25 @@
|
||||
# Changelog
|
||||
|
||||
## [4.0.1]
|
||||
|
||||
### Changed
|
||||
|
||||
- Roll the stable VS Code extension back to the pre-SDK-migration codebase to resolve regressions reported in 4.0.0. This release ships the 3.89.2 extension code under a higher version number so existing 4.0.0 users receive the update. SDK-migration work continues separately on `main`.
|
||||
|
||||
## [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,58 @@
|
||||
# Cline CLI Changelog
|
||||
|
||||
## 3.0.29
|
||||
|
||||
- Costs are now hidden for Cline free models
|
||||
- Fixed Z.ai model metadata resolution for Z.ai models accessed through the Cline provider
|
||||
- Reverted the model-name-only display change from v3.0.28; the model picker, selector, and status bar return to their previous display behavior
|
||||
|
||||
## 3.0.28
|
||||
|
||||
- Added a ClinePass onboarding flow with selectable ClinePass models, plus improved ClinePass error handling
|
||||
- Added hub primitive catalogs and refreshed the hub dashboard design with a dedicated customizations breakout
|
||||
- Auto-approve toggles now apply immediately when changed
|
||||
- Feature flags now resolve using your user ID on startup
|
||||
- Fixed Cline model display names so they resolve by model name
|
||||
- Truncate large tool results by default (including MCP and custom tool output) to keep requests within context budget
|
||||
- Hardened parallel tool-call guidance for faster, more reliable multi-tool execution
|
||||
|
||||
## 3.0.27
|
||||
|
||||
- Added a `cline skill` command to install and manage skills, matching `cline plugin install` and `cline mcp` (installs default to the Cline agent directory)
|
||||
- Added a prefilled MCP install wizard command for quicker MCP server setup
|
||||
- Improved error handling and messaging when plugin MCP OAuth authorization fails
|
||||
- The CLI now rejects unknown commands and unquoted multi-word input with a clear error instead of silently treating bad arguments as a prompt
|
||||
|
||||
## 3.0.26
|
||||
|
||||
- Reverted the expandable model picker sections and ClinePass models, restoring the previous model-selection UI
|
||||
|
||||
## 3.0.25
|
||||
|
||||
- Added ClinePass support, with selectable ClinePass models in the model picker
|
||||
- Made model picker sections expandable
|
||||
- Added MCP server support to plugins, including authorizing plugin MCP OAuth during install
|
||||
- Encouraged parallel tool calls for faster task execution
|
||||
- Capped tool output for bash commands and file reads to keep large output within context limits
|
||||
- Allowed ranged reads on large files
|
||||
- Fixed apply_patch to fail when a hunk is skipped
|
||||
- Fixed run_commands to return captured stdout on failure and handle split heredocs
|
||||
- Fixed search tools to treat zero results as success
|
||||
- Fixed disabled-reasoning handling for StepFun flash
|
||||
- Fixed history resume rendering isolation
|
||||
- Fixed the Hugging Face URL
|
||||
- Fixed Cline OAuth token formatting in provider config
|
||||
|
||||
## 3.0.24
|
||||
|
||||
- Plugin commands can now submit prompts to the agent
|
||||
- Added support for overriding the API base URL
|
||||
- Open the verification URL automatically when starting device authentication
|
||||
- Enforced a single shared Cline Hub, so a stale hub is respawned after an upgrade
|
||||
- Suppressed flickering console windows on Windows
|
||||
- Fixed truncation of structured tool operation result strings so oversized tool output stays within limits
|
||||
- Stopped echoing the full command text in run_commands tool results
|
||||
|
||||
## 3.0.23
|
||||
|
||||
- Fixed Vertex AI GCP settings configuration
|
||||
|
||||
@@ -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.23",
|
||||
"version": "3.0.29",
|
||||
"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 {
|
||||
|
||||
@@ -19,12 +19,8 @@ const {
|
||||
mockReadHubDiscovery,
|
||||
mockProbeHubServer,
|
||||
mockClearHubDiscovery,
|
||||
mockCreateHubServerUrl,
|
||||
mockEnsureDetachedHubServer,
|
||||
mockStopLocalHubServerGracefully,
|
||||
mockStopConnectorsForHubs,
|
||||
mockEnsureFileExists,
|
||||
mockResolveHubEndpointOptions,
|
||||
mockStopAllConnectors,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSpawnSync: vi.fn(),
|
||||
@@ -51,22 +47,8 @@ const {
|
||||
mockReadHubDiscovery: vi.fn(),
|
||||
mockProbeHubServer: vi.fn(),
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockCreateHubServerUrl: vi.fn(
|
||||
(host: string, port: number, pathname: string) =>
|
||||
`ws://${host}:${port}${pathname}`,
|
||||
),
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockStopLocalHubServerGracefully: vi.fn(async () => false),
|
||||
mockStopConnectorsForHubs: vi.fn(async () => ({
|
||||
stoppedProcesses: 0,
|
||||
queuedRestarts: 0,
|
||||
})),
|
||||
mockEnsureFileExists: vi.fn(),
|
||||
mockResolveHubEndpointOptions: vi.fn(() => ({
|
||||
host: "127.0.0.1",
|
||||
port: 25466,
|
||||
pathname: "/hub",
|
||||
})),
|
||||
mockStopAllConnectors: vi.fn(async () => ({
|
||||
stoppedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
@@ -83,11 +65,8 @@ vi.mock("@cline/core", () => ({
|
||||
resolveProductionHubOwnerContext: mockResolveProductionHubOwnerContext,
|
||||
resolveSharedHubOwnerContext: mockResolveSharedHubOwnerContext,
|
||||
clearHubDiscovery: mockClearHubDiscovery,
|
||||
createHubServerUrl: mockCreateHubServerUrl,
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
probeHubServer: mockProbeHubServer,
|
||||
readHubDiscovery: mockReadHubDiscovery,
|
||||
resolveHubEndpointOptions: mockResolveHubEndpointOptions,
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
ensureFileExists: mockEnsureFileExists,
|
||||
}));
|
||||
@@ -96,10 +75,6 @@ vi.mock("../connectors/common", () => ({
|
||||
isProcessRunning: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/restart", () => ({
|
||||
stopConnectorsForHubs: mockStopConnectorsForHubs,
|
||||
}));
|
||||
|
||||
vi.mock("./connect", () => ({
|
||||
stopAllConnectors: mockStopAllConnectors,
|
||||
}));
|
||||
@@ -122,10 +97,6 @@ describe("runDoctorCommand", () => {
|
||||
),
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(false);
|
||||
mockStopConnectorsForHubs.mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
queuedRestarts: 0,
|
||||
});
|
||||
mockStopAllConnectors.mockResolvedValue({
|
||||
stoppedProcesses: 0,
|
||||
stoppedSessions: 0,
|
||||
@@ -302,118 +273,6 @@ describe("runDoctorCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("doctor --fix queues active connectors for restart when killing hubs", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 70001,
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 70001,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mockStopConnectorsForHubs.mockResolvedValue({
|
||||
stoppedProcesses: 2,
|
||||
queuedRestarts: 1,
|
||||
});
|
||||
mockSpawnSync.mockReturnValue({ status: 1, stdout: "" });
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true, fix: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(mockStopConnectorsForHubs).toHaveBeenCalledWith(
|
||||
expect.any(Array),
|
||||
expect.any(Object),
|
||||
{ targetHubUrl: "ws://127.0.0.1:25466/hub" },
|
||||
);
|
||||
// Connectors must be stopped and queued while the hub is still up.
|
||||
expect(mockStopConnectorsForHubs.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockStopLocalHubServerGracefully.mock.invocationCallOrder[0] ?? Infinity,
|
||||
);
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
killed: {
|
||||
connectorProcesses: 2,
|
||||
connectorRestartsQueued: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("doctor --fix kills stale random-port hub daemons", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 70001,
|
||||
});
|
||||
mockProbeHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25466/hub",
|
||||
port: 25466,
|
||||
pid: 70001,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mockSpawnSync.mockImplementation((command: string, args?: string[]) => {
|
||||
if (command === "lsof") {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: "70001\n",
|
||||
};
|
||||
}
|
||||
if (
|
||||
command === "pgrep" &&
|
||||
Array.isArray(args) &&
|
||||
args[0] === "-fal" &&
|
||||
args[1] === "--" &&
|
||||
args[2] === "/sdk/packages/core/src/hub/daemon/entry.ts"
|
||||
) {
|
||||
return {
|
||||
status: 0,
|
||||
stdout: [
|
||||
"70001 /Users/example/.bun/bin/bun /repo/sdk/packages/core/src/hub/daemon/entry.ts --cwd /workspace --host 127.0.0.1 --port 25466 --pathname /hub",
|
||||
"70002 /Users/example/.bun/bin/bun /repo/sdk/packages/core/src/hub/daemon/entry.ts --cwd /workspace --host 127.0.0.1 --port 0 --pathname /hub",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
return { status: 1, stdout: "" };
|
||||
});
|
||||
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
|
||||
|
||||
const output: string[] = [];
|
||||
const code = await runDoctorCommand(
|
||||
{ cwd, json: true, fix: true },
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
);
|
||||
|
||||
expect(code).toBe(0);
|
||||
expect(killSpy).toHaveBeenCalledWith(70002, "SIGKILL");
|
||||
expect(killSpy).not.toHaveBeenCalledWith(70001, "SIGKILL");
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({
|
||||
before: {
|
||||
staleHubPids: [70002],
|
||||
},
|
||||
killed: {
|
||||
staleHubDaemons: 1,
|
||||
},
|
||||
});
|
||||
killSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("doctor --fix kills stale code sidecar processes", async () => {
|
||||
const cwd = "/workspace";
|
||||
mockReadHubDiscovery.mockResolvedValue(undefined);
|
||||
|
||||
@@ -15,13 +15,11 @@ import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import open from "open";
|
||||
import { isProcessRunning } from "../connectors/common";
|
||||
import { stopConnectorsForHubs } from "../connectors/restart";
|
||||
import {
|
||||
type ActiveConnectorRecord,
|
||||
listActiveConnectors,
|
||||
} from "../connectors/status";
|
||||
import { getCliBuildInfo } from "../utils/common";
|
||||
import { resolveDefaultCliHubUrl } from "../utils/hub-runtime";
|
||||
import { c, writeln } from "../utils/output";
|
||||
import { stopAllConnectors } from "./connect";
|
||||
|
||||
@@ -463,14 +461,6 @@ export async function runDoctorCommand(
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Stop and queue connectors while the hub (and the connectors themselves)
|
||||
// are still running, matching the ordering in hub stop and update. A
|
||||
// connector that disappears once the hub is gone can no longer be queued.
|
||||
const restartAwareStoppedConnectors = await stopConnectorsForHubs(
|
||||
before.activeConnectors.map((record) => record.hubUrl),
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
{ targetHubUrl: resolveDefaultCliHubUrl() },
|
||||
);
|
||||
const gracefullyStoppedHub = before.hubHealthy
|
||||
? await stopLocalHubServerGracefully(resolveCliHubOwnerContext()).catch(
|
||||
() => false,
|
||||
@@ -520,10 +510,7 @@ export async function runDoctorCommand(
|
||||
staleHubDaemons: killedStaleHubs,
|
||||
cliProcesses: killedCli,
|
||||
sidecarProcesses: killedSidecars,
|
||||
connectorProcesses:
|
||||
stoppedConnectors.stoppedProcesses +
|
||||
restartAwareStoppedConnectors.stoppedProcesses,
|
||||
connectorRestartsQueued: restartAwareStoppedConnectors.queuedRestarts,
|
||||
connectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
connectorSessions: stoppedConnectors.stoppedSessions,
|
||||
hubStartupLocks: clearedArtifacts.startupLocks,
|
||||
hubDiscovery: clearedArtifacts.discovery,
|
||||
@@ -537,16 +524,8 @@ export async function runDoctorCommand(
|
||||
writeln(`killed cli processes ${c.dim}${killedCli}${c.reset}`);
|
||||
writeln(`killed sidecar processes ${c.dim}${killedSidecars}${c.reset}`);
|
||||
writeln(
|
||||
`stopped connector processes ${c.dim}${stoppedConnectors.stoppedProcesses + restartAwareStoppedConnectors.stoppedProcesses}${c.reset}`,
|
||||
`stopped connector processes ${c.dim}${stoppedConnectors.stoppedProcesses}${c.reset}`,
|
||||
);
|
||||
writeln(
|
||||
`queued connector restarts ${c.dim}${restartAwareStoppedConnectors.queuedRestarts}${c.reset}`,
|
||||
);
|
||||
if (restartAwareStoppedConnectors.queuedRestarts > 0) {
|
||||
writeln(
|
||||
`${c.dim}queued connectors relaunch automatically the next time the hub starts (any cline command, or 'cline hub start')${c.reset}`,
|
||||
);
|
||||
}
|
||||
writeln(
|
||||
`stopped connector sessions ${c.dim}${stoppedConnectors.stoppedSessions}${c.reset}`,
|
||||
);
|
||||
|
||||
@@ -7,9 +7,7 @@ const {
|
||||
mockReadHubDiscovery,
|
||||
mockResolveProductionHubOwnerContext,
|
||||
mockResolveSharedHubOwnerContext,
|
||||
mockRestartQueuedConnectorsForHub,
|
||||
mockStopLocalHubServerGracefully,
|
||||
mockStopConnectorsForHubs,
|
||||
} = vi.hoisted(() => ({
|
||||
mockClearHubDiscovery: vi.fn(),
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
@@ -23,15 +21,7 @@ const {
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
})),
|
||||
mockRestartQueuedConnectorsForHub: vi.fn(async () => ({
|
||||
restarted: 0,
|
||||
remaining: 0,
|
||||
})),
|
||||
mockStopLocalHubServerGracefully: vi.fn(),
|
||||
mockStopConnectorsForHubs: vi.fn(async () => ({
|
||||
stoppedProcesses: 0,
|
||||
queuedRestarts: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
@@ -44,15 +34,6 @@ vi.mock("@cline/core", () => ({
|
||||
stopLocalHubServerGracefully: mockStopLocalHubServerGracefully,
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/restart", () => ({
|
||||
restartQueuedConnectorsForHub: mockRestartQueuedConnectorsForHub,
|
||||
stopConnectorsForHubs: mockStopConnectorsForHubs,
|
||||
}));
|
||||
|
||||
vi.mock("../utils/hub-runtime", () => ({
|
||||
resolveDefaultCliHubUrl: () => "ws://127.0.0.1:25463/hub",
|
||||
}));
|
||||
|
||||
import { createHubCommand } from "./hub";
|
||||
|
||||
const originalBuildEnv = process.env.CLINE_BUILD_ENV;
|
||||
@@ -110,77 +91,6 @@ describe("createHubCommand", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("queues associated connectors on stop", async () => {
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
port: 25463,
|
||||
pid: 50174,
|
||||
});
|
||||
mockStopLocalHubServerGracefully.mockResolvedValue(true);
|
||||
mockStopConnectorsForHubs.mockResolvedValue({
|
||||
stoppedProcesses: 2,
|
||||
queuedRestarts: 2,
|
||||
});
|
||||
|
||||
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(mockStopConnectorsForHubs).toHaveBeenCalledWith(
|
||||
["ws://127.0.0.1:25463/hub"],
|
||||
expect.any(Object),
|
||||
{ targetHubUrl: "ws://127.0.0.1:25463/hub" },
|
||||
);
|
||||
expect(JSON.parse(output.at(-1) || "")).toMatchObject({
|
||||
stopped: true,
|
||||
stoppedConnectorProcesses: 2,
|
||||
queuedConnectorRestarts: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("restarts queued connectors on start", async () => {
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
const output: string[] = [];
|
||||
let exitCode = 0;
|
||||
const cmd = createHubCommand(
|
||||
{
|
||||
writeln: (text) => {
|
||||
output.push(text ?? "");
|
||||
},
|
||||
writeErr: () => {},
|
||||
},
|
||||
(code) => {
|
||||
exitCode = code;
|
||||
},
|
||||
);
|
||||
|
||||
await cmd.parseAsync(["start"], { from: "user" });
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(mockRestartQueuedConnectorsForHub).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(output.at(-1)).toBe("ws://127.0.0.1:25463/hub");
|
||||
});
|
||||
|
||||
it("passes the selected owner to graceful stop", async () => {
|
||||
process.env.CLINE_BUILD_ENV = "development";
|
||||
mockReadHubDiscovery.mockResolvedValue({
|
||||
@@ -211,6 +121,6 @@ describe("createHubCommand", () => {
|
||||
ownerId: "hub-owner",
|
||||
discoveryPath: "/tmp/cline-data/locks/hub/owners/hub-owner.json",
|
||||
});
|
||||
expect(JSON.parse(output[0] || "")).toMatchObject({ stopped: true });
|
||||
expect(JSON.parse(output[0] || "")).toEqual({ stopped: true });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,39 +9,18 @@ import {
|
||||
} from "@cline/core";
|
||||
import { formatUptime, resolveClineBuildEnv } from "@cline/shared";
|
||||
import { Command } from "commander";
|
||||
import {
|
||||
restartQueuedConnectorsForHub,
|
||||
stopConnectorsForHubs,
|
||||
} from "../connectors/restart";
|
||||
import { resolveDefaultCliHubUrl } from "../utils/hub-runtime";
|
||||
|
||||
interface HubCommandIo {
|
||||
writeln: (text?: string) => void;
|
||||
writeErr: (text: string) => void;
|
||||
}
|
||||
|
||||
async function stopHubServer(
|
||||
_workspaceRoot: string,
|
||||
io: HubCommandIo,
|
||||
): Promise<{
|
||||
stopped: boolean;
|
||||
stoppedConnectorProcesses: number;
|
||||
queuedConnectorRestarts: number;
|
||||
}> {
|
||||
async function stopHubServer(_workspaceRoot: string): Promise<boolean> {
|
||||
const owner = resolveCliHubOwnerContext();
|
||||
const discovery = await readHubDiscovery(owner.discoveryPath);
|
||||
const stoppedConnectors = discovery?.url
|
||||
? await stopConnectorsForHubs([discovery.url], io, {
|
||||
targetHubUrl: resolveDefaultCliHubUrl(),
|
||||
})
|
||||
: { stoppedProcesses: 0, queuedRestarts: 0 };
|
||||
if (await stopLocalHubServerGracefully(owner)) {
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return {
|
||||
stopped: true,
|
||||
stoppedConnectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
queuedConnectorRestarts: stoppedConnectors.queuedRestarts,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
const pid = discovery?.pid;
|
||||
if (pid) {
|
||||
@@ -52,11 +31,7 @@ async function stopHubServer(
|
||||
}
|
||||
}
|
||||
await clearHubDiscovery(owner.discoveryPath);
|
||||
return {
|
||||
stopped: !!pid,
|
||||
stoppedConnectorProcesses: stoppedConnectors.stoppedProcesses,
|
||||
queuedConnectorRestarts: stoppedConnectors.queuedRestarts,
|
||||
};
|
||||
return !!pid;
|
||||
}
|
||||
|
||||
function formatHubUptimeFromStartedAt(
|
||||
@@ -121,7 +96,6 @@ export function createHubCommand(
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
});
|
||||
await restartQueuedConnectorsForHub(url, io);
|
||||
io.writeln(url);
|
||||
}),
|
||||
);
|
||||
@@ -139,7 +113,6 @@ export function createHubCommand(
|
||||
port: opts.port,
|
||||
pathname: opts.pathname,
|
||||
});
|
||||
await restartQueuedConnectorsForHub(url, io);
|
||||
io.writeln(url);
|
||||
}),
|
||||
);
|
||||
@@ -169,7 +142,8 @@ export function createHubCommand(
|
||||
hub.command("stop").action(
|
||||
action(async () => {
|
||||
const opts = hub.opts<{ cwd: string }>();
|
||||
io.writeln(JSON.stringify(await stopHubServer(opts.cwd, io)));
|
||||
const stopped = await stopHubServer(opts.cwd);
|
||||
io.writeln(JSON.stringify({ stopped }));
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -11,11 +11,7 @@ import {
|
||||
} from "@cline/core";
|
||||
import { resolveClineBuildEnv } from "@cline/shared";
|
||||
import { version } from "../../package.json";
|
||||
import { stopConnectorsForHubs } from "../connectors/restart";
|
||||
import {
|
||||
ensureCliHubServer,
|
||||
resolveDefaultCliHubUrl,
|
||||
} from "../utils/hub-runtime";
|
||||
import { ensureCliHubServer } from "../utils/hub-runtime";
|
||||
import { c, writeErr, writeln } from "../utils/output";
|
||||
import {
|
||||
getInstalledKanbanVersion,
|
||||
@@ -317,16 +313,6 @@ async function restartHubServerIfRunning(): Promise<void> {
|
||||
|
||||
const pid = discovery?.pid;
|
||||
writeln(`${c.dim}[hub] restarting server…${c.reset}`);
|
||||
await stopConnectorsForHubs(
|
||||
[health.url],
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
{
|
||||
targetHubUrl: resolveDefaultCliHubUrl(),
|
||||
},
|
||||
);
|
||||
|
||||
let stopped = await stopLocalHubServerGracefully(owner).catch(() => false);
|
||||
if (!stopped && pid) {
|
||||
@@ -349,10 +335,9 @@ async function restartHubServerIfRunning(): Promise<void> {
|
||||
|
||||
await clearHubDiscovery(owner.discoveryPath).catch(() => undefined);
|
||||
|
||||
// Re-ensure a fresh hub instance is spawned. ensureCliHubServer also
|
||||
// drains the connector restart queue for the new hub.
|
||||
// Re-ensure a fresh hub instance is spawned.
|
||||
try {
|
||||
await ensureCliHubServer(process.cwd());
|
||||
await ensureCliHubServer(process.cwd()); // return value intentionally unused here
|
||||
writeln(`${c.green}✓${c.reset} ${c.dim}[hub] server restarted${c.reset}`);
|
||||
} catch (err) {
|
||||
writeErr(
|
||||
@@ -390,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) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/core";
|
||||
import { Command, CommanderError } from "commander";
|
||||
import {
|
||||
CLINE_CONNECTOR_RESTART_SPEC_ENV,
|
||||
isProcessRunning,
|
||||
readJsonFile,
|
||||
removeFile,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectorRestartSpec,
|
||||
ConnectStopResult,
|
||||
} from "./types";
|
||||
|
||||
@@ -115,19 +113,6 @@ export abstract class ConnectorBase<Options, State>
|
||||
}
|
||||
|
||||
protected writeStateFile(statePath: string, state: unknown): void {
|
||||
const restart = this.readRestartSpecFromEnv();
|
||||
if (
|
||||
restart &&
|
||||
state &&
|
||||
typeof state === "object" &&
|
||||
!Array.isArray(state)
|
||||
) {
|
||||
writeJsonFile(statePath, {
|
||||
...(state as Record<string, unknown>),
|
||||
restart,
|
||||
});
|
||||
return;
|
||||
}
|
||||
writeJsonFile(statePath, state);
|
||||
}
|
||||
|
||||
@@ -135,33 +120,6 @@ export abstract class ConnectorBase<Options, State>
|
||||
removeFile(statePath);
|
||||
}
|
||||
|
||||
private readRestartSpecFromEnv(): ConnectorRestartSpec | undefined {
|
||||
const raw = process.env[CLINE_CONNECTOR_RESTART_SPEC_ENV]?.trim();
|
||||
if (!raw) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<ConnectorRestartSpec>;
|
||||
if (
|
||||
parsed.connector === this.name &&
|
||||
Array.isArray(parsed.args) &&
|
||||
parsed.args.every((arg) => typeof arg === "string")
|
||||
) {
|
||||
return {
|
||||
connector: parsed.connector,
|
||||
args: parsed.args,
|
||||
cwd:
|
||||
typeof parsed.cwd === "string" && parsed.cwd.trim()
|
||||
? parsed.cwd
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed restart metadata from the environment.
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
protected removeStaleState(
|
||||
statePath: string,
|
||||
readState: (path: string) => State | undefined,
|
||||
|
||||
@@ -1,36 +1,2 @@
|
||||
export type ConnectorCatalogEntry = {
|
||||
name: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const CONNECTOR_CATALOG: ConnectorCatalogEntry[] = [
|
||||
{
|
||||
name: "discord",
|
||||
description:
|
||||
"Discord interactions and gateway bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "gchat",
|
||||
description: "Google Chat webhook bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "linear",
|
||||
description: "Linear webhook bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "slack",
|
||||
description: "Slack webhook/socket bridge backed by RPC runtime sessions",
|
||||
},
|
||||
{
|
||||
name: "telegram",
|
||||
description: "Bridge Telegram bot messages into RPC chat sessions",
|
||||
},
|
||||
{
|
||||
name: "whatsapp",
|
||||
description: "Bridge WhatsApp webhook messages into RPC chat sessions",
|
||||
},
|
||||
];
|
||||
|
||||
export function listConnectorCatalog(): ConnectorCatalogEntry[] {
|
||||
return CONNECTOR_CATALOG.map((entry) => ({ ...entry }));
|
||||
}
|
||||
export type { ConnectorCatalogEntry } from "@cline/shared";
|
||||
export { CONNECTOR_CATALOG, listConnectorCatalog } from "@cline/shared";
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import {
|
||||
chmodSync,
|
||||
closeSync,
|
||||
existsSync,
|
||||
openSync,
|
||||
@@ -16,8 +15,6 @@ import { createCliLoggerAdapter } from "../logging/adapter";
|
||||
import { logSpawnedProcess } from "../logging/process";
|
||||
import { resolveCliLaunchSpec } from "../utils/internal-launch";
|
||||
|
||||
export const CLINE_CONNECTOR_RESTART_SPEC_ENV = "CLINE_CONNECTOR_RESTART_SPEC";
|
||||
|
||||
export function parseBooleanFlag(rawArgs: string[], flag: string): boolean {
|
||||
return rawArgs.includes(flag);
|
||||
}
|
||||
@@ -186,8 +183,6 @@ export function spawnDetachedConnector(
|
||||
}
|
||||
const detachedLogFd = tryOpenDetachedLogFd(options?.logPath);
|
||||
try {
|
||||
const connectorName =
|
||||
commandPrefixArgs[0] === "connect" ? commandPrefixArgs[1] : undefined;
|
||||
const child = spawn(command.launcher, command.childArgs, {
|
||||
cwd: process.cwd(),
|
||||
detached: true,
|
||||
@@ -198,16 +193,10 @@ export function spawnDetachedConnector(
|
||||
env: {
|
||||
...withResolvedClineBuildEnv(process.env),
|
||||
[childEnvKey]: "1",
|
||||
...(connectorName
|
||||
? {
|
||||
[CLINE_CONNECTOR_RESTART_SPEC_ENV]: JSON.stringify({
|
||||
connector: connectorName,
|
||||
args: rawArgs,
|
||||
cwd: process.cwd(),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
// Prevent a console window from appearing on Windows; detached
|
||||
// processes otherwise allocate a new visible console.
|
||||
windowsHide: true,
|
||||
});
|
||||
logSpawnedProcess({
|
||||
component: options?.component ?? "connectors",
|
||||
@@ -273,20 +262,7 @@ export function readJsonFile<T>(path: string, fallback: T): T {
|
||||
|
||||
export function writeJsonFile(path: string, value: unknown): void {
|
||||
ensureParentDir(path);
|
||||
// Connector state and the restart queue persist raw CLI args, which can
|
||||
// include secrets like bot tokens. Recreate the file owner-only, matching
|
||||
// the discipline used for hub discovery records. The mode option only
|
||||
// applies on create, so remove any existing file first.
|
||||
rmSync(path, { force: true });
|
||||
writeFileSync(path, JSON.stringify(value, null, 2), {
|
||||
encoding: "utf8",
|
||||
mode: 0o600,
|
||||
});
|
||||
try {
|
||||
chmodSync(path, 0o600);
|
||||
} catch {
|
||||
// Best-effort tightening on filesystems without chmod support.
|
||||
}
|
||||
writeFileSync(path, JSON.stringify(value, null, 2), "utf8");
|
||||
}
|
||||
|
||||
export function removeFile(path: string): void {
|
||||
|
||||
@@ -1,639 +0,0 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockResolveClineDataDir, mockGetConnector } = vi.hoisted(() => ({
|
||||
mockResolveClineDataDir: vi.fn(),
|
||||
mockGetConnector: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
resolveClineDataDir: mockResolveClineDataDir,
|
||||
ensureParentDir: (path: string) => {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./registry", () => ({
|
||||
getConnector: mockGetConnector,
|
||||
}));
|
||||
|
||||
import {
|
||||
restartQueuedConnectorsForHub,
|
||||
stopConnectorsForHubs,
|
||||
} from "./restart";
|
||||
|
||||
describe("connector restart queue", () => {
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
mockGetConnector.mockReset();
|
||||
mockResolveClineDataDir.mockReset();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("queues connector restart metadata when stopping connectors for a killed hub", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const statePath = join(dataDir, "connectors", "telegram", "bot.json");
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors", "telegram"), { recursive: true });
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
botUsername: "bot",
|
||||
pid: 12345,
|
||||
rpcAddress: "ws://127.0.0.1:57648/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
restart: {
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot", "--rpc-address", "ws://127.0.0.1:57648/hub"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const alive = new Set([12345]);
|
||||
const killSpy = vi
|
||||
.spyOn(process, "kill")
|
||||
.mockImplementation((pid, signal) => {
|
||||
if (signal === 0 || signal === undefined) {
|
||||
if (alive.has(Number(pid))) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
alive.delete(Number(pid));
|
||||
return true;
|
||||
});
|
||||
|
||||
const stopped = await stopConnectorsForHubs(
|
||||
["ws://127.0.0.1:57648/hub"],
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
},
|
||||
{
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
},
|
||||
);
|
||||
|
||||
expect(stopped).toEqual({ stoppedProcesses: 1, queuedRestarts: 1 });
|
||||
expect(killSpy).toHaveBeenCalledWith(12345, "SIGTERM");
|
||||
expect(existsSync(statePath)).toBe(false);
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{
|
||||
connector: "telegram",
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
pid: 12345,
|
||||
},
|
||||
]);
|
||||
|
||||
const run = vi.fn(async () => 0);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 0 });
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
["-m", "bot", "--rpc-address", "ws://127.0.0.1:25466/hub"],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(existsSync(queuePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("rewrites equals-form rpc address args when restarting queued connectors", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot", "--rpc-address=ws://127.0.0.1:57648/hub"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const run = vi.fn(async () => 0);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 0 });
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
["-m", "bot", "--rpc-address=ws://127.0.0.1:25466/hub"],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("replays queued connector cwd when restarting without an explicit cwd arg", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
cwd: "/workspace/original",
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
const run = vi.fn(async () => 0);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 0 });
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
[
|
||||
"-m",
|
||||
"bot",
|
||||
"--rpc-address",
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
"--cwd",
|
||||
"/workspace/original",
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an explicit cwd arg when restarting queued connectors", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot", "--cwd", "/workspace/from-args"],
|
||||
cwd: "/workspace/original",
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
const run = vi.fn(async () => 0);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 0 });
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
[
|
||||
"-m",
|
||||
"bot",
|
||||
"--cwd",
|
||||
"/workspace/from-args",
|
||||
"--rpc-address",
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
],
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("only restarts queue entries targeted at the started hub", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot-a"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot-a.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot-b"],
|
||||
hubUrl: "ws://127.0.0.1:57649/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25467/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot-b.json"),
|
||||
pid: 12346,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const run = vi.fn(async () => 0);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 1 });
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run).toHaveBeenCalledWith(
|
||||
["-m", "bot-a", "--rpc-address", "ws://127.0.0.1:25466/hub"],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{
|
||||
args: ["-m", "bot-b"],
|
||||
targetHubUrl: "ws://127.0.0.1:25467/hub",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"writes the restart queue owner-only",
|
||||
async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const statePath = join(dataDir, "connectors", "telegram", "bot.json");
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors", "telegram"), { recursive: true });
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
pid: 12345,
|
||||
rpcAddress: "ws://127.0.0.1:57648/hub",
|
||||
restart: {
|
||||
connector: "telegram",
|
||||
args: ["--bot-token", "secret"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const alive = new Set([12345]);
|
||||
vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
|
||||
if (signal === 0 || signal === undefined) {
|
||||
if (alive.has(Number(pid))) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
alive.delete(Number(pid));
|
||||
return true;
|
||||
});
|
||||
|
||||
await stopConnectorsForHubs(["ws://127.0.0.1:57648/hub"], {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
|
||||
expect(statSync(queuePath).mode & 0o777).toBe(0o600);
|
||||
},
|
||||
);
|
||||
|
||||
it("claims queue entries before launching connectors", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
let queueExistedDuringRun: boolean | undefined;
|
||||
const run = vi.fn(async () => {
|
||||
queueExistedDuringRun = existsSync(queuePath);
|
||||
return 0;
|
||||
});
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 1, remaining: 0 });
|
||||
expect(queueExistedDuringRun).toBe(false);
|
||||
});
|
||||
|
||||
it("drops queue entries for unknown connectors", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "renamed-connector",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
mockGetConnector.mockResolvedValue(undefined);
|
||||
const errors: string[] = [];
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: (text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 0, remaining: 0 });
|
||||
expect(existsSync(queuePath)).toBe(false);
|
||||
expect(errors).toEqual([
|
||||
'[connect] dropping queued restart for unknown connector "renamed-connector"',
|
||||
]);
|
||||
});
|
||||
|
||||
it("requeues failed restarts with an attempt count and drops them at the cap", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
const entry = {
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
};
|
||||
writeFileSync(queuePath, JSON.stringify([entry]), "utf8");
|
||||
const run = vi.fn(async () => 1);
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
const io = { writeln: () => {}, writeErr: () => {} };
|
||||
|
||||
const first = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
io,
|
||||
);
|
||||
expect(first).toEqual({ restarted: 0, remaining: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{ connector: "telegram", attempts: 1 },
|
||||
]);
|
||||
|
||||
const second = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
io,
|
||||
);
|
||||
expect(second).toEqual({ restarted: 0, remaining: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{ connector: "telegram", attempts: 2 },
|
||||
]);
|
||||
|
||||
const errors: string[] = [];
|
||||
const third = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{
|
||||
writeln: () => {},
|
||||
writeErr: (text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(third).toEqual({ restarted: 0, remaining: 0 });
|
||||
expect(existsSync(queuePath)).toBe(false);
|
||||
expect(errors).toEqual([
|
||||
'[connect] dropping queued restart for connector "telegram" after 3 failed attempts',
|
||||
]);
|
||||
});
|
||||
|
||||
it("requeues entries when the connector run throws", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
const run = vi.fn(async () => {
|
||||
throw new Error("spawn failed");
|
||||
});
|
||||
mockGetConnector.mockResolvedValue({ name: "telegram", run });
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 0, remaining: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{ connector: "telegram", attempts: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("requeues entries when connector loading throws", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors"), { recursive: true });
|
||||
writeFileSync(
|
||||
queuePath,
|
||||
JSON.stringify([
|
||||
{
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
hubUrl: "ws://127.0.0.1:57648/hub",
|
||||
targetHubUrl: "ws://127.0.0.1:25466/hub",
|
||||
statePath: join(dataDir, "connectors", "telegram", "bot.json"),
|
||||
pid: 12345,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
},
|
||||
]),
|
||||
"utf8",
|
||||
);
|
||||
mockGetConnector.mockRejectedValue(new Error("import failed"));
|
||||
|
||||
const restarted = await restartQueuedConnectorsForHub(
|
||||
"ws://127.0.0.1:25466/hub",
|
||||
{ writeln: () => {}, writeErr: () => {} },
|
||||
);
|
||||
|
||||
expect(restarted).toEqual({ restarted: 0, remaining: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{ connector: "telegram", attempts: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps state and skips restart queue when connector termination fails", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const statePath = join(dataDir, "connectors", "telegram", "bot.json");
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors", "telegram"), { recursive: true });
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
botUsername: "bot",
|
||||
pid: 12345,
|
||||
rpcAddress: "ws://127.0.0.1:57648/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
restart: {
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
|
||||
if (signal === 0 || signal === undefined) {
|
||||
if (Number(pid) === 12345) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const errors: string[] = [];
|
||||
|
||||
const stopped = await stopConnectorsForHubs(["ws://127.0.0.1:57648/hub"], {
|
||||
writeln: () => {},
|
||||
writeErr: (text) => {
|
||||
errors.push(text);
|
||||
},
|
||||
});
|
||||
|
||||
expect(stopped).toEqual({ stoppedProcesses: 0, queuedRestarts: 0 });
|
||||
expect(existsSync(statePath)).toBe(true);
|
||||
expect(existsSync(queuePath)).toBe(false);
|
||||
expect(errors).toEqual([
|
||||
"[connect] failed to stop connector pid=12345 hub=ws://127.0.0.1:57648/hub",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores non-directory entries while scanning connector state", async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), "connector-restart-test-"));
|
||||
tempDirs.push(dataDir);
|
||||
mockResolveClineDataDir.mockReturnValue(dataDir);
|
||||
const statePath = join(dataDir, "connectors", "telegram", "bot.json");
|
||||
const queuePath = join(dataDir, "connectors", "restart-queue.json");
|
||||
mkdirSync(join(dataDir, "connectors", "telegram"), { recursive: true });
|
||||
writeFileSync(queuePath, "[]", "utf8");
|
||||
writeFileSync(
|
||||
statePath,
|
||||
JSON.stringify({
|
||||
botUsername: "bot",
|
||||
pid: 12345,
|
||||
rpcAddress: "ws://127.0.0.1:57648/hub",
|
||||
startedAt: new Date().toISOString(),
|
||||
restart: {
|
||||
connector: "telegram",
|
||||
args: ["-m", "bot"],
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const alive = new Set([12345]);
|
||||
vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
|
||||
if (signal === 0 || signal === undefined) {
|
||||
if (alive.has(Number(pid))) {
|
||||
return true;
|
||||
}
|
||||
throw Object.assign(new Error("missing"), { code: "ESRCH" });
|
||||
}
|
||||
alive.delete(Number(pid));
|
||||
return true;
|
||||
});
|
||||
|
||||
const stopped = await stopConnectorsForHubs(["ws://127.0.0.1:57648/hub"], {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
});
|
||||
|
||||
expect(stopped).toEqual({ stoppedProcesses: 1, queuedRestarts: 1 });
|
||||
expect(JSON.parse(readFileSync(queuePath, "utf8"))).toMatchObject([
|
||||
{
|
||||
connector: "telegram",
|
||||
targetHubUrl: "ws://127.0.0.1:57648/hub",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,353 +0,0 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveClineDataDir } from "@cline/core";
|
||||
import {
|
||||
isProcessRunning,
|
||||
readJsonFile,
|
||||
removeFile,
|
||||
terminateProcess,
|
||||
writeJsonFile,
|
||||
} from "./common";
|
||||
import { getConnector } from "./registry";
|
||||
import type {
|
||||
ConnectCommandDefinition,
|
||||
ConnectIo,
|
||||
ConnectorRestartSpec,
|
||||
} from "./types";
|
||||
|
||||
type ConnectorStateForRestart = {
|
||||
statePath: string;
|
||||
pid: number;
|
||||
hubUrl: string;
|
||||
restart?: ConnectorRestartSpec;
|
||||
};
|
||||
|
||||
type QueuedConnectorRestart = ConnectorRestartSpec & {
|
||||
hubUrl: string;
|
||||
targetHubUrl: string;
|
||||
statePath: string;
|
||||
pid: number;
|
||||
stoppedAt: string;
|
||||
attempts?: number;
|
||||
};
|
||||
|
||||
const MAX_RESTART_ATTEMPTS = 3;
|
||||
|
||||
export type StopConnectorsForHubsOptions = {
|
||||
targetHubUrl?: string;
|
||||
};
|
||||
|
||||
export type StopConnectorsForHubsResult = {
|
||||
stoppedProcesses: number;
|
||||
queuedRestarts: number;
|
||||
};
|
||||
|
||||
export type RestartQueuedConnectorsResult = {
|
||||
restarted: number;
|
||||
remaining: number;
|
||||
};
|
||||
|
||||
function restartQueuePath(): string {
|
||||
return join(resolveClineDataDir(), "connectors", "restart-queue.json");
|
||||
}
|
||||
|
||||
function normalizeHubUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url.includes("://") ? url : `ws://${url}`);
|
||||
if (parsed.protocol === "http:") {
|
||||
parsed.protocol = "ws:";
|
||||
} else if (parsed.protocol === "https:") {
|
||||
parsed.protocol = "wss:";
|
||||
}
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url.trim();
|
||||
}
|
||||
}
|
||||
|
||||
function readQueue(): QueuedConnectorRestart[] {
|
||||
const parsed = readJsonFile<unknown>(restartQueuePath(), []);
|
||||
if (!Array.isArray(parsed)) {
|
||||
return [];
|
||||
}
|
||||
return parsed.filter((entry): entry is QueuedConnectorRestart => {
|
||||
if (!entry || typeof entry !== "object") {
|
||||
return false;
|
||||
}
|
||||
const record = entry as Partial<QueuedConnectorRestart>;
|
||||
return (
|
||||
typeof record.connector === "string" &&
|
||||
Array.isArray(record.args) &&
|
||||
record.args.every((arg) => typeof arg === "string") &&
|
||||
typeof record.hubUrl === "string" &&
|
||||
typeof record.targetHubUrl === "string" &&
|
||||
typeof record.statePath === "string" &&
|
||||
typeof record.pid === "number" &&
|
||||
typeof record.stoppedAt === "string" &&
|
||||
(record.cwd === undefined || typeof record.cwd === "string") &&
|
||||
(record.attempts === undefined || typeof record.attempts === "number")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function writeQueue(queue: QueuedConnectorRestart[]): void {
|
||||
if (queue.length === 0) {
|
||||
removeFile(restartQueuePath());
|
||||
return;
|
||||
}
|
||||
writeJsonFile(restartQueuePath(), queue);
|
||||
}
|
||||
|
||||
function listConnectorStatePaths(): string[] {
|
||||
const root = join(resolveClineDataDir(), "connectors");
|
||||
if (!existsSync(root)) {
|
||||
return [];
|
||||
}
|
||||
const paths: string[] = [];
|
||||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
const dir = join(root, entry.name);
|
||||
try {
|
||||
for (const name of readdirSync(dir)) {
|
||||
if (name.endsWith(".json") && !name.endsWith(".threads.json")) {
|
||||
paths.push(join(dir, name));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore connector directories that disappear while scanning.
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function readConnectorStateForRestart(
|
||||
statePath: string,
|
||||
): ConnectorStateForRestart | undefined {
|
||||
const parsed = readJsonFile<Record<string, unknown> | undefined>(
|
||||
statePath,
|
||||
undefined,
|
||||
);
|
||||
if (!parsed) {
|
||||
return undefined;
|
||||
}
|
||||
const pid = typeof parsed.pid === "number" ? parsed.pid : undefined;
|
||||
const hubUrl =
|
||||
typeof parsed.hubUrl === "string"
|
||||
? parsed.hubUrl
|
||||
: typeof parsed.rpcAddress === "string"
|
||||
? parsed.rpcAddress
|
||||
: undefined;
|
||||
if (!pid || !hubUrl || !isProcessRunning(pid)) {
|
||||
return undefined;
|
||||
}
|
||||
const restart =
|
||||
parsed.restart &&
|
||||
typeof parsed.restart === "object" &&
|
||||
!Array.isArray(parsed.restart)
|
||||
? (parsed.restart as Partial<ConnectorRestartSpec>)
|
||||
: undefined;
|
||||
return {
|
||||
statePath,
|
||||
pid,
|
||||
hubUrl,
|
||||
restart:
|
||||
typeof restart?.connector === "string" &&
|
||||
Array.isArray(restart.args) &&
|
||||
restart.args.every((arg) => typeof arg === "string")
|
||||
? {
|
||||
connector: restart.connector,
|
||||
args: restart.args,
|
||||
cwd:
|
||||
typeof restart.cwd === "string" && restart.cwd.trim()
|
||||
? restart.cwd
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function queueConnectorRestart(
|
||||
state: ConnectorStateForRestart,
|
||||
targetHubUrl: string,
|
||||
): boolean {
|
||||
if (!state.restart) {
|
||||
return false;
|
||||
}
|
||||
const queue = readQueue().filter(
|
||||
(entry) =>
|
||||
entry.statePath !== state.statePath &&
|
||||
!(
|
||||
entry.connector === state.restart?.connector && entry.pid === state.pid
|
||||
),
|
||||
);
|
||||
queue.push({
|
||||
...state.restart,
|
||||
hubUrl: state.hubUrl,
|
||||
targetHubUrl,
|
||||
statePath: state.statePath,
|
||||
pid: state.pid,
|
||||
stoppedAt: new Date().toISOString(),
|
||||
});
|
||||
writeQueue(queue);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function stopConnectorsForHubs(
|
||||
hubUrls: string[],
|
||||
io: ConnectIo,
|
||||
options: StopConnectorsForHubsOptions = {},
|
||||
): Promise<StopConnectorsForHubsResult> {
|
||||
const targetHubUrls = new Set(hubUrls.map(normalizeHubUrl));
|
||||
if (targetHubUrls.size === 0) {
|
||||
return { stoppedProcesses: 0, queuedRestarts: 0 };
|
||||
}
|
||||
const restartTargetHubUrl = options.targetHubUrl
|
||||
? normalizeHubUrl(options.targetHubUrl)
|
||||
: undefined;
|
||||
let stoppedProcesses = 0;
|
||||
let queuedRestarts = 0;
|
||||
for (const statePath of listConnectorStatePaths()) {
|
||||
const state = readConnectorStateForRestart(statePath);
|
||||
if (!state || !targetHubUrls.has(normalizeHubUrl(state.hubUrl))) {
|
||||
continue;
|
||||
}
|
||||
if (!(await terminateProcess(state.pid))) {
|
||||
io.writeErr(
|
||||
`[connect] failed to stop connector pid=${state.pid} hub=${state.hubUrl}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
stoppedProcesses += 1;
|
||||
io.writeln(
|
||||
`[connect] stopped connector pid=${state.pid} hub=${state.hubUrl}`,
|
||||
);
|
||||
if (
|
||||
queueConnectorRestart(
|
||||
state,
|
||||
restartTargetHubUrl ?? normalizeHubUrl(state.hubUrl),
|
||||
)
|
||||
) {
|
||||
queuedRestarts += 1;
|
||||
}
|
||||
removeFile(statePath);
|
||||
}
|
||||
return { stoppedProcesses, queuedRestarts };
|
||||
}
|
||||
|
||||
function hasCwdArg(args: string[]): boolean {
|
||||
return args.some((arg) => arg === "--cwd" || arg.startsWith("--cwd="));
|
||||
}
|
||||
|
||||
function withHubRpcAddress(args: string[], hubUrl: string): string[] {
|
||||
// Drains run from background contexts (hub start, doctor, update), so an
|
||||
// interactive flag in the saved args would block the drain waiting on a
|
||||
// terminal that does not exist. Relaunch in detached mode unconditionally;
|
||||
// the spec normally never carries these flags since interactive runs do
|
||||
// not persist a restart spec.
|
||||
const next = args.filter((arg) => arg !== "-i" && arg !== "--interactive");
|
||||
for (let index = 0; index < next.length; index += 1) {
|
||||
if (next[index]?.startsWith("--rpc-address=")) {
|
||||
next[index] = `--rpc-address=${hubUrl}`;
|
||||
return next;
|
||||
}
|
||||
if (next[index] === "--rpc-address" && next[index + 1]) {
|
||||
next[index + 1] = hubUrl;
|
||||
return next;
|
||||
}
|
||||
}
|
||||
return [...next, "--rpc-address", hubUrl];
|
||||
}
|
||||
|
||||
function withRestartLaunchArgs(entry: QueuedConnectorRestart, hubUrl: string) {
|
||||
const args = withHubRpcAddress(entry.args, hubUrl);
|
||||
return entry.cwd && !hasCwdArg(args) ? [...args, "--cwd", entry.cwd] : args;
|
||||
}
|
||||
|
||||
function recordFailedRestartAttempt(
|
||||
entry: QueuedConnectorRestart,
|
||||
failed: QueuedConnectorRestart[],
|
||||
io: ConnectIo,
|
||||
): void {
|
||||
const attempts = (entry.attempts ?? 0) + 1;
|
||||
if (attempts >= MAX_RESTART_ATTEMPTS) {
|
||||
io.writeErr(
|
||||
`[connect] dropping queued restart for connector "${entry.connector}" after ${attempts} failed attempts`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
failed.push({ ...entry, attempts });
|
||||
}
|
||||
|
||||
// Restarting a connector re-runs its connect command, which ensures the hub
|
||||
// and drains this queue again. The guard turns those nested drains into
|
||||
// no-ops so a queue entry is never picked up twice within one process.
|
||||
let drainInProgress = false;
|
||||
|
||||
export async function restartQueuedConnectorsForHub(
|
||||
hubUrl: string,
|
||||
io: ConnectIo,
|
||||
): Promise<RestartQueuedConnectorsResult> {
|
||||
if (drainInProgress) {
|
||||
return { restarted: 0, remaining: readQueue().length };
|
||||
}
|
||||
const queue = readQueue();
|
||||
if (queue.length === 0) {
|
||||
return { restarted: 0, remaining: 0 };
|
||||
}
|
||||
const targetHubUrl = normalizeHubUrl(hubUrl);
|
||||
const matched: QueuedConnectorRestart[] = [];
|
||||
const remaining: QueuedConnectorRestart[] = [];
|
||||
for (const entry of queue) {
|
||||
if (normalizeHubUrl(entry.targetHubUrl) === targetHubUrl) {
|
||||
matched.push(entry);
|
||||
} else {
|
||||
remaining.push(entry);
|
||||
}
|
||||
}
|
||||
if (matched.length === 0) {
|
||||
return { restarted: 0, remaining: remaining.length };
|
||||
}
|
||||
// Claim matched entries before running them so a crash mid-restart (or a
|
||||
// concurrent drain in another process) cannot replay entries that already
|
||||
// launched a connector.
|
||||
writeQueue(remaining);
|
||||
let restarted = 0;
|
||||
const failed: QueuedConnectorRestart[] = [];
|
||||
drainInProgress = true;
|
||||
try {
|
||||
for (const entry of matched) {
|
||||
let connector: ConnectCommandDefinition | undefined;
|
||||
try {
|
||||
connector = await getConnector(entry.connector);
|
||||
} catch {
|
||||
recordFailedRestartAttempt(entry, failed, io);
|
||||
continue;
|
||||
}
|
||||
if (!connector) {
|
||||
io.writeErr(
|
||||
`[connect] dropping queued restart for unknown connector "${entry.connector}"`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const exitCode = await connector
|
||||
.run(withRestartLaunchArgs(entry, hubUrl), io)
|
||||
.catch(() => 1);
|
||||
if (exitCode === 0) {
|
||||
restarted += 1;
|
||||
continue;
|
||||
}
|
||||
recordFailedRestartAttempt(entry, failed, io);
|
||||
}
|
||||
} finally {
|
||||
drainInProgress = false;
|
||||
}
|
||||
if (failed.length > 0) {
|
||||
// Re-read before appending so entries queued while restarting survive.
|
||||
writeQueue([...readQueue(), ...failed]);
|
||||
}
|
||||
return { restarted, remaining: readQueue().length };
|
||||
}
|
||||
@@ -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 ||
|
||||
|
||||
@@ -8,12 +8,6 @@ export type ConnectStopResult = {
|
||||
stoppedSessions: number;
|
||||
};
|
||||
|
||||
export type ConnectorRestartSpec = {
|
||||
connector: string;
|
||||
args: string[];
|
||||
cwd?: string;
|
||||
};
|
||||
|
||||
export interface ConnectCommandDefinition {
|
||||
name: string;
|
||||
description: string;
|
||||
|
||||
+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,41 @@ 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?personal=true";
|
||||
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}`;
|
||||
const CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE =
|
||||
"Organization accounts cannot use ClinePass subscriptions. Go to /account -> change account to switch to your personal account for ClinePass";
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
getClineOrgIndividualInferenceSubscriptionMessage: () =>
|
||||
CLINE_ORG_INDIVIDUAL_INFERENCE_SUBSCRIPTION_MESSAGE,
|
||||
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"),
|
||||
isClineOrgIndividualInferenceSubscriptionError: (error: unknown) =>
|
||||
error instanceof Error &&
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError",
|
||||
isClineOrgIndividualInferenceSubscriptionMessage: (text: string) =>
|
||||
text
|
||||
.toLowerCase()
|
||||
.includes(
|
||||
"organization accounts cannot use individual model inference subscriptions",
|
||||
),
|
||||
prewarmFileIndex: vi.fn(async () => undefined),
|
||||
SessionSource: {
|
||||
CLI: "cli",
|
||||
@@ -77,7 +111,7 @@ vi.mock("./prompt", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("./session-events", () => ({
|
||||
subscribeToAgentEvents: vi.fn(() => () => {}),
|
||||
subscribeToAgentEvents: sessionEventsMocks.subscribeToAgentEvents,
|
||||
}));
|
||||
|
||||
describe("runAgent", () => {
|
||||
@@ -101,6 +135,9 @@ describe("runAgent", () => {
|
||||
outputMocks.writeln.mockReset();
|
||||
outputMocks.emitJsonLine.mockReset();
|
||||
outputMocks.setActiveCliSession.mockReset();
|
||||
sessionEventsMocks.listener = undefined;
|
||||
sessionEventsMocks.subscribeToAgentEvents.mockClear();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -511,6 +548,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 +646,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 +964,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,12 @@ import { useTerminalDimensions } from "@opentui/react";
|
||||
import type React from "react";
|
||||
import { useState } from "react";
|
||||
import "opentui-spinner/react";
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
isClinePassSubscriptionError,
|
||||
} from "../../utils/cline-pass-errors";
|
||||
import {
|
||||
CLINE_CREDITS_DASHBOARD_URL,
|
||||
isClineAccountCreditsErrorMessage,
|
||||
@@ -290,6 +296,65 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
function ClineOrgIndividualInferenceSubscriptionErrorView(props: {
|
||||
defaultFg?: string;
|
||||
}) {
|
||||
return (
|
||||
<box flexDirection="row">
|
||||
<text fg="yellow" content="* " />
|
||||
<box
|
||||
flexDirection="column"
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor="yellow"
|
||||
paddingX={1}
|
||||
>
|
||||
<text fg="yellow">Personal ClinePass required</text>
|
||||
<text
|
||||
fg={props.defaultFg}
|
||||
selectable
|
||||
content={getClineOrgIndividualInferenceSubscriptionMessage()}
|
||||
/>
|
||||
</box>
|
||||
</box>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatEntryView(props: {
|
||||
entry: ChatEntry;
|
||||
accent?: string;
|
||||
@@ -388,6 +453,14 @@ export function ChatEntryView(props: {
|
||||
if (isClineAccountCreditsErrorMessage(entry.text)) {
|
||||
return <ClineCreditsErrorView defaultFg={defaultFg} />;
|
||||
}
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(entry.text)) {
|
||||
return (
|
||||
<ClineOrgIndividualInferenceSubscriptionErrorView 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) {
|
||||
|
||||
@@ -57,7 +57,7 @@ describe("formatStatusBarUsageText", () => {
|
||||
totalCost: 0.123,
|
||||
showCost: true,
|
||||
}),
|
||||
).toBe("(12,345) $0.12");
|
||||
).toBe("(12,345 tokens) $0.12");
|
||||
});
|
||||
|
||||
it("omits cost when usage cost is hidden", () => {
|
||||
@@ -67,6 +67,6 @@ describe("formatStatusBarUsageText", () => {
|
||||
totalCost: 0.123,
|
||||
showCost: false,
|
||||
}),
|
||||
).toBe("(12,345)");
|
||||
).toBe("(12,345 tokens)");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ export function formatStatusBarUsageText(input: {
|
||||
totalCost: number;
|
||||
showCost: boolean;
|
||||
}): string {
|
||||
const tokens = `(${input.totalTokens.toLocaleString()})`;
|
||||
const tokens = `(${input.totalTokens.toLocaleString()} tokens)`;
|
||||
if (!input.showCost) return tokens;
|
||||
return `${tokens} ${formatCost(input.totalCost)}`;
|
||||
}
|
||||
|
||||
@@ -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,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
formatCliErrorMessage,
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage,
|
||||
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?personal=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("recognizes and formats organization account individual subscription errors", () => {
|
||||
const raw =
|
||||
"403 Error 403: organization accounts cannot use individual model inference subscriptions";
|
||||
const formatted = getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
|
||||
expect(isClineOrgIndividualInferenceSubscriptionErrorMessage(raw)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
new Error(formatted),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(formatCliErrorMessage(new Error(raw))).toBe(formatted);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
getClinePassSubscriptionUrl,
|
||||
isClineNotSubscribedError,
|
||||
isClineNotSubscribedMessage,
|
||||
isClineOrgIndividualInferenceSubscriptionError,
|
||||
isClineOrgIndividualInferenceSubscriptionMessage,
|
||||
} from "@cline/core";
|
||||
|
||||
export {
|
||||
getClineOrgIndividualInferenceSubscriptionMessage,
|
||||
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 isClineOrgIndividualInferenceSubscriptionErrorMessage(
|
||||
error: unknown,
|
||||
): boolean {
|
||||
if (isClineOrgIndividualInferenceSubscriptionError(error)) {
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return (
|
||||
error.name === "ClineOrgIndividualInferenceSubscriptionError" ||
|
||||
isClineOrgIndividualInferenceSubscriptionMessage(error.message) ||
|
||||
error.message === getClineOrgIndividualInferenceSubscriptionMessage()
|
||||
);
|
||||
}
|
||||
return (
|
||||
typeof error === "string" &&
|
||||
(isClineOrgIndividualInferenceSubscriptionMessage(error) ||
|
||||
error === getClineOrgIndividualInferenceSubscriptionMessage())
|
||||
);
|
||||
}
|
||||
|
||||
export function formatCliErrorMessage(error: unknown): string {
|
||||
if (isClineOrgIndividualInferenceSubscriptionErrorMessage(error)) {
|
||||
return getClineOrgIndividualInferenceSubscriptionMessage();
|
||||
}
|
||||
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)));
|
||||
});
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { mockEnsureDetachedHubServer, mockRestartQueuedConnectorsForHub } =
|
||||
vi.hoisted(() => ({
|
||||
mockEnsureDetachedHubServer: vi.fn(),
|
||||
mockRestartQueuedConnectorsForHub: vi.fn(async () => ({
|
||||
restarted: 0,
|
||||
remaining: 0,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@cline/core", () => ({
|
||||
createHubServerUrl: (host: string, port: number, pathname: string) =>
|
||||
`ws://${host}:${port}${pathname}`,
|
||||
ensureDetachedHubServer: mockEnsureDetachedHubServer,
|
||||
resolveDefaultHubHost: () => "127.0.0.1",
|
||||
resolveDefaultHubPort: () => 25463,
|
||||
resolveHubEndpointOptions: () => ({
|
||||
host: "127.0.0.1",
|
||||
port: 25463,
|
||||
pathname: "/hub",
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../connectors/restart", () => ({
|
||||
restartQueuedConnectorsForHub: mockRestartQueuedConnectorsForHub,
|
||||
}));
|
||||
|
||||
import { ensureCliHubServer } from "./hub-runtime";
|
||||
|
||||
describe("ensureCliHubServer", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("drains the connector restart queue after ensuring the hub", async () => {
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
|
||||
const resolution = await ensureCliHubServer("/workspace");
|
||||
|
||||
expect(resolution.url).toBe("ws://127.0.0.1:25463/hub");
|
||||
expect(mockRestartQueuedConnectorsForHub).toHaveBeenCalledWith(
|
||||
"ws://127.0.0.1:25463/hub",
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the hub resolution even when draining the queue fails", async () => {
|
||||
mockEnsureDetachedHubServer.mockResolvedValue({
|
||||
url: "ws://127.0.0.1:25463/hub",
|
||||
authToken: "token",
|
||||
});
|
||||
mockRestartQueuedConnectorsForHub.mockRejectedValueOnce(
|
||||
new Error("queue unreadable"),
|
||||
);
|
||||
|
||||
const resolution = await ensureCliHubServer("/workspace");
|
||||
|
||||
expect(resolution.url).toBe("ws://127.0.0.1:25463/hub");
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,10 @@
|
||||
import {
|
||||
createHubServerUrl,
|
||||
type DetachedHubResolution,
|
||||
ensureDetachedHubServer,
|
||||
type HubEndpointOverrides,
|
||||
resolveDefaultHubHost,
|
||||
resolveDefaultHubPort,
|
||||
resolveHubEndpointOptions,
|
||||
} from "@cline/core";
|
||||
import { restartQueuedConnectorsForHub } from "../connectors/restart";
|
||||
|
||||
/**
|
||||
* Build a `host:port` rpc address string that respects the current build
|
||||
@@ -18,11 +15,6 @@ export function resolveDefaultCliRpcAddress(): string {
|
||||
return `${resolveDefaultHubHost()}:${resolveDefaultHubPort()}`;
|
||||
}
|
||||
|
||||
export function resolveDefaultCliHubUrl(): string {
|
||||
const endpoint = resolveHubEndpointOptions();
|
||||
return createHubServerUrl(endpoint.host, endpoint.port, endpoint.pathname);
|
||||
}
|
||||
|
||||
export function parseHubEndpointOverride(
|
||||
rawAddress: string | undefined,
|
||||
): HubEndpointOverrides {
|
||||
@@ -51,12 +43,5 @@ export async function ensureCliHubServer(
|
||||
workspaceRoot: string,
|
||||
endpoint: HubEndpointOverrides = {},
|
||||
): Promise<DetachedHubResolution> {
|
||||
const resolution = await ensureDetachedHubServer(workspaceRoot, endpoint);
|
||||
// Connectors queued by hub stop/doctor/update cleanup come back as soon
|
||||
// as any CLI path brings the hub up, not only explicit hub commands.
|
||||
await restartQueuedConnectorsForHub(resolution.url, {
|
||||
writeln: () => {},
|
||||
writeErr: () => {},
|
||||
}).catch(() => undefined);
|
||||
return resolution;
|
||||
return await ensureDetachedHubServer(workspaceRoot, endpoint);
|
||||
}
|
||||
|
||||
@@ -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,325 +1,16 @@
|
||||
export interface PlatformDef {
|
||||
id: string;
|
||||
name: string;
|
||||
type: "polling" | "webhook" | "hybrid";
|
||||
hint: string;
|
||||
fields: FieldDef[];
|
||||
security?: SecurityDef;
|
||||
}
|
||||
import {
|
||||
CONNECTOR_PLATFORMS,
|
||||
shouldIncludeConnectorField,
|
||||
} from "@cline/shared";
|
||||
|
||||
export interface FieldDef {
|
||||
flag: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
help?: string[];
|
||||
initialValue?: string;
|
||||
options?: Array<{ value: string; label: string; hint?: string }>;
|
||||
includeWhen?: FieldCondition;
|
||||
}
|
||||
export type {
|
||||
ConnectorFieldCondition as FieldCondition,
|
||||
ConnectorFieldDef as FieldDef,
|
||||
ConnectorPlatformDef as PlatformDef,
|
||||
ConnectorSecurityDef as SecurityDef,
|
||||
ConnectorSecurityFieldDef as SecurityFieldDef,
|
||||
} from "@cline/shared";
|
||||
export { CONNECTOR_PLATFORMS, shouldIncludeConnectorField };
|
||||
|
||||
export type FieldCondition = {
|
||||
flag: string;
|
||||
equals?: string;
|
||||
notEquals?: string;
|
||||
};
|
||||
|
||||
export interface SecurityFieldDef {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
help?: string[];
|
||||
requiredMessage: string;
|
||||
validate?: (value: string) => string | undefined;
|
||||
}
|
||||
|
||||
export interface SecurityDef {
|
||||
prompt: string;
|
||||
fields: SecurityFieldDef[];
|
||||
buildArgs: (values: Record<string, string>) => string[];
|
||||
}
|
||||
|
||||
export function shouldIncludeField(
|
||||
field: FieldDef,
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
const condition = field.includeWhen;
|
||||
if (!condition) {
|
||||
return true;
|
||||
}
|
||||
const value = values[condition.flag] ?? "";
|
||||
if (condition.equals !== undefined && value !== condition.equals) {
|
||||
return false;
|
||||
}
|
||||
if (condition.notEquals !== undefined && value === condition.notEquals) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validateTelegramUserId(value: string): string | undefined {
|
||||
return /^\d+$/.test(value)
|
||||
? undefined
|
||||
: "Telegram user ID must contain digits only";
|
||||
}
|
||||
|
||||
function validateSlackTeamId(value: string): string | undefined {
|
||||
return /^T[A-Z0-9]+$/.test(value)
|
||||
? undefined
|
||||
: "Slack workspace ID must start with T and contain uppercase letters or digits only";
|
||||
}
|
||||
|
||||
function validateSlackUserId(value: string): string | undefined {
|
||||
return /^[UW][A-Z0-9]+$/.test(value)
|
||||
? undefined
|
||||
: "Slack member ID must start with U or W and contain uppercase letters or digits only";
|
||||
}
|
||||
|
||||
export const PLATFORMS: PlatformDef[] = [
|
||||
{
|
||||
id: "telegram",
|
||||
name: "Telegram",
|
||||
type: "polling",
|
||||
hint: "Easiest to set up. No public URL needed.",
|
||||
fields: [
|
||||
{
|
||||
flag: "-k",
|
||||
label: "Bot token",
|
||||
placeholder: "7123456789:AAH...",
|
||||
required: true,
|
||||
help: [
|
||||
"Open Telegram and start a chat with @BotFather",
|
||||
"Send /newbot and follow the prompts",
|
||||
"BotFather gives you this after creating the bot",
|
||||
"It looks like 7123456789:AAHxxx...",
|
||||
],
|
||||
},
|
||||
],
|
||||
security: {
|
||||
prompt:
|
||||
"By default, anyone who finds your bot can message it and run tasks on your machine. Restrict access to your Telegram user ID?",
|
||||
fields: [
|
||||
{
|
||||
key: "userId",
|
||||
label: "Your Telegram user ID",
|
||||
placeholder: "123456789",
|
||||
help: [
|
||||
"Message @userinfobot on Telegram",
|
||||
"It will reply with your numeric user ID",
|
||||
],
|
||||
requiredMessage: "User ID is required to restrict access",
|
||||
validate: validateTelegramUserId,
|
||||
},
|
||||
],
|
||||
buildArgs: ({ userId }) => ["--allowed-user-id", userId ?? ""],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "slack",
|
||||
name: "Slack",
|
||||
type: "hybrid",
|
||||
hint: "Public URL for webhook mode; leave blank for socket mode.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--bot-token",
|
||||
label: "Bot token",
|
||||
placeholder: "xoxb-...",
|
||||
required: true,
|
||||
help: [
|
||||
"Go to api.slack.com/apps and create a new app",
|
||||
"Add Bot Token Scopes: chat:write, app_mentions:read, channels:history, channels:read, im:history, im:read, im:write, users:read",
|
||||
"Install to workspace and copy the Bot Token",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "leave blank for socket mode",
|
||||
help: [
|
||||
"Enter a publicly accessible URL for webhook mode",
|
||||
"Leave blank to use Slack socket mode instead",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--signing-secret",
|
||||
label: "Signing secret",
|
||||
required: true,
|
||||
help: ["Found in your app's Basic Information page"],
|
||||
includeWhen: { flag: "--base-url", notEquals: "" },
|
||||
},
|
||||
{
|
||||
flag: "--app-token",
|
||||
label: "App-level token",
|
||||
placeholder: "xapp-...",
|
||||
required: true,
|
||||
help: [
|
||||
"Enable Socket Mode in the Slack app",
|
||||
"Generate an app-level token with the connections:write scope",
|
||||
],
|
||||
includeWhen: { flag: "--base-url", equals: "" },
|
||||
},
|
||||
],
|
||||
security: {
|
||||
prompt: "Restrict which Slack users can interact with the bot?",
|
||||
fields: [
|
||||
{
|
||||
key: "teamId",
|
||||
label: "Allowed Slack workspace ID",
|
||||
placeholder: "T01ABC123",
|
||||
help: [
|
||||
"Open your Slack workspace URL in a browser",
|
||||
"The workspace ID is the segment after /client/, for example T01ABC123",
|
||||
],
|
||||
requiredMessage: "Workspace ID is required to restrict access",
|
||||
validate: validateSlackTeamId,
|
||||
},
|
||||
{
|
||||
key: "userId",
|
||||
label: "Allowed Slack member ID",
|
||||
placeholder: "U01ABC123",
|
||||
help: [
|
||||
"Click a user's name in Slack, then View full profile",
|
||||
"Click ... and Copy member ID",
|
||||
],
|
||||
requiredMessage: "Member ID is required to restrict access",
|
||||
validate: validateSlackUserId,
|
||||
},
|
||||
],
|
||||
buildArgs: ({ teamId, userId }) => [
|
||||
"--hook-command",
|
||||
`jq -r ".payload.actor.participantKey" | grep -qx "slack:team:${teamId}:user:${userId}" && echo '{"action":"allow"}' || echo '{"action":"deny"}'`,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "discord",
|
||||
name: "Discord",
|
||||
type: "webhook",
|
||||
hint: "Requires a Discord app and public URL.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--application-id",
|
||||
label: "Application ID",
|
||||
required: true,
|
||||
help: [
|
||||
"Go to discord.com/developers/applications",
|
||||
"Create a new app, copy the Application ID",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--bot-token",
|
||||
label: "Bot token",
|
||||
required: true,
|
||||
help: ["Go to Bot section, create a bot, copy the token"],
|
||||
},
|
||||
{
|
||||
flag: "--public-key",
|
||||
label: "Public key",
|
||||
required: true,
|
||||
help: ["Found in General Information of your app"],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
help: [
|
||||
"Base URL for the connector",
|
||||
"For Discord, set the Interactions Endpoint URL to <base-url>/api/webhooks/discord",
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "whatsapp",
|
||||
name: "WhatsApp",
|
||||
type: "webhook",
|
||||
hint: "Requires Meta developer account and public URL.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--phone-number-id",
|
||||
label: "Phone number ID",
|
||||
required: true,
|
||||
help: ["From your WhatsApp Business account in Meta Developer portal"],
|
||||
},
|
||||
{
|
||||
flag: "--access-token",
|
||||
label: "Access token",
|
||||
required: true,
|
||||
help: ["Generate a permanent token in Meta Developer portal"],
|
||||
},
|
||||
{
|
||||
flag: "--app-secret",
|
||||
label: "App secret",
|
||||
required: true,
|
||||
help: ["Found in App Settings > Basic"],
|
||||
},
|
||||
{
|
||||
flag: "--verify-token",
|
||||
label: "Webhook verify token",
|
||||
placeholder: "my-verify-token",
|
||||
required: true,
|
||||
help: ["Any string you choose, used to verify webhook setup"],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "gchat",
|
||||
name: "Google Chat",
|
||||
type: "webhook",
|
||||
hint: "Requires Google Cloud project and public URL.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--credentials-json",
|
||||
label: "Service account credentials JSON",
|
||||
required: true,
|
||||
help: [
|
||||
"Create a service account in Google Cloud Console",
|
||||
"Download the credentials JSON file",
|
||||
"Paste the JSON content here",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "linear",
|
||||
name: "Linear",
|
||||
type: "webhook",
|
||||
hint: "React to Linear issues and comments.",
|
||||
fields: [
|
||||
{
|
||||
flag: "--api-key",
|
||||
label: "API key",
|
||||
required: true,
|
||||
help: ["Go to Linear Settings > API > Personal API keys"],
|
||||
},
|
||||
{
|
||||
flag: "--webhook-secret",
|
||||
label: "Webhook signing secret",
|
||||
required: true,
|
||||
help: [
|
||||
"Go to Settings > API > Webhooks, create one",
|
||||
"Copy the signing secret",
|
||||
],
|
||||
},
|
||||
{
|
||||
flag: "--base-url",
|
||||
label: "Public base URL",
|
||||
placeholder: "https://example.com",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
export const PLATFORMS = CONNECTOR_PLATFORMS;
|
||||
export const shouldIncludeField = shouldIncludeConnectorField;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user