Compare commits

..
Author SHA1 Message Date
Saoud Rizwan b8f465e316 fix(cli): address greptile review on the agent install command
Ctrl+C in the plugin confirm prompt now cancels the wizard (exit 1, with
a pointer to the already-installed profile) instead of being read as a
decline. The --json output reports plugin names in one consistent shape
across all five outcome categories instead of mixing a nested plan with
top-level arrays. Display-name resolution failures are guarded per
plugin path so one unreadable plugin cannot hide the rest of a directory
during install planning or profile restriction.
2026-06-11 01:41:38 -07:00
Saoud Rizwan b94d3c2751 fix(cli): sweep stale always-on state and bound wrapped descriptions
Any plugin enable/disable toggle now clears the always-on flag, so a
stale disabled-plus-always-on state from older settings is cleaned up by
the next toggle, and the asterisk no longer renders on disabled rows.
Wrapped descriptions in below-mode searchable lists are capped at two
lines so a pathological description cannot grow the list past the dialog
height.
2026-06-11 01:39:08 -07:00
Saoud Rizwan eb401bbb44 fix(cli): clear a plugin's always-on flag when it is disabled
Always-on never overrides a global disable, so an asterisk on a disabled
plugin row was dead state. Disabling a plugin now clears the flag, and
the A action is only offered on enabled rows.
2026-06-11 01:20:50 -07:00
Saoud Rizwan cb87c76d18 feat(cli): show full agent descriptions on their own wrapped line
The agents selector squeezed names against truncated descriptions on one
line. SearchableList gains an opt-in detailPosition=below mode: the name
line never shrinks and the description renders in full underneath with
word wrap, sharing one container so the selection highlight and mouse
target cover both lines. Below-mode windows show fewer items since each
takes about two lines.
2026-06-11 01:18:00 -07:00
Saoud Rizwan abfe3f2330 fix(cli): keep the full item name visible in searchable list rows
A long description could squeeze the name in /agents and other selector
dialogs. The label no longer shrinks; the detail truncates in whatever
space remains.
2026-06-11 01:14:54 -07:00
Saoud Rizwan 9333463a41 fix(cli): refresh plugin slash commands after plugin deletion too
Deleting a plugin from settings restarted session policies but left the
plugin command host and autocomplete holding the deleted plugin's
commands. The delete path now resets the host and bumps the autocomplete
refresh key like toggles and profile switches do.
2026-06-11 00:32:10 -07:00
Saoud Rizwan a8105d54c4 fix(cli): refresh plugin slash command autocomplete after plugin set changes
The runtime drops its plugin command host when toggles or profile
switches change the plugin set, but the TUI loaded the autocomplete list
once and never asked again, so the palette kept showing the old plugin
commands. A refresh key bumped after plugin toggles, always-on changes,
and profile switches re-runs the loader; when the host kept its cache the
reload is a no-op.
2026-06-11 00:24:47 -07:00
Saoud Rizwan 4d23e414bc fix(cli): apply always-on plugin toggles to the live session
The A keybinding wrote alwaysEnabledPlugins straight from the settings
view and only patched local UI state, so flipping it while a profile
restriction was active left the running session and plugin slash
commands stale. The toggle now flows through the config data loader and
a run-interactive wrapper like the other toggles: it returns fresh
config data, surfaces write errors in the settings footer, and restarts
the session (resetting the slash command host) only when an active
profile restriction makes the flag matter.
2026-06-11 00:13:22 -07:00
Saoud Rizwan 54537e217b fix(sdk): plugin display names only come from owning manifests
getPluginDisplayName walked up to the nearest package.json regardless of
whether it had anything to do with the plugin, so a single-file plugin in
.cline/plugins inside a package repo took the repo's name. Since profile
plugin matching and agent install dependency checks now key off this
name, the walk only trusts a manifest that owns the entry: the entry's
immediate parent directory, or an ancestor whose cline.plugins declares
the entry path (install wrappers). Unrelated ancestor manifests stop the
walk and the name falls back to the filename. This also fixes the
long-flaky display-name assertions in sandboxes with a stray
/tmp/package.json.
2026-06-11 00:12:54 -07:00
Saoud Rizwan 83595bc06a fix(cli): agent command review fixes
Plugin dependency planning now only matches globally installed plugins;
a workspace-local plugin satisfied the check before, leaving the globally
installed profile without its plugin in other workspaces. agent list now
resolves the git workspace root like the rest of the CLI instead of
treating the raw cwd as the root.
2026-06-10 23:29:55 -07:00
Saoud Rizwan 88ff2d8000 fix(cli): restart the live session when a plugin is toggled in settings
Plugin toggles persisted to global settings but returned undefined from
the config data loader, so the runtime never refreshed session policies
and the running session kept its plugin set until the next restart.
Returning fresh config data (with plugin diagnostics) routes plugin
toggles through the same deferred restart that skills and mcp toggles
already use, and makes the view's manual plugin reload branch redundant.
2026-06-10 23:29:27 -07:00
Saoud Rizwan 7859f6ca1c fix(cli): apply profile plugin restriction to plugin slash commands
The plugin chat command host loaded every installed plugin and was cached
for the whole TUI session, so a plugin excluded by the active profile
could still register and execute slash commands, and commands loaded
before a profile switch survived the restart. The host now resolves with
the profile's disabled set and is dropped (with shutdown) on profile
switches and plugin toggles so the next use reloads against the current
plugin set.
2026-06-10 23:28:57 -07:00
Saoud Rizwan caa32e3a30 feat(cli): add cline agent install/uninstall/list command
Installs single-file agent profiles from an official cline/agents
registry slug, an https .yml URL (GitHub blob URLs rewritten to raw), or
a local path. Profiles are validated with parseConfiguredAgentConfig
before anything is written to ~/.cline/agents/<name>.yml; name collisions
require --force.

When the profile declares plugins, the install classifies them: already
installed (matched by display name), installable (carries an install
source), or manual (no source, reported with guidance). Installable
plugins run arbitrary code, so the interactive flow shows a clack note
listing them and asks for confirmation; --yes skips the prompt and
non-interactive runs without --yes skip the installs entirely.
2026-06-10 21:35:25 -07:00
Saoud Rizwan 8acf15f406 refactor(cli): extract shared install helpers from the plugin command
Pulls the source-agnostic pieces (home path expansion, slug checks, git
subprocess runner, https single-file URL normalization with GitHub raw
rewriting, and the size-capped remote download) into install-utils so the
upcoming cline agent command reuses them instead of copying.
2026-06-10 21:27:59 -07:00
Saoud Rizwan 5f707a637a feat(cli): always-on plugin marker in the settings dialog
Pressing A on a plugin row in /settings flips alwaysEnabledPlugins in
global settings and shows an asterisk next to the plugin name. Marked
plugins stay enabled when an agent profile restricts the plugin set.
2026-06-10 21:23:11 -07:00
Saoud Rizwan ae40cf1926 feat(cli): restrict plugin set from the active agent profile
When a profile carries a plugins list, every installed plugin whose
display name is not listed and not marked always-enabled is added to the
session's disabledPluginPaths. The set is recomputed on every session
(re)start, so /agents switches swap plugins via the existing in-place
restart and reverting to the default agent clears the restriction. The
--agent flag applies the same restriction for non-interactive runs.
Names that match no installed plugin are silently ignored.
2026-06-10 21:18:05 -07:00
Saoud Rizwan 85d009a4ab refactor(sdk,cli): move getPluginDisplayName to shared storage
The TUI-local copy becomes the single shared derivation so the session
config layer and the settings tab resolve the same plugin names.
2026-06-10 21:17:27 -07:00
Saoud Rizwan ccfc3b9c5d feat(sdk): session-scoped plugin disabling and always-enabled plugins
disabledPluginPaths on CoreSessionConfig is a session-only disable list
unioned with globally disabled plugins during plugin resolution, so agent
profiles can restrict the plugin set without mutating persisted settings.
The field is plain data so it survives the JSON serialization to hub-backed
daemon sessions. Plugin skill directories follow the filtered set since the
bootstrap derives them from the loaded plugin paths.

alwaysEnabledPlugins in global settings marks plugins exempt from profile
driven disabling; it never overrides an explicit global disable.
2026-06-10 20:45:54 -07:00
Saoud Rizwan d5d989111f feat(sdk): add plugins frontmatter field to agent profiles
Each entry is either a bare plugin name or a {name, install} mapping where
install is a source string for cline agent install. Entries normalize to
ConfiguredAgentPluginRef with case-insensitive dedupe by name. An absent
field stays undefined (no plugin restriction) while an explicitly empty
list parses to [] so a profile can opt into running with no plugins.
2026-06-10 20:45:23 -07:00
Saoud Rizwan acf7fde625 fix(cli): tune agent status label 2026-06-10 17:30:11 -07:00
Saoud Rizwan c725f3da42 fix(cli): show agent profile in status bar 2026-06-10 17:29:47 -07:00
Saoud Rizwan 874b495abf Merge branch 'main' into saoudrizwan/agent-profiles 2026-06-10 17:11:46 -07:00
Saoud Rizwan ccc8e5759d fix(cli): unblock agent profile merge checks 2026-06-10 17:10:22 -07:00
Saoud Rizwan 8b9590e1ea fix(cli): require path separator when classifying workspace agents
A bare startsWith(workspaceRoot) would label a profile under
/workspace-linked as a workspace agent when the root is /workspace.
Append the native separator so only real children match.
2026-06-10 15:51:02 -07:00
Saoud Rizwan 9b4aa6307b refactor(sdk,cli): trim comments and dead surface from agent profiles
Drop unused description/path fields from ActiveAgentProfile (captured and
threaded but never read), remove the unconsumed package index exports of
composeClineSystemPrompt/DEFAULT_CLINE_PERSONA/DEFAULT_CLINE_WORKING_GUIDELINES,
and cut inline comments that restated the code, keeping the ones that
explain non-obvious behavior (byte-identity, persona insertion order,
metadata dedup, mid-run guard).
2026-06-10 15:31:47 -07:00
Saoud Rizwan adb7f014cf fix(cli): refuse agent switching while a task is running
Applying a profile restarts the session in place, and the selector is
reachable mid-run via the status bar indicator and /agents autocomplete.
Guard the selector entry point with a status message instead of yanking
the live stream, mirroring how fork refuses when unavailable.
2026-06-10 15:09:08 -07:00
Saoud Rizwan ebefaa68c4 fix(sdk,cli): harden persona insertion and --system/--agent precedence
Insert the persona body after all placeholder replacements so template
tokens inside a profile body stay literal instead of hijacking the first
replacement. Resolve the guidelines slot before the persona inside
composeClineSystemPrompt for the same reason. Clarify in the docs that the
persona slot covers the default persona and its working guidelines.

When --system and --agent are both passed, ignore the agent profile
entirely instead of storing it: keeping it around meant a later plan/act
toggle silently switched the session to the profile prompt and the status
bar showed an agent that was never used.
2026-06-10 15:04:00 -07:00
Saoud Rizwan 0e240ed329 feat(cli): wire up agent profile selection for the main agent
Agent profiles in .cline/agents were discovered and listed in /settings
but never usable. This adds session-only selection of a profile for the
main Cline agent:

- /agents dialog selector (with hidden /agent alias) listing workspace
  and global profiles plus parse errors, following the provider picker
  pattern; also reachable from the command palette (Opt+T)
- status bar indicator showing the active profile next to the Plan/Act
  toggle, clickable to reopen the selector
- selecting a profile re-resolves the system prompt with the profile
  body in the persona slot and restarts the session in place keeping
  messages; plan/act toggles preserve the active profile
- --agent <name> flag for non-interactive runs (rejected in yolo mode,
  errors list available profiles); -s still wins over --agent
- interactive-config agent listing now reuses the SDK parser instead of
  a duplicated frontmatter regex, and broken profile files stay visible
  with their load error

