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
64 changed files with 3221 additions and 397 deletions
+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;
}
+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);
}
}
+27 -220
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,
@@ -27,6 +24,16 @@ import {
resolveClineDir,
resolvePluginModuleEntries,
} from "@cline/shared/storage";
import {
downloadRemoteFile,
hashSource,
isLocalPathLike,
isOfficialRegistrySlug,
normalizeRemoteSingleFileUrl,
resolveHomePath,
runCommand,
sanitizeSegment,
} from "./install-utils";
export interface PluginInstallOptions {
source: string;
@@ -109,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 {
@@ -219,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(
@@ -377,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);
@@ -496,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 {
@@ -832,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(
+5
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]",
@@ -225,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;
+158
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,
@@ -47,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"),
}));
@@ -142,6 +167,7 @@ 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 () => {}),
@@ -215,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);
@@ -872,6 +903,133 @@ 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();
+117 -1
View File
@@ -14,6 +14,7 @@ import {
autoUpdateOnStartup,
getPreferredKanbanInstaller,
} from "./commands/update";
import { resolveAgentProfileDisabledPluginPaths } from "./runtime/agent-profile-plugins";
import { CLI_DEFAULT_CHECKPOINT_CONFIG } from "./runtime/defaults";
import {
buildCliCompactionConfig,
@@ -42,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";
@@ -311,6 +312,71 @@ export async function runCli(): Promise<void> {
io,
});
});
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 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")
@@ -928,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:
@@ -942,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,
@@ -964,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];
}
@@ -429,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),
@@ -443,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);
@@ -1,5 +1,6 @@
import {
createCoreSettingsService,
setAlwaysEnabledPlugin,
setDisabledPlugin,
setDisabledTools,
type UserInstructionConfigService,
@@ -71,7 +72,15 @@ export function createInteractiveConfigDataLoader(input: {
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
setDisabledPlugin(item.path, item.enabled);
return undefined;
// 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") {
@@ -119,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 = {},
@@ -139,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",
});
+49
View File
@@ -37,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";
@@ -90,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 }) => {
@@ -108,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();
@@ -318,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;
@@ -330,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;
@@ -409,6 +452,7 @@ export async function runInteractive(
}),
loadConfigData: configDataLoader.loadConfigData,
onToggleConfigItem,
onToggleAlwaysEnabledConfigItem,
onDeleteConfigItem,
subscribeToEvents: ({
onAgentEvent: onAgent,
@@ -598,6 +642,11 @@ 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();
},
@@ -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",
+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,
@@ -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,
+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({
+35 -96
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;
@@ -175,91 +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): string {
let current = dirname(filePath);
for (let depth = 0; depth < 4; depth++) {
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 {
@@ -375,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));
@@ -387,6 +325,7 @@ export async function loadInteractiveConfigData(input: {
name: getPluginDisplayName(filePath),
path: filePath,
enabled: !disabledPlugins.has(filePath),
alwaysEnabled: alwaysEnabledPlugins.has(filePath),
kind: "plugin",
configKind: "plugin",
source: detectPluginSource(filePath, input.workspaceRoot),
+42
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,
+10 -1
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,
@@ -166,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>
@@ -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");
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import type { InteractiveConfigItem } from "../../tui/interactive-config";
import {
canAlwaysEnableConfigFooterRow,
canToggleConfigFooterRow,
getAdjacentConfigTab,
getConfigFooterText,
@@ -116,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");
+57 -9
View File
@@ -19,6 +19,7 @@ import { resolveModelDisplayName } from "../components/status-bar";
import { getModeAccent, palette } from "../palette";
import {
type ConfigAction,
canAlwaysEnableConfigFooterRow,
canDeleteConfigFooterRow,
canToggleConfigFooterRow,
getAdjacentConfigTab,
@@ -136,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,
@@ -532,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);
@@ -550,15 +559,9 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
if (nextData) {
setConfigData(nextData);
setPluginToolsLoaded(nextData.tools.some((tool) => tool.pluginName));
} else if (item.kind === "plugin" && loadConfigData) {
const refreshedData = await loadConfigData({
includePluginTools: true,
});
setConfigData(refreshedData);
setPluginToolsLoaded(
refreshedData.tools.some((tool) => tool.pluginName),
);
setPluginToolsError(undefined);
if (item.kind === "plugin") {
setPluginToolsError(undefined);
}
}
} catch (error) {
setConfigData(previousData);
@@ -639,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;
@@ -693,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();
}
@@ -863,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>
@@ -895,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>
@@ -55,6 +55,7 @@ function createPluginCommandDefinition(
export async function createWorkspaceChatCommandHost(input: {
cwd: string;
workspaceRoot?: string;
disabledPluginPaths?: ReadonlyArray<string>;
logger?: BasicLogger;
}): Promise<WorkspaceChatCommandHostResult> {
const workspaceRoot = input.workspaceRoot?.trim() || input.cwd;
@@ -63,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);
+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;
}
@@ -388,6 +388,37 @@ describe("plugin-config-loader", () => {
}
});
it("unions session-scoped disabled paths with globally disabled plugins", async () => {
const root = await mkdtemp(join(tmpdir(), "core-plugin-config-loader-"));
try {
process.env.HOME = root;
setHomeDir(root);
const keptPlugin = join(root, "kept.js");
const globallyDisabled = join(root, "globally-disabled.js");
const sessionDisabled = join(root, "session-disabled.js");
const settingsPath = join(root, "global-settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
await writeFile(keptPlugin, "export default {}", "utf8");
await writeFile(globallyDisabled, "export default {}", "utf8");
await writeFile(sessionDisabled, "export default {}", "utf8");
await writeFile(
settingsPath,
JSON.stringify({ disabledPlugins: [globallyDisabled] }, null, 2),
"utf8",
);
const resolved = resolveAgentPluginPaths({
pluginPaths: [keptPlugin, globallyDisabled, sessionDisabled],
cwd: root,
disabledPluginPaths: [sessionDisabled],
});
expect(resolved).toEqual([keptPlugin]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("loads valid plugins while reporting failures and duplicate overrides", async () => {
const root = await mkdtemp(join(tmpdir(), "core-plugin-config-loader-"));
try {
@@ -36,6 +36,11 @@ export interface ResolveAgentPluginPathsOptions {
pluginPaths?: ReadonlyArray<string>;
workspacePath?: string;
cwd?: string;
/**
* Session-scoped disable list (absolute entry paths), applied in addition
* to the globally disabled plugins from settings.
*/
disabledPluginPaths?: ReadonlyArray<string>;
}
function isDirectory(path: string): boolean {
@@ -60,9 +65,19 @@ function dedupePaths(paths: Iterable<string>): string[] {
return deduped;
}
function mergePluginPaths(paths: Iterable<string>): string[] {
function mergePluginPaths(
paths: Iterable<string>,
sessionDisabledPaths?: ReadonlyArray<string>,
): string[] {
const deduped = dedupePaths(paths);
return filterDisabledPluginPaths(deduped);
const filtered = filterDisabledPluginPaths(deduped);
if (!sessionDisabledPaths?.length) {
return filtered;
}
const sessionDisabled = new Set(
sessionDisabledPaths.map((path) => resolve(path)),
);
return filtered.filter((path) => !sessionDisabled.has(path));
}
function resolveDiscoveredPluginPaths(
@@ -187,7 +202,10 @@ export function resolveAgentPluginPaths(
cwd,
);
return mergePluginPaths([...configuredPaths, ...discoveredFromSearchPaths]);
return mergePluginPaths(
[...configuredPaths, ...discoveredFromSearchPaths],
options.disabledPluginPaths,
);
}
function resolveAgentPluginPathsBestEffort(
@@ -202,7 +220,10 @@ function resolveAgentPluginPathsBestEffort(
cwd,
);
return mergePluginPaths([...configuredPaths, ...discoveredFromSearchPaths]);
return mergePluginPaths(
[...configuredPaths, ...discoveredFromSearchPaths],
options.disabledPluginPaths,
);
}
export function resolvePluginSkillDirectoriesFromPaths(
@@ -23,6 +23,72 @@ You are a code reviewer.`);
});
});
it("parses plugins entries as bare names and install mappings", () => {
const config = parseConfiguredAgentConfig(`---
name: code-reviewer
description: Reviews code
plugins:
- branch-protector
- name: clickhouse-data-analyst
install: clickhouse-data-analyst
- name: my-tool
install: https://github.com/someone/repo/blob/main/plugin.ts
---
You are a code reviewer.`);
expect(config.plugins).toEqual([
{ name: "branch-protector" },
{ name: "clickhouse-data-analyst", install: "clickhouse-data-analyst" },
{
name: "my-tool",
install: "https://github.com/someone/repo/blob/main/plugin.ts",
},
]);
});
it("parses plugins from a comma-separated string and dedupes by name", () => {
const config = parseConfiguredAgentConfig(`---
name: code-reviewer
description: Reviews code
plugins: branch-protector, Branch-Protector, , other-tool
---
You are a code reviewer.`);
expect(config.plugins).toEqual([
{ name: "branch-protector" },
{ name: "other-tool" },
]);
});
it("distinguishes an absent plugins field from an empty one", () => {
const absent = parseConfiguredAgentConfig(`---
name: code-reviewer
description: Reviews code
---
You are a code reviewer.`);
expect(absent.plugins).toBeUndefined();
const empty = parseConfiguredAgentConfig(`---
name: code-reviewer
description: Reviews code
plugins: []
---
You are a code reviewer.`);
expect(empty.plugins).toEqual([]);
});
it("rejects plugin mappings without a name", () => {
expect(() =>
parseConfiguredAgentConfig(`---
name: code-reviewer
description: Reviews code
plugins:
- install: https://example.com/plugin.ts
---
You are a code reviewer.`),
).toThrow();
});
it("does not treat delimiter lines in the body as frontmatter delimiters", () => {
const config = parseConfiguredAgentConfig(`---
name: code-reviewer
@@ -4,21 +4,40 @@ import { resolveAgentConfigSearchPaths } from "@cline/shared/storage";
import YAML from "yaml";
import { z } from "zod";
// A plugin entry is either a bare name (matched against installed plugins)
// or a mapping carrying an install source consumed by `cline agent install`.
const ConfiguredAgentPluginEntrySchema = z.union([
z.string(),
z.object({
name: z.string().trim().min(1),
install: z.string().trim().min(1).optional(),
}),
]);
const ConfiguredAgentFrontmatterSchema = z.object({
name: z.string().trim().min(1),
description: z.string().trim().min(1),
tools: z.union([z.string(), z.array(z.string())]).optional(),
skills: z.union([z.string(), z.array(z.string())]).optional(),
plugins: z
.union([z.string(), z.array(ConfiguredAgentPluginEntrySchema)])
.optional(),
providerId: z.string().trim().min(1).optional(),
modelId: z.string().trim().min(1).optional(),
maxIterations: z.number().int().positive().optional(),
});
export interface ConfiguredAgentPluginRef {
name: string;
install?: string;
}
export interface ConfiguredAgentConfig {
name: string;
description: string;
tools?: string[];
skills?: string[];
plugins?: ConfiguredAgentPluginRef[];
providerId?: string;
modelId?: string;
maxIterations?: number;
@@ -100,6 +119,31 @@ function parseStringList(
);
}
function parsePluginList(
value: z.infer<typeof ConfiguredAgentFrontmatterSchema>["plugins"],
): ConfiguredAgentPluginRef[] | undefined {
if (value === undefined) {
return undefined;
}
const raw = Array.isArray(value) ? value : value.split(",");
const refs: ConfiguredAgentPluginRef[] = [];
const seen = new Set<string>();
for (const entry of raw) {
const name = (typeof entry === "string" ? entry : entry.name).trim();
if (!name) {
continue;
}
const key = name.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
const install = typeof entry === "string" ? undefined : entry.install;
refs.push(install ? { name, install } : { name });
}
return refs;
}
function normalizeAgentName(name: string): string {
return name.trim().toLowerCase();
}
@@ -134,6 +178,7 @@ export function parseConfiguredAgentConfig(
description: parsed.description,
tools: parseStringList(parsed.tools),
skills: parseStringList(parsed.skills),
plugins: parsePluginList(parsed.plugins),
providerId: parsed.providerId,
modelId: parsed.modelId,
maxIterations: parsed.maxIterations,
@@ -1,6 +1,7 @@
export {
type ConfiguredAgentConfig,
type ConfiguredAgentLoadResult,
type ConfiguredAgentPluginRef,
type ConfiguredAgentReadError,
loadConfiguredAgentConfigs,
parseConfiguredAgentConfig,
@@ -320,11 +320,17 @@ describe("createSpawnAgentTool", () => {
},
);
expect(agentConstructorSpy).toHaveBeenCalledWith(
expect.objectContaining({
systemPrompt: inputSystemPrompt,
}),
const constructedConfig = agentConstructorSpy.mock.calls[0]?.[0] as {
systemPrompt: string;
};
expect(constructedConfig.systemPrompt.startsWith(inputSystemPrompt)).toBe(
true,
);
// The embedded workspace configuration is not injected a second time.
const markerCount = constructedConfig.systemPrompt.split(
"# Workspace Configuration",
).length;
expect(markerCount - 1).toBe(1);
});
it("resolves connection settings lazily at execution time", async () => {
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import type { DelegatedAgentRuntimeConfig } from "./delegated-agent";
import {
buildSubAgentSystemPrompt,
buildTeammateSystemPrompt,
} from "./subagent-prompts";
const PROFILE_BODY = "You are a reviewer. Focus on correctness.";
function makeConfig(
overrides: Partial<DelegatedAgentRuntimeConfig> = {},
): DelegatedAgentRuntimeConfig {
return {
providerId: "cline",
modelId: "model",
cwd: "/repo",
apiKey: "key",
clineIdeName: "Terminal",
clinePlatform: "linux",
workspaceMetadata: '{"workspaces":{}}',
...overrides,
};
}
describe("buildSubAgentSystemPrompt", () => {
it("fills the persona slot and keeps the agent harness for cline", () => {
const prompt = buildSubAgentSystemPrompt(PROFILE_BODY, makeConfig());
expect(prompt.startsWith(PROFILE_BODY)).toBe(true);
expect(prompt).toContain("Environment you are running in:");
expect(prompt).toContain("4. Working Directory: /repo");
expect(prompt).toContain(
"IMPORTANT: Always includes tool calls in your response until the task is completed.",
);
expect(prompt).toContain("# Workspace Configuration");
expect(prompt).not.toContain("You are Cline, an AI coding agent.");
});
it("keeps the harness for non-cline providers without cline metadata", () => {
const prompt = buildSubAgentSystemPrompt(
PROFILE_BODY,
makeConfig({ providerId: "openai" }),
);
expect(prompt.startsWith(PROFILE_BODY)).toBe(true);
expect(prompt).toContain("Environment you are running in:");
expect(prompt).toContain(
"IMPORTANT: Always includes tool calls in your response until the task is completed.",
);
expect(prompt).not.toContain("# Workspace Configuration");
});
});
describe("buildTeammateSystemPrompt", () => {
it("injects the role prompt as rules under the default persona for cline", () => {
const prompt = buildTeammateSystemPrompt(PROFILE_BODY, makeConfig());
expect(prompt).toContain("You are Cline, an AI coding agent.");
expect(prompt).toContain(`# Team Teammate Role\n${PROFILE_BODY}`);
});
it("returns the raw prompt for non-cline providers", () => {
const prompt = buildTeammateSystemPrompt(
PROFILE_BODY,
makeConfig({ providerId: "openai" }),
);
expect(prompt).toBe(PROFILE_BODY);
});
});
@@ -26,15 +26,13 @@ export function buildSubAgentSystemPrompt(
config: DelegatedAgentRuntimeConfig,
): string {
const trimmedPrompt = prompt.trim();
if (config.providerId.toLowerCase() !== "cline") {
return trimmedPrompt;
}
// The spawn prompt fills the persona slot; the provider-agnostic harness
// (env block, tool-call loop contract) is kept for every provider.
return buildClineSystemPrompt({
ide: config.clineIdeName || "Terminal",
workspaceRoot: config.cwd?.trim() || "/",
providerId: config.providerId,
overridePrompt: trimmedPrompt,
personaPrompt: trimmedPrompt,
metadata: config.workspaceMetadata,
platform: config.clinePlatform,
});
+4
View File
@@ -305,6 +305,7 @@ export {
type ConfiguredAgentConfig,
type ConfiguredAgentInput,
type ConfiguredAgentLoadResult,
type ConfiguredAgentPluginRef,
type ConfiguredAgentReadError,
type ConfiguredAgentToolConfig,
type ConfiguredAgentToolDescriptor,
@@ -441,12 +442,15 @@ export {
filterExtensionToolRegistrations,
GlobalSettingsSchema,
isAutoUpdateEnabledGlobally,
isPluginAlwaysEnabledGlobally,
isPluginDisabledGlobally,
isTelemetryOptedOutGlobally,
isToolDisabledGlobally,
readGlobalSettings,
resolveAlwaysEnabledPluginPaths,
resolveDisabledPluginPaths,
resolveDisabledToolNames,
setAlwaysEnabledPlugin,
setAutoUpdateEnabledGlobally,
setDisabledPlugin,
setDisabledTools,
@@ -2,8 +2,8 @@ import { EMPTY_CONTENT_TEXT } from "@cline/shared";
import { describe, expect, it } from "vitest";
import {
agentMessageToMessageWithMetadata,
messageToAgentMessages,
messagesToAgentMessages,
messageToAgentMessages,
} from "./agent-message-codec";
describe("agent message codec", () => {
@@ -5,7 +5,9 @@ import type { ITelemetryService } from "@cline/shared";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
GlobalSettingsSchema,
isPluginAlwaysEnabledGlobally,
readGlobalSettings,
setAlwaysEnabledPlugin,
setAutoUpdateEnabledGlobally,
setDisabledPlugin,
setDisabledTools,
@@ -152,6 +154,35 @@ describe("global-settings", () => {
}
});
it("tracks always-enabled plugins independently of disabled plugins", async () => {
const root = await mkdtemp(join(tmpdir(), "core-global-settings-"));
try {
const settingsPath = join(root, "global-settings.json");
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
setAlwaysEnabledPlugin("/plugins/example.js", true);
setDisabledPlugin("/plugins/other.js", true);
expect(isPluginAlwaysEnabledGlobally("/plugins/example.js")).toBe(true);
expect(isPluginAlwaysEnabledGlobally("/plugins/other.js")).toBe(false);
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
alwaysEnabledPlugins: ["/plugins/example.js"],
disabledPlugins: ["/plugins/other.js"],
telemetryOptOut: false,
});
setAlwaysEnabledPlugin("/plugins/example.js", false);
expect(readGlobalSettings()).toEqual({
autoUpdateEnabled: true,
disabledPlugins: ["/plugins/other.js"],
telemetryOptOut: false,
});
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("records telemetry opt-out once when the setting changes to true", async () => {
const root = await mkdtemp(join(tmpdir(), "core-global-settings-"));
try {
@@ -35,6 +35,7 @@ export const GlobalSettingsSchema = z
autoUpdateEnabled: z.boolean().default(true).catch(true),
disabledTools: GlobalSettingsStringListSchema.optional(),
disabledPlugins: GlobalSettingsStringListSchema.optional(),
alwaysEnabledPlugins: GlobalSettingsStringListSchema.optional(),
})
.strip()
.transform((settings) => {
@@ -43,6 +44,7 @@ export const GlobalSettingsSchema = z
autoUpdateEnabled: boolean;
disabledTools?: string[];
disabledPlugins?: string[];
alwaysEnabledPlugins?: string[];
} = {
autoUpdateEnabled: settings.autoUpdateEnabled,
telemetryOptOut: settings.telemetryOptOut,
@@ -53,6 +55,9 @@ export const GlobalSettingsSchema = z
if (settings.disabledPlugins?.length) {
normalized.disabledPlugins = settings.disabledPlugins;
}
if (settings.alwaysEnabledPlugins?.length) {
normalized.alwaysEnabledPlugins = settings.alwaysEnabledPlugins;
}
return normalized;
});
@@ -86,6 +91,9 @@ function freezeSettings(value: GlobalSettings): GlobalSettings {
if (value.disabledPlugins) {
Object.freeze(value.disabledPlugins);
}
if (value.alwaysEnabledPlugins) {
Object.freeze(value.alwaysEnabledPlugins);
}
return Object.freeze(value);
}
@@ -265,6 +273,47 @@ export function setDisabledPlugin(
writeGlobalSettings({ ...settings, disabledPlugins: [...disabled] });
}
export function resolveAlwaysEnabledPluginPaths(
alwaysEnabledPluginPaths?: ReadonlyArray<string>,
): Set<string> {
return new Set(
alwaysEnabledPluginPaths ?? readGlobalSettings().alwaysEnabledPlugins ?? [],
);
}
export function isPluginAlwaysEnabledGlobally(pluginPath: string): boolean {
return resolveAlwaysEnabledPluginPaths().has(pluginPath);
}
/**
* Marks a plugin as exempt from agent-profile plugin restrictions. Only
* consulted when a profile-driven restriction is active; a plugin the user
* disabled globally stays disabled regardless of this flag.
*/
export function setAlwaysEnabledPlugin(
pluginPath: string,
alwaysEnabledValue: boolean,
): void {
const path = pluginPath.trim();
if (!path) {
return;
}
const settings = readGlobalSettings();
const alwaysEnabled = resolveAlwaysEnabledPluginPaths(
settings.alwaysEnabledPlugins,
);
if (alwaysEnabledValue) {
alwaysEnabled.add(path);
} else {
alwaysEnabled.delete(path);
}
writeGlobalSettings({
...settings,
alwaysEnabledPlugins: [...alwaysEnabled],
});
}
export function filterDisabledPluginPaths(
pluginPaths: ReadonlyArray<string>,
disabledPluginPaths?: ReadonlyArray<string>,
@@ -370,6 +370,8 @@ export async function prepareLocalRuntimeBootstrap(
try {
loadedPlugins = await resolveAndLoadAgentPlugins({
pluginPaths: localConfig?.pluginPaths,
disabledPluginPaths:
localConfig?.disabledPluginPaths ?? input.config.disabledPluginPaths,
workspacePath,
cwd: input.config.cwd,
onEvent: onPluginEvent,
+6
View File
@@ -200,6 +200,12 @@ export interface CoreSessionConfig
extensionContext?: ExtensionContext;
extraTools?: AgentTool[];
pluginPaths?: string[];
/**
* Session-scoped plugin disable list (absolute entry paths), unioned with
* the globally disabled plugins. Used by agent profiles to restrict the
* plugin set for one session without touching persisted user settings.
*/
disabledPluginPaths?: string[];
extensions?: AgentConfig["extensions"];
execution?: AgentConfig["execution"];
compaction?: CoreCompactionConfig;
+16
View File
@@ -1,6 +1,22 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const rootDir = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: {
alias: [
{
find: /^@cline\/shared$/,
replacement: resolve(rootDir, "../shared/src/index.ts"),
},
{
find: /^@cline\/shared\/(.+)$/,
replacement: resolve(rootDir, "../shared/src/$1"),
},
],
},
test: {
environment: "node",
include: ["src/**/*.test.ts"],
@@ -0,0 +1,109 @@
import { describe, expect, it } from "vitest";
import { buildClineSystemPrompt } from "./cline";
import { DEFAULT_CLINE_PERSONA } from "./system";
const PERSONA = "You are Reviewer, a meticulous code review agent.";
describe("buildClineSystemPrompt", () => {
it("uses the default persona when no personaPrompt is provided", () => {
const prompt = buildClineSystemPrompt({
workspaceRoot: "/repo",
platform: "linux",
});
expect(prompt).toContain("You are Cline, an AI coding agent.");
expect(prompt).toContain("4. Working Directory: /repo");
});
it("applies personaPrompt while keeping the harness", () => {
const prompt = buildClineSystemPrompt({
workspaceRoot: "/repo",
platform: "linux",
ide: "Terminal",
personaPrompt: PERSONA,
});
expect(prompt.startsWith(PERSONA)).toBe(true);
expect(prompt).toContain("1. Platform: linux");
expect(prompt).toContain("4. Working Directory: /repo");
expect(prompt).toContain(
"IMPORTANT: Always includes tool calls in your response until the task is completed.",
);
expect(prompt).not.toContain(DEFAULT_CLINE_PERSONA);
});
it("appends workspace metadata for the cline provider with a persona", () => {
const prompt = buildClineSystemPrompt({
workspaceRoot: "/repo",
providerId: "cline",
personaPrompt: PERSONA,
metadata: '{"workspaces":{}}',
});
expect(prompt.startsWith(PERSONA)).toBe(true);
expect(prompt).toContain("# Workspace Configuration");
});
it("does not duplicate metadata when the persona already embeds it", () => {
const personaWithMetadata = `${PERSONA}\n\n# Workspace Configuration\n{"workspaces":{}}`;
const prompt = buildClineSystemPrompt({
workspaceRoot: "/repo",
providerId: "cline",
personaPrompt: personaWithMetadata,
metadata: '{"workspaces":{}}',
});
const markerCount = prompt.split("# Workspace Configuration").length - 1;
expect(markerCount).toBe(1);
});
it("omits workspace metadata for non-cline providers with a persona", () => {
const prompt = buildClineSystemPrompt({
workspaceRoot: "/repo",
providerId: "openai",
personaPrompt: PERSONA,
metadata: '{"workspaces":{}}',
});
expect(prompt.startsWith(PERSONA)).toBe(true);
expect(prompt).not.toContain("# Workspace Configuration");
});
it("lets overridePrompt win over personaPrompt", () => {
const prompt = buildClineSystemPrompt({
workspaceRoot: "/repo",
overridePrompt: "Full override.",
personaPrompt: PERSONA,
});
expect(prompt).toBe("Full override.");
});
it("ignores personaPrompt in yolo mode", () => {
const prompt = buildClineSystemPrompt({
workspaceRoot: "/repo",
mode: "yolo",
personaPrompt: PERSONA,
});
expect(prompt).toContain(
"You are Cline, a careful and helpful coding agent that works in the background.",
);
expect(prompt).not.toContain(PERSONA);
});
it("inserts rules containing replacement patterns literally", () => {
const prompt = buildClineSystemPrompt({
workspaceRoot: "/repo",
rules: "Use $& and $' carefully.",
});
expect(prompt).toContain("Use $& and $' carefully.");
});
it("keeps template-like tokens inside the persona literal", () => {
const persona =
"You report {{PLATFORM_NAME}} and honor {{CLINE_RULES}} verbatim.";
const prompt = buildClineSystemPrompt({
workspaceRoot: "/repo",
platform: "linux",
personaPrompt: persona,
rules: "Real rules here.",
});
expect(prompt.startsWith(persona)).toBe(true);
expect(prompt).toContain("1. Platform: linux");
expect(prompt).toContain("Real rules here.");
});
});
+34 -13
View File
@@ -1,7 +1,8 @@
import type { WorkspaceContext } from "../extensions/context";
import type { WorkspaceInfo } from "../session/workspace";
import {
DEFAULT_CLINE_SYSTEM_PROMPT,
AGENT_PERSONA_SLOT,
composeClineSystemPrompt,
YOLO_CLINE_SYSTEM_PROMPT,
} from "./system";
@@ -59,14 +60,20 @@ export interface ClineSystemPromptOptions
extends Omit<WorkspaceContext, "rootPath"> {
/**
* Workspace root path. Accepts either `rootPath` (from WorkspaceContext/WorkspaceInfo)
* or `workspaceRoot` (legacy alias) whichever is provided will be used.
* or `workspaceRoot` (legacy alias) - whichever is provided will be used.
*/
rootPath?: string;
/** Alias for rootPath kept for backwards compatibility with existing call sites */
/** Alias for rootPath - kept for backwards compatibility with existing call sites */
workspaceRoot?: string;
/** Per-request system prompt override */
overridePrompt?: string;
/** Provider ID — used to gate Cline-specific metadata injection */
/**
* Agent-profile persona: replaces the default Cline persona and its
* working guidelines while keeping the agent harness. Ignored when
* `overridePrompt` is set or in yolo mode.
*/
personaPrompt?: string;
/** Provider ID - used to gate Cline-specific metadata injection */
providerId?: string;
}
@@ -81,6 +88,7 @@ export function buildClineSystemPrompt(
metadata,
rules,
overridePrompt,
personaPrompt,
providerId,
} = options;
const workspaceRoot = options.workspaceRoot ?? options.rootPath ?? "";
@@ -98,20 +106,33 @@ export function buildClineSystemPrompt(
return trimmed;
}
const persona = mode === "yolo" ? undefined : personaPrompt?.trim();
// Keep the persona slot in place and fill it last, so `{{...}}` sequences
// inside a persona body stay literal.
const basePrompt =
mode === "yolo" ? YOLO_CLINE_SYSTEM_PROMPT : DEFAULT_CLINE_SYSTEM_PROMPT;
mode === "yolo"
? YOLO_CLINE_SYSTEM_PROMPT
: composeClineSystemPrompt(
persona ? { persona: AGENT_PERSONA_SLOT } : {},
);
// Skip metadata injection when the persona already embeds a workspace
// configuration block (e.g. spawn prompts composed by a parent agent).
const includeMetadata =
isCline && !persona?.includes(WORKSPACE_CONFIGURATION_MARKER);
// Replacer functions (not replacement strings) so values containing
// `$&`-style patterns are inserted literally.
return basePrompt
.replace("{{PLATFORM_NAME}}", platform)
.replace("{{CWD}}", workspaceRoot)
.replace("{{CURRENT_DATE}}", new Date().toLocaleDateString())
.replace("{{IDE_NAME}}", ide)
.replace(
"{{CLINE_METADATA}}",
isCline
.replace("{{PLATFORM_NAME}}", () => platform)
.replace("{{CWD}}", () => workspaceRoot)
.replace("{{CURRENT_DATE}}", () => new Date().toLocaleDateString())
.replace("{{IDE_NAME}}", () => ide)
.replace("{{CLINE_METADATA}}", () =>
includeMetadata
? buildWorkspaceMetadata(workspaceRoot, workspaceName, metadata)
: "",
)
.replace("{{CLINE_RULES}}", rules || "")
.replace("{{CLINE_RULES}}", () => rules || "")
.replace(AGENT_PERSONA_SLOT, () => persona ?? "")
.trim();
}
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
composeClineSystemPrompt,
DEFAULT_CLINE_PERSONA,
DEFAULT_CLINE_SYSTEM_PROMPT,
DEFAULT_CLINE_WORKING_GUIDELINES,
} from "./system";
// The exact default system prompt before the persona/harness split. The
// refactor must keep the default output byte-identical, including trailing
// whitespace, so this is pinned as a literal rather than recomposed.
const PRE_SPLIT_DEFAULT_CLINE_SYSTEM_PROMPT = `You are Cline, an AI coding agent. Your primary goal is to assist users with various coding tasks by leveraging your knowledge and the tools at your disposal. Given the user's prompt, you should use the tools available to you to answer user's question.
Always gather all the necessary context before starting to work on a task. For example, if you are generating a unit test or new code, make sure you understand the requirement, the naming conventions, frameworks and libraries used and aligned in the current codebase, and the environment and commands used to run and test the code etc. Always validate the new unit test at the end including running the code if possible for live feedback.
Review each question carefully and answer it with detailed, accurate information.
If you need more information, use one of the available tools or ask for clarification instead of making assumptions or lies.
Environment you are running in:
<env>
1. Platform: {{PLATFORM_NAME}}
2. Date: {{CURRENT_DATE}}
3. IDE: {{IDE_NAME}}
4. Working Directory: {{CWD}}
</env>
Remember:
- Always adhere to existing code conventions and patterns.
- Use only libraries and frameworks that are confirmed to be in use in the current codebase.
- Provide complete and functional code without omissions or placeholders.
- Be explicit about any assumptions or limitations in your solution.
- Always show your planning process before executing any task. This will help ensure that you have a clear understanding of the requirements and that your approach aligns with the user's needs.
- Always use absolute paths when referring to files.
- Always verify the files you have edited or created at the end of the task to ensure they are completed and working as expected.
Begin by analyzing the user's input and gathering any necessary additional context. Then, present your plan at the start of your response along with tool calls before proceeding with the task. It's OK for this section to be quite long.
REMEMBER, be helpful and proactive! Don't ask for permission to do something when you can do it! Do not indicates you will be using a tool unless you are actually going to use it.
IMPORTANT: Always includes tool calls in your response until the task is completed. Response without tool calls will considered as completed with final answer.
When you have completed the task, please provide a summary of what you did and any relevant information that the user should know. This will help ensure that the user understands the changes made and can easily follow up if they have any questions or need further assistance. Do not indicate that you will perform an action without actually doing it. Always provide the final result in your response. Always validate your answer with checking the code and running it if possible.${" "}
If user asked a simple question without any coding context, answer it directly without using any tools.
{{CLINE_RULES}}
{{CLINE_METADATA}}`;
describe("composeClineSystemPrompt", () => {
it("produces a byte-identical default prompt after the persona/harness split", () => {
expect(DEFAULT_CLINE_SYSTEM_PROMPT).toBe(
PRE_SPLIT_DEFAULT_CLINE_SYSTEM_PROMPT,
);
expect(composeClineSystemPrompt()).toBe(
PRE_SPLIT_DEFAULT_CLINE_SYSTEM_PROMPT,
);
expect(composeClineSystemPrompt({})).toBe(
PRE_SPLIT_DEFAULT_CLINE_SYSTEM_PROMPT,
);
});
it("treats a blank persona as the default", () => {
expect(composeClineSystemPrompt({ persona: " " })).toBe(
PRE_SPLIT_DEFAULT_CLINE_SYSTEM_PROMPT,
);
});
it("swaps the persona slot while keeping the harness", () => {
const persona =
"You are Reviewer, a meticulous code review agent. Focus on correctness.";
const prompt = composeClineSystemPrompt({ persona });
expect(prompt.startsWith(persona)).toBe(true);
expect(prompt).toContain("Environment you are running in:");
expect(prompt).toContain("4. Working Directory: {{CWD}}");
expect(prompt).toContain(
"IMPORTANT: Always includes tool calls in your response until the task is completed.",
);
expect(prompt).toContain("{{CLINE_RULES}}");
expect(prompt).toContain("{{CLINE_METADATA}}");
expect(prompt).not.toContain("You are Cline, an AI coding agent.");
expect(prompt).not.toContain(DEFAULT_CLINE_WORKING_GUIDELINES);
// The guidelines slot collapses cleanly: env block flows into the
// harness reminders with a single blank line.
expect(prompt).toContain("</env>\n\nREMEMBER, be helpful and proactive!");
});
it("inserts persona content literally, including replacement patterns", () => {
const persona = "Echo the captured group $& and $' verbatim.";
const prompt = composeClineSystemPrompt({ persona });
expect(prompt.startsWith(persona)).toBe(true);
});
it("keeps template-like tokens inside the persona literal", () => {
const persona = "Mention {{AGENT_GUIDELINES}} and {{AGENT_PERSONA}} as-is.";
const prompt = composeClineSystemPrompt({ persona });
expect(prompt.startsWith(persona)).toBe(true);
// The harness guidelines slot still collapsed cleanly.
expect(prompt).toContain("</env>\n\nREMEMBER, be helpful and proactive!");
});
it("keeps the exported default persona and guidelines in sync with the default prompt", () => {
expect(DEFAULT_CLINE_SYSTEM_PROMPT).toContain(DEFAULT_CLINE_PERSONA);
expect(DEFAULT_CLINE_SYSTEM_PROMPT).toContain(
DEFAULT_CLINE_WORKING_GUIDELINES,
);
});
});
+45 -14
View File
@@ -1,8 +1,25 @@
export const DEFAULT_CLINE_SYSTEM_PROMPT = `You are Cline, an AI coding agent. Your primary goal is to assist users with various coding tasks by leveraging your knowledge and the tools at your disposal. Given the user's prompt, you should use the tools available to you to answer user's question.
export const DEFAULT_CLINE_PERSONA = `You are Cline, an AI coding agent. Your primary goal is to assist users with various coding tasks by leveraging your knowledge and the tools at your disposal. Given the user's prompt, you should use the tools available to you to answer user's question.
Always gather all the necessary context before starting to work on a task. For example, if you are generating a unit test or new code, make sure you understand the requirement, the naming conventions, frameworks and libraries used and aligned in the current codebase, and the environment and commands used to run and test the code etc. Always validate the new unit test at the end including running the code if possible for live feedback.
Review each question carefully and answer it with detailed, accurate information.
If you need more information, use one of the available tools or ask for clarification instead of making assumptions or lies.
If you need more information, use one of the available tools or ask for clarification instead of making assumptions or lies.`;
export const DEFAULT_CLINE_WORKING_GUIDELINES = `Remember:
- Always adhere to existing code conventions and patterns.
- Use only libraries and frameworks that are confirmed to be in use in the current codebase.
- Provide complete and functional code without omissions or placeholders.
- Be explicit about any assumptions or limitations in your solution.
- Always show your planning process before executing any task. This will help ensure that you have a clear understanding of the requirements and that your approach aligns with the user's needs.
- Always use absolute paths when referring to files.
- Always verify the files you have edited or created at the end of the task to ensure they are completed and working as expected.
Begin by analyzing the user's input and gathering any necessary additional context. Then, present your plan at the start of your response along with tool calls before proceeding with the task. It's OK for this section to be quite long.`;
// The agent harness: env block, tool-call loop contract, completion
// instructions, and rules/metadata placeholders. {{AGENT_PERSONA}} and
// {{AGENT_GUIDELINES}} hold the coding-agent-specific prompting; an agent
// profile body replaces both while the harness is preserved.
const CLINE_SYSTEM_PROMPT_TEMPLATE = `{{AGENT_PERSONA}}
Environment you are running in:
<env>
@@ -12,18 +29,7 @@ Environment you are running in:
4. Working Directory: {{CWD}}
</env>
Remember:
- Always adhere to existing code conventions and patterns.
- Use only libraries and frameworks that are confirmed to be in use in the current codebase.
- Provide complete and functional code without omissions or placeholders.
- Be explicit about any assumptions or limitations in your solution.
- Always show your planning process before executing any task. This will help ensure that you have a clear understanding of the requirements and that your approach aligns with the user's needs.
- Always use absolute paths when referring to files.
- Always verify the files you have edited or created at the end of the task to ensure they are completed and working as expected.
Begin by analyzing the user's input and gathering any necessary additional context. Then, present your plan at the start of your response along with tool calls before proceeding with the task. It's OK for this section to be quite long.
REMEMBER, be helpful and proactive! Don't ask for permission to do something when you can do it! Do not indicates you will be using a tool unless you are actually going to use it.
{{AGENT_GUIDELINES}}REMEMBER, be helpful and proactive! Don't ask for permission to do something when you can do it! Do not indicates you will be using a tool unless you are actually going to use it.
IMPORTANT: Always includes tool calls in your response until the task is completed. Response without tool calls will considered as completed with final answer.
@@ -33,6 +39,31 @@ If user asked a simple question without any coding context, answer it directly w
{{CLINE_RULES}}
{{CLINE_METADATA}}`;
/** The persona placeholder of the harness template. */
export const AGENT_PERSONA_SLOT = "{{AGENT_PERSONA}}";
export interface ComposeClineSystemPromptInput {
/**
* Replaces the default Cline persona AND its working guidelines while
* keeping the agent harness. A custom persona (e.g. an agent profile
* body) is expected to bring its own working guidance.
*/
persona?: string;
}
export function composeClineSystemPrompt(
input: ComposeClineSystemPromptInput = {},
): string {
const persona = input.persona?.trim();
// The persona is inserted last via a replacer function so `{{...}}` and
// `$&`-style sequences inside it stay literal.
return CLINE_SYSTEM_PROMPT_TEMPLATE.replace("{{AGENT_GUIDELINES}}", () =>
persona ? "" : `${DEFAULT_CLINE_WORKING_GUIDELINES}\n\n`,
).replace(AGENT_PERSONA_SLOT, () => persona || DEFAULT_CLINE_PERSONA);
}
export const DEFAULT_CLINE_SYSTEM_PROMPT = composeClineSystemPrompt();
export const YOLO_CLINE_SYSTEM_PROMPT = `You are Cline, a careful and helpful coding agent that works in the background.
You are tasked to solve an issue reported by the user who you cannot communicate with directly.
Your goal is to utilize the tools at your disposal to investigate and answer the question according to user's instructions with the aim to verify that the issue is resolved.
+1
View File
@@ -8,6 +8,7 @@ export {
ensureFileExists,
ensureHookLogDir,
ensureParentDir,
getPluginDisplayName,
HOOKS_CONFIG_DIRECTORY_NAME,
isPluginModulePath,
type ResolveCronSpecsDirOptions,
@@ -1,8 +1,11 @@
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
AGENT_CONFIG_DIRECTORY_NAME,
CLINE_MCP_SETTINGS_FILE_NAME,
getPluginDisplayName,
HOOKS_CONFIG_DIRECTORY_NAME,
RULES_CONFIG_DIRECTORY_NAME,
resolveAgentsConfigDirPath,
@@ -178,3 +181,66 @@ describe("storage path resolution", () => {
]);
});
});
describe("getPluginDisplayName", () => {
it("uses the manifest in the entry's immediate parent directory", async () => {
const root = await mkdtemp(join(tmpdir(), "shared-plugin-name-"));
try {
const pluginDir = join(root, "my-plugin");
await mkdir(pluginDir, { recursive: true });
await writeFile(
join(pluginDir, "package.json"),
JSON.stringify({ name: "my-plugin" }),
"utf8",
);
const entryPath = join(pluginDir, "index.ts");
await writeFile(entryPath, "export default {}", "utf8");
expect(getPluginDisplayName(entryPath)).toBe("my-plugin");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("uses an ancestor manifest only when it declares the entry", async () => {
const root = await mkdtemp(join(tmpdir(), "shared-plugin-name-"));
try {
const installRoot = join(root, "wrapper");
const packageRoot = join(installRoot, "package");
await mkdir(packageRoot, { recursive: true });
await writeFile(
join(installRoot, "package.json"),
JSON.stringify({
name: "branch-protector",
cline: { plugins: [{ paths: ["./package/index.ts"] }] },
}),
"utf8",
);
const entryPath = join(packageRoot, "index.ts");
await writeFile(entryPath, "export default {}", "utf8");
expect(getPluginDisplayName(entryPath)).toBe("branch-protector");
} finally {
await rm(root, { recursive: true, force: true });
}
});
it("ignores unrelated ancestor manifests and falls back to the filename", async () => {
const root = await mkdtemp(join(tmpdir(), "shared-plugin-name-"));
try {
await writeFile(
join(root, "package.json"),
JSON.stringify({ name: "unrelated-repo" }),
"utf8",
);
const pluginsDir = join(root, ".cline", "plugins");
await mkdir(pluginsDir, { recursive: true });
const entryPath = join(pluginsDir, "my-tool.ts");
await writeFile(entryPath, "export default {}", "utf8");
expect(getPluginDisplayName(entryPath)).toBe("my-tool");
} finally {
await rm(root, { recursive: true, force: true });
}
});
});
+56 -1
View File
@@ -8,7 +8,7 @@ import {
statSync,
} from "node:fs";
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { basename, dirname, extname, join, resolve } from "node:path";
import type { PluginManifest } from "..";
const DEPRECATED_CONFIG_DIR = ".clinerules";
@@ -529,6 +529,61 @@ export function discoverPluginModulePaths(directoryPath: string): string[] {
return discovered.sort((a, b) => a.localeCompare(b));
}
function packageManifestDeclaresEntry(
packageRoot: string,
entryPath: string,
): boolean {
const manifest = readPluginPackageManifest(
join(packageRoot, PLUGIN_PACKAGE_JSON_FILE_NAME),
);
const normalizedEntryPath = resolve(entryPath);
return getManifestPluginEntries(manifest).some(
(declared) => resolve(packageRoot, declared) === normalizedEntryPath,
);
}
/**
* Human-facing plugin name for an entry file path: the name from the
* package.json that owns the entry (its immediate parent directory, or an
* ancestor manifest that declares the entry in cline.plugins, covering
* install wrappers), else the filename without extension. A manifest that
* does not own the entry stops the walk so names never come from unrelated
* ancestor packages. This is the name shown in /settings and the name agent
* profiles reference in their plugins list.
*/
export function getPluginDisplayName(filePath: string): string {
const entryDir = dirname(filePath);
let current = entryDir;
for (let depth = 0; depth < 4; depth++) {
const packageJsonPath = join(current, PLUGIN_PACKAGE_JSON_FILE_NAME);
if (existsSync(packageJsonPath)) {
if (
current !== entryDir &&
!packageManifestDeclaresEntry(current, filePath)
) {
break;
}
try {
const packageJson = JSON.parse(
readFileSync(packageJsonPath, "utf8"),
) as { name?: unknown };
if (typeof packageJson.name === "string" && packageJson.name.trim()) {
return packageJson.name.trim();
}
} catch {
// Unreadable manifest: fall back to the filename below.
}
break;
}
const parent = resolve(current, "..");
if (parent === current) {
break;
}
current = parent;
}
return basename(filePath, extname(filePath));
}
export function resolveConfiguredPluginModulePaths(
pluginPaths: ReadonlyArray<string>,
cwd: string,