mirror of
https://github.com/cline/cline.git
synced 2026-09-12 17:19:32 +08:00
Compare commits
31
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8f465e316 | ||
|
|
b94d3c2751 | ||
|
|
eb401bbb44 | ||
|
|
cb87c76d18 | ||
|
|
abfe3f2330 | ||
|
|
9333463a41 | ||
|
|
a8105d54c4 | ||
|
|
4d23e414bc | ||
|
|
54537e217b | ||
|
|
83595bc06a | ||
|
|
88ff2d8000 | ||
|
|
7859f6ca1c | ||
|
|
caa32e3a30 | ||
|
|
8acf15f406 | ||
|
|
5f707a637a | ||
|
|
ae40cf1926 | ||
|
|
85d009a4ab | ||
|
|
ccfc3b9c5d | ||
|
|
d5d989111f | ||
|
|
acf7fde625 | ||
|
|
c725f3da42 | ||
|
|
874b495abf | ||
|
|
ccc8e5759d | ||
|
|
8b9590e1ea | ||
|
|
9b4aa6307b | ||
|
|
adb7f014cf | ||
|
|
ebefaa68c4 | ||
|
|
0e240ed329 | ||
|
|
2a4e33105c | ||
|
|
7e8a0df023 | ||
|
|
df7124d561 |
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user