Only name/description/body apply to the main agent; providerId/modelId/
tools in profile frontmatter remain subagent-spawn concerns.
2026-06-10 14:43:36 -07:00
Saoud Rizwan 2a4e33105c style(sdk): sort imports in agent-message-codec.test.ts 2026-06-10 14:43:06 -07:00
Saoud Rizwan 7e8a0df023 feat(sdk): apply persona slot to subagent system prompts
Subagent spawn prompts (configured agent profiles and ad-hoc spawn_agent)
now fill the persona slot instead of fully replacing the system prompt, so
subagents keep the env block and tool-call loop harness. The non-cline
provider early return is removed: the harness is provider-agnostic and the
Cline-specific workspace metadata is already gated on providerId inside
buildClineSystemPrompt. Teammate prompts are unchanged.
2026-06-10 14:42:06 -07:00
Saoud Rizwan df7124d561 feat(sdk): split Cline system prompt into persona slot and agent harness
Extract the coding-agent persona from DEFAULT_CLINE_SYSTEM_PROMPT into a
template slot so an agent profile body can replace just the persona while
keeping the harness (env block, tool-call loop contract, completion
instructions, rules/metadata placeholders). Adds composeClineSystemPrompt
plus a personaPrompt option on buildClineSystemPrompt; the default output
stays byte-identical, pinned by test. overridePrompt and the yolo template
are unchanged. Metadata injection is skipped when the persona already
embeds a workspace configuration block. Placeholder replacements now use
replacer functions so values containing $&-style patterns are inserted
literally.
2026-06-10 14:41:26 -07:00
226 changed files with 7695 additions and 17539 deletions
-14
View File
@@ -1,19 +1,5 @@
# Changelog
## [3.89.2]
### Fixed
- Complete the fix for the Anthropic provider on VS Code 1.123 and later by upgrading the bundled Anthropic SDK to a release compatible with the Node 24 runtime.
- Update the Vertex AI provider to a compatible Anthropic Vertex SDK release so it works with the upgraded Anthropic SDK.
## [3.89.1]
### Fixed
- Restore the Anthropic provider on VS Code 1.123 and later, where the updated Node 24 runtime broke the bundled Anthropic SDK.
- Handle the DeepSeek V4 reasoning format.
## [3.89.0]
### Added
-37
View File
@@ -1,42 +1,5 @@
# Cline CLI Changelog
## 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
-24
View File
@@ -163,30 +163,6 @@ 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.
-14
View File
@@ -85,20 +85,6 @@ 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 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@cline/cli",
"displayName": "cline",
"version": "3.0.27",
"version": "3.0.23",
"description": "Autonomous coding agent CLI - capable of creating/editing files, running commands, using the browser, and more",
"type": "module",
"publishConfig": {
@@ -87,7 +87,6 @@
"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",
-21
View File
@@ -746,27 +746,6 @@ 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-"));
+22 -94
View File
@@ -18,8 +18,6 @@ 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
@@ -53,40 +51,16 @@ function buildScriptCommand(scriptedInput: string, launchArgs: string): string {
return `(${scriptedInput}) | script ${quietFlag} /dev/null ${toShellSingleQuotedLiteral(bunExec)} ${launchArgs}`;
}
function createCliEnv(): NodeJS.ProcessEnv {
function runInteractiveCli(
steps: KeyStep[],
options?: { launchConfigView?: boolean },
): CliResult {
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.
@@ -106,13 +80,9 @@ function runInteractiveCli(
"-k",
"test-key",
];
const launchArgs = (
options?.launchArgs
? [cliEntry, ...options.launchArgs]
: options?.launchConfigView
? [...baseArgs, "config"]
: baseArgs
)
const launchArgs = [
...(options?.launchConfigView ? [...baseArgs, "config"] : baseArgs),
]
.map((arg) => toShellSingleQuotedLiteral(arg))
.join(" ");
const command = buildScriptCommand(scriptedInput, launchArgs);
@@ -120,7 +90,21 @@ function runInteractiveCli(
return spawnSync("bash", ["-lc", command], {
cwd: cliRoot,
encoding: "utf8",
env,
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"),
},
timeout: INTERACTIVE_TEST_TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024,
});
@@ -204,62 +188,6 @@ 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: "" }],
+210
View File
@@ -0,0 +1,210 @@
import { existsSync, readFileSync } from "node:fs";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setHomeDir } from "@cline/shared/storage";
import { afterEach, describe, expect, it } from "vitest";
import {
installAgentProfile,
parseAgentSource,
planAgentPluginInstalls,
uninstallAgentProfile,
} from "./agent";
const PROFILE = `---
name: reviewer
description: Reviews code
plugins:
- branch-protector
- name: my-tool
install: https://example.com/my-tool.ts
---
You are a meticulous reviewer.`;
describe("agent command", () => {
const envSnapshot = { HOME: process.env.HOME };
afterEach(() => {
process.env.HOME = envSnapshot.HOME;
setHomeDir(envSnapshot.HOME ?? "~");
});
async function setUpHome(): Promise<{ root: string; home: string }> {
// Home is nested under a fixture root so the plugin display-name
// package.json walk never escapes into the shared temp directory.
const root = await mkdtemp(join(tmpdir(), "cli-agent-cmd-"));
const home = join(root, "home");
await mkdir(home, { recursive: true });
process.env.HOME = home;
setHomeDir(home);
return { root, home };
}
describe("parseAgentSource", () => {
it("parses local paths, official slugs, and remote URLs", () => {
expect(parseAgentSource("./reviewer.yml")).toEqual({
type: "local",
path: "./reviewer.yml",
});
expect(parseAgentSource("~/agents/reviewer.yaml")).toEqual({
type: "local",
path: "~/agents/reviewer.yaml",
});
expect(parseAgentSource("reviewer")).toEqual({
type: "official",
slug: "reviewer",
});
expect(parseAgentSource("code-reviewer")).toEqual({
type: "official",
slug: "code-reviewer",
});
expect(
parseAgentSource("https://example.com/profiles/reviewer.yml"),
).toEqual({
type: "remote",
url: "https://example.com/profiles/reviewer.yml",
filename: "reviewer.yml",
});
});
it("rewrites GitHub blob URLs to raw URLs", () => {
expect(
parseAgentSource(
"https://github.com/cline/agents/blob/main/agents/reviewer.yml",
),
).toEqual({
type: "remote",
url: "https://raw.githubusercontent.com/cline/agents/main/agents/reviewer.yml",
filename: "reviewer.yml",
});
});
it("rejects non-yaml GitHub file URLs and http URLs", () => {
expect(() =>
parseAgentSource(
"https://github.com/cline/agents/blob/main/agents/reviewer.md",
),
).toThrow(/must be \.yml or \.yaml/);
expect(() => parseAgentSource("http://example.com/reviewer.yml")).toThrow(
/must use https/,
);
});
});
describe("installAgentProfile", () => {
it("validates and writes the profile under the global agents dir", async () => {
const { root, home } = await setUpHome();
try {
const { config, installPath } = installAgentProfile({
content: PROFILE,
source: "./reviewer.yml",
});
expect(config.name).toBe("reviewer");
expect(installPath).toBe(
join(home, ".cline", "agents", "reviewer.yml"),
);
expect(readFileSync(installPath, "utf8")).toBe(PROFILE);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("rejects invalid profiles before writing anything", async () => {
const { root, home } = await setUpHome();
try {
expect(() =>
installAgentProfile({
content: "not a profile",
source: "./broken.yml",
}),
).toThrow(/Invalid agent profile from \.\/broken\.yml/);
expect(existsSync(join(home, ".cline", "agents"))).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("refuses to replace an existing profile without force", async () => {
const { root } = await setUpHome();
try {
installAgentProfile({ content: PROFILE, source: "a" });
expect(() =>
installAgentProfile({ content: PROFILE, source: "a" }),
).toThrow(/already installed/);
expect(() =>
installAgentProfile({ content: PROFILE, source: "a", force: true }),
).not.toThrow();
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
describe("planAgentPluginInstalls", () => {
it("classifies listed plugins as installed, installable, or manual", async () => {
const { root, home } = await setUpHome();
try {
const userPlugins = join(home, ".cline", "plugins");
await mkdir(userPlugins, { recursive: true });
await writeFile(
join(userPlugins, "branch-protector.ts"),
"export default {}",
"utf8",
);
const plan = planAgentPluginInstalls([
{ name: "Branch-Protector" },
{ name: "my-tool", install: "https://example.com/my-tool.ts" },
{ name: "mystery-plugin" },
]);
expect(plan.alreadyInstalled).toEqual([{ name: "Branch-Protector" }]);
expect(plan.installable).toEqual([
{ name: "my-tool", install: "https://example.com/my-tool.ts" },
]);
expect(plan.manual).toEqual([{ name: "mystery-plugin" }]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("returns an empty plan when the profile lists no plugins", async () => {
const { root } = await setUpHome();
try {
expect(planAgentPluginInstalls(undefined)).toEqual({
alreadyInstalled: [],
installable: [],
manual: [],
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
describe("uninstallAgentProfile", () => {
it("removes a profile by frontmatter name or file name", async () => {
const { root } = await setUpHome();
try {
installAgentProfile({ content: PROFILE, source: "a" });
const result = uninstallAgentProfile("Reviewer");
expect(result.name).toBe("reviewer");
expect(existsSync(result.installPath)).toBe(false);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("lists available profiles when the name does not match", async () => {
const { root } = await setUpHome();
try {
installAgentProfile({ content: PROFILE, source: "a" });
expect(() => uninstallAgentProfile("nope")).toThrow(
/available: reviewer/,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
});
+520
View File
@@ -0,0 +1,520 @@
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { basename, extname, join, resolve } from "node:path";
import * as p from "@clack/prompts";
import {
type ConfiguredAgentConfig,
type ConfiguredAgentPluginRef,
discoverPluginModulePaths,
loadConfiguredAgentConfigs,
parseConfiguredAgentConfig,
resolvePluginConfigSearchPaths,
} from "@cline/core";
import {
getPluginDisplayName,
resolveAgentsConfigDirPath,
} from "@cline/shared/storage";
import { resolveWorkspaceRoot } from "../utils/helpers";
import {
downloadRemoteFile,
isLocalPathLike,
isOfficialRegistrySlug,
normalizeRemoteSingleFileUrl,
resolveHomePath,
runCommand,
sanitizeSegment,
} from "./install-utils";
import { installPlugin } from "./plugin";
export interface AgentCommandIo {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
}
export interface AgentInstallOptions {
source: string;
force?: boolean;
/** Install profile-declared plugins without asking. */
yes?: boolean;
json?: boolean;
cwd?: string;
officialAgentsRepo?: string;
io?: AgentCommandIo;
}
export interface AgentInstallResult {
source: string;
name: string;
installPath: string;
/** Plugin names by outcome, one consistent shape across categories. */
plugins: {
alreadyInstalled: string[];
installed: string[];
failed: string[];
skipped: string[];
manual: string[];
};
}
export type ParsedAgentSource =
| { type: "official"; slug: string }
| { type: "remote"; url: string; filename: string }
| { type: "local"; path: string };
export const OFFICIAL_AGENTS_REPO = "https://github.com/cline/agents.git";
const AGENTS_REPO_DIRECTORY_NAME = "agents";
const REMOTE_AGENT_FETCH_TIMEOUT_MS = 30_000;
const REMOTE_AGENT_MAX_BYTES = 1024 * 1024;
const AGENT_SOURCE_KIND = "agent profile";
function isAgentConfigFilename(filename: string): boolean {
const extension = extname(filename).toLowerCase();
return extension === ".yml" || extension === ".yaml";
}
export function parseAgentSource(source: string): ParsedAgentSource {
const trimmed = source.trim();
if (!trimmed) {
throw new Error("agent install requires a source");
}
if (isLocalPathLike(trimmed)) {
return { type: "local", path: source };
}
const remote = normalizeRemoteSingleFileUrl(trimmed, {
isExpectedFile: isAgentConfigFilename,
kind: AGENT_SOURCE_KIND,
extensionsLabel: ".yml or .yaml",
fallbackFilename: "agent.yml",
});
if (remote) {
return { type: "remote", ...remote };
}
if (isOfficialRegistrySlug(trimmed)) {
return { type: "official", slug: trimmed };
}
return { type: "local", path: source };
}
async function fetchOfficialAgentProfile(
slug: string,
officialAgentsRepo: string,
): Promise<string> {
const stagingRoot = await mkdtemp(join(tmpdir(), "cline-agent-install-"));
try {
await runCommand("git", [
"clone",
"--filter=blob:none",
"--depth",
"1",
"--",
officialAgentsRepo,
stagingRoot,
]);
for (const extension of [".yml", ".yaml"]) {
const candidate = join(
stagingRoot,
AGENTS_REPO_DIRECTORY_NAME,
`${slug}${extension}`,
);
if (existsSync(candidate)) {
return readFileSync(candidate, "utf8");
}
}
throw new Error(
`Official Cline agent "${slug}" was not found at ${AGENTS_REPO_DIRECTORY_NAME}/${slug}.yml in ${officialAgentsRepo}`,
);
} finally {
await rm(stagingRoot, { recursive: true, force: true });
}
}
async function fetchAgentProfileContent(
parsed: ParsedAgentSource,
cwd: string,
officialAgentsRepo: string,
): Promise<string> {
if (parsed.type === "official") {
return fetchOfficialAgentProfile(parsed.slug, officialAgentsRepo);
}
if (parsed.type === "remote") {
const body = await downloadRemoteFile(parsed.url, {
timeoutMs: REMOTE_AGENT_FETCH_TIMEOUT_MS,
maxBytes: REMOTE_AGENT_MAX_BYTES,
kind: AGENT_SOURCE_KIND,
});
return body.toString("utf8");
}
const absolutePath = resolve(cwd, resolveHomePath(parsed.path));
if (!existsSync(absolutePath)) {
throw new Error(`Agent profile path does not exist: ${absolutePath}`);
}
if (!isAgentConfigFilename(absolutePath)) {
throw new Error(`Agent profile must be .yml or .yaml: ${absolutePath}`);
}
return readFileSync(absolutePath, "utf8");
}
export interface AgentPluginInstallPlan {
/** Listed plugins already installed (matched by display name). */
alreadyInstalled: ConfiguredAgentPluginRef[];
/** Listed plugins with an install source, not installed yet. */
installable: ConfiguredAgentPluginRef[];
/** Listed plugins with no install source and no local match. */
manual: ConfiguredAgentPluginRef[];
}
export function planAgentPluginInstalls(
plugins: ConfiguredAgentPluginRef[] | undefined,
): AgentPluginInstallPlan {
const plan: AgentPluginInstallPlan = {
alreadyInstalled: [],
installable: [],
manual: [],
};
if (!plugins?.length) {
return plan;
}
const installedNames = new Set<string>();
// Global plugin directories only: the profile installs globally, so a
// workspace-local plugin cannot satisfy its dependencies.
for (const directory of resolvePluginConfigSearchPaths(undefined)) {
let pluginPaths: string[] = [];
try {
pluginPaths = discoverPluginModulePaths(directory);
} catch {
// Best effort: skip unreadable plugin roots.
}
for (const pluginPath of pluginPaths) {
try {
installedNames.add(getPluginDisplayName(pluginPath).toLowerCase());
} catch {
// Best effort: one unreadable plugin should not hide the rest.
}
}
}
for (const ref of plugins) {
if (installedNames.has(ref.name.toLowerCase())) {
plan.alreadyInstalled.push(ref);
} else if (ref.install) {
plan.installable.push(ref);
} else {
plan.manual.push(ref);
}
}
return plan;
}
export function installAgentProfile(options: {
content: string;
source: string;
force?: boolean;
}): { config: ConfiguredAgentConfig; installPath: string } {
let config: ConfiguredAgentConfig;
try {
config = parseConfiguredAgentConfig(options.content);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Invalid agent profile from ${options.source}: ${message}`);
}
const agentsDir = resolveAgentsConfigDirPath();
const installPath = join(
agentsDir,
`${sanitizeSegment(config.name.toLowerCase(), "agent")}.yml`,
);
if (existsSync(installPath) && options.force !== true) {
throw new Error(
`Agent profile is already installed at ${installPath}. Use --force to replace it.`,
);
}
mkdirSync(agentsDir, { recursive: true });
writeFileSync(installPath, options.content, "utf8");
return { config, installPath };
}
function formatPluginRef(ref: ConfiguredAgentPluginRef): string {
return ref.install && ref.install !== ref.name
? `${ref.name} (${ref.install})`
: ref.name;
}
async function installPluginDependencies(input: {
refs: ConfiguredAgentPluginRef[];
wizard: boolean;
io?: AgentCommandIo;
}): Promise<{ installed: string[]; failed: string[] }> {
const installed: string[] = [];
const failed: string[] = [];
for (const ref of input.refs) {
const source = ref.install ?? ref.name;
const spinner = input.wizard ? p.spinner() : undefined;
spinner?.start(`Installing plugin ${ref.name}`);
try {
const result = await installPlugin({ source });
spinner?.stop(`Installed plugin ${ref.name}`);
if (!input.wizard) {
input.io?.writeln(
`Installed plugin ${ref.name} at ${result.installPath}`,
);
}
installed.push(ref.name);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
spinner?.stop(`Failed to install plugin ${ref.name}: ${message}`);
if (!input.wizard) {
input.io?.writeErr(`Failed to install plugin ${ref.name}: ${message}`);
}
failed.push(ref.name);
}
}
return { installed, failed };
}
export async function runAgentInstallCommand(
options: AgentInstallOptions,
): Promise<number> {
const json = options.json === true;
const wizard = !json && process.stdout.isTTY === true;
const cwd = options.cwd?.trim() ? resolve(options.cwd) : process.cwd();
const officialAgentsRepo =
options.officialAgentsRepo?.trim() || OFFICIAL_AGENTS_REPO;
try {
if (wizard) {
p.intro("cline agent install");
}
const parsed = parseAgentSource(options.source);
const content = await fetchAgentProfileContent(
parsed,
cwd,
officialAgentsRepo,
);
const { config, installPath } = installAgentProfile({
content,
source: options.source.trim(),
force: options.force,
});
if (wizard) {
p.log.success(`Installed agent profile "${config.name}"`);
p.log.info(`Path: ${installPath}`);
} else if (!json) {
options.io?.writeln(`Installed agent profile "${config.name}"`);
options.io?.writeln(` Path: ${installPath}`);
}
const plan = planAgentPluginInstalls(config.plugins);
const reportLine = (text: string) => {
if (wizard) {
p.log.info(text);
} else if (!json) {
options.io?.writeln(text);
}
};
for (const ref of plan.alreadyInstalled) {
reportLine(`Plugin ${ref.name} is already installed`);
}
for (const ref of plan.manual) {
reportLine(
`Profile references plugin ${ref.name} with no install source; install it manually with: cline plugin install <source>`,
);
}
let installed: string[] = [];
let failed: string[] = [];
let skipped: string[] = [];
if (plan.installable.length > 0) {
// Profile-declared plugin installs run arbitrary code; never install
// them without an explicit confirmation or --yes.
let confirmed = options.yes === true;
if (!confirmed && wizard) {
const lines = plan.installable.map(formatPluginRef).join("\n");
p.note(lines, "This agent profile wants to install plugins");
const answer = await p.confirm({
message: `Install ${plan.installable.length} plugin${plan.installable.length === 1 ? "" : "s"}?`,
});
if (p.isCancel(answer)) {
p.cancel(
`Cancelled. The agent profile is installed at ${installPath}; install its plugins later with cline plugin install.`,
);
return 1;
}
confirmed = answer === true;
}
if (confirmed) {
const result = await installPluginDependencies({
refs: plan.installable,
wizard,
io: options.io,
});
installed = result.installed;
failed = result.failed;
} else {
skipped = plan.installable.map((ref) => ref.name);
const sources = plan.installable
.map((ref) => `cline plugin install ${ref.install ?? ref.name}`)
.join("; ");
reportLine(`Skipped plugin installs. Run manually: ${sources}`);
}
}
if (wizard) {
p.outro(
failed.length > 0
? "Done with errors"
: `Agent "${config.name}" is ready. Switch to it with /agents or --agent ${config.name}.`,
);
}
if (json) {
const result: AgentInstallResult = {
source: options.source.trim(),
name: config.name,
installPath,
plugins: {
alreadyInstalled: plan.alreadyInstalled.map((ref) => ref.name),
installed,
failed,
skipped,
manual: plan.manual.map((ref) => ref.name),
},
};
process.stdout.write(JSON.stringify(result));
}
return failed.length > 0 ? 1 : 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (wizard) {
p.cancel(message);
} else {
options.io?.writeErr(message);
}
return 1;
}
}
export interface AgentUninstallResult {
name: string;
installPath: string;
}
export function uninstallAgentProfile(name: string): AgentUninstallResult {
const trimmed = name.trim();
if (!trimmed) {
throw new Error("agent uninstall requires a profile name");
}
const agentsDir = resolveAgentsConfigDirPath();
const normalized = trimmed.toLowerCase();
const available: string[] = [];
let entries: string[];
try {
entries = readdirSync(agentsDir);
} catch {
entries = [];
}
for (const entry of entries) {
if (!isAgentConfigFilename(entry)) {
continue;
}
const filePath = join(agentsDir, entry);
let profileName = basename(entry, extname(entry));
try {
profileName = parseConfiguredAgentConfig(
readFileSync(filePath, "utf8"),
).name;
} catch {
// Unparseable file: fall back to matching the filename.
}
available.push(profileName);
if (
profileName.trim().toLowerCase() === normalized ||
basename(entry, extname(entry)).toLowerCase() === normalized
) {
rmSync(filePath);
return { name: profileName, installPath: filePath };
}
}
throw new Error(
available.length > 0
? `Agent profile "${trimmed}" was not found in ${agentsDir} (available: ${available.join(", ")})`
: `Agent profile "${trimmed}" was not found (no agent profiles in ${agentsDir})`,
);
}
export async function runAgentUninstallCommand(options: {
name: string;
json?: boolean;
io?: AgentCommandIo;
}): Promise<number> {
try {
const result = uninstallAgentProfile(options.name);
if (options.json) {
process.stdout.write(JSON.stringify(result));
return 0;
}
options.io?.writeln(`Uninstalled agent profile "${result.name}"`);
options.io?.writeln(` Removed: ${result.installPath}`);
return 0;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
options.io?.writeErr(message);
return 1;
}
}
export async function runAgentListCommand(options: {
cwd?: string;
json?: boolean;
io?: AgentCommandIo;
}): Promise<number> {
const workspaceRoot = resolveWorkspaceRoot(
options.cwd?.trim() ? resolve(options.cwd) : process.cwd(),
);
const { configs, errors } = loadConfiguredAgentConfigs({ workspaceRoot });
if (options.json) {
process.stdout.write(
JSON.stringify({
agents: configs.map((config) => ({
name: config.name,
description: config.description,
path: config.path,
plugins: config.plugins,
})),
errors: errors.map((error) => ({
path: error.path,
message: error.error.message,
})),
}),
);
return 0;
}
if (configs.length === 0 && errors.length === 0) {
options.io?.writeln(
"No agent profiles found. Install one with: cline agent install <source>",
);
return 0;
}
for (const config of configs) {
options.io?.writeln(`${config.name} ${config.description}`);
if (config.path) {
options.io?.writeln(` path: ${config.path}`);
}
if (config.plugins?.length) {
options.io?.writeln(
` plugins: ${config.plugins.map((plugin) => plugin.name).join(", ")}`,
);
}
}
for (const error of errors) {
options.io?.writeErr(
`failed to load ${error.path}: ${error.error.message}`,
);
}
return errors.length > 0 ? 1 : 0;
}
+2 -7
View File
@@ -4,6 +4,7 @@ import {
createOAuthClientCallbacks,
ensureCustomProvidersLoaded,
getProviderAuthHandler,
listLocalProviders,
loginAndSaveProviderOAuthCredentials,
type ProviderSettings,
type ProviderSettingsManager,
@@ -21,8 +22,6 @@ import {
type OAuthCredentials,
toProviderApiKey,
} from "../utils/provider-auth";
import { listLocalProviders } from "../utils/provider-catalog";
import { identifyTelemetryAccount } from "../utils/telemetry";
export {
getPersistedProviderApiKey,
@@ -435,15 +434,11 @@ export async function runAuthProviderCommand(
return 1;
}
try {
const settings = await loginAndSaveProviderOAuthCredentials(
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}`,
);
+280
View File
@@ -0,0 +1,280 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { homedir } from "node:os";
import { basename, join } from "node:path";
/**
* Generic helpers shared by the single-source install commands
* (`cline plugin install`, `cline agent install`).
*/
export function resolveHomePath(value: string): string {
if (value === "~") {
return homedir();
}
if (value.startsWith("~/")) {
return join(homedir(), value.slice(2));
}
return value;
}
export function hashSource(source: string): string {
return createHash("sha256").update(source).digest("hex").slice(0, 12);
}
export function sanitizeSegment(value: string, fallback = "plugin"): string {
const sanitized = value
.replace(/^@/, "")
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return sanitized || fallback;
}
export function isOfficialRegistrySlug(source: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
}
export function isLocalPathLike(source: string): boolean {
return (
source.startsWith(".") ||
source.startsWith("/") ||
source === "~" ||
source.startsWith("~/") ||
/^[A-Za-z]:[\\/]|^\\\\/.test(source)
);
}
export async function runCommand(
command: string,
args: string[],
options: { cwd?: string } = {},
): Promise<void> {
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(command, args, {
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) => {
stderr += String(chunk);
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolvePromise();
return;
}
const details = stderr.trim();
reject(
new Error(
`${command} ${args.join(" ")} failed with exit code ${code}${details ? `: ${details}` : ""}`,
),
);
});
});
}
function decodePathSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function filenameFromUrlPath(pathname: string, fallback: string): string {
const filename = basename(decodePathSegment(pathname));
return filename || fallback;
}
function isGitHubFilePath(pathname: string): boolean {
const parts = pathname.split("/").filter(Boolean);
return parts.length >= 5 && (parts[2] === "blob" || parts[2] === "raw");
}
export interface NormalizeRemoteSingleFileUrlOptions {
/** Whether the URL's filename has an expected extension for this kind. */
isExpectedFile: (filename: string) => boolean;
/** Short noun for error messages, e.g. "plugin" or "agent profile". */
kind: string;
/** Human label of accepted extensions, e.g. ".js or .ts". */
extensionsLabel: string;
/** Fallback filename when the URL path has none. */
fallbackFilename: string;
}
/**
* Validates an https single-file URL, rewriting GitHub blob/raw page URLs to
* raw.githubusercontent.com. Returns null when the source is not a candidate
* file URL for this kind; throws when it is but violates a constraint.
*/
export function normalizeRemoteSingleFileUrl(
source: string,
options: NormalizeRemoteSingleFileUrlOptions,
): { url: string; filename: string } | null {
if (!/^https?:\/\//i.test(source)) {
return null;
}
let parsed: URL;
try {
parsed = new URL(source);
} catch {
return null;
}
const host = parsed.hostname.toLowerCase();
const filename = filenameFromUrlPath(
parsed.pathname,
options.fallbackFilename,
);
const isExpectedFile = options.isExpectedFile(filename);
const isGitHubFile =
(host === "github.com" || host === "www.github.com") &&
isGitHubFilePath(parsed.pathname);
if (parsed.protocol !== "https:") {
if (
isGitHubFile ||
host === "raw.githubusercontent.com" ||
isExpectedFile
) {
throw new Error(
`Remote ${options.kind} file URLs must use https: ${source}`,
);
}
return null;
}
if (host === "github.com" || host === "www.github.com") {
const parts = parsed.pathname.split("/").filter(Boolean);
if (!isGitHubFile) {
return null;
}
if (!isExpectedFile) {
throw new Error(
`Remote ${options.kind} file must be ${options.extensionsLabel}: ${source}`,
);
}
const rawParts = [parts[0], parts[1], ...parts.slice(3)];
return {
url: `https://raw.githubusercontent.com/${rawParts.join("/")}`,
filename,
};
}
if (host === "raw.githubusercontent.com") {
if (!isExpectedFile) {
throw new Error(
`Remote ${options.kind} file must be ${options.extensionsLabel}: ${source}`,
);
}
return { url: parsed.toString(), filename };
}
if (!isExpectedFile) {
return null;
}
return { url: parsed.toString(), filename };
}
export interface DownloadRemoteFileOptions {
timeoutMs: number;
maxBytes: number;
/** Short noun for error messages, e.g. "plugin" or "agent profile". */
kind: string;
}
function sizeLimitError(
url: string,
options: DownloadRemoteFileOptions,
): Error {
return new Error(
`Remote ${options.kind} file from ${url} exceeds the ${options.maxBytes} byte limit`,
);
}
function getContentLength(response: Response): number | undefined {
const raw = response.headers.get("content-length");
if (!raw) {
return undefined;
}
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
return undefined;
}
return value;
}
async function readRemoteBody(
response: Response,
url: string,
options: DownloadRemoteFileOptions,
): Promise<Buffer> {
const contentLength = getContentLength(response);
if (contentLength !== undefined && contentLength > options.maxBytes) {
throw sizeLimitError(url, options);
}
if (!response.body) {
const body = Buffer.from(await response.text(), "utf8");
if (body.byteLength > options.maxBytes) {
throw sizeLimitError(url, options);
}
return body;
}
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let received = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = Buffer.from(value);
received += chunk.byteLength;
if (received > options.maxBytes) {
await reader.cancel().catch(() => undefined);
throw sizeLimitError(url, options);
}
chunks.push(chunk);
}
return Buffer.concat(chunks, received);
} finally {
reader.releaseLock();
}
}
export async function downloadRemoteFile(
url: string,
options: DownloadRemoteFileOptions,
): Promise<Buffer> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, options.timeoutMs);
try {
const response = await fetch(url, { signal: controller.signal });
if (!response.ok) {
const suffix = response.statusText ? ` ${response.statusText}` : "";
throw new Error(
`Failed to download ${options.kind} file from ${url}: ${response.status}${suffix}`,
);
}
return await readRemoteBody(response, url, options);
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error(
`Timed out downloading ${options.kind} file from ${url} after ${options.timeoutMs}ms`,
);
}
throw error;
} finally {
clearTimeout(timeout);
}
}
-145
View File
@@ -1,145 +0,0 @@
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.",
);
});
});
-121
View File
@@ -1,121 +0,0 @@
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;
}
}
-338
View File
@@ -17,7 +17,6 @@ import {
} from "@cline/shared/storage";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
collectPluginMcpOAuthCandidates,
installPlugin,
isOfficialPluginSlug,
parsePluginSource,
@@ -36,7 +35,6 @@ 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-"));
@@ -45,7 +43,6 @@ 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");
@@ -94,11 +91,6 @@ 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 });
});
@@ -684,341 +676,11 @@ 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": {
+30 -458
View File
@@ -1,5 +1,3 @@
import { spawn } from "node:child_process";
import { createHash } from "node:crypto";
import {
type Dirent,
existsSync,
@@ -11,7 +9,6 @@ import {
statSync,
} from "node:fs";
import { cp, mkdir, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import {
basename,
dirname,
@@ -21,20 +18,22 @@ import {
resolve,
sep,
} from "node:path";
import {
type McpServerRegistration,
type PluginMcpSettingsSyncResult,
type PluginUninstallOptions,
resolveDefaultMcpSettingsPath,
resolveMcpServerRegistrations,
syncPluginMcpServersToSettings,
uninstallPlugin,
} from "@cline/core";
import { type PluginUninstallOptions, uninstallPlugin } from "@cline/core";
import {
isPluginModulePath,
resolveClineDir,
resolvePluginModuleEntries,
} from "@cline/shared/storage";
import {
downloadRemoteFile,
hashSource,
isLocalPathLike,
isOfficialRegistrySlug,
normalizeRemoteSingleFileUrl,
resolveHomePath,
runCommand,
sanitizeSegment,
} from "./install-utils";
export interface PluginInstallOptions {
source: string;
@@ -44,31 +43,12 @@ 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 {
@@ -136,35 +116,12 @@ const WRAPPER_PACKAGE_JSON = {
},
};
function resolveHomePath(value: string): string {
if (value === "~") {
return homedir();
}
if (value.startsWith("~/")) {
return join(homedir(), value.slice(2));
}
return value;
}
function toPosixPath(path: string): string {
return path.split(sep).join("/");
}
function hashSource(source: string): string {
return createHash("sha256").update(source).digest("hex").slice(0, 12);
}
function sanitizeSegment(value: string): string {
const sanitized = value
.replace(/^@/, "")
.replace(/[^a-zA-Z0-9._-]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
return sanitized || "plugin";
}
export function isOfficialPluginSlug(source: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(source.trim());
return isOfficialRegistrySlug(source);
}
function resolveOfficialPluginsRepo(override: string | undefined): string {
@@ -246,78 +203,16 @@ function splitGitRef(input: string): { repo: string; ref?: string } {
};
}
function decodePathSegment(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function filenameFromUrlPath(pathname: string): string {
const filename = basename(decodePathSegment(pathname));
return filename || "plugin";
}
function isGitHubFilePath(pathname: string): boolean {
const parts = pathname.split("/").filter(Boolean);
return parts.length >= 5 && (parts[2] === "blob" || parts[2] === "raw");
}
function normalizeRemotePluginFileUrl(
source: string,
): Extract<ParsedPluginSource, { type: "remote" }> | null {
if (!/^https?:\/\//i.test(source)) {
return null;
}
let parsed: URL;
try {
parsed = new URL(source);
} catch {
return null;
}
const host = parsed.hostname.toLowerCase();
const filename = filenameFromUrlPath(parsed.pathname);
const isPluginFile = isPluginModulePath(filename);
const isGitHubFile =
(host === "github.com" || host === "www.github.com") &&
isGitHubFilePath(parsed.pathname);
if (parsed.protocol !== "https:") {
if (isGitHubFile || host === "raw.githubusercontent.com" || isPluginFile) {
throw new Error(`Remote plugin file URLs must use https: ${source}`);
}
return null;
}
if (host === "github.com" || host === "www.github.com") {
const parts = parsed.pathname.split("/").filter(Boolean);
if (!isGitHubFile) {
return null;
}
if (!isPluginFile) {
throw new Error(`Remote plugin file must be .js or .ts: ${source}`);
}
const rawParts = [parts[0], parts[1], ...parts.slice(3)];
return {
type: "remote",
url: `https://raw.githubusercontent.com/${rawParts.join("/")}`,
filename,
};
}
if (host === "raw.githubusercontent.com") {
if (!isPluginFile) {
throw new Error(`Remote plugin file must be .js or .ts: ${source}`);
}
return { type: "remote", url: parsed.toString(), filename };
}
if (!isPluginFile) {
return null;
}
return { type: "remote", url: parsed.toString(), filename };
const remote = normalizeRemoteSingleFileUrl(source, {
isExpectedFile: isPluginModulePath,
kind: "plugin",
extensionsLabel: ".js or .ts",
fallbackFilename: "plugin",
});
return remote ? { type: "remote", ...remote } : null;
}
function parseGitSource(
@@ -404,13 +299,7 @@ export function parsePluginSource(
const { name } = parseNpmSpec(spec);
return { type: "npm", spec, name };
}
const localPathLike =
trimmed.startsWith(".") ||
trimmed.startsWith("/") ||
trimmed === "~" ||
trimmed.startsWith("~/") ||
/^[A-Za-z]:[\\/]|^\\\\/.test(trimmed);
if (localPathLike) {
if (isLocalPathLike(trimmed)) {
return { type: "local", path: source };
}
const remote = normalizeRemotePluginFileUrl(trimmed);
@@ -523,39 +412,6 @@ function getWrapperPackageName(
return sanitizeSegment(basename(resolve(cwd, resolveHomePath(parsed.path))));
}
async function runCommand(
command: string,
args: string[],
options: { cwd?: string } = {},
): Promise<void> {
await new Promise<void>((resolvePromise, reject) => {
const child = spawn(command, args, {
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) => {
stderr += String(chunk);
});
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) {
resolvePromise();
return;
}
const details = stderr.trim();
reject(
new Error(
`${command} ${args.join(" ")} failed with exit code ${code}${details ? `: ${details}` : ""}`,
),
);
});
});
}
function readPackageManifest(
packageRoot: string,
): PluginPackageManifest | null {
@@ -859,94 +715,18 @@ async function installOfficialPlugin(
return packageRoot;
}
function remotePluginSizeLimitError(url: string): Error {
return new Error(
`Remote plugin file from ${url} exceeds the ${REMOTE_PLUGIN_MAX_BYTES} byte limit`,
);
}
function getContentLength(response: Response): number | undefined {
const raw = response.headers.get("content-length");
if (!raw) {
return undefined;
}
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) {
return undefined;
}
return value;
}
async function readRemotePluginBody(
response: Response,
url: string,
): Promise<Buffer> {
const contentLength = getContentLength(response);
if (contentLength !== undefined && contentLength > REMOTE_PLUGIN_MAX_BYTES) {
throw remotePluginSizeLimitError(url);
}
if (!response.body) {
const body = Buffer.from(await response.text(), "utf8");
if (body.byteLength > REMOTE_PLUGIN_MAX_BYTES) {
throw remotePluginSizeLimitError(url);
}
return body;
}
const reader = response.body.getReader();
const chunks: Buffer[] = [];
let received = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
const chunk = Buffer.from(value);
received += chunk.byteLength;
if (received > REMOTE_PLUGIN_MAX_BYTES) {
await reader.cancel().catch(() => undefined);
throw remotePluginSizeLimitError(url);
}
chunks.push(chunk);
}
return Buffer.concat(chunks, received);
} finally {
reader.releaseLock();
}
}
async function installRemoteFile(
parsed: Extract<ParsedPluginSource, { type: "remote" }>,
stagingRoot: string,
): Promise<string> {
const controller = new AbortController();
const timeout = setTimeout(() => {
controller.abort();
}, REMOTE_PLUGIN_FETCH_TIMEOUT_MS);
try {
const response = await fetch(parsed.url, { signal: controller.signal });
if (!response.ok) {
const suffix = response.statusText ? ` ${response.statusText}` : "";
throw new Error(
`Failed to download plugin file from ${parsed.url}: ${response.status}${suffix}`,
);
}
const body = await readRemotePluginBody(response, parsed.url);
mkdirSync(stagingRoot, { recursive: true });
await writeFile(join(stagingRoot, parsed.filename), body);
return stagingRoot;
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error(
`Timed out downloading plugin file from ${parsed.url} after ${REMOTE_PLUGIN_FETCH_TIMEOUT_MS}ms`,
);
}
throw error;
} finally {
clearTimeout(timeout);
}
const body = await downloadRemoteFile(parsed.url, {
timeoutMs: REMOTE_PLUGIN_FETCH_TIMEOUT_MS,
maxBytes: REMOTE_PLUGIN_MAX_BYTES,
kind: "plugin",
});
mkdirSync(stagingRoot, { recursive: true });
await writeFile(join(stagingRoot, parsed.filename), body);
return stagingRoot;
}
async function installLocalPackage(
@@ -1032,81 +812,6 @@ 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> {
@@ -1173,161 +878,28 @@ export async function installPlugin(
}
replaceInstallPath(stagingRoot, installPath, force);
const result = {
return {
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(serializePluginInstallResult(result)),
);
process.stdout.write(JSON.stringify(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);
+6
View File
@@ -51,6 +51,10 @@ export function addRootOptions(cmd: Command): Command {
"-s, --system <system-prompt>",
"Override the default system prompt",
)
.option(
"--agent <name>",
"Use an agent profile from .cline/agents for this session",
)
.option("-z, --zen", "Start a session that runs in the background hub")
.option(
"--retries [value]",
@@ -116,6 +120,7 @@ export function createProgram(): Command {
writeOut: () => {}, // suppress by default; main.ts re-enables for routing
writeErr: () => {},
})
.allowUnknownOption()
.allowExcessArguments()
.enablePositionalOptions()
.argument(
@@ -224,6 +229,7 @@ export function commanderToParsedArgs(program: Command): ParsedArgs {
if (opts.cwd !== undefined) result.cwd = opts.cwd;
if (opts.teamName !== undefined) result.teamName = opts.teamName;
if (opts.system !== undefined) result.systemPrompt = opts.system;
if (opts.agent !== undefined) result.agent = opts.agent;
if (opts.model !== undefined) result.model = opts.model;
if (opts.provider !== undefined) result.provider = opts.provider;
if (opts.key !== undefined) result.key = opts.key;
-50
View File
@@ -1,50 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildSkillsArgs } from "./skill";
describe("buildSkillsArgs", () => {
it("runs the skills package through npx with -y", () => {
expect(buildSkillsArgs(["list"])).toEqual(["-y", "skills@latest", "list"]);
});
it("injects --agent cline for install-style subcommands", () => {
expect(buildSkillsArgs(["install", "owner/repo"])).toEqual([
"-y",
"skills@latest",
"install",
"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("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("does not scope non-install subcommands to cline", () => {
expect(buildSkillsArgs(["use", "owner/repo"])).not.toContain("--agent");
expect(buildSkillsArgs(["remove"])).not.toContain("--agent");
expect(buildSkillsArgs(["list"])).not.toContain("--agent");
});
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"]);
});
});
-117
View File
@@ -1,117 +0,0 @@
import { type SpawnOptions, spawn } from "node:child_process";
export interface SkillCommandIo {
writeln: (text?: string) => void;
writeErr: (text: string) => void;
}
// `cline skill` is a thin wrapper around the open skills CLI
// (https://www.npmjs.com/package/skills). We run it through `npx` so users
// don't need a separate global install. Pin the version here if we ever need to
// lock behavior to a known-good release.
const SKILLS_PACKAGE = "skills@latest";
// Subcommands that write skill files into an agent's skills directory. For a
// `cline skill` command we default these to Cline unless the user picked their
// own agent. `use` is intentionally excluded: without --agent it prints the
// generated prompt to stdout, whereas adding --agent would launch that agent
// interactively instead — not what someone scoping to Cline would expect.
const CLINE_SCOPED_SUBCOMMANDS = new Set(["add", "install", "i", "update"]);
function hasAgentFlag(args: readonly string[]): boolean {
return args.some(
(arg) => arg === "-a" || arg === "--agent" || arg.startsWith("--agent="),
);
}
function findSubcommand(args: readonly string[]): string | undefined {
return args.find((arg) => !arg.startsWith("-"));
}
/**
* 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);
if (
subcommand &&
CLINE_SCOPED_SUBCOMMANDS.has(subcommand) &&
!hasAgentFlag(args)
) {
args.push("--agent", "cline");
}
return ["-y", SKILLS_PACKAGE, ...args];
}
function resolveExitCode(
code: number | null,
signal: NodeJS.Signals | null,
): number {
if (code !== null) {
return code;
}
switch (signal) {
case "SIGINT":
return 130;
case "SIGTERM":
return 143;
default:
return 1;
}
}
/**
* Forward all arguments to the open skills CLI via `npx skills`.
*
* Returns the child process exit code, or 1 if npx is unavailable or fails to
* spawn. stdio is inherited so the skills CLI's interactive prompts and output
* pass straight through to the user's terminal.
*/
export async function runSkillCommand(
userArgs: readonly string[],
io: SkillCommandIo,
): Promise<number> {
const args = buildSkillsArgs(userArgs);
const isWindows = process.platform === "win32";
const options: SpawnOptions = {
stdio: "inherit",
env: process.env,
// Prevent a console window from flashing on Windows.
windowsHide: true,
...(isWindows ? { shell: true } : {}),
};
return new Promise<number>((resolve) => {
const child = spawn("npx", args, options);
const forward = (signal: NodeJS.Signals) => {
child.kill(signal);
};
const handleSigint = () => forward("SIGINT");
const handleSigterm = () => forward("SIGTERM");
process.on("SIGINT", handleSigint);
process.on("SIGTERM", handleSigterm);
const cleanup = () => {
process.off("SIGINT", handleSigint);
process.off("SIGTERM", handleSigterm);
};
child.once("error", (error: NodeJS.ErrnoException) => {
cleanup();
if (error.code === "ENOENT") {
io.writeErr(
'npx was not found. Install Node.js (which includes npx) to use "cline skill".',
);
} else {
io.writeErr(`Failed to run npx ${SKILLS_PACKAGE}: ${error.message}`);
}
resolve(1);
});
child.once("close", (code, signal) => {
cleanup();
resolve(resolveExitCode(code, signal));
});
});
}
@@ -1,17 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, 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 () => {
@@ -20,8 +18,8 @@ vi.mock("@cline/core", async () => {
return {
...actual,
ProviderSettingsManager: class {
getLastUsedProviderSettings(options?: unknown) {
return mockGetLastUsedProviderSettings(options);
getLastUsedProviderSettings() {
return mockGetLastUsedProviderSettings();
}
getProviderSettings(providerId: string) {
@@ -45,12 +43,6 @@ 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")>(
@@ -65,10 +57,6 @@ 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;
@@ -100,64 +88,5 @@ 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");
});
});
+1 -5
View File
@@ -16,7 +16,6 @@ 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,
@@ -63,10 +62,7 @@ export async function buildConnectorStartRequest(input: {
}): Promise<ChatStartSessionRequest> {
const providerSettingsManager = new ProviderSettingsManager();
const lastUsedProviderSettings =
providerSettingsManager.getLastUsedProviderSettings({
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
providerSettingsManager.getLastUsedProviderSettings();
const provider = normalizeProviderId(
input.options.provider?.trim() ||
lastUsedProviderSettings?.provider ||
+165 -118
View File
@@ -2,6 +2,23 @@ import { fstatSync } from "node:fs";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CliMigrationNotice } from "./kanban-migration/notice";
interface MockConfiguredAgentConfig {
name: string;
description: string;
systemPrompt: string;
path?: string;
}
interface MockConfiguredAgentReadError {
path: string;
error: Error;
}
interface MockConfiguredAgentLoadResult {
configs: MockConfiguredAgentConfig[];
errors: MockConfiguredAgentReadError[];
}
/** Real `fstatSync`: used when tests stub only stdin (fd 0); throwing for every fd breaks imports and session I/O. */
const fsActual = vi.hoisted(() => ({
realFstatSync: null as null | typeof import("node:fs").fstatSync,
@@ -29,9 +46,7 @@ const authMocks = vi.hoisted(() => ({
runAuthCommand: vi.fn(),
}));
const providerSettingsMocks = vi.hoisted(() => ({
getLastUsedProviderSettings: vi.fn<(options?: unknown) => unknown>(
() => undefined,
),
getLastUsedProviderSettings: vi.fn<() => unknown>(() => undefined),
getProviderConfig: vi.fn<(providerId: string, options?: unknown) => unknown>(
() => undefined,
),
@@ -49,6 +64,14 @@ const sessionMocks = vi.hoisted(() => ({
const llmMocks = vi.hoisted(() => ({
resolveProviderConfig: vi.fn(async (): Promise<unknown> => undefined),
}));
const agentConfigMocks = vi.hoisted(() => ({
loadConfiguredAgentConfigs: vi.fn<
(input: { workspaceRoot?: string }) => MockConfiguredAgentLoadResult
>(() => ({
configs: [],
errors: [],
})),
}));
const promptMocks = vi.hoisted(() => ({
resolveSystemPrompt: vi.fn(async () => "system prompt"),
}));
@@ -84,11 +107,6 @@ 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: {
@@ -108,13 +126,10 @@ const hubRuntimeMocks = vi.hoisted(() => ({
}));
const telemetryMocks = vi.hoisted(() => ({
captureCliExtensionActivated: vi.fn(),
identifyTelemetryAccount: vi.fn(),
identifyCliTelemetryAccount: vi.fn(),
getCliTelemetryService: vi.fn(),
disposeCliTelemetryService: vi.fn(async () => {}),
}));
const featureFlagMocks = vi.hoisted(() => ({
getBooleanFlagEnabled: vi.fn(() => false),
}));
function forcePromptModeInput() {
Object.defineProperty(process.stdin, "isTTY", {
@@ -152,14 +167,15 @@ vi.mock("./session/session", () => sessionMocks);
vi.mock("@cline/core", () => {
return {
resolveProviderConfig: llmMocks.resolveProviderConfig,
loadConfiguredAgentConfigs: agentConfigMocks.loadConfiguredAgentConfigs,
createTeamName: vi.fn(() => "team-test"),
createUserInstructionConfigService: vi.fn(() => ({
start: vi.fn(async () => {}),
stop: vi.fn(),
})),
ProviderSettingsManager: class {
getLastUsedProviderSettings(options?: unknown) {
return providerSettingsMocks.getLastUsedProviderSettings(options);
getLastUsedProviderSettings() {
return providerSettingsMocks.getLastUsedProviderSettings();
}
getProviderSettings(providerId: string) {
return providerSettingsMocks.getProviderSettings(providerId);
@@ -174,12 +190,6 @@ vi.mock("@cline/core", () => {
};
});
vi.mock("./utils/provider-auth", () => authMocks);
vi.mock("./utils/feature-flags", () => ({
getCliFeatureFlagsService: () => ({
getBooleanFlagEnabled: featureFlagMocks.getBooleanFlagEnabled,
}),
refreshCliFeatureFlagsInBackground: vi.fn(),
}));
vi.mock("./runtime/prompt", () => ({
resolveSystemPrompt: promptMocks.resolveSystemPrompt,
}));
@@ -188,7 +198,6 @@ 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);
@@ -208,8 +217,6 @@ 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",
@@ -234,6 +241,11 @@ describe("runCli lightweight command dispatch", () => {
});
llmMocks.resolveProviderConfig.mockReset();
llmMocks.resolveProviderConfig.mockResolvedValue(undefined);
agentConfigMocks.loadConfiguredAgentConfigs.mockReset();
agentConfigMocks.loadConfiguredAgentConfigs.mockReturnValue({
configs: [],
errors: [],
});
authMocks.ensureOAuthProviderApiKey.mockReset();
authMocks.getPersistedProviderApiKey.mockReset();
authMocks.getPersistedProviderApiKey.mockReturnValue(undefined);
@@ -265,7 +277,7 @@ describe("runCli lightweight command dispatch", () => {
updateMocks.getPreferredKanbanInstaller.mockReset();
updateMocks.getPreferredKanbanInstaller.mockReturnValue(undefined);
telemetryMocks.captureCliExtensionActivated.mockReset();
telemetryMocks.identifyTelemetryAccount.mockReset();
telemetryMocks.identifyCliTelemetryAccount.mockReset();
telemetryMocks.getCliTelemetryService.mockReset();
telemetryMocks.disposeCliTelemetryService.mockReset();
telemetryMocks.disposeCliTelemetryService.mockResolvedValue(undefined);
@@ -411,61 +423,6 @@ 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"];
@@ -793,47 +750,10 @@ describe("runCli lightweight command dispatch", () => {
);
});
it("resumes a history-picked session in a child process", async () => {
it("forces chat view when resuming from history picker", 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");
@@ -983,11 +903,138 @@ describe("runCli lightweight command dispatch", () => {
expect(hubRuntimeMocks.ensureCliHubServer).not.toHaveBeenCalled();
});
it("applies an agent profile to prompt runs", async () => {
agentConfigMocks.loadConfiguredAgentConfigs.mockReturnValue({
configs: [
{
name: "Reviewer",
description: "Reviews code",
systemPrompt: "You are a reviewer.",
path: "/repo/.cline/agents/reviewer.yaml",
},
],
errors: [],
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--agent", "reviewer", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(agentConfigMocks.loadConfiguredAgentConfigs).toHaveBeenCalledWith({
workspaceRoot: "/workspace/cline",
});
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
expect.objectContaining({
agentPersona: "You are a reviewer.",
}),
);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
expect.objectContaining({
agentProfile: {
name: "Reviewer",
systemPrompt: "You are a reviewer.",
},
systemPrompt: "system prompt",
}),
expect.anything(),
);
});
it("lets --system override --agent without storing the profile", async () => {
agentConfigMocks.loadConfiguredAgentConfigs.mockReturnValue({
configs: [
{
name: "Reviewer",
description: "Reviews code",
systemPrompt: "You are a reviewer.",
path: "/repo/.cline/agents/reviewer.yaml",
},
],
errors: [],
});
forcePromptModeInput();
process.argv = [
"bun",
"src/index.ts",
"--agent",
"reviewer",
"--system",
"Custom system.",
"hello",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(agentConfigMocks.loadConfiguredAgentConfigs).not.toHaveBeenCalled();
expect(promptMocks.resolveSystemPrompt).toHaveBeenCalledWith(
expect.objectContaining({
explicitSystemPrompt: "Custom system.",
}),
);
expect(runtimeMocks.runAgent).toHaveBeenCalledWith(
"hello",
expect.objectContaining({
agentProfile: undefined,
}),
expect.anything(),
);
});
it("rejects missing agent profiles before starting the runtime", async () => {
agentConfigMocks.loadConfiguredAgentConfigs.mockReturnValue({
configs: [
{
name: "Planner",
description: "Plans work",
systemPrompt: "You are a planner.",
path: "/repo/.cline/agents/planner.yaml",
},
],
errors: [
{
path: "/repo/.cline/agents/broken.yaml",
error: new Error("Missing system prompt body"),
},
],
});
forcePromptModeInput();
process.argv = ["bun", "src/index.ts", "--agent", "reviewer", "hello"];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
expect(runtimeMocks.runInteractive).not.toHaveBeenCalled();
});
it("rejects --agent in yolo mode before loading profiles", async () => {
forcePromptModeInput();
process.argv = [
"bun",
"src/index.ts",
"--yolo",
"--agent",
"reviewer",
"hello",
];
const { runCli } = await import("./main");
await expect(runCli()).resolves.toBeUndefined();
expect(process.exitCode).toBe(1);
expect(agentConfigMocks.loadConfiguredAgentConfigs).not.toHaveBeenCalled();
expect(runtimeMocks.runAgent).not.toHaveBeenCalled();
});
it("rewrites /team prompts and enables teams in single-prompt mode", async () => {
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");
+117 -77
View File
@@ -14,15 +14,12 @@ import {
autoUpdateOnStartup,
getPreferredKanbanInstaller,
} from "./commands/update";
import { resolveAgentProfileDisabledPluginPaths } from "./runtime/agent-profile-plugins";
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
import {
buildCliCompactionConfig,
CLI_COMPACTION_MODE_EXPECTED_TEXT,
} from "./utils/compaction-mode";
import {
getCliFeatureFlagsService,
refreshCliFeatureFlagsInBackground,
} from "./utils/feature-flags";
import {
configureSandboxEnvironment,
normalizeAutoApproveArgs,
@@ -46,7 +43,7 @@ import {
captureCliExtensionActivated,
getCliTelemetryService,
} from "./utils/telemetry";
import type { Config } from "./utils/types";
import type { ActiveAgentProfile, Config } from "./utils/types";
import { runConnectWizard } from "./wizards/connect";
import { runMcpWizard } from "./wizards/mcp";
import { runScheduleWizard } from "./wizards/schedule";
@@ -139,7 +136,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: () => {},
writeErr: (str: string) => process.stderr.write(str),
});
// Default action handles non-subcommand args (e.g. prompt text)
program.action(() => {});
@@ -315,26 +312,71 @@ 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 install <owner/repo> Install a skill into Cline\n" +
" cline skill list List installed skills\n" +
" cline skill remove Remove installed skills\n" +
"\ninstall/add default to '--agent cline' unless you pass your own --agent.\n" +
"Run 'npx skills --help' for the full command reference.",
const agentCmd = program
.command("agent")
.description("Manage Cline Agent profiles")
.action(() => {
agentCmd.help();
});
const agentInstallCmd = agentCmd
.command("install")
.alias("i")
.description(
"Install an agent profile from an official keyword, profile file URL, or a local path",
)
.argument(
"<source>",
"official keyword, profile .yml URL, or local profile path",
)
.option("--force", "Replace an existing profile with the same name")
.option("--yes", "Install profile-declared plugins without asking")
.option("--json", "Output as JSON")
.action(async (source: string) => {
const opts = agentInstallCmd.opts<{
force?: boolean;
yes?: boolean;
json?: boolean;
}>();
const { runAgentInstallCommand } = await import("./commands/agent");
ctx.exitCode = await runAgentInstallCommand({
source,
force: opts.force === true,
yes: opts.yes === true,
json: opts.json === true || program.opts().json === true,
cwd: program.opts().cwd,
io,
});
});
const agentUninstallCmd = agentCmd
.command("uninstall")
.alias("remove")
.alias("rm")
.description("Uninstall a globally installed agent profile by name")
.argument("<name>", "agent profile name or file name")
.option("--json", "Output as JSON")
.action(async (name: string) => {
const opts = agentUninstallCmd.opts<{ json?: boolean }>();
const { runAgentUninstallCommand } = await import("./commands/agent");
ctx.exitCode = await runAgentUninstallCommand({
name,
json: opts.json === true || program.opts().json === true,
io,
});
});
const agentListCmd = agentCmd
.command("list")
.alias("ls")
.description("List available agent profiles")
.option("--json", "Output as JSON")
.action(async () => {
const { runSkillCommand } = await import("./commands/skill");
ctx.exitCode = await runSkillCommand(skillCmd.args, io);
const opts = agentListCmd.opts<{ json?: boolean }>();
const { runAgentListCommand } = await import("./commands/agent");
ctx.exitCode = await runAgentListCommand({
cwd: program.opts().cwd,
json: opts.json === true || program.opts().json === true,
io,
});
});
const connectCmd = program
.command("connect")
.description("Connect to an external channel")
@@ -376,7 +418,7 @@ export async function runCli(): Promise<void> {
}
});
const mcpCmd = program
program
.command("mcp")
.description("Manage MCP servers")
.action(async () => {
@@ -388,31 +430,6 @@ 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");
@@ -659,7 +676,6 @@ export async function runCli(): Promise<void> {
if (err instanceof CommanderError) {
if (err.exitCode !== 0) {
writeErr(err.message);
process.exitCode = err.exitCode;
return;
}
return;
@@ -710,31 +726,9 @@ 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,
@@ -908,12 +902,8 @@ export async function runCli(): Promise<void> {
};
registerDisposable(stopUserInstructionService);
try {
refreshCliFeatureFlagsInBackground();
const lastUsedProviderSettings =
providerSettingsManager.getLastUsedProviderSettings({
isClinePassEnabled:
getCliFeatureFlagsService().getBooleanFlagEnabled("ext-cline-pass"),
});
providerSettingsManager.getLastUsedProviderSettings();
const provider = normalizeProviderId(
args.provider?.trim() || lastUsedProviderSettings?.provider || "cline",
);
@@ -1004,6 +994,50 @@ export async function runCli(): Promise<void> {
cwd,
});
let activeAgentProfile: ActiveAgentProfile | undefined;
const requestedAgentName = args.agent?.trim();
if (requestedAgentName) {
if (isYoloMode) {
writeErr("--agent is not supported in yolo mode");
process.exitCode = 1;
return;
}
if (args.systemPrompt) {
// Don't store an unused profile: it would resurface on plan/act toggles.
writeln(
`${c.dim}[warn] --system overrides --agent; ignoring agent profile "${requestedAgentName}"${c.reset}`,
);
} else {
const { loadConfiguredAgentConfigs } = await import("@cline/core");
const { configs, errors } = loadConfiguredAgentConfigs({
workspaceRoot,
});
const profile = configs.find(
(candidate) =>
candidate.name.trim().toLowerCase() ===
requestedAgentName.toLowerCase(),
);
if (!profile) {
const availableNames = configs.map((candidate) => candidate.name);
writeErr(
availableNames.length > 0
? `agent profile "${requestedAgentName}" not found (available: ${availableNames.join(", ")})`
: `agent profile "${requestedAgentName}" not found (no agent profiles in .cline/agents)`,
);
for (const error of errors) {
writeErr(`failed to load ${error.path}: ${error.error.message}`);
}
process.exitCode = 1;
return;
}
activeAgentProfile = {
name: profile.name,
systemPrompt: profile.systemPrompt,
plugins: profile.plugins?.map((plugin) => plugin.name),
};
}
}
const config: Config = {
providerId: provider,
modelId:
@@ -1018,6 +1052,7 @@ export async function runCli(): Promise<void> {
explicitSystemPrompt: args.systemPrompt,
providerId: provider,
mode: args.mode ?? "act",
agentPersona: activeAgentProfile?.systemPrompt,
}),
execution: {
maxConsecutiveMistakes: args.retries ?? 3,
@@ -1040,6 +1075,11 @@ export async function runCli(): Promise<void> {
telemetry: getCliTelemetryService(loggerAdapter.core),
defaultToolAutoApprove,
toolPolicies,
agentProfile: activeAgentProfile,
disabledPluginPaths: resolveAgentProfileDisabledPluginPaths(
activeAgentProfile,
workspaceRoot,
),
enableSpawnAgent: !isYoloMode,
enableAgentTeams: !isYoloMode,
enableTools: true,
@@ -0,0 +1,171 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { setHomeDir } from "@cline/shared/storage";
import { afterEach, describe, expect, it } from "vitest";
import { resolveAgentProfileDisabledPluginPaths } from "./agent-profile-plugins";
describe("resolveAgentProfileDisabledPluginPaths", () => {
const envSnapshot = {
HOME: process.env.HOME,
CLINE_GLOBAL_SETTINGS_PATH: process.env.CLINE_GLOBAL_SETTINGS_PATH,
};
afterEach(() => {
process.env.HOME = envSnapshot.HOME;
process.env.CLINE_GLOBAL_SETTINGS_PATH =
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
setHomeDir(envSnapshot.HOME ?? "~");
});
async function setUpFixture(): Promise<{
root: string;
home: string;
workspace: string;
listedPlugin: string;
unlistedPlugin: string;
alwaysEnabledPlugin: string;
}> {
// Nested under a fixture root so the display-name package.json walk
// never escapes into the shared temp directory.
const root = await mkdtemp(join(tmpdir(), "cli-profile-plugins-"));
const home = join(root, "home");
const workspace = join(root, "workspace");
await mkdir(home, { recursive: true });
await mkdir(workspace, { recursive: true });
process.env.HOME = home;
setHomeDir(home);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(home, "global-settings.json");
const workspacePlugins = join(workspace, ".cline", "plugins");
const userPlugins = join(home, ".cline", "plugins");
await mkdir(workspacePlugins, { recursive: true });
await mkdir(userPlugins, { recursive: true });
const listedPlugin = join(workspacePlugins, "listed-plugin.js");
const unlistedPlugin = join(workspacePlugins, "unlisted-plugin.js");
const alwaysEnabledPlugin = join(userPlugins, "always-on.js");
await writeFile(listedPlugin, "export default {}", "utf8");
await writeFile(unlistedPlugin, "export default {}", "utf8");
await writeFile(alwaysEnabledPlugin, "export default {}", "utf8");
return {
root,
home,
workspace,
listedPlugin,
unlistedPlugin,
alwaysEnabledPlugin,
};
}
it("returns undefined when the profile has no plugins field", async () => {
const fixture = await setUpFixture();
try {
expect(
resolveAgentProfileDisabledPluginPaths(undefined, fixture.workspace),
).toBeUndefined();
expect(
resolveAgentProfileDisabledPluginPaths({}, fixture.workspace),
).toBeUndefined();
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("disables installed plugins not listed in the profile", async () => {
const fixture = await setUpFixture();
try {
const disabled = resolveAgentProfileDisabledPluginPaths(
{ plugins: ["Listed-Plugin"] },
fixture.workspace,
);
expect(disabled).toContain(fixture.unlistedPlugin);
expect(disabled).toContain(fixture.alwaysEnabledPlugin);
expect(disabled).not.toContain(fixture.listedPlugin);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("exempts always-enabled plugins from profile disabling", async () => {
const fixture = await setUpFixture();
try {
await writeFile(
process.env.CLINE_GLOBAL_SETTINGS_PATH ?? "",
JSON.stringify({
alwaysEnabledPlugins: [fixture.alwaysEnabledPlugin],
}),
"utf8",
);
const disabled = resolveAgentProfileDisabledPluginPaths(
{ plugins: ["listed-plugin"] },
fixture.workspace,
);
expect(disabled).toContain(fixture.unlistedPlugin);
expect(disabled).not.toContain(fixture.alwaysEnabledPlugin);
expect(disabled).not.toContain(fixture.listedPlugin);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("disables everything but always-enabled plugins for an empty list", async () => {
const fixture = await setUpFixture();
try {
await writeFile(
process.env.CLINE_GLOBAL_SETTINGS_PATH ?? "",
JSON.stringify({
alwaysEnabledPlugins: [fixture.alwaysEnabledPlugin],
}),
"utf8",
);
const disabled = resolveAgentProfileDisabledPluginPaths(
{ plugins: [] },
fixture.workspace,
);
expect(disabled).toContain(fixture.listedPlugin);
expect(disabled).toContain(fixture.unlistedPlugin);
expect(disabled).not.toContain(fixture.alwaysEnabledPlugin);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
it("matches names resolved from an install wrapper package.json", async () => {
const fixture = await setUpFixture();
try {
const installRoot = join(
fixture.home,
".cline",
"plugins",
"_installed",
"registry",
"branch-protector-abc123",
);
const packageRoot = join(installRoot, "package");
await mkdir(packageRoot, { recursive: true });
await writeFile(
join(installRoot, "package.json"),
JSON.stringify({
name: "branch-protector",
private: true,
cline: { plugins: [{ paths: ["./package/index.ts"] }] },
}),
"utf8",
);
const wrappedEntry = join(packageRoot, "index.ts");
await writeFile(wrappedEntry, "export default {}", "utf8");
const disabled = resolveAgentProfileDisabledPluginPaths(
{ plugins: ["branch-protector"] },
fixture.workspace,
);
expect(disabled).not.toContain(wrappedEntry);
expect(disabled).toContain(fixture.listedPlugin);
} finally {
await rm(fixture.root, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,57 @@
import {
discoverPluginModulePaths,
resolveAlwaysEnabledPluginPaths,
resolvePluginConfigSearchPaths,
} from "@cline/core";
import { getPluginDisplayName } from "@cline/shared/storage";
import type { ActiveAgentProfile } from "../utils/types";
/**
* Computes the session-scoped plugin disable list for an agent profile's
* plugins restriction: every installed plugin whose display name is not in
* the profile's list and is not marked always-enabled in global settings.
* Returns undefined when the profile has no plugins field (no restriction).
* Names listed in the profile that match no installed plugin are silently
* ignored. Recomputed on every session (re)start so plugin installs and
* always-enabled toggles apply on the next restart.
*/
export function resolveAgentProfileDisabledPluginPaths(
profile: Pick<ActiveAgentProfile, "plugins"> | undefined,
workspaceRoot: string | undefined,
): string[] | undefined {
const pluginNames = profile?.plugins;
if (!pluginNames) {
return undefined;
}
const allowedNames = new Set(
pluginNames.map((name) => name.trim().toLowerCase()).filter(Boolean),
);
const alwaysEnabled = resolveAlwaysEnabledPluginPaths();
const disabled = new Set<string>();
for (const directory of resolvePluginConfigSearchPaths(workspaceRoot)) {
let pluginPaths: string[];
try {
pluginPaths = discoverPluginModulePaths(directory);
} catch {
continue;
}
for (const pluginPath of pluginPaths) {
if (alwaysEnabled.has(pluginPath)) {
continue;
}
let displayName: string;
try {
displayName = getPluginDisplayName(pluginPath);
} catch {
// Unresolvable name cannot match the allowlist; disable it.
disabled.add(pluginPath);
continue;
}
if (allowedNames.has(displayName.toLowerCase())) {
continue;
}
disabled.add(pluginPath);
}
}
return [...disabled];
}
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
import {
type ChatCommandState,
chatCommandHost,
createChatCommandHost,
} from "../../utils/chat-commands";
import type { Config } from "../../utils/types";
import {
@@ -163,37 +162,4 @@ 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; commandOutput?: string };
| { handled: false; input: string };
function commandTurnResult(commandOutput?: string): InteractiveTurnResult {
return {
@@ -46,7 +46,6 @@ 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);
@@ -65,7 +64,6 @@ export async function runInteractiveChatCommand(input: {
}
let commandOutput: string | undefined;
let submitPrompt: string | undefined;
const handled = await maybeHandleChatCommand(prompt, {
enabled: input.enabled,
host: input.host,
@@ -82,13 +80,6 @@ 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();
@@ -107,13 +98,6 @@ 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,11 +1,4 @@
import {
chmod,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from "node:fs/promises";
import { 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";
@@ -50,17 +43,9 @@ describe("interactive config data loader", () => {
};
afterEach(async () => {
if (envSnapshot.CLINE_GLOBAL_SETTINGS_PATH === undefined) {
delete process.env.CLINE_GLOBAL_SETTINGS_PATH;
} else {
process.env.CLINE_GLOBAL_SETTINGS_PATH =
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
}
if (envSnapshot.CLINE_MCP_SETTINGS_PATH === undefined) {
delete process.env.CLINE_MCP_SETTINGS_PATH;
} else {
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
}
process.env.CLINE_GLOBAL_SETTINGS_PATH =
envSnapshot.CLINE_GLOBAL_SETTINGS_PATH;
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
await Promise.all(
tempRoots.map((dir) => rm(dir, { recursive: true, force: true })),
);
@@ -91,28 +76,6 @@ 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);
@@ -348,70 +311,6 @@ 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);
@@ -530,7 +429,15 @@ Find installable skills.`,
await writeFile(pluginPath, "export default {};\n");
await writeFile(
process.env.CLINE_GLOBAL_SETTINGS_PATH,
JSON.stringify({ disabledPlugins: [pluginPath] }, null, 2),
JSON.stringify(
{
disabledPlugins: [pluginPath],
// Stale state: disabled and always-on at the same time.
alwaysEnabledPlugins: [pluginPath],
},
null,
2,
),
);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
@@ -544,18 +451,58 @@ Find installable skills.`,
}
const nextData = await loader.onToggleConfigItem(plugin);
const refreshedData = await loader.loadConfigData();
const settings = JSON.parse(
await readFile(process.env.CLINE_GLOBAL_SETTINGS_PATH, "utf8"),
) as { disabledPlugins?: string[] };
) as { disabledPlugins?: string[]; alwaysEnabledPlugins?: string[] };
expect(settings.disabledPlugins).toBeUndefined();
expect(nextData).toBeUndefined();
// Toggling sweeps up the stale always-on flag too.
expect(settings.alwaysEnabledPlugins).toBeUndefined();
// Plugin toggles return fresh data so the runtime restarts the session.
expect(
refreshedData.plugins.find((item) => item.path === pluginPath)?.enabled,
nextData?.plugins.find((item) => item.path === pluginPath)?.enabled,
).toBe(true);
});
it("clears the always-on flag when a plugin is disabled", 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 pluginsDir = join(tempRoot, ".cline", "plugins");
await mkdir(pluginsDir, { recursive: true });
const pluginPath = join(pluginsDir, "workspace-plugin.js");
await writeFile(pluginPath, "export default {};\n");
await writeFile(
process.env.CLINE_GLOBAL_SETTINGS_PATH,
JSON.stringify({ alwaysEnabledPlugins: [pluginPath] }, null, 2),
);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData();
const plugin = data.plugins.find((item) => item.path === pluginPath);
expect(plugin?.enabled).toBe(true);
expect(plugin?.alwaysEnabled).toBe(true);
if (!plugin) {
throw new Error("Expected workspace plugin to be listed");
}
const nextData = await loader.onToggleConfigItem(plugin);
const settings = JSON.parse(
await readFile(process.env.CLINE_GLOBAL_SETTINGS_PATH, "utf8"),
) as { disabledPlugins?: string[]; alwaysEnabledPlugins?: string[] };
expect(settings.disabledPlugins).toEqual([pluginPath]);
expect(settings.alwaysEnabledPlugins).toBeUndefined();
const toggled = nextData?.plugins.find((item) => item.path === pluginPath);
expect(toggled?.enabled).toBe(false);
expect(toggled?.alwaysEnabled).toBe(false);
});
it("deletes a package-backed plugin and refreshes bundled slash commands", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -832,142 +779,6 @@ 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);
+23 -29
View File
@@ -1,9 +1,8 @@
import {
createCoreSettingsService,
disablePluginMcpServersInSettings,
setAlwaysEnabledPlugin,
setDisabledPlugin,
setDisabledTools,
syncPluginMcpServersToSettings,
type UserInstructionConfigService,
uninstallPlugin,
} from "@cline/core";
@@ -72,33 +71,16 @@ export function createInteractiveConfigDataLoader(input: {
}
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
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;
setDisabledPlugin(item.path, item.enabled);
// Any enable/disable toggle clears always-on: the flag never
// overrides a global disable, so it would be a dead marker on a
// disabled plugin, and clearing on enable too sweeps up stale
// disabled-plus-always-on states. It is only set deliberately via
// the A action on an enabled plugin.
setAlwaysEnabledPlugin(item.path, false);
// Returning fresh data signals the runtime to restart the live
// session so the toggle applies immediately, matching skills/mcp.
return await loadConfigData({ ...options, includePluginTools: true });
}
if (item.kind === "mcp" && typeof item.enabled === "boolean") {
@@ -146,6 +128,17 @@ export function createInteractiveConfigDataLoader(input: {
return undefined;
};
const onToggleAlwaysEnabledConfigItem = async (
item: InteractiveConfigItem,
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData | undefined> => {
if (item.kind !== "plugin") {
return undefined;
}
setAlwaysEnabledPlugin(item.path, item.alwaysEnabled !== true);
return await loadConfigData(options);
};
const onDeleteConfigItem = async (
item: InteractiveConfigItem,
options: LoadInteractiveConfigDataOptions = {},
@@ -166,6 +159,7 @@ export function createInteractiveConfigDataLoader(input: {
return {
loadConfigData,
onToggleConfigItem,
onToggleAlwaysEnabledConfigItem,
onDeleteConfigItem,
};
}
@@ -78,4 +78,36 @@ describe("applyInteractiveModeConfig", () => {
expect(config.extraTools).toEqual([]);
expect(config.systemPrompt).toBe("system prompt for act");
});
it("threads the active agent profile persona across mode switches", async () => {
const config = makeConfig();
config.agentProfile = {
name: "reviewer",
systemPrompt: "You are a reviewer.",
};
await applyInteractiveModeConfig({
config,
mode: "plan",
switchToActModeTool,
});
expect(resolveSystemPrompt).toHaveBeenLastCalledWith({
cwd: config.cwd,
providerId: config.providerId,
mode: "plan",
agentPersona: "You are a reviewer.",
});
await applyInteractiveModeConfig({
config,
mode: "act",
switchToActModeTool,
});
expect(resolveSystemPrompt).toHaveBeenLastCalledWith({
cwd: config.cwd,
providerId: config.providerId,
mode: "act",
agentPersona: "You are a reviewer.",
});
});
});
+1
View File
@@ -43,5 +43,6 @@ export async function applyInteractiveModeConfig(input: {
cwd: input.config.cwd,
providerId: input.config.providerId,
mode: input.mode,
agentPersona: input.config.agentProfile?.systemPrompt,
});
}
@@ -1,6 +1,7 @@
import type { TeamEvent } from "@cline/core";
import type { ChatCommandState } from "../../utils/chat-commands";
import type { Config } from "../../utils/types";
import { resolveAgentProfileDisabledPluginPaths } from "../agent-profile-plugins";
import {
CLI_DEFAULT_CHECKPOINT_CONFIG,
CLI_DEFAULT_LOOP_DETECTION,
@@ -27,5 +28,11 @@ export function buildInteractiveSessionConfig(input: {
hooks: input.runtimeHooks.hooks,
onTeamEvent: input.onTeamEvent,
onConsecutiveMistakeLimitReached: input.resolveMistakeLimitDecision,
// Recomputed on every session (re)start so switching profiles swaps the
// plugin set and reverting to the default agent clears the restriction.
disabledPluginPaths: resolveAgentProfileDisabledPluginPaths(
input.config.agentProfile,
input.chatCommandState.workspaceRoot,
),
};
}
@@ -21,7 +21,7 @@ import type {
import { createRuntimeHooks } from "../../utils/hooks";
import { setActiveCliSession } from "../../utils/output";
import { loadInteractiveResumeMessages } from "../../utils/resume";
import type { Config } from "../../utils/types";
import type { ActiveAgentProfile, Config } from "../../utils/types";
import { markAbortInProgress } from "../active-runtime";
import type {
PendingPromptSnapshot,
@@ -359,6 +359,19 @@ export function createInteractiveSessionRuntime(input: {
await restartWithCurrentMessages();
};
const applyAgentProfile = async (
profile: ActiveAgentProfile | undefined,
): Promise<void> => {
input.config.agentProfile = profile;
// Re-apply the current mode so the system prompt picks up the persona.
await applyInteractiveModeConfig({
config: input.config,
mode: input.config.mode === "plan" ? "plan" : "act",
switchToActModeTool: input.switchToActModeTool,
});
await restartWithCurrentMessages();
};
const sendCurrentTurn = async (
turnInput: CurrentTurnInput,
): Promise<CurrentTurnResult> => {
@@ -639,6 +652,7 @@ export function createInteractiveSessionRuntime(input: {
getCheckpointData,
restoreCheckpoint,
applyMode,
applyAgentProfile,
resetAbortRequest,
abortAll,
cleanup,
+3
View File
@@ -28,6 +28,8 @@ export async function resolveSystemPrompt(input: {
providerId?: string;
rules?: string;
mode?: AgentMode;
/** Agent profile body that replaces the persona slot of the base prompt */
agentPersona?: string;
}): Promise<string> {
const metadata = await buildWorkspaceMetadata(input.cwd);
let rules = mergeRulesForSystemPrompt(undefined, input.rules);
@@ -45,6 +47,7 @@ export async function resolveSystemPrompt(input: {
mode: input.mode,
providerId: input.providerId,
overridePrompt: input.explicitSystemPrompt,
personaPrompt: input.agentPersona,
platform:
(typeof process !== "undefined" && process?.platform) || "unknown",
});
+50 -24
View File
@@ -8,7 +8,6 @@ import type { CliMigrationNotice } from "../kanban-migration/notice";
import { logCliError } from "../logging/errors";
import {
loadClineAccountSnapshot,
onProviderChange,
switchClineAccount,
} from "../tui/cline-account";
import type {
@@ -38,6 +37,7 @@ import {
setActiveRuntimeAbort,
setActiveRuntimeCleanup,
} from "./active-runtime";
import { resolveAgentProfileDisabledPluginPaths } from "./agent-profile-plugins";
import { createInteractiveApprovalController } from "./interactive/approvals";
import { runInteractiveChatCommand } from "./interactive/chat-command-runner";
import { createInteractiveConfigDataLoader } from "./interactive/config-data";
@@ -91,6 +91,11 @@ export async function runInteractive(
pluginChatCommandHostPromise ??= createWorkspaceChatCommandHost({
cwd: config.cwd,
workspaceRoot: config.workspaceRoot,
// Honor the active profile's plugin restriction for slash commands too.
disabledPluginPaths: resolveAgentProfileDisabledPluginPaths(
config.agentProfile,
config.workspaceRoot?.trim() || config.cwd,
),
logger: config.logger,
})
.then(({ host, pluginSlashCommands, shutdown }) => {
@@ -109,6 +114,19 @@ export async function runInteractive(
});
return await pluginChatCommandHostPromise;
};
// Drops the cached plugin command host so the next use reloads it against
// the current plugin set (profile switches and plugin toggles change it).
const resetPluginChatCommandHost = async (): Promise<void> => {
await pluginChatCommandHostPromise?.catch(() => []);
const shutdown = pluginChatCommandHostShutdown;
pluginChatCommandHostShutdown = undefined;
pluginChatCommandHostLoaded = false;
pluginChatSlashCommands = [];
interactiveChatCommandHost = chatCommandHost;
await shutdown?.().catch(() => {
// Best effort cleanup for plugin command discovery sandbox.
});
};
const loadAdditionalSlashCommands = async (): Promise<
InteractiveSlashCommand[]
> => await ensurePluginChatCommandHost();
@@ -319,6 +337,27 @@ export async function runInteractive(
> => {
const data = await configDataLoader.onToggleConfigItem(item, options);
if (data && shouldRefreshInteractiveSessionForConfigItem(item)) {
if (item.kind === "plugin") {
await resetPluginChatCommandHost();
}
await refreshInteractiveSessionPolicies();
}
return data;
};
const onToggleAlwaysEnabledConfigItem = async (
item: InteractiveConfigItem,
options: LoadInteractiveConfigDataOptions = {},
): Promise<
Awaited<ReturnType<typeof configDataLoader.onToggleAlwaysEnabledConfigItem>>
> => {
const data = await configDataLoader.onToggleAlwaysEnabledConfigItem(
item,
options,
);
// The flag only affects the live session while a profile restriction is
// active; without one there is nothing to restart.
if (data && config.agentProfile?.plugins) {
await resetPluginChatCommandHost();
await refreshInteractiveSessionPolicies();
}
return data;
@@ -331,6 +370,9 @@ export async function runInteractive(
> => {
const data = await configDataLoader.onDeleteConfigItem(item, options);
if (data && shouldRefreshInteractiveSessionForConfigItem(item)) {
if (item.kind === "plugin") {
await resetPluginChatCommandHost();
}
await refreshInteractiveSessionPolicies();
}
return data;
@@ -410,6 +452,7 @@ export async function runInteractive(
}),
loadConfigData: configDataLoader.loadConfigData,
onToggleConfigItem,
onToggleAlwaysEnabledConfigItem,
onDeleteConfigItem,
subscribeToEvents: ({
onAgentEvent: onAgent,
@@ -428,8 +471,7 @@ export async function runInteractive(
uiEvents.off("pending-prompt-submitted", onPendingPromptSubmitted);
};
},
onSubmit: async (input, mode, delivery, attachments, onCommandOutput) => {
let commandOutput: string | undefined;
onSubmit: async (input, mode, delivery, attachments) => {
try {
await sessionRuntime.ensureReady();
await waitForSubmittedMode(mode);
@@ -448,7 +490,6 @@ export async function runInteractive(
setInteractiveAutoApprove,
sessionRuntime,
stop: () => tuiApp?.destroy(),
onCommandOutput,
});
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
@@ -468,14 +509,12 @@ export async function runInteractive(
setInteractiveAutoApprove,
sessionRuntime,
stop: () => tuiApp?.destroy(),
onCommandOutput,
});
if (chatCommandResult.handled) {
return chatCommandResult.turnResult;
}
}
input = chatCommandResult.input;
commandOutput = chatCommandResult.commandOutput;
const {
prompt: userInput,
userImages,
@@ -512,7 +551,6 @@ export async function runInteractive(
iterations: 0,
finishReason: "queued",
queued: delivery === "queue" || delivery === "steer",
commandOutput,
};
}
if (result.finishReason !== "completed") {
@@ -525,7 +563,6 @@ export async function runInteractive(
currentContextSize: getCurrentContextSize(result.messages),
iterations: result.iterations,
finishReason: "aborted",
commandOutput,
};
}
const errorText = result.text.trim();
@@ -539,7 +576,6 @@ export async function runInteractive(
currentContextSize: getCurrentContextSize(result.messages),
iterations: result.iterations,
finishReason: result.finishReason,
commandOutput,
};
} catch (error) {
if (isAbortInProgress()) {
@@ -547,7 +583,6 @@ export async function runInteractive(
usage: { inputTokens: 0, outputTokens: 0 },
iterations: 0,
finishReason: "aborted",
commandOutput,
};
}
logCliError(config.logger, "Interactive turn failed", {
@@ -607,15 +642,16 @@ export async function runInteractive(
}
await applyModeChange(mode);
},
onAgentProfileChange: async (profile) => {
await sessionRuntime.ensureReady();
await sessionRuntime.applyAgentProfile(profile ?? undefined);
await resetPluginChatCommandHost();
},
onNewSession: async () => {
await sessionRuntime.resetForNewSession();
},
onModelChange: async () => {
await sessionRuntime.ensureReady();
await onProviderChange({
config,
providerId: config.providerId,
});
const existing = providerSettingsManager.getProviderSettings(
config.providerId,
) ?? {
@@ -636,16 +672,6 @@ 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) => {
-9
View File
@@ -12,8 +12,6 @@ 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 =
@@ -51,10 +49,6 @@ 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(),
@@ -74,8 +68,6 @@ 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(() => {
@@ -116,7 +108,6 @@ describe("createCliCore", () => {
backendMode: expect.anything(),
}),
);
expect(featureFlagsPoll).toHaveBeenCalledTimes(1);
});
it("forces the local backend when requested by the caller", async () => {
+1 -13
View File
@@ -15,7 +15,6 @@ 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";
@@ -41,11 +40,6 @@ 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
@@ -59,18 +53,12 @@ export async function createCliCore(options?: {
}
: {}),
capabilities: options?.capabilities,
telemetry,
featureFlags,
telemetry: getCliTelemetryService(options?.logger),
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,
-86
View File
@@ -9,15 +9,9 @@ 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")>();
@@ -30,15 +24,6 @@ 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) {
@@ -51,10 +36,6 @@ vi.mock("@cline/core", async (importOriginal) => {
};
});
vi.mock("../utils/telemetry", () => ({
identifyTelemetryAccount: telemetryMocks.identifyTelemetryAccount,
}));
function makeConfig(overrides: Partial<Config> = {}): Config {
return {
providerId: "cline",
@@ -97,11 +78,7 @@ 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(() => {
@@ -186,66 +163,3 @@ 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),
);
});
});
+1 -37
View File
@@ -14,15 +14,12 @@ 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" | "logger" | "providerId">;
const CLINE_PASS_PROVIDER_ID = "cline-pass";
type ClineAccountConfig = Pick<Config, "apiKey" | "providerId">;
export interface ClineAccountSnapshot {
user: ClineAccountUser;
@@ -170,15 +167,6 @@ 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,
@@ -202,27 +190,3 @@ 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;
}
@@ -271,6 +271,35 @@ describe("slash command registry", () => {
).toContain("settings");
});
it("exposes agents as a local command with a hidden agent alias", () => {
const registry = buildSlashCommandRegistry({});
const commandNames = getVisibleSystemSlashCommands(registry).map(
(command) => command.name,
);
expect(resolveSlashCommand(registry, "agents")).toMatchObject({
source: "tui",
execution: "local",
description: "Switch agent profile",
visible: true,
selectable: true,
});
expect(resolveSlashCommand(registry, "agent")).toMatchObject({
source: "tui",
execution: "local",
visible: false,
selectable: false,
});
expect(commandNames).toContain("agents");
expect(commandNames).not.toContain("agent");
expect(commandNames.indexOf("agents")).toBeGreaterThan(
commandNames.indexOf("model"),
);
expect(commandNames.indexOf("agents")).toBeLessThan(
commandNames.indexOf("account"),
);
});
it("always exposes the account command", () => {
const registry = buildSlashCommandRegistry({});
@@ -17,6 +17,8 @@ export type LocalSlashCommandName =
| "plugins"
| "account"
| "model"
| "agents"
| "agent"
| "compact"
| "skills"
| "fork"
@@ -62,6 +64,15 @@ const TUI_LOCAL_COMMANDS: Array<{
name: "model",
description: "Switch model or provider",
},
{
name: "agents",
description: "Switch agent profile",
},
{
name: "agent",
description: "Switch agent profile",
visible: false,
},
{
name: "account",
description: "View Cline account",
@@ -112,6 +123,7 @@ const TUI_LOCAL_COMMANDS: Array<{
const SYSTEM_COMMAND_ORDER = [
"settings",
"model",
"agents",
"account",
"mcp",
"plugins",
@@ -0,0 +1,110 @@
import { basename } from "node:path";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useMemo } from "react";
import {
type SearchableItem,
SearchableList,
useSearchableList,
} from "../searchable-list";
/** Sentinel resolved when the user picks the default Cline agent. */
export const DEFAULT_AGENT_ACTION = "__default_agent__";
export interface AgentProfileOption {
name: string;
description?: string;
systemPrompt: string;
plugins?: string[];
source: "workspace" | "global";
}
export interface AgentProfileLoadError {
path: string;
message: string;
}
export function AgentSelectorContent(
props: ChoiceContext<string> & {
currentAgentName: string | null;
agents: AgentProfileOption[];
loadErrors: AgentProfileLoadError[];
},
) {
const { resolve, dismiss, dialogId, currentAgentName, agents, loadErrors } =
props;
const items: SearchableItem[] = useMemo(() => {
const normalizedCurrent = currentAgentName?.trim().toLowerCase() ?? null;
const defaultItem: SearchableItem = {
key: DEFAULT_AGENT_ACTION,
label: "Cline (default)",
detail: "Standard Cline agent",
section: "Agents",
rightLabel: normalizedCurrent === null ? "(current)" : undefined,
};
const profileItems = agents.map((agent) => ({
key: agent.name.toLowerCase(),
label: agent.name,
detail: agent.description,
section:
agent.source === "workspace" ? "Workspace agents" : "Global agents",
rightLabel:
normalizedCurrent === agent.name.trim().toLowerCase()
? "(current)"
: undefined,
}));
return [defaultItem, ...profileItems];
}, [agents, currentAgentName]);
const list = useSearchableList(items);
useDialogKeyboard((key) => {
if (key.name === "escape") {
dismiss();
return;
}
if (key.name === "return") {
const item = list.selectedItem;
if (item) resolve(item.key);
return;
}
if (key.name === "up") {
list.moveUp();
return;
}
if (key.name === "down") {
list.moveDown();
}
}, dialogId);
return (
<box flexDirection="column" gap={1}>
<text>Select Agent</text>
<SearchableList
items={list.filtered}
selected={list.safeSelected}
placeholder="Search agents..."
onSearchChange={list.setSearch}
onItemSelect={(item) => resolve(item.key)}
emptyText="No agents match"
detailPosition="below"
/>
{loadErrors.length > 0 && (
<box flexDirection="column">
{loadErrors.map((error) => (
<text key={error.path} fg="red">
{basename(error.path)}: {error.message}
</text>
))}
</box>
)}
<text fg="gray">
Type to search, / navigate, Enter to select, Esc to go back
</text>
</box>
);
}
@@ -2,6 +2,7 @@ export type CommandPaletteAction =
| "settings"
| "change-model"
| "change-provider"
| "agents"
| "account"
| "mcp"
| "plugins"
@@ -57,6 +58,13 @@ const ACTION_ITEMS: Array<{
description: "Switch provider and configure credentials",
keywords: ["provider", "api key", "account", "auth"],
},
{
action: "agents",
label: "Switch Agent",
shortcut: "Opt+T",
description: "Use an agent profile from .cline/agents",
keywords: ["agent", "agents", "profile", "persona", "subagent"],
},
{
action: "mcp",
label: "Manage MCP Servers",
@@ -120,6 +120,12 @@ const HELP_ROWS: HelpRow[] = [
key: "/model",
desc: "Switch model or provider",
},
{
kind: "entry",
id: "c-agents",
key: "/agents",
desc: "Switch agent profile",
},
{
kind: "entry",
id: "c-settings",
@@ -58,11 +58,7 @@ describe("mcp manager dialog helpers", () => {
};
afterEach(async () => {
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;
}
process.env.CLINE_MCP_SETTINGS_PATH = envSnapshot.CLINE_MCP_SETTINGS_PATH;
await Promise.all(
tempRoots.map((directory) =>
rm(directory, { recursive: true, force: true }),
@@ -111,44 +107,6 @@ 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,7 +13,6 @@ export interface McpEntry {
enabled?: boolean;
description?: string;
lastError?: string;
pluginName?: string;
}
export type McpServerToggleResult =
@@ -37,12 +36,6 @@ 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({
@@ -78,7 +71,6 @@ 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") {
@@ -158,7 +150,6 @@ export function McpManagerContent(
{isSel ? "\u25b8 " : " "}
{enabledIcon}
{srv.name}
{srv.pluginName ? " *" : ""}
</text>
{status && (
<text fg={srv.lastError ? palette.error : "gray"}>
@@ -193,12 +184,6 @@ 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,6 +2,7 @@ import {
completeClineDeviceAuth,
getProviderConfigFields,
isOAuthProvider,
listLocalProviders,
loginLocalProvider,
type ProviderConfigFieldKey,
type ProviderConfigFieldRequirement,
@@ -21,7 +22,6 @@ import {
checkCodexCliInstalled,
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { palette } from "../../palette";
import {
getDefaultAwsRegion,
+63 -14
View File
@@ -219,6 +219,8 @@ export function useSearchableList(
}
const MAX_VISIBLE = 10;
// Below-mode items take roughly two lines each, so show fewer at once.
const MAX_VISIBLE_DETAIL_BELOW = 6;
export function SearchableList(props: {
items: SearchableItem[];
@@ -228,6 +230,11 @@ export function SearchableList(props: {
onItemSelect?: (item: SearchableItem) => void;
emptyText?: string;
borderColor?: string;
/**
* Where to render item details: truncated inline next to the label
* (default), or word-wrapped in full on their own line below it.
*/
detailPosition?: "inline" | "below";
}) {
const terminalBg = useTerminalBackground();
const defaultFg = getDefaultForeground(terminalBg);
@@ -239,11 +246,16 @@ export function SearchableList(props: {
onItemSelect,
emptyText = "No results",
borderColor = "gray",
detailPosition = "inline",
} = props;
const safeSelected = Math.min(selected, Math.max(0, items.length - 1));
const { visibleRows, aboveCount, belowCount, showAbove, showBelow } =
getSearchableListRowsWindow(items, safeSelected, MAX_VISIBLE);
getSearchableListRowsWindow(
items,
safeSelected,
detailPosition === "below" ? MAX_VISIBLE_DETAIL_BELOW : MAX_VISIBLE,
);
return (
<box flexDirection="column" gap={1}>
@@ -282,27 +294,21 @@ export function SearchableList(props: {
}
const item = row.item;
const isSel = row.itemIndex === safeSelected;
return (
<box
key={item.key}
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={isSel ? palette.selection : undefined}
onMouseDown={() => onItemSelect?.(item)}
overflow="hidden"
height={1}
>
const labelLine = (
<>
<text
fg={isSel ? palette.textOnSelection : "gray"}
flexShrink={0}
>
{isSel ? "\u276f" : " "}
</text>
<text fg={isSel ? palette.textOnSelection : defaultFg}>
<text
fg={isSel ? palette.textOnSelection : defaultFg}
flexShrink={0}
>
{item.label}
</text>
{item.detail && (
{detailPosition === "inline" && item.detail && (
<text
fg={isSel ? palette.textOnSelection : "gray"}
flexShrink={1}
@@ -334,6 +340,49 @@ export function SearchableList(props: {
{item.rightLabel}
</text>
)}
</>
);
if (detailPosition === "below") {
// One container for both lines so the selection highlight
// and mouse target cover the name and the description.
return (
<box
key={item.key}
paddingX={1}
flexDirection="column"
backgroundColor={isSel ? palette.selection : undefined}
onMouseDown={() => onItemSelect?.(item)}
>
<box flexDirection="row" gap={1} overflow="hidden" height={1}>
{labelLine}
</box>
{item.detail && (
// maxHeight bounds pathological descriptions so wrapped
// items cannot grow the list past the dialog height.
<box paddingLeft={2} maxHeight={2} overflow="hidden">
<text
fg={isSel ? palette.textOnSelection : "gray"}
wrapMode="word"
>
{item.detail}
</text>
</box>
)}
</box>
);
}
return (
<box
key={item.key}
paddingX={1}
flexDirection="row"
gap={1}
backgroundColor={isSel ? palette.selection : undefined}
onMouseDown={() => onItemSelect?.(item)}
overflow="hidden"
height={1}
>
{labelLine}
</box>
);
})}
@@ -1,6 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import {
createContextBar,
formatStatusBarAgentLabel,
formatStatusBarAgentName,
formatStatusBarUsageText,
resolveContextBarFilledForeground,
} from "./status-bar";
@@ -49,6 +51,48 @@ describe("createContextBar", () => {
});
});
describe("formatStatusBarAgentName", () => {
it("keeps short names intact", () => {
expect(formatStatusBarAgentName("reviewer")).toBe("reviewer");
expect(formatStatusBarAgentName(" reviewer ")).toBe("reviewer");
});
it("truncates long names with an ellipsis", () => {
expect(formatStatusBarAgentName("documentation-specialist")).toBe(
"documentation...",
);
expect(formatStatusBarAgentName("documentation-specialist").length).toBe(
16,
);
});
it("handles very narrow limits without negative slicing", () => {
expect(formatStatusBarAgentName("reviewer", 3)).toBe("...");
expect(formatStatusBarAgentName("reviewer", 0)).toBe("");
});
});
describe("formatStatusBarAgentLabel", () => {
it("returns the trimmed active agent name", () => {
expect(formatStatusBarAgentLabel("reviewer")).toBe("reviewer");
expect(formatStatusBarAgentLabel(" reviewer ")).toBe("reviewer");
});
it("truncates to fit the label width", () => {
expect(formatStatusBarAgentLabel("documentation-specialist", 18)).toBe(
"documentation-s...",
);
expect(
formatStatusBarAgentLabel("documentation-specialist", 18)?.length,
).toBe(18);
});
it("hides blank or too-narrow labels", () => {
expect(formatStatusBarAgentLabel(" ")).toBeUndefined();
expect(formatStatusBarAgentLabel("reviewer", 0)).toBeUndefined();
});
});
describe("formatStatusBarUsageText", () => {
it("includes cost when usage cost is visible", () => {
expect(
+54 -6
View File
@@ -104,6 +104,23 @@ export function resolveModelMaxInputTokens(config: {
return undefined;
}
export function formatStatusBarAgentName(name: string, maxLen = 16): string {
const normalized = name.trim();
if (maxLen <= 0) return "";
if (normalized.length <= maxLen) return normalized;
if (maxLen <= 3) return ".".repeat(maxLen);
return `${normalized.slice(0, maxLen - 3)}...`;
}
export function formatStatusBarAgentLabel(
name: string,
maxLen = 18,
): string | undefined {
const normalized = name.trim();
if (!normalized || maxLen <= 0) return undefined;
return formatStatusBarAgentName(normalized, maxLen);
}
export interface StatusBarProps {
providerId: string;
modelId: string;
@@ -120,6 +137,9 @@ export interface StatusBarProps {
deletions: number;
} | null;
onToggleMode?: () => void;
/** Active agent profile name; the indicator is hidden when unset */
agentName?: string | null;
onOpenAgent?: () => void;
variant?: "home" | "chat";
}
@@ -135,6 +155,8 @@ export function StatusBar(props: StatusBarProps) {
gitBranch,
gitDiffStats,
onToggleMode,
agentName,
onOpenAgent,
} = props;
const { width } = useTerminalDimensions();
@@ -162,20 +184,26 @@ export function StatusBar(props: StatusBarProps) {
? Math.min(width, HOME_VIEW_MAX_WIDTH) - 2
: width - 2;
// Row 1 layout: [model + context info] .... [Plan/Act toggle]
// Row 1 layout: [model + context info] .... [agent label] [Plan/Act toggle]
// When the full row doesn't fit, context info drops to its own row 2.
// Model ID truncates with "..." before wrapping; toggle stays right-aligned.
const toggleWidth = 20;
const fullAgentLabel = agentName
? formatStatusBarAgentLabel(agentName)
: undefined;
const agentLabelWidth = fullAgentLabel ? fullAgentLabel.length + 3 : 0;
const usageText = formatStatusBarUsageText({
totalTokens,
totalCost,
showCost: showUsageCost,
});
const contextText = bar
? ` ${bar.filled}${bar.empty} ${usageText}`
: ` ${usageText}`;
const contextInlineText = bar
? `${bar.filled}${bar.empty} ${usageText}`
: usageText;
const contextText = ` ${contextInlineText}`;
const firstRowFits =
modelId.length + contextText.length + toggleWidth + 1 <= avail;
modelId.length + contextText.length + toggleWidth + agentLabelWidth + 1 <=
avail;
const renderContextText = (withLeadingSpace: boolean) => (
<>
{withLeadingSpace && " "}
@@ -191,7 +219,11 @@ export function StatusBar(props: StatusBarProps) {
const modelMaxLen = Math.max(
10,
avail - toggleWidth - (firstRowFits ? contextText.length : 0) - 1,
avail -
toggleWidth -
agentLabelWidth -
(firstRowFits ? contextText.length : 0) -
1,
);
const truncatedModel =
modelId.length > modelMaxLen
@@ -210,6 +242,14 @@ export function StatusBar(props: StatusBarProps) {
pathPart.length > pathMax
? `${pathPart.slice(0, pathMax - 3)}...`
: pathPart;
const firstRowAgentMaxLen = Math.min(
18,
Math.max(0, avail - toggleWidth - 3),
);
const agentLabel = agentName
? formatStatusBarAgentLabel(agentName, firstRowAgentMaxLen)
: undefined;
return (
<box flexDirection="column" paddingX={1}>
<box flexDirection="row" justifyContent="space-between">
@@ -223,6 +263,14 @@ export function StatusBar(props: StatusBarProps) {
flexShrink={0}
onMouseDown={onToggleMode}
>
{agentLabel && (
<>
<box flexShrink={0} onMouseDown={onOpenAgent}>
<text fg={defaultFg}>{agentLabel}</text>
</box>
<text fg="gray">|</text>
</>
)}
<text fg={uiMode === "plan" ? planAccent : "gray"}>
{uiMode === "plan" ? "●" : "○"} Plan
</text>
@@ -21,6 +21,7 @@ interface SessionContextValue {
uiMode: AgentMode;
autoApproveAll: boolean;
compactionMode: CliCompactionMode;
activeAgentName: string | null;
lastTotalTokens: number;
lastTotalCost: number;
isExitRequested: boolean;
@@ -41,6 +42,7 @@ interface SessionContextValue {
setUiMode: (mode: AgentMode) => void;
toggleMode: () => void;
toggleAutoApprove: () => void;
setActiveAgentName: (name: string | null) => void;
setCompactionMode: (mode: CliCompactionMode) => void;
requestExit: () => void;
clearEntries: () => void;
@@ -112,6 +114,9 @@ export function SessionProvider(props: {
const [compactionMode, _setCompactionMode] = useState<CliCompactionMode>(() =>
getCliCompactionMode(config),
);
const [activeAgentName, setActiveAgentName] = useState<string | null>(
config.agentProfile?.name ?? null,
);
const [lastTotalTokens, setLastTotalTokens] = useState(
() => initialUsage?.totalTokens ?? 0,
);
@@ -250,6 +255,7 @@ export function SessionProvider(props: {
uiMode,
autoApproveAll,
compactionMode,
activeAgentName,
lastTotalTokens,
lastTotalCost,
isExitRequested,
@@ -268,6 +274,7 @@ export function SessionProvider(props: {
setUiMode,
toggleMode,
toggleAutoApprove,
setActiveAgentName,
setCompactionMode,
requestExit,
clearEntries,
+13 -20
View File
@@ -17,9 +17,7 @@ export async function renderHistoryStandalone(input: {
});
return new Promise((resolve) => {
let result: number | string = 0;
let resolved = false;
let destroyStarted = false;
let settled = false;
let unmounted = false;
const root = createRoot(renderer);
@@ -31,29 +29,24 @@ export async function renderHistoryStandalone(input: {
root.unmount();
};
// Resolve only once teardown has finished, so callers never run while
// the renderer is still restoring the terminal.
renderer.on("destroy", () => {
unmountRoot();
if (!resolved) {
resolved = true;
resolve(result);
}
});
const settle = (value: number | string) => {
if (destroyStarted) {
if (settled) {
return;
}
destroyStarted = true;
result = value;
settled = true;
unmountRoot();
// Let OpenTUI finish parsing the current stdin batch before teardown.
queueMicrotask(() => {
renderer.destroy();
});
renderer.destroy();
resolve(value);
};
renderer.on("destroy", () => {
unmountRoot();
if (!settled) {
settled = true;
resolve(0);
}
});
root.render(
React.createElement(HistoryStandaloneContent, {
rows: input.rows,
@@ -7,6 +7,7 @@ export interface LocalSlashCommandActionInput {
openConfig: (options?: OpenConfigOptions) => void;
openMcpManager: () => Promise<boolean>;
openModelSelector: () => void;
openAgentSelector: () => void;
openSkills: (invocation?: LocalSlashCommandInvocation) => void;
invocation?: LocalSlashCommandInvocation;
runCompact: () => void;
@@ -45,6 +46,10 @@ export function runLocalSlashCommandAction(
input.openModelSelector();
return true;
}
if (normalized === "agents" || normalized === "agent") {
input.openAgentSelector();
return true;
}
if (normalized === "compact") {
input.runCompact();
return true;
@@ -0,0 +1,121 @@
import { sep } from "node:path";
import { loadConfiguredAgentConfigs } from "@cline/core";
import type { ChoiceContext } from "@opentui-ui/dialog";
import type { DialogActions } from "@opentui-ui/dialog/react";
import { useCallback } from "react";
import type { Config } from "../../utils/types";
import {
type AgentProfileLoadError,
type AgentProfileOption,
AgentSelectorContent,
DEFAULT_AGENT_ACTION,
} from "../components/dialogs/agent-selector";
import { withLoadingDialog } from "../components/dialogs/loading-dialog";
import { useSession } from "../contexts/session-context";
import type { TuiProps } from "../types";
function loadAgentProfileEntries(config: Config): {
agents: AgentProfileOption[];
loadErrors: AgentProfileLoadError[];
} {
const workspaceRoot = config.workspaceRoot?.trim() || config.cwd;
const { configs, errors } = loadConfiguredAgentConfigs({ workspaceRoot });
return {
agents: configs.map((profile) => ({
name: profile.name,
description: profile.description,
systemPrompt: profile.systemPrompt,
plugins: profile.plugins?.map((plugin) => plugin.name),
source:
workspaceRoot && profile.path?.startsWith(`${workspaceRoot}${sep}`)
? ("workspace" as const)
: ("global" as const),
})),
loadErrors: errors.map((error) => ({
path: error.path,
message: error.error.message,
})),
};
}
export function useAgentSelector(opts: {
dialog: DialogActions;
config: Config;
termHeight: number;
onAgentProfileChange: TuiProps["onAgentProfileChange"];
refocusTextarea: () => void;
}): () => Promise<void> {
const { dialog, config, termHeight, onAgentProfileChange, refocusTextarea } =
opts;
const session = useSession();
const openAgentSelector = useCallback(async () => {
// Applying a profile restarts the session in place, so refuse while a
// turn is running instead of yanking the live stream out from under it.
if (session.isRunning) {
session.appendEntry({
kind: "status",
text: "Finish or abort the current task before switching agents.",
});
refocusTextarea();
return;
}
const { agents, loadErrors } = loadAgentProfileEntries(config);
const currentAgentName = config.agentProfile?.name ?? null;
const selectedKey = await dialog.choice<string>({
style: { maxHeight: termHeight - 2 },
content: (ctx: ChoiceContext<string>) => (
<AgentSelectorContent
{...ctx}
currentAgentName={currentAgentName}
agents={agents}
loadErrors={loadErrors}
/>
),
});
if (!selectedKey) {
refocusTextarea();
return;
}
if (selectedKey === DEFAULT_AGENT_ACTION) {
if (config.agentProfile) {
await withLoadingDialog(dialog, "Applying agent...", async () => {
await onAgentProfileChange(null);
});
session.setActiveAgentName(null);
}
refocusTextarea();
return;
}
const profile = agents.find(
(agent) => agent.name.toLowerCase() === selectedKey,
);
if (!profile) {
refocusTextarea();
return;
}
if (profile.name !== currentAgentName) {
await withLoadingDialog(dialog, "Applying agent...", async () => {
await onAgentProfileChange({
name: profile.name,
systemPrompt: profile.systemPrompt,
plugins: profile.plugins,
});
});
session.setActiveAgentName(profile.name);
}
refocusTextarea();
}, [
dialog,
config,
termHeight,
onAgentProfileChange,
refocusTextarea,
session,
]);
return openAgentSelector;
}
@@ -39,6 +39,10 @@ export function useConfigPanel(opts: {
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
onToggleAlwaysEnabledConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
onDeleteConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
@@ -98,6 +102,9 @@ export function useConfigPanel(opts: {
activeTab = tab;
}}
onToggleConfigItem={opts.onToggleConfigItem}
onToggleAlwaysEnabledConfigItem={
opts.onToggleAlwaysEnabledConfigItem
}
onDeleteConfigItem={opts.onDeleteConfigItem}
onToggleMode={opts.toggleMode}
onToggleAutoApprove={opts.toggleAutoApprove}
@@ -13,6 +13,7 @@ function makeActions(
openConfig: vi.fn(),
openMcpManager: vi.fn(async () => false),
openModelSelector: vi.fn(),
openAgentSelector: vi.fn(),
openSkills: vi.fn(),
runCompact: vi.fn(),
runFork: vi.fn(),
@@ -45,6 +46,21 @@ describe("runLocalSlashCommandAction", () => {
expect(openSkills).toHaveBeenCalledWith(invocation);
});
it("opens the agent selector with agents and the agent alias", () => {
for (const name of ["agents", "agent"]) {
const openAgentSelector = vi.fn();
const actions = makeActions({ openAgentSelector });
const handled = runLocalSlashCommandAction({
name,
...actions,
});
expect(handled).toBe(true);
expect(openAgentSelector).toHaveBeenCalledOnce();
}
});
it("opens settings to the plugins tab with plugins", () => {
const openConfig = vi.fn();
const actions = makeActions({ openConfig });
@@ -23,6 +23,7 @@ export function useLocalCommandActions(input: {
openConfig: (options?: OpenConfigOptions) => void;
openMcpManager: () => Promise<boolean>;
openModelSelector: () => void;
openAgentSelector: () => void;
openSkills: (invocation?: LocalSlashCommandInvocation) => void;
refocusTextarea: () => void;
setAppView: (view: AppView) => void;
@@ -43,6 +44,7 @@ export function useLocalCommandActions(input: {
openConfig,
openMcpManager,
openModelSelector,
openAgentSelector,
openSkills,
refocusTextarea,
setAppView,
@@ -185,6 +187,7 @@ export function useLocalCommandActions(input: {
openConfig,
openMcpManager,
openModelSelector,
openAgentSelector,
openSkills,
runCompact,
runFork,
@@ -205,6 +208,7 @@ export function useLocalCommandActions(input: {
openHelp,
openHistory,
openModelSelector,
openAgentSelector,
openSkills,
runCompact,
runFork,
@@ -17,7 +17,6 @@ function toMcpEntries(items: InteractiveConfigItem[]): McpEntry[] {
enabled: item.enabled,
description: item.description,
lastError: item.loadError,
pluginName: item.pluginName,
}));
}
@@ -179,6 +179,7 @@ async function runProviderChange(
config.providerId = newProviderId;
config.apiKey = newApiKey;
const resolved = await resolveProviderConfig(
newProviderId,
{
@@ -327,14 +327,6 @@ 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,
@@ -343,9 +335,8 @@ export function usePromptInputController(input: {
activeUserImages.length > 0
? { userImages: activeUserImages }
: undefined,
appendCommandOutput,
);
if (result.commandOutput && !commandOutputAppended) {
if (result.commandOutput) {
session.appendEntry({
kind: "status",
text: result.commandOutput,
+5 -1
View File
@@ -11,13 +11,17 @@ export function useSlashCommands(input: {
workflowSlashCommands: TuiProps["workflowSlashCommands"];
loadAdditionalSlashCommands: TuiProps["loadAdditionalSlashCommands"];
canFork: boolean;
/** Bump to re-run the loader, e.g. after the plugin set changes. */
refreshKey?: number;
}) {
const { workflowSlashCommands, loadAdditionalSlashCommands, canFork } = input;
const refreshKey = input.refreshKey ?? 0;
const [additionalSlashCommands, setAdditionalSlashCommands] = useState<
TuiProps["workflowSlashCommands"] | undefined
>(loadAdditionalSlashCommands ? [] : undefined);
useEffect(() => {
void refreshKey;
if (!loadAdditionalSlashCommands) {
setAdditionalSlashCommands(undefined);
return;
@@ -37,7 +41,7 @@ export function useSlashCommands(input: {
return () => {
cancelled = true;
};
}, [loadAdditionalSlashCommands]);
}, [loadAdditionalSlashCommands, refreshKey]);
const registry = useMemo(() => {
return buildSlashCommandRegistry({
+38 -117
View File
@@ -1,24 +1,16 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import {
basename,
dirname,
extname,
isAbsolute,
join,
relative,
resolve,
} from "node:path";
import { existsSync } from "node:fs";
import { basename, extname, isAbsolute, relative, resolve } from "node:path";
import {
type BuiltinToolAvailabilityContext,
discoverPluginModulePaths,
hasMcpSettingsFile,
listHookConfigFiles,
listPluginToolsWithDiagnostics,
loadConfiguredAgentConfigs,
type McpServerRegistration,
type PluginInitializationFailure,
type RuleConfig,
readGlobalSettings,
resolveAgentConfigSearchPaths,
resolveDefaultMcpSettingsPath,
resolveMcpServerRegistrations,
resolvePluginConfigSearchPaths,
@@ -27,6 +19,7 @@ import {
type UserInstructionConfigService,
type WorkflowConfig,
} from "@cline/core";
import { getPluginDisplayName } from "@cline/shared/storage";
import { getToolCatalog } from "../runtime/tools";
import {
type InteractiveSlashCommand,
@@ -61,6 +54,8 @@ export interface InteractiveConfigItem {
enabled?: boolean;
kind: InteractiveConfigItemKind;
enabledState?: "enabled" | "disabled" | "partial";
/** Plugins only: exempt from agent-profile plugin restrictions. */
alwaysEnabled?: boolean;
toolNames?: string[];
configKind?: "tool" | "plugin";
pluginName?: string;
@@ -86,7 +81,6 @@ export interface InteractiveConfigData {
mcp: InteractiveConfigItem[];
tools: InteractiveConfigItem[];
workflowSlashCommands: InteractiveSlashCommand[];
pluginDiagnosticsLoaded?: boolean;
}
export interface LoadInteractiveConfigDataOptions {
@@ -94,14 +88,12 @@ export interface LoadInteractiveConfigDataOptions {
}
export function isToggleableInteractiveConfigItem(
item: Pick<InteractiveConfigItem, "kind" | "source" | "pluginName">,
item: Pick<InteractiveConfigItem, "kind" | "source">,
): 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"
@@ -178,92 +170,30 @@ function getMcpDescription(registration: McpServerRegistration): string {
}
function loadAgentConfigItems(workspaceRoot: string): InteractiveConfigItem[] {
const agentsById = new Map<string, InteractiveConfigItem>();
const directories = resolveAgentConfigSearchPaths(workspaceRoot).filter(
(directory) => existsSync(directory),
);
for (const directory of directories) {
try {
const entries = readdirSync(directory, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const extension = extname(entry.name).toLowerCase();
if (extension !== ".yml" && extension !== ".yaml") {
continue;
}
const filePath = join(directory, entry.name);
const raw = readFileSync(filePath, "utf8");
const frontmatterMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
const frontmatter = frontmatterMatch?.[1] ?? "";
const nameMatch = frontmatter.match(/^\s*name:\s*(.+?)\s*$/m);
const descriptionMatch = frontmatter.match(
/^\s*description:\s*(.+?)\s*$/m,
);
const parsedName = nameMatch?.[1]?.replace(/^["']|["']$/g, "").trim();
const parsedDescription = descriptionMatch?.[1]
?.replace(/^["']|["']$/g, "")
.trim();
const name =
parsedName && parsedName.length > 0
? parsedName
: basename(entry.name, extension);
const id = name.toLowerCase();
if (agentsById.has(id)) {
continue;
}
agentsById.set(id, {
id,
name,
path: filePath,
enabled: true,
kind: "agent",
source: detectSource(filePath, workspaceRoot),
description: parsedDescription,
});
}
} catch {
// Best effort: keep listing other agent config roots.
}
const { configs, errors } = loadConfiguredAgentConfigs({ workspaceRoot });
const items: InteractiveConfigItem[] = configs.map((config) => ({
id: config.name.toLowerCase(),
name: config.name,
path: config.path ?? "",
enabled: true,
kind: "agent" as const,
source: detectSource(config.path ?? "", workspaceRoot),
description: config.description,
}));
// Keep broken profile files visible so users can spot and fix them.
for (const error of errors) {
items.push({
id: error.path,
name: basename(error.path, extname(error.path)),
path: error.path,
enabled: false,
kind: "agent" as const,
source: detectSource(error.path, workspaceRoot),
description: error.error.message,
loadError: error.error.message,
});
}
return [...agentsById.values()];
}
function readPackageName(packageJsonPath: string): string | undefined {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
name?: unknown;
};
return typeof packageJson.name === "string" && packageJson.name.trim()
? packageJson.name.trim()
: undefined;
} catch {
return undefined;
}
}
function getPluginDisplayName(filePath: string, searchRoot: string): string {
let current = dirname(filePath);
const root = resolve(searchRoot);
while (isPathWithin(root, current)) {
const packageJsonPath = join(current, "package.json");
if (existsSync(packageJsonPath)) {
const packageName = readPackageName(packageJsonPath);
if (packageName) {
return packageName;
}
break;
}
const parent = resolve(current, "..");
if (parent === current) {
break;
}
current = parent;
}
return basename(filePath, extname(filePath));
return items;
}
function isPathWithin(parentPath: string, childPath: string): boolean {
@@ -379,7 +309,11 @@ export async function loadInteractiveConfigData(input: {
agents.push(...loadAgentConfigItems(input.workspaceRoot));
const disabledPlugins = new Set(readGlobalSettings().disabledPlugins ?? []);
const globalSettings = readGlobalSettings();
const disabledPlugins = new Set(globalSettings.disabledPlugins ?? []);
const alwaysEnabledPlugins = new Set(
globalSettings.alwaysEnabledPlugins ?? [],
);
const pluginDirectories = resolvePluginConfigSearchPaths(
input.workspaceRoot,
).filter((directory) => existsSync(directory));
@@ -388,9 +322,10 @@ export async function loadInteractiveConfigData(input: {
for (const filePath of discoverPluginModulePaths(directory)) {
plugins.push({
id: filePath,
name: getPluginDisplayName(filePath, directory),
name: getPluginDisplayName(filePath),
path: filePath,
enabled: !disabledPlugins.has(filePath),
alwaysEnabled: alwaysEnabledPlugins.has(filePath),
kind: "plugin",
configKind: "plugin",
source: detectPluginSource(filePath, input.workspaceRoot),
@@ -462,16 +397,6 @@ 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,
@@ -481,8 +406,6 @@ export async function loadInteractiveConfigData(input: {
source: detectSource(mcpSettingsPath, input.workspaceRoot),
description: getMcpDescription(registration),
loadError: registration.oauth?.lastError,
pluginName,
pluginPath,
});
}
} catch {
@@ -530,7 +453,6 @@ export async function loadInteractiveConfigData(input: {
toolNames: [pluginTool.name],
configKind: "tool",
pluginName: pluginTool.pluginName,
pluginPath: pluginTool.path,
source: pluginTool.source,
description: pluginTool.description,
});
@@ -550,6 +472,5 @@ export async function loadInteractiveConfigData(input: {
mcp: toSorted(mcp.filter((item) => existsSync(item.path))),
tools: toSorted(tools),
workflowSlashCommands,
pluginDiagnosticsLoaded: input.includePluginTools !== false,
};
}
+42 -1
View File
@@ -41,6 +41,7 @@ import { EventBridgeProvider } from "./contexts/event-bridge-context";
import { SessionProvider, useSession } from "./contexts/session-context";
import { useAccountDialog } from "./hooks/use-account-dialog";
import { useAgentEventHandlers } from "./hooks/use-agent-events";
import { useAgentSelector } from "./hooks/use-agent-selector";
import { useAutocomplete } from "./hooks/use-autocomplete";
import { useConfigPanel } from "./hooks/use-config-panel";
import { useLocalCommandActions } from "./hooks/use-local-command-actions";
@@ -90,6 +91,9 @@ function App(props: TuiProps) {
const [workflowSlashCommands, setWorkflowSlashCommands] = useState(
props.workflowSlashCommands,
);
// Bumped after actions that can change the loaded plugin set so plugin
// slash command autocomplete reloads (a no-op when the host kept its cache).
const [pluginCommandsRefreshKey, setPluginCommandsRefreshKey] = useState(0);
const toastTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const checkpointRestoreInFlightRef = useRef(false);
@@ -118,6 +122,7 @@ function App(props: TuiProps) {
workflowSlashCommands,
loadAdditionalSlashCommands: props.loadAdditionalSlashCommands,
canFork: canForkSession,
refreshKey: pluginCommandsRefreshKey,
});
const autocomplete = useAutocomplete({
@@ -187,6 +192,17 @@ function App(props: TuiProps) {
refocusTextarea: () => refocusTextareaRef.current(),
});
const openAgentSelector = useAgentSelector({
dialog,
config: props.config,
termHeight,
onAgentProfileChange: async (profile) => {
await props.onAgentProfileChange(profile);
setPluginCommandsRefreshKey((key) => key + 1);
},
refocusTextarea: () => refocusTextareaRef.current(),
});
const openMcpManager = useMcpManager({
dialog,
termHeight,
@@ -203,10 +219,28 @@ function App(props: TuiProps) {
const data = await propsOnToggleConfigItem(item, options);
if (data) {
setWorkflowSlashCommands(data.workflowSlashCommands);
if (item.kind === "plugin") {
setPluginCommandsRefreshKey((key) => key + 1);
}
}
return data;
};
}, [propsOnToggleConfigItem]);
const propsOnToggleAlwaysEnabled = props.onToggleAlwaysEnabledConfigItem;
const onToggleAlwaysEnabledConfigItem = useMemo<
TuiProps["onToggleAlwaysEnabledConfigItem"]
>(() => {
if (!propsOnToggleAlwaysEnabled) {
return undefined;
}
return async (item, options) => {
const data = await propsOnToggleAlwaysEnabled(item, options);
if (data) {
setPluginCommandsRefreshKey((key) => key + 1);
}
return data;
};
}, [propsOnToggleAlwaysEnabled]);
const propsOnDeleteConfigItem = props.onDeleteConfigItem;
const onDeleteConfigItem = useMemo<TuiProps["onDeleteConfigItem"]>(() => {
if (!propsOnDeleteConfigItem) {
@@ -216,6 +250,9 @@ function App(props: TuiProps) {
const data = await propsOnDeleteConfigItem(item, options);
if (data) {
setWorkflowSlashCommands(data.workflowSlashCommands);
if (item.kind === "plugin") {
setPluginCommandsRefreshKey((key) => key + 1);
}
}
return data;
};
@@ -232,6 +269,7 @@ function App(props: TuiProps) {
termHeight,
loadConfigData: props.loadConfigData,
onToggleConfigItem,
onToggleAlwaysEnabledConfigItem: onToggleAlwaysEnabledConfigItem,
onDeleteConfigItem,
openModelSelector,
openMcpManager,
@@ -639,6 +677,7 @@ function App(props: TuiProps) {
openConfig,
openMcpManager,
openModelSelector,
openAgentSelector,
openSkills,
refocusTextarea: () => refocusTextareaRef.current(),
setAppView,
@@ -886,6 +925,9 @@ function App(props: TuiProps) {
void saveQueuedPromptEdit(id, prompt);
},
onToggleMode: toggleMode,
onOpenAgentSelector: () => {
void openAgentSelector();
},
runtimeInteraction,
onResolveToolApproval: runtimeBridge.resolveToolApproval,
onResolveAskQuestion: runtimeBridge.resolveAskQuestion,
@@ -921,7 +963,6 @@ function App(props: TuiProps) {
if (result.reasoningEffort !== undefined) {
props.config.reasoningEffort = result.reasoningEffort;
}
handleModelChange().then(() => setAppView("home"));
}}
onExit={() => {
+10 -2
View File
@@ -15,7 +15,11 @@ import type {
PendingPromptSubmittedEvent,
} from "../runtime/session-events";
import type { RepoStatus } from "../utils/repo-status";
import type { CliCompactionMode, Config } from "../utils/types";
import type {
ActiveAgentProfile,
CliCompactionMode,
Config,
} from "../utils/types";
import type { ClineAccountSnapshot } from "./cline-account";
import type {
InteractiveConfigData,
@@ -137,6 +141,10 @@ export interface TuiProps {
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
onToggleAlwaysEnabledConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
onDeleteConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
@@ -152,7 +160,6 @@ export interface TuiProps {
mode: AgentMode,
delivery?: "queue" | "steer",
attachments?: UserInputAttachments,
onCommandOutput?: (text: string) => void,
) => Promise<InteractiveTurnResult>;
onUpdatePendingPrompt: (input: {
promptId: string;
@@ -167,6 +174,7 @@ export interface TuiProps {
onCompactionModeChange: (mode: CliCompactionMode) => Promise<void>;
onModelChange: () => Promise<void>;
onModeChange: (mode: AgentMode) => Promise<void>;
onAgentProfileChange: (profile: ActiveAgentProfile | null) => Promise<void>;
onNewSession: () => Promise<void>;
onSessionRestart: () => Promise<void>;
onAccountChange: () => Promise<void>;
+3
View File
@@ -56,6 +56,7 @@ export function ChatView(props: {
editingQueuedPrompt?: QueuedPromptItem;
onQueuedPromptEditConfirm: (id: string, prompt: string) => void;
onToggleMode: () => void;
onOpenAgentSelector: () => void;
runtimeInteraction?: RuntimeToolInteraction | null;
onResolveToolApproval: (id: number, approved: boolean) => void;
onResolveAskQuestion: (id: number, answer: string | null) => void;
@@ -157,6 +158,8 @@ export function ChatView(props: {
gitBranch={repoStatus.branch}
gitDiffStats={repoStatus.diffStats}
onToggleMode={props.onToggleMode}
agentName={session.activeAgentName}
onOpenAgent={props.onOpenAgentSelector}
variant="chat"
/>
</box>
+22 -12
View File
@@ -208,17 +208,39 @@ export function canDeleteConfigFooterRow(
);
}
export function canAlwaysEnableConfigFooterRow(
row:
| { kind: "ext"; item: InteractiveConfigItem }
| { kind: string }
| undefined,
): boolean {
// Always-on never overrides a global disable, so the action is only
// offered on enabled plugin rows.
return (
row?.kind === "ext" &&
"item" in row &&
row.item.kind === "plugin" &&
row.item.enabled !== false &&
!row.item.loadError
);
}
export function getConfigFooterText({
canToggle = false,
canDelete = false,
canAlwaysEnable = false,
}: {
canToggle?: boolean;
canDelete?: boolean;
canAlwaysEnable?: boolean;
} = {}): string {
const actions = ["←/→ switch tabs", "↑/↓ navigate", "Tab/Enter select"];
if (canToggle) {
actions.push("Space toggle");
}
if (canAlwaysEnable) {
actions.push("A always-on (*)");
}
if (canDelete) {
actions.push("D delete");
}
@@ -229,15 +251,3 @@ 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;
}
+38 -12
View File
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { InteractiveConfigItem } from "../../tui/interactive-config";
import {
canAlwaysEnableConfigFooterRow,
canToggleConfigFooterRow,
getAdjacentConfigTab,
getConfigFooterText,
@@ -59,18 +60,6 @@ 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",
@@ -128,6 +117,43 @@ describe("config view helpers", () => {
expect(canToggleConfigFooterRow({ kind: "mcp-manager" })).toBe(false);
});
it("offers the always-on action only for healthy enabled plugin rows", () => {
const plugin = createItem({ kind: "plugin" });
const brokenPlugin = createItem({ kind: "plugin", loadError: "boom" });
const disabledPlugin = createItem({ kind: "plugin", enabled: false });
const skill = createItem({ kind: "skill" });
expect(
canAlwaysEnableConfigFooterRow({
kind: "ext",
item: plugin,
}),
).toBe(true);
expect(
canAlwaysEnableConfigFooterRow({
kind: "ext",
item: brokenPlugin,
}),
).toBe(false);
expect(
canAlwaysEnableConfigFooterRow({
kind: "ext",
item: disabledPlugin,
}),
).toBe(false);
expect(
canAlwaysEnableConfigFooterRow({
kind: "ext",
item: skill,
}),
).toBe(false);
expect(canAlwaysEnableConfigFooterRow({ kind: "toggle" })).toBe(false);
expect(getConfigFooterText({ canAlwaysEnable: true })).toContain(
"A always-on",
);
expect(getConfigFooterText()).not.toContain("A always-on");
});
it("supports restoring and advancing the active settings tab", () => {
expect(resolveInitialConfigTab("skills")).toBe("skills");
expect(resolveInitialConfigTab(undefined)).toBe("general");
+69 -37
View File
@@ -19,13 +19,13 @@ import { resolveModelDisplayName } from "../components/status-bar";
import { getModeAccent, palette } from "../palette";
import {
type ConfigAction,
canAlwaysEnableConfigFooterRow,
canDeleteConfigFooterRow,
canToggleConfigFooterRow,
getAdjacentConfigTab,
getConfigFooterText,
getConfigItemDisplayName,
getConfigTabs,
getPluginDiagnosticsLoadingText,
isInlineConfigAction,
isToggleableConfigItem,
resolveActiveConfigItems,
@@ -137,6 +137,10 @@ export interface ConfigPanelProps extends ChoiceContext<ConfigAction> {
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
onToggleAlwaysEnabledConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
onDeleteConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
@@ -199,7 +203,6 @@ function appendToolGroupRows(
rightLabel: `${enabledCount}/${groupItems.length} tools enabled`,
indent: 2,
});
for (const item of sortBySourceThenName(groupItems)) {
rows.push({
kind: "ext",
@@ -247,24 +250,17 @@ function appendToolRows(
appendExtRows(rows, builtinTools);
}
const pluginToolItems = items.filter((item) => item.pluginName);
const pluginGroups = groupToolItems(pluginToolItems);
const pluginGroups = groupToolItems(items.filter((item) => item.pluginName));
if (pluginGroups.length > 0) {
rows.push({ kind: "head", label: "Plugins" });
appendToolGroupRows(
rows,
pluginGroups,
getSharedToolNames(pluginToolItems),
getSharedToolNames(items.filter((item) => item.pluginName)),
);
}
}
function hasPluginDiagnostics(data: InteractiveConfigData): boolean {
return (
data.pluginDiagnosticsLoaded || data.tools.some((item) => item.pluginName)
);
}
function appendSkillRows(
rows: ConfigRow[],
items: InteractiveConfigItem[],
@@ -314,18 +310,11 @@ function withOptimisticToggle(
).filter(Boolean),
);
const updateItems = (items: InteractiveConfigItem[]) =>
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;
});
items.map((candidate) =>
matchesItem(candidate)
? { ...candidate, enabled: nextEnabled }
: candidate,
);
const updateTools = (items: InteractiveConfigItem[]) =>
items.map((candidate) => {
if (matchesItem(candidate)) {
@@ -397,7 +386,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
);
const [configData, setConfigData] = useState(props.configData);
const [pluginToolsLoaded, setPluginToolsLoaded] = useState(
hasPluginDiagnostics(props.configData),
props.configData.tools.some((item) => item.pluginName),
);
const [pluginToolsLoading, setPluginToolsLoading] = useState(false);
const [pluginToolsError, setPluginToolsError] = useState<
@@ -481,11 +470,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
});
} else if (activeTab === "tools") {
appendToolRows(r, activeItems);
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
if (pluginToolsLoading && loadingText) {
if (pluginToolsLoading) {
r.push({
kind: "detail",
text: loadingText,
text: "Loading plugin tools...",
});
}
if (pluginToolsError) {
@@ -516,10 +504,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
});
}
if (activeTab === "plugins" && pluginToolsLoading) {
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
r.push({
kind: "detail",
text: loadingText ?? "Loading plugin diagnostics...",
text: "Loading plugin diagnostics...",
});
}
if (activeTab === "plugins" && pluginToolsError) {
@@ -550,6 +537,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
const canDeleteSelectedRow = Boolean(
props.onDeleteConfigItem && canDeleteConfigFooterRow(selectedRow),
);
const canAlwaysEnableSelectedRow = Boolean(
props.onToggleAlwaysEnabledConfigItem &&
canAlwaysEnableConfigFooterRow(selectedRow),
);
const setNavPosition = (nextNavPos: number) => {
setNavPos(nextNavPos);
@@ -567,14 +558,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
});
if (nextData) {
setConfigData(nextData);
setPluginToolsLoaded(hasPluginDiagnostics(nextData));
} else if (item.kind === "plugin" && loadConfigData) {
const refreshedData = await loadConfigData({
includePluginTools: true,
});
setConfigData(refreshedData);
setPluginToolsLoaded(hasPluginDiagnostics(refreshedData));
setPluginToolsError(undefined);
setPluginToolsLoaded(nextData.tools.some((tool) => tool.pluginName));
if (item.kind === "plugin") {
setPluginToolsError(undefined);
}
}
} catch (error) {
setConfigData(previousData);
@@ -655,6 +642,37 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
}
};
const handleAlwaysEnableSelected = () => {
const row = rows[selectedRowIdx];
if (
!row ||
row.kind !== "ext" ||
!canAlwaysEnableConfigFooterRow(row) ||
!props.onToggleAlwaysEnabledConfigItem ||
togglingItemId
) {
return;
}
const item = row.item;
void (async () => {
setTogglingItemId(item.id);
setToggleError(undefined);
try {
const nextData = await props.onToggleAlwaysEnabledConfigItem?.(item, {
includePluginTools: pluginToolsLoaded,
});
if (nextData) {
setConfigData(nextData);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setToggleError(`Failed to update ${item.name}: ${message}`);
} finally {
setTogglingItemId(null);
}
})();
};
const handleDeleteSelected = () => {
if (!props.onDeleteConfigItem) {
return;
@@ -709,6 +727,16 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
handleDeleteSelected();
return;
}
if (
key.name === "a" &&
!key.ctrl &&
!key.meta &&
!key.option &&
!key.shift
) {
handleAlwaysEnableSelected();
return;
}
if (key.name === "return" || key.name === "tab") {
handleSelect();
}
@@ -879,6 +907,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
{prefix}
{enabledIcon}
{getConfigItemDisplayName(row.name)}
{row.item.alwaysEnabled && row.item.enabled !== false
? " *"
: ""}
</text>
<text fg="gray">{rightLabel}</text>
</box>
@@ -911,6 +942,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
: getConfigFooterText({
canToggle: canToggleSelectedRow,
canDelete: canDeleteSelectedRow,
canAlwaysEnable: canAlwaysEnableSelectedRow,
})}
</em>
</text>
+3
View File
@@ -46,6 +46,7 @@ export function HomeView(props: {
textareaRef?: React.MutableRefObject<TextareaHandle | null>;
autocomplete?: AutocompleteDropdownProps;
onToggleMode: () => void;
onOpenAgentSelector: () => void;
}) {
const {
config,
@@ -156,6 +157,8 @@ export function HomeView(props: {
gitBranch={repoStatus.branch}
gitDiffStats={repoStatus.diffStats}
onToggleMode={props.onToggleMode}
agentName={session.activeAgentName}
onOpenAgent={props.onOpenAgentSelector}
variant="home"
/>
</box>
@@ -146,50 +146,5 @@ 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.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.",
);
});
});
+3 -10
View File
@@ -89,18 +89,11 @@ export function runDeviceCodeAuthFlow(input: {
startClineDeviceAuth()
.then((result) => {
if (input.isAborted()) return;
const verifyUrl =
result.verificationUriComplete || result.verificationUri;
input.setUserCode(result.userCode);
input.setVerifyUrl(verifyUrl);
input.setVerifyUrl(
result.verificationUriComplete || result.verificationUri,
);
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,
@@ -2,6 +2,7 @@ import {
captureProviderConfigured,
getLocalProviderModels,
getProviderConfigFields,
listLocalProviders,
type ProviderConfigFieldKey,
type ProviderConfigFields,
ProviderSettingsManager,
@@ -16,7 +17,6 @@ import {
isOpenAICodexCliProvider,
} from "../../../utils/codex-cli";
import { getPersistedProviderApiKey } from "../../../utils/provider-auth";
import { listLocalProviders } from "../../../utils/provider-catalog";
import { getCliTelemetryService } from "../../../utils/telemetry";
import {
buildClineModelEntries,
-1
View File
@@ -28,7 +28,6 @@ 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;
-19
View File
@@ -1,19 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import {
disposeCliFeatureFlagsService,
getCliFeatureFlagsService,
} from "./feature-flags";
describe("CLI feature flags singleton", () => {
afterEach(async () => {
await disposeCliFeatureFlagsService();
});
it("recreates the singleton after disposal", async () => {
const service = getCliFeatureFlagsService();
await disposeCliFeatureFlagsService();
expect(getCliFeatureFlagsService()).not.toBe(service);
});
});
-111
View File
@@ -1,111 +0,0 @@
import { join } from "node:path";
import {
type BasicLogger,
type FeatureFlagsContext,
FeatureFlagsService,
type ITelemetryService,
NoOpFeatureFlagsProvider,
registerDisposable,
resolveCoreDistinctId,
} from "@cline/core";
import {
buildClinePostHogClient,
PostHogFeatureFlagsProvider,
} from "@cline/core/services/feature-flags/posthog";
import { resolveClineDataDir } from "@cline/shared/storage";
let cliFeatureFlagsContext: FeatureFlagsContext = { clientName: "cline-cli" };
let cliFeatureFlagsService: FeatureFlagsService | undefined;
const CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
function resolveCliFeatureFlagsCachePath(): string {
return join(resolveClineDataDir(), "cache", "feature-flags.json");
}
function ensureCliDistinctId(): string {
const distinctId = cliFeatureFlagsContext.distinctId?.trim();
if (distinctId) {
return distinctId;
}
const resolved = resolveCoreDistinctId();
cliFeatureFlagsContext.distinctId = resolved;
return resolved;
}
export function getCliFeatureFlagsContext(): FeatureFlagsContext {
ensureCliDistinctId();
return { ...cliFeatureFlagsContext };
}
export function getCliFeatureFlagsService(options?: {
logger?: BasicLogger;
telemetry?: ITelemetryService;
}): FeatureFlagsService {
if (!cliFeatureFlagsService) {
const apiKey = process.env.TELEMETRY_SERVICE_API_KEY;
const provider =
apiKey &&
process.env.IS_TEST !== "true" &&
process.env.E2E_TEST !== "true"
? new PostHogFeatureFlagsProvider({
client: buildClinePostHogClient(apiKey),
config: {
logger: options?.logger,
},
})
: new NoOpFeatureFlagsProvider();
cliFeatureFlagsService = new FeatureFlagsService({
provider,
telemetry: options?.telemetry,
logger: options?.logger,
context: getCliFeatureFlagsContext(),
cacheFilePath: resolveCliFeatureFlagsCachePath(),
persistentCacheMaxAgeMs: CLI_FEATURE_FLAGS_CACHE_MAX_AGE_MS,
});
registerDisposable(disposeCliFeatureFlagsService);
}
return cliFeatureFlagsService;
}
export function refreshCliFeatureFlagsInBackground(logger?: BasicLogger): void {
const service = getCliFeatureFlagsService({ logger });
void service.poll().catch((error) => {
logger?.error?.("Error refreshing CLI feature flags", { error });
});
}
export async function disposeCliFeatureFlagsService(): Promise<void> {
if (!cliFeatureFlagsService) {
return;
}
const current = cliFeatureFlagsService;
cliFeatureFlagsService = undefined;
await current.dispose();
}
export async function identifyFeatureFlagsAccount(
account: { id?: string; email?: string },
logger?: BasicLogger,
): Promise<void> {
const accountId = account.id?.trim();
cliFeatureFlagsContext = {
...cliFeatureFlagsContext,
...(accountId ? { distinctId: accountId, userId: accountId } : {}),
...(account.email?.trim() ? { email: account.email.trim() } : {}),
};
if (!cliFeatureFlagsService) {
return;
}
cliFeatureFlagsService.setContext(getCliFeatureFlagsContext());
try {
await cliFeatureFlagsService.poll();
} catch (error) {
logger?.error?.("Error polling CLI feature flags", { error });
}
}
-99
View File
@@ -1,99 +0,0 @@
import { describe, expect, it } from "vitest";
import { buildHistoryResumeArgs } from "./history-resume";
describe("buildHistoryResumeArgs", () => {
it("replaces the history subcommand with --id", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history"],
remainingArgs: ["history"],
}),
).toEqual(["--id", "sess_1"]);
});
it("preserves global flags that precede the subcommand", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: [
"--data-dir",
"/tmp/data",
"-m",
"claude-sonnet-4-6",
"history",
"--limit",
"5",
],
remainingArgs: ["history", "--limit", "5"],
}),
).toEqual([
"--data-dir",
"/tmp/data",
"-m",
"claude-sonnet-4-6",
"--id",
"sess_1",
]);
});
it("keeps a global flag value that matches the subcommand alias", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["-m", "h", "h"],
remainingArgs: ["h"],
}),
).toEqual(["-m", "h", "--id", "sess_1"]);
});
it("forwards a config dir passed as a subcommand option", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history", "--config", "/tmp/conf"],
remainingArgs: ["history", "--config", "/tmp/conf"],
configDir: "/tmp/conf",
}),
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
});
it("does not duplicate a config dir already in the global flags", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["--config", "/tmp/conf", "history"],
remainingArgs: ["history"],
configDir: "/tmp/conf",
}),
).toEqual(["--config", "/tmp/conf", "--id", "sess_1"]);
});
it("recognizes the --config=<dir> spelling in global flags", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["--config=/tmp/conf", "history"],
remainingArgs: ["history"],
configDir: "/tmp/conf",
}),
).toEqual(["--config=/tmp/conf", "--id", "sess_1"]);
});
it("returns undefined when remaining args are not a suffix of argv", () => {
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history", "--limit", "5"],
remainingArgs: ["history", "--limit", "9"],
}),
).toBeUndefined();
expect(
buildHistoryResumeArgs({
sessionId: "sess_1",
normalizedArgs: ["history"],
remainingArgs: ["extra", "history"],
}),
).toBeUndefined();
});
});
-115
View File
@@ -1,115 +0,0 @@
import { resolveCliLaunchSpec } from "./internal-launch";
export interface HistoryResumeCommand {
launcher: string;
childArgs: string[];
}
export interface BuildHistoryResumeArgsInput {
sessionId: string;
/** Full normalized CLI args (process.argv.slice(2) after normalization). */
normalizedArgs: string[];
/**
* Commander's `program.args` after parsing: the `history` subcommand token
* and everything following it. Must be a suffix of `normalizedArgs`.
*/
remainingArgs: string[];
/**
* Config dir resolved from the full argv. Forwarded explicitly because
* `--config` may have been passed as a `history` subcommand option, which
* would otherwise be dropped with the rest of the subcommand args.
*/
configDir?: string;
}
/**
* Builds argv for relaunching the CLI as `cline <globalFlags> --id <sessionId>`
* after a session is picked in `cline history`. Returns undefined when the
* global-flag prefix cannot be derived safely (caller falls back to resuming
* in-process).
*/
export function buildHistoryResumeArgs(
input: BuildHistoryResumeArgsInput,
): string[] | undefined {
const { sessionId, normalizedArgs, remainingArgs, configDir } = input;
const splitIndex = normalizedArgs.length - remainingArgs.length;
if (splitIndex < 0) {
return undefined;
}
for (let i = 0; i < remainingArgs.length; i++) {
if (normalizedArgs[splitIndex + i] !== remainingArgs[i]) {
return undefined;
}
}
const globalArgs = normalizedArgs.slice(0, splitIndex);
const args = [...globalArgs];
const hasConfigFlag = globalArgs.some(
(arg) => arg === "--config" || arg.startsWith("--config="),
);
if (configDir && !hasConfigFlag) {
args.push("--config", configDir);
}
args.push("--id", sessionId);
return args;
}
export function buildHistoryResumeCommand(
input: BuildHistoryResumeArgsInput,
): HistoryResumeCommand | undefined {
const childArgs = buildHistoryResumeArgs(input);
if (!childArgs) {
return undefined;
}
const spec = resolveCliLaunchSpec();
if (!spec) {
return undefined;
}
return {
launcher: spec.launcher,
childArgs: [...spec.childArgsPrefix, ...childArgs],
};
}
/**
* Resumes a history-picked session in a fresh `cline --id <sessionId>` child
* process with inherited stdio, and returns its exit code. Creating a second
* OpenTUI renderer in the picker's process can crash natively during teardown
* (Bun "panic(main thread): Segmentation fault" on Ctrl+C), so the resumed
* interactive TUI must get a process of its own.
*
* Returns undefined when the child cannot be launched; the caller should fall
* back to resuming in-process.
*/
export async function spawnHistoryResume(
input: BuildHistoryResumeArgsInput,
): Promise<number | undefined> {
const command = buildHistoryResumeCommand(input);
if (!command) {
return undefined;
}
const { spawn } = await import("node:child_process");
return await new Promise<number | undefined>((resolve) => {
let child: ReturnType<typeof spawn>;
try {
child = spawn(command.launcher, command.childArgs, {
stdio: "inherit",
});
} catch {
resolve(undefined);
return;
}
// The child shares this foreground process group, so terminal-generated
// Ctrl+C already reaches it. Keep the parent alive to reap the child
// without re-forwarding a second signal into the TUI teardown path.
const suppressParentSignal = () => {};
process.on("SIGINT", suppressParentSignal);
process.on("SIGTERM", suppressParentSignal);
const finish = (value: number | undefined) => {
process.off("SIGINT", suppressParentSignal);
process.off("SIGTERM", suppressParentSignal);
resolve(value);
};
child.once("error", () => finish(undefined));
child.once("exit", (code, signal) => finish(signal ? 1 : (code ?? 0)));
});
}
@@ -65,55 +65,4 @@ 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?.();
});
});
+4 -31
View File
@@ -1,6 +1,5 @@
import {
type AgentExtensionCommand,
type AgentExtensionCommandResult,
type BasicLogger,
createContributionRegistry,
resolveAndLoadAgentPlugins,
@@ -46,44 +45,17 @@ function createPluginCommandDefinition(
names: [normalizedName.toLowerCase()],
run: async ({ args }, context) => {
const result = await command.handler?.(args.join(" "));
const { reply, submitPrompt } = normalizeCommandResult(result);
if (reply) {
await context.reply(reply);
}
if (submitPrompt) {
await context.submitPrompt?.(submitPrompt);
if (typeof result === "string" && result.trim()) {
await context.reply(result);
}
},
};
}
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;
disabledPluginPaths?: ReadonlyArray<string>;
logger?: BasicLogger;
}): Promise<WorkspaceChatCommandHostResult> {
const workspaceRoot = input.workspaceRoot?.trim() || input.cwd;
@@ -92,6 +64,7 @@ export async function createWorkspaceChatCommandHost(input: {
loaded = await resolveAndLoadAgentPlugins({
cwd: input.cwd,
workspacePath: workspaceRoot,
disabledPluginPaths: input.disabledPluginPaths,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -1,34 +0,0 @@
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,
});
});
});
-14
View File
@@ -1,14 +0,0 @@
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"),
});
}
@@ -34,7 +34,7 @@ vi.mock("./telemetry", async (importOriginal) => {
import {
captureCliExtensionActivated,
identifyTelemetryAccount,
identifyCliTelemetryAccount,
} from "./telemetry";
import { resetCliExtensionActivationForTests } from "./telemetry.test-helpers";
@@ -93,7 +93,7 @@ describe("captureCliExtensionActivated", () => {
});
});
describe("identifyTelemetryAccount", () => {
describe("identifyCliTelemetryAccount", () => {
beforeEach(() => {
hoisted.identifyAccount.mockClear();
hoisted.getCliTelemetryService.mockClear();
@@ -107,7 +107,7 @@ describe("identifyTelemetryAccount", () => {
memberId: "member-7",
provider: "cline",
};
identifyTelemetryAccount(account);
identifyCliTelemetryAccount(account);
expect(hoisted.identifyAccount).toHaveBeenCalledWith(undefined, account);
});
});
+1 -4
View File
@@ -9,7 +9,6 @@ import {
TelemetryLoggerSink,
} from "@cline/core";
import { getCliBuildInfo } from "./common";
import { identifyFeatureFlagsAccount } from "./feature-flags";
import {
markActivationCaptured,
wasActivationCaptured,
@@ -103,12 +102,11 @@ export interface CliTelemetryAccountContext {
* Safe to call multiple times; the latest values win, mirroring the legacy
* singleton-based behavior.
*/
export function identifyTelemetryAccount(
export function identifyCliTelemetryAccount(
account: CliTelemetryAccountContext,
logger?: BasicLogger,
): void {
identifyAccount(getCliTelemetryService(logger), account);
void identifyFeatureFlagsAccount(account, logger);
}
/**
@@ -136,7 +134,6 @@ export function captureCliExtensionActivated(
const telemetry = getCliTelemetryService(logger);
if (account) {
identifyAccount(telemetry, account);
void identifyFeatureFlagsAccount(account, logger);
}
captureExtensionActivated(telemetry);
}
+18
View File
@@ -17,6 +17,21 @@ export type CliReasoningEffort = NonNullable<
>;
export type CliCompactionMode = "agentic" | "basic" | "off";
/**
* An agent profile from .cline/agents applied to the main Cline agent for
* the current session. Session-only: never persisted to settings.
*/
export interface ActiveAgentProfile {
name: string;
/** Profile body, captured at selection time (survives file deletion mid-session) */
systemPrompt: string;
/**
* Plugin names from the profile's plugins frontmatter. When present (even
* empty), only these plugins plus always-enabled ones load this session.
*/
plugins?: string[];
}
export interface Config extends Omit<CoreSessionConfig, "apiKey" | "mode"> {
apiKey: string;
knownModels?: Record<string, Llms.ModelInfo>;
@@ -30,6 +45,7 @@ export interface Config extends Omit<CoreSessionConfig, "apiKey" | "mode"> {
mode: CliAgentMode;
defaultToolAutoApprove: boolean;
toolPolicies: Record<string, ToolPolicy>;
agentProfile?: ActiveAgentProfile;
}
export interface ActiveCliSession {
@@ -96,4 +112,6 @@ export interface ParsedArgs {
teamName?: string;
defaultToolAutoApprove: boolean;
autoApproveOverride?: boolean;
/** Agent profile name from .cline/agents to apply to the main agent */
agent?: string;
}
+40 -45
View File
@@ -1,5 +1,6 @@
import * as p from "@clack/prompts";
import { authorizeMcpServerOAuthWithBrowser as authorizeOAuth } from "./oauth";
import { authorizeMcpServerOAuth } from "@cline/core";
import open from "open";
import {
addServer,
clearServerOAuth,
@@ -16,6 +17,16 @@ 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}`;
@@ -51,19 +62,6 @@ 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 = "";
@@ -109,15 +107,12 @@ export function parseStdioCommand(input: string): string[] {
return tokens;
}
async function collectStdioTransport(
defaultCommand?: string,
): Promise<McpTransport | null> {
async function collectStdioTransport(): 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;
@@ -157,12 +152,10 @@ async function collectStdioTransport(
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 {
@@ -229,11 +222,33 @@ async function collectUrlTransport(
};
}
async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
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> {
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();
@@ -247,7 +262,6 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
const type = await p.select({
message: "Server type",
initialValue: defaults?.type,
options: [
{
value: "stdio",
@@ -271,12 +285,9 @@ async function actionAdd(defaults?: McpAddDefaults): Promise<void> {
let transport: McpTransport | null;
let authMode: RemoteAuthMode = "none";
if (type === "stdio") {
transport = await collectStdioTransport(defaults?.command);
transport = await collectStdioTransport();
} else {
const config = await collectUrlTransport(
type as "sse" | "streamableHttp",
defaults?.url,
);
const config = await collectUrlTransport(type as "sse" | "streamableHttp");
transport = config?.transport ?? null;
authMode = config?.authMode ?? "none";
}
@@ -430,25 +441,9 @@ async function actionAuthorizeOAuth(): Promise<void> {
await authorizeOAuth(name);
}
export async function runMcpWizard(
options: RunMcpWizardOptions = {},
): Promise<number> {
export async function runMcpWizard(): 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({
-45
View File
@@ -1,45 +0,0 @@
import * as p from "@clack/prompts";
import {
authorizeMcpServerOAuth,
resolveDefaultMcpSettingsPath,
} from "@cline/core";
import open from "open";
function toErrorMessage(error: unknown): string {
if (error instanceof Error) {
const message = error.message.trim();
if (message.length > 0) {
return message;
}
}
return String(error);
}
export async function authorizeMcpServerOAuthWithBrowser(
name: string,
options: { throwOnError?: boolean } = {},
): Promise<void> {
p.log.info("Opening browser for MCP OAuth authorization");
try {
const result = await authorizeMcpServerOAuth({
serverName: name,
filePath: resolveDefaultMcpSettingsPath(),
openUrl: async (url) => {
p.log.message(`Authorization URL: ${url}`);
await open(url, { wait: false });
},
onServerListening: (info) => {
p.log.message(`Waiting for OAuth callback at ${info.callbackUrl}`);
},
});
p.log.success(result.message);
} catch (error) {
if (options.throwOnError === true) {
throw error instanceof Error ? error : new Error(toErrorMessage(error));
}
p.log.error(`OAuth authorization failed: ${toErrorMessage(error)}`);
p.log.warn(
`Server "${name}" is still saved. Choose "Authorize OAuth" to retry.`,
);
}
}
+38 -20
View File
@@ -1,16 +1,16 @@
{
"name": "claude-dev",
"version": "3.89.2",
"version": "3.89.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "claude-dev",
"version": "3.89.2",
"version": "3.89.0",
"license": "Apache-2.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.50.4",
"@anthropic-ai/vertex-sdk": "^0.11.5",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
"@aws-sdk/credential-providers": "^3.922.0",
"@azure/identity": "^4.13.0",
@@ -156,24 +156,42 @@
}
},
"node_modules/@anthropic-ai/sdk": {
"version": "0.50.4",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.50.4.tgz",
"integrity": "sha512-zZOWyIuznx2uqiRcCNuidkAsLC8IBHgS9lTwSVEB29sUCwEcqL95MWpWP1nccHSKfiZga+hbZaI0btR2zjuMmw==",
"license": "MIT",
"bin": {
"anthropic-ai-sdk": "bin/cli"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/@anthropic-ai/vertex-sdk": {
"version": "0.11.5",
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.11.5.tgz",
"integrity": "sha512-V7sB5nY80unEQu8lSQaEzh1WhYwpIdpC3iXNRHUskghkuQDhS6dQu2ASZBgA5MNuJ1Yv5PhY61NM15dLaQhPQw==",
"version": "0.37.0",
"resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.37.0.tgz",
"integrity": "sha512-tHjX2YbkUBwEgg0JZU3EFSSAQPoK4qQR/NFYa8Vtzd5UAyXzZksCw2In69Rml4R/TyHPBfRYaLK35XiOe33pjw==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": ">=0.50.3 <1",
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.4",
"abort-controller": "^3.0.0",
"agentkeepalive": "^4.2.1",
"form-data-encoder": "1.7.2",
"formdata-node": "^4.3.2",
"node-fetch": "^2.6.7"
}
},
"node_modules/@anthropic-ai/sdk/node_modules/@types/node": {
"version": "18.19.130",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
"integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@anthropic-ai/sdk/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/@anthropic-ai/vertex-sdk": {
"version": "0.6.4",
"resolved": "https://registry.npmjs.org/@anthropic-ai/vertex-sdk/-/vertex-sdk-0.6.4.tgz",
"integrity": "sha512-rMBlO2jF53TfMRmsQMm1bPO2JRUh4jYddjq/OJLj8DSAkfbCrNWhc0yhDed6oLYJg5s+VpDbvlPzMggqHhTfMw==",
"license": "MIT",
"dependencies": {
"@anthropic-ai/sdk": ">=0.35 <1",
"google-auth-library": "^9.4.2"
}
},
+4 -4
View File
@@ -2,7 +2,7 @@
"name": "claude-dev",
"displayName": "Cline",
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
"version": "3.89.2",
"version": "3.89.0",
"icon": "assets/icons/icon.png",
"engines": {
"vscode": "^1.84.0"
@@ -389,7 +389,7 @@
"protos": "node scripts/build-proto.mjs",
"protos-python": "node scripts/build-python-proto.mjs",
"download-ripgrep": "node scripts/download-ripgrep.mjs",
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/generated webview-ui/src/services/grpc-client.ts --write --no-errors-on-unmatched",
"postprotos": "biome format --config-path ./biome.jsonc src/shared/proto src/core/controller src/hosts/ webview-ui/src/services src/generated --write --no-errors-on-unmatched",
"clean:build": "rimraf dist dist-standalone webview-ui/build src/generated out/",
"clean:deps": "rimraf node_modules webview-ui/node_modules",
"clean:all": "npm run clean:build && npm run clean:deps",
@@ -486,8 +486,8 @@
"typescript": "^5.4.5"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.50.4",
"@anthropic-ai/vertex-sdk": "^0.11.5",
"@anthropic-ai/sdk": "^0.37.0",
"@anthropic-ai/vertex-sdk": "^0.6.4",
"@aws-sdk/client-bedrock-runtime": "^3.922.0",
"@aws-sdk/credential-providers": "^3.922.0",
"@azure/identity": "^4.13.0",
@@ -1,43 +0,0 @@
import "should";
import {
huggingFaceDefaultModelId,
huggingFaceModels,
} from "../../../../shared/api";
import { HuggingFaceHandler } from "../huggingface";
describe("HuggingFaceHandler", () => {
it("uses dynamic Hugging Face model info for models outside the static list", () => {
const modelInfo = {
maxTokens: 8192,
contextWindow: 128_000,
supportsImages: false,
supportsPromptCache: false,
inputPrice: 0,
outputPrice: 0,
description: "Available on providers: test-provider",
};
const handler = new HuggingFaceHandler({
huggingFaceApiKey: "test-api-key",
huggingFaceModelId: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
huggingFaceModelInfo: modelInfo,
});
handler.getModel().should.deepEqual({
id: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
info: modelInfo,
});
});
it("preserves unknown model IDs when model info is unavailable", () => {
const handler = new HuggingFaceHandler({
huggingFaceApiKey: "test-api-key",
huggingFaceModelId: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
});
handler.getModel().should.deepEqual({
id: "Qwen/Qwen3-Coder-480B-A35B-Instruct",
info: huggingFaceModels[huggingFaceDefaultModelId],
});
});
});
@@ -1,66 +1,56 @@
import {
huggingFaceDefaultModelId,
huggingFaceModels,
type ModelInfo,
} from "@shared/api";
import { calculateApiCostOpenAI } from "@utils/cost";
import type OpenAI from "openai";
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions";
import type { ClineStorageMessage } from "@/shared/messages/content";
import { createOpenAIClient } from "@/shared/net";
import type { ApiHandler, CommonApiHandlerOptions } from "../";
import { withRetry } from "../retry";
import { convertToOpenAiMessages } from "../transform/openai-format";
import type { ApiStream } from "../transform/stream";
import {
getOpenAIToolParams,
ToolCallProcessor,
} from "../transform/tool-call-processor";
import { HuggingFaceModelId, huggingFaceDefaultModelId, huggingFaceModels, ModelInfo } from "@shared/api"
import { calculateApiCostOpenAI } from "@utils/cost"
import OpenAI from "openai"
import type { ChatCompletionTool as OpenAITool } from "openai/resources/chat/completions"
import { ClineStorageMessage } from "@/shared/messages/content"
import { createOpenAIClient } from "@/shared/net"
import { ApiHandler, CommonApiHandlerOptions } from "../"
import { withRetry } from "../retry"
import { convertToOpenAiMessages } from "../transform/openai-format"
import { ApiStream } from "../transform/stream"
import { getOpenAIToolParams, ToolCallProcessor } from "../transform/tool-call-processor"
interface HuggingFaceHandlerOptions extends CommonApiHandlerOptions {
huggingFaceApiKey?: string;
huggingFaceModelId?: string;
huggingFaceModelInfo?: ModelInfo;
huggingFaceApiKey?: string
huggingFaceModelId?: string
huggingFaceModelInfo?: ModelInfo
}
export class HuggingFaceHandler implements ApiHandler {
private options: HuggingFaceHandlerOptions;
private client: OpenAI | undefined;
private cachedModel: { id: string; info: ModelInfo } | undefined;
private options: HuggingFaceHandlerOptions
private client: OpenAI | undefined
private cachedModel: { id: HuggingFaceModelId; info: ModelInfo } | undefined
constructor(options: HuggingFaceHandlerOptions) {
this.options = options;
this.options = options
}
private ensureClient(): OpenAI {
if (!this.client) {
if (!this.options.huggingFaceApiKey) {
throw new Error("Hugging Face API key is required");
throw new Error("Hugging Face API key is required")
}
try {
this.client = createOpenAIClient({
baseURL: "https://router.huggingface.co/v1",
apiKey: this.options.huggingFaceApiKey,
});
})
} catch (error: any) {
throw new Error(`Error creating Hugging Face client: ${error.message}`);
throw new Error(`Error creating Hugging Face client: ${error.message}`)
}
}
return this.client;
return this.client
}
private async *yieldUsage(
info: ModelInfo,
usage: OpenAI.Completions.CompletionUsage | undefined,
): ApiStream {
private async *yieldUsage(info: ModelInfo, usage: OpenAI.Completions.CompletionUsage | undefined): ApiStream {
if (!usage) {
return;
return
}
const inputTokens = usage.prompt_tokens || 0;
const outputTokens = usage.completion_tokens || 0;
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens);
const inputTokens = usage.prompt_tokens || 0
const outputTokens = usage.completion_tokens || 0
const totalCost = calculateApiCostOpenAI(info, inputTokens, outputTokens)
const usageData = {
type: "usage" as const,
@@ -69,25 +59,21 @@ export class HuggingFaceHandler implements ApiHandler {
cacheWriteTokens: 0,
cacheReadTokens: 0,
totalCost: totalCost,
};
}
yield usageData;
yield usageData
}
@withRetry()
async *createMessage(
systemPrompt: string,
messages: ClineStorageMessage[],
tools?: OpenAITool[],
): ApiStream {
async *createMessage(systemPrompt: string, messages: ClineStorageMessage[], tools?: OpenAITool[]): ApiStream {
try {
const client = this.ensureClient();
const model = this.getModel();
const client = this.ensureClient()
const model = this.getModel()
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [
{ role: "system", content: systemPrompt },
...convertToOpenAiMessages(messages),
];
]
const requestParams = {
model: model.id,
@@ -97,71 +83,66 @@ export class HuggingFaceHandler implements ApiHandler {
stream_options: { include_usage: true },
temperature: 0,
...getOpenAIToolParams(tools),
};
}
const toolCallProcessor = new ToolCallProcessor();
const stream = (await client.chat.completions.create(
requestParams,
)) as any;
const toolCallProcessor = new ToolCallProcessor()
const stream = (await client.chat.completions.create(requestParams)) as any
let _chunkCount = 0;
let _totalContent = "";
let _chunkCount = 0
let _totalContent = ""
for await (const chunk of stream) {
_chunkCount++;
const delta = chunk.choices?.[0]?.delta;
_chunkCount++
const delta = chunk.choices?.[0]?.delta
if (delta?.content) {
_totalContent += delta.content;
_totalContent += delta.content
yield {
type: "text",
text: delta.content,
};
}
}
if (delta?.tool_calls) {
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls);
yield* toolCallProcessor.processToolCallDeltas(delta.tool_calls)
}
if (chunk.usage) {
yield* this.yieldUsage(model.info, chunk.usage);
yield* this.yieldUsage(model.info, chunk.usage)
}
}
} catch (error: any) {
throw error;
throw error
}
}
getModel(): { id: string; info: ModelInfo } {
getModel(): { id: HuggingFaceModelId; info: ModelInfo } {
// Return cached model if available
if (this.cachedModel) {
return this.cachedModel;
return this.cachedModel
}
const modelId = this.options.huggingFaceModelId;
let result: { id: string; info: ModelInfo };
const modelId = this.options.huggingFaceModelId
// List all available models for debugging
const _availableModels = Object.keys(huggingFaceModels)
let result: { id: HuggingFaceModelId; info: ModelInfo }
if (modelId && modelId in huggingFaceModels) {
const id = modelId as keyof typeof huggingFaceModels;
const modelInfo = huggingFaceModels[id];
result = { id, info: modelInfo };
} else if (modelId) {
const defaultInfo = huggingFaceModels[huggingFaceDefaultModelId];
result = {
id: modelId,
info: this.options.huggingFaceModelInfo || defaultInfo,
};
const id = modelId as HuggingFaceModelId
const modelInfo = huggingFaceModels[id]
result = { id, info: modelInfo }
} else {
const defaultInfo = huggingFaceModels[huggingFaceDefaultModelId];
const defaultInfo = huggingFaceModels[huggingFaceDefaultModelId]
result = {
id: huggingFaceDefaultModelId,
info: defaultInfo,
};
}
}
// Cache the result for future calls
this.cachedModel = result;
this.cachedModel = result
return result;
return result
}
}
File diff suppressed because it is too large Load Diff
@@ -1,8 +1,5 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import {
type ClineStorageMessage,
convertClineStorageToAnthropicMessage,
} from "@/shared/messages/content";
import { Anthropic } from "@anthropic-ai/sdk"
import { ClineStorageMessage, convertClineStorageToAnthropicMessage } from "@/shared/messages/content"
/**
* Converts Cline storage messages to Anthropic API format with optional cache control.
@@ -15,7 +12,7 @@ import {
* @returns Array of Anthropic-compatible messages with cache control applied
*/
export function sanitizeAnthropicMessages(
clineMessages: ClineStorageMessage[],
clineMessages: Array<ClineStorageMessage | Anthropic.MessageParam>,
supportCache: boolean,
): Array<Anthropic.MessageParam> {
// The latest message will be the new user message, one before will be the assistant message from a previous request,
@@ -24,37 +21,32 @@ export function sanitizeAnthropicMessages(
// know the last message to retrieve from the cache for the current request.
const userMsgIndices = clineMessages.reduce((acc, msg, index) => {
if (msg.role === "user") {
acc.push(index);
acc.push(index)
}
return acc;
}, [] as number[]);
return acc
}, [] as number[])
// Set to -1 if there are no user messages so the indices are invalid
const indicesLength = userMsgIndices.length ?? -1;
const lastUserMsgIndex = userMsgIndices[indicesLength - 1];
const secondLastMsgUserIndex = userMsgIndices[indicesLength - 2];
const indicesLength = userMsgIndices.length ?? -1
const lastUserMsgIndex = userMsgIndices[indicesLength - 1]
const secondLastMsgUserIndex = userMsgIndices[indicesLength - 2]
return clineMessages.map((msg, index) => {
const anthropicMsg = convertClineStorageToAnthropicMessage(msg);
const anthropicMsg = convertClineStorageToAnthropicMessage(msg)
// Add cache control to the last two user messages
if (
supportCache &&
(index === lastUserMsgIndex || index === secondLastMsgUserIndex)
) {
return addCacheControl(anthropicMsg);
if (supportCache && (index === lastUserMsgIndex || index === secondLastMsgUserIndex)) {
return addCacheControl(anthropicMsg)
}
return anthropicMsg;
});
return anthropicMsg
})
}
const isThinkingBlock = (
block: Anthropic.ContentBlockParam,
): block is
| Anthropic.Messages.ThinkingBlockParam
| Anthropic.Messages.RedactedThinkingBlockParam => {
return block.type === "thinking" || block.type === "redacted_thinking";
};
): block is Anthropic.Messages.ThinkingBlockParam | Anthropic.Messages.RedactedThinkingBlockParam => {
return block.type === "thinking" || block.type === "redacted_thinking"
}
/**
* Adds ephemeral cache control to the last content block of a message.
@@ -63,9 +55,7 @@ const isThinkingBlock = (
* @param message - The Anthropic message to add cache control to
* @returns A new message with cache control added to the last content block
*/
function addCacheControl(
message: Anthropic.MessageParam,
): Anthropic.MessageParam {
function addCacheControl(message: Anthropic.MessageParam): Anthropic.MessageParam {
// Convert string content to array format
if (typeof message.content === "string") {
return {
@@ -77,24 +67,24 @@ function addCacheControl(
cache_control: { type: "ephemeral" },
} satisfies Anthropic.TextBlockParam,
],
};
}
}
// Handle array content - add cache control to the last block
const content = [...message.content];
const lastIndex = content.length - 1;
const content = [...message.content]
const lastIndex = content.length - 1
if (lastIndex >= 0) {
const lastBlock = content[lastIndex];
const lastBlock = content[lastIndex]
// Only add cache_control to block types that support it (not ThinkingBlockParam)
if (!isThinkingBlock(lastBlock)) {
content[lastIndex] = {
...lastBlock,
cache_control: { type: "ephemeral" },
} satisfies Anthropic.ContentBlockParam;
} satisfies Anthropic.ContentBlockParam
}
}
return { ...message, content };
return { ...message, content }
}
@@ -1,6 +1,6 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type { Content, GenerateContentResponse, Part } from "@google/genai";
import type { ClineStorageMessage } from "@/shared/messages/content";
import { Anthropic } from "@anthropic-ai/sdk"
import { Content, GenerateContentResponse, Part } from "@google/genai"
import { ClineStorageMessage } from "@/shared/messages/content"
// Source: https://ai.google.dev/gemini-api/docs/thought-signatures#faqs
// While injecting custom function call blocks into the request is strongly discouraged,
@@ -8,29 +8,27 @@ import type { ClineStorageMessage } from "@/shared/messages/content";
// calls and responses that were executed deterministically by the client, or transferring a
// trace from a different model that does not include thought signatures, you can set the following dummy signatures of either
// "context_engineering_is_the_way_to_go" or "skip_thought_signature_validator" in the thought signature field to skip validation.
const GEMINI_DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator";
const GEMINI_DUMMY_THOUGHT_SIGNATURE = "skip_thought_signature_validator"
export function convertAnthropicContentToGemini(
content: string | ClineStorageMessage["content"],
): Part[] {
export function convertAnthropicContentToGemini(content: string | ClineStorageMessage["content"]): Part[] {
if (typeof content === "string") {
return [{ text: content }];
return [{ text: content }]
}
return content
.flatMap((block): Part | undefined => {
switch (block.type) {
case "text":
return { text: block.text, thoughtSignature: block.signature };
return { text: block.text, thoughtSignature: block.signature }
case "image":
if (block.source.type !== "base64") {
throw new Error("Unsupported image source type");
throw new Error("Unsupported image source type")
}
return {
inlineData: {
data: block.source.data,
mimeType: block.source.media_type,
},
};
}
case "tool_use":
return {
functionCall: {
@@ -39,7 +37,7 @@ export function convertAnthropicContentToGemini(
},
// Thought signature is required, so provide a dummy one if not present
thoughtSignature: block.signature || GEMINI_DUMMY_THOUGHT_SIGNATURE,
};
}
case "tool_result":
return {
functionResponse: {
@@ -48,66 +46,57 @@ export function convertAnthropicContentToGemini(
result: block.content,
},
},
};
}
case "thinking":
return {
text: block.thinking,
thought: true,
thoughtSignature: block.signature || GEMINI_DUMMY_THOUGHT_SIGNATURE,
};
}
default:
return undefined;
return undefined
}
})
.filter((part): part is Part => part !== undefined); // Filter out unsupported blocks
.filter((part): part is Part => part !== undefined) // Filter out unsupported blocks
}
export function convertAnthropicMessageToGemini(
message: ClineStorageMessage,
): Content {
export function convertAnthropicMessageToGemini(message: Anthropic.Messages.MessageParam): Content {
return {
role: message.role === "assistant" ? "model" : "user",
parts: convertAnthropicContentToGemini(message.content),
};
}
}
/*
It looks like gemini likes to double escape certain characters when writing file contents: https://discuss.ai.google.dev/t/function-call-string-property-is-double-escaped/37867
*/
export function unescapeGeminiContent(content: string) {
return content
.replace(/\\n/g, "\n")
.replace(/\\'/g, "'")
.replace(/\\"/g, '"')
.replace(/\\r/g, "\r")
.replace(/\\t/g, "\t");
return content.replace(/\\n/g, "\n").replace(/\\'/g, "'").replace(/\\"/g, '"').replace(/\\r/g, "\r").replace(/\\t/g, "\t")
}
export function convertGeminiResponseToAnthropic(
response: GenerateContentResponse,
): Anthropic.Messages.Message {
const content: Anthropic.Messages.ContentBlock[] = [];
export function convertGeminiResponseToAnthropic(response: GenerateContentResponse): Anthropic.Messages.Message {
const content: Anthropic.Messages.ContentBlock[] = []
const text = response.text;
const text = response.text
if (text) {
content.push({ type: "text", text, citations: null });
content.push({ type: "text", text, citations: null })
}
let stop_reason: Anthropic.Messages.Message["stop_reason"] = null;
const finishReason = response.candidates?.[0]?.finishReason;
let stop_reason: Anthropic.Messages.Message["stop_reason"] = null
const finishReason = response.candidates?.[0]?.finishReason
if (finishReason) {
switch (finishReason) {
case "STOP":
stop_reason = "end_turn";
break;
stop_reason = "end_turn"
break
case "MAX_TOKENS":
stop_reason = "max_tokens";
break;
stop_reason = "max_tokens"
break
case "SAFETY":
case "RECITATION":
case "OTHER":
stop_reason = "stop_sequence";
break;
stop_reason = "stop_sequence"
break
}
}
@@ -124,7 +113,6 @@ export function convertGeminiResponseToAnthropic(
output_tokens: response.usageMetadata?.candidatesTokenCount ?? 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
};
}
}
@@ -1,32 +1,29 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage";
import type { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage";
import type { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage";
import type { UserMessage } from "@mistralai/mistralai/models/components/usermessage";
import { getImageDataUrl } from "@/shared/messages/content";
import { Anthropic } from "@anthropic-ai/sdk"
import { AssistantMessage } from "@mistralai/mistralai/models/components/assistantmessage"
import { SystemMessage } from "@mistralai/mistralai/models/components/systemmessage"
import { ToolMessage } from "@mistralai/mistralai/models/components/toolmessage"
import { UserMessage } from "@mistralai/mistralai/models/components/usermessage"
export type MistralMessage =
| (SystemMessage & { role: "system" })
| (UserMessage & { role: "user" })
| (AssistantMessage & { role: "assistant" })
| (ToolMessage & { role: "tool" });
| (ToolMessage & { role: "tool" })
export function convertToMistralMessages(
anthropicMessages: Anthropic.Messages.MessageParam[],
): MistralMessage[] {
const mistralMessages: MistralMessage[] = [];
export function convertToMistralMessages(anthropicMessages: Anthropic.Messages.MessageParam[]): MistralMessage[] {
const mistralMessages: MistralMessage[] = []
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
mistralMessages.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
});
})
} else {
if (anthropicMessage.role === "user") {
// Filter to only include text and image blocks
const textAndImageBlocks = anthropicMessage.content.filter(
(part) => part.type === "text" || part.type === "image",
);
)
if (textAndImageBlocks.length > 0) {
mistralMessages.push({
@@ -36,31 +33,29 @@ export function convertToMistralMessages(
return {
type: "image_url",
imageUrl: {
url: getImageDataUrl(part.source),
url: `data:${part.source.media_type};base64,${part.source.data}`,
},
};
}
}
return { type: "text", text: part.text };
return { type: "text", text: part.text }
}),
});
})
}
} else if (anthropicMessage.role === "assistant") {
// Only process text blocks - assistant cannot send images or other content types in Mistral's API format
const textBlocks = anthropicMessage.content.filter(
(part) => part.type === "text",
);
const textBlocks = anthropicMessage.content.filter((part) => part.type === "text")
if (textBlocks.length > 0) {
const content = textBlocks.map((part) => part.text).join("\n");
const content = textBlocks.map((part) => part.text).join("\n")
mistralMessages.push({
role: "assistant",
content,
});
})
}
}
}
}
return mistralMessages;
return mistralMessages
}
+75 -90
View File
@@ -1,6 +1,6 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type OpenAI from "openai";
import { Logger } from "@/shared/services/Logger";
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { Logger } from "@/shared/services/Logger"
const o1SystemPrompt = (systemPrompt: string) => `
# System Prompt
@@ -164,7 +164,7 @@ I've analyzed the project structure, but I need more information to proceed. Let
<ask_followup_question>
<question>Which specific feature would you like me to implement in the example.py file?</question>
</ask_followup_question>
`;
`
export function convertToO1Messages(
openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[],
@@ -176,26 +176,26 @@ export function convertToO1Messages(
acc.push({
role: "user",
content: message.content || "",
});
})
} else if (message.role === "assistant" && message.tool_calls) {
// Convert tool calls to content and remove tool_calls
let content = message.content || "";
let content = message.content || ""
message.tool_calls.forEach((toolCall) => {
if (toolCall.type === "function") {
content += `\nTool Call: ${toolCall.function.name}\nArguments: ${toolCall.function.arguments}`;
content += `\nTool Call: ${toolCall.function.name}\nArguments: ${toolCall.function.arguments}`
}
});
})
acc.push({
role: "assistant",
content: content,
tool_calls: undefined,
});
})
} else {
// Keep other messages as they are
acc.push(message);
acc.push(message)
}
return acc;
}, [] as OpenAI.Chat.ChatCompletionMessageParam[]);
return acc
}, [] as OpenAI.Chat.ChatCompletionMessageParam[])
// Find the index of the last assistant message
// const lastAssistantIndex = findLastIndex(toolsReplaced, (message) => message.role === "assistant")
@@ -207,7 +207,7 @@ export function convertToO1Messages(
content: o1SystemPrompt(systemPrompt),
} as OpenAI.Chat.ChatCompletionUserMessageParam,
...toolsReplaced,
];
]
// If there's an assistant message, insert the system prompt after it
// if (lastAssistantIndex !== -1) {
@@ -226,12 +226,12 @@ export function convertToO1Messages(
// })
// }
return messagesWithSystemPrompt;
return messagesWithSystemPrompt
}
interface ToolCall {
tool: string;
tool_input: Record<string, string>;
tool: string
tool_input: Record<string, string>
}
const toolNames = [
@@ -243,116 +243,106 @@ const toolNames = [
"write_to_file",
"ask_followup_question",
"attempt_completion",
];
]
function parseAIResponse(response: string): {
normalText: string;
toolCalls: ToolCall[];
normalText: string
toolCalls: ToolCall[]
} {
// Create a regex pattern to match any tool call opening tag
const toolCallPattern = new RegExp(`<(${toolNames.join("|")})`, "i");
const match = response.match(toolCallPattern);
const toolCallPattern = new RegExp(`<(${toolNames.join("|")})`, "i")
const match = response.match(toolCallPattern)
if (!match) {
// No tool calls found
return { normalText: response.trim(), toolCalls: [] };
return { normalText: response.trim(), toolCalls: [] }
}
const toolCallStart = match.index!;
const normalText = response.slice(0, toolCallStart).trim();
const toolCallsText = response.slice(toolCallStart);
const toolCallStart = match.index!
const normalText = response.slice(0, toolCallStart).trim()
const toolCallsText = response.slice(toolCallStart)
const toolCalls = parseToolCalls(toolCallsText);
const toolCalls = parseToolCalls(toolCallsText)
return { normalText, toolCalls };
return { normalText, toolCalls }
}
function parseToolCalls(toolCallsText: string): ToolCall[] {
const toolCalls: ToolCall[] = [];
const toolCalls: ToolCall[] = []
let remainingText = toolCallsText;
let remainingText = toolCallsText
while (remainingText.length > 0) {
const toolMatch = toolNames.find((tool) =>
new RegExp(`<${tool}`, "i").test(remainingText),
);
const toolMatch = toolNames.find((tool) => new RegExp(`<${tool}`, "i").test(remainingText))
if (!toolMatch) {
break; // No more tool calls found
break // No more tool calls found
}
const startTag = `<${toolMatch}`;
const endTag = `</${toolMatch}>`;
const startIndex = remainingText.indexOf(startTag);
const endIndex = remainingText.indexOf(endTag, startIndex);
const startTag = `<${toolMatch}`
const endTag = `</${toolMatch}>`
const startIndex = remainingText.indexOf(startTag)
const endIndex = remainingText.indexOf(endTag, startIndex)
if (endIndex === -1) {
break; // Malformed XML, no closing tag found
break // Malformed XML, no closing tag found
}
const toolCallContent = remainingText.slice(
startIndex,
endIndex + endTag.length,
);
remainingText = remainingText.slice(endIndex + endTag.length).trim();
const toolCallContent = remainingText.slice(startIndex, endIndex + endTag.length)
remainingText = remainingText.slice(endIndex + endTag.length).trim()
const toolCall = parseToolCall(toolMatch, toolCallContent);
const toolCall = parseToolCall(toolMatch, toolCallContent)
if (toolCall) {
toolCalls.push(toolCall);
toolCalls.push(toolCall)
}
}
return toolCalls;
return toolCalls
}
function parseToolCall(toolName: string, content: string): ToolCall | null {
const tool_input: Record<string, string> = {};
const tool_input: Record<string, string> = {}
// Remove the outer tool tags
const innerContent = content
.replace(new RegExp(`^<${toolName}>|</${toolName}>$`, "g"), "")
.trim();
const innerContent = content.replace(new RegExp(`^<${toolName}>|</${toolName}>$`, "g"), "").trim()
// Parse nested XML elements
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/gs;
let match: RegExpExecArray | null;
const paramRegex = /<(\w+)>([\s\S]*?)<\/\1>/gs
let match: RegExpExecArray | null
while ((match = paramRegex.exec(innerContent)) !== null) {
const [, paramName, paramValue] = match;
const [, paramName, paramValue] = match
// Preserve newlines and trim only leading/trailing whitespace
tool_input[paramName] = paramValue.replace(/^\s+|\s+$/g, "");
tool_input[paramName] = paramValue.replace(/^\s+|\s+$/g, "")
}
// Validate required parameters
if (!validateToolInput(toolName, tool_input)) {
Logger.error(`Invalid tool call for ${toolName}:`, content);
return null;
Logger.error(`Invalid tool call for ${toolName}:`, content)
return null
}
return { tool: toolName, tool_input };
return { tool: toolName, tool_input }
}
function validateToolInput(
toolName: string,
tool_input: Record<string, string>,
): boolean {
function validateToolInput(toolName: string, tool_input: Record<string, string>): boolean {
switch (toolName) {
case "execute_command":
return "command" in tool_input;
return "command" in tool_input
case "read_file":
case "list_code_definition_names":
case "list_files":
return "path" in tool_input;
return "path" in tool_input
case "search_files":
return "path" in tool_input && "regex" in tool_input;
return "path" in tool_input && "regex" in tool_input
case "write_to_file":
return "path" in tool_input && "content" in tool_input;
return "path" in tool_input && "content" in tool_input
case "ask_followup_question":
return "question" in tool_input;
return "question" in tool_input
case "attempt_completion":
return "result" in tool_input;
return "result" in tool_input
default:
return false;
return false
}
}
@@ -376,10 +366,8 @@ function validateToolInput(
export function convertO1ResponseToAnthropicMessage(
completion: OpenAI.Chat.Completions.ChatCompletion,
): Anthropic.Messages.Message {
const openAiMessage = completion.choices[0].message;
const { normalText, toolCalls } = parseAIResponse(
openAiMessage.content || "",
);
const openAiMessage = completion.choices[0].message
const { normalText, toolCalls } = parseAIResponse(openAiMessage.content || "")
const anthropicMessage: Anthropic.Messages.Message = {
id: completion.id,
@@ -396,14 +384,14 @@ export function convertO1ResponseToAnthropicMessage(
stop_reason: (() => {
switch (completion.choices[0].finish_reason) {
case "stop":
return "end_turn";
return "end_turn"
case "length":
return "max_tokens";
return "max_tokens"
case "tool_calls":
return "tool_use";
return "tool_use"
case "content_filter": // Anthropic doesn't have an exact equivalent
default:
return null;
return null
}
})(),
stop_sequence: null, // which custom stop_sequence was generated, if any (not applicable if you don't use stop_sequence)
@@ -412,26 +400,23 @@ export function convertO1ResponseToAnthropicMessage(
output_tokens: completion.usage?.completion_tokens || 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
};
}
if (toolCalls.length > 0) {
anthropicMessage.content.push(
...toolCalls.map(
(toolCall: ToolCall, index: number): Anthropic.ToolUseBlock => {
return {
type: "tool_use",
id: `call_${index}_${Date.now()}`, // Generate a unique ID for each tool call
name: toolCall.tool,
input: toolCall.tool_input,
};
},
),
);
...toolCalls.map((toolCall: ToolCall, index: number): Anthropic.ToolUseBlock => {
return {
type: "tool_use",
id: `call_${index}_${Date.now()}`, // Generate a unique ID for each tool call
name: toolCall.tool,
input: toolCall.tool_input,
}
}),
)
}
return anthropicMessage;
return anthropicMessage
}
// Example usage:
@@ -1,68 +1,64 @@
import type { Message } from "ollama";
import { Message } from "ollama"
import {
type ClineAssistantToolUseBlock,
type ClineImageContentBlock,
type ClineStorageMessage,
type ClineTextContentBlock,
type ClineUserToolResultContentBlock,
getImageDataUrl,
} from "@/shared/messages/content";
ClineAssistantToolUseBlock,
ClineImageContentBlock,
ClineStorageMessage,
ClineTextContentBlock,
ClineUserToolResultContentBlock,
} from "@/shared/messages/content"
export function convertToOllamaMessages(
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
): Message[] {
const ollamaMessages: Message[] = [];
export function convertToOllamaMessages(anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[]): Message[] {
const ollamaMessages: Message[] = []
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
ollamaMessages.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
});
})
} else {
if (anthropicMessage.role === "user") {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[];
toolMessages: ClineUserToolResultContentBlock[];
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
}
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
toolMessages: ClineUserToolResultContentBlock[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
}
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool result messages FIRST since they must follow the tool use messages
const toolResultImages: string[] = [];
const toolResultImages: string[] = []
toolMessages.forEach((toolMessage) => {
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the Ollama SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
let content: string;
let content: string
if (typeof toolMessage.content === "string") {
content = toolMessage.content;
content = toolMessage.content
} else {
content =
toolMessage.content
?.map((part) => {
if (part.type === "image") {
toolResultImages.push(getImageDataUrl(part.source));
return "(see following user message for image)";
toolResultImages.push(`data:${part.source.media_type};base64,${part.source.data}`)
return "(see following user message for image)"
}
return part.text;
return part.text
})
.join("\n") ?? "";
.join("\n") ?? ""
}
ollamaMessages.push({
role: "user",
images: toolResultImages.length > 0 ? toolResultImages : undefined,
content: content,
});
});
})
})
// Process non-tool messages
if (nonToolMessages.length > 0) {
@@ -71,50 +67,49 @@ export function convertToOllamaMessages(
content: nonToolMessages
.map((part) => {
if (part.type === "image") {
return getImageDataUrl(part.source);
return `data:${part.source.media_type};base64,${part.source.data}`
}
return part.text;
return part.text
})
.join("\n"),
});
})
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[];
toolMessages: ClineAssistantToolUseBlock[];
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
} // assistant cannot send tool_result messages
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
toolMessages: ClineAssistantToolUseBlock[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process non-tool messages
let content: string = "";
let content: string = ""
if (nonToolMessages.length > 0) {
content = nonToolMessages
.map((part) => {
if (part.type === "image") {
return ""; // impossible as the assistant cannot send images
return "" // impossible as the assistant cannot send images
}
return part.text;
return part.text
})
.join("\n");
.join("\n")
}
ollamaMessages.push({
role: "assistant",
content,
});
})
}
}
}
return ollamaMessages;
return ollamaMessages
}
@@ -1,19 +1,19 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type OpenAI from "openai";
import type { ApiProvider } from "@/shared/api";
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ApiProvider } from "@/shared/api"
import {
type ClineAssistantRedactedThinkingBlock,
type ClineAssistantThinkingBlock,
type ClineAssistantToolUseBlock,
type ClineImageContentBlock,
type ClineTextContentBlock,
type ClineUserToolResultContentBlock,
getImageDataUrl,
} from "@/shared/messages/content";
import { Logger } from "@/shared/services/Logger";
ClineAssistantRedactedThinkingBlock,
ClineAssistantThinkingBlock,
ClineAssistantToolUseBlock,
ClineImageContentBlock,
ClineStorageMessage,
ClineTextContentBlock,
ClineUserToolResultContentBlock,
} from "@/shared/messages/content"
import { Logger } from "@/shared/services/Logger"
// OpenAI API has a maximum tool call ID length of 40 characters
const MAX_TOOL_CALL_ID_LENGTH = 40;
const MAX_TOOL_CALL_ID_LENGTH = 40
/**
* Determines if a given tool ID follows the OpenAI Responses API format for tool calls.
@@ -23,7 +23,7 @@ const MAX_TOOL_CALL_ID_LENGTH = 40;
* @returns True if the tool ID matches the OpenAI Responses API format, false otherwise
*/
function isOpenAIResponseToolId(callId: string): boolean {
return callId.startsWith("fc_") && callId.length === 53;
return callId.startsWith("fc_") && callId.length === 53
}
/**
@@ -37,24 +37,21 @@ function isOpenAIResponseToolId(callId: string): boolean {
* @param provider - The API provider that the OpenAI formatted messages will be sent to
* @returns The transformed ID suitable for OpenAI API
*/
function transformToolCallIdForNativeApi(
toolId: string,
provider?: ApiProvider,
): string {
function transformToolCallIdForNativeApi(toolId: string, provider?: ApiProvider): string {
// OpenAI Responses API uses "fc_" prefix with 53 char length
// Convert these to "call_" prefix format for Chat Completions API
if (isOpenAIResponseToolId(toolId)) {
// Use the last 33 chars + "call_" (5 chars) to stay under the 40-char limit.
return `call_${toolId.slice(toolId.length - (MAX_TOOL_CALL_ID_LENGTH - 5))}`;
return `call_${toolId.slice(toolId.length - (MAX_TOOL_CALL_ID_LENGTH - 5))}`
}
if (provider !== "openai-native") {
return toolId;
return toolId
}
// Ensure ID doesn't exceed max length
if (toolId.length > MAX_TOOL_CALL_ID_LENGTH) {
return toolId.slice(0, MAX_TOOL_CALL_ID_LENGTH);
return toolId.slice(0, MAX_TOOL_CALL_ID_LENGTH)
}
return toolId;
return toolId
}
/**
@@ -68,17 +65,17 @@ function transformToolCallIdForNativeApi(
* @returns Array of OpenAI.Chat.ChatCompletionMessageParam objects
*/
export function convertToOpenAiMessages(
anthropicMessages: Anthropic.Messages.MessageParam[],
anthropicMessages: Omit<ClineStorageMessage, "modelInfo">[],
provider?: ApiProvider,
): OpenAI.Chat.ChatCompletionMessageParam[] {
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = [];
const openAiMessages: OpenAI.Chat.ChatCompletionMessageParam[] = []
for (const anthropicMessage of anthropicMessages) {
if (typeof anthropicMessage.content === "string") {
openAiMessages.push({
role: anthropicMessage.role,
content: anthropicMessage.content,
});
})
} else {
// image_url.url is base64 encoded image data
// ensure it contains the content-type of the image: data:image/png;base64,
@@ -89,56 +86,52 @@ export function convertToOpenAiMessages(
{ role: "tool", tool_call_id: "", content: ""}
*/
if (anthropicMessage.role === "user") {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[];
toolMessages: ClineUserToolResultContentBlock[];
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
} // user cannot send tool_use messages
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (ClineTextContentBlock | ClineImageContentBlock)[]
toolMessages: ClineUserToolResultContentBlock[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // user cannot send tool_use messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool result messages FIRST since they must follow the tool use messages
const toolResultImages: ClineImageContentBlock[] = [];
const toolResultImages: ClineImageContentBlock[] = []
toolMessages.forEach((toolMessage) => {
// The Anthropic SDK allows tool results to be a string or an array of text and image blocks, enabling rich and structured content. In contrast, the OpenAI SDK only supports tool results as a single string, so we map the Anthropic tool result parts into one concatenated string to maintain compatibility.
let content: string;
let content: string
if (typeof toolMessage.content === "string") {
content = toolMessage.content;
content = toolMessage.content
} else if (Array.isArray(toolMessage.content)) {
content =
toolMessage.content
?.map((part) => {
if (part.type === "image") {
toolResultImages.push(part);
return "(see following user message for image)";
toolResultImages.push(part)
return "(see following user message for image)"
}
return part.text;
return part.text
})
.join("\n") ?? "";
.join("\n") ?? ""
} else {
// Handle undefined content
content = "";
content = ""
}
openAiMessages.push({
role: "tool",
// The tool_call_id must match the id used in the assistant's tool_calls array.
// Use the same transformation logic as tool_calls to ensure IDs match.
tool_call_id: transformToolCallIdForNativeApi(
toolMessage.tool_use_id,
provider,
),
tool_call_id: transformToolCallIdForNativeApi(toolMessage.tool_use_id, provider),
content: content,
});
});
})
})
// If tool results contain images, send as a separate user message
// I ran into an issue where if I gave feedback for one of many tool uses, the request would fail.
@@ -151,9 +144,9 @@ export function convertToOpenAiMessages(
role: "user",
content: toolResultImages.map((part) => ({
type: "image_url",
image_url: { url: getImageDataUrl(part.source) },
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
})),
});
})
}
// Process non-tool messages
@@ -165,117 +158,106 @@ export function convertToOpenAiMessages(
return {
type: "image_url",
image_url: {
url: getImageDataUrl(part.source),
url: `data:${part.source.media_type};base64,${part.source.data}`,
},
};
}
}
return { type: "text", text: part.text };
return { type: "text", text: part.text }
}),
});
})
}
} else if (anthropicMessage.role === "assistant") {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (
| ClineTextContentBlock
| ClineImageContentBlock
| ClineAssistantThinkingBlock
| ClineAssistantRedactedThinkingBlock
)[];
toolMessages: ClineAssistantToolUseBlock[];
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
} // assistant cannot send tool_result messages
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (
| ClineTextContentBlock
| ClineImageContentBlock
| ClineAssistantThinkingBlock
| ClineAssistantRedactedThinkingBlock
)[]
toolMessages: ClineAssistantToolUseBlock[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
} // assistant cannot send tool_result messages
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process non-tool messages
let content: string | undefined;
const reasoningDetails: any[] = [];
const thinkingBlock = [];
let content: string | undefined
const reasoningDetails: any[] = []
const thinkingBlock = []
if (nonToolMessages.length > 0) {
nonToolMessages.forEach((part) => {
const anyPart = part as any;
const anyPart = part as any
if (part.type === "text" && anyPart.reasoning_details) {
if (Array.isArray(anyPart.reasoning_details)) {
reasoningDetails.push(...anyPart.reasoning_details);
reasoningDetails.push(...anyPart.reasoning_details)
} else {
reasoningDetails.push(anyPart.reasoning_details);
reasoningDetails.push(anyPart.reasoning_details)
}
}
if (part.type === "thinking" && part.thinking) {
// Reasoning details should have been moved to the text block
thinkingBlock.push(part);
thinkingBlock.push(part)
}
});
})
content = nonToolMessages
.map((part) => {
if (part.type === "text" && part.text) {
return part.text;
return part.text
}
return "";
return ""
})
.join("\n");
.join("\n")
}
// Process tool use messages
const tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] =
toolMessages.map((toolMessage) => {
const toolDetails = toolMessage.reasoning_details;
const toolId = toolMessage.id;
if (toolDetails) {
if (Array.isArray(toolDetails)) {
// For Gemini: reasoning details must be linkable back to the tool call.
// Sometimes OpenRouter/Gemini returns entries without `id`; those poison the next request.
// Keep only entries with an id matching the tool call id.
// See: https://github.com/cline/cline/issues/8214
const validDetails = toolDetails.filter(
(detail: any) => detail?.id === toolId,
);
if (validDetails.length > 0) {
reasoningDetails.push(...validDetails);
}
} else {
// Single reasoning detail - only include if it has matching id
const detail = toolDetails as any;
if (detail?.id === toolId) {
reasoningDetails.push(toolDetails);
}
const tool_calls: OpenAI.Chat.ChatCompletionMessageToolCall[] = toolMessages.map((toolMessage) => {
const toolDetails = toolMessage.reasoning_details
const toolId = toolMessage.id
if (toolDetails) {
if (Array.isArray(toolDetails)) {
// For Gemini: reasoning details must be linkable back to the tool call.
// Sometimes OpenRouter/Gemini returns entries without `id`; those poison the next request.
// Keep only entries with an id matching the tool call id.
// See: https://github.com/cline/cline/issues/8214
const validDetails = toolDetails.filter((detail: any) => detail?.id === toolId)
if (validDetails.length > 0) {
reasoningDetails.push(...validDetails)
}
} else {
// Single reasoning detail - only include if it has matching id
const detail = toolDetails as any
if (detail?.id === toolId) {
reasoningDetails.push(toolDetails)
}
}
}
return {
// Use the same transformation as tool_call_id to ensure IDs match
id: transformToolCallIdForNativeApi(toolId, provider),
type: "function",
function: {
name: toolMessage.name,
// json string
arguments: JSON.stringify(toolMessage.input),
},
};
});
return {
// Use the same transformation as tool_call_id to ensure IDs match
id: transformToolCallIdForNativeApi(toolId, provider),
type: "function",
function: {
name: toolMessage.name,
// json string
arguments: JSON.stringify(toolMessage.input),
},
}
})
// Set content to blank when tool_calls are present but content has no text, per OpenAI API spec
const hasToolCalls = tool_calls.length > 0;
const hasMeaningfulContent =
content !== undefined && content.trim() !== "";
const finalContent = hasMeaningfulContent
? content
: hasToolCalls
? null
: undefined;
const hasToolCalls = tool_calls.length > 0
const hasMeaningfulContent = content !== undefined && content.trim() !== ""
const finalContent = hasMeaningfulContent ? content : hasToolCalls ? null : undefined
const consolidatedReasoningDetails =
reasoningDetails.length > 0
? consolidateReasoningDetails(reasoningDetails as any)
: [];
reasoningDetails.length > 0 ? consolidateReasoningDetails(reasoningDetails as any) : []
openAiMessages.push({
role: "assistant",
@@ -284,91 +266,86 @@ export function convertToOpenAiMessages(
tool_calls: tool_calls?.length > 0 ? tool_calls : undefined,
// Only include reasoning_details when non-empty; sending [] can trigger provider validation issues.
// @ts-expect-error
reasoning_details:
consolidatedReasoningDetails.length > 0
? consolidatedReasoningDetails
: undefined,
});
reasoning_details: consolidatedReasoningDetails.length > 0 ? consolidatedReasoningDetails : undefined,
})
}
}
}
return openAiMessages;
return openAiMessages
}
// Type for OpenRouter's reasoning detail elements
// https://openrouter.ai/docs/use-cases/reasoning-tokens#streaming-response
type ReasoningDetail = {
// https://openrouter.ai/docs/use-cases/reasoning-tokens#reasoning-detail-types
type: string; // "reasoning.summary" | "reasoning.encrypted" | "reasoning.text"
text?: string;
data?: string; // Encrypted reasoning data
signature?: string | null;
id?: string | null; // Unique identifier for the reasoning detail
type: string // "reasoning.summary" | "reasoning.encrypted" | "reasoning.text"
text?: string
data?: string // Encrypted reasoning data
signature?: string | null
id?: string | null // Unique identifier for the reasoning detail
/*
The format of the reasoning detail, with possible values:
"unknown" - Format is not specified
"openai-responses-v1" - OpenAI responses format version 1
"anthropic-claude-v1" - Anthropic Claude format version 1 (default)
*/
format: string; //"unknown" | "openai-responses-v1" | "anthropic-claude-v1" | "xai-responses-v1"
index?: number; // Sequential index of the reasoning detail
};
format: string //"unknown" | "openai-responses-v1" | "anthropic-claude-v1" | "xai-responses-v1"
index?: number // Sequential index of the reasoning detail
}
// Helper function to convert reasoning_details array to the format OpenRouter API expects
// Takes an array of reasoning detail objects and consolidates them by index
function consolidateReasoningDetails(
reasoningDetails: ReasoningDetail[],
): ReasoningDetail[] {
function consolidateReasoningDetails(reasoningDetails: ReasoningDetail[]): ReasoningDetail[] {
if (!reasoningDetails || reasoningDetails.length === 0) {
return [];
return []
}
// Group by index
const groupedByIndex = new Map<number, ReasoningDetail[]>();
const groupedByIndex = new Map<number, ReasoningDetail[]>()
for (const detail of reasoningDetails) {
// Drop corrupted encrypted reasoning blocks that would otherwise trigger:
// "Invalid input: expected string, received undefined" for reasoning_details.*.data
// See: https://github.com/cline/cline/issues/8214
if (detail.type === "reasoning.encrypted" && !detail.data) continue;
if (detail.type === "reasoning.encrypted" && !detail.data) continue
const index = detail.index ?? 0;
const index = detail.index ?? 0
if (!groupedByIndex.has(index)) {
groupedByIndex.set(index, []);
groupedByIndex.set(index, [])
}
groupedByIndex.get(index)!.push(detail);
groupedByIndex.get(index)!.push(detail)
}
// Consolidate each group
const consolidated: ReasoningDetail[] = [];
const consolidated: ReasoningDetail[] = []
for (const [index, details] of groupedByIndex.entries()) {
// Concatenate all text parts
let concatenatedText = "";
let signature: string | undefined;
let id: string | undefined;
let format = "unknown";
let type = "reasoning.text";
let concatenatedText = ""
let signature: string | undefined
let id: string | undefined
let format = "unknown"
let type = "reasoning.text"
for (const detail of details) {
if (detail.text) {
concatenatedText += detail.text;
concatenatedText += detail.text
}
// Keep the signature from the last item that has one
if (detail.signature) {
signature = detail.signature;
signature = detail.signature
}
// Keep the id from the last item that has one
if (detail.id) {
id = detail.id;
id = detail.id
}
// Keep format and type from any item (they should all be the same)
if (detail.format) {
format = detail.format;
format = detail.format
}
if (detail.type) {
type = detail.type;
type = detail.type
}
}
@@ -381,12 +358,12 @@ function consolidateReasoningDetails(
id: id,
format: format,
index: index,
};
consolidated.push(consolidatedEntry);
}
consolidated.push(consolidatedEntry)
}
// For encrypted chunks (data), only keep the last one
let lastDataEntry: ReasoningDetail | undefined;
let lastDataEntry: ReasoningDetail | undefined
for (const detail of details) {
if (detail.data) {
lastDataEntry = {
@@ -396,25 +373,23 @@ function consolidateReasoningDetails(
id: detail.id,
format: detail.format,
index: index,
};
}
}
}
if (lastDataEntry) {
consolidated.push(lastDataEntry);
consolidated.push(lastDataEntry)
}
}
return consolidated;
return consolidated
}
// Unique name to use to filter out tool call that cannot be parsed correctly
const UNIQUE_ERROR_TOOL_NAME = "_cline_error_unknown_function_";
const UNIQUE_ERROR_TOOL_NAME = "_cline_error_unknown_function_"
// Convert OpenAI response to Anthropic format
export function convertToAnthropicMessage(
completion: OpenAI.Chat.Completions.ChatCompletion,
): Anthropic.Messages.Message {
const openAiMessage = completion.choices[0].message;
export function convertToAnthropicMessage(completion: OpenAI.Chat.Completions.ChatCompletion): Anthropic.Messages.Message {
const openAiMessage = completion.choices[0].message
const anthropicMessage: Anthropic.Messages.Message = {
id: completion.id,
type: "message",
@@ -430,14 +405,14 @@ export function convertToAnthropicMessage(
stop_reason: (() => {
switch (completion.choices[0].finish_reason) {
case "stop":
return "end_turn";
return "end_turn"
case "length":
return "max_tokens";
return "max_tokens"
case "tool_calls":
return "tool_use";
return "tool_use"
case "content_filter": // Anthropic doesn't have an exact equivalent
default:
return null;
return null
}
})(),
stop_sequence: null, // which custom stop_sequence was generated, if any (not applicable if you don't use stop_sequence)
@@ -446,40 +421,37 @@ export function convertToAnthropicMessage(
output_tokens: completion.usage?.completion_tokens || 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
};
}
try {
if (openAiMessage?.tool_calls?.length) {
const functionCalls = openAiMessage.tool_calls.filter(
(tc: any) => tc?.type === "function" && tc.function,
);
const functionCalls = openAiMessage.tool_calls.filter((tc: any) => tc?.type === "function" && tc.function)
if (functionCalls.length > 0) {
anthropicMessage.content.push(
...functionCalls.map((toolCall: any): Anthropic.ToolUseBlock => {
let parsedInput = {};
let parsedInput = {}
try {
parsedInput = JSON.parse(toolCall.function?.arguments || "{}");
parsedInput = JSON.parse(toolCall.function?.arguments || "{}")
} catch (error) {
Logger.error("Failed to parse tool arguments:", error);
Logger.error("Failed to parse tool arguments:", error)
}
return {
type: "tool_use",
id: toolCall.id,
name: toolCall.function?.name || UNIQUE_ERROR_TOOL_NAME,
input: parsedInput,
};
}
}),
);
)
}
return anthropicMessage;
return anthropicMessage
}
} catch (error) {
Logger.error("Error converting OpenAI message to Anthropic format:", error);
Logger.error("Error converting OpenAI message to Anthropic format:", error)
}
return anthropicMessage;
return anthropicMessage
}
/**
@@ -498,47 +470,43 @@ export function sanitizeGeminiMessages(
modelId: string,
): OpenAI.Chat.ChatCompletionMessageParam[] {
if (!modelId.includes("gemini")) {
return messages;
return messages
}
const droppedToolCallIds = new Set<string>();
const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = [];
const droppedToolCallIds = new Set<string>()
const sanitized: OpenAI.Chat.ChatCompletionMessageParam[] = []
for (const msg of messages) {
if (msg.role === "assistant") {
const anyMsg = msg as any;
const toolCalls = anyMsg.tool_calls;
const anyMsg = msg as any
const toolCalls = anyMsg.tool_calls
if (Array.isArray(toolCalls) && toolCalls.length > 0) {
const reasoningDetails = anyMsg.reasoning_details;
const hasReasoningDetails =
Array.isArray(reasoningDetails) && reasoningDetails.length > 0;
const reasoningDetails = anyMsg.reasoning_details
const hasReasoningDetails = Array.isArray(reasoningDetails) && reasoningDetails.length > 0
if (!hasReasoningDetails) {
for (const tc of toolCalls) {
if (tc?.id) {
droppedToolCallIds.add(tc.id);
droppedToolCallIds.add(tc.id)
}
}
// Keep any textual content, but drop the tool_calls themselves.
if (anyMsg.content) {
sanitized.push({
role: "assistant",
content: anyMsg.content,
} as any);
sanitized.push({ role: "assistant", content: anyMsg.content } as any)
}
continue;
continue
}
}
}
if (msg.role === "tool") {
const anyMsg = msg as any;
const anyMsg = msg as any
if (anyMsg.tool_call_id && droppedToolCallIds.has(anyMsg.tool_call_id)) {
continue;
continue
}
}
sanitized.push(msg);
sanitized.push(msg)
}
return sanitized;
return sanitized
}
@@ -1,13 +1,5 @@
import type {
ResponseInput,
ResponseInputMessageContentList,
ResponseReasoningItem,
} from "openai/resources/responses/responses";
import {
type ClineStorageMessage,
getBase64ImageSource,
getImageDataUrl,
} from "@/shared/messages/content";
import { ResponseInput, ResponseInputMessageContentList, ResponseReasoningItem } from "openai/resources/responses/responses"
import { ClineStorageMessage } from "@/shared/messages/content"
/**
* Converts an array of ClineStorageMessage objects (extension of Anthropic format) to a ResponseInput array to use with OpenAI's Responses API.
@@ -83,69 +75,56 @@ export function convertToOpenAIResponsesInput(
_messages: ClineStorageMessage[],
options?: { usePreviousResponseId?: boolean },
): {
input: ResponseInput;
previousResponseId?: string;
input: ResponseInput
previousResponseId?: string
} {
// Chain from the latest stored Responses API assistant message when available.
// When chaining, only send new items after that assistant turn.
let previousResponseId: string | undefined;
let messages = _messages;
let previousResponseId: string | undefined
let messages = _messages
if (options?.usePreviousResponseId) {
for (let i = _messages.length - 1; i >= 0; i--) {
const msg = _messages[i];
const msg = _messages[i]
// Must be less than 24 hours old to be considered for chaining as the previous Id is only valid for 24 hours.
// Set to 23 hours to account for any potential delays in processing.
const isLessThan23HoursOld = msg.ts
? Date.now() - msg.ts < 23 * 60 * 60 * 1000
: false;
const isLessThan23HoursOld = msg.ts ? Date.now() - msg.ts < 23 * 60 * 60 * 1000 : false
if (msg.role === "assistant" && msg.id && isLessThan23HoursOld) {
previousResponseId = msg.id;
messages = _messages.slice(i + 1);
break;
previousResponseId = msg.id
messages = _messages.slice(i + 1)
break
}
}
}
const allItems: any[] = [];
const toolUseIdToCallId = new Map<string, string>();
const allItems: any[] = []
const toolUseIdToCallId = new Map<string, string>()
for (const m of messages) {
if (typeof m.content === "string") {
allItems.push({
role: m.role,
content: [{ type: "input_text", text: m.content }],
});
continue;
allItems.push({ role: m.role, content: [{ type: "input_text", text: m.content }] })
continue
}
if (m.role === "assistant") {
// For assistant messages, we must ensure reasoning items are IMMEDIATELY followed
// by their corresponding message or function_call. Process the entire assistant
// turn and ensure proper pairing.
const assistantItems: any[] = [];
const assistantItems: any[] = []
for (const part of m.content) {
switch (part.type) {
case "thinking": {
case "thinking":
// Only include reasoning item if it has actual content (thinking text or summary)
// Empty reasoning items cause API errors: "Item 'rs_...' of type 'reasoning' was provided without its required following item"
const hasThinkingContent =
part.thinking && part.thinking.trim().length > 0;
const hasSummaryContent =
part.summary &&
Array.isArray(part.summary) &&
part.summary.length > 0;
const hasThinkingContent = part.thinking && part.thinking.trim().length > 0
const hasSummaryContent = part.summary && Array.isArray(part.summary) && part.summary.length > 0
if (
part.call_id &&
part.call_id.length > 0 &&
(hasThinkingContent || hasSummaryContent)
) {
if (part.call_id && part.call_id.length > 0 && (hasThinkingContent || hasSummaryContent)) {
// Use summary if available, otherwise use thinking text
let summary: any[] = [];
let summary: any[] = []
if (hasSummaryContent) {
// part.summary is already in the correct format from OpenAI Responses API
summary = part.summary as any[];
summary = part.summary as any[]
} else if (hasThinkingContent) {
// Convert thinking text to summary format
summary = [
@@ -153,17 +132,16 @@ export function convertToOpenAIResponsesInput(
type: "summary_text",
text: part.thinking,
},
];
]
}
assistantItems.push({
id: part.call_id,
type: "reasoning",
summary,
} as ResponseReasoningItem);
} as ResponseReasoningItem)
}
break;
}
break
case "redacted_thinking":
// Include reasoning item with encrypted content if it has a call_id
// Even if data is missing, we need to maintain the reasoning-function_call pairing
@@ -172,115 +150,100 @@ export function convertToOpenAIResponsesInput(
id: part.call_id,
type: "reasoning",
summary: [],
};
}
// Only include encrypted_content if data exists
if (part.data) {
reasoningItem.encrypted_content = part.data;
reasoningItem.encrypted_content = part.data
}
assistantItems.push(reasoningItem as ResponseReasoningItem);
assistantItems.push(reasoningItem as ResponseReasoningItem)
}
break;
case "text": {
break
case "text":
// Message ID goes at the message level, not in the content
// The reasoning item and message can have different IDs - they just need to be adjacent
const messageItem: any = {
type: "message",
role: "assistant",
content: [{ type: "output_text", text: part.text }],
};
}
// Set message-level id if available
if (part.call_id) {
messageItem.id = part.call_id;
messageItem.id = part.call_id
}
assistantItems.push(messageItem);
break;
}
case "image": {
assistantItems.push(messageItem)
break
case "image":
// Message ID goes at the message level, not in the content
const imageItem: any = {
type: "message",
role: "assistant",
content: [
{
type: "output_text",
text: `[image:${getBase64ImageSource(part.source).mediaType}]`,
},
],
};
content: [{ type: "output_text", text: `[image:${part.source.media_type}]` }],
}
// Set message-level id if available (though images typically don't have call_id)
if (part.call_id) {
imageItem.id = part.call_id;
imageItem.id = part.call_id
}
assistantItems.push(imageItem);
break;
}
assistantItems.push(imageItem)
break
case "tool_use": {
// Function calls use call_id, not related to reasoning item ID
const call_id = part.call_id || part.id;
const call_id = part.call_id || part.id
if (part.call_id) {
toolUseIdToCallId.set(part.id, part.call_id);
toolUseIdToCallId.set(part.id, part.call_id)
}
assistantItems.push({
type: "function_call",
call_id,
// MAX 53 characters for OpenAI Responses API tool IDs
id: !part.id.startsWith("fc_")
? `fc_${part.id.slice(0, 50)}`
: part.id,
id: !part.id.startsWith("fc_") ? `fc_${part.id.slice(0, 50)}` : part.id,
name: part.name,
arguments: JSON.stringify(part.input ?? {}),
});
break;
})
break
}
}
}
allItems.push(...assistantItems);
allItems.push(...assistantItems)
} else {
// User messages - collect all content
const messageContent: ResponseInputMessageContentList = [];
const messageContent: ResponseInputMessageContentList = []
for (const part of m.content) {
switch (part.type) {
case "text":
messageContent.push({ type: "input_text", text: part.text });
break;
messageContent.push({ type: "input_text", text: part.text })
break
case "image":
messageContent.push({
type: "input_image",
detail: "auto",
image_url: getImageDataUrl(part.source),
});
break;
image_url: `data:${part.source.media_type};base64,${part.source.data}`,
})
break
case "tool_result": {
// Flush any pending message content before adding tool result
if (messageContent.length > 0) {
allItems.push({ role: m.role, content: [...messageContent] });
messageContent.length = 0;
allItems.push({ role: m.role, content: [...messageContent] })
messageContent.length = 0
}
const call_id =
part.call_id ||
toolUseIdToCallId.get(part.tool_use_id) ||
part.tool_use_id;
const call_id = part.call_id || toolUseIdToCallId.get(part.tool_use_id) || part.tool_use_id
allItems.push({
type: "function_call_output",
call_id,
output:
typeof part.content === "string"
? part.content
: JSON.stringify(part.content),
});
break;
output: typeof part.content === "string" ? part.content : JSON.stringify(part.content),
})
break
}
}
}
// Flush any remaining user message content
if (messageContent.length > 0) {
allItems.push({ role: m.role, content: [...messageContent] });
allItems.push({ role: m.role, content: [...messageContent] })
}
}
}
return { input: allItems, previousResponseId };
return { input: allItems, previousResponseId }
}
+88 -114
View File
@@ -1,17 +1,13 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import type OpenAI from "openai";
import {
type ClineAssistantThinkingBlock,
type ClineStorageMessage,
getImageDataUrl,
} from "@/shared/messages/content";
import { Anthropic } from "@anthropic-ai/sdk"
import OpenAI from "openai"
import { ClineAssistantThinkingBlock, ClineStorageMessage } from "@/shared/messages/content"
/**
* DeepSeek Reasoner message format with reasoning_content support.
*/
export type DeepSeekReasonerMessage = OpenAI.Chat.ChatCompletionMessageParam & {
reasoning_content?: string;
};
reasoning_content?: string
}
/**
* Adds reasoning_content to OpenAI messages for DeepSeek Reasoner.
@@ -25,45 +21,43 @@ export function addReasoningContent(
// Find last user message index (start of current turn)
// If no user message exists (lastUserIndex = -1), all messages are in the "current turn",
// so reasoning_content will be added to all assistant messages. This is intentional.
let lastUserIndex = -1;
let lastUserIndex = -1
for (let i = openAiMessages.length - 1; i >= 0; i--) {
if (openAiMessages[i].role === "user") {
lastUserIndex = i;
break;
lastUserIndex = i
break
}
}
// Extract thinking content from original messages, keyed by assistant index
const thinkingByIndex = new Map<number, string>();
let assistantIdx = 0;
const thinkingByIndex = new Map<number, string>()
let assistantIdx = 0
for (const msg of originalMessages) {
if (msg.role === "assistant") {
if (Array.isArray(msg.content)) {
const thinking = msg.content
.filter(
(p): p is ClineAssistantThinkingBlock => p.type === "thinking",
)
.filter((p): p is ClineAssistantThinkingBlock => p.type === "thinking")
.map((p) => p.thinking)
.join("\n");
.join("\n")
if (thinking) {
thinkingByIndex.set(assistantIdx, thinking);
thinkingByIndex.set(assistantIdx, thinking)
}
}
assistantIdx++;
assistantIdx++
}
}
// Add reasoning_content only to assistant messages in current turn
let aiIdx = 0;
let aiIdx = 0
return openAiMessages.map((msg, i): DeepSeekReasonerMessage => {
if (msg.role === "assistant") {
const thinking = thinkingByIndex.get(aiIdx++);
const thinking = thinkingByIndex.get(aiIdx++)
if (thinking && i >= lastUserIndex) {
return { ...msg, reasoning_content: thinking };
return { ...msg, reasoning_content: thinking }
}
}
return msg;
});
return msg
})
}
/**
@@ -74,104 +68,84 @@ export function addReasoningContent(
* @param messages Array of Anthropic messages
* @returns Array of OpenAI messages where consecutive messages with the same role are merged together
*/
export function convertToR1Format(
messages: Anthropic.Messages.MessageParam[],
): OpenAI.Chat.ChatCompletionMessageParam[] {
return messages.reduce<OpenAI.Chat.ChatCompletionMessageParam[]>(
(merged, message) => {
const lastMessage = merged[merged.length - 1];
let messageContent:
| string
| (
| OpenAI.Chat.ChatCompletionContentPartText
| OpenAI.Chat.ChatCompletionContentPartImage
)[] = "";
let hasImages = false;
export function convertToR1Format(messages: Anthropic.Messages.MessageParam[]): OpenAI.Chat.ChatCompletionMessageParam[] {
return messages.reduce<OpenAI.Chat.ChatCompletionMessageParam[]>((merged, message) => {
const lastMessage = merged[merged.length - 1]
let messageContent: string | (OpenAI.Chat.ChatCompletionContentPartText | OpenAI.Chat.ChatCompletionContentPartImage)[] =
""
let hasImages = false
if (Array.isArray(message.content)) {
const textParts: string[] = [];
const imageParts: OpenAI.Chat.ChatCompletionContentPartImage[] = [];
if (Array.isArray(message.content)) {
const textParts: string[] = []
const imageParts: OpenAI.Chat.ChatCompletionContentPartImage[] = []
message.content.forEach((part) => {
if (part.type === "text") {
textParts.push(part.text);
}
if (part.type === "image") {
hasImages = true;
imageParts.push({
type: "image_url",
image_url: { url: getImageDataUrl(part.source) },
});
}
});
if (hasImages) {
const parts: (
| OpenAI.Chat.ChatCompletionContentPartText
| OpenAI.Chat.ChatCompletionContentPartImage
)[] = [];
if (textParts.length > 0) {
parts.push({ type: "text", text: textParts.join("\n") });
}
parts.push(...imageParts);
messageContent = parts;
} else {
messageContent = textParts.join("\n");
message.content.forEach((part) => {
if (part.type === "text") {
textParts.push(part.text)
}
if (part.type === "image") {
hasImages = true
imageParts.push({
type: "image_url",
image_url: { url: `data:${part.source.media_type};base64,${part.source.data}` },
})
}
})
if (hasImages) {
const parts: (OpenAI.Chat.ChatCompletionContentPartText | OpenAI.Chat.ChatCompletionContentPartImage)[] = []
if (textParts.length > 0) {
parts.push({ type: "text", text: textParts.join("\n") })
}
parts.push(...imageParts)
messageContent = parts
} else {
messageContent = message.content;
messageContent = textParts.join("\n")
}
} else {
messageContent = message.content
}
// If the last message has the same role, merge the content
if (lastMessage?.role === message.role) {
if (
typeof lastMessage.content === "string" &&
typeof messageContent === "string"
) {
lastMessage.content += `\n${messageContent}`;
} else {
const lastContent = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text" as const, text: lastMessage.content || "" }];
const newContent = Array.isArray(messageContent)
? messageContent
: [{ type: "text" as const, text: messageContent }];
if (message.role === "assistant") {
const mergedContent = [
...lastContent,
...newContent,
] as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"];
lastMessage.content = mergedContent;
} else {
const mergedContent = [
...lastContent,
...newContent,
] as OpenAI.Chat.ChatCompletionUserMessageParam["content"];
lastMessage.content = mergedContent;
}
}
// If the last message has the same role, merge the content
if (lastMessage?.role === message.role) {
if (typeof lastMessage.content === "string" && typeof messageContent === "string") {
lastMessage.content += `\n${messageContent}`
} else {
// Adds new message with the correct type based on role
const lastContent = Array.isArray(lastMessage.content)
? lastMessage.content
: [{ type: "text" as const, text: lastMessage.content || "" }]
const newContent = Array.isArray(messageContent)
? messageContent
: [{ type: "text" as const, text: messageContent }]
if (message.role === "assistant") {
const newMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam = {
role: "assistant",
content:
messageContent as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"],
};
merged.push(newMessage);
const mergedContent = [
...lastContent,
...newContent,
] as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"]
lastMessage.content = mergedContent
} else {
const newMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {
role: "user",
content:
messageContent as OpenAI.Chat.ChatCompletionUserMessageParam["content"],
};
merged.push(newMessage);
const mergedContent = [...lastContent, ...newContent] as OpenAI.Chat.ChatCompletionUserMessageParam["content"]
lastMessage.content = mergedContent
}
}
return merged;
},
[],
);
} else {
// Adds new message with the correct type based on role
if (message.role === "assistant") {
const newMessage: OpenAI.Chat.ChatCompletionAssistantMessageParam = {
role: "assistant",
content: messageContent as OpenAI.Chat.ChatCompletionAssistantMessageParam["content"],
}
merged.push(newMessage)
} else {
const newMessage: OpenAI.Chat.ChatCompletionUserMessageParam = {
role: "user",
content: messageContent as OpenAI.Chat.ChatCompletionUserMessageParam["content"],
}
merged.push(newMessage)
}
}
return merged
}, [])
}
@@ -1,6 +1,6 @@
import type { Anthropic } from "@anthropic-ai/sdk";
import * as vscode from "vscode";
import { Logger } from "@/shared/services/Logger";
import { Anthropic } from "@anthropic-ai/sdk"
import * as vscode from "vscode"
import { Logger } from "@/shared/services/Logger"
/**
* Safely converts a value into a plain object.
@@ -8,31 +8,31 @@ import { Logger } from "@/shared/services/Logger";
export function asObjectSafe(value: any): object {
// Handle null/undefined
if (!value) {
return {};
return {}
}
try {
// Handle strings that might be JSON
if (typeof value === "string") {
return JSON.parse(value);
return JSON.parse(value)
}
// Handle pre-existing objects
if (typeof value === "object") {
return Object.assign({}, value);
return Object.assign({}, value)
}
return {};
return {}
} catch (error) {
Logger.warn("Cline <Language Model API>: Failed to parse object:", error);
return {};
Logger.warn("Cline <Language Model API>: Failed to parse object:", error)
return {}
}
}
export function convertToVsCodeLmMessages(
anthropicMessages: Anthropic.Messages.MessageParam[],
): vscode.LanguageModelChatMessage[] {
const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = [];
const vsCodeLmMessages: vscode.LanguageModelChatMessage[] = []
for (const anthropicMessage of anthropicMessages) {
// Handle simple string messages
@@ -41,31 +41,27 @@ export function convertToVsCodeLmMessages(
anthropicMessage.role === "assistant"
? vscode.LanguageModelChatMessage.Assistant(anthropicMessage.content)
: vscode.LanguageModelChatMessage.User(anthropicMessage.content),
);
continue;
)
continue
}
// Handle complex message structures
switch (anthropicMessage.role) {
case "user": {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
)[];
toolMessages: Anthropic.ToolResultBlockParam[];
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
}
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolResultBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_result") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
}
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool messages first then non-tool messages
const contentParts = [
@@ -78,55 +74,46 @@ export function convertToVsCodeLmMessages(
: (toolMessage.content?.map((part) => {
if (part.type === "image") {
return new vscode.LanguageModelTextPart(
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && part.source.media_type) || "unknown media-type"} not supported by VSCode LM API]`,
);
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
)
}
return new vscode.LanguageModelTextPart(part.text);
}) ?? [new vscode.LanguageModelTextPart("")]);
return new vscode.LanguageModelTextPart(part.text)
}) ?? [new vscode.LanguageModelTextPart("")])
return new vscode.LanguageModelToolResultPart(
toolMessage.tool_use_id,
toolContentParts,
);
return new vscode.LanguageModelToolResultPart(toolMessage.tool_use_id, toolContentParts)
}),
// Convert non-tool messages to TextParts after tool messages
...nonToolMessages.map((part) => {
if (part.type === "image") {
return new vscode.LanguageModelTextPart(
`[Image (${part.source?.type || "Unknown source-type"}): ${(part.source?.type === "base64" && part.source.media_type) || "unknown media-type"} not supported by VSCode LM API]`,
);
`[Image (${part.source?.type || "Unknown source-type"}): ${part.source?.media_type || "unknown media-type"} not supported by VSCode LM API]`,
)
}
return new vscode.LanguageModelTextPart(part.text);
return new vscode.LanguageModelTextPart(part.text)
}),
];
]
// Add single user message with all content parts
vsCodeLmMessages.push(
vscode.LanguageModelChatMessage.User(contentParts),
);
break;
vsCodeLmMessages.push(vscode.LanguageModelChatMessage.User(contentParts))
break
}
case "assistant": {
const { nonToolMessages, toolMessages } =
anthropicMessage.content.reduce<{
nonToolMessages: (
| Anthropic.TextBlockParam
| Anthropic.ImageBlockParam
)[];
toolMessages: Anthropic.ToolUseBlockParam[];
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part);
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part);
}
return acc;
},
{ nonToolMessages: [], toolMessages: [] },
);
const { nonToolMessages, toolMessages } = anthropicMessage.content.reduce<{
nonToolMessages: (Anthropic.TextBlockParam | Anthropic.ImageBlockParam)[]
toolMessages: Anthropic.ToolUseBlockParam[]
}>(
(acc, part) => {
if (part.type === "tool_use") {
acc.toolMessages.push(part)
} else if (part.type === "text" || part.type === "image") {
acc.nonToolMessages.push(part)
}
return acc
},
{ nonToolMessages: [], toolMessages: [] },
)
// Process tool messages first then non-tool messages
const contentParts = [
@@ -143,24 +130,20 @@ export function convertToVsCodeLmMessages(
// Convert non-tool messages to TextParts after tool messages
...nonToolMessages.map((part) => {
if (part.type === "image") {
return new vscode.LanguageModelTextPart(
"[Image generation not supported by VSCode LM API]",
);
return new vscode.LanguageModelTextPart("[Image generation not supported by VSCode LM API]")
}
return new vscode.LanguageModelTextPart(part.text);
return new vscode.LanguageModelTextPart(part.text)
}),
];
]
// Add the assistant message to the list of messages
vsCodeLmMessages.push(
vscode.LanguageModelChatMessage.Assistant(contentParts),
);
break;
vsCodeLmMessages.push(vscode.LanguageModelChatMessage.Assistant(contentParts))
break
}
}
}
return vsCodeLmMessages;
return vsCodeLmMessages
}
export function convertToAnthropicRole(
@@ -168,22 +151,18 @@ export function convertToAnthropicRole(
): Anthropic.Messages.MessageParam["role"] | null {
switch (vsCodeLmMessageRole) {
case vscode.LanguageModelChatMessageRole.Assistant:
return "assistant";
return "assistant"
case vscode.LanguageModelChatMessageRole.User:
return "user";
return "user"
default:
return null;
return null
}
}
export function convertToAnthropicMessage(
vsCodeLmMessage: vscode.LanguageModelChatMessage,
): Anthropic.Messages.Message {
const anthropicRole = convertToAnthropicRole(vsCodeLmMessage.role);
export function convertToAnthropicMessage(vsCodeLmMessage: vscode.LanguageModelChatMessage): Anthropic.Messages.Message {
const anthropicRole = convertToAnthropicRole(vsCodeLmMessage.role)
if (anthropicRole !== "assistant") {
throw new Error(
"Cline <Language Model API>: Only assistant messages are supported.",
);
throw new Error("Cline <Language Model API>: Only assistant messages are supported.")
}
return {
@@ -198,7 +177,7 @@ export function convertToAnthropicMessage(
type: "text",
text: part.value,
citations: null,
};
}
}
if (part instanceof vscode.LanguageModelToolCallPart) {
@@ -207,10 +186,10 @@ export function convertToAnthropicMessage(
id: part.callId || crypto.randomUUID(),
name: part.name,
input: asObjectSafe(part.input),
};
}
}
return null;
return null
})
.filter((part): part is Anthropic.ContentBlock => part !== null),
stop_reason: null,
@@ -220,7 +199,6 @@ export function convertToAnthropicMessage(
output_tokens: 0,
cache_creation_input_tokens: null,
cache_read_input_tokens: null,
server_tool_use: null,
},
};
}
}
+86 -132
View File
@@ -1,33 +1,33 @@
import { findLastIndex } from "@shared/array";
import type { ClineMessage } from "@shared/ExtensionMessage";
import type { ClineStorageMessage } from "@shared/messages/content";
import { Logger } from "@/shared/services/Logger";
import type { ContextManager } from "../context/context-management/ContextManager";
import type { MessageStateHandler } from "../task/message-state";
import type { HookModelInputContext } from "./hook-factory";
import { findLastIndex } from "@shared/array"
import type { ClineMessage } from "@shared/ExtensionMessage"
import type { ClineStorageMessage } from "@shared/messages/content"
import { Logger } from "@/shared/services/Logger"
import type { ContextManager } from "../context/context-management/ContextManager"
import type { MessageStateHandler } from "../task/message-state"
import type { HookModelInputContext } from "./hook-factory"
/**
* Active hook execution state
* Represents a hook process that is currently running
*/
export type HookExecution = {
hookName: string;
toolName?: string;
messageTs: number;
abortController: AbortController;
};
hookName: string
toolName?: string
messageTs: number
abortController: AbortController
}
/**
* Custom error class for hook cancellation
* Used to signal that a hook cancelled an operation
*/
export class HookCancellationError extends Error {
public readonly wasCancelled: boolean;
public readonly wasCancelled: boolean
constructor(wasCancelled: boolean) {
super("Hook cancelled the operation");
this.name = "HookCancellationError";
this.wasCancelled = wasCancelled;
super("Hook cancelled the operation")
this.name = "HookCancellationError"
this.wasCancelled = wasCancelled
}
}
@@ -35,10 +35,10 @@ export class HookCancellationError extends Error {
* Token usage information extracted from an API request message
*/
export interface TokenUsage {
tokensIn: number;
tokensOut: number;
tokensInCache: number;
tokensOutCache: number;
tokensIn: number
tokensOut: number
tokensInCache: number
tokensOutCache: number
}
/**
@@ -46,34 +46,29 @@ export interface TokenUsage {
* @param message The API request message to parse
* @returns Token usage information, or zeros if parsing fails
*/
export function extractTokenUsageFromMessage(
message: ClineMessage | undefined,
): TokenUsage {
export function extractTokenUsageFromMessage(message: ClineMessage | undefined): TokenUsage {
const defaultUsage: TokenUsage = {
tokensIn: 0,
tokensOut: 0,
tokensInCache: 0,
tokensOutCache: 0,
};
}
if (!message?.text) {
return defaultUsage;
return defaultUsage
}
try {
const apiReqInfo = JSON.parse(message.text);
const apiReqInfo = JSON.parse(message.text)
return {
tokensIn: apiReqInfo.tokensIn || 0,
tokensOut: apiReqInfo.tokensOut || 0,
tokensInCache: apiReqInfo.cacheWrites || 0,
tokensOutCache: apiReqInfo.cacheReads || 0,
};
}
} catch (error) {
Logger.error(
"[PreCompact] Failed to parse API request token usage:",
error,
);
return defaultUsage;
Logger.error("[PreCompact] Failed to parse API request token usage:", error)
return defaultUsage
}
}
@@ -81,9 +76,9 @@ export function extractTokenUsageFromMessage(
* Context files written for hook access
*/
export interface PreCompactContextFiles {
contextJsonPath: string;
contextRawPath: string;
hookTimestamp: number;
contextJsonPath: string
contextRawPath: string
hookTimestamp: number
}
/**
@@ -96,32 +91,23 @@ export async function writePreCompactContextFiles(
taskId: string,
currentContext: ClineStorageMessage[],
): Promise<PreCompactContextFiles> {
const { writeConversationHistoryJson, writeConversationHistoryText } =
await import("../storage/disk");
const { writeConversationHistoryJson, writeConversationHistoryText } = await import("../storage/disk")
// Generate single timestamp for both files to ensure they match
const hookTimestamp = Date.now();
const hookTimestamp = Date.now()
// Write context files for hook access
const contextJsonPath = await writeConversationHistoryJson(
taskId,
currentContext,
hookTimestamp,
);
const contextRawPath = await writeConversationHistoryText(
taskId,
currentContext,
hookTimestamp,
);
const contextJsonPath = await writeConversationHistoryJson(taskId, currentContext, hookTimestamp)
const contextRawPath = await writeConversationHistoryText(taskId, currentContext, hookTimestamp)
return { contextJsonPath, contextRawPath, hookTimestamp };
return { contextJsonPath, contextRawPath, hookTimestamp }
}
/**
* Task state interface for cancellation handling
*/
export interface TaskStateForCancellation {
didFinishAbortingStream: boolean;
didFinishAbortingStream: boolean
}
/**
@@ -131,61 +117,53 @@ export interface TaskStateForCancellation {
export interface PreCompactHookParams {
// Task identification
/** Task identifier */
taskId: string;
taskId: string
/** ULID for telemetry */
ulid: string;
ulid: string
/** Active hook model context */
modelContext: HookModelInputContext;
modelContext: HookModelInputContext
// Conversation state
/** API conversation history */
apiConversationHistory: ClineStorageMessage[];
apiConversationHistory: ClineStorageMessage[]
/** Current deleted range (if any) */
conversationHistoryDeletedRange?: [number, number];
conversationHistoryDeletedRange?: [number, number]
/** Cline messages for extracting token usage */
clineMessages: ClineMessage[];
clineMessages: ClineMessage[]
// Services
/** Context manager for getting truncated messages */
contextManager: ContextManager;
contextManager: ContextManager
/** Message state handler for accessing conversation data */
messageStateHandler: MessageStateHandler;
messageStateHandler: MessageStateHandler
// Compaction metadata
/** Compaction strategy to report in hook data */
compactionStrategy: string;
compactionStrategy: string
/** Optional: Pre-calculated deleted range to report */
deletedRange?: [number, number];
deletedRange?: [number, number]
// UI callbacks
/** Callback to display messages */
say: (
type: any,
text?: string,
images?: string[],
files?: string[],
partial?: boolean,
) => Promise<number | undefined>;
say: (type: any, text?: string, images?: string[], files?: string[], partial?: boolean) => Promise<number | undefined>
/** Callback to save state and post to webview */
postStateToWebview: () => Promise<void>;
postStateToWebview: () => Promise<void>
// Hook management callbacks
/** Callback to set active hook execution */
setActiveHookExecution: (
hookExecution: HookExecution | undefined,
) => Promise<void>;
setActiveHookExecution: (hookExecution: HookExecution | undefined) => Promise<void>
/** Callback to clear active hook execution */
clearActiveHookExecution: () => Promise<void>;
clearActiveHookExecution: () => Promise<void>
// Cancellation dependencies
/** Task state object for setting abort flag */
taskState: TaskStateForCancellation;
taskState: TaskStateForCancellation
/** Callback to cancel the task */
cancelTask: () => Promise<void>;
cancelTask: () => Promise<void>
// Configuration
/** Whether hooks are enabled */
hooksEnabled: boolean;
hooksEnabled: boolean
}
/**
@@ -193,7 +171,7 @@ export interface PreCompactHookParams {
*/
export interface PreCompactHookResult {
/** Context modification provided by the hook */
contextModification?: string;
contextModification?: string
}
/**
@@ -206,52 +184,37 @@ export interface PreCompactHookResult {
* @throws HookCancellationError if the hook cancels the operation
* @throws Re-throws other errors after cleanup (caller should handle gracefully)
*/
export async function executePreCompactHookWithCleanup(
params: PreCompactHookParams,
): Promise<PreCompactHookResult> {
const { executeHook } = await import("./hook-executor");
const { cleanupConversationHistoryFile } = await import("../storage/disk");
export async function executePreCompactHookWithCleanup(params: PreCompactHookParams): Promise<PreCompactHookResult> {
const { executeHook } = await import("./hook-executor")
const { cleanupConversationHistoryFile } = await import("../storage/disk")
let contextJsonPath: string | undefined;
let contextRawPath: string | undefined;
let contextJsonPath: string | undefined
let contextRawPath: string | undefined
try {
// Get current active context (respects previous compactions).
// getTruncatedMessages types its output as Anthropic.MessageParam[], but it slices the Cline-stored
// conversation history (ClineStorageMessage[]) passed in, so narrow it back here.
// Get current active context (respects previous compactions)
const currentContext = params.contextManager.getTruncatedMessages(
params.apiConversationHistory,
params.conversationHistoryDeletedRange,
) as ClineStorageMessage[];
)
// Write context files for hook access
const contextFiles = await writePreCompactContextFiles(
params.taskId,
currentContext,
);
contextJsonPath = contextFiles.contextJsonPath;
contextRawPath = contextFiles.contextRawPath;
const contextFiles = await writePreCompactContextFiles(params.taskId, currentContext)
contextJsonPath = contextFiles.contextJsonPath
contextRawPath = contextFiles.contextRawPath
// Extract token usage from the most recent API request
const previousApiReqIndex = findLastIndex(
params.clineMessages,
(m) => m.say === "api_req_started",
);
const previousRequest =
previousApiReqIndex !== -1
? params.clineMessages[previousApiReqIndex]
: undefined;
const { tokensIn, tokensOut, tokensInCache, tokensOutCache } =
extractTokenUsageFromMessage(previousRequest);
const previousApiReqIndex = findLastIndex(params.clineMessages, (m) => m.say === "api_req_started")
const previousRequest = previousApiReqIndex !== -1 ? params.clineMessages[previousApiReqIndex] : undefined
const { tokensIn, tokensOut, tokensInCache, tokensOutCache } = extractTokenUsageFromMessage(previousRequest)
// Extract truncation range - use provided range or extract from conversationHistoryDeletedRange
let deletedRangeStart = 0;
let deletedRangeEnd = 0;
let deletedRangeStart = 0
let deletedRangeEnd = 0
if (params.deletedRange) {
[deletedRangeStart, deletedRangeEnd] = params.deletedRange;
;[deletedRangeStart, deletedRangeEnd] = params.deletedRange
} else if (params.conversationHistoryDeletedRange) {
[deletedRangeStart, deletedRangeEnd] =
params.conversationHistoryDeletedRange;
;[deletedRangeStart, deletedRangeEnd] = params.conversationHistoryDeletedRange
}
// Execute the hook
@@ -282,62 +245,53 @@ export async function executePreCompactHookWithCleanup(
taskId: params.taskId,
hooksEnabled: params.hooksEnabled,
model: params.modelContext,
});
})
// Handle cancellation from hook
if (preCompactResult.cancel === true) {
// Log cancellation for debugging
const cancellationSource = preCompactResult.wasCancelled
? "user"
: "PreCompact hook";
Logger.log(
`[PreCompact] Context compaction cancelled by ${cancellationSource} for task ${params.taskId}`,
);
const cancellationSource = preCompactResult.wasCancelled ? "user" : "PreCompact hook"
Logger.log(`[PreCompact] Context compaction cancelled by ${cancellationSource} for task ${params.taskId}`)
// Internalized cancellation state management (replaces handleCancellation callback)
// Always save state before cancelling, regardless of cancellation source
params.taskState.didFinishAbortingStream = true;
await params.messageStateHandler.saveClineMessagesAndUpdateHistory();
params.taskState.didFinishAbortingStream = true
await params.messageStateHandler.saveClineMessagesAndUpdateHistory()
await params.messageStateHandler.overwriteApiConversationHistory(
params.messageStateHandler.getApiConversationHistory(),
);
await params.postStateToWebview();
)
await params.postStateToWebview()
// Trigger full cancellation flow
await params.cancelTask();
await params.cancelTask()
// Throw error to signal cancellation to caller
throw new HookCancellationError(preCompactResult.wasCancelled);
throw new HookCancellationError(preCompactResult.wasCancelled)
}
// Hook completed successfully - log if context modification provided
if (preCompactResult.contextModification) {
Logger.log(
`[PreCompact] Hook provided context modification for task ${params.taskId}`,
);
Logger.log(`[PreCompact] Hook provided context modification for task ${params.taskId}`)
}
return {
contextModification: preCompactResult.contextModification,
};
}
} catch (error) {
// Re-throw error for caller to handle
throw error;
throw error
} finally {
// Clean up temporary files - always executed regardless of success or error
// Wrap in try-catch to prevent cleanup failures from masking original errors
try {
if (contextJsonPath) {
await cleanupConversationHistoryFile(contextJsonPath);
await cleanupConversationHistoryFile(contextJsonPath)
}
if (contextRawPath) {
await cleanupConversationHistoryFile(contextRawPath);
await cleanupConversationHistoryFile(contextRawPath)
}
} catch (cleanupError) {
Logger.error(
"[PreCompact] Failed to cleanup context files:",
cleanupError,
);
Logger.error("[PreCompact] Failed to cleanup context files:", cleanupError)
// Don't throw - cleanup failure shouldn't mask original error
}
}

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