mirror of
https://github.com/cline/cline.git
synced 2026-09-04 11:44:01 +08:00
Compare commits
16 Commits
cli-v3.0.61
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 952df213ee | |||
| d7cb79aea5 | |||
| 5de79a75d0 | |||
| ab3acd6a8a | |||
| c322ab2bbc | |||
| 72b714b6be | |||
| f0de3a2c20 | |||
| 4ae53292d3 | |||
| 3d0531238e | |||
| b318c84f52 | |||
| 9e0af5c010 | |||
| 97b0700151 | |||
| b46cf77ed1 | |||
| c85384431d | |||
| be59305d7a | |||
| 833be95cfb |
@@ -1,5 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## [4.1.17]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
### Added
|
||||
|
||||
- ClinePass is now surfaced across the app: a card on the account page describing what the plan covers, a hint in provider settings, and a banner on the home screen. Dismissed banners stay dismissed.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed the background Hub process ballooning in memory during long sessions. Session status updates broadcast a full copy of the conversation transcript to every connected client, so on a large task each status change shipped megabytes and could grow the process to tens of gigabytes. Snapshots now carry state only.
|
||||
- Hook scripts that fail to spawn no longer crash the extension's core process and take the running task down with them.
|
||||
- Fixed a chat render crash on malformed `api_req` payloads.
|
||||
- Cost estimates no longer appear in task history for subscription-billed tasks (ClinePass, ChatGPT via Codex, and Claude Code), matching the task header.
|
||||
- Pasted provider API keys are now stripped of the invisible characters clipboards smuggle in (newlines, zero-width spaces, BOM). A key corrupted that way was hidden by the masked field and rejected by the provider with a 401 indistinguishable from a genuinely wrong key. Credential rejections now say that the API key is the problem and point at its configuration, keeping the provider's raw response as a diagnostic tail.
|
||||
- Signing in to OpenAI Codex (ChatGPT subscription) now fails with a clear "port in use" error when callback port 1455 is occupied. Previously the button opened a browser to a flow whose callback could never arrive, and nothing else happened. OAuth redirect errors such as `access_denied` are surfaced instead of being reported as a missing authorization code.
|
||||
- A transient network failure while refreshing OpenAI Codex or OpenAI-compatible-account tokens no longer signs you out. Only a genuinely rejected refresh token now requires re-authentication.
|
||||
- Fixed tool calling being silently disabled for Dify, SAP AI Core, opencode, and Codex CLI models. Their catalog entries declare no capabilities, and the empty list was read as an authoritative denial that stripped every tool from the request.
|
||||
- Fixed images being dropped from file reads on models whose capability list is empty.
|
||||
- Restoring a checkpoint now refuses to run when commits were made after it, instead of silently knocking them off the branch where only the reflog could recover them. Chat-only restore is unaffected.
|
||||
- `apply_patch` now preserves a file's existing CRLF line endings.
|
||||
- Global rules are now also read from `~/Cline/Rules`, which is where the Rules tab writes them on WSL and headless installs whose Documents folder resolves to the home directory.
|
||||
- An enabled but unreachable remote (SSE or streamable HTTP) MCP server no longer stalls session startup; remote connects now have a 10 second budget.
|
||||
- Aborting a task now also cancels the delegated subagents and teammates it spawned, instead of leaving their work running.
|
||||
- Langfuse tracing now works in released builds. Detection identified the OpenTelemetry provider by class name, which minification renames, so tracing silently initialized as not ready in every published build while working in development.
|
||||
- Cline provider models are now read from the live catalog, so newly published models appear without an extension update.
|
||||
- Hook execution telemetry now fires; the task id was not threaded into hook runner creation, so those events were dropped.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refreshed the built-in model catalog. Adds ten providers (Bothub, OpenReason, SenseNova (China), TokenGo, TokenRouter, Vancine, Volcengine Ark, Volcengine Ark Coding Plan, above.dev, and klokintegration.se) and updates model lists and pricing throughout. This is an unusually wide refresh: the resolved default model changes for 57 providers, most consequentially Anthropic, which now resolves to Claude Fable 5.1 instead of Claude Opus 5, with Amazon Bedrock, Vertex, OpenRouter, Vercel AI Gateway, Kilo Gateway, LLM Gateway, DevPass, DigitalOcean, CrossModel, Eden AI, and NanoGPT following. If you use a provider without pinning a model, expect a different default.
|
||||
- The message the model receives when you reject a tool call now names the rejected tool and reads as your decision rather than an error.
|
||||
|
||||
## [4.1.16]
|
||||
|
||||
Everything here lands through the SDK bundle, so it applies to windows running that bundle.
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
## 3.0.60
|
||||
|
||||
- The config screen now separates Cline Plugins from Agent Plugins discovered by the Hub. Agent Plugins can be enabled or disabled with Space; the Hub persists the state and their skills and MCP servers follow it when the interactive runtime is rebuilt
|
||||
- Fixed the background hub process ballooning in memory during long sessions — session status updates were broadcasting a full copy of the conversation transcript to every connected client, which on a large task could grow the process to tens of gigabytes. Upgrading retires the running hub so the fix takes effect on the next command
|
||||
- New files are now created with your platform's native line endings
|
||||
- Fixed the codebase search tool crashing on files that contain a single enormous line
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
import { readFileSyncStrippingUtf8Bom } from "@cline/shared/node";
|
||||
import { Command } from "commander";
|
||||
import { getToolCatalog } from "../runtime/tools";
|
||||
import { createCliCore } from "../session/session";
|
||||
import { loadInteractiveConfigData } from "../tui/interactive-config";
|
||||
import type { CliOutputMode } from "../utils/types";
|
||||
|
||||
@@ -423,8 +424,20 @@ async function loadInteractiveConfigDataForCommand(
|
||||
cwd: string,
|
||||
): Promise<Awaited<ReturnType<typeof loadInteractiveConfigData>>> {
|
||||
const userInstructionService = createConfigUserInstructionService(cwd);
|
||||
const core = await createCliCore({
|
||||
backendMode: "auto",
|
||||
cwd,
|
||||
workspaceRoot: cwd,
|
||||
});
|
||||
try {
|
||||
await userInstructionService.start();
|
||||
const [, agentPluginSettings] = await Promise.all([
|
||||
userInstructionService.start(),
|
||||
core.settings.list({
|
||||
cwd,
|
||||
workspaceRoot: cwd,
|
||||
includePluginTools: true,
|
||||
}),
|
||||
]);
|
||||
return await loadInteractiveConfigData({
|
||||
userInstructionService,
|
||||
cwd,
|
||||
@@ -432,9 +445,11 @@ async function loadInteractiveConfigDataForCommand(
|
||||
availabilityContext: {
|
||||
mode: "act",
|
||||
},
|
||||
agentPluginSettings,
|
||||
});
|
||||
} finally {
|
||||
userInstructionService.stop();
|
||||
await core.dispose("cli_config_command_complete");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import {
|
||||
applyPluginFailures,
|
||||
type InteractiveConfigItem,
|
||||
isToggleableInteractiveConfigItem,
|
||||
} from "../../tui/interactive-config";
|
||||
import type { Config } from "../../utils/types";
|
||||
import { createInteractiveConfigDataLoader } from "./config-data";
|
||||
@@ -113,6 +114,172 @@ describe("interactive config data loader", () => {
|
||||
return pluginPath;
|
||||
}
|
||||
|
||||
it("merges the hub-owned Agent Plugin inventory into the config view", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-agent-plugin-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const pluginRoot = "/remote/home/.agents/plugins/portable-review";
|
||||
const calls: unknown[] = [];
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: createConfig(tempRoot),
|
||||
loadCoreSettings: async (input) => {
|
||||
calls.push(input);
|
||||
return {
|
||||
workflows: [],
|
||||
rules: [],
|
||||
tools: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "agent-plugin:portable-review",
|
||||
name: "portable-review",
|
||||
path: pluginRoot,
|
||||
kind: "plugin",
|
||||
source: "global-plugin",
|
||||
enabled: true,
|
||||
toggleable: true,
|
||||
agentPlugin: true,
|
||||
},
|
||||
],
|
||||
skills: [
|
||||
{
|
||||
id: "portable-review:review",
|
||||
name: "review",
|
||||
path: `${pluginRoot}/skills/review/SKILL.md`,
|
||||
kind: "skill",
|
||||
source: "global-plugin",
|
||||
enabled: true,
|
||||
toggleable: false,
|
||||
agentPlugin: true,
|
||||
pluginName: "portable-review",
|
||||
pluginPath: pluginRoot,
|
||||
},
|
||||
],
|
||||
mcp: [
|
||||
{
|
||||
id: "portable-review.docs",
|
||||
name: "portable-review.docs",
|
||||
path: `${pluginRoot}/mcp.json`,
|
||||
kind: "mcp",
|
||||
source: "global-plugin",
|
||||
enabled: true,
|
||||
toggleable: false,
|
||||
agentPlugin: true,
|
||||
pluginName: "portable-review",
|
||||
pluginPath: pluginRoot,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const data = await loader.loadConfigData({ includePluginTools: false });
|
||||
|
||||
expect(calls).toEqual([
|
||||
expect.objectContaining({
|
||||
includePluginTools: false,
|
||||
}),
|
||||
]);
|
||||
expect(data.plugins).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "portable-review",
|
||||
agentPlugin: true,
|
||||
toggleable: true,
|
||||
deletable: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const skill = data.skills.find(
|
||||
(item) => item.id === "portable-review:review",
|
||||
);
|
||||
expect(skill).toMatchObject({
|
||||
pluginName: "portable-review",
|
||||
agentPlugin: true,
|
||||
});
|
||||
expect(skill && isToggleableInteractiveConfigItem(skill)).toBe(false);
|
||||
expect(data.mcp).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "portable-review.docs" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("toggles Agent Plugins through the hub without mutating client settings", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-agent-toggle-"));
|
||||
tempRoots.push(tempRoot);
|
||||
const globalSettingsPath = join(tempRoot, "global-settings.json");
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = globalSettingsPath;
|
||||
const pluginRoot = "/hub/home/.agents/plugins/portable-review";
|
||||
const toggleCalls: unknown[] = [];
|
||||
const loader = createInteractiveConfigDataLoader({
|
||||
config: {
|
||||
...createConfig(tempRoot),
|
||||
agentPluginPaths: ["./portable-review"],
|
||||
},
|
||||
toggleCoreSettings: async (input) => {
|
||||
toggleCalls.push(input);
|
||||
return {
|
||||
changedTypes: ["plugins", "skills", "mcp"],
|
||||
snapshot: {
|
||||
workflows: [],
|
||||
rules: [],
|
||||
tools: [],
|
||||
skills: [],
|
||||
mcp: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "agent-plugin:portable-review",
|
||||
name: "portable-review",
|
||||
path: pluginRoot,
|
||||
kind: "plugin",
|
||||
source: "global-plugin",
|
||||
enabled: false,
|
||||
toggleable: true,
|
||||
agentPlugin: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const data = await loader.onToggleConfigItem(
|
||||
{
|
||||
id: "agent-plugin:portable-review",
|
||||
name: "portable-review",
|
||||
path: pluginRoot,
|
||||
kind: "plugin",
|
||||
source: "global-plugin",
|
||||
enabled: true,
|
||||
toggleable: true,
|
||||
deletable: false,
|
||||
agentPlugin: true,
|
||||
},
|
||||
{ includePluginTools: false },
|
||||
);
|
||||
|
||||
expect(toggleCalls).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "plugins",
|
||||
id: "agent-plugin:portable-review",
|
||||
path: pluginRoot,
|
||||
name: "portable-review",
|
||||
enabled: false,
|
||||
agentPluginPaths: ["./portable-review"],
|
||||
includePluginTools: false,
|
||||
}),
|
||||
]);
|
||||
expect(data?.plugins).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "portable-review",
|
||||
enabled: false,
|
||||
agentPlugin: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
await expect(readFile(globalSettingsPath, "utf8")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("toggles a skill item to the opposite enabled state and refreshes before reload", async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
|
||||
tempRoots.push(tempRoot);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import {
|
||||
type CoreSettingsListInput,
|
||||
type CoreSettingsMutationResult,
|
||||
type CoreSettingsSnapshot,
|
||||
type CoreSettingsToggleInput,
|
||||
createCoreSettingsService,
|
||||
disablePluginMcpServersInSettings,
|
||||
setDisabledPlugin,
|
||||
@@ -10,6 +14,7 @@ import {
|
||||
import {
|
||||
type InteractiveConfigData,
|
||||
type InteractiveConfigItem,
|
||||
isToggleableInteractiveConfigItem,
|
||||
type LoadInteractiveConfigDataOptions,
|
||||
loadInteractiveConfigData,
|
||||
} from "../../tui/interactive-config";
|
||||
@@ -18,6 +23,12 @@ import type { Config } from "../../utils/types";
|
||||
export function createInteractiveConfigDataLoader(input: {
|
||||
config: Config;
|
||||
userInstructionService?: UserInstructionConfigService;
|
||||
loadCoreSettings?: (
|
||||
input: CoreSettingsListInput,
|
||||
) => Promise<CoreSettingsSnapshot>;
|
||||
toggleCoreSettings?: (
|
||||
input: CoreSettingsToggleInput,
|
||||
) => Promise<CoreSettingsMutationResult>;
|
||||
}) {
|
||||
const workspaceRoot = () =>
|
||||
input.config.workspaceRoot?.trim() || input.config.cwd;
|
||||
@@ -28,16 +39,36 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
enableSpawnAgent: input.config.enableSpawnAgent,
|
||||
enableAgentTeams: input.config.enableAgentTeams,
|
||||
});
|
||||
const loadConfigData = async (
|
||||
const buildSettingsInput = (
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
): Promise<InteractiveConfigData> =>
|
||||
await loadInteractiveConfigData({
|
||||
): CoreSettingsListInput => ({
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: workspaceRoot(),
|
||||
availabilityContext: availabilityContext(),
|
||||
agentPluginPaths: input.config.agentPluginPaths,
|
||||
includePluginTools: options.includePluginTools,
|
||||
});
|
||||
const buildConfigData = async (
|
||||
options: LoadInteractiveConfigDataOptions,
|
||||
agentPluginSettings: CoreSettingsSnapshot | undefined,
|
||||
): Promise<InteractiveConfigData> => {
|
||||
return await loadInteractiveConfigData({
|
||||
userInstructionService: input.userInstructionService,
|
||||
cwd: input.config.cwd,
|
||||
workspaceRoot: workspaceRoot(),
|
||||
availabilityContext: availabilityContext(),
|
||||
includePluginTools: options.includePluginTools,
|
||||
agentPluginSettings,
|
||||
});
|
||||
};
|
||||
const loadConfigData = async (
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
): Promise<InteractiveConfigData> => {
|
||||
const agentPluginSettings = await input
|
||||
.loadCoreSettings?.(buildSettingsInput(options))
|
||||
.catch(() => undefined);
|
||||
return await buildConfigData(options, agentPluginSettings);
|
||||
};
|
||||
|
||||
const refreshUserInstructionConfigs = async (): Promise<void> => {
|
||||
const service = input.userInstructionService;
|
||||
@@ -55,6 +86,9 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
item: InteractiveConfigItem,
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
): Promise<InteractiveConfigData | undefined> => {
|
||||
if (!isToggleableInteractiveConfigItem(item)) {
|
||||
return undefined;
|
||||
}
|
||||
const settings = createCoreSettingsService();
|
||||
if (item.kind === "skill" && typeof item.enabled === "boolean") {
|
||||
await settings.toggle({
|
||||
@@ -72,6 +106,22 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
}
|
||||
|
||||
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
|
||||
if (item.agentPlugin === true) {
|
||||
if (!input.toggleCoreSettings) {
|
||||
throw new Error(
|
||||
"Agent Plugin settings require a connected Cline Hub.",
|
||||
);
|
||||
}
|
||||
const result = await input.toggleCoreSettings({
|
||||
...buildSettingsInput(options),
|
||||
type: "plugins",
|
||||
id: item.id,
|
||||
path: item.path,
|
||||
name: item.name,
|
||||
enabled: !item.enabled,
|
||||
});
|
||||
return await buildConfigData(options, result.snapshot);
|
||||
}
|
||||
if (item.enabled) {
|
||||
disablePluginMcpServersInSettings({ pluginPaths: [item.path] });
|
||||
setDisabledPlugin(item.path, true);
|
||||
@@ -150,7 +200,11 @@ export function createInteractiveConfigDataLoader(input: {
|
||||
item: InteractiveConfigItem,
|
||||
options: LoadInteractiveConfigDataOptions = {},
|
||||
): Promise<InteractiveConfigData | undefined> => {
|
||||
if (item.kind !== "plugin") {
|
||||
if (
|
||||
item.kind !== "plugin" ||
|
||||
item.agentPlugin === true ||
|
||||
item.deletable === false
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
await uninstallPlugin({
|
||||
|
||||
@@ -2,6 +2,10 @@ import {
|
||||
type AgentEvent,
|
||||
type AgentHooks,
|
||||
type CheckpointEntry,
|
||||
type CoreSettingsListInput,
|
||||
type CoreSettingsMutationResult,
|
||||
type CoreSettingsSnapshot,
|
||||
type CoreSettingsToggleInput,
|
||||
createSessionCompactionState,
|
||||
isSessionNotFoundError,
|
||||
type PendingPromptMutationResult,
|
||||
@@ -299,6 +303,19 @@ export function createInteractiveSessionRuntime(input: {
|
||||
return await startupPromise;
|
||||
};
|
||||
|
||||
const listCoreSettings = async (
|
||||
settingsInput: CoreSettingsListInput,
|
||||
): Promise<CoreSettingsSnapshot> => {
|
||||
const manager = await ensureSessionManager();
|
||||
return await manager.settings.list(settingsInput);
|
||||
};
|
||||
const toggleCoreSettings = async (
|
||||
settingsInput: CoreSettingsToggleInput,
|
||||
): Promise<CoreSettingsMutationResult> => {
|
||||
const manager = await ensureSessionManager();
|
||||
return await manager.settings.toggle(settingsInput);
|
||||
};
|
||||
|
||||
const readCurrentMessages = async (): Promise<CurrentMessagesRead> => {
|
||||
const manager = sessionManager;
|
||||
const sessionId = activeSessionId;
|
||||
@@ -883,6 +900,8 @@ export function createInteractiveSessionRuntime(input: {
|
||||
|
||||
return {
|
||||
ensureReady,
|
||||
listCoreSettings,
|
||||
toggleCoreSettings,
|
||||
sendCurrentTurn,
|
||||
updatePendingPrompt,
|
||||
getAccumulatedUsage,
|
||||
|
||||
@@ -200,10 +200,6 @@ export async function runInteractive(
|
||||
let pluginChatCommandHostPromise:
|
||||
| Promise<InteractiveSlashCommand[]>
|
||||
| undefined;
|
||||
const configDataLoader = createInteractiveConfigDataLoader({
|
||||
config,
|
||||
userInstructionService,
|
||||
});
|
||||
const ensurePluginChatCommandHost = async (): Promise<
|
||||
InteractiveSlashCommand[]
|
||||
> => {
|
||||
@@ -302,6 +298,12 @@ export async function runInteractive(
|
||||
uiEvents.emit("pending-prompt-submitted", event);
|
||||
},
|
||||
});
|
||||
const configDataLoader = createInteractiveConfigDataLoader({
|
||||
config,
|
||||
userInstructionService,
|
||||
loadCoreSettings: sessionRuntime.listCoreSettings,
|
||||
toggleCoreSettings: sessionRuntime.toggleCoreSettings,
|
||||
});
|
||||
let modeChangePromise: Promise<void> | undefined;
|
||||
let modeChangeTarget: "plan" | "act" | undefined;
|
||||
const modeSwitchNotice = createModeSwitchNoticeTracker();
|
||||
|
||||
@@ -47,7 +47,10 @@ export function shouldCloseExtDetailForKey(keyName: string): boolean {
|
||||
|
||||
export function shouldToggleExtDetailForKey(
|
||||
keyName: string,
|
||||
item: Pick<InteractiveConfigItem, "kind" | "source" | "enabled">,
|
||||
item: Pick<
|
||||
InteractiveConfigItem,
|
||||
"kind" | "source" | "enabled" | "pluginName" | "toggleable"
|
||||
>,
|
||||
): boolean {
|
||||
return (
|
||||
keyName === "space" &&
|
||||
@@ -57,7 +60,10 @@ export function shouldToggleExtDetailForKey(
|
||||
}
|
||||
|
||||
export function getExtDetailFooterText(
|
||||
item: Pick<InteractiveConfigItem, "kind" | "source" | "enabled">,
|
||||
item: Pick<
|
||||
InteractiveConfigItem,
|
||||
"kind" | "source" | "enabled" | "pluginName" | "toggleable"
|
||||
>,
|
||||
): string {
|
||||
return typeof item.enabled === "boolean" &&
|
||||
isToggleableInteractiveConfigItem(item)
|
||||
|
||||
@@ -23,6 +23,9 @@ export function Toast(props: { toast: ToastState | null }) {
|
||||
};
|
||||
const availableWidth = Math.max(1, width - 4);
|
||||
const maxWidth = Math.min(44, availableWidth);
|
||||
// Border and horizontal padding take four columns. An explicit width (not
|
||||
// maxWidth) is what makes the text wrap instead of clipping at the edge.
|
||||
const boxWidth = Math.min(maxWidth, props.toast.message.length + 4);
|
||||
const right = width < 32 ? 0 : 2;
|
||||
const color = variantColor[props.toast.variant];
|
||||
|
||||
@@ -32,7 +35,7 @@ export function Toast(props: { toast: ToastState | null }) {
|
||||
zIndex={100}
|
||||
top={1}
|
||||
right={right}
|
||||
maxWidth={maxWidth}
|
||||
width={boxWidth}
|
||||
border
|
||||
borderStyle="rounded"
|
||||
borderColor={color}
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from "node:path";
|
||||
import {
|
||||
type BuiltinToolAvailabilityContext,
|
||||
type CoreSettingsItem,
|
||||
type CoreSettingsSnapshot,
|
||||
DEFAULT_MCP_CONNECT_TIMEOUT_MS,
|
||||
discoverPluginModulePaths,
|
||||
getPluginDisplayName,
|
||||
@@ -80,6 +82,12 @@ export interface InteractiveConfigItem {
|
||||
| "global-plugin"
|
||||
| "workspace-plugin";
|
||||
description?: string;
|
||||
/** True when the hub discovered this through agent-plugins.org. */
|
||||
agentPlugin?: boolean;
|
||||
/** Explicitly overrides the default toggle policy for this item. */
|
||||
toggleable?: boolean;
|
||||
/** Explicitly overrides the default delete policy for this item. */
|
||||
deletable?: boolean;
|
||||
}
|
||||
|
||||
export interface InteractiveConfigData {
|
||||
@@ -100,8 +108,14 @@ export interface LoadInteractiveConfigDataOptions {
|
||||
}
|
||||
|
||||
export function isToggleableInteractiveConfigItem(
|
||||
item: Pick<InteractiveConfigItem, "kind" | "source" | "pluginName">,
|
||||
item: Pick<
|
||||
InteractiveConfigItem,
|
||||
"kind" | "source" | "pluginName" | "toggleable"
|
||||
>,
|
||||
): boolean {
|
||||
if (item.toggleable !== undefined) {
|
||||
return item.toggleable;
|
||||
}
|
||||
if (item.kind === "mcp") {
|
||||
return !item.pluginName;
|
||||
}
|
||||
@@ -324,12 +338,53 @@ export function applyPluginFailures(
|
||||
}
|
||||
}
|
||||
|
||||
function toAgentPluginInteractiveItem(
|
||||
item: CoreSettingsItem,
|
||||
): InteractiveConfigItem {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
enabled: item.enabled,
|
||||
kind: item.kind,
|
||||
source: item.source,
|
||||
description: item.description,
|
||||
pluginName: item.pluginName,
|
||||
pluginPath: item.pluginPath,
|
||||
loadError: item.loadError,
|
||||
agentPlugin: true,
|
||||
toggleable: item.toggleable ?? false,
|
||||
deletable: false,
|
||||
...(item.kind === "plugin" ? { configKind: "plugin" as const } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function appendAgentPluginSnapshotItems(
|
||||
target: InteractiveConfigItem[],
|
||||
items: readonly CoreSettingsItem[],
|
||||
): void {
|
||||
const existing = new Set(
|
||||
target.map((item) => `${item.kind}\0${item.id}\0${item.path}`),
|
||||
);
|
||||
for (const item of items) {
|
||||
if (item.agentPlugin !== true) {
|
||||
continue;
|
||||
}
|
||||
const key = `${item.kind}\0${item.id}\0${item.path}`;
|
||||
if (!existing.has(key)) {
|
||||
target.push(toAgentPluginInteractiveItem(item));
|
||||
existing.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadInteractiveConfigData(input: {
|
||||
userInstructionService?: UserInstructionConfigService;
|
||||
cwd: string;
|
||||
workspaceRoot: string;
|
||||
availabilityContext?: BuiltinToolAvailabilityContext;
|
||||
includePluginTools?: boolean;
|
||||
agentPluginSettings?: CoreSettingsSnapshot;
|
||||
}): Promise<InteractiveConfigData> {
|
||||
const workflows: InteractiveConfigItem[] = [];
|
||||
const rules: InteractiveConfigItem[] = [];
|
||||
@@ -518,14 +573,23 @@ export async function loadInteractiveConfigData(input: {
|
||||
}
|
||||
}
|
||||
|
||||
if (input.agentPluginSettings) {
|
||||
appendAgentPluginSnapshotItems(plugins, input.agentPluginSettings.plugins);
|
||||
appendAgentPluginSnapshotItems(skills, input.agentPluginSettings.skills);
|
||||
appendAgentPluginSnapshotItems(mcp, input.agentPluginSettings.mcp);
|
||||
}
|
||||
|
||||
const existsLocallyOrComesFromHub = (item: InteractiveConfigItem) =>
|
||||
item.agentPlugin === true || existsSync(item.path);
|
||||
|
||||
return {
|
||||
workflows: toSorted(workflows.filter((item) => existsSync(item.path))),
|
||||
rules: toSorted(rules.filter((item) => existsSync(item.path))),
|
||||
skills: toSorted(skills.filter((item) => existsSync(item.path))),
|
||||
hooks: toSorted(hooks.filter((item) => existsSync(item.path))),
|
||||
agents: toSorted(agents.filter((item) => existsSync(item.path))),
|
||||
plugins: toSorted(plugins.filter((item) => existsSync(item.path))),
|
||||
mcp: toSorted(mcp.filter((item) => existsSync(item.path))),
|
||||
workflows: toSorted(workflows.filter(existsLocallyOrComesFromHub)),
|
||||
rules: toSorted(rules.filter(existsLocallyOrComesFromHub)),
|
||||
skills: toSorted(skills.filter(existsLocallyOrComesFromHub)),
|
||||
hooks: toSorted(hooks.filter(existsLocallyOrComesFromHub)),
|
||||
agents: toSorted(agents.filter(existsLocallyOrComesFromHub)),
|
||||
plugins: toSorted(plugins.filter(existsLocallyOrComesFromHub)),
|
||||
mcp: toSorted(mcp.filter(existsLocallyOrComesFromHub)),
|
||||
tools: toSorted(tools),
|
||||
workflowSlashCommands,
|
||||
pluginDiagnosticsLoaded: input.includePluginTools !== false,
|
||||
|
||||
@@ -128,12 +128,61 @@ export function resolveActiveConfigItems(
|
||||
}
|
||||
}
|
||||
|
||||
export interface ConfigPluginSection {
|
||||
label: string;
|
||||
items: InteractiveConfigItem[];
|
||||
}
|
||||
|
||||
export function getConfigPluginSections(
|
||||
items: readonly InteractiveConfigItem[],
|
||||
): ConfigPluginSection[] {
|
||||
const clinePlugins = items.filter((item) => item.agentPlugin !== true);
|
||||
const agentPlugins = items.filter((item) => item.agentPlugin === true);
|
||||
return [
|
||||
...(clinePlugins.length > 0
|
||||
? [
|
||||
{
|
||||
label: `Cline Plugins (${clinePlugins.length})`,
|
||||
items: clinePlugins,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
...(agentPlugins.length > 0
|
||||
? [
|
||||
{
|
||||
label: `Agent Plugins (${agentPlugins.length})`,
|
||||
items: agentPlugins,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
}
|
||||
|
||||
export function getConfigTabCountHeading(
|
||||
tab: InteractiveConfigTab,
|
||||
itemCount: number,
|
||||
): string | undefined {
|
||||
return tab === "plugins" ? undefined : `${toTabLabel(tab)} (${itemCount})`;
|
||||
}
|
||||
|
||||
export function shouldRenderConfigItemAsEnabled(
|
||||
item: InteractiveConfigItem,
|
||||
enabledState: "enabled" | "disabled" | "partial",
|
||||
): boolean {
|
||||
return (
|
||||
enabledState === "enabled" &&
|
||||
(isToggleableInteractiveConfigItem(item) || item.agentPlugin === true)
|
||||
);
|
||||
}
|
||||
|
||||
export function isToggleableConfigItem(item: InteractiveConfigItem): boolean {
|
||||
return isToggleableInteractiveConfigItem(item);
|
||||
}
|
||||
|
||||
export function isDeletableConfigItem(item: InteractiveConfigItem): boolean {
|
||||
return item.kind === "plugin";
|
||||
return (
|
||||
item.deletable ?? (item.kind === "plugin" && item.agentPlugin !== true)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveConfigItemSelectAction(
|
||||
|
||||
@@ -5,11 +5,16 @@ import {
|
||||
getAdjacentConfigTab,
|
||||
getConfigFooterText,
|
||||
getConfigItemDisplayName,
|
||||
getConfigPluginSections,
|
||||
getConfigTabCountHeading,
|
||||
isDeletableConfigItem,
|
||||
isInlineConfigAction,
|
||||
isToggleableConfigItem,
|
||||
resolveConfigItemDeleteAction,
|
||||
resolveConfigItemSelectAction,
|
||||
resolveConfigItemToggleAction,
|
||||
resolveInitialConfigTab,
|
||||
shouldRenderConfigItemAsEnabled,
|
||||
} from "./config-view-helpers";
|
||||
|
||||
function createItem(
|
||||
@@ -71,6 +76,65 @@ describe("config view helpers", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("lets users toggle hub-discovered Agent Plugins without deleting them", () => {
|
||||
const plugin = createItem({
|
||||
kind: "plugin",
|
||||
agentPlugin: true,
|
||||
toggleable: true,
|
||||
deletable: false,
|
||||
source: "global-plugin",
|
||||
});
|
||||
|
||||
expect(isToggleableConfigItem(plugin)).toBe(true);
|
||||
expect(isDeletableConfigItem(plugin)).toBe(false);
|
||||
expect(resolveConfigItemToggleAction(plugin)).toEqual({
|
||||
kind: "toggle-item",
|
||||
item: plugin,
|
||||
});
|
||||
expect(resolveConfigItemDeleteAction(plugin)).toBeUndefined();
|
||||
expect(resolveConfigItemSelectAction(plugin)).toEqual({
|
||||
kind: "toggle-item",
|
||||
item: plugin,
|
||||
});
|
||||
});
|
||||
|
||||
it("separates Cline and Agent Plugins into labeled sections", () => {
|
||||
const clinePlugin = createItem({
|
||||
kind: "plugin",
|
||||
name: "cline-plugin",
|
||||
source: "workspace-plugin",
|
||||
});
|
||||
const agentPlugin = createItem({
|
||||
kind: "plugin",
|
||||
name: "portable-plugin",
|
||||
source: "global-plugin",
|
||||
agentPlugin: true,
|
||||
});
|
||||
|
||||
expect(getConfigPluginSections([clinePlugin, agentPlugin])).toEqual([
|
||||
{ label: "Cline Plugins (1)", items: [clinePlugin] },
|
||||
{ label: "Agent Plugins (1)", items: [agentPlugin] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses section counts instead of a combined Plugins heading", () => {
|
||||
expect(getConfigTabCountHeading("plugins", 10)).toBeUndefined();
|
||||
expect(getConfigTabCountHeading("skills", 20)).toBe("Skills (20)");
|
||||
});
|
||||
|
||||
it("renders a loaded Agent Plugin as enabled", () => {
|
||||
const agentPlugin = createItem({
|
||||
kind: "plugin",
|
||||
agentPlugin: true,
|
||||
toggleable: true,
|
||||
});
|
||||
|
||||
expect(shouldRenderConfigItemAsEnabled(agentPlugin, "enabled")).toBe(true);
|
||||
expect(shouldRenderConfigItemAsEnabled(agentPlugin, "disabled")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves Enter/Tab on a skill row to details", () => {
|
||||
const skill = createItem({
|
||||
kind: "skill",
|
||||
|
||||
@@ -25,6 +25,8 @@ import {
|
||||
getAdjacentConfigTab,
|
||||
getConfigFooterText,
|
||||
getConfigItemDisplayName,
|
||||
getConfigPluginSections,
|
||||
getConfigTabCountHeading,
|
||||
getConfigTabs,
|
||||
getPluginDiagnosticsLoadingText,
|
||||
isInlineConfigAction,
|
||||
@@ -34,6 +36,7 @@ import {
|
||||
resolveConfigItemSelectAction,
|
||||
resolveConfigItemToggleAction,
|
||||
resolveInitialConfigTab,
|
||||
shouldRenderConfigItemAsEnabled,
|
||||
toTabLabel,
|
||||
} from "./config-view-helpers";
|
||||
|
||||
@@ -298,6 +301,16 @@ function appendSkillRows(
|
||||
}
|
||||
}
|
||||
|
||||
function appendPluginRows(
|
||||
rows: ConfigRow[],
|
||||
items: InteractiveConfigItem[],
|
||||
): void {
|
||||
for (const section of getConfigPluginSections(items)) {
|
||||
rows.push({ kind: "head", label: section.label });
|
||||
appendExtRows(rows, section.items);
|
||||
}
|
||||
}
|
||||
|
||||
function withOptimisticToggle(
|
||||
data: InteractiveConfigData,
|
||||
item: InteractiveConfigItem,
|
||||
@@ -477,10 +490,13 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
r.push({ kind: "toggle", id: "verbose", label: "Verbose" });
|
||||
} else {
|
||||
const activeItems = resolveActiveConfigItems(configData, activeTab);
|
||||
r.push({
|
||||
kind: "head",
|
||||
label: `${toTabLabel(activeTab)} (${activeItems.length})`,
|
||||
});
|
||||
const countHeading = getConfigTabCountHeading(
|
||||
activeTab,
|
||||
activeItems.length,
|
||||
);
|
||||
if (countHeading) {
|
||||
r.push({ kind: "head", label: countHeading });
|
||||
}
|
||||
|
||||
if (activeItems.length === 0 && !pluginToolsLoading) {
|
||||
r.push({
|
||||
@@ -504,6 +520,21 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
}
|
||||
} else if (activeTab === "skills") {
|
||||
appendSkillRows(r, activeItems);
|
||||
} else if (activeTab === "plugins") {
|
||||
appendPluginRows(r, activeItems);
|
||||
if (pluginToolsLoading) {
|
||||
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: loadingText ?? "Loading plugin diagnostics...",
|
||||
});
|
||||
}
|
||||
if (pluginToolsError) {
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: pluginToolsError,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
for (const item of activeItems) {
|
||||
r.push({
|
||||
@@ -523,19 +554,6 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
: getPluginLoadErrorLabel(item),
|
||||
});
|
||||
}
|
||||
if (activeTab === "plugins" && pluginToolsLoading) {
|
||||
const loadingText = getPluginDiagnosticsLoadingText(activeTab);
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: loadingText ?? "Loading plugin diagnostics...",
|
||||
});
|
||||
}
|
||||
if (activeTab === "plugins" && pluginToolsError) {
|
||||
r.push({
|
||||
kind: "detail",
|
||||
text: pluginToolsError,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (activeTab === "mcp") {
|
||||
@@ -871,11 +889,10 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
|
||||
: "○ "
|
||||
: "";
|
||||
const rightLabel = row.rightLabel ?? "";
|
||||
const toggleable = isToggleableConfigItem(row.item);
|
||||
const prefix = " ".repeat(row.indent ?? 0);
|
||||
const rowColor = row.item.loadError
|
||||
? "red"
|
||||
: toggleable && enabledState === "enabled"
|
||||
: shouldRenderConfigItemAsEnabled(row.item, enabledState)
|
||||
? palette.success
|
||||
: enabledState === "partial"
|
||||
? "yellow"
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
# Cline Desktop Changelog
|
||||
|
||||
## 0.0.23
|
||||
|
||||
- Agent Plugins are now discovered and run by the shared Hub. Packages under `~/.agents/plugins` are validated from their `plugin.json`, their valid Agent Skills become available to the agent, and their stdio / Streamable HTTP / SSE MCP servers start automatically. Settings → Customize lists Agent Plugins separately from Cline Plugins, with each plugin's description, badge, and contributed tools, and enable/disable is Hub-managed per plugin. Workspace `.agents/plugins` directories are intentionally ignored
|
||||
- The "Cline Hub was updated" dialog no longer appears on every launch and reconnect. The app no longer prompts about a Hub running the same core version it does — a desktop and CLI release cut from different commits bundle the same core but never share a build fingerprint, so anyone with both installed got a dialog whose "Update and restart" looped on "no app update available". The build-mismatch dialog now also waits until an app update is actually staged, and "Later" sticks across session switches, reloads, and relaunches instead of resurfacing every time. A Hub the app genuinely cannot talk to still warns every time
|
||||
- Signing in now shows the device confirmation code in the app while you wait on the browser, so you can match it against the code the browser asks you to confirm — in onboarding, Account settings, and the provider list
|
||||
- Voice input failures caused by provider setup — missing credentials, transcription config — now take you straight to voice settings instead of a toast you cannot act on. Genuine microphone permission failures still toast, with a clearer message
|
||||
- Fixed the scheduled-task report vanishing when a finished run's step collapsed
|
||||
- Fixed one wedged MCP server blocking the rest from shutting down, leaking their processes
|
||||
|
||||
## 0.0.22
|
||||
|
||||
- Import your history from Claude Code, Codex, and opencode. An Import button in the Sessions header (and a row in Settings → General) scans your local stores from all three tools and turns the conversations you pick into fully resumable Cline sessions. Sessions are grouped per tool with select-all and a search across title, folder, and first prompt; already-imported ones are shown as such so re-opening the dialog is safe. Imported sessions resume on your configured provider and model, not the source tool's. If you have history from any of these tools, onboarding now offers the import as a step
|
||||
- Runs of a schedule now fold into a single collapsible sidebar row named after the schedule, with its run count, instead of one row per run all carrying the same prompt title. Expanding lists them newest-first as "Run N" with the usual status dot, time, hover card, context menu, and delete; the group holding the active session opens on its own
|
||||
- Voice input now works on macOS. The app shipped without a microphone usage description or entitlement, so dictation failed silently
|
||||
- Web search is now on by default
|
||||
- The marketplace detail panel now opens on click rather than hover, with left-aligned content, a single "Learn more" link, and the selected entry staying open while you filter the list
|
||||
- When the Hub is older than the app, you are now offered a choice — replace it, with a count of the sessions that would be interrupted, or keep it running — instead of the app quietly working against stale code. Replacing drains the Hub first so in-flight turns finish
|
||||
- Editing and resending a message now works on sessions with no checkpoint history, such as imported ones, instead of failing with "No checkpoint found at or before run N"
|
||||
- Fixed tool calling being silently disabled for Dify, SAP AI Core, opencode, and Codex CLI models. Their catalog entries declare no capabilities, and the empty list was read as an authoritative denial that stripped every tool from the request
|
||||
- Fixed images being dropped from file reads on models whose capability list is empty
|
||||
- The message the model receives when you reject a tool call now names the tool and reads as your decision rather than an error
|
||||
- Refreshed the model catalog. Adds eight providers (Bothub, OpenReason, SenseNova (China), TokenRouter, Vancine, Volcengine Ark Coding Plan, above.dev, and klokintegration.se) and changes the resolved default model for 36 providers — most consequentially Anthropic, which now resolves to Claude Fable 5.1 instead of Claude Opus 5, with Amazon Bedrock, Vertex, OpenRouter, Kilo Gateway, DevPass, DigitalOcean, CrossModel, and Eden AI following. If you use a provider without pinning a model, expect a different default
|
||||
|
||||
## 0.0.21
|
||||
|
||||
- Marketplace is now a two-pane explorer: a browsable list on the left and full catalog metadata for the selected item on the right, with category tag filters that collapse behind a "more" toggle
|
||||
@@ -13,6 +36,7 @@
|
||||
|
||||
## 0.0.20
|
||||
|
||||
- Customize now separates Cline Plugins from Agent Plugins discovered by the Hub. Agent Plugin switches use Hub-managed enablement, contributed skills appear in the Skills inventory, and connected desktop views refresh when Hub settings change
|
||||
- Cline Desktop now ships on Windows: releases include a code-signed x64 installer, and installed apps auto-update on the same feed macOS does
|
||||
- Windows shell fixes: background processes (the sidecar, git) no longer pop visible console windows; updates now download in the background and install when you restart the app; the MCP settings path falls back to `USERPROFILE` when `HOME` is unset
|
||||
- Tool results that return images — screenshots from browser or MCP tools — now render as inline images you can click to expand, with a carousel for stepping through multiple images, instead of raw base64 text
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cline/code",
|
||||
"version": "0.0.21",
|
||||
"version": "0.0.23",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build:ui": "bun -F @cline/ui build",
|
||||
|
||||
@@ -1036,6 +1036,34 @@ async function listUserInstructionConfigs(
|
||||
} finally {
|
||||
userInstructionService.stop();
|
||||
}
|
||||
const knownSkillPaths = new Set(
|
||||
skills.flatMap((skill) => {
|
||||
if (!skill || typeof skill !== "object") return [];
|
||||
const path = (skill as JsonRecord).path;
|
||||
return typeof path === "string" ? [path] : [];
|
||||
}),
|
||||
);
|
||||
for (const skill of hubSettings.skills) {
|
||||
if (
|
||||
skill.agentPlugin !== true ||
|
||||
skill.enabled === false ||
|
||||
knownSkillPaths.has(skill.path)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
skills.push({
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
instructions: "",
|
||||
path: skill.path,
|
||||
enabled: true,
|
||||
source: skill.source,
|
||||
agentPlugin: true,
|
||||
pluginName: skill.pluginName,
|
||||
});
|
||||
knownSkillPaths.add(skill.path);
|
||||
}
|
||||
|
||||
const disabledTools = new Set(readGlobalSettings().disabledTools ?? []);
|
||||
// Pin spawn/teams availability so this listing matches the hub's
|
||||
@@ -1055,9 +1083,15 @@ async function listUserInstructionConfigs(
|
||||
runtimeCommands,
|
||||
agents: loadAgents(),
|
||||
plugins: hubSettings.plugins.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
path: plugin.path,
|
||||
enabled: plugin.enabled !== false,
|
||||
source: plugin.source,
|
||||
toggleable: plugin.toggleable === true,
|
||||
agentPlugin: plugin.agentPlugin === true,
|
||||
description: plugin.description,
|
||||
loadError: plugin.loadError,
|
||||
contributions: plugin.contributions,
|
||||
})),
|
||||
tools: [
|
||||
@@ -2084,7 +2118,16 @@ export async function handleCommand(
|
||||
);
|
||||
});
|
||||
},
|
||||
{ owner: options?.connection },
|
||||
{
|
||||
owner: options?.connection,
|
||||
// Push the device sign-in confirmation code so the webview can
|
||||
// show it while the user confirms it in the browser.
|
||||
onUserCode: (userCode) =>
|
||||
broadcastEvent(ctx, "provider_oauth_user_code", {
|
||||
provider: providerId,
|
||||
userCode,
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (command === "cancel_provider_oauth_login") {
|
||||
|
||||
@@ -1193,6 +1193,33 @@ describe("Code sidecar runtime capabilities", () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards Hub settings changes so open desktop views can refresh", async () => {
|
||||
const { createSidecarContext, handleHubLiveEvent } = await import(
|
||||
"./context"
|
||||
);
|
||||
const ctx = createSidecarContext("/workspace/project");
|
||||
ctx.wsClients.add({ send: vi.fn() } as never);
|
||||
|
||||
handleHubLiveEvent(ctx, {
|
||||
event: "settings.changed",
|
||||
payload: {
|
||||
types: ["plugins", "skills", "mcp"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(readEvents(ctx)).toEqual([
|
||||
{
|
||||
type: "event",
|
||||
event: {
|
||||
name: "settings.changed",
|
||||
payload: {
|
||||
types: ["plugins", "skills", "mcp"],
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("disposeSidecarContext attachment cleanup", () => {
|
||||
|
||||
@@ -811,6 +811,10 @@ export function handleHubLiveEvent(
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event.event === "settings.changed") {
|
||||
sendEvent(ctx, event.event, event.payload ?? {});
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionId = typeof event.sessionId === "string" ? event.sessionId : "";
|
||||
if (!sessionId) {
|
||||
|
||||
@@ -187,4 +187,19 @@ describe("official plugin install detection", () => {
|
||||
);
|
||||
expect(populated.installedKeys).toEqual(["plugin:goal"]);
|
||||
});
|
||||
|
||||
it("does not match portable Agent Plugins to Cline marketplace entries", () => {
|
||||
const result = listMarketplaceInstalledEntries({ entries: [GOAL_ENTRY] }, {
|
||||
plugins: [
|
||||
{
|
||||
id: "agent-plugin:goal",
|
||||
name: "goal",
|
||||
path: "/home/user/.agents/plugins/goal",
|
||||
agentPlugin: true,
|
||||
},
|
||||
],
|
||||
} as JsonRecord);
|
||||
|
||||
expect(result.installedKeys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -714,6 +714,7 @@ function hasMatchingInventoryItem(
|
||||
return items.some((item) => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
const record = item as JsonRecord;
|
||||
if (record.agentPlugin === true) return false;
|
||||
const values = [
|
||||
typeof record.name === "string" ? record.name : undefined,
|
||||
typeof record.id === "string" ? record.id : undefined,
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { ProviderSettingsManager } from "@cline/core";
|
||||
import {
|
||||
completeClineDeviceAuth,
|
||||
getProviderAuthStorageId,
|
||||
loginLocalProvider,
|
||||
markLocalProviderEnabled,
|
||||
saveLocalProviderOAuthCredentials,
|
||||
startClineDeviceAuth,
|
||||
} from "@cline/core";
|
||||
import { getClineEnvironmentConfig } from "@cline/shared";
|
||||
|
||||
export class OAuthLoginCancelledError extends Error {
|
||||
constructor(providerId: string) {
|
||||
@@ -26,13 +29,41 @@ type PendingOAuthLogin = {
|
||||
const pendingOAuthLoginsByProvider = new Map<string, PendingOAuthLogin>();
|
||||
|
||||
export type OAuthLoginDependencies = {
|
||||
login: typeof loginLocalProvider;
|
||||
login: typeof loginProviderForDesktop;
|
||||
save: typeof saveLocalProviderOAuthCredentials;
|
||||
markEnabled: typeof markLocalProviderEnabled;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cline account providers sign in with the WorkOS device-code grant, whose
|
||||
* browser page asks the user to confirm a short code. `loginLocalProvider`
|
||||
* runs that flow but discards the code, so use the split helpers instead and
|
||||
* surface the code through `onUserCode` for the UI to display.
|
||||
*/
|
||||
async function loginProviderForDesktop(
|
||||
providerId: string,
|
||||
existing: Parameters<typeof loginLocalProvider>[1],
|
||||
openUrl: (url: string) => void,
|
||||
onUserCode?: (userCode: string) => void,
|
||||
): ReturnType<typeof loginLocalProvider> {
|
||||
if (providerId !== "cline" && providerId !== "cline-pass") {
|
||||
return loginLocalProvider(providerId, existing, openUrl);
|
||||
}
|
||||
const device = await startClineDeviceAuth();
|
||||
onUserCode?.(device.userCode);
|
||||
openUrl(device.verificationUriComplete ?? device.verificationUri);
|
||||
return completeClineDeviceAuth({
|
||||
deviceCode: device.deviceCode,
|
||||
expiresInSeconds: device.expiresInSeconds,
|
||||
pollIntervalSeconds: device.pollIntervalSeconds,
|
||||
apiBaseUrl:
|
||||
existing?.baseUrl?.trim() || getClineEnvironmentConfig().apiBaseUrl,
|
||||
provider: providerId,
|
||||
});
|
||||
}
|
||||
|
||||
const defaultDependencies: OAuthLoginDependencies = {
|
||||
login: loginLocalProvider,
|
||||
login: loginProviderForDesktop,
|
||||
save: saveLocalProviderOAuthCredentials,
|
||||
markEnabled: markLocalProviderEnabled,
|
||||
};
|
||||
@@ -47,7 +78,11 @@ export async function runCancellableProviderOAuthLogin(
|
||||
manager: ProviderSettingsManager,
|
||||
providerId: string,
|
||||
openUrl: (url: string) => void,
|
||||
options: { owner?: object } = {},
|
||||
options: {
|
||||
owner?: object;
|
||||
/** Receives the device sign-in confirmation code, when the flow has one. */
|
||||
onUserCode?: (userCode: string) => void;
|
||||
} = {},
|
||||
dependencies: OAuthLoginDependencies = defaultDependencies,
|
||||
): Promise<{ provider: string; accessToken: string }> {
|
||||
const storageProviderId = getProviderAuthStorageId(providerId) ?? providerId;
|
||||
@@ -74,7 +109,7 @@ export async function runCancellableProviderOAuthLogin(
|
||||
// after cancellation is observed and cannot become an unhandled
|
||||
// rejection that kills the sidecar.
|
||||
const credentials = await Promise.race([
|
||||
dependencies.login(providerId, existing, openUrl),
|
||||
dependencies.login(providerId, existing, openUrl, options.onUserCode),
|
||||
cancellation,
|
||||
]);
|
||||
if (entry.cancelled) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Cline",
|
||||
"version": "0.0.21",
|
||||
"version": "0.0.23",
|
||||
"identifier": "bot.cline.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run build:sidecar:bin && bun run dev:web",
|
||||
|
||||
@@ -14,11 +14,15 @@ import {
|
||||
import {
|
||||
checkForUpdateNow,
|
||||
restartToApplyUpdate,
|
||||
useAppUpdateStatus,
|
||||
} from "@/hooks/use-app-update";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import {
|
||||
describeOutdatedHubSessions,
|
||||
isPersistableHubMismatchKey,
|
||||
resolveHubUpdateRestartDecision,
|
||||
retainDismissalForIncomingMismatch,
|
||||
shouldShowHubMismatchDialog,
|
||||
} from "./hub-update-required-helpers";
|
||||
|
||||
type HubBuildMismatchPayload = {
|
||||
@@ -38,6 +42,36 @@ type UpdatePhase = "idle" | "updating" | "restarting";
|
||||
/** Generous deadline: drain wait + graceful retire + fresh daemon startup. */
|
||||
const HUB_UPGRADE_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* "Later" must survive webview remounts and reconnects: the sidecar replays
|
||||
* a pending mismatch on every new webview connection (session switches,
|
||||
* reloads, relaunches), and in-memory dismissal state resurrected the modal
|
||||
* each time. Storage keeps one key - a different hub build prompts again.
|
||||
*/
|
||||
const DISMISSED_MISMATCH_STORAGE_KEY = "cline.hub-mismatch-dismissed";
|
||||
|
||||
function readPersistedDismissedKey(): string | null {
|
||||
try {
|
||||
const key = localStorage.getItem(DISMISSED_MISMATCH_STORAGE_KEY);
|
||||
return isPersistableHubMismatchKey(key) ? key : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistDismissedKey(key: string): void {
|
||||
try {
|
||||
localStorage.setItem(DISMISSED_MISMATCH_STORAGE_KEY, key);
|
||||
} catch {
|
||||
// Best effort: without storage the dismissal lasts this mount only.
|
||||
}
|
||||
}
|
||||
|
||||
// One updater kick per observed mismatch per page lifetime. Module scope
|
||||
// survives component remounts (session switches) so the update feed is not
|
||||
// re-hit every time the dialog mounts.
|
||||
let updateCheckKickedForKey: string | null = null;
|
||||
|
||||
/**
|
||||
* Blocking prompt shown when the sidecar reports that the shared Cline Hub
|
||||
* does not match this app's build.
|
||||
@@ -58,9 +92,12 @@ export function HubUpdateRequiredDialog() {
|
||||
const [mismatch, setMismatch] = useState<HubBuildMismatchPayload | null>(
|
||||
null,
|
||||
);
|
||||
const [dismissedKey, setDismissedKey] = useState<string | null>(null);
|
||||
const [dismissedKey, setDismissedKey] = useState<string | null>(
|
||||
readPersistedDismissedKey,
|
||||
);
|
||||
const [phase, setPhase] = useState<UpdatePhase>("idle");
|
||||
const [updateHint, setUpdateHint] = useState<string | null>(null);
|
||||
const updateStatus = useAppUpdateStatus();
|
||||
|
||||
useEffect(() => {
|
||||
return desktopClient.subscribe("hub_build_mismatch", (payload) => {
|
||||
@@ -74,16 +111,43 @@ export function HubUpdateRequiredDialog() {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
return;
|
||||
}
|
||||
setMismatch(payload as HubBuildMismatchPayload);
|
||||
const incoming = payload as HubBuildMismatchPayload;
|
||||
setMismatch(incoming);
|
||||
// A new mismatch is a fresh prompt: drop any "no update available"
|
||||
// hint left over from a previous dialog so it reopens in its
|
||||
// initial state instead of pre-set to "Try again".
|
||||
setUpdateHint(null);
|
||||
// Delivery includes replays on in-place transport reconnects, where
|
||||
// this component never remounts: a non-persistable dismissal
|
||||
// (unsupported_protocol) must not survive them, or the warning about
|
||||
// a Hub the app cannot talk to stays silenced indefinitely.
|
||||
setDismissedKey((previous) =>
|
||||
retainDismissalForIncomingMismatch(previous, mismatchKeyOf(incoming)),
|
||||
);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const mismatchKey = mismatch ? mismatchKeyOf(mismatch) : null;
|
||||
|
||||
// When a newer Hub appears, stage the matching app update right away (if
|
||||
// a release exists) so the prompt can open actionable instead of waiting
|
||||
// for the next 30s background cycle. Without a staged update the
|
||||
// build_mismatch modal stays hidden entirely - see
|
||||
// shouldShowHubMismatchDialog.
|
||||
useEffect(() => {
|
||||
if (
|
||||
!mismatch ||
|
||||
mismatch.reason !== "build_mismatch" ||
|
||||
mismatchKey === null ||
|
||||
mismatchKey === dismissedKey ||
|
||||
updateCheckKickedForKey === mismatchKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
updateCheckKickedForKey = mismatchKey;
|
||||
void checkForUpdateNow();
|
||||
}, [mismatch, mismatchKey, dismissedKey]);
|
||||
|
||||
const handleUpdateAndRestart = useCallback(async () => {
|
||||
setPhase("updating");
|
||||
setUpdateHint(null);
|
||||
@@ -191,14 +255,23 @@ export function HubUpdateRequiredDialog() {
|
||||
);
|
||||
}
|
||||
|
||||
const open = mismatchKey !== null && mismatchKey !== dismissedKey;
|
||||
const open =
|
||||
mismatchKey !== null &&
|
||||
mismatchKey !== dismissedKey &&
|
||||
shouldShowHubMismatchDialog(mismatch?.reason, updateStatus.state);
|
||||
|
||||
return (
|
||||
<AlertDialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && phase === "idle") {
|
||||
if (!nextOpen && phase === "idle" && mismatchKey !== null) {
|
||||
setDismissedKey(mismatchKey);
|
||||
// unsupported_protocol never persists: hub-backed features
|
||||
// stay broken against that Hub, so its warning must return
|
||||
// on the next reconnect or relaunch.
|
||||
if (isPersistableHubMismatchKey(mismatchKey)) {
|
||||
persistDismissedKey(mismatchKey);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,9 +1,82 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
describeOutdatedHubSessions,
|
||||
isPersistableHubMismatchKey,
|
||||
resolveHubUpdateRestartDecision,
|
||||
retainDismissalForIncomingMismatch,
|
||||
shouldShowHubMismatchDialog,
|
||||
} from "./hub-update-required-helpers";
|
||||
|
||||
describe("shouldShowHubMismatchDialog", () => {
|
||||
it("always allows the truly-broken and blocking reasons", () => {
|
||||
for (const state of [
|
||||
"idle",
|
||||
"checking",
|
||||
"downloading",
|
||||
"ready",
|
||||
"error",
|
||||
undefined,
|
||||
] as const) {
|
||||
expect(shouldShowHubMismatchDialog("unsupported_protocol", state)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(shouldShowHubMismatchDialog("outdated_hub", state)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("persists dismissals only for the advisory build_mismatch case", () => {
|
||||
expect(isPersistableHubMismatchKey("build_mismatch:abc123")).toBe(true);
|
||||
expect(isPersistableHubMismatchKey("unsupported_protocol:abc123")).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isPersistableHubMismatchKey("outdated_hub:abc123")).toBe(false);
|
||||
expect(isPersistableHubMismatchKey(null)).toBe(false);
|
||||
expect(isPersistableHubMismatchKey("")).toBe(false);
|
||||
});
|
||||
|
||||
it("reopens a dismissed protocol warning on redelivery, keeps advisory and unrelated dismissals", () => {
|
||||
// A replayed unsupported_protocol mismatch clears its own dismissal:
|
||||
// the app cannot talk to that Hub, so "Later" must not outlive an
|
||||
// in-place reconnect replay.
|
||||
expect(
|
||||
retainDismissalForIncomingMismatch(
|
||||
"unsupported_protocol:abc",
|
||||
"unsupported_protocol:abc",
|
||||
),
|
||||
).toBeNull();
|
||||
// The advisory newer-hub dismissal stands across replays.
|
||||
expect(
|
||||
retainDismissalForIncomingMismatch(
|
||||
"build_mismatch:abc",
|
||||
"build_mismatch:abc",
|
||||
),
|
||||
).toBe("build_mismatch:abc");
|
||||
// A dismissal for a different mismatch is untouched.
|
||||
expect(
|
||||
retainDismissalForIncomingMismatch(
|
||||
"build_mismatch:abc",
|
||||
"unsupported_protocol:def",
|
||||
),
|
||||
).toBe("build_mismatch:abc");
|
||||
expect(retainDismissalForIncomingMismatch(null, "build_mismatch:abc")).toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it("allows a newer-hub prompt only once an app update is staged", () => {
|
||||
expect(shouldShowHubMismatchDialog("build_mismatch", "ready")).toBe(true);
|
||||
for (const state of [
|
||||
"idle",
|
||||
"checking",
|
||||
"downloading",
|
||||
"error",
|
||||
undefined,
|
||||
] as const) {
|
||||
expect(shouldShowHubMismatchDialog("build_mismatch", state)).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("describeOutdatedHubSessions", () => {
|
||||
it("quantifies sessions and clients when the hub reported both", () => {
|
||||
expect(
|
||||
|
||||
@@ -4,6 +4,62 @@ export type HubUpdateRestartDecision =
|
||||
| { action: "restart" }
|
||||
| { action: "stay"; hint: string };
|
||||
|
||||
/**
|
||||
* Whether a hub build mismatch may interrupt with a modal at all.
|
||||
*
|
||||
* - `unsupported_protocol` and `outdated_hub` always may: the first means
|
||||
* the app cannot talk to the Hub, the second is the blocking
|
||||
* replace-or-quit decision.
|
||||
* - `build_mismatch` may only once an app update is actually staged. A
|
||||
* newer Hub is advisory while the wire protocol still works, and without
|
||||
* a staged update the modal's only exit is "no update available yet",
|
||||
* which loops on every launch and webview reconnect until a release
|
||||
* ships - so it stays silent until it can offer a real action.
|
||||
*/
|
||||
export function shouldShowHubMismatchDialog(
|
||||
reason: string | undefined,
|
||||
updateState: AppUpdateStatus["state"] | undefined,
|
||||
): boolean {
|
||||
if (reason === "unsupported_protocol" || reason === "outdated_hub") {
|
||||
return true;
|
||||
}
|
||||
return updateState === "ready";
|
||||
}
|
||||
|
||||
/**
|
||||
* Only the advisory `build_mismatch` dismissal may persist across webview
|
||||
* mounts and app relaunches. An `unsupported_protocol` Hub leaves hub-backed
|
||||
* features broken, so that warning must return on every reconnect and
|
||||
* relaunch - its "Later" lasts only for the current mount. Applied on both
|
||||
* write and read, so a key persisted by any other path is ignored too.
|
||||
* (Mismatch keys are `${reason}:${hubBuildId}`.)
|
||||
*/
|
||||
export function isPersistableHubMismatchKey(key: string | null): key is string {
|
||||
return typeof key === "string" && key.startsWith("build_mismatch:");
|
||||
}
|
||||
|
||||
/**
|
||||
* What a dismissal becomes when the sidecar delivers a mismatch again - it
|
||||
* replays the pending mismatch on every webview (re)connection, including
|
||||
* in-place transport reconnects where the dialog never remounts. A reason
|
||||
* whose dismissal may not outlive the moment (`unsupported_protocol`: the
|
||||
* app cannot talk to the Hub) drops its matching in-memory "Later" so the
|
||||
* warning reopens on the replay; the advisory `build_mismatch` dismissal
|
||||
* stands. An unrelated dismissed key is kept either way.
|
||||
*/
|
||||
export function retainDismissalForIncomingMismatch(
|
||||
previousDismissedKey: string | null,
|
||||
incomingKey: string,
|
||||
): string | null {
|
||||
if (
|
||||
previousDismissedKey === incomingKey &&
|
||||
!isPersistableHubMismatchKey(incomingKey)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return previousDismissedKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human phrase for the live work an outdated Hub is serving, used by the
|
||||
* blocking "Hub update required" dialog. Falls back to an unquantified
|
||||
|
||||
@@ -348,6 +348,7 @@ function ChatInputBarImpl({
|
||||
onSteerPromptInQueue,
|
||||
onEditPromptInQueue,
|
||||
onRemovePromptInQueue,
|
||||
onOpenVoiceInputSettings,
|
||||
summary,
|
||||
}: ChatInputBarProps) {
|
||||
const {
|
||||
@@ -752,24 +753,38 @@ function ChatInputBarImpl({
|
||||
[transcriptionTarget],
|
||||
);
|
||||
|
||||
const handleSpeechInputError = useCallback((error: unknown) => {
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Check microphone permission and audio provider settings.";
|
||||
writeDesktopDebugLog({
|
||||
scope: "voice-input",
|
||||
level: "error",
|
||||
message: "Speech input failed in the webview",
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: { failure: message },
|
||||
});
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Speech input failed",
|
||||
description: message,
|
||||
});
|
||||
}, []);
|
||||
const handleSpeechInputError = useCallback(
|
||||
(error: unknown) => {
|
||||
// Microphone failures surface as DOMExceptions (getUserMedia) or
|
||||
// capture-layer events; provider failures (credentials, transcription
|
||||
// setup) as plain Errors, and are fixed in Settings → Voice.
|
||||
const isMicrophoneError =
|
||||
error instanceof DOMException || error instanceof Event;
|
||||
const message =
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Check microphone permission and audio provider settings.";
|
||||
writeDesktopDebugLog({
|
||||
scope: "voice-input",
|
||||
level: "error",
|
||||
message: "Speech input failed in the webview",
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: { failure: message },
|
||||
});
|
||||
if (!isMicrophoneError && onOpenVoiceInputSettings) {
|
||||
onOpenVoiceInputSettings();
|
||||
return;
|
||||
}
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: "Speech input failed",
|
||||
description: isMicrophoneError
|
||||
? "Check the microphone permission for Cline and try again."
|
||||
: message,
|
||||
});
|
||||
},
|
||||
[onOpenVoiceInputSettings],
|
||||
);
|
||||
|
||||
const effortIndex = useMemo(
|
||||
() => resolveEffortIndex(thinking, reasoningEffort),
|
||||
|
||||
@@ -331,6 +331,61 @@ describe("ChatMessages tool disclosures", () => {
|
||||
expect(container.textContent).not.toContain("Scheduled task completed");
|
||||
});
|
||||
|
||||
it("keeps the scheduled-task report visible when the run collapses", async () => {
|
||||
// A follow-up prompt settles the scheduled run's span and folds its
|
||||
// working rows into the work summary; the submit_and_exit row is the
|
||||
// run's final report and must stay visible below it.
|
||||
const summary = "All feeds healthy.";
|
||||
await renderMessages([
|
||||
{
|
||||
id: "user-schedule",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Check the feeds",
|
||||
createdAt: 1_000,
|
||||
},
|
||||
{
|
||||
id: "tool-read",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "read_files",
|
||||
input: { paths: ["feeds.json"] },
|
||||
result: {},
|
||||
}),
|
||||
createdAt: 2_000,
|
||||
},
|
||||
{
|
||||
id: "tool-submit",
|
||||
sessionId: "session-1",
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "submit_and_exit",
|
||||
input: { summary, verified: true },
|
||||
result: summary,
|
||||
}),
|
||||
createdAt: 3_000,
|
||||
},
|
||||
{
|
||||
id: "user-followup",
|
||||
sessionId: "session-1",
|
||||
role: "user",
|
||||
content: "Thanks!",
|
||||
createdAt: 9_000,
|
||||
},
|
||||
]);
|
||||
|
||||
// The working rows folded into a collapsed work summary…
|
||||
const workTrigger = container.querySelector(
|
||||
"button.cline-chat-work-trigger",
|
||||
);
|
||||
expect(workTrigger?.getAttribute("aria-expanded")).toBe("false");
|
||||
// …but the report row did not fold with them: it stays visible and
|
||||
// expanded outside the summary.
|
||||
expect(container.textContent).toContain("Scheduled task completed");
|
||||
expect(container.textContent).toContain(summary);
|
||||
});
|
||||
|
||||
it("renders consecutive tool calls as individual rows", async () => {
|
||||
const tools: ChatMessage[] = [
|
||||
{
|
||||
|
||||
+218
@@ -420,6 +420,224 @@ describe("collapseCompletedWork", () => {
|
||||
expect(work.durationMilliseconds).toBe(4_000);
|
||||
});
|
||||
|
||||
function makeSubmitTool(id: string, createdAt: number): ChatMessage {
|
||||
return makeMessage({
|
||||
id,
|
||||
role: "tool",
|
||||
content: JSON.stringify({
|
||||
toolName: "submit_and_exit",
|
||||
input: { summary: "Report ready." },
|
||||
result: "Report ready.",
|
||||
}),
|
||||
createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
it("keeps a trailing submit_and_exit row visible as the collapsed run's answer", () => {
|
||||
// Scheduled runs end on submit_and_exit — its row carries the final
|
||||
// report, so it must not fold into the work summary.
|
||||
const items = collapse(
|
||||
[
|
||||
makeMessage({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "go",
|
||||
createdAt: 1_000,
|
||||
}),
|
||||
makeTool("t1", 2_000),
|
||||
makeSubmitTool("submit", 5_000),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual([
|
||||
"message",
|
||||
"work",
|
||||
"tools",
|
||||
]);
|
||||
const work = items[1];
|
||||
if (work?.type !== "work") throw new Error("expected work item");
|
||||
expect(work.toolCallCount).toBe(1);
|
||||
expect(work.durationMilliseconds).toBe(4_000);
|
||||
const submit = items[2];
|
||||
if (submit?.type !== "tools") throw new Error("expected tools item");
|
||||
expect(submit.messages.map((message) => message.id)).toEqual(["submit"]);
|
||||
});
|
||||
|
||||
it("keeps the submit_and_exit row visible once a later user message exists", () => {
|
||||
// A follow-up prompt in a finished scheduled session settles the run's
|
||||
// span; the report row must survive the collapse instead of hiding
|
||||
// inside the work summary.
|
||||
const items = collapse(
|
||||
[
|
||||
makeMessage({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "go",
|
||||
createdAt: 1_000,
|
||||
}),
|
||||
makeTool("t1", 2_000),
|
||||
makeSubmitTool("submit", 5_000),
|
||||
makeMessage({
|
||||
id: "u2",
|
||||
role: "user",
|
||||
content: "thanks, one more thing",
|
||||
createdAt: 9_000,
|
||||
}),
|
||||
],
|
||||
false,
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual([
|
||||
"message",
|
||||
"work",
|
||||
"tools",
|
||||
"message",
|
||||
]);
|
||||
const submit = items[2];
|
||||
if (submit?.type !== "tools") throw new Error("expected tools item");
|
||||
expect(submit.messages.map((message) => message.id)).toEqual(["submit"]);
|
||||
});
|
||||
|
||||
it("keeps a live run's trailing submit_and_exit with its working rows", () => {
|
||||
const items = collapse(
|
||||
[
|
||||
makeMessage({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "go",
|
||||
createdAt: 1_000,
|
||||
}),
|
||||
makeMessage({
|
||||
id: "r1",
|
||||
reasoning: "wrapping up",
|
||||
createdAt: 1_500,
|
||||
}),
|
||||
makeTool("t1", 2_000),
|
||||
makeSubmitTool("submit", 3_000),
|
||||
],
|
||||
false,
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual(["message", "run"]);
|
||||
const run = items[1];
|
||||
if (run?.type !== "run") throw new Error("expected run item");
|
||||
const tools = run.items.at(-1);
|
||||
if (tools?.type !== "tools") throw new Error("expected tools item");
|
||||
expect(tools.messages.map((message) => message.id)).toEqual([
|
||||
"t1",
|
||||
"submit",
|
||||
]);
|
||||
});
|
||||
|
||||
it("treats a mid-run submit_and_exit as ordinary work", () => {
|
||||
// Only a submit the run actually ended on is its deliverable; one
|
||||
// followed by more work folds with everything else.
|
||||
const items = collapse(
|
||||
[
|
||||
makeMessage({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "go",
|
||||
createdAt: 1_000,
|
||||
}),
|
||||
makeSubmitTool("submit", 2_000),
|
||||
makeTool("t1", 3_000),
|
||||
makeMessage({ id: "a1", content: "Done.", createdAt: 5_000 }),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual([
|
||||
"message",
|
||||
"work",
|
||||
"message",
|
||||
]);
|
||||
const work = items[1];
|
||||
if (work?.type !== "work") throw new Error("expected work item");
|
||||
expect(work.toolCallCount).toBe(2);
|
||||
});
|
||||
|
||||
it("prefers trailing assistant text over an earlier submit as the answer", () => {
|
||||
// When the model narrates after submitting, the narration is the
|
||||
// answer and the run folds exactly as it did before the submit
|
||||
// special-case existed.
|
||||
const items = collapse(
|
||||
[
|
||||
makeMessage({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "go",
|
||||
createdAt: 1_000,
|
||||
}),
|
||||
makeTool("t1", 2_000),
|
||||
makeSubmitTool("submit", 3_000),
|
||||
makeMessage({ id: "a1", content: "All wrapped up.", createdAt: 4_000 }),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual([
|
||||
"message",
|
||||
"work",
|
||||
"message",
|
||||
]);
|
||||
const work = items[1];
|
||||
if (work?.type !== "work") throw new Error("expected work item");
|
||||
expect(work.toolCallCount).toBe(2);
|
||||
const answer = items[2];
|
||||
if (answer?.type !== "message") throw new Error("expected message item");
|
||||
expect(answer.message.id).toBe("a1");
|
||||
});
|
||||
|
||||
it("detects submit_and_exit from message meta when the content is not JSON", () => {
|
||||
const items = collapse(
|
||||
[
|
||||
makeMessage({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "go",
|
||||
createdAt: 1_000,
|
||||
}),
|
||||
makeTool("t1", 2_000),
|
||||
makeMessage({
|
||||
id: "submit-meta",
|
||||
role: "tool",
|
||||
content: "not-json",
|
||||
meta: { toolName: "submit_and_exit" },
|
||||
createdAt: 3_000,
|
||||
}),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual([
|
||||
"message",
|
||||
"work",
|
||||
"tools",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat other trailing tool calls as the run's answer", () => {
|
||||
// A finished tail ending on an ordinary tool call still reads as an
|
||||
// interrupted run: rows stay visible, nothing collapses.
|
||||
const items = collapse(
|
||||
[
|
||||
makeMessage({
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "go",
|
||||
createdAt: 1_000,
|
||||
}),
|
||||
makeTool("t1", 2_000),
|
||||
makeTool("t2", 3_000),
|
||||
],
|
||||
true,
|
||||
);
|
||||
|
||||
expect(items.map((item) => item.type)).toEqual(["message", "tools"]);
|
||||
});
|
||||
|
||||
it("measures duration from the first working row when no user message precedes it", () => {
|
||||
const items = collapse(
|
||||
[
|
||||
|
||||
+46
-11
@@ -1,5 +1,6 @@
|
||||
import type { AgentMessageRole } from "@cline/ui/components/agent-chat";
|
||||
import type { ChatMessage } from "@/lib/chat-schema";
|
||||
import { parseToolPayload } from "./tool-summaries";
|
||||
|
||||
export type ChatRenderItem =
|
||||
| {
|
||||
@@ -174,6 +175,18 @@ function maxFiniteTimestamp(
|
||||
return max;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `submit_and_exit` call carries the run's final report (scheduled tasks
|
||||
* end with it), so a run that ends on one treats that row as its deliverable
|
||||
* — it must stay visible when the working rows fold into a work summary.
|
||||
*/
|
||||
function isSubmitAndExitMessage(message: ChatMessage): boolean {
|
||||
if (message.role !== "tool") return false;
|
||||
const toolName =
|
||||
message.meta?.toolName || parseToolPayload(message.content)?.toolName;
|
||||
return toolName?.toLowerCase() === "submit_and_exit";
|
||||
}
|
||||
|
||||
function firstMessageId(item: ChatRenderItem): string | undefined {
|
||||
if (item.type === "tools") return item.messages[0]?.id;
|
||||
if (item.type === "message") {
|
||||
@@ -185,7 +198,8 @@ function firstMessageId(item: ChatRenderItem): string | undefined {
|
||||
/**
|
||||
* Folds each finished run's working rows (tool calls, thinking traces,
|
||||
* intermediate narration) into a single expandable `work` item, keeping the
|
||||
* run's final answer — the assistant text the run ended on — visible after it.
|
||||
* run's final answer — the assistant text or submit_and_exit report the run
|
||||
* ended on — visible after it.
|
||||
* Working rows that stay visible (live stream, tool-less runs, tails that
|
||||
* never produced an answer) are grouped into a `run` item instead, so they
|
||||
* share one tight rhythm and hold their position when the collapse happens.
|
||||
@@ -215,15 +229,33 @@ export function collapseCompletedWork(
|
||||
|
||||
const flushSpan = (nextIndex: number) => {
|
||||
if (span.length === 0) return;
|
||||
// "Done" means assistant text not followed by more tool calls: that
|
||||
// message is the run's answer and stays visible below the summary.
|
||||
// "Done" means the run ended on its deliverable: assistant text not
|
||||
// followed by more tool calls, or a submit_and_exit call carrying the
|
||||
// run's final report. That item is the run's answer and stays visible
|
||||
// below the summary.
|
||||
const last = span.at(-1);
|
||||
const answer =
|
||||
let answer: ChatRenderItem | undefined;
|
||||
let workRows = span;
|
||||
if (
|
||||
last?.type === "message" &&
|
||||
last.message.role === "assistant" &&
|
||||
last.message.content.trim()
|
||||
? last
|
||||
: undefined;
|
||||
) {
|
||||
answer = last;
|
||||
workRows = span.slice(0, -1);
|
||||
} else if (last?.type === "tools") {
|
||||
const lastToolMessage = last.messages.at(-1);
|
||||
if (lastToolMessage && isSubmitAndExitMessage(lastToolMessage)) {
|
||||
answer = { type: "tools", messages: [lastToolMessage] };
|
||||
workRows =
|
||||
last.messages.length > 1
|
||||
? [
|
||||
...span.slice(0, -1),
|
||||
{ type: "tools", messages: last.messages.slice(0, -1) },
|
||||
]
|
||||
: span.slice(0, -1);
|
||||
}
|
||||
}
|
||||
// A span is settled once a later user message exists. The trailing span
|
||||
// settles only when the session stopped running AND the run actually
|
||||
// ended on an answer — a cancelled or failed tail keeps its rows
|
||||
@@ -231,7 +263,7 @@ export function collapseCompletedWork(
|
||||
const complete =
|
||||
nextIndex <= lastUserIndex ||
|
||||
(collapseTrailingRun && answer !== undefined);
|
||||
const collapsed = complete && answer ? span.slice(0, -1) : span;
|
||||
const collapsed = complete && answer ? workRows : span;
|
||||
const toolCallCount = collapsed.reduce(
|
||||
(count, item) =>
|
||||
item.type === "tools" ? count + item.messages.length : count,
|
||||
@@ -242,8 +274,11 @@ export function collapseCompletedWork(
|
||||
// Not collapsed: group the working rows (everything but a trailing
|
||||
// answer-looking message) so they render with the tight in-run
|
||||
// rhythm instead of full transcript spacing. Pure prose spans have
|
||||
// no tool work to group and keep normal spacing.
|
||||
const body = answer ? span.slice(0, -1) : span;
|
||||
// no tool work to group and keep normal spacing. A trailing submit
|
||||
// row stays inside the group here — it only pops out once the run
|
||||
// actually collapses.
|
||||
const messageAnswer = answer?.type === "message" ? answer : undefined;
|
||||
const body = messageAnswer ? span.slice(0, -1) : span;
|
||||
const firstBody = body[0];
|
||||
const hasToolWork = body.some((item) => item.type === "tools");
|
||||
if (body.length >= 2 && hasToolWork && firstBody !== undefined) {
|
||||
@@ -252,8 +287,8 @@ export function collapseCompletedWork(
|
||||
id: firstMessageId(firstBody) ?? "run",
|
||||
items: body,
|
||||
});
|
||||
if (answer) {
|
||||
out.push(answer);
|
||||
if (messageAnswer) {
|
||||
out.push(messageAnswer);
|
||||
}
|
||||
} else {
|
||||
out.push(...span);
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ import {
|
||||
|
||||
const { invoke } = vi.hoisted(() => ({ invoke: vi.fn() }));
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke },
|
||||
desktopClient: { invoke, subscribe: vi.fn(() => () => {}) },
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ import { GitHubConnectStep } from "@/components/views/onboarding/onboarding-gith
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import { OAUTH_MANAGED_PROVIDERS } from "@/hooks/chat-session/constants";
|
||||
import { isFeatureEnabled, useFeatureFlags } from "@/hooks/use-feature-flags";
|
||||
import { useOAuthUserCode } from "@/hooks/use-oauth-user-code";
|
||||
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
|
||||
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
|
||||
import {
|
||||
@@ -322,6 +323,7 @@ function ConnectStep({
|
||||
}) {
|
||||
const { user, refreshAccount } = useAccount();
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
const deviceUserCode = useOAuthUserCode(signingIn);
|
||||
const [signInError, setSignInError] = useState<string | null>(null);
|
||||
const [clineApiKey, setClineApiKey] = useState("");
|
||||
const [clineKeySaving, setClineKeySaving] = useState(false);
|
||||
@@ -619,6 +621,14 @@ function ConnectStep({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!user && signingIn && deviceUserCode ? (
|
||||
<p className="mt-4 ml-12 text-sm text-muted-foreground max-[720px]:ml-0">
|
||||
Confirm this code in your browser:{" "}
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{deviceUserCode}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
{signInError ? (
|
||||
<p
|
||||
className="mt-6 ml-12 text-xs text-destructive max-[720px]:ml-0"
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useAccount } from "@/contexts/account-context";
|
||||
import { useOAuthUserCode } from "@/hooks/use-oauth-user-code";
|
||||
import { isClineAccountNotAuthenticatedResult } from "@/lib/cline-account-state";
|
||||
import { desktopClient, openExternalUrl } from "@/lib/desktop-client";
|
||||
import { invalidateProviderCatalogCache } from "@/lib/provider-model-catalog";
|
||||
@@ -181,6 +182,7 @@ export function AccountView() {
|
||||
const [accountActionPending, setAccountActionPending] = useState<
|
||||
"sign-in" | "sign-out" | null
|
||||
>(null);
|
||||
const deviceUserCode = useOAuthUserCode(accountActionPending === "sign-in");
|
||||
// Organization id being switched to, "" while switching to the personal
|
||||
// account, null when no switch is in flight.
|
||||
const [switchTargetId, setSwitchTargetId] = useState<string | null>(null);
|
||||
@@ -502,6 +504,14 @@ export function AccountView() {
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
{accountActionPending === "sign-in" && deviceUserCode ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Confirm this code in your browser:{" "}
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{deviceUserCode}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,10 @@ import { Button } from "@/components/ui/button";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { PageFrame, PageHeader } from "../page-layout";
|
||||
import { CustomizationSectionView } from "./extensions-view";
|
||||
import {
|
||||
CustomizationSectionView,
|
||||
invalidateExtensionInventoryCache,
|
||||
} from "./extensions-view";
|
||||
import { McpServersContent } from "./mcp-view";
|
||||
|
||||
/**
|
||||
@@ -75,6 +78,15 @@ export function CustomizeView({
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [refreshCounts]);
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
desktopClient.subscribe("settings.changed", () => {
|
||||
invalidateExtensionInventoryCache();
|
||||
void refreshCounts();
|
||||
}),
|
||||
[refreshCounts],
|
||||
);
|
||||
|
||||
const handleInventoryChanged = useCallback(() => {
|
||||
void refreshCounts();
|
||||
}, [refreshCounts]);
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CustomizationSectionView,
|
||||
invalidateExtensionInventoryCache,
|
||||
} from "./extensions-view";
|
||||
|
||||
const { fetchMarketplaceCatalog, invoke } = vi.hoisted(() => ({
|
||||
fetchMarketplaceCatalog: vi.fn(),
|
||||
invoke: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/desktop-client", () => ({
|
||||
desktopClient: { invoke },
|
||||
openExternalUrl: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/marketplace", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@/lib/marketplace")>()),
|
||||
fetchMarketplaceCatalog,
|
||||
}));
|
||||
|
||||
const EMPTY_CATALOG = {
|
||||
version: 1,
|
||||
counts: { total: 0, plugins: 0, skills: 0, mcps: 0 },
|
||||
tags: [],
|
||||
entries: [],
|
||||
};
|
||||
|
||||
const AGENT_PLUGIN = {
|
||||
id: "agent-plugin:/Users/test/.agents/plugins/example",
|
||||
name: "agent-plugins-example",
|
||||
path: "/Users/test/.agents/plugins/example",
|
||||
enabled: true,
|
||||
source: "agent-plugin",
|
||||
toggleable: true,
|
||||
agentPlugin: true,
|
||||
contributions: {
|
||||
inspectionStatus: "available",
|
||||
capabilities: ["skills"],
|
||||
tools: [],
|
||||
skills: ["example-skill"],
|
||||
rules: [],
|
||||
hooks: [],
|
||||
commands: [],
|
||||
mcpServers: [],
|
||||
providers: [],
|
||||
},
|
||||
};
|
||||
|
||||
const AGENT_PLUGIN_SKILL = {
|
||||
name: "example-skill",
|
||||
description: "A skill contributed by an Agent Plugin.",
|
||||
instructions: "",
|
||||
path: "/Users/test/.agents/plugins/example/skills/example-skill/SKILL.md",
|
||||
agentPlugin: true,
|
||||
pluginName: "agent-plugins-example",
|
||||
};
|
||||
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
|
||||
invalidateExtensionInventoryCache();
|
||||
fetchMarketplaceCatalog.mockReset();
|
||||
fetchMarketplaceCatalog.mockResolvedValue(EMPTY_CATALOG);
|
||||
invoke.mockReset();
|
||||
invoke.mockImplementation((command: string) => {
|
||||
if (command === "list_marketplace_installed_entries") {
|
||||
return Promise.resolve({ installedKeys: [] });
|
||||
}
|
||||
if (command === "list_user_instruction_configs") {
|
||||
return Promise.resolve({
|
||||
workspaceRoot: "/workspace",
|
||||
rules: [],
|
||||
workflows: [],
|
||||
skills: [AGENT_PLUGIN_SKILL],
|
||||
agents: [],
|
||||
plugins: [AGENT_PLUGIN],
|
||||
tools: [],
|
||||
hooks: [],
|
||||
mcp: { servers: [] },
|
||||
warnings: [],
|
||||
});
|
||||
}
|
||||
return Promise.reject(new Error(`Unexpected command: ${command}`));
|
||||
});
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
container.remove();
|
||||
invalidateExtensionInventoryCache();
|
||||
});
|
||||
|
||||
describe("CustomizationSectionView Agent Plugin inventory", () => {
|
||||
it("shows Hub-managed Agent Plugins in the installed Plugins view", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<CustomizationSectionView
|
||||
catalogPrimitive="plugin"
|
||||
chrome="embedded"
|
||||
marketplaceVariant="installed"
|
||||
section="Plugins"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("agent-plugins-example");
|
||||
expect(container.textContent).toContain("Agent Plugin");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Agent Plugin skills in the installed Skills view", async () => {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<CustomizationSectionView
|
||||
catalogPrimitive="skill"
|
||||
chrome="embedded"
|
||||
marketplaceVariant="installed"
|
||||
section="Skills"
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(container.textContent).toContain("example-skill");
|
||||
expect(container.textContent).toContain("Agent Plugin");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -84,6 +84,8 @@ type SkillItem = {
|
||||
description?: string;
|
||||
instructions: string;
|
||||
path: string;
|
||||
agentPlugin?: boolean;
|
||||
pluginName?: string;
|
||||
};
|
||||
|
||||
type CommandItem = {
|
||||
@@ -94,6 +96,8 @@ type CommandItem = {
|
||||
instructions: string;
|
||||
path: string;
|
||||
scope: ItemScope;
|
||||
agentPlugin?: boolean;
|
||||
pluginName?: string;
|
||||
};
|
||||
|
||||
type ItemScope = "Global" | "Project";
|
||||
@@ -104,9 +108,15 @@ type AgentItem = {
|
||||
};
|
||||
|
||||
type PluginItem = {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
enabled: boolean;
|
||||
source?: string;
|
||||
toggleable?: boolean;
|
||||
agentPlugin?: boolean;
|
||||
description?: string;
|
||||
loadError?: string;
|
||||
contributions?: PluginContributions;
|
||||
};
|
||||
|
||||
@@ -774,6 +784,8 @@ export function CustomizationSectionView({
|
||||
instructions: skill.instructions,
|
||||
path: skill.path,
|
||||
scope: getPathScope(skill.path, workspaceRoot),
|
||||
agentPlugin: skill.agentPlugin,
|
||||
pluginName: skill.pluginName,
|
||||
}));
|
||||
return [...workflowItems, ...skillItems].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
@@ -825,9 +837,11 @@ export function CustomizationSectionView({
|
||||
for (const plugin of plugins) {
|
||||
const normalized = normalizePath(plugin.path);
|
||||
if (
|
||||
normalizedRoot &&
|
||||
normalized.startsWith(`${normalizedRoot}/`) &&
|
||||
normalized.includes("/.cline/plugins")
|
||||
plugin.source === "workspace-plugin" ||
|
||||
(normalizedRoot &&
|
||||
normalized.startsWith(`${normalizedRoot}/`) &&
|
||||
(normalized.includes("/.cline/plugins") ||
|
||||
normalized.includes("/.agents/plugins")))
|
||||
) {
|
||||
project.push(plugin);
|
||||
} else {
|
||||
@@ -872,6 +886,14 @@ export function CustomizationSectionView({
|
||||
],
|
||||
[globalPlugins, projectPlugins],
|
||||
);
|
||||
const clinePlugins = useMemo(
|
||||
() => scopedPlugins.filter(({ plugin }) => plugin.agentPlugin !== true),
|
||||
[scopedPlugins],
|
||||
);
|
||||
const agentPlugins = useMemo(
|
||||
() => scopedPlugins.filter(({ plugin }) => plugin.agentPlugin === true),
|
||||
[scopedPlugins],
|
||||
);
|
||||
|
||||
const scopedRules = useMemo(
|
||||
() => [
|
||||
@@ -976,15 +998,17 @@ export function CustomizationSectionView({
|
||||
key={key}
|
||||
className="relative grid min-w-0 gap-2 rounded-lg border bg-card p-4"
|
||||
>
|
||||
<div className="absolute top-4 right-4">
|
||||
{renderLocalActionButton({
|
||||
key,
|
||||
type: item.type,
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
})}
|
||||
</div>
|
||||
{item.agentPlugin !== true ? (
|
||||
<div className="absolute top-4 right-4">
|
||||
{renderLocalActionButton({
|
||||
key,
|
||||
type: item.type,
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex min-w-0 items-center gap-2 pr-28">
|
||||
{item.type === "workflow" ? (
|
||||
<Play className="h-4 w-4 shrink-0 text-primary" />
|
||||
@@ -998,6 +1022,11 @@ export function CustomizationSectionView({
|
||||
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||
{item.type}
|
||||
</Badge>
|
||||
{item.agentPlugin === true ? (
|
||||
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||
Agent Plugin
|
||||
</Badge>
|
||||
) : null}
|
||||
{context?.matchedEntries?.length ? (
|
||||
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||
Marketplace
|
||||
@@ -1057,6 +1086,9 @@ export function CustomizationSectionView({
|
||||
{plugin.name}
|
||||
</h3>
|
||||
<ScopeBadge scope={scope} />
|
||||
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||
{plugin.agentPlugin === true ? "Agent Plugin" : "Cline Plugin"}
|
||||
</Badge>
|
||||
{context?.matchedEntries?.length ? (
|
||||
<Badge variant="outline" className="shrink-0 text-muted-foreground">
|
||||
Marketplace
|
||||
@@ -1071,18 +1103,33 @@ export function CustomizationSectionView({
|
||||
void setPluginEnabled(plugin);
|
||||
}}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
disabled={togglingPluginPaths.has(plugin.path)}
|
||||
disabled={
|
||||
plugin.toggleable === false ||
|
||||
togglingPluginPaths.has(plugin.path)
|
||||
}
|
||||
aria-label={`Toggle ${plugin.name}`}
|
||||
/>
|
||||
{renderPluginMenu({
|
||||
key,
|
||||
type: "plugin",
|
||||
id: plugin.name,
|
||||
name: plugin.name,
|
||||
path: plugin.path,
|
||||
})}
|
||||
{plugin.agentPlugin !== true
|
||||
? renderPluginMenu({
|
||||
key,
|
||||
type: "plugin",
|
||||
id: plugin.name,
|
||||
name: plugin.name,
|
||||
path: plugin.path,
|
||||
})
|
||||
: null}
|
||||
</summary>
|
||||
<div className="mt-3">
|
||||
{plugin.description?.trim() ? (
|
||||
<p className="mb-2 whitespace-pre-line text-xs text-muted-foreground">
|
||||
{plugin.description}
|
||||
</p>
|
||||
) : null}
|
||||
{plugin.loadError?.trim() ? (
|
||||
<p className="mb-2 whitespace-pre-line text-xs text-destructive">
|
||||
{plugin.loadError}
|
||||
</p>
|
||||
) : null}
|
||||
{plugin.contributions?.inspectionStatus === "disabled" ? (
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Enable this plugin to inspect its dynamic contributions.
|
||||
@@ -1520,68 +1567,19 @@ export function CustomizationSectionView({
|
||||
{activeTab === "Plugins" && !catalogPrimitive && (
|
||||
<div>
|
||||
<p className="mb-6 text-sm leading-relaxed text-muted-foreground">
|
||||
Plugins discovered from workspace and global plugin directories.
|
||||
Cline and portable Agent Plugins discovered by the shared Hub.
|
||||
Changes apply when a session is rebuilt or started.
|
||||
</p>
|
||||
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Global Plugins
|
||||
Cline Plugins ({clinePlugins.length})
|
||||
</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
{globalPlugins.map((plugin) => (
|
||||
<div
|
||||
key={plugin.path}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{plugin.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={plugin.enabled}
|
||||
onCheckedChange={() => {
|
||||
void setPluginEnabled(plugin);
|
||||
}}
|
||||
disabled={togglingPluginPaths.has(plugin.path)}
|
||||
aria-label={`Toggle ${plugin.name}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 ml-7 flex max-h-56 flex-col gap-2 overflow-y-auto">
|
||||
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
|
||||
(tool) => {
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center justify-between gap-4 rounded-md border border-border/70 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{tool.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tool.description?.trim() ||
|
||||
"No description available."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{(pluginToolsByPluginKey.get(plugin.path)?.length ?? 0) ===
|
||||
0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No plugin tools found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{globalPlugins.length === 0 && (
|
||||
{clinePlugins.map((plugin) => renderPluginCard(plugin))}
|
||||
{clinePlugins.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
No global plugins found.
|
||||
No Cline Plugins found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -1589,63 +1587,13 @@ export function CustomizationSectionView({
|
||||
|
||||
<div>
|
||||
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Project Plugins
|
||||
Agent Plugins ({agentPlugins.length})
|
||||
</h3>
|
||||
<div className="flex flex-col gap-3">
|
||||
{projectPlugins.map((plugin) => (
|
||||
<div
|
||||
key={plugin.path}
|
||||
className="rounded-lg border border-border px-5 py-4"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="min-w-0 flex-1 text-sm font-semibold text-foreground">
|
||||
{plugin.name}
|
||||
</h3>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{plugin.enabled ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
<Switch
|
||||
checked={plugin.enabled}
|
||||
onCheckedChange={() => {
|
||||
void setPluginEnabled(plugin);
|
||||
}}
|
||||
disabled={togglingPluginPaths.has(plugin.path)}
|
||||
aria-label={`Toggle ${plugin.name}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 ml-7 flex max-h-56 flex-col gap-2 overflow-y-auto">
|
||||
{(pluginToolsByPluginKey.get(plugin.path) ?? []).map(
|
||||
(tool) => {
|
||||
return (
|
||||
<div
|
||||
key={tool.id}
|
||||
className="flex items-center justify-between gap-4 rounded-md border border-border/70 px-3 py-2"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-medium text-foreground">
|
||||
{tool.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tool.description?.trim() ||
|
||||
"No description available."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
)}
|
||||
{(pluginToolsByPluginKey.get(plugin.path)?.length ?? 0) ===
|
||||
0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
No plugin tools found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{projectPlugins.length === 0 && (
|
||||
{agentPlugins.map((plugin) => renderPluginCard(plugin))}
|
||||
{agentPlugins.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed border-border px-4 py-3 text-sm text-muted-foreground">
|
||||
No project plugins found.
|
||||
No Agent Plugins found.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useOAuthUserCode } from "@/hooks/use-oauth-user-code";
|
||||
import { openExternalUrl } from "@/lib/desktop-client";
|
||||
import {
|
||||
getProviderAuthKind,
|
||||
@@ -539,6 +540,7 @@ export function ProviderDetailContent({
|
||||
onDisconnect?: () => void;
|
||||
variant?: "page" | "panel";
|
||||
}) {
|
||||
const deviceUserCode = useOAuthUserCode(oauthLoginPending);
|
||||
const [shownSecrets, setShownSecrets] = useState<Record<string, boolean>>({});
|
||||
const [localConfigValues, setLocalConfigValues] = useState<
|
||||
Record<string, ProviderConfigFieldPrimitive>
|
||||
@@ -821,6 +823,14 @@ export function ProviderDetailContent({
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{oauthLoginPending && deviceUserCode ? (
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
Confirm this code in your browser:{" "}
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
{deviceUserCode}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
{apiKeyField ? (
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { desktopClient } from "@/lib/desktop-client";
|
||||
|
||||
/**
|
||||
* Device sign-in confirmation code pushed by the sidecar while a provider
|
||||
* OAuth login is pending, so the user can match it against the code shown in
|
||||
* their browser. Cleared whenever the pending flow ends.
|
||||
*/
|
||||
export function useOAuthUserCode(pending: boolean): string | null {
|
||||
const [userCode, setUserCode] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (!pending) {
|
||||
setUserCode(null);
|
||||
return;
|
||||
}
|
||||
return desktopClient.subscribe("provider_oauth_user_code", (payload) => {
|
||||
const code = (payload as { userCode?: unknown } | null)?.userCode;
|
||||
if (typeof code === "string" && code) {
|
||||
setUserCode(code);
|
||||
}
|
||||
});
|
||||
}, [pending]);
|
||||
return userCode;
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "claude-dev",
|
||||
"displayName": "Cline",
|
||||
"description": "Autonomous coding agent right in your IDE, capable of creating/editing files, running commands, using the browser, and more with your permission every step of the way.",
|
||||
"version": "4.1.16",
|
||||
"version": "4.1.17",
|
||||
"icon": "assets/icons/icon.png",
|
||||
"engines": {
|
||||
"vscode": "^1.101.0"
|
||||
|
||||
@@ -126,6 +126,8 @@ async function generateVscodeProtobusServers(protobusServices) {
|
||||
const imports = [];
|
||||
const servers = [];
|
||||
const serviceMap = [];
|
||||
const decoders = [];
|
||||
const decoderMap = [];
|
||||
const streamingMethods = [];
|
||||
for (const [serviceName, def] of Object.entries(protobusServices)) {
|
||||
const domain = getDomainName(serviceName);
|
||||
@@ -134,23 +136,34 @@ async function generateVscodeProtobusServers(protobusServices) {
|
||||
servers.push(
|
||||
`const ${serviceName}Handlers: serviceTypes.${serviceName}Handlers = {`,
|
||||
);
|
||||
decoders.push(
|
||||
`const ${serviceName}RequestDecoders: Record<string, (json: unknown) => unknown> = {`,
|
||||
);
|
||||
for (const [rpcName, rpc] of Object.entries(def.service)) {
|
||||
imports.push(
|
||||
`import { ${rpcName} } from "@core/controller/${dir}/${rpcName}"`,
|
||||
);
|
||||
servers.push(` ${rpcName}: ${rpcName},`);
|
||||
decoders.push(
|
||||
` ${rpcName}: ${getFqn(rpc.requestType.type.name)}.fromJSON,`,
|
||||
);
|
||||
if (rpc.responseStream) {
|
||||
streamingMethods.push(` "cline.${serviceName}.${rpcName}",`);
|
||||
}
|
||||
}
|
||||
servers.push(`} \n`);
|
||||
decoders.push(`} \n`);
|
||||
serviceMap.push(` "cline.${serviceName}": ${serviceName}Handlers,`);
|
||||
decoderMap.push(
|
||||
` "cline.${serviceName}": ${serviceName}RequestDecoders,`,
|
||||
);
|
||||
imports.push("");
|
||||
}
|
||||
|
||||
// Create output file
|
||||
const output = `// GENERATED CODE -- DO NOT EDIT!
|
||||
// Generated by ${SCRIPT_NAME}
|
||||
import * as proto from "@shared/proto/index"
|
||||
import * as serviceTypes from "@generated/hosts/vscode/protobus-service-types"
|
||||
|
||||
${imports.join("\n")}
|
||||
@@ -159,6 +172,18 @@ export const serviceHandlers: Record<string, any> = {
|
||||
${serviceMap.join("\n")}
|
||||
}
|
||||
|
||||
${decoders.join("\n")}
|
||||
/**
|
||||
* Per-method request decoders (proto3 JSON -> ts-proto message) for hosts whose
|
||||
* transport delivers protobus requests as JSON. Proto3 JSON spells enums as
|
||||
* string names and omits default-valued fields (empty repeated fields included),
|
||||
* while the handlers assume ts-proto message shapes: numeric enums, repeated
|
||||
* fields always present. fromJSON restores those invariants.
|
||||
*/
|
||||
export const serviceRequestDecoders: Record<string, Record<string, (json: unknown) => unknown>> = {
|
||||
${decoderMap.join("\n")}
|
||||
}
|
||||
|
||||
/** Fully-qualified response-streaming methods, derived from the proto descriptors. */
|
||||
export const responseStreamingMethods: ReadonlySet<string> = new Set([
|
||||
${streamingMethods.join("\n")}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { serviceHandlers, serviceRequestDecoders } from "@generated/hosts/vscode/protobus-services"
|
||||
import { ModelsApiConfiguration, OpenRouterModelInfo, UpdateApiConfigurationRequest } from "@shared/proto/cline/models"
|
||||
import { PlanActMode, TogglePlanActModeRequest } from "@shared/proto/cline/state"
|
||||
import { fromProtobufModelInfo } from "@shared/proto-conversions/models/typeConversion"
|
||||
import { decodeCoreConnectionRequestMessage } from "../core-connection-dispatcher"
|
||||
|
||||
/**
|
||||
* The core connection delivers protobus requests as proto3 JSON produced by the
|
||||
* webview's ts-proto toJSON encoders. These tests round-trip real toJSON output
|
||||
* through the dispatcher's decode step to pin the invariants the handlers rely
|
||||
* on: repeated fields are arrays even when the wire omitted them, and enums are
|
||||
* numeric even though the wire spells them as names.
|
||||
*/
|
||||
describe("core connection request decoding", () => {
|
||||
it("restores empty repeated fields that proto3 JSON omits", () => {
|
||||
// One getter for both stages so the pre- and post-decode assertions
|
||||
// can't dereference different paths.
|
||||
const getTiers = (obj: any) => obj?.apiConfiguration?.planModeOpenRouterModelInfo?.tiers
|
||||
const request = UpdateApiConfigurationRequest.create({
|
||||
apiConfiguration: ModelsApiConfiguration.create({
|
||||
planModeOpenRouterModelInfo: OpenRouterModelInfo.create({ name: "some-model" }),
|
||||
}),
|
||||
})
|
||||
// The wire format really does drop the empty `tiers` array; without that
|
||||
// this test would not be exercising anything.
|
||||
const wireJson = JSON.parse(JSON.stringify(UpdateApiConfigurationRequest.toJSON(request)))
|
||||
expect(getTiers(wireJson)).toBeUndefined()
|
||||
|
||||
const decoded = decodeCoreConnectionRequestMessage(
|
||||
"cline.ModelsService",
|
||||
"updateApiConfigurationProto",
|
||||
wireJson,
|
||||
) as UpdateApiConfigurationRequest
|
||||
|
||||
expect(getTiers(decoded)).toEqual([])
|
||||
// The original failure: fromProtobufModelInfo reads `.tiers.length` and
|
||||
// threw a TypeError on the undecoded wire JSON.
|
||||
const modelInfo = decoded.apiConfiguration?.planModeOpenRouterModelInfo
|
||||
if (!modelInfo) {
|
||||
throw new Error("decoded request lost planModeOpenRouterModelInfo")
|
||||
}
|
||||
expect(() => fromProtobufModelInfo(modelInfo)).not.toThrow()
|
||||
})
|
||||
|
||||
it("converts enum names back to numeric enum values", () => {
|
||||
const wireJson = JSON.parse(
|
||||
JSON.stringify(TogglePlanActModeRequest.toJSON(TogglePlanActModeRequest.create({ mode: PlanActMode.ACT }))),
|
||||
)
|
||||
expect(wireJson.mode).toBe("ACT")
|
||||
|
||||
const decoded = decodeCoreConnectionRequestMessage(
|
||||
"cline.StateService",
|
||||
"togglePlanActModeProto",
|
||||
wireJson,
|
||||
) as TogglePlanActModeRequest
|
||||
expect(decoded.mode).toBe(PlanActMode.ACT)
|
||||
})
|
||||
|
||||
it("restores an enum's zero value when proto3 JSON omits the field", () => {
|
||||
const wireJson = JSON.parse(
|
||||
JSON.stringify(TogglePlanActModeRequest.toJSON(TogglePlanActModeRequest.create({ mode: PlanActMode.PLAN }))),
|
||||
)
|
||||
expect(wireJson.mode).toBeUndefined()
|
||||
|
||||
const decoded = decodeCoreConnectionRequestMessage(
|
||||
"cline.StateService",
|
||||
"togglePlanActModeProto",
|
||||
wireJson,
|
||||
) as TogglePlanActModeRequest
|
||||
expect(decoded.mode).toBe(PlanActMode.PLAN)
|
||||
})
|
||||
|
||||
it("passes unknown services and methods through untouched", () => {
|
||||
const message = { anything: "goes" }
|
||||
expect(decodeCoreConnectionRequestMessage("cline.NoSuchService", "noSuchMethod", message)).toBe(message)
|
||||
expect(decodeCoreConnectionRequestMessage("cline.ModelsService", "noSuchMethod", message)).toBe(message)
|
||||
})
|
||||
|
||||
it("has a decoder for every registered protobus handler", () => {
|
||||
for (const [serviceName, handlers] of Object.entries(serviceHandlers)) {
|
||||
const decoders = serviceRequestDecoders[serviceName]
|
||||
expect(decoders).toBeDefined()
|
||||
for (const methodName of Object.keys(handlers)) {
|
||||
expect(typeof decoders[methodName]).toBe("function")
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,27 @@
|
||||
import type { Controller } from "@core/controller";
|
||||
import { handleGrpcRequest } from "@core/controller/grpc-handler";
|
||||
import { responseStreamingMethods } from "@generated/hosts/vscode/protobus-services";
|
||||
import type { Controller } from "@core/controller"
|
||||
import { handleGrpcRequest } from "@core/controller/grpc-handler"
|
||||
import { responseStreamingMethods, serviceRequestDecoders } from "@generated/hosts/vscode/protobus-services"
|
||||
import {
|
||||
type CoreConnectionMessageWriter,
|
||||
dispatchCoreConnectionRequest as dispatchProtocolRequest,
|
||||
} from "./core-connection-protocol";
|
||||
} from "./core-connection-protocol"
|
||||
|
||||
/**
|
||||
* Requests arrive over the core connection as proto3 JSON (the webview encodes
|
||||
* them with ts-proto's toJSON): enums are string names, and default-valued
|
||||
* fields — empty repeated fields included — are omitted entirely. The protobus
|
||||
* handlers assume ts-proto message shapes (numeric enums, repeated fields
|
||||
* always present), so decode with the generated fromJSON before dispatch.
|
||||
* The in-process VS Code webview does not need this: it posts structured-cloned
|
||||
* ts-proto objects that never pass through JSON.
|
||||
*
|
||||
* Unknown methods fall through undecoded; handleGrpcRequest rejects them with
|
||||
* its own unknown-rpc error.
|
||||
*/
|
||||
export function decodeCoreConnectionRequestMessage(service: string, method: string, message: unknown): unknown {
|
||||
const decoder = serviceRequestDecoders[service]?.[method]
|
||||
return decoder ? decoder(message) : message
|
||||
}
|
||||
|
||||
export async function dispatchCoreConnectionRequest(
|
||||
controller: Controller,
|
||||
@@ -16,6 +33,11 @@ export async function dispatchCoreConnectionRequest(
|
||||
request,
|
||||
(qualifiedMethod) => responseStreamingMethods.has(qualifiedMethod),
|
||||
(postMessage, grpcRequest) =>
|
||||
handleGrpcRequest(controller, postMessage, grpcRequest),
|
||||
);
|
||||
// A decode failure throws out of the dispatch; the connection layer
|
||||
// reports it as this request's error response.
|
||||
handleGrpcRequest(controller, postMessage, {
|
||||
...grpcRequest,
|
||||
message: decodeCoreConnectionRequestMessage(grpcRequest.service, grpcRequest.method, grpcRequest.message),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -553,8 +553,15 @@ export const OpenAICompatibleProvider = ({
|
||||
|
||||
{modelConfigurationSelected && (
|
||||
<>
|
||||
{/* Render the in-flight pending override, not the stale resolved
|
||||
value: fast-foundation checkboxes re-emit programmatic checked
|
||||
re-syncs as change events, so re-applying the stale value during
|
||||
the commit round-trip would commit it right back (#13694). */}
|
||||
<VSCodeCheckbox
|
||||
checked={!!openAiModelInfo?.supportsImages}
|
||||
checked={
|
||||
selectedModelOverridesRef.current[currentMode].overrides.supportsVision ??
|
||||
!!openAiModelInfo?.supportsImages
|
||||
}
|
||||
onChange={(e: any) => updateModelOverride("supportsVision", e.target.checked === true)}>
|
||||
Supports Images
|
||||
</VSCodeCheckbox>
|
||||
|
||||
@@ -10,23 +10,242 @@ description: "Control which files and directories Cline can access in your proje
|
||||
`.clineignore` filters what Cline loads automatically, but it is not a security or access-control boundary — ignored files can still be read via explicit `@` mentions or shell commands. We're moving away from it as a supported feature.
|
||||
</Warning>
|
||||
|
||||
## Restricting File Access with a Plugin
|
||||
## Enforcing .clineignore with a Hook
|
||||
|
||||
While `.clineignore` is being phased out, you can get a stronger, enforced restriction on **file reads and edits** with the [Block Ignored File Access](https://docs.cline.bot/sdk/plugin-examples#block-ignored-file-access) Cline plugin example. Unlike `.clineignore`, it uses a `beforeTool` runtime hook to actively **block** tool calls: when `read_files`, `editor`, or `apply_patch` targets a file ignored by your workspace's `.gitignore` file.
|
||||
While `.clineignore` is being phased out as a built-in feature, you can keep using the same file — and get a **stronger, enforced** restriction than the original ever provided — with a `PreToolUse` hook. The original `.clineignore` only filtered automatic context loading; the hook script below actively **blocks** the tool call whenever a file read (`read_files`), edit (`editor`, `apply_patch`), or shell command (`run_commands`) targets a file matching a `.clineignore` pattern.
|
||||
|
||||
Install it into your project with:
|
||||
### Install in the VS Code extension
|
||||
|
||||
Hooks in the extension live in `.clinerules/hooks/` (workspace) or `~/Documents/Cline/Hooks/` (global), must be named exactly after their event with no file extension, and need a shebang and the executable bit:
|
||||
|
||||
```bash
|
||||
cline plugin install https://github.com/cline/cline/blob/main/sdk/examples/plugins/gitignore-read-files-guard.ts --cwd .
|
||||
mkdir -p .clinerules/hooks
|
||||
curl -o .clinerules/hooks/PreToolUse https://raw.githubusercontent.com/cline/cline/main/sdk/examples/hooks/PreToolUse_ClineignoreGuard.sh
|
||||
chmod +x .clinerules/hooks/PreToolUse
|
||||
```
|
||||
|
||||
<Note>
|
||||
This plugin covers only part of what `.clineignore` did:
|
||||
- It **guards file reads/edits** (`read_files`, `editor`, `apply_patch`) — it does **not** filter `search_files`/`list_files` results or guard shell commands, so it is not a full context-reduction tool.
|
||||
- It reads your **`.gitignore`** (not a separate `.clineignore`) and requires a Git repository.
|
||||
- Plugins run on the Cline **SDK, CLI, and Kanban**. Support in the VS Code and JetBrains extensions arrives as they migrate onto the SDK.
|
||||
Then check **Enable Hooks** in Cline's feature settings.
|
||||
|
||||
See [Plugins](/customization/plugins) for installation details and scopes.
|
||||
### Install in the CLI
|
||||
|
||||
The CLI discovers hooks from `.cline/hooks/` in the workspace (or `~/.cline/hooks/` globally, or a custom `--hooks-dir`):
|
||||
|
||||
```bash
|
||||
mkdir -p .cline/hooks
|
||||
curl -o .cline/hooks/PreToolUse.sh https://raw.githubusercontent.com/cline/cline/main/sdk/examples/hooks/PreToolUse_ClineignoreGuard.sh
|
||||
chmod +x .cline/hooks/PreToolUse.sh
|
||||
```
|
||||
|
||||
### List the files to protect
|
||||
|
||||
Add a `.clineignore` file at your workspace root. Patterns use `.gitignore` syntax — directories, globs, and `!` negations all work:
|
||||
|
||||
```text
|
||||
# .clineignore
|
||||
.env
|
||||
.env.*
|
||||
secrets/
|
||||
*.pem
|
||||
```
|
||||
|
||||
When Cline tries to touch a matching file, the hook cancels the tool call before it runs — the file is never accessed, and the current task run stops. In the extension you'll see the `PreToolUse` hook row followed by an **Aborted** status; in the CLI the run ends. Send a follow-up message to continue the conversation.
|
||||
|
||||
The hook records the block reason in its output:
|
||||
|
||||
```json
|
||||
{
|
||||
"cancel": true,
|
||||
"errorMessage": "Blocked read_files: .env matched a .clineignore pattern, so Cline may not access it. Update .clineignore if access should be allowed."
|
||||
}
|
||||
```
|
||||
|
||||
### The hook script
|
||||
|
||||
The script handles both hook payload shapes (the extension's `preToolUse.parameters` and the CLI's `tool_call.input`), evaluates paths with real gitignore semantics via `git check-ignore` scoped to `.clineignore` alone, and also blocks attempts to modify `.clineignore` itself so the agent cannot un-ignore files. It requires `jq` and `git` — though `git` is used purely as a pattern matcher, so your workspace does not need to be a git repository.
|
||||
|
||||
<Accordion title="PreToolUse_ClineignoreGuard.sh (full script)">
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
# Cline Hook: PreToolUse (.clineignore guard)
|
||||
# Blocks file reads, edits, and shell commands whose paths match
|
||||
# gitignore-style patterns listed in <workspace>/.clineignore.
|
||||
#
|
||||
# Works with both file-hook payload shapes:
|
||||
# - VS Code extension hooks (.clinerules/hooks/PreToolUse):
|
||||
# .preToolUse.toolName + .preToolUse.parameters (values JSON-stringified)
|
||||
# - CLI / SDK file hooks (.cline/hooks/PreToolUse.sh):
|
||||
# .tool_call.name + .tool_call.input
|
||||
#
|
||||
# Install (VS Code extension — name must be exactly "PreToolUse"):
|
||||
# mkdir -p .clinerules/hooks
|
||||
# cp PreToolUse_ClineignoreGuard.sh .clinerules/hooks/PreToolUse
|
||||
# chmod +x .clinerules/hooks/PreToolUse
|
||||
# ...and check "Enable Hooks" in Cline's feature settings.
|
||||
#
|
||||
# Install (CLI):
|
||||
# mkdir -p .cline/hooks
|
||||
# cp PreToolUse_ClineignoreGuard.sh .cline/hooks/PreToolUse.sh
|
||||
# chmod +x .cline/hooks/PreToolUse.sh
|
||||
#
|
||||
# Then list the files to protect in a .clineignore file at your workspace
|
||||
# root using .gitignore syntax (directories, globs, and ! negations work).
|
||||
#
|
||||
# Requires: jq, git (used only as a pattern matcher; the workspace does not
|
||||
# need to be a git repository).
|
||||
|
||||
set -eu
|
||||
|
||||
input=$(cat)
|
||||
|
||||
tool=$(echo "$input" | jq -r '.tool_call.name // .preToolUse.toolName // ""')
|
||||
|
||||
# Only guard tools that read or write files or run shell commands.
|
||||
case "$tool" in
|
||||
read_files|editor|apply_patch|run_commands) ;;
|
||||
*) echo '{"cancel": false}'; exit 0 ;;
|
||||
esac
|
||||
|
||||
root=$(echo "$input" | jq -r '.workspaceRoots[0] // empty')
|
||||
[ -n "$root" ] || root="$PWD"
|
||||
ignore_file="$root/.clineignore"
|
||||
if [ ! -f "$ignore_file" ]; then
|
||||
echo '{"cancel": false}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Normalize the tool input: prefer the rich CLI shape, fall back to the
|
||||
# extension's stringified parameters (dejson re-parses those values).
|
||||
jq_prelude='
|
||||
def dejson: if type == "string" then (fromjson? // .) else . end;
|
||||
((.tool_call.input // .preToolUse.parameters // {}) | dejson) as $in |'
|
||||
|
||||
# Collect every candidate path from the tool input.
|
||||
case "$tool" in
|
||||
apply_patch)
|
||||
# apply_patch carries its paths inside the patch body headers.
|
||||
paths=$(echo "$input" | jq -r "$jq_prelude"'
|
||||
if ($in | type) == "string" then $in
|
||||
elif ($in | type) == "object" then ($in.input // "" | dejson)
|
||||
else "" end | strings' \
|
||||
| sed -n \
|
||||
-e 's/^\*\*\* Add File: //p' \
|
||||
-e 's/^\*\*\* Update File: //p' \
|
||||
-e 's/^\*\*\* Delete File: //p' \
|
||||
-e 's/^\*\*\* Move to: //p')
|
||||
;;
|
||||
run_commands)
|
||||
# Conservative shell guard: treat every token of every command as a
|
||||
# candidate path. Catches straightforward access like `cat .env`
|
||||
# without attempting full shell parsing.
|
||||
paths=$(echo "$input" | jq -r "$jq_prelude"'
|
||||
(if ($in | type) == "string" then [$in]
|
||||
elif ($in | type) == "array" then $in
|
||||
elif ($in | type) == "object" then
|
||||
[($in.commands // $in.command // $in.cmd // empty) | dejson] | flatten
|
||||
else [] end)
|
||||
| .[]
|
||||
| if type == "object"
|
||||
then ((.command // empty), ((.args // []) | dejson | .[]?))
|
||||
else . end
|
||||
| strings' \
|
||||
| tr -s '[:space:];|&()<>' '\n' \
|
||||
| sed -e "s/^[\"']*//" -e "s/[\"']*\$//" \
|
||||
| grep -v '^-' | grep -v '^$' || true)
|
||||
;;
|
||||
*)
|
||||
# read_files / editor accept a few input shapes:
|
||||
# {files: [{path}]}, {path}, plain strings, string arrays, aliases...
|
||||
paths=$(echo "$input" | jq -r "$jq_prelude"'
|
||||
(if ($in | type) == "string" then [$in]
|
||||
elif ($in | type) == "array" then $in
|
||||
elif ($in | type) == "object" then
|
||||
[$in.path?, $in.file_path?, $in.filePath?]
|
||||
+ (($in.files // []) | dejson | if type == "array" then . else [.] end)
|
||||
+ (($in.paths // []) | dejson | if type == "array" then . else [.] end)
|
||||
+ (($in.file_paths // []) | dejson | if type == "array" then . else [.] end)
|
||||
else [] end)
|
||||
| .[]
|
||||
| dejson
|
||||
| if type == "object" then (.path // .file_path // .filePath // empty) else . end
|
||||
| strings')
|
||||
;;
|
||||
esac
|
||||
|
||||
# Lexically collapse ".", "..", and empty segments of an absolute path, so
|
||||
# noncanonical forms like ./.clineignore or secrets/../.env cannot slip
|
||||
# past the checks below.
|
||||
normalize_abs() {
|
||||
local out="" seg rest="$1/"
|
||||
while [ -n "$rest" ]; do
|
||||
seg="${rest%%/*}"
|
||||
rest="${rest#*/}"
|
||||
case "$seg" in
|
||||
""|".") ;;
|
||||
"..") out="${out%/*}" ;;
|
||||
*) out="$out/$seg" ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' "${out:-/}"
|
||||
}
|
||||
|
||||
# Make paths canonical and workspace-relative. Paths that resolve outside
|
||||
# the workspace are not covered by .clineignore, so they pass through.
|
||||
root=$(normalize_abs "$root")
|
||||
rel_paths=""
|
||||
while IFS= read -r p; do
|
||||
[ -n "$p" ] || continue
|
||||
case "$p" in
|
||||
/*) ;;
|
||||
*) p="$root/$p" ;;
|
||||
esac
|
||||
p=$(normalize_abs "$p")
|
||||
case "$p" in
|
||||
"$root"/*) rel_paths+="${p#"$root"/}"$'\n' ;;
|
||||
esac
|
||||
done <<< "$paths"
|
||||
|
||||
if [ -z "$rel_paths" ]; then
|
||||
echo '{"cancel": false}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The guard is only as strong as the ignore file itself: protect
|
||||
# .clineignore from modification so the agent cannot un-ignore files.
|
||||
if [ "$tool" != "read_files" ] && printf '%s' "$rel_paths" | grep -qx '\.clineignore'; then
|
||||
jq -n --arg tool "$tool" \
|
||||
'{cancel: true, errorMessage: "Blocked \($tool): modifying .clineignore is not allowed. Update it yourself if a file should be un-ignored."}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Evaluate the paths against .clineignore with real gitignore semantics via
|
||||
# `git check-ignore`. Running it inside an empty scratch repo scopes the
|
||||
# check to .clineignore alone -- the workspace's own .gitignore files are
|
||||
# never consulted, and this works even outside a git repository.
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
git init -q "$scratch"
|
||||
|
||||
blocked=$(printf '%s' "$rel_paths" \
|
||||
| git -C "$scratch" -c core.excludesFile="$ignore_file" check-ignore --stdin --no-index 2>/dev/null \
|
||||
| paste -sd, -)
|
||||
|
||||
if [ -n "$blocked" ]; then
|
||||
jq -n --arg tool "$tool" --arg files "$blocked" \
|
||||
'{cancel: true, errorMessage: "Blocked \($tool): \($files) matched a .clineignore pattern, so Cline may not access it. Update .clineignore if access should be allowed."}'
|
||||
else
|
||||
echo '{"cancel": false}'
|
||||
fi
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
The source lives at [`sdk/examples/hooks/PreToolUse_ClineignoreGuard.sh`](https://github.com/cline/cline/blob/main/sdk/examples/hooks/PreToolUse_ClineignoreGuard.sh).
|
||||
|
||||
<Note>
|
||||
This hook covers only part of what `.clineignore` did — and enforces more than it ever did:
|
||||
- It **blocks file reads, edits, and shell commands** — it does **not** filter search or file-listing results, so it is an access-control boundary rather than a context-reduction tool (the original `.clineignore` was the reverse).
|
||||
- A cancelled tool call stops the current task run with the reason shown; send a follow-up message to continue the conversation.
|
||||
- The shell guard is a conservative token check, not a full shell parser: it catches straightforward access like `cat .env`, but a sufficiently creative command could still slip through. Paths are canonicalized lexically, but symlinks are not resolved — a pre-existing symlink aliasing an ignored file is not caught, so add such aliases to `.clineignore` too.
|
||||
- In the CLI, hooks are disabled in `--yolo` mode; use `--act` or `--plan`.
|
||||
</Note>
|
||||
|
||||
---
|
||||
|
||||
@@ -179,6 +179,12 @@
|
||||
"pages": [
|
||||
"troubleshooting/networking-and-proxies"
|
||||
]
|
||||
},
|
||||
{
|
||||
"group": "Resources",
|
||||
"pages": [
|
||||
"resources/deprecations"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
title: Deprecations
|
||||
sidebarTitle: Deprecations
|
||||
description: Track Cline features that are being phased out and what to use instead.
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
As Cline evolves, we regularly revisit existing features to make sure they still fit how the product works today. This can include changes introduced by our newer agent architecture, features that are no longer providing enough value, or functionality where a different approach is more appropriate.
|
||||
|
||||
This page tracks deprecated Cline features and recommended alternatives where available.
|
||||
|
||||
| Feature | Primary surface | Status | Recommended alternative |
|
||||
| --------------- | --------------- | ---------------- | -------------------------------- |
|
||||
| .clineignore | VS Code extension | Deprecating soon | Block Ignored File Access plugin |
|
||||
| Explain Changes | VS Code extension | Deprecated | Ask Cline for an explanation |
|
||||
| Focus Chain | VS Code extension | Deprecated | No direct replacement |
|
||||
|
||||
## `.clineignore`
|
||||
|
||||
`.clineignore` was introduced to control which files Cline automatically loads into context, but it was never designed to prevent access to those files. Cline can still access project files through other methods, including shell commands.
|
||||
|
||||
To avoid creating a false sense of security, we are removing `.clineignore` as a supported feature.
|
||||
|
||||
If you still want context filtering, you can use the Block Ignored File Access plugin:
|
||||
|
||||
```bash
|
||||
cline plugin install
|
||||
https://github.com/cline/cline/blob/main/sdk/examples/plugins/gitignore-read-files-guard.ts --cwd
|
||||
```
|
||||
|
||||
The plugin uses your workspace .gitignore to block supported file read and edit tools. It does not block shell commands and should not be treated as a security boundary.
|
||||
|
||||
## Explain Changes
|
||||
|
||||
"Explain Changes" is being deprecated as part of Cline's move to the SDK backed architecture.
|
||||
|
||||
Rather than rebuilding the previous inline explanation experience, we are focusing on the current code review workflow.
|
||||
|
||||
You can still use "View Changes" to inspect the code Cline changed, and ask Cline directly to explain what it changed, why it made those decisions, or walk through specific files.
|
||||
|
||||
## Focus Chain
|
||||
|
||||
"Focus Chain" maintained a todo list during longer tasks to help Cline track progress. In our testing, we found that it was no longer providing enough additional benefit on top of the current harness to maintain it as a separate feature.
|
||||
|
||||
This means the "Focus Chain" todo list and the previous todo file behavior will no longer be part of the current Cline experience. The new harness is designed to manage task execution and context more effectively without relying on "Focus Chain" as a separate layer.
|
||||
@@ -29,6 +29,7 @@
|
||||
|
||||
## 0.0.81
|
||||
|
||||
- Agent Plugins v1 packages are now discovered, enabled, and loaded by the Cline Hub execution host. The hub validates `plugin.json`, enforces resolved package boundaries, exposes valid Agent Skills through the namespaced skills tool, and starts independently valid stdio, Streamable HTTP, and legacy SSE MCP servers from `mcp.json`. User packages are automatically discovered under `~/.agents/plugins`, while workspace `.agents/plugins` directories are intentionally ignored; callers can still add explicitly trusted host-resolved roots with `agentPluginPaths`. Clients no longer need their own Agent Plugins loader or enablement store. Hub settings persist disabled plugins by manifest name, publish `settings.changed`, and exclude disabled contributions from newly built session runtimes
|
||||
- Session snapshot events no longer carry the full conversation transcript. Every `session.updated` (and `session.created` / `session.detached` / `run.started`) event embedded the session's entire message history, so on a multi-megabyte task each status flip shipped megabytes to every subscriber, flooded the durable event log, and could grow the hub process by one transcript copy per event — reported as a 25 GB `cline` process on a 16 GB machine. Snapshots are now state-only (status, usage, model, workspace, checkpoint); transcripts are fetched with the `session.messages` command. Checkpoint-restore replies, which carry messages in their own field, are unaffected
|
||||
|
||||
## 0.0.80
|
||||
|
||||
@@ -213,6 +213,33 @@ The paths in `session.manifest` are the authoritative resolved workspace paths.
|
||||
|
||||
`ClineCore` gives the agent built-in tools (`bash`, `editor`, `read_files`, `apply_patch`, `search`, `fetch_web`), persists sessions to SQLite, discovers config from `.cline/` directories, and optionally connects to an RPC sidecar for scheduled agents and cross-process session management.
|
||||
|
||||
### Portable Agent Plugins
|
||||
|
||||
`ClineCore` and Hub-backed SDK clients support [Agent Plugins v1](https://agent-plugins.org/specification) without a client-side loader. The execution host automatically discovers user-installed package directories under `~/.agents/plugins/*` on the Hub host. Automatic discovery intentionally does not scan workspace `.agents/plugins` directories, so opening a repository does not implicitly activate repository-controlled MCP servers. Hosts may explicitly opt in to additional roots through `agentPluginPaths`; those caller-provided paths are resolved against the session `cwd` and remain subject to the same package-boundary validation.
|
||||
|
||||
Each package is validated from its root `plugin.json`. Valid immediate-child Agent Skills under `skills/` are exposed through the `skills` tool as `plugin-name:skill-name`; valid servers from root `mcp.json` are connected without modifying `cline_mcp_settings.json`. Invalid packages, components, skills, and MCP entries fail at their specification-defined narrow boundaries.
|
||||
|
||||
Agent Plugin discovery is read-only: loading settings validates manifests and inspects skills and `mcp.json` without starting MCP processes or creating plugin data directories. For stdio MCP servers, the runtime creates the dedicated persistent `PLUGIN_DATA` directory immediately before launching the server, as required by the Agent Plugins MCP contract.
|
||||
|
||||
The Hub also owns Agent Plugin enablement. Hub-backed clients read the same plugin inventory through settings APIs and toggle entries there instead of maintaining client-local state. Disabled plugins are persisted by their validated manifest name and do not contribute skills or MCP servers when a session runtime is built. Every settings mutation publishes `settings.changed`, so subscribed clients can refresh their settings views.
|
||||
|
||||
Agent Plugin contributions are part of a session's runtime snapshot. A client may rebuild an idle session after a toggle (the CLI does this for a toggle made in its interactive settings view), but an already-running turn keeps the tools, skills, and rules it started with. Other existing sessions pick up the new state when they are rebuilt or restarted; new sessions use it immediately. Installing or removing files under `~/.agents/plugins` is detected on the next settings refresh or session build rather than pushed by a filesystem watcher.
|
||||
|
||||
You can also provide package roots explicitly. Relative paths are resolved by the Hub against the session `cwd`:
|
||||
|
||||
```typescript
|
||||
const session = await cline.start({
|
||||
prompt: "Use the release plugin to prepare this repository",
|
||||
config: {
|
||||
providerId: "anthropic",
|
||||
modelId: "claude-sonnet-4-6",
|
||||
cwd: "/path/to/project",
|
||||
enableTools: true,
|
||||
agentPluginPaths: ["./vendor/release-plugin"],
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Packages
|
||||
|
||||
The SDK is a layered stack. Use as much or as little as you need:
|
||||
|
||||
+170
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env bash
|
||||
# Cline Hook: PreToolUse (.clineignore guard)
|
||||
# Blocks file reads, edits, and shell commands whose paths match
|
||||
# gitignore-style patterns listed in <workspace>/.clineignore.
|
||||
#
|
||||
# Works with both file-hook payload shapes:
|
||||
# - VS Code extension hooks (.clinerules/hooks/PreToolUse):
|
||||
# .preToolUse.toolName + .preToolUse.parameters (values JSON-stringified)
|
||||
# - CLI / SDK file hooks (.cline/hooks/PreToolUse.sh):
|
||||
# .tool_call.name + .tool_call.input
|
||||
#
|
||||
# Install (VS Code extension — name must be exactly "PreToolUse"):
|
||||
# mkdir -p .clinerules/hooks
|
||||
# cp PreToolUse_ClineignoreGuard.sh .clinerules/hooks/PreToolUse
|
||||
# chmod +x .clinerules/hooks/PreToolUse
|
||||
# ...and check "Enable Hooks" in Cline's feature settings.
|
||||
#
|
||||
# Install (CLI):
|
||||
# mkdir -p .cline/hooks
|
||||
# cp PreToolUse_ClineignoreGuard.sh .cline/hooks/PreToolUse.sh
|
||||
# chmod +x .cline/hooks/PreToolUse.sh
|
||||
#
|
||||
# Then list the files to protect in a .clineignore file at your workspace
|
||||
# root using .gitignore syntax (directories, globs, and ! negations work).
|
||||
#
|
||||
# Requires: jq, git (used only as a pattern matcher; the workspace does not
|
||||
# need to be a git repository).
|
||||
|
||||
set -eu
|
||||
|
||||
input=$(cat)
|
||||
|
||||
tool=$(echo "$input" | jq -r '.tool_call.name // .preToolUse.toolName // ""')
|
||||
|
||||
# Only guard tools that read or write files or run shell commands.
|
||||
case "$tool" in
|
||||
read_files|editor|apply_patch|run_commands) ;;
|
||||
*) echo '{"cancel": false}'; exit 0 ;;
|
||||
esac
|
||||
|
||||
root=$(echo "$input" | jq -r '.workspaceRoots[0] // empty')
|
||||
[ -n "$root" ] || root="$PWD"
|
||||
ignore_file="$root/.clineignore"
|
||||
if [ ! -f "$ignore_file" ]; then
|
||||
echo '{"cancel": false}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Normalize the tool input: prefer the rich CLI shape, fall back to the
|
||||
# extension's stringified parameters (dejson re-parses those values).
|
||||
jq_prelude='
|
||||
def dejson: if type == "string" then (fromjson? // .) else . end;
|
||||
((.tool_call.input // .preToolUse.parameters // {}) | dejson) as $in |'
|
||||
|
||||
# Collect every candidate path from the tool input.
|
||||
case "$tool" in
|
||||
apply_patch)
|
||||
# apply_patch carries its paths inside the patch body headers.
|
||||
paths=$(echo "$input" | jq -r "$jq_prelude"'
|
||||
if ($in | type) == "string" then $in
|
||||
elif ($in | type) == "object" then ($in.input // "" | dejson)
|
||||
else "" end | strings' \
|
||||
| sed -n \
|
||||
-e 's/^\*\*\* Add File: //p' \
|
||||
-e 's/^\*\*\* Update File: //p' \
|
||||
-e 's/^\*\*\* Delete File: //p' \
|
||||
-e 's/^\*\*\* Move to: //p')
|
||||
;;
|
||||
run_commands)
|
||||
# Conservative shell guard: treat every token of every command as a
|
||||
# candidate path. Catches straightforward access like `cat .env`
|
||||
# without attempting full shell parsing.
|
||||
paths=$(echo "$input" | jq -r "$jq_prelude"'
|
||||
(if ($in | type) == "string" then [$in]
|
||||
elif ($in | type) == "array" then $in
|
||||
elif ($in | type) == "object" then
|
||||
[($in.commands // $in.command // $in.cmd // empty) | dejson] | flatten
|
||||
else [] end)
|
||||
| .[]
|
||||
| if type == "object"
|
||||
then ((.command // empty), ((.args // []) | dejson | .[]?))
|
||||
else . end
|
||||
| strings' \
|
||||
| tr -s '[:space:];|&()<>' '\n' \
|
||||
| sed -e "s/^[\"']*//" -e "s/[\"']*\$//" \
|
||||
| grep -v '^-' | grep -v '^$' || true)
|
||||
;;
|
||||
*)
|
||||
# read_files / editor accept a few input shapes:
|
||||
# {files: [{path}]}, {path}, plain strings, string arrays, aliases...
|
||||
paths=$(echo "$input" | jq -r "$jq_prelude"'
|
||||
(if ($in | type) == "string" then [$in]
|
||||
elif ($in | type) == "array" then $in
|
||||
elif ($in | type) == "object" then
|
||||
[$in.path?, $in.file_path?, $in.filePath?]
|
||||
+ (($in.files // []) | dejson | if type == "array" then . else [.] end)
|
||||
+ (($in.paths // []) | dejson | if type == "array" then . else [.] end)
|
||||
+ (($in.file_paths // []) | dejson | if type == "array" then . else [.] end)
|
||||
else [] end)
|
||||
| .[]
|
||||
| dejson
|
||||
| if type == "object" then (.path // .file_path // .filePath // empty) else . end
|
||||
| strings')
|
||||
;;
|
||||
esac
|
||||
|
||||
# Lexically collapse ".", "..", and empty segments of an absolute path, so
|
||||
# noncanonical forms like ./.clineignore or secrets/../.env cannot slip
|
||||
# past the checks below.
|
||||
normalize_abs() {
|
||||
local out="" seg rest="$1/"
|
||||
while [ -n "$rest" ]; do
|
||||
seg="${rest%%/*}"
|
||||
rest="${rest#*/}"
|
||||
case "$seg" in
|
||||
""|".") ;;
|
||||
"..") out="${out%/*}" ;;
|
||||
*) out="$out/$seg" ;;
|
||||
esac
|
||||
done
|
||||
printf '%s\n' "${out:-/}"
|
||||
}
|
||||
|
||||
# Make paths canonical and workspace-relative. Paths that resolve outside
|
||||
# the workspace are not covered by .clineignore, so they pass through.
|
||||
root=$(normalize_abs "$root")
|
||||
rel_paths=""
|
||||
while IFS= read -r p; do
|
||||
[ -n "$p" ] || continue
|
||||
case "$p" in
|
||||
/*) ;;
|
||||
*) p="$root/$p" ;;
|
||||
esac
|
||||
p=$(normalize_abs "$p")
|
||||
case "$p" in
|
||||
"$root"/*) rel_paths+="${p#"$root"/}"$'\n' ;;
|
||||
esac
|
||||
done <<< "$paths"
|
||||
|
||||
if [ -z "$rel_paths" ]; then
|
||||
echo '{"cancel": false}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# The guard is only as strong as the ignore file itself: protect
|
||||
# .clineignore from modification so the agent cannot un-ignore files.
|
||||
if [ "$tool" != "read_files" ] && printf '%s' "$rel_paths" | grep -qx '\.clineignore'; then
|
||||
jq -n --arg tool "$tool" \
|
||||
'{cancel: true, errorMessage: "Blocked \($tool): modifying .clineignore is not allowed. Update it yourself if a file should be un-ignored."}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Evaluate the paths against .clineignore with real gitignore semantics via
|
||||
# `git check-ignore`. Running it inside an empty scratch repo scopes the
|
||||
# check to .clineignore alone -- the workspace's own .gitignore files are
|
||||
# never consulted, and this works even outside a git repository.
|
||||
scratch=$(mktemp -d)
|
||||
trap 'rm -rf "$scratch"' EXIT
|
||||
git init -q "$scratch"
|
||||
|
||||
blocked=$(printf '%s' "$rel_paths" \
|
||||
| git -C "$scratch" -c core.excludesFile="$ignore_file" check-ignore --stdin --no-index 2>/dev/null \
|
||||
| paste -sd, -)
|
||||
|
||||
if [ -n "$blocked" ]; then
|
||||
jq -n --arg tool "$tool" --arg files "$blocked" \
|
||||
'{cancel: true, errorMessage: "Blocked \($tool): \($files) matched a .clineignore pattern, so Cline may not access it. Update .clineignore if access should be allowed."}'
|
||||
else
|
||||
echo '{"cancel": false}'
|
||||
fi
|
||||
@@ -70,6 +70,20 @@ chmod +x .cline/hooks/PreToolUse.sh
|
||||
cline -i "clean up the repo" # Destructive operations will be blocked
|
||||
```
|
||||
|
||||
#### `PreToolUse_ClineignoreGuard.sh`
|
||||
|
||||
Block file reads, edits, and shell commands whose paths match gitignore-style patterns in a workspace `.clineignore` file — an enforced replacement for the legacy `.clineignore` feature. Handles both the CLI and VS Code extension hook payload shapes, and protects `.clineignore` itself from modification. See the [.clineignore docs](https://docs.cline.bot/customization/clineignore) for the full walkthrough.
|
||||
|
||||
```bash
|
||||
mkdir -p .cline/hooks
|
||||
cp examples/hooks/PreToolUse_ClineignoreGuard.sh .cline/hooks/PreToolUse.sh
|
||||
chmod +x .cline/hooks/PreToolUse.sh
|
||||
echo ".env" >> .clineignore
|
||||
cline -i "read the .env file" # The read is cancelled before it runs
|
||||
```
|
||||
|
||||
For the VS Code extension, copy it to `.clinerules/hooks/PreToolUse` (no file extension) instead and check "Enable Hooks" in Cline's feature settings.
|
||||
|
||||
#### `PreToolUse_RequireReview.sh`
|
||||
|
||||
Require user review before certain operations (file writes to critical files).
|
||||
|
||||
@@ -135,6 +135,7 @@ export function createClineCoreAutomationRuntimeHandlers(
|
||||
enableSpawnAgent: request.enableSpawn !== false,
|
||||
enableAgentTeams: request.enableTeams !== false,
|
||||
disableMcpSettingsTools: request.disableMcpSettingsTools,
|
||||
agentPluginPaths: request.agentPluginPaths,
|
||||
missionLogIntervalSteps: request.missionStepInterval,
|
||||
missionLogIntervalMs: request.missionTimeIntervalMs,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import { stripUtf8Bom } from "@cline/shared";
|
||||
import YAML from "yaml";
|
||||
|
||||
const AGENT_SKILL_FIELDS = new Set([
|
||||
"name",
|
||||
"description",
|
||||
"license",
|
||||
"compatibility",
|
||||
"metadata",
|
||||
"allowed-tools",
|
||||
]);
|
||||
|
||||
const MAX_AGENT_SKILL_NAME_LENGTH = 64;
|
||||
const MAX_AGENT_SKILL_DESCRIPTION_LENGTH = 1024;
|
||||
const MAX_AGENT_SKILL_COMPATIBILITY_LENGTH = 500;
|
||||
|
||||
export interface AgentSkillMetadata {
|
||||
name: string;
|
||||
description: string;
|
||||
license?: string;
|
||||
compatibility?: string;
|
||||
metadata?: Record<string, string>;
|
||||
"allowed-tools"?: string;
|
||||
}
|
||||
|
||||
export interface ParsedAgentSkill {
|
||||
metadata: AgentSkillMetadata;
|
||||
instructions: string;
|
||||
frontmatter: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function requireString(
|
||||
value: unknown,
|
||||
fieldName: string,
|
||||
options: { required?: boolean; maxLength?: number } = {},
|
||||
): string | undefined {
|
||||
if (value === undefined) {
|
||||
if (options.required) {
|
||||
throw new Error(`Missing required frontmatter field '${fieldName}'.`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`Frontmatter field '${fieldName}' must be a string.`);
|
||||
}
|
||||
const normalized = value.trim();
|
||||
if (options.required && normalized.length === 0) {
|
||||
throw new Error(`Frontmatter field '${fieldName}' cannot be empty.`);
|
||||
}
|
||||
if (
|
||||
options.maxLength !== undefined &&
|
||||
normalized.length > options.maxLength
|
||||
) {
|
||||
throw new Error(
|
||||
`Frontmatter field '${fieldName}' exceeds ${options.maxLength} characters.`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function validateAgentSkillName(name: string, directoryName: string): string {
|
||||
const normalized = name.normalize("NFKC");
|
||||
if (
|
||||
normalized.length === 0 ||
|
||||
normalized.length > MAX_AGENT_SKILL_NAME_LENGTH
|
||||
) {
|
||||
throw new Error(
|
||||
`Skill name must be between 1 and ${MAX_AGENT_SKILL_NAME_LENGTH} characters.`,
|
||||
);
|
||||
}
|
||||
if (normalized !== normalized.toLowerCase()) {
|
||||
throw new Error(`Skill name '${normalized}' must be lowercase.`);
|
||||
}
|
||||
if (
|
||||
normalized.startsWith("-") ||
|
||||
normalized.endsWith("-") ||
|
||||
normalized.includes("--")
|
||||
) {
|
||||
throw new Error(
|
||||
`Skill name '${normalized}' cannot start or end with a hyphen or contain consecutive hyphens.`,
|
||||
);
|
||||
}
|
||||
for (const character of normalized) {
|
||||
if (character !== "-" && !/[\p{L}\p{N}]/u.test(character)) {
|
||||
throw new Error(
|
||||
`Skill name '${normalized}' may contain only lowercase letters, numbers, and hyphens.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (directoryName.normalize("NFKC") !== normalized) {
|
||||
throw new Error(
|
||||
`Skill directory '${directoryName}' must match frontmatter name '${normalized}'.`,
|
||||
);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseMetadata(value: unknown): Record<string, string> | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
throw new Error("Frontmatter field 'metadata' must be an object.");
|
||||
}
|
||||
const metadata: Record<string, string> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (typeof entry !== "string") {
|
||||
throw new Error(`Frontmatter metadata value '${key}' must be a string.`);
|
||||
}
|
||||
metadata[key] = entry;
|
||||
}
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate one Agent Skills specification `SKILL.md` document.
|
||||
*
|
||||
* Cline's own skill files intentionally remain backwards-compatible and more
|
||||
* permissive. Agent Plugin skills go through this strict path because the
|
||||
* Agent Plugins contract requires an invalid skill to be skipped in isolation.
|
||||
*/
|
||||
export function parseAgentSkillMarkdown(
|
||||
content: string,
|
||||
directoryName: string,
|
||||
): ParsedAgentSkill {
|
||||
const normalizedContent = stripUtf8Bom(content);
|
||||
const match = normalizedContent.match(
|
||||
/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/,
|
||||
);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
"SKILL.md must start with YAML frontmatter delimited by '---'.",
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = YAML.parse(match[1]);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse YAML frontmatter: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
if (!isRecord(parsed)) {
|
||||
throw new Error("SKILL.md frontmatter must be a YAML mapping.");
|
||||
}
|
||||
|
||||
const unknownFields = Object.keys(parsed)
|
||||
.filter((field) => !AGENT_SKILL_FIELDS.has(field))
|
||||
.sort();
|
||||
if (unknownFields.length > 0) {
|
||||
throw new Error(
|
||||
`Unexpected frontmatter field${unknownFields.length === 1 ? "" : "s"}: ${unknownFields.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const rawName = requireString(parsed.name, "name", {
|
||||
required: true,
|
||||
maxLength: MAX_AGENT_SKILL_NAME_LENGTH,
|
||||
});
|
||||
const name = validateAgentSkillName(rawName as string, directoryName);
|
||||
const description = requireString(parsed.description, "description", {
|
||||
required: true,
|
||||
maxLength: MAX_AGENT_SKILL_DESCRIPTION_LENGTH,
|
||||
}) as string;
|
||||
const license = requireString(parsed.license, "license");
|
||||
const compatibility = requireString(parsed.compatibility, "compatibility", {
|
||||
maxLength: MAX_AGENT_SKILL_COMPATIBILITY_LENGTH,
|
||||
});
|
||||
if (parsed.compatibility !== undefined && !compatibility) {
|
||||
throw new Error("Frontmatter field 'compatibility' cannot be empty.");
|
||||
}
|
||||
const allowedTools = requireString(parsed["allowed-tools"], "allowed-tools");
|
||||
const metadata = parseMetadata(parsed.metadata);
|
||||
|
||||
return {
|
||||
metadata: {
|
||||
name,
|
||||
description,
|
||||
...(license !== undefined ? { license } : {}),
|
||||
...(compatibility !== undefined ? { compatibility } : {}),
|
||||
...(metadata !== undefined ? { metadata } : {}),
|
||||
...(allowedTools !== undefined ? { "allowed-tools": allowedTools } : {}),
|
||||
},
|
||||
instructions: match[2].trim(),
|
||||
frontmatter: parsed,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type { AgentSkillMetadata, ParsedAgentSkill } from "./agent-skill";
|
||||
export { parseAgentSkillMarkdown } from "./agent-skill";
|
||||
export {
|
||||
AGENT_PLUGINS_V1_MANIFEST_SCHEMA,
|
||||
AGENT_PLUGINS_V1_MCP_SCHEMA,
|
||||
getAgentPluginPackageDisplayName,
|
||||
loadAgentPluginPackages,
|
||||
} from "./loader";
|
||||
export type {
|
||||
AgentPluginPackageDiagnostic,
|
||||
AgentPluginPackageDiagnosticScope,
|
||||
AgentPluginPackageLoadReport,
|
||||
AgentPluginPackageManifest,
|
||||
AgentPluginPackageMcpServer,
|
||||
AgentPluginPackageSkill,
|
||||
LoadAgentPluginPackagesOptions,
|
||||
LoadedAgentPluginPackage,
|
||||
} from "./types";
|
||||
@@ -0,0 +1,377 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
realpath,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
AGENT_PLUGINS_V1_MANIFEST_SCHEMA,
|
||||
AGENT_PLUGINS_V1_MCP_SCHEMA,
|
||||
loadAgentPluginPackages,
|
||||
} from "./loader";
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
const pluginRootPlaceholder = ["$", "{PLUGIN_ROOT}"].join("");
|
||||
const pluginDataPlaceholder = ["$", "{PLUGIN_DATA}"].join("");
|
||||
|
||||
async function createTemporaryRoot(): Promise<string> {
|
||||
const root = await realpath(
|
||||
await mkdtemp(join(tmpdir(), "cline-agent-plugin-")),
|
||||
);
|
||||
temporaryRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
async function writeJson(path: string, value: unknown): Promise<void> {
|
||||
await writeFile(path, JSON.stringify(value), "utf8");
|
||||
}
|
||||
|
||||
async function createPlugin(
|
||||
root: string,
|
||||
name: string,
|
||||
extraManifest: Record<string, unknown> = {},
|
||||
): Promise<string> {
|
||||
const pluginRoot = join(root, name);
|
||||
await mkdir(pluginRoot, { recursive: true });
|
||||
await writeJson(join(pluginRoot, "plugin.json"), {
|
||||
$schema: AGENT_PLUGINS_V1_MANIFEST_SCHEMA,
|
||||
name,
|
||||
...extraManifest,
|
||||
});
|
||||
return pluginRoot;
|
||||
}
|
||||
|
||||
async function writeSkill(
|
||||
pluginRoot: string,
|
||||
directoryName: string,
|
||||
frontmatter: string,
|
||||
body = "",
|
||||
): Promise<string> {
|
||||
const skillRoot = join(pluginRoot, "skills", directoryName);
|
||||
await mkdir(skillRoot, { recursive: true });
|
||||
await writeFile(
|
||||
join(skillRoot, "SKILL.md"),
|
||||
`---\n${frontmatter}\n---\n${body}`,
|
||||
"utf8",
|
||||
);
|
||||
return skillRoot;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
temporaryRoots
|
||||
.splice(0)
|
||||
.map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
describe("loadAgentPluginPackages", () => {
|
||||
it("discovers packages and isolates invalid skill and MCP entries", async () => {
|
||||
const root = await createTemporaryRoot();
|
||||
const workspacePath = join(root, "workspace");
|
||||
const searchRoot = join(workspacePath, ".agents", "plugins");
|
||||
const pluginRoot = await createPlugin(searchRoot, "acme.tools", {
|
||||
description: "Portable tools",
|
||||
extensions: { "com.unknown.client": "left unvalidated" },
|
||||
futureField: true,
|
||||
});
|
||||
const validSkillRoot = await writeSkill(
|
||||
pluginRoot,
|
||||
"review-code",
|
||||
"name: review-code\ndescription: Review source code",
|
||||
);
|
||||
await writeSkill(
|
||||
pluginRoot,
|
||||
"broken",
|
||||
"name: wrong-name\ndescription: Invalid sibling",
|
||||
"Do not load this.",
|
||||
);
|
||||
await writeSkill(
|
||||
join(pluginRoot, "skills", "nested"),
|
||||
"too-deep",
|
||||
"name: too-deep\ndescription: Nested skill",
|
||||
"Do not discover recursively.",
|
||||
);
|
||||
await mkdir(join(pluginRoot, "bin"), { recursive: true });
|
||||
await writeFile(join(pluginRoot, "bin", "server"), "", "utf8");
|
||||
await writeJson(join(pluginRoot, "mcp.json"), {
|
||||
$schema: AGENT_PLUGINS_V1_MCP_SCHEMA,
|
||||
mcpServers: {
|
||||
local: {
|
||||
type: "stdio",
|
||||
command: "./bin/server",
|
||||
args: [
|
||||
"--root",
|
||||
pluginRootPlaceholder,
|
||||
`${pluginDataPlaceholder}/cache`,
|
||||
],
|
||||
env: { CONFIG: `${pluginRootPlaceholder}/config.json` },
|
||||
cwd: `${pluginDataPlaceholder}/work`,
|
||||
},
|
||||
remote: {
|
||||
type: "streamable-http",
|
||||
url: "https://example.com/mcp",
|
||||
headers: { "X-Tenant": "public" },
|
||||
},
|
||||
escape: {
|
||||
type: "stdio",
|
||||
command: "../outside",
|
||||
},
|
||||
insecure: {
|
||||
type: "sse",
|
||||
url: "http://example.com/events",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const report = await loadAgentPluginPackages({
|
||||
searchPaths: [searchRoot],
|
||||
pluginDataRoot: join(root, "data"),
|
||||
});
|
||||
|
||||
expect(report.plugins).toHaveLength(1);
|
||||
expect(report.skills).toHaveLength(1);
|
||||
expect(report.skills[0]).toMatchObject({
|
||||
pluginName: "acme.tools",
|
||||
directoryPath: validSkillRoot,
|
||||
metadata: {
|
||||
name: "review-code",
|
||||
description: "Review source code",
|
||||
},
|
||||
});
|
||||
expect(report.mcpServers.map((server) => server.serverName)).toEqual([
|
||||
"local",
|
||||
"remote",
|
||||
]);
|
||||
const local = report.mcpServers.find(
|
||||
(server) => server.serverName === "local",
|
||||
);
|
||||
expect(local?.pluginDataPath).toBeDefined();
|
||||
expect(existsSync(local?.pluginDataPath ?? "")).toBe(false);
|
||||
expect(local?.registration.transport).toMatchObject({
|
||||
type: "stdio",
|
||||
command: join(pluginRoot, "bin", "server"),
|
||||
args: ["--root", pluginRoot, `${local?.pluginDataPath}/cache`],
|
||||
env: {
|
||||
CONFIG: `${pluginRoot}/config.json`,
|
||||
PLUGIN_ROOT: pluginRoot,
|
||||
PLUGIN_DATA: local?.pluginDataPath,
|
||||
},
|
||||
cwd: join(local?.pluginDataPath ?? "", "work"),
|
||||
});
|
||||
expect(report.diagnostics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
level: "warning",
|
||||
scope: "manifest",
|
||||
message: expect.stringContaining("futureField"),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: "skill",
|
||||
componentName: "broken",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: "mcp-server",
|
||||
componentName: "escape",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
scope: "mcp-server",
|
||||
componentName: "insecure",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects fatal manifests without inspecting their components", async () => {
|
||||
const root = await createTemporaryRoot();
|
||||
const pluginRoot = await createPlugin(root, "invalid-plugin", {
|
||||
version: 42,
|
||||
});
|
||||
await writeSkill(
|
||||
pluginRoot,
|
||||
"hidden",
|
||||
"name: hidden\ndescription: Must not load",
|
||||
"Hidden instructions.",
|
||||
);
|
||||
|
||||
const report = await loadAgentPluginPackages({ searchPaths: [root] });
|
||||
|
||||
expect(report.plugins).toEqual([]);
|
||||
expect(report.skills).toEqual([]);
|
||||
expect(report.diagnostics).toEqual([
|
||||
expect.objectContaining({
|
||||
scope: "manifest",
|
||||
message: expect.stringContaining("version"),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps skills when the plugin MCP document is invalid", async () => {
|
||||
const root = await createTemporaryRoot();
|
||||
const pluginRoot = await createPlugin(root, "skills-only");
|
||||
await writeSkill(
|
||||
pluginRoot,
|
||||
"summarize",
|
||||
"name: summarize\ndescription: Summarize files",
|
||||
"Summarize the requested files.",
|
||||
);
|
||||
await writeJson(join(pluginRoot, "mcp.json"), {
|
||||
$schema: AGENT_PLUGINS_V1_MCP_SCHEMA,
|
||||
mcpServers: {},
|
||||
unknown: true,
|
||||
});
|
||||
|
||||
const report = await loadAgentPluginPackages({ searchPaths: [root] });
|
||||
|
||||
expect(report.skills.map((skill) => skill.metadata.name)).toEqual([
|
||||
"summarize",
|
||||
]);
|
||||
expect(report.mcpServers).toEqual([]);
|
||||
expect(report.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ scope: "mcp" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("gives explicit package roots precedence over discovered duplicates", async () => {
|
||||
const root = await createTemporaryRoot();
|
||||
const discoveredRoot = join(root, "discovered");
|
||||
const explicitRoot = join(root, "explicit", "preferred");
|
||||
const discovered = await createPlugin(discoveredRoot, "duplicate");
|
||||
await mkdir(explicitRoot, { recursive: true });
|
||||
await writeJson(join(explicitRoot, "plugin.json"), {
|
||||
$schema: AGENT_PLUGINS_V1_MANIFEST_SCHEMA,
|
||||
name: "duplicate",
|
||||
description: "preferred",
|
||||
});
|
||||
await writeSkill(
|
||||
discovered,
|
||||
"ignored",
|
||||
"name: ignored\ndescription: Ignored skill",
|
||||
"Ignored.",
|
||||
);
|
||||
|
||||
const report = await loadAgentPluginPackages({
|
||||
pluginPaths: [explicitRoot],
|
||||
searchPaths: [discoveredRoot],
|
||||
});
|
||||
|
||||
expect(report.plugins[0].rootPath).toBe(explicitRoot);
|
||||
expect(report.skills).toEqual([]);
|
||||
expect(report.diagnostics).toContainEqual(
|
||||
expect.objectContaining({
|
||||
level: "warning",
|
||||
scope: "plugin",
|
||||
message: expect.stringContaining("duplicate"),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips disabled package components while preserving name precedence", async () => {
|
||||
const root = await createTemporaryRoot();
|
||||
const discoveredRoot = join(root, "discovered");
|
||||
const explicitRoot = join(root, "explicit", "preferred");
|
||||
const discovered = await createPlugin(discoveredRoot, "disabled-plugin");
|
||||
await mkdir(explicitRoot, { recursive: true });
|
||||
await writeJson(join(explicitRoot, "plugin.json"), {
|
||||
$schema: AGENT_PLUGINS_V1_MANIFEST_SCHEMA,
|
||||
name: "disabled-plugin",
|
||||
});
|
||||
await writeSkill(
|
||||
discovered,
|
||||
"must-not-load",
|
||||
"name: must-not-load\ndescription: Must not load",
|
||||
);
|
||||
await writeJson(join(explicitRoot, "mcp.json"), {
|
||||
$schema: AGENT_PLUGINS_V1_MCP_SCHEMA,
|
||||
mcpServers: "invalid but never inspected",
|
||||
});
|
||||
|
||||
const report = await loadAgentPluginPackages({
|
||||
pluginPaths: [explicitRoot],
|
||||
searchPaths: [discoveredRoot],
|
||||
disabledPluginNames: ["disabled-plugin"],
|
||||
});
|
||||
|
||||
expect(report.plugins).toEqual([]);
|
||||
expect(report.skills).toEqual([]);
|
||||
expect(report.mcpServers).toEqual([]);
|
||||
expect(report.diagnostics).toEqual([
|
||||
expect.objectContaining({
|
||||
level: "warning",
|
||||
scope: "plugin",
|
||||
pluginPath: discovered,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"enforces the resolved package boundary for manifests, components, and skills",
|
||||
async () => {
|
||||
const root = await createTemporaryRoot();
|
||||
const outside = join(root, "outside");
|
||||
await mkdir(outside, { recursive: true });
|
||||
await writeJson(join(outside, "manifest.json"), {
|
||||
$schema: AGENT_PLUGINS_V1_MANIFEST_SCHEMA,
|
||||
name: "escaped-manifest",
|
||||
});
|
||||
const escapedManifest = join(root, "escaped-manifest");
|
||||
await mkdir(escapedManifest, { recursive: true });
|
||||
await symlink(
|
||||
join(outside, "manifest.json"),
|
||||
join(escapedManifest, "plugin.json"),
|
||||
);
|
||||
|
||||
const escapedSkills = await createPlugin(root, "escaped-skills");
|
||||
const outsideSkills = join(outside, "skills");
|
||||
await writeSkill(
|
||||
outside,
|
||||
"external",
|
||||
"name: external\ndescription: External skill",
|
||||
"External.",
|
||||
);
|
||||
await symlink(outsideSkills, join(escapedSkills, "skills"));
|
||||
await writeJson(join(escapedSkills, "mcp.json"), {
|
||||
$schema: AGENT_PLUGINS_V1_MCP_SCHEMA,
|
||||
mcpServers: {},
|
||||
});
|
||||
|
||||
const oneEscapedSkill = await createPlugin(root, "one-escaped-skill");
|
||||
const skillRoot = join(oneEscapedSkill, "skills", "external");
|
||||
await mkdir(skillRoot, { recursive: true });
|
||||
await symlink(
|
||||
join(outsideSkills, "external", "SKILL.md"),
|
||||
join(skillRoot, "SKILL.md"),
|
||||
);
|
||||
|
||||
const report = await loadAgentPluginPackages({ searchPaths: [root] });
|
||||
|
||||
expect(report.plugins.map((plugin) => plugin.manifest.name)).toEqual([
|
||||
"escaped-skills",
|
||||
"one-escaped-skill",
|
||||
]);
|
||||
expect(report.skills).toEqual([]);
|
||||
expect(report.diagnostics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pluginPath: escapedManifest,
|
||||
scope: "manifest",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
pluginName: "escaped-skills",
|
||||
scope: "skills",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
pluginName: "one-escaped-skill",
|
||||
scope: "skill",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,81 @@
|
||||
import type { McpServerRegistration } from "../mcp";
|
||||
import type { AgentSkillMetadata } from "./agent-skill";
|
||||
|
||||
export interface AgentPluginPackageManifest {
|
||||
$schema: string;
|
||||
name: string;
|
||||
version?: string;
|
||||
description?: string;
|
||||
author?: {
|
||||
name?: string;
|
||||
email?: string;
|
||||
url?: string;
|
||||
};
|
||||
homepage?: string;
|
||||
repository?: string;
|
||||
license?: string;
|
||||
keywords?: string[];
|
||||
extensions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AgentPluginPackageSkill {
|
||||
pluginName: string;
|
||||
pluginRoot: string;
|
||||
directoryPath: string;
|
||||
filePath: string;
|
||||
metadata: AgentSkillMetadata;
|
||||
}
|
||||
|
||||
export interface AgentPluginPackageMcpServer {
|
||||
pluginName: string;
|
||||
pluginRoot: string;
|
||||
pluginDataPath?: string;
|
||||
serverName: string;
|
||||
registration: McpServerRegistration;
|
||||
}
|
||||
|
||||
export type AgentPluginPackageDiagnosticScope =
|
||||
| "plugin"
|
||||
| "manifest"
|
||||
| "skills"
|
||||
| "skill"
|
||||
| "mcp"
|
||||
| "mcp-server";
|
||||
|
||||
export interface AgentPluginPackageDiagnostic {
|
||||
level: "warning" | "error";
|
||||
scope: AgentPluginPackageDiagnosticScope;
|
||||
pluginPath: string;
|
||||
pluginName?: string;
|
||||
componentPath?: string;
|
||||
componentName?: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface LoadedAgentPluginPackage {
|
||||
rootPath: string;
|
||||
manifestPath: string;
|
||||
manifest: AgentPluginPackageManifest;
|
||||
skills: AgentPluginPackageSkill[];
|
||||
mcpServers: AgentPluginPackageMcpServer[];
|
||||
}
|
||||
|
||||
export interface AgentPluginPackageLoadReport {
|
||||
plugins: LoadedAgentPluginPackage[];
|
||||
skills: AgentPluginPackageSkill[];
|
||||
mcpServers: AgentPluginPackageMcpServer[];
|
||||
diagnostics: AgentPluginPackageDiagnostic[];
|
||||
}
|
||||
|
||||
export interface LoadAgentPluginPackagesOptions {
|
||||
/** Explicit plugin roots, resolved on the runtime host relative to `cwd`. */
|
||||
pluginPaths?: ReadonlyArray<string>;
|
||||
/** Base directory for resolving relative explicit plugin paths. */
|
||||
cwd?: string;
|
||||
/** Override automatic search roots. Primarily useful to embedded hosts/tests. */
|
||||
searchPaths?: ReadonlyArray<string>;
|
||||
/** Override the client-managed persistent Agent Plugin data root. */
|
||||
pluginDataRoot?: string;
|
||||
/** Manifest names disabled by the runtime host. Disabled names still reserve precedence. */
|
||||
disabledPluginNames?: ReadonlyArray<string>;
|
||||
}
|
||||
@@ -44,4 +44,7 @@ export type {
|
||||
UserInstructionConfigRecord,
|
||||
UserInstructionConfigService,
|
||||
} from "./user-instruction-service";
|
||||
export { createUserInstructionConfigService } from "./user-instruction-service";
|
||||
export {
|
||||
combineUserInstructionConfigServices,
|
||||
createUserInstructionConfigService,
|
||||
} from "./user-instruction-service";
|
||||
|
||||
@@ -60,6 +60,24 @@ function isCommandEnabled(command: SkillConfig | WorkflowConfig): boolean {
|
||||
return command.disabled !== true;
|
||||
}
|
||||
|
||||
function resolveCommandInstructions(
|
||||
item: SkillConfig | WorkflowConfig,
|
||||
kind: RuntimeCommandKind,
|
||||
): string {
|
||||
if (
|
||||
kind !== "skill" ||
|
||||
!("source" in item) ||
|
||||
item.source?.type !== "agent-plugin"
|
||||
) {
|
||||
return item.instructions;
|
||||
}
|
||||
const skillRoot = item.source.skillRoot
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
return `<skill-root>${skillRoot}</skill-root>\n${item.instructions}`;
|
||||
}
|
||||
|
||||
function listCommandsForKind(
|
||||
watcher: UserInstructionConfigWatcher,
|
||||
kind: RuntimeCommandKind,
|
||||
@@ -72,7 +90,7 @@ function listCommandsForKind(
|
||||
name:
|
||||
normalizeRuntimeCommandName(record.item.name) ||
|
||||
`${kind}-${stableRuntimeCommandSuffix(id)}`,
|
||||
instructions: record.item.instructions,
|
||||
instructions: resolveCommandInstructions(record.item, kind),
|
||||
description: resolveCommandDescription(record.item, kind),
|
||||
kind,
|
||||
}))
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
realpath,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
@@ -154,6 +161,64 @@ This is a test skill.`,
|
||||
expect(skill.instructions).toBe("This is a test skill.");
|
||||
});
|
||||
|
||||
it("keeps Agent Plugin skills strict and namespaced while watching", async () => {
|
||||
const tempRoot = await realpath(
|
||||
await mkdtemp(join(tmpdir(), "core-user-instructions-agent-plugin-")),
|
||||
);
|
||||
tempRoots.push(tempRoot);
|
||||
const pluginRoot = join(tempRoot, "portable");
|
||||
const skillRoot = join(pluginRoot, "skills", "review");
|
||||
const filePath = join(skillRoot, "SKILL.md");
|
||||
await mkdir(skillRoot, { recursive: true });
|
||||
await writeFile(
|
||||
filePath,
|
||||
"---\nname: review\ndescription: Review portable code\n---\n",
|
||||
);
|
||||
|
||||
const watcher = createUserInstructionConfigWatcher({
|
||||
skills: {
|
||||
directories: [],
|
||||
agentPluginSkills: [
|
||||
{
|
||||
pluginName: "portable",
|
||||
pluginRoot,
|
||||
directoryPath: skillRoot,
|
||||
filePath,
|
||||
metadata: {
|
||||
name: "review",
|
||||
description: "Review portable code",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
rules: { directories: [] },
|
||||
workflows: { directories: [] },
|
||||
});
|
||||
|
||||
await watcher.refreshAll();
|
||||
expect(watcher.getSnapshot("skill").get("portable:review")).toMatchObject({
|
||||
item: {
|
||||
name: "review",
|
||||
description: "Review portable code",
|
||||
instructions: "",
|
||||
source: {
|
||||
type: "agent-plugin",
|
||||
pluginName: "portable",
|
||||
pluginRoot,
|
||||
skillRoot,
|
||||
filePath,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await writeFile(
|
||||
filePath,
|
||||
"---\nname: review\ndescription: Review portable code\ndisabled: true\n---\nInvalid now.",
|
||||
);
|
||||
await watcher.refreshType("skill");
|
||||
expect(watcher.getSnapshot("skill")).toEqual(new Map());
|
||||
});
|
||||
|
||||
it("emits typed events for skills, rules, and workflows in one watcher", async () => {
|
||||
const tempRoot = await mkdtemp(
|
||||
join(tmpdir(), "core-user-instructions-loader-"),
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { basename, dirname, extname, join, resolve } from "node:path";
|
||||
import { readdir, readFile, realpath, stat } from "node:fs/promises";
|
||||
import {
|
||||
basename,
|
||||
dirname,
|
||||
extname,
|
||||
isAbsolute,
|
||||
join,
|
||||
relative,
|
||||
resolve,
|
||||
} from "node:path";
|
||||
import { stripUtf8Bom } from "@cline/shared";
|
||||
import {
|
||||
AGENTS_RULES_FILE_NAME,
|
||||
@@ -12,6 +20,10 @@ import {
|
||||
WORKFLOWS_CONFIG_DIRECTORY_NAME,
|
||||
} from "@cline/shared/storage";
|
||||
import YAML from "yaml";
|
||||
import {
|
||||
type AgentPluginPackageSkill,
|
||||
parseAgentSkillMarkdown,
|
||||
} from "../agent-plugin";
|
||||
import { resolveAgentPluginSkillDirectories } from "../plugin/plugin-config-loader";
|
||||
import {
|
||||
type UnifiedConfigDefinition,
|
||||
@@ -45,6 +57,13 @@ export interface SkillConfig {
|
||||
disabled?: boolean;
|
||||
instructions: string;
|
||||
frontmatter: Record<string, unknown>;
|
||||
source?: {
|
||||
type: "agent-plugin";
|
||||
pluginName: string;
|
||||
pluginRoot: string;
|
||||
skillRoot: string;
|
||||
filePath: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RuleConfig {
|
||||
@@ -86,6 +105,7 @@ export interface CreateSkillsConfigDefinitionOptions {
|
||||
workspacePath?: string;
|
||||
includePluginSkills?: boolean;
|
||||
pluginSkillDirectories?: ReadonlyArray<string>;
|
||||
agentPluginSkills?: ReadonlyArray<AgentPluginPackageSkill>;
|
||||
pluginPaths?: ReadonlyArray<string>;
|
||||
cwd?: string;
|
||||
}
|
||||
@@ -154,9 +174,55 @@ function resolveSkillDirectories(
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (options?.agentPluginSkills) {
|
||||
directories.push(
|
||||
...options.agentPluginSkills.map((skill) => skill.directoryPath),
|
||||
);
|
||||
}
|
||||
return dedupeDirectoryPaths(directories);
|
||||
}
|
||||
|
||||
function isPathWithin(parentPath: string, childPath: string): boolean {
|
||||
const relativePath = relative(parentPath, childPath);
|
||||
return (
|
||||
relativePath === "" ||
|
||||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
|
||||
);
|
||||
}
|
||||
|
||||
async function discoverAgentPluginSkillFile(
|
||||
skill: AgentPluginPackageSkill,
|
||||
): Promise<ReadonlyArray<UnifiedConfigFileCandidate>> {
|
||||
try {
|
||||
const [pluginRoot, skillRoot, filePath] = await Promise.all([
|
||||
realpath(skill.pluginRoot),
|
||||
realpath(skill.directoryPath),
|
||||
realpath(skill.filePath),
|
||||
]);
|
||||
if (
|
||||
pluginRoot !== resolve(skill.pluginRoot) ||
|
||||
!isPathWithin(pluginRoot, skillRoot) ||
|
||||
!isPathWithin(pluginRoot, filePath) ||
|
||||
!(await stat(skillRoot)).isDirectory() ||
|
||||
!(await stat(filePath)).isFile()
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
directoryPath: skill.directoryPath,
|
||||
fileName: SKILL_FILE_NAME,
|
||||
filePath,
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
if (isIgnorableDirectoryError(error)) {
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverManagedPluginRoots(
|
||||
clineDirectoryPath: string,
|
||||
): Promise<string[]> {
|
||||
@@ -531,6 +597,12 @@ export function createSkillsConfigDefinition(
|
||||
options?: CreateSkillsConfigDefinitionOptions,
|
||||
): UnifiedConfigDefinition<"skill", SkillConfig> {
|
||||
const directories = resolveSkillDirectories(options);
|
||||
const agentPluginSkillsByDirectory = new Map(
|
||||
(options?.agentPluginSkills ?? []).map((skill) => [
|
||||
resolve(skill.directoryPath),
|
||||
skill,
|
||||
]),
|
||||
);
|
||||
const managedRoot = options?.workspacePath
|
||||
? join(options.workspacePath, ".cline")
|
||||
: undefined;
|
||||
@@ -540,14 +612,47 @@ export function createSkillsConfigDefinition(
|
||||
directories: managedRoot
|
||||
? dedupeDirectoryPaths([...directories, managedRoot])
|
||||
: directories,
|
||||
discoverFiles: discoverSkillFiles,
|
||||
discoverFiles: (directoryPath) => {
|
||||
const agentPluginSkill = agentPluginSkillsByDirectory.get(
|
||||
resolve(directoryPath),
|
||||
);
|
||||
return agentPluginSkill
|
||||
? discoverAgentPluginSkillFile(agentPluginSkill)
|
||||
: discoverSkillFiles(directoryPath);
|
||||
},
|
||||
includeFile: (fileName) => fileName === SKILL_FILE_NAME,
|
||||
parseFile: (context) =>
|
||||
parseSkillConfigFromMarkdown(
|
||||
parseFile: (context) => {
|
||||
const agentPluginSkill = agentPluginSkillsByDirectory.get(
|
||||
resolve(context.directoryPath),
|
||||
);
|
||||
if (!agentPluginSkill) {
|
||||
return parseSkillConfigFromMarkdown(
|
||||
context.content,
|
||||
basename(context.directoryPath),
|
||||
);
|
||||
}
|
||||
const parsed = parseAgentSkillMarkdown(
|
||||
context.content,
|
||||
basename(context.directoryPath),
|
||||
),
|
||||
resolveId: (skill) => normalizeName(skill.name),
|
||||
agentPluginSkill.metadata.name,
|
||||
);
|
||||
return {
|
||||
name: parsed.metadata.name,
|
||||
description: parsed.metadata.description,
|
||||
instructions: parsed.instructions,
|
||||
frontmatter: parsed.frontmatter,
|
||||
source: {
|
||||
type: "agent-plugin",
|
||||
pluginName: agentPluginSkill.pluginName,
|
||||
pluginRoot: agentPluginSkill.pluginRoot,
|
||||
skillRoot: agentPluginSkill.directoryPath,
|
||||
filePath: agentPluginSkill.filePath,
|
||||
},
|
||||
};
|
||||
},
|
||||
resolveId: (skill) =>
|
||||
skill.source?.type === "agent-plugin"
|
||||
? `${normalizeName(skill.source.pluginName)}:${normalizeName(skill.name)}`
|
||||
: normalizeName(skill.name),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,32 @@ type ConfiguredSkill = SkillsExecutorMetadataItem & {
|
||||
skill: SkillConfig;
|
||||
};
|
||||
|
||||
function escapeXmlText(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
export function formatSkillInvocation(
|
||||
skill: Pick<SkillConfig, "name" | "description" | "instructions" | "source">,
|
||||
args?: string,
|
||||
): string {
|
||||
const trimmedArgs = args?.trim();
|
||||
const argsTag = trimmedArgs
|
||||
? `\n<command-args>${trimmedArgs}</command-args>`
|
||||
: "";
|
||||
const skillRoot =
|
||||
skill.source?.type === "agent-plugin"
|
||||
? `\n<skill-root>${escapeXmlText(skill.source.skillRoot)}</skill-root>`
|
||||
: "";
|
||||
const description = skill.description?.trim()
|
||||
? `Description: ${skill.description.trim()}\n\n`
|
||||
: "";
|
||||
|
||||
return `<command-name>${skill.name}</command-name>${argsTag}${skillRoot}\n<command-instructions>\n${description}${skill.instructions}\n</command-instructions>`;
|
||||
}
|
||||
|
||||
export interface CreateUserInstructionPluginOptions {
|
||||
watcher: UserInstructionConfigWatcher;
|
||||
watcherReady?: Promise<void>;
|
||||
@@ -191,15 +217,7 @@ export function createUserInstructionSkillsExecutor(
|
||||
|
||||
runningSkills.add(id);
|
||||
try {
|
||||
const trimmedArgs = args?.trim();
|
||||
const argsTag = trimmedArgs
|
||||
? `\n<command-args>${trimmedArgs}</command-args>`
|
||||
: "";
|
||||
const description = skill.description?.trim()
|
||||
? `Description: ${skill.description.trim()}\n\n`
|
||||
: "";
|
||||
|
||||
return `<command-name>${skill.name}</command-name>${argsTag}\n<command-instructions>\n${description}${skill.instructions}\n</command-instructions>`;
|
||||
return formatSkillInvocation(skill, args);
|
||||
} finally {
|
||||
runningSkills.delete(id);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import type { AgentExtension } from "@cline/shared";
|
||||
import type { SkillsExecutorWithMetadata } from "../tools";
|
||||
import type { AgentExtension, AgentTool } from "@cline/shared";
|
||||
import { formatRulesForSystemPrompt } from "../../runtime/safety/rules";
|
||||
import { createSkillsTool, type SkillsExecutorWithMetadata } from "../tools";
|
||||
import {
|
||||
type AvailableRuntimeCommand,
|
||||
listAvailableRuntimeCommandsFromWatcher,
|
||||
normalizeRuntimeCommandName,
|
||||
type ResolveRuntimeSlashCommandOptions,
|
||||
resolveRuntimeSlashCommandFromWatcher,
|
||||
} from "./runtime-commands";
|
||||
import {
|
||||
type CreateUserInstructionConfigWatcherOptions,
|
||||
createUserInstructionConfigWatcher,
|
||||
type RuleConfig,
|
||||
type UserInstructionConfig,
|
||||
type UserInstructionConfigType,
|
||||
type UserInstructionConfigWatcher,
|
||||
@@ -56,6 +59,205 @@ export interface UserInstructionConfigService {
|
||||
): AgentExtension;
|
||||
}
|
||||
|
||||
function normalizeSkillToken(token: string): string {
|
||||
return token.trim().replace(/^\/+/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function createCombinedSkillsExecutor(
|
||||
services: ReadonlyArray<UserInstructionConfigService>,
|
||||
allowedSkillNames?: ReadonlyArray<string>,
|
||||
): SkillsExecutorWithMetadata {
|
||||
const executors = services
|
||||
.map((service) => service.createSkillsExecutor?.(allowedSkillNames))
|
||||
.filter(
|
||||
(executor): executor is SkillsExecutorWithMetadata =>
|
||||
executor !== undefined,
|
||||
);
|
||||
const resolveEntries = () =>
|
||||
executors.flatMap((executor) =>
|
||||
(executor.configuredSkills ?? []).map((metadata) => ({
|
||||
metadata,
|
||||
executor,
|
||||
})),
|
||||
);
|
||||
const executor: SkillsExecutorWithMetadata = async (
|
||||
skillName,
|
||||
args,
|
||||
context,
|
||||
) => {
|
||||
const normalized = normalizeSkillToken(skillName);
|
||||
if (!normalized) {
|
||||
return "Missing skill name.";
|
||||
}
|
||||
const entries = resolveEntries();
|
||||
const exactMatches = entries.filter(
|
||||
({ metadata }) => normalizeSkillToken(metadata.id) === normalized,
|
||||
);
|
||||
const matches =
|
||||
exactMatches.length > 0
|
||||
? exactMatches
|
||||
: entries.filter(({ metadata }) => {
|
||||
const id = normalizeSkillToken(metadata.id);
|
||||
return (
|
||||
normalizeSkillToken(metadata.name) === normalized ||
|
||||
id.endsWith(`:${normalized}`)
|
||||
);
|
||||
});
|
||||
const enabled = matches.filter(({ metadata }) => !metadata.disabled);
|
||||
if (enabled.length === 1) {
|
||||
return enabled[0].executor(enabled[0].metadata.id, args, context);
|
||||
}
|
||||
if (enabled.length > 1) {
|
||||
return `Skill "${skillName}" is ambiguous. Use one of: ${enabled.map(({ metadata }) => metadata.id).join(", ")}`;
|
||||
}
|
||||
if (matches.length > 0) {
|
||||
return `Skill "${skillName}" is configured but disabled.`;
|
||||
}
|
||||
const available = entries
|
||||
.filter(({ metadata }) => !metadata.disabled)
|
||||
.map(({ metadata }) => metadata.id)
|
||||
.sort((left, right) => left.localeCompare(right));
|
||||
return available.length > 0
|
||||
? `Skill "${skillName}" not found. Available skills: ${available.join(", ")}`
|
||||
: "No skills are currently available.";
|
||||
};
|
||||
|
||||
Object.defineProperty(executor, "configuredSkills", {
|
||||
get: () => resolveEntries().map(({ metadata }) => metadata),
|
||||
enumerable: true,
|
||||
configurable: false,
|
||||
});
|
||||
return executor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Present multiple instruction sources as one extension/tool surface. This is
|
||||
* used by the hub to overlay host-local Agent Plugin skills on a client-owned
|
||||
* instruction snapshot without moving package discovery into each client.
|
||||
*/
|
||||
export function combineUserInstructionConfigServices(
|
||||
services: ReadonlyArray<UserInstructionConfigService>,
|
||||
): UserInstructionConfigService {
|
||||
if (services.length === 0) {
|
||||
throw new Error("At least one user instruction service is required.");
|
||||
}
|
||||
if (services.length === 1) {
|
||||
return services[0];
|
||||
}
|
||||
|
||||
const listRecords = <
|
||||
TConfig extends UserInstructionConfig = UserInstructionConfig,
|
||||
>(
|
||||
type: UserInstructionConfigType,
|
||||
): UserInstructionConfigRecord<TConfig>[] => {
|
||||
const records = new Map<string, UserInstructionConfigRecord<TConfig>>();
|
||||
for (const service of services) {
|
||||
for (const record of service.listRecords<TConfig>(type)) {
|
||||
if (!records.has(record.id)) {
|
||||
records.set(record.id, record);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...records.values()];
|
||||
};
|
||||
const listRuntimeCommands = (): AvailableRuntimeCommand[] => {
|
||||
const commands = new Map<string, AvailableRuntimeCommand>();
|
||||
for (const service of services) {
|
||||
for (const command of service.listRuntimeCommands()) {
|
||||
const normalized = normalizeRuntimeCommandName(command.name);
|
||||
if (normalized && !commands.has(normalized)) {
|
||||
commands.set(normalized, command);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...commands.values()].sort((left, right) =>
|
||||
left.name.localeCompare(right.name),
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
start: async () => {
|
||||
await Promise.all(services.map((service) => service.start()));
|
||||
},
|
||||
stop: () => {
|
||||
for (const service of services) {
|
||||
service.stop();
|
||||
}
|
||||
},
|
||||
refreshType: async (type) => {
|
||||
await Promise.all(services.map((service) => service.refreshType(type)));
|
||||
},
|
||||
listRecords,
|
||||
listRuntimeCommands,
|
||||
resolveRuntimeSlashCommand: (input, options) => {
|
||||
for (const service of services) {
|
||||
const resolved = service.resolveRuntimeSlashCommand(input, options);
|
||||
if (resolved !== input) {
|
||||
return resolved;
|
||||
}
|
||||
}
|
||||
return input;
|
||||
},
|
||||
hasConfiguredSkills: (allowedSkillNames) =>
|
||||
services.some((service) =>
|
||||
service.hasConfiguredSkills(allowedSkillNames),
|
||||
),
|
||||
createSkillsExecutor: (allowedSkillNames) =>
|
||||
createCombinedSkillsExecutor(services, allowedSkillNames),
|
||||
createExtension: (options): AgentExtension => ({
|
||||
name: "cline-combined-user-instructions",
|
||||
manifest: {
|
||||
capabilities: [
|
||||
options.includeRules ? "rules" : undefined,
|
||||
options.registerSkillsTool ? "tools" : undefined,
|
||||
options.includeSkills || options.includeWorkflows
|
||||
? "commands"
|
||||
: undefined,
|
||||
].filter((value): value is "rules" | "tools" | "commands" =>
|
||||
Boolean(value),
|
||||
),
|
||||
},
|
||||
setup(api) {
|
||||
if (options.includeRules) {
|
||||
api.registerRule({
|
||||
id: "cline-combined-user-instructions:rules",
|
||||
source: "combined-user-instructions",
|
||||
content: () =>
|
||||
formatRulesForSystemPrompt(
|
||||
listRecords<RuleConfig>("rule")
|
||||
.map((record) => record.item)
|
||||
.filter((rule) => rule.disabled !== true),
|
||||
),
|
||||
});
|
||||
}
|
||||
if (options.registerSkillsTool) {
|
||||
api.registerTool(
|
||||
createSkillsTool(
|
||||
createCombinedSkillsExecutor(services, options.allowedSkillNames),
|
||||
) as AgentTool,
|
||||
);
|
||||
}
|
||||
for (const command of listRuntimeCommands().filter(
|
||||
(command) =>
|
||||
(command.kind === "skill" && options.includeSkills) ||
|
||||
(command.kind === "workflow" && options.includeWorkflows),
|
||||
)) {
|
||||
api.registerCommand({
|
||||
name: command.name,
|
||||
description: command.description,
|
||||
handler: (input) => {
|
||||
const trimmed = input.trim();
|
||||
return trimmed
|
||||
? `${command.instructions}\n\n${trimmed}`
|
||||
: command.instructions;
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
class DefaultUserInstructionConfigService
|
||||
implements UserInstructionConfigService
|
||||
{
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
export type {
|
||||
AgentPluginPackageDiagnostic,
|
||||
AgentPluginPackageDiagnosticScope,
|
||||
AgentPluginPackageLoadReport,
|
||||
AgentPluginPackageManifest,
|
||||
AgentPluginPackageMcpServer,
|
||||
AgentPluginPackageSkill,
|
||||
AgentSkillMetadata,
|
||||
LoadAgentPluginPackagesOptions,
|
||||
LoadedAgentPluginPackage,
|
||||
ParsedAgentSkill,
|
||||
} from "./agent-plugin";
|
||||
export {
|
||||
AGENT_PLUGINS_V1_MANIFEST_SCHEMA,
|
||||
AGENT_PLUGINS_V1_MCP_SCHEMA,
|
||||
loadAgentPluginPackages,
|
||||
parseAgentSkillMarkdown,
|
||||
} from "./agent-plugin";
|
||||
export type { ResolveAgentPluginPathsOptions } from "./plugin/plugin-config-loader";
|
||||
export {
|
||||
discoverPluginModulePaths,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
||||
import { SseError } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js";
|
||||
@@ -57,6 +58,136 @@ vi.mock("@modelcontextprotocol/sdk/client/index.js", () => ({
|
||||
|
||||
import { createDefaultMcpServerClientFactory } from "./client";
|
||||
import { listMcpServerOAuthStatuses } from "./config-loader";
|
||||
import {
|
||||
createMcpSdkTransport,
|
||||
createOriginBoundMcpHeadersFetch,
|
||||
} from "./oauth";
|
||||
|
||||
describe("origin-bound MCP headers", () => {
|
||||
it("removes configured headers while following a cross-origin redirect", async () => {
|
||||
const requests: Array<{ url: string; headers: Headers }> = [];
|
||||
const guardedFetch = createOriginBoundMcpHeadersFetch({
|
||||
endpointUrl: "https://mcp.example.test/start",
|
||||
configuredHeaders: { "X-Tenant": "portable" },
|
||||
baseFetch: async (url, init) => {
|
||||
requests.push({
|
||||
url: String(url),
|
||||
headers: new Headers(init?.headers),
|
||||
});
|
||||
return requests.length === 1
|
||||
? new Response(null, {
|
||||
status: 302,
|
||||
headers: { location: "https://other.example.test/mcp" },
|
||||
})
|
||||
: new Response("ok");
|
||||
},
|
||||
});
|
||||
|
||||
await guardedFetch("https://mcp.example.test/start", {
|
||||
headers: {
|
||||
"X-Tenant": "portable",
|
||||
"X-Client-Generated": "keep",
|
||||
},
|
||||
});
|
||||
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[0].headers.get("x-tenant")).toBe("portable");
|
||||
expect(requests[1].headers.get("x-tenant")).toBeNull();
|
||||
expect(requests[1].headers.get("x-client-generated")).toBe("keep");
|
||||
});
|
||||
|
||||
it("does not send configured headers to a cross-origin SSE event endpoint", async () => {
|
||||
let receivedHeaders: Headers | undefined;
|
||||
const guardedFetch = createOriginBoundMcpHeadersFetch({
|
||||
endpointUrl: "https://mcp.example.test/events",
|
||||
configuredHeaders: { Authorization: "visible-package-data" },
|
||||
baseFetch: async (_url, init) => {
|
||||
receivedHeaders = new Headers(init?.headers);
|
||||
return new Response("ok");
|
||||
},
|
||||
});
|
||||
|
||||
await guardedFetch("https://messages.example.test/send", {
|
||||
headers: {
|
||||
Authorization: "visible-package-data",
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
expect(receivedHeaders?.get("authorization")).toBeNull();
|
||||
expect(receivedHeaders?.get("accept")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("gives client-generated MCP and authorization headers precedence", async () => {
|
||||
const requests: Headers[] = [];
|
||||
const oauthProvider = {
|
||||
tokens: async () => ({
|
||||
access_token: "client-token",
|
||||
token_type: "Bearer",
|
||||
}),
|
||||
} as OAuthClientProvider;
|
||||
const transport = createMcpSdkTransport({
|
||||
registration: {
|
||||
name: "portable.remote",
|
||||
transport: {
|
||||
type: "streamableHttp",
|
||||
url: "https://mcp.example.test/mcp",
|
||||
headers: {
|
||||
Authorization: "plugin-token",
|
||||
"Mcp-Protocol-Version": "plugin-version",
|
||||
"Mcp-Session-Id": "plugin-session",
|
||||
"X-Tenant": "portable",
|
||||
},
|
||||
},
|
||||
},
|
||||
oauthProvider,
|
||||
restrictConfiguredHeadersToOrigin: true,
|
||||
fetch: async (_url, init) => {
|
||||
requests.push(new Headers(init?.headers));
|
||||
if (init?.method === "DELETE") {
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
const request = (await new Response(init?.body).json()) as {
|
||||
id: number;
|
||||
};
|
||||
return new Response(
|
||||
JSON.stringify({ jsonrpc: "2.0", id: request.id, result: {} }),
|
||||
{
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"mcp-session-id": "client-session",
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
transport.onmessage = () => {};
|
||||
await transport.start();
|
||||
transport.setProtocolVersion("client-version");
|
||||
|
||||
await transport.send({
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {},
|
||||
});
|
||||
await transport.send({
|
||||
jsonrpc: "2.0",
|
||||
id: 2,
|
||||
method: "tools/list",
|
||||
params: {},
|
||||
});
|
||||
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[0].get("authorization")).toBe("Bearer client-token");
|
||||
expect(requests[0].get("mcp-protocol-version")).toBe("client-version");
|
||||
expect(requests[0].get("mcp-session-id")).toBeNull();
|
||||
expect(requests[0].get("x-tenant")).toBe("portable");
|
||||
expect(requests[1].get("mcp-session-id")).toBe("client-session");
|
||||
|
||||
await transport.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SDK URL MCP client timeout", () => {
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -424,6 +424,36 @@ describe("mcp client request timeout", () => {
|
||||
await waitFor(() => !isProcessRunning(pid));
|
||||
}, 30_000);
|
||||
|
||||
it("waits for the stdio child to close before disconnect resolves", async () => {
|
||||
const serverCwd = mkdtempSync(join(tempRoot, "disconnect-cwd-"));
|
||||
const pidFile = join(tempRoot, `disconnect-${Date.now()}.pid`);
|
||||
const registration = fakeServerRegistration({
|
||||
delayMs: 0,
|
||||
pidFile,
|
||||
});
|
||||
if (registration.transport.type !== "stdio") {
|
||||
throw new Error("Expected stdio registration.");
|
||||
}
|
||||
registration.transport.cwd = serverCwd;
|
||||
const client = await createDefaultMcpServerClientFactory()(registration);
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await waitFor(() => existsSync(pidFile));
|
||||
const pid = Number(readFileSync(pidFile, "utf8"));
|
||||
expect(isProcessRunning(pid)).toBe(true);
|
||||
|
||||
await client.disconnect();
|
||||
|
||||
expect(isProcessRunning(pid)).toBe(false);
|
||||
expect(() =>
|
||||
rmSync(serverCwd, { recursive: true, force: true }),
|
||||
).not.toThrow();
|
||||
} finally {
|
||||
await client.disconnect().catch(() => {});
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("aborts a long stdio tool call without waiting for its timeout", async () => {
|
||||
const factory = createDefaultMcpServerClientFactory();
|
||||
const client = await factory(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import {
|
||||
type AgentToolContext,
|
||||
@@ -68,11 +69,36 @@ export const DEFAULT_MCP_CONNECT_TIMEOUT_MS = 3_000;
|
||||
export const DEFAULT_HTTP_MCP_CONNECT_TIMEOUT_MS = 10_000;
|
||||
const DEFAULT_HTTP_MCP_REDIRECT_URL =
|
||||
"http://127.0.0.1:1456/mcp/oauth/callback";
|
||||
const STDIO_MCP_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 250;
|
||||
const STDIO_MCP_FORCED_SHUTDOWN_TIMEOUT_MS = 2_000;
|
||||
|
||||
function toErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
async function settlesWithin(
|
||||
promise: Promise<void>,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (result: boolean) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(result);
|
||||
};
|
||||
const timeout = setTimeout(() => finish(false), timeoutMs);
|
||||
timeout.unref();
|
||||
promise.then(
|
||||
() => finish(true),
|
||||
() => finish(true),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Both stdio framings were tried during connect and both failed. When the two
|
||||
* attempts failed the same way (typically both timed out), the shared message
|
||||
@@ -173,6 +199,7 @@ class NewlineMessageParser {
|
||||
class StdioMcpClient implements McpServerClient {
|
||||
private readonly registration: McpServerRegistration;
|
||||
private process?: ChildProcessWithoutNullStreams;
|
||||
private processClose?: Promise<void>;
|
||||
private nextRequestId = 1;
|
||||
private readonly pending = new Map<
|
||||
number,
|
||||
@@ -222,6 +249,7 @@ class StdioMcpClient implements McpServerClient {
|
||||
capabilities: {},
|
||||
clientInfo: { name: "@cline/core", version: "0.0.0" },
|
||||
};
|
||||
await this.ensureAgentPluginDataDirectory();
|
||||
this.spawnProcess("newline");
|
||||
try {
|
||||
await this.request(
|
||||
@@ -251,17 +279,74 @@ class StdioMcpClient implements McpServerClient {
|
||||
this.connected = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Agent Plugin discovery is intentionally read-only. Create the plugin's
|
||||
* persistent writable data directory only when its stdio server is about to
|
||||
* launch, as required by the Agent Plugins MCP runtime contract.
|
||||
*/
|
||||
private async ensureAgentPluginDataDirectory(): Promise<void> {
|
||||
if (this.registration.metadata?.source !== "agent-plugin") {
|
||||
return;
|
||||
}
|
||||
const pluginDataPath = this.registration.metadata.pluginDataPath;
|
||||
if (typeof pluginDataPath !== "string" || pluginDataPath.length === 0) {
|
||||
return;
|
||||
}
|
||||
await mkdir(pluginDataPath, { recursive: true });
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
const child = this.process;
|
||||
const processClose = this.processClose;
|
||||
this.connected = false;
|
||||
this.process = undefined;
|
||||
this.processClose = undefined;
|
||||
this.failAllPending(
|
||||
new Error(`Disconnected from MCP server "${this.registration.name}".`),
|
||||
);
|
||||
if (!child) {
|
||||
if (!processClose) {
|
||||
return;
|
||||
}
|
||||
if (!child) {
|
||||
if (
|
||||
!(await settlesWithin(
|
||||
processClose,
|
||||
STDIO_MCP_FORCED_SHUTDOWN_TIMEOUT_MS,
|
||||
))
|
||||
) {
|
||||
throw new Error(
|
||||
`MCP process for "${this.registration.name}" did not close during disconnect.`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Closing stdin first lets well-behaved MCP servers shut down cleanly. This
|
||||
// matters on Windows, where a live child process keeps its cwd locked.
|
||||
if (!child.stdin.destroyed) {
|
||||
child.stdin.end();
|
||||
}
|
||||
if (
|
||||
await settlesWithin(processClose, STDIO_MCP_GRACEFUL_SHUTDOWN_TIMEOUT_MS)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill();
|
||||
if (
|
||||
await settlesWithin(processClose, STDIO_MCP_FORCED_SHUTDOWN_TIMEOUT_MS)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
child.kill("SIGKILL");
|
||||
if (
|
||||
!(await settlesWithin(processClose, STDIO_MCP_FORCED_SHUTDOWN_TIMEOUT_MS))
|
||||
) {
|
||||
throw new Error(
|
||||
`MCP process for "${this.registration.name}" did not exit during disconnect.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async listTools(): Promise<readonly McpToolDescriptor[]> {
|
||||
@@ -339,6 +424,9 @@ class StdioMcpClient implements McpServerClient {
|
||||
});
|
||||
|
||||
this.process = child;
|
||||
this.processClose = new Promise((resolve) => {
|
||||
child.once("close", () => resolve());
|
||||
});
|
||||
child.stdout.on("data", (chunk: Buffer) => this.handleStdout(chunk));
|
||||
child.stderr.on("data", (chunk: Buffer) => {
|
||||
if (this.process !== child) {
|
||||
@@ -557,6 +645,8 @@ export interface DefaultMcpServerClientFactoryOptions {
|
||||
clientName?: string;
|
||||
clientVersion?: string;
|
||||
fetch?: FetchLike;
|
||||
/** Keep literal configured headers on the declared MCP origin only. */
|
||||
restrictConfiguredHeadersToOrigin?: boolean;
|
||||
}
|
||||
|
||||
export interface ProbeMcpServerConnectionOptions
|
||||
@@ -632,6 +722,8 @@ class SdkUrlMcpClient implements McpServerClient {
|
||||
registration: this.registration,
|
||||
oauthProvider,
|
||||
fetch: this.options.fetch,
|
||||
restrictConfiguredHeadersToOrigin:
|
||||
this.options.restrictConfiguredHeadersToOrigin,
|
||||
});
|
||||
await client.connect(transport, {
|
||||
timeout: this.connectAttemptTimeoutMs,
|
||||
|
||||
@@ -127,6 +127,43 @@ describe("InMemoryMcpManager", () => {
|
||||
expect(client.disconnect).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("disconnects the remaining servers when one fails during dispose", async () => {
|
||||
const wedgedClient = createClient({
|
||||
disconnect: vi.fn(async () => {
|
||||
throw new Error("process did not exit");
|
||||
}),
|
||||
});
|
||||
const healthyClient = createClient();
|
||||
const clients = new Map([
|
||||
["wedged", wedgedClient],
|
||||
["healthy", healthyClient],
|
||||
]);
|
||||
const manager = new InMemoryMcpManager({
|
||||
clientFactory: async (registration) => {
|
||||
const client = clients.get(registration.name);
|
||||
if (!client) {
|
||||
throw new Error(`Unexpected server: ${registration.name}`);
|
||||
}
|
||||
return client;
|
||||
},
|
||||
});
|
||||
// "wedged" is registered first, so dispose reaches it before "healthy".
|
||||
await manager.registerServer({
|
||||
name: "wedged",
|
||||
transport: { type: "stdio", command: "node" },
|
||||
});
|
||||
await manager.registerServer({
|
||||
name: "healthy",
|
||||
transport: { type: "stdio", command: "node" },
|
||||
});
|
||||
await manager.connectServer("wedged");
|
||||
await manager.connectServer("healthy");
|
||||
|
||||
await expect(manager.dispose()).rejects.toThrow(AggregateError);
|
||||
expect(wedgedClient.disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(healthyClient.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("replaces the client when its timeout snapshot changes", async () => {
|
||||
const firstClient = createClient();
|
||||
const secondClient = createClient();
|
||||
|
||||
@@ -180,8 +180,18 @@ export class InMemoryMcpManager implements McpManager {
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
const names = [...this.servers.keys()];
|
||||
// One wedged server (e.g. a stdio child that never exits) must not stop
|
||||
// the remaining servers from being disconnected.
|
||||
const errors: unknown[] = [];
|
||||
for (const name of names) {
|
||||
await this.unregisterServer(name);
|
||||
try {
|
||||
await this.unregisterServer(name);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
throw new AggregateError(errors, "MCP manager dispose failed");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,120 @@ export type McpSdkAuthCapableTransport =
|
||||
| SSEClientTransport
|
||||
| StreamableHTTPClientTransport;
|
||||
|
||||
const MCP_REDIRECT_STATUS_CODES = new Set([301, 302, 303, 307, 308]);
|
||||
const MAX_MCP_REDIRECTS = 20;
|
||||
const CROSS_ORIGIN_SENSITIVE_HEADERS = new Set([
|
||||
"authorization",
|
||||
"cookie",
|
||||
"proxy-authorization",
|
||||
]);
|
||||
const CLIENT_MANAGED_MCP_HEADERS = new Set([
|
||||
"mcp-protocol-version",
|
||||
"mcp-session-id",
|
||||
]);
|
||||
|
||||
function resolveConfiguredMcpHeaders(input: {
|
||||
headers?: Record<string, string>;
|
||||
oauthProvider?: OAuthClientProvider;
|
||||
restrictConfiguredHeadersToOrigin?: boolean;
|
||||
}): Record<string, string> | undefined {
|
||||
if (!input.headers || !input.restrictConfiguredHeadersToOrigin) {
|
||||
return input.headers;
|
||||
}
|
||||
|
||||
const entries = Object.entries(input.headers).filter(([name]) => {
|
||||
const normalized = name.toLowerCase();
|
||||
if (CLIENT_MANAGED_MCP_HEADERS.has(normalized)) {
|
||||
return false;
|
||||
}
|
||||
return normalized !== "authorization" || !input.oauthProvider;
|
||||
});
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
|
||||
export function createOriginBoundMcpHeadersFetch(input: {
|
||||
endpointUrl: string;
|
||||
configuredHeaders: Record<string, string>;
|
||||
baseFetch?: FetchLike;
|
||||
}): FetchLike {
|
||||
const configuredOrigin = new URL(input.endpointUrl).origin;
|
||||
const configuredHeaderNames = new Set(
|
||||
Object.keys(input.configuredHeaders).map((name) => name.toLowerCase()),
|
||||
);
|
||||
const baseFetch = input.baseFetch ?? globalThis.fetch;
|
||||
const stripCrossOriginHeaders = (headers: Headers): void => {
|
||||
const headerNamesToDelete: string[] = [];
|
||||
headers.forEach((_value, headerName) => {
|
||||
const normalized = headerName.toLowerCase();
|
||||
if (
|
||||
configuredHeaderNames.has(normalized) ||
|
||||
CROSS_ORIGIN_SENSITIVE_HEADERS.has(normalized)
|
||||
) {
|
||||
headerNamesToDelete.push(headerName);
|
||||
}
|
||||
});
|
||||
for (const headerName of headerNamesToDelete) {
|
||||
headers.delete(headerName);
|
||||
}
|
||||
};
|
||||
|
||||
return async (requestInput, requestInit) => {
|
||||
const initialRequest =
|
||||
requestInput instanceof Request
|
||||
? new Request(requestInput, requestInit as RequestInit)
|
||||
: new Request(String(requestInput), requestInit as RequestInit);
|
||||
let currentUrl = new URL(initialRequest.url);
|
||||
let method = initialRequest.method;
|
||||
let body =
|
||||
method === "GET" || method === "HEAD"
|
||||
? undefined
|
||||
: await initialRequest.clone().arrayBuffer();
|
||||
const headers = new Headers(initialRequest.headers);
|
||||
if (currentUrl.origin !== configuredOrigin) {
|
||||
stripCrossOriginHeaders(headers);
|
||||
}
|
||||
|
||||
for (let redirectCount = 0; ; redirectCount += 1) {
|
||||
const response = await baseFetch(currentUrl, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
redirect: "manual",
|
||||
signal: initialRequest.signal,
|
||||
});
|
||||
if (!MCP_REDIRECT_STATUS_CODES.has(response.status)) {
|
||||
return response;
|
||||
}
|
||||
if (redirectCount >= MAX_MCP_REDIRECTS) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new Error(
|
||||
`MCP endpoint exceeded ${MAX_MCP_REDIRECTS} redirects.`,
|
||||
);
|
||||
}
|
||||
const location = response.headers.get("location");
|
||||
if (!location) {
|
||||
return response;
|
||||
}
|
||||
const nextUrl = new URL(location, currentUrl);
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
if (nextUrl.origin !== configuredOrigin) {
|
||||
stripCrossOriginHeaders(headers);
|
||||
}
|
||||
if (
|
||||
(response.status === 303 && method !== "GET" && method !== "HEAD") ||
|
||||
((response.status === 301 || response.status === 302) &&
|
||||
method === "POST")
|
||||
) {
|
||||
method = "GET";
|
||||
body = undefined;
|
||||
headers.delete("content-length");
|
||||
headers.delete("content-type");
|
||||
}
|
||||
currentUrl = nextUrl;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateMcpOAuthProviderContextOptions {
|
||||
settingsPath?: string;
|
||||
serverName: string;
|
||||
@@ -409,6 +523,7 @@ export function createMcpSdkTransport(input: {
|
||||
registration: McpServerRegistration;
|
||||
oauthProvider?: OAuthClientProvider;
|
||||
fetch?: FetchLike;
|
||||
restrictConfiguredHeadersToOrigin?: boolean;
|
||||
}): McpSdkAuthCapableTransport {
|
||||
const transport = input.registration.transport;
|
||||
if (transport.type === "stdio") {
|
||||
@@ -417,19 +532,32 @@ export function createMcpSdkTransport(input: {
|
||||
);
|
||||
}
|
||||
|
||||
const requestInit = transport.headers
|
||||
const configuredHeaders = resolveConfiguredMcpHeaders({
|
||||
headers: transport.headers,
|
||||
oauthProvider: input.oauthProvider,
|
||||
restrictConfiguredHeadersToOrigin: input.restrictConfiguredHeadersToOrigin,
|
||||
});
|
||||
const requestInit = configuredHeaders
|
||||
? {
|
||||
headers: transport.headers,
|
||||
headers: configuredHeaders,
|
||||
}
|
||||
: undefined;
|
||||
const effectiveFetch =
|
||||
input.restrictConfiguredHeadersToOrigin && configuredHeaders
|
||||
? createOriginBoundMcpHeadersFetch({
|
||||
endpointUrl: transport.url,
|
||||
configuredHeaders,
|
||||
baseFetch: input.fetch,
|
||||
})
|
||||
: input.fetch;
|
||||
// The upstream transports only surface a typed UnauthorizedError for a 401
|
||||
// when an OAuth provider is present. For passive connections without stored
|
||||
// tokens, translate the response at the fetch boundary so callers can show
|
||||
// an explicit sign-in action without starting discovery/registration/PKCE.
|
||||
const transportFetch: FetchLike | undefined = input.oauthProvider
|
||||
? input.fetch
|
||||
? effectiveFetch
|
||||
: async (url, init) => {
|
||||
const response = await (input.fetch ?? globalThis.fetch)(url, init);
|
||||
const response = await (effectiveFetch ?? globalThis.fetch)(url, init);
|
||||
if (response.status === 401) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw new UnauthorizedError("MCP server requires authorization");
|
||||
@@ -445,7 +573,7 @@ export function createMcpSdkTransport(input: {
|
||||
// passed-through 401 fails the connection with an SseError carrying
|
||||
// the HTTP code that isMcpUnauthorizedError recognizes.
|
||||
eventSourceInit: {
|
||||
fetch: (url, init) => (input.fetch ?? globalThis.fetch)(url, init),
|
||||
fetch: (url, init) => (effectiveFetch ?? globalThis.fetch)(url, init),
|
||||
},
|
||||
fetch: transportFetch,
|
||||
});
|
||||
|
||||
@@ -777,7 +777,7 @@ export function createSkillsTool(
|
||||
get() {
|
||||
const skills = executor.configuredSkills
|
||||
?.filter((s) => !s.disabled)
|
||||
.map((s) => s.name);
|
||||
.map((s) => (s.id.includes(":") ? s.id : s.name));
|
||||
if (skills && skills.length > 0) {
|
||||
return `${baseDescription} Available skills: ${skills.join(", ")}.`;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,15 @@ type DiscoveryMockOptions = {
|
||||
* settles under this file's fake timers.
|
||||
*/
|
||||
activity?: { activeSessionCount: number; participantClientCount: number };
|
||||
/**
|
||||
* This client's own build identity; omitted keeps the real resolution
|
||||
* (env-driven buildId/epoch plus the actual core package version).
|
||||
*/
|
||||
selfIdentity?: {
|
||||
buildId?: string;
|
||||
buildEpochMs?: number;
|
||||
coreVersion?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function mockDiscovery(options: DiscoveryMockOptions): void {
|
||||
@@ -38,6 +47,9 @@ function mockDiscovery(options: DiscoveryMockOptions): void {
|
||||
return {
|
||||
...actual,
|
||||
resolveHubBuildId: () => options.expectedBuildId ?? "current-build",
|
||||
...(options.selfIdentity
|
||||
? { resolveHubBuildIdentity: () => options.selfIdentity }
|
||||
: {}),
|
||||
readHubDiscovery: vi.fn(async () => options.record),
|
||||
probeHubServer: vi.fn(async () => options.probe),
|
||||
};
|
||||
@@ -62,6 +74,93 @@ describe("checkManagedHubBuildMismatch", () => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
// Two artifacts of the same release cut from different commits never share
|
||||
// a build fingerprint or epoch (e.g. desktop-v0.0.22 and cli-v3.0.61 both
|
||||
// bundling core 0.0.82). Neither side can act on that - "update" installs
|
||||
// nothing newer - so neither direction may prompt.
|
||||
it("does not prompt for a same-release hub with a different fingerprint (hub built earlier)", async () => {
|
||||
mockDiscovery({
|
||||
expectedBuildId: "desktop-fingerprint",
|
||||
selfIdentity: {
|
||||
buildId: "desktop-fingerprint",
|
||||
buildEpochMs: 2_000,
|
||||
coreVersion: "0.0.82",
|
||||
},
|
||||
record: liveRecord,
|
||||
probe: {
|
||||
protocolVersion: "v1",
|
||||
buildId: "cli-fingerprint",
|
||||
buildEpochMs: 1_000,
|
||||
coreVersion: "0.0.82",
|
||||
host: "127.0.0.1",
|
||||
port: 59999,
|
||||
url: "ws://127.0.0.1:59999/hub",
|
||||
},
|
||||
});
|
||||
const { checkManagedHubBuildMismatch } = await import(
|
||||
"./managed-hub-build-watcher"
|
||||
);
|
||||
|
||||
// Without the same-release rule this would classify as outdated_hub.
|
||||
await expect(checkManagedHubBuildMismatch()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not prompt for a same-release hub with a different fingerprint (hub built later)", async () => {
|
||||
mockDiscovery({
|
||||
expectedBuildId: "desktop-fingerprint",
|
||||
selfIdentity: {
|
||||
buildId: "desktop-fingerprint",
|
||||
buildEpochMs: 1_000,
|
||||
coreVersion: "0.0.82",
|
||||
},
|
||||
record: liveRecord,
|
||||
probe: {
|
||||
protocolVersion: "v1",
|
||||
buildId: "cli-fingerprint",
|
||||
buildEpochMs: 2_000,
|
||||
coreVersion: "0.0.82",
|
||||
host: "127.0.0.1",
|
||||
port: 59999,
|
||||
url: "ws://127.0.0.1:59999/hub",
|
||||
},
|
||||
});
|
||||
const { checkManagedHubBuildMismatch } = await import(
|
||||
"./managed-hub-build-watcher"
|
||||
);
|
||||
|
||||
// Without the same-release rule this would classify as build_mismatch.
|
||||
await expect(checkManagedHubBuildMismatch()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("still prompts across genuinely different releases", async () => {
|
||||
mockDiscovery({
|
||||
expectedBuildId: "desktop-fingerprint",
|
||||
selfIdentity: {
|
||||
buildId: "desktop-fingerprint",
|
||||
buildEpochMs: 1_000,
|
||||
coreVersion: "0.0.82",
|
||||
},
|
||||
record: liveRecord,
|
||||
probe: {
|
||||
protocolVersion: "v1",
|
||||
buildId: "cli-fingerprint",
|
||||
buildEpochMs: 2_000,
|
||||
coreVersion: "0.0.83",
|
||||
host: "127.0.0.1",
|
||||
port: 59999,
|
||||
url: "ws://127.0.0.1:59999/hub",
|
||||
},
|
||||
});
|
||||
const { checkManagedHubBuildMismatch } = await import(
|
||||
"./managed-hub-build-watcher"
|
||||
);
|
||||
|
||||
await expect(checkManagedHubBuildMismatch()).resolves.toMatchObject({
|
||||
reason: "build_mismatch",
|
||||
hubCoreVersion: "0.0.83",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a live hub running a newer build", async () => {
|
||||
vi.stubEnv("CLINE_HUB_BUILD_EPOCH_MS", "1000");
|
||||
mockDiscovery({
|
||||
|
||||
@@ -143,12 +143,29 @@ export async function checkManagedHubBuildMismatch(): Promise<
|
||||
if (compatibility.reason === "unsupported_protocol") {
|
||||
return report("unsupported_protocol");
|
||||
}
|
||||
// Two independently built artifacts of the same release never share a
|
||||
// build fingerprint: a CLI and a desktop app cut from different commits
|
||||
// at the same core version carry different buildIds and build epochs, so
|
||||
// they disagree forever. That disagreement is not user-actionable in
|
||||
// either direction - "update" would install nothing newer, and the
|
||||
// prompt loops until the next release happens to align the fingerprints.
|
||||
// Same core version therefore never prompts; the fingerprint keeps its
|
||||
// role in the reuse/retire total order, where its antisymmetry matters,
|
||||
// but it does not drive prompts on its own.
|
||||
const self = resolveHubBuildIdentity();
|
||||
if (
|
||||
self.coreVersion &&
|
||||
healthy.coreVersion &&
|
||||
self.coreVersion === healthy.coreVersion
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
// Only a Hub this client is strictly newer than gets the "older Hub" copy;
|
||||
// that is the case the retire path defers while sessions are live. A newer
|
||||
// Hub - or one that carries too little metadata to order, where updating
|
||||
// this client is what supplies the missing ordering - is a client-update
|
||||
// prompt as before.
|
||||
if (compareHubBuilds(resolveHubBuildIdentity(), healthy) > 0) {
|
||||
if (compareHubBuilds(self, healthy) > 0) {
|
||||
return await withHubSessionActivity(
|
||||
report("outdated_hub"),
|
||||
record.authToken,
|
||||
|
||||
@@ -94,6 +94,34 @@ describe("HubSessionClient", () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("sends explicit Agent Plugin paths to the hub execution host", async () => {
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
MockWebSocket.commandPayloads.set("session.create", {
|
||||
session: { sessionId: "agent-plugin-session" },
|
||||
});
|
||||
const client = new HubSessionClient({
|
||||
address: "ws://127.0.0.1:25463/hub",
|
||||
clientId: "client-1",
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
await client.startRuntimeSession({
|
||||
workspaceRoot: "/hub/workspace",
|
||||
provider: "cline",
|
||||
model: "test-model",
|
||||
enableTools: true,
|
||||
agentPluginPaths: ["./portable-plugin"],
|
||||
});
|
||||
|
||||
const createCommand = MockWebSocket.sentCommands.find(
|
||||
(command) => command.command === "session.create",
|
||||
);
|
||||
expect(createCommand?.payload?.sessionConfig).toMatchObject({
|
||||
agentPluginPaths: ["./portable-plugin"],
|
||||
});
|
||||
client.close();
|
||||
});
|
||||
|
||||
it("normalizes run.failed events to include a top-level error", async () => {
|
||||
vi.stubGlobal("WebSocket", MockWebSocket);
|
||||
const client = new HubSessionClient({
|
||||
|
||||
@@ -359,6 +359,7 @@ export class HubSessionClient {
|
||||
enableSpawnAgent: request.enableSpawn !== false,
|
||||
enableAgentTeams: request.enableTeams !== false,
|
||||
disableMcpSettingsTools: request.disableMcpSettingsTools,
|
||||
agentPluginPaths: request.agentPluginPaths,
|
||||
missionLogIntervalSteps: request.missionStepInterval,
|
||||
missionLogIntervalMs: request.missionTimeIntervalMs,
|
||||
},
|
||||
@@ -527,6 +528,7 @@ export class HubSessionClient {
|
||||
enableSpawnAgent: request.enableSpawn !== false,
|
||||
enableAgentTeams: request.enableTeams !== false,
|
||||
disableMcpSettingsTools: request.disableMcpSettingsTools,
|
||||
agentPluginPaths: request.agentPluginPaths,
|
||||
missionLogIntervalSteps: request.missionStepInterval,
|
||||
missionLogIntervalMs: request.missionTimeIntervalMs,
|
||||
},
|
||||
|
||||
@@ -116,6 +116,7 @@ export function createLocalHubScheduleRuntimeHandlers(
|
||||
enableSpawnAgent: request.enableSpawn !== false,
|
||||
enableAgentTeams: request.enableTeams !== false,
|
||||
disableMcpSettingsTools: request.disableMcpSettingsTools,
|
||||
agentPluginPaths: request.agentPluginPaths,
|
||||
missionLogIntervalSteps: request.missionStepInterval,
|
||||
missionLogIntervalMs: request.missionTimeIntervalMs,
|
||||
},
|
||||
|
||||
@@ -99,7 +99,10 @@ describe("HubRuntimeHost", () => {
|
||||
const host = new HubRuntimeHost({ url: "ws://127.0.0.1:25463/hub" });
|
||||
|
||||
const started = await host.startSession({
|
||||
config: createConfig(),
|
||||
config: {
|
||||
...createConfig(),
|
||||
agentPluginPaths: ["./portable-plugin"],
|
||||
},
|
||||
source: SessionSource.CLI,
|
||||
localRuntime: {
|
||||
extensionContext: {
|
||||
@@ -126,6 +129,7 @@ describe("HubRuntimeHost", () => {
|
||||
systemPrompt: "system",
|
||||
mode: "act",
|
||||
checkpoint: { enabled: true },
|
||||
agentPluginPaths: ["./portable-plugin"],
|
||||
enableTools: true,
|
||||
enableSpawnAgent: true,
|
||||
enableAgentTeams: true,
|
||||
|
||||
@@ -35,12 +35,14 @@ export {
|
||||
|
||||
import type {
|
||||
AvailableRuntimeCommand,
|
||||
SkillConfig,
|
||||
UserInstructionConfig,
|
||||
UserInstructionConfigRecord,
|
||||
UserInstructionConfigService,
|
||||
UserInstructionConfigType,
|
||||
} from "../../extensions/config";
|
||||
import { normalizeRuntimeCommandName } from "../../extensions/config/runtime-commands";
|
||||
import { formatSkillInvocation } from "../../extensions/config/user-instruction-plugin";
|
||||
import type { ToolExecutors } from "../../extensions/tools";
|
||||
import {
|
||||
createSkillsTool,
|
||||
@@ -309,19 +311,8 @@ function createSnapshotSkillsExecutor(
|
||||
? `Skill "${skillName}" is ambiguous. Use one of: ${enabled.map((entry) => entry.id).join(", ")}`
|
||||
: `Skill "${skillName}" not found.`;
|
||||
}
|
||||
const skill = enabled[0].skill as {
|
||||
name: string;
|
||||
description?: string;
|
||||
instructions: string;
|
||||
};
|
||||
const trimmedArgs = args?.trim();
|
||||
const argsTag = trimmedArgs
|
||||
? `\n<command-args>${trimmedArgs}</command-args>`
|
||||
: "";
|
||||
const description = skill.description?.trim()
|
||||
? `Description: ${skill.description.trim()}\n\n`
|
||||
: "";
|
||||
return `<command-name>${skill.name}</command-name>${argsTag}\n<command-instructions>\n${description}${skill.instructions}\n</command-instructions>`;
|
||||
const skill = enabled[0].skill as SkillConfig;
|
||||
return formatSkillInvocation(skill, args);
|
||||
}) as SkillsExecutor;
|
||||
|
||||
Object.defineProperty(executor, "configuredSkills", {
|
||||
|
||||
@@ -161,7 +161,7 @@ function requireOptionalString(
|
||||
|
||||
function requireOptionalBoolean(
|
||||
payload: Record<string, unknown>,
|
||||
key: "enabled",
|
||||
key: "enabled" | "includePluginTools",
|
||||
): boolean | undefined {
|
||||
const value = payload[key];
|
||||
if (value === undefined) {
|
||||
@@ -173,6 +173,23 @@ function requireOptionalBoolean(
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireOptionalStringArray(
|
||||
payload: Record<string, unknown>,
|
||||
key: "agentPluginPaths",
|
||||
): string[] | undefined {
|
||||
const value = payload[key];
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
!value.every((entry) => typeof entry === "string")
|
||||
) {
|
||||
throw new Error(`settings payload '${key}' must be an array of strings.`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseSettingsListInput(payload: unknown): CoreSettingsListInput {
|
||||
if (payload === undefined) {
|
||||
return {};
|
||||
@@ -183,6 +200,8 @@ function parseSettingsListInput(payload: unknown): CoreSettingsListInput {
|
||||
return {
|
||||
cwd: requireOptionalString(payload, "cwd"),
|
||||
workspaceRoot: requireOptionalString(payload, "workspaceRoot"),
|
||||
agentPluginPaths: requireOptionalStringArray(payload, "agentPluginPaths"),
|
||||
includePluginTools: requireOptionalBoolean(payload, "includePluginTools"),
|
||||
availabilityContext: isPayloadObject(payload.availabilityContext)
|
||||
? (payload.availabilityContext as CoreSettingsListInput["availabilityContext"])
|
||||
: undefined,
|
||||
|
||||
@@ -75,7 +75,12 @@ describe("hub settings commands", () => {
|
||||
payload: {},
|
||||
});
|
||||
expect(listed.payload?.snapshot).toMatchObject({
|
||||
plugins: [{ path: "host:memory-plugin", enabled: true }],
|
||||
plugins: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "host:memory-plugin",
|
||||
enabled: true,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
|
||||
const toggled = await transport.handleCommand({
|
||||
@@ -90,7 +95,12 @@ describe("hub settings commands", () => {
|
||||
},
|
||||
});
|
||||
expect(toggled.payload?.snapshot).toMatchObject({
|
||||
plugins: [{ path: "host:memory-plugin", enabled: false }],
|
||||
plugins: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "host:memory-plugin",
|
||||
enabled: false,
|
||||
}),
|
||||
]),
|
||||
});
|
||||
} finally {
|
||||
await transport.stop();
|
||||
@@ -144,7 +154,12 @@ describe("hub settings commands", () => {
|
||||
payload: {
|
||||
changedTypes: ["plugins"],
|
||||
snapshot: {
|
||||
plugins: [{ path: "host:memory-plugin", enabled: false }],
|
||||
plugins: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "host:memory-plugin",
|
||||
enabled: false,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -153,7 +168,12 @@ describe("hub settings commands", () => {
|
||||
expect(settingsEvents[0]).toMatchObject({
|
||||
types: ["plugins"],
|
||||
snapshot: {
|
||||
plugins: [{ path: "host:memory-plugin", enabled: false }],
|
||||
plugins: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: "host:memory-plugin",
|
||||
enabled: false,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
@@ -235,6 +255,51 @@ describe("hub settings commands", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards explicit Agent Plugin paths to the hub settings service", async () => {
|
||||
const snapshot = {
|
||||
workflows: [],
|
||||
rules: [],
|
||||
skills: [],
|
||||
plugins: [],
|
||||
tools: [],
|
||||
mcp: [],
|
||||
};
|
||||
const settingsService = {
|
||||
list: vi.fn().mockResolvedValue(snapshot),
|
||||
} as unknown as CoreSettingsService;
|
||||
const transport = new HubServerTransport({
|
||||
runtimeHandlers: createLocalHubScheduleRuntimeHandlers(),
|
||||
scheduleOptions: { dbPath: ":memory:" },
|
||||
settingsService,
|
||||
});
|
||||
|
||||
try {
|
||||
const reply = await transport.handleCommand({
|
||||
version: "v1",
|
||||
command: "settings.list",
|
||||
requestId: "agent-plugins-list",
|
||||
clientId: "cli",
|
||||
payload: {
|
||||
cwd: "/workspace",
|
||||
workspaceRoot: "/workspace",
|
||||
agentPluginPaths: ["./portable"],
|
||||
includePluginTools: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(reply).toMatchObject({ ok: true, payload: { snapshot } });
|
||||
expect(settingsService.list).toHaveBeenCalledWith({
|
||||
cwd: "/workspace",
|
||||
workspaceRoot: "/workspace",
|
||||
agentPluginPaths: ["./portable"],
|
||||
includePluginTools: false,
|
||||
availabilityContext: undefined,
|
||||
});
|
||||
} finally {
|
||||
await transport.stop();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed settings.toggle payloads before calling settings", async () => {
|
||||
const settingsService = {
|
||||
toggle: vi.fn(),
|
||||
|
||||
@@ -225,18 +225,32 @@ export type {
|
||||
RestoreResult,
|
||||
} from "./cline-core/types";
|
||||
export type {
|
||||
AgentPluginPackageDiagnostic,
|
||||
AgentPluginPackageDiagnosticScope,
|
||||
AgentPluginPackageLoadReport,
|
||||
AgentPluginPackageManifest,
|
||||
AgentPluginPackageMcpServer,
|
||||
AgentPluginPackageSkill,
|
||||
AgentSkillMetadata,
|
||||
LoadAgentPluginFromPathOptions,
|
||||
LoadAgentPluginPackagesOptions,
|
||||
LoadedAgentPluginPackage,
|
||||
ParsedAgentSkill,
|
||||
PluginInitializationFailure,
|
||||
PluginInitializationWarning,
|
||||
PluginLoadDiagnostics,
|
||||
ResolveAgentPluginPathsOptions,
|
||||
} from "./extensions";
|
||||
export {
|
||||
AGENT_PLUGINS_V1_MANIFEST_SCHEMA,
|
||||
AGENT_PLUGINS_V1_MCP_SCHEMA,
|
||||
discoverPluginModulePaths,
|
||||
getPluginDisplayName,
|
||||
loadAgentPluginFromPath,
|
||||
loadAgentPluginPackages,
|
||||
loadAgentPluginsFromPaths,
|
||||
loadAgentPluginsFromPathsWithDiagnostics,
|
||||
parseAgentSkillMarkdown,
|
||||
resolveAgentPluginPaths,
|
||||
resolveAndLoadAgentPlugins,
|
||||
resolvePluginConfigSearchPaths,
|
||||
@@ -266,6 +280,7 @@ export type {
|
||||
WorkflowConfig,
|
||||
} from "./extensions/config";
|
||||
export {
|
||||
combineUserInstructionConfigServices,
|
||||
createRulesConfigDefinition,
|
||||
createSkillsConfigDefinition,
|
||||
createUserInstructionConfigService,
|
||||
@@ -556,6 +571,7 @@ export {
|
||||
filterDisabledTools,
|
||||
filterExtensionToolRegistrations,
|
||||
GlobalSettingsSchema,
|
||||
isAgentPluginDisabledGlobally,
|
||||
isAutoUpdateEnabledGlobally,
|
||||
isModelToolEnabledGlobally,
|
||||
isPluginDisabledGlobally,
|
||||
@@ -567,12 +583,14 @@ export {
|
||||
readPlanActModeGlobally,
|
||||
readToolAutoApproveGlobally,
|
||||
readTuiThemeGlobally,
|
||||
resolveDisabledAgentPluginNames,
|
||||
resolveDisabledPluginPaths,
|
||||
resolveDisabledToolNames,
|
||||
resolveModelToolSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setCompactionModeGlobally,
|
||||
setCompactionStrategyGlobally,
|
||||
setDisabledAgentPlugin,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setModelToolEnabledGlobally,
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
@@ -547,6 +554,142 @@ process.stdin.on("data", (chunk) => {
|
||||
}
|
||||
});
|
||||
|
||||
it("combines hub-owned Agent Plugin skills and MCP servers with client instructions", async () => {
|
||||
const tempRoot = realpathSync.native(
|
||||
mkdtempSync(join(tmpdir(), "runtime-builder-agent-plugin-")),
|
||||
);
|
||||
tempDirs.push(tempRoot);
|
||||
const previousSettingsPath = process.env.CLINE_MCP_SETTINGS_PATH;
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = join(
|
||||
tempRoot,
|
||||
"missing-settings.json",
|
||||
);
|
||||
const pluginRoot = join(tempRoot, "portable");
|
||||
const pluginSkillRoot = join(pluginRoot, "skills", "portable-review");
|
||||
const pluginSkillPath = join(pluginSkillRoot, "SKILL.md");
|
||||
const localSkillRoot = join(tempRoot, "local-skills", "local-review");
|
||||
const serverPath = join(pluginRoot, "server.js");
|
||||
mkdirSync(pluginSkillRoot, { recursive: true });
|
||||
mkdirSync(localSkillRoot, { recursive: true });
|
||||
writeFileSync(
|
||||
pluginSkillPath,
|
||||
"---\nname: portable-review\ndescription: Review with the portable plugin\n---\nUse portable guidance.",
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(localSkillRoot, "SKILL.md"),
|
||||
"---\nname: local-review\ndescription: Review locally\n---\nUse local guidance.",
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
serverPath,
|
||||
`let buffer = "";
|
||||
function write(payload) { process.stdout.write(JSON.stringify(payload) + "\\n"); }
|
||||
process.stdin.on("data", (chunk) => {
|
||||
buffer += chunk.toString("utf8");
|
||||
let newline;
|
||||
while ((newline = buffer.indexOf("\\n")) >= 0) {
|
||||
const line = buffer.slice(0, newline).trim();
|
||||
buffer = buffer.slice(newline + 1);
|
||||
if (!line) continue;
|
||||
const message = JSON.parse(line);
|
||||
if (message.method === "notifications/initialized") continue;
|
||||
if (message.method === "initialize") write({ jsonrpc: "2.0", id: message.id, result: { protocolVersion: "2024-11-05", capabilities: { tools: {} }, serverInfo: { name: "portable", version: "1.0.0" } } });
|
||||
if (message.method === "tools/list") write({ jsonrpc: "2.0", id: message.id, result: { tools: [{ name: "echo", description: "Portable echo", inputSchema: { type: "object" } }] } });
|
||||
if (message.method === "tools/call") write({ jsonrpc: "2.0", id: message.id, result: { echoed: message.params?.arguments ?? null } });
|
||||
}
|
||||
});`,
|
||||
"utf8",
|
||||
);
|
||||
const resolvedPluginRoot = realpathSync.native(pluginRoot);
|
||||
const resolvedPluginSkillRoot = realpathSync.native(pluginSkillRoot);
|
||||
const resolvedPluginSkillPath = realpathSync.native(pluginSkillPath);
|
||||
|
||||
const clientInstructionService = createUserInstructionConfigService({
|
||||
skills: { directories: [join(tempRoot, "local-skills")] },
|
||||
rules: { directories: [] },
|
||||
workflows: { directories: [] },
|
||||
});
|
||||
let runtime:
|
||||
| Awaited<ReturnType<DefaultRuntimeBuilder["build"]>>
|
||||
| undefined;
|
||||
try {
|
||||
runtime = await new DefaultRuntimeBuilder().build({
|
||||
config: makeBaseConfig({
|
||||
cwd: tempRoot,
|
||||
disableMcpSettingsTools: true,
|
||||
}),
|
||||
userInstructionService: clientInstructionService,
|
||||
agentPluginSkills: [
|
||||
{
|
||||
pluginName: "portable",
|
||||
pluginRoot: resolvedPluginRoot,
|
||||
directoryPath: resolvedPluginSkillRoot,
|
||||
filePath: resolvedPluginSkillPath,
|
||||
metadata: {
|
||||
name: "portable-review",
|
||||
description: "Review with the portable plugin",
|
||||
},
|
||||
},
|
||||
],
|
||||
agentPluginMcpServers: [
|
||||
{
|
||||
pluginName: "portable",
|
||||
pluginRoot: resolvedPluginRoot,
|
||||
pluginDataPath: join(tempRoot, "plugin-data"),
|
||||
serverName: "tools",
|
||||
registration: {
|
||||
name: "portable.tools",
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: process.execPath,
|
||||
args: [serverPath],
|
||||
cwd: resolvedPluginRoot,
|
||||
},
|
||||
metadata: {
|
||||
source: "agent-plugin",
|
||||
pluginDataPath: join(tempRoot, "plugin-data"),
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const mcpTool = runtime.tools.find(
|
||||
(tool) => tool.description === "Portable echo",
|
||||
);
|
||||
expect(existsSync(join(tempRoot, "plugin-data"))).toBe(true);
|
||||
expect(mcpTool).toBeDefined();
|
||||
const extensionTools = await collectExtensionTools(runtime.extensions);
|
||||
const skillsTool = extensionTools.find((tool) => tool.name === "skills");
|
||||
expect(skillsTool).toBeDefined();
|
||||
if (!skillsTool) {
|
||||
throw new Error("Expected combined skills tool.");
|
||||
}
|
||||
expect(skillsTool.description).toContain("portable:portable-review");
|
||||
const context = {
|
||||
agentId: "agent-1",
|
||||
conversationId: "conv-1",
|
||||
iteration: 1,
|
||||
};
|
||||
const portableResult = await skillsTool.execute(
|
||||
{ skill: "portable:portable-review" },
|
||||
context,
|
||||
);
|
||||
expect(portableResult).toContain("Use portable guidance.");
|
||||
expect(portableResult).toContain(
|
||||
`<skill-root>${resolvedPluginSkillRoot}</skill-root>`,
|
||||
);
|
||||
await expect(
|
||||
skillsTool.execute({ skill: "local-review" }, context),
|
||||
).resolves.toContain("Use local guidance.");
|
||||
} finally {
|
||||
await runtime?.shutdown("test");
|
||||
clientInstructionService.stop();
|
||||
process.env.CLINE_MCP_SETTINGS_PATH = previousSettingsPath;
|
||||
}
|
||||
});
|
||||
|
||||
it("skips MCP settings tools when disableMcpSettingsTools is true", async () => {
|
||||
const tempRoot = mkdtempSync(
|
||||
join(tmpdir(), "runtime-builder-mcp-disabled-"),
|
||||
|
||||
@@ -12,7 +12,12 @@ import {
|
||||
resolveMcpTimeoutSeconds,
|
||||
} from "@cline/shared";
|
||||
import { nanoid } from "nanoid";
|
||||
import { createUserInstructionConfigService } from "../../extensions/config";
|
||||
import type { AgentPluginPackageMcpServer } from "../../extensions/agent-plugin";
|
||||
import {
|
||||
combineUserInstructionConfigServices,
|
||||
createUserInstructionConfigService,
|
||||
type UserInstructionConfigService,
|
||||
} from "../../extensions/config";
|
||||
import {
|
||||
createDefaultMcpServerClientFactory,
|
||||
createMcpTools,
|
||||
@@ -198,34 +203,75 @@ function isSkillsToolEnabledForSession(input: {
|
||||
|
||||
const SKILLS_PROBE_EXECUTOR = (async () => "") as SkillsExecutorWithMetadata;
|
||||
|
||||
async function loadConfiguredMcpTools(logger?: BasicLogger): Promise<{
|
||||
async function loadConfiguredMcpTools(options: {
|
||||
logger?: BasicLogger;
|
||||
includeSettings: boolean;
|
||||
agentPluginServers?: ReadonlyArray<AgentPluginPackageMcpServer>;
|
||||
}): Promise<{
|
||||
tools: AgentTool[];
|
||||
shutdown?: () => Promise<void>;
|
||||
}> {
|
||||
const settingsPath = resolveDefaultMcpSettingsPath();
|
||||
if (!hasMcpSettingsFile({ filePath: settingsPath })) {
|
||||
const hasSettings =
|
||||
options.includeSettings && hasMcpSettingsFile({ filePath: settingsPath });
|
||||
if (!hasSettings && !options.agentPluginServers?.length) {
|
||||
return { tools: [] };
|
||||
}
|
||||
|
||||
const settingsClientFactory = createDefaultMcpServerClientFactory({
|
||||
settingsPath,
|
||||
});
|
||||
const agentPluginClientFactory = createDefaultMcpServerClientFactory({
|
||||
restrictConfiguredHeadersToOrigin: true,
|
||||
});
|
||||
const manager = new InMemoryMcpManager({
|
||||
clientFactory: createDefaultMcpServerClientFactory({
|
||||
settingsPath,
|
||||
}),
|
||||
clientFactory: (registration) =>
|
||||
registration.metadata?.source === "agent-plugin"
|
||||
? agentPluginClientFactory(registration)
|
||||
: settingsClientFactory(registration),
|
||||
});
|
||||
|
||||
let registrations: Awaited<
|
||||
ReturnType<typeof registerMcpServersFromSettingsFile>
|
||||
>;
|
||||
try {
|
||||
registrations = await registerMcpServersFromSettingsFile(manager, {
|
||||
filePath: settingsPath,
|
||||
});
|
||||
} catch (error) {
|
||||
> = [];
|
||||
if (hasSettings) {
|
||||
try {
|
||||
registrations = await registerMcpServersFromSettingsFile(manager, {
|
||||
filePath: settingsPath,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.logger?.log(
|
||||
`[mcp] Failed to load MCP settings, skipping settings-backed MCP tools: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const registeredNames = new Set(registrations.map((entry) => entry.name));
|
||||
for (const agentPluginServer of options.agentPluginServers ?? []) {
|
||||
const registration = agentPluginServer.registration;
|
||||
if (registeredNames.has(registration.name)) {
|
||||
options.logger?.log(
|
||||
`[agent-plugins] MCP server '${registration.name}' conflicts with an existing server and was skipped.`,
|
||||
{ severity: "error" },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await manager.registerServer(registration);
|
||||
registrations.push(registration);
|
||||
registeredNames.add(registration.name);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
options.logger?.log(
|
||||
`[agent-plugins] Failed to register MCP server '${registration.name}', skipping: ${message}`,
|
||||
{ severity: "error" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (registrations.length === 0) {
|
||||
await manager.dispose().catch(() => {});
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logger?.log(
|
||||
`[mcp] Failed to load MCP settings, skipping MCP tools: ${message}`,
|
||||
);
|
||||
return { tools: [] };
|
||||
}
|
||||
|
||||
@@ -250,7 +296,7 @@ async function loadConfiguredMcpTools(logger?: BasicLogger): Promise<{
|
||||
result.reason instanceof Error
|
||||
? result.reason.message
|
||||
: String(result.reason);
|
||||
logger?.log(
|
||||
options.logger?.log(
|
||||
`[mcp] Failed to load tools from MCP server "${enabled[i].name}", skipping: ${message}`,
|
||||
);
|
||||
}
|
||||
@@ -412,9 +458,7 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
const userInstructionsEnabled =
|
||||
rulesEnabled || rootSkillsEnabled || workflowsEnabled;
|
||||
let teamToolsRegistered = false;
|
||||
const userInstructionServiceProvided = Boolean(
|
||||
sharedUserInstructionService,
|
||||
);
|
||||
const ownedUserInstructionServices: UserInstructionConfigService[] = [];
|
||||
let userInstructionService = sharedUserInstructionService;
|
||||
let mcpShutdown: (() => Promise<void>) | undefined;
|
||||
|
||||
@@ -438,11 +482,34 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
: undefined,
|
||||
pluginPaths: config.pluginPaths,
|
||||
cwd: config.cwd,
|
||||
agentPluginSkills: pluginsEnabled
|
||||
? input.agentPluginSkills
|
||||
: undefined,
|
||||
}
|
||||
: { workspacePath: workspaceConfigRoot },
|
||||
rules: { workspacePath: config.cwd },
|
||||
workflows: { workspacePath: config.cwd },
|
||||
});
|
||||
ownedUserInstructionServices.push(userInstructionService);
|
||||
} else if (
|
||||
userInstructionService &&
|
||||
pluginsEnabled &&
|
||||
input.agentPluginSkills?.length &&
|
||||
(userInstructionsEnabled || configuredAgentsNeedSkills)
|
||||
) {
|
||||
const agentPluginInstructionService = createUserInstructionConfigService({
|
||||
skills: {
|
||||
directories: [],
|
||||
agentPluginSkills: input.agentPluginSkills,
|
||||
},
|
||||
rules: { directories: [] },
|
||||
workflows: { directories: [] },
|
||||
});
|
||||
ownedUserInstructionServices.push(agentPluginInstructionService);
|
||||
userInstructionService = combineUserInstructionConfigServices([
|
||||
userInstructionService,
|
||||
agentPluginInstructionService,
|
||||
]);
|
||||
}
|
||||
|
||||
if (userInstructionService) {
|
||||
@@ -511,8 +578,18 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
input.runCommandExecutionController,
|
||||
),
|
||||
);
|
||||
if (!normalized.disableMcpSettingsTools) {
|
||||
const mcpRuntime = await loadConfiguredMcpTools(config.logger);
|
||||
const agentPluginMcpServers = pluginsEnabled
|
||||
? input.agentPluginMcpServers
|
||||
: undefined;
|
||||
if (
|
||||
!normalized.disableMcpSettingsTools ||
|
||||
agentPluginMcpServers?.length
|
||||
) {
|
||||
const mcpRuntime = await loadConfiguredMcpTools({
|
||||
logger: config.logger,
|
||||
includeSettings: !normalized.disableMcpSettingsTools,
|
||||
agentPluginServers: agentPluginMcpServers,
|
||||
});
|
||||
tools.push(...mcpRuntime.tools);
|
||||
mcpShutdown = mcpRuntime.shutdown;
|
||||
}
|
||||
@@ -799,8 +876,8 @@ export class DefaultRuntimeBuilder implements RuntimeBuilder {
|
||||
shutdownTeamRuntime(teamRuntime, reason);
|
||||
this.teamRuntimeEntries.delete(registryKey);
|
||||
await mcpShutdown?.();
|
||||
if (!userInstructionServiceProvided) {
|
||||
userInstructionService?.stop();
|
||||
for (const service of ownedUserInstructionServices) {
|
||||
service.stop();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,6 +11,10 @@ import type {
|
||||
ToolApprovalRequest,
|
||||
ToolApprovalResult,
|
||||
} from "@cline/shared";
|
||||
import type {
|
||||
AgentPluginPackageMcpServer,
|
||||
AgentPluginPackageSkill,
|
||||
} from "../../extensions/agent-plugin";
|
||||
import type { UserInstructionConfigService } from "../../extensions/config";
|
||||
import type {
|
||||
RunCommandExecutionController,
|
||||
@@ -71,6 +75,8 @@ export interface RuntimeBuilderInput {
|
||||
onTeamRestored?: () => void;
|
||||
userInstructionService?: UserInstructionConfigService;
|
||||
pluginSkillDirectories?: ReadonlyArray<string>;
|
||||
agentPluginSkills?: ReadonlyArray<AgentPluginPackageSkill>;
|
||||
agentPluginMcpServers?: ReadonlyArray<AgentPluginPackageMcpServer>;
|
||||
configExtensions?: RuntimeConfigExtensionKind[];
|
||||
toolExecutors?: Partial<ToolExecutors>;
|
||||
runCommandExecutionController?: RunCommandExecutionController;
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { ITelemetryService } from "@cline/shared";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
GlobalSettingsSchema,
|
||||
isAgentPluginDisabledGlobally,
|
||||
isModelToolEnabledGlobally,
|
||||
readCompactionModeGlobally,
|
||||
readCompactionStrategyGlobally,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setCompactionModeGlobally,
|
||||
setCompactionStrategyGlobally,
|
||||
setDisabledAgentPlugin,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setModelToolEnabledGlobally,
|
||||
@@ -35,11 +37,13 @@ describe("global-settings", () => {
|
||||
it("defines the global settings file schema", () => {
|
||||
expect(
|
||||
GlobalSettingsSchema.parse({
|
||||
disabledAgentPlugins: [" portable ", "portable"],
|
||||
disabledTools: [" read_files ", "read_files", "editor"],
|
||||
disabledPlugins: ["/plugins/example.js", "/plugins/example.js"],
|
||||
}),
|
||||
).toEqual({
|
||||
autoUpdateEnabled: true,
|
||||
disabledAgentPlugins: ["portable"],
|
||||
disabledPlugins: ["/plugins/example.js"],
|
||||
disabledTools: ["editor", "read_files"],
|
||||
telemetryOptOut: false,
|
||||
@@ -139,28 +143,35 @@ describe("global-settings", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves disabled tools and plugins across targeted updates", async () => {
|
||||
it("preserves disabled tools and both plugin formats across targeted updates", 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;
|
||||
|
||||
setDisabledPlugin("/plugins/example.js", true);
|
||||
setDisabledAgentPlugin("portable-review", true);
|
||||
setDisabledTools(["read_files", "editor"], true);
|
||||
setDisabledTools(["editor"], false);
|
||||
|
||||
expect(readGlobalSettings()).toEqual({
|
||||
autoUpdateEnabled: true,
|
||||
disabledAgentPlugins: ["portable-review"],
|
||||
disabledPlugins: ["/plugins/example.js"],
|
||||
disabledTools: ["read_files"],
|
||||
telemetryOptOut: false,
|
||||
});
|
||||
expect(JSON.parse(await readFile(settingsPath, "utf8"))).toEqual({
|
||||
autoUpdateEnabled: true,
|
||||
disabledAgentPlugins: ["portable-review"],
|
||||
disabledPlugins: ["/plugins/example.js"],
|
||||
disabledTools: ["read_files"],
|
||||
telemetryOptOut: false,
|
||||
});
|
||||
|
||||
setDisabledAgentPlugin("portable-review", false);
|
||||
expect(isAgentPluginDisabledGlobally("portable-review")).toBe(false);
|
||||
expect(readGlobalSettings().disabledAgentPlugins).toBeUndefined();
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -477,6 +488,7 @@ describe("global-settings", () => {
|
||||
const settingsPath = join(root, "global-settings.json");
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = settingsPath;
|
||||
writeGlobalSettings({
|
||||
disabledAgentPlugins: ["portable-review"],
|
||||
disabledTools: ["editor"],
|
||||
disabledPlugins: ["/plugins/example.js"],
|
||||
});
|
||||
@@ -484,6 +496,7 @@ describe("global-settings", () => {
|
||||
const settings = readGlobalSettings();
|
||||
|
||||
expect(Object.isFrozen(settings)).toBe(true);
|
||||
expect(Object.isFrozen(settings.disabledAgentPlugins)).toBe(true);
|
||||
expect(Object.isFrozen(settings.disabledTools)).toBe(true);
|
||||
expect(Object.isFrozen(settings.disabledPlugins)).toBe(true);
|
||||
expect(() => {
|
||||
@@ -492,6 +505,7 @@ describe("global-settings", () => {
|
||||
expect(() => {
|
||||
settings.disabledTools?.push("malicious");
|
||||
}).toThrow();
|
||||
expect(isAgentPluginDisabledGlobally("portable-review")).toBe(true);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ export const GlobalSettingsSchema = z
|
||||
disabledTools: GlobalSettingsStringListSchema.optional(),
|
||||
tools: ModelToolSettingsSchema,
|
||||
disabledPlugins: GlobalSettingsStringListSchema.optional(),
|
||||
disabledAgentPlugins: GlobalSettingsStringListSchema.optional(),
|
||||
})
|
||||
.strip()
|
||||
.transform((settings) => {
|
||||
@@ -84,6 +85,7 @@ export const GlobalSettingsSchema = z
|
||||
disabledTools?: string[];
|
||||
tools?: ModelToolSettings;
|
||||
disabledPlugins?: string[];
|
||||
disabledAgentPlugins?: string[];
|
||||
} = {
|
||||
autoUpdateEnabled: settings.autoUpdateEnabled,
|
||||
telemetryOptOut: settings.telemetryOptOut,
|
||||
@@ -112,6 +114,9 @@ export const GlobalSettingsSchema = z
|
||||
if (settings.disabledPlugins?.length) {
|
||||
normalized.disabledPlugins = settings.disabledPlugins;
|
||||
}
|
||||
if (settings.disabledAgentPlugins?.length) {
|
||||
normalized.disabledAgentPlugins = settings.disabledAgentPlugins;
|
||||
}
|
||||
return normalized;
|
||||
});
|
||||
|
||||
@@ -151,6 +156,9 @@ function freezeSettings(value: GlobalSettings): GlobalSettings {
|
||||
if (value.disabledPlugins) {
|
||||
Object.freeze(value.disabledPlugins);
|
||||
}
|
||||
if (value.disabledAgentPlugins) {
|
||||
Object.freeze(value.disabledAgentPlugins);
|
||||
}
|
||||
return Object.freeze(value);
|
||||
}
|
||||
|
||||
@@ -326,6 +334,14 @@ export function resolveDisabledPluginPaths(
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveDisabledAgentPluginNames(
|
||||
disabledPluginNames?: ReadonlyArray<string>,
|
||||
): Set<string> {
|
||||
return new Set(
|
||||
disabledPluginNames ?? readGlobalSettings().disabledAgentPlugins ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
export function isToolDisabledGlobally(toolName: string): boolean {
|
||||
if (isModelToolName(toolName)) {
|
||||
return !isModelToolEnabledGlobally(toolName);
|
||||
@@ -435,6 +451,34 @@ export function setDisabledPlugin(
|
||||
writeGlobalSettings({ ...settings, disabledPlugins: [...disabled] });
|
||||
}
|
||||
|
||||
export function isAgentPluginDisabledGlobally(pluginName: string): boolean {
|
||||
return resolveDisabledAgentPluginNames().has(pluginName);
|
||||
}
|
||||
|
||||
export function setDisabledAgentPlugin(
|
||||
pluginName: string,
|
||||
disabledValue: boolean,
|
||||
): void {
|
||||
const name = pluginName.trim();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = readGlobalSettings();
|
||||
const disabled = resolveDisabledAgentPluginNames(
|
||||
settings.disabledAgentPlugins,
|
||||
);
|
||||
if (disabledValue) {
|
||||
disabled.add(name);
|
||||
} else {
|
||||
disabled.delete(name);
|
||||
}
|
||||
writeGlobalSettings({
|
||||
...settings,
|
||||
disabledAgentPlugins: [...disabled],
|
||||
});
|
||||
}
|
||||
|
||||
export function filterDisabledPluginPaths(
|
||||
pluginPaths: ReadonlyArray<string>,
|
||||
disabledPluginPaths?: ReadonlyArray<string>,
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
realpathSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { setHomeDir } from "@cline/shared/storage";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { version as corePackageVersion } from "../../package.json";
|
||||
import type { ProviderSettings } from "../types/provider-settings";
|
||||
@@ -80,6 +87,154 @@ describe("prepareLocalRuntimeBootstrap", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("discovers user Agent Plugins on the execution host and ignores workspace packages", async () => {
|
||||
const root = realpathSync(
|
||||
mkdtempSync(join(tmpdir(), "core-agent-plugin-bootstrap-")),
|
||||
);
|
||||
const previousHome = process.env.HOME;
|
||||
const homeRoot = join(root, "home");
|
||||
setHomeDir(homeRoot);
|
||||
try {
|
||||
const globalSettingsPath = join(root, "global-settings.json");
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = globalSettingsPath;
|
||||
const workspaceRoot = join(root, "workspace");
|
||||
mkdirSync(workspaceRoot, { recursive: true });
|
||||
const pluginRoot = join(homeRoot, ".agents", "plugins", "portable");
|
||||
const skillRoot = join(pluginRoot, "skills", "review");
|
||||
mkdirSync(skillRoot, { recursive: true });
|
||||
writeFileSync(
|
||||
join(pluginRoot, "plugin.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
name: "portable",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const workspacePluginRoot = join(
|
||||
workspaceRoot,
|
||||
".agents",
|
||||
"plugins",
|
||||
"workspace-owned",
|
||||
);
|
||||
mkdirSync(workspacePluginRoot, { recursive: true });
|
||||
writeFileSync(
|
||||
join(workspacePluginRoot, "plugin.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
name: "workspace-owned",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(workspacePluginRoot, "mcp.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
|
||||
mcpServers: {
|
||||
untrusted: {
|
||||
type: "streamable-http",
|
||||
url: "https://workspace.example.test/mcp",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
const resolvedSkillRoot = realpathSync.native(skillRoot);
|
||||
writeFileSync(
|
||||
join(skillRoot, "SKILL.md"),
|
||||
"---\nname: review\ndescription: Review code\n---\nReview carefully.",
|
||||
"utf8",
|
||||
);
|
||||
writeFileSync(
|
||||
join(pluginRoot, "mcp.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
|
||||
mcpServers: {
|
||||
tools: {
|
||||
type: "streamable-http",
|
||||
url: "https://example.com/mcp",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const { prepareLocalRuntimeBootstrap } = await import(
|
||||
"./local-runtime-bootstrap"
|
||||
);
|
||||
const bootstrap = await prepareLocalRuntimeBootstrap({
|
||||
input: {
|
||||
...createStartInput(),
|
||||
config: {
|
||||
...createStartInput().config,
|
||||
cwd: workspaceRoot,
|
||||
workspaceRoot,
|
||||
},
|
||||
},
|
||||
sessionId: "agent-plugin-session",
|
||||
providerSettingsManager: createProviderSettingsManager() as never,
|
||||
onPluginEvent: () => {},
|
||||
onTeamEvent: () => {},
|
||||
createSpawnTool,
|
||||
readSessionMetadata: async () => undefined,
|
||||
writeSessionMetadata: async () => {},
|
||||
});
|
||||
|
||||
expect(bootstrap.runtimeBuilderInput.agentPluginSkills).toEqual([
|
||||
expect.objectContaining({
|
||||
pluginName: "portable",
|
||||
directoryPath: resolvedSkillRoot,
|
||||
}),
|
||||
]);
|
||||
expect(bootstrap.runtimeBuilderInput.agentPluginMcpServers).toEqual([
|
||||
expect.objectContaining({
|
||||
pluginName: "portable",
|
||||
serverName: "tools",
|
||||
registration: expect.objectContaining({
|
||||
name: "portable.tools",
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(
|
||||
bootstrap.runtimeBuilderInput.agentPluginMcpServers?.some(
|
||||
(server) => server.pluginName === "workspace-owned",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
writeFileSync(
|
||||
globalSettingsPath,
|
||||
JSON.stringify({ disabledAgentPlugins: ["portable"] }),
|
||||
"utf8",
|
||||
);
|
||||
const disabledBootstrap = await prepareLocalRuntimeBootstrap({
|
||||
input: {
|
||||
...createStartInput(),
|
||||
config: {
|
||||
...createStartInput().config,
|
||||
cwd: workspaceRoot,
|
||||
workspaceRoot,
|
||||
},
|
||||
},
|
||||
sessionId: "disabled-agent-plugin-session",
|
||||
providerSettingsManager: createProviderSettingsManager() as never,
|
||||
onPluginEvent: () => {},
|
||||
onTeamEvent: () => {},
|
||||
createSpawnTool,
|
||||
readSessionMetadata: async () => undefined,
|
||||
writeSessionMetadata: async () => {},
|
||||
});
|
||||
|
||||
expect(disabledBootstrap.runtimeBuilderInput.agentPluginSkills).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(
|
||||
disabledBootstrap.runtimeBuilderInput.agentPluginMcpServers,
|
||||
).toEqual([]);
|
||||
} finally {
|
||||
setHomeDir(previousHome ?? "~");
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("lets stored provider model catalog settings override hub defaults", async () => {
|
||||
const { prepareLocalRuntimeBootstrap } = await import(
|
||||
"./local-runtime-bootstrap"
|
||||
|
||||
@@ -15,6 +15,10 @@ import type {
|
||||
} from "@cline/shared";
|
||||
import { hasRuntimeConfigExtension } from "@cline/shared";
|
||||
import { version as corePackageVersion } from "../../package.json";
|
||||
import {
|
||||
type AgentPluginPackageDiagnostic,
|
||||
loadAgentPluginPackages,
|
||||
} from "../extensions/agent-plugin";
|
||||
import {
|
||||
resolveAndLoadAgentPlugins,
|
||||
resolvePluginSkillDirectoriesFromPaths,
|
||||
@@ -49,7 +53,10 @@ import {
|
||||
toProviderConfig,
|
||||
} from "../types/provider-settings";
|
||||
import { resolveWorkspacePath } from "./config";
|
||||
import { filterExtensionToolRegistrations } from "./global-settings";
|
||||
import {
|
||||
filterExtensionToolRegistrations,
|
||||
resolveDisabledAgentPluginNames,
|
||||
} from "./global-settings";
|
||||
import { hasRuntimeHooks, mergeAgentExtensions } from "./session-data";
|
||||
import type { ProviderSettingsManager } from "./storage/provider-settings-manager";
|
||||
import { InMemoryWorkspaceManager } from "./workspace/workspace-manager";
|
||||
@@ -94,6 +101,19 @@ function logPluginDiagnostics(
|
||||
}
|
||||
}
|
||||
|
||||
function logAgentPluginDiagnostics(
|
||||
diagnostics: ReadonlyArray<AgentPluginPackageDiagnostic>,
|
||||
logger: BasicLogger | undefined,
|
||||
): void {
|
||||
for (const item of diagnostics) {
|
||||
const component = item.componentName ? ` (${item.componentName})` : "";
|
||||
logger?.log(
|
||||
`[agent-plugins] ${item.pluginName ?? item.pluginPath}${component}: ${item.message}`,
|
||||
{ severity: item.level === "warning" ? "warn" : "error" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover client identity from the Cline request headers baked into the
|
||||
* session config. Hub-backed sessions do not transport `extensionContext`
|
||||
@@ -394,6 +414,29 @@ export async function prepareLocalRuntimeBootstrap(
|
||||
}
|
||||
}
|
||||
|
||||
let loadedAgentPluginPackages:
|
||||
| Awaited<ReturnType<typeof loadAgentPluginPackages>>
|
||||
| undefined;
|
||||
if (hasConfigExtension(configExtensions, "plugins")) {
|
||||
try {
|
||||
loadedAgentPluginPackages = await loadAgentPluginPackages({
|
||||
pluginPaths: input.config.agentPluginPaths,
|
||||
cwd: input.config.cwd,
|
||||
disabledPluginNames: [...resolveDisabledAgentPluginNames()],
|
||||
});
|
||||
logAgentPluginDiagnostics(
|
||||
loadedAgentPluginPackages.diagnostics,
|
||||
extensionContext.logger,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
extensionContext.logger?.log(
|
||||
`[agent-plugins] Package discovery failed; continuing without Agent Plugins: ${message}`,
|
||||
{ severity: "error" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const builtInExtensions = fileHookExtension ? [fileHookExtension] : undefined;
|
||||
const extensions = mergeAgentExtensions(
|
||||
builtInExtensions,
|
||||
@@ -482,6 +525,8 @@ export async function prepareLocalRuntimeBootstrap(
|
||||
onSubAgentEnd: subAgentLifecycleCallbacks?.onSubAgentEnd,
|
||||
userInstructionService: userInstructionService,
|
||||
pluginSkillDirectories,
|
||||
agentPluginSkills: loadedAgentPluginPackages?.skills,
|
||||
agentPluginMcpServers: loadedAgentPluginPackages?.mcpServers,
|
||||
configExtensions: configExtensions,
|
||||
toolExecutors: effectiveToolExecutors,
|
||||
toolPolicies,
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
realpath,
|
||||
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 type { UserInstructionConfigService } from "../extensions/config";
|
||||
import { loadAgentPluginPackages } from "../extensions/agent-plugin";
|
||||
import {
|
||||
createUserInstructionConfigService,
|
||||
type UserInstructionConfigService,
|
||||
} from "../extensions/config";
|
||||
import { listPluginToolsWithDiagnostics } from "../services/plugin-tools";
|
||||
import { CoreSettingsService } from "./settings-service";
|
||||
|
||||
@@ -174,6 +185,241 @@ Use the browser.`,
|
||||
).toEqual(["test-plugin-skill-owner"]);
|
||||
});
|
||||
|
||||
it("lists portable Agent Plugins, skills, and MCP servers from the hub-owned roots", async () => {
|
||||
const tempRoot = await mkdtemp(
|
||||
join(tmpdir(), "core-settings-agent-plugin-"),
|
||||
);
|
||||
tempRoots.push(tempRoot);
|
||||
process.env.HOME = tempRoot;
|
||||
setHomeDir(tempRoot);
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
|
||||
tempRoot,
|
||||
"global-settings.json",
|
||||
);
|
||||
const workspaceRoot = join(tempRoot, "workspace");
|
||||
await mkdir(workspaceRoot, { recursive: true });
|
||||
const workspacePluginRoot = join(
|
||||
workspaceRoot,
|
||||
".agents",
|
||||
"plugins",
|
||||
"workspace-owned",
|
||||
);
|
||||
await mkdir(workspacePluginRoot, { recursive: true });
|
||||
await writeFile(
|
||||
join(workspacePluginRoot, "plugin.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
name: "workspace-owned",
|
||||
}),
|
||||
);
|
||||
const pluginRoot = join(tempRoot, ".agents", "plugins", "portable-review");
|
||||
const skillRoot = join(pluginRoot, "skills", "review");
|
||||
await mkdir(skillRoot, { recursive: true });
|
||||
await writeFile(
|
||||
join(pluginRoot, "plugin.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
name: "portable-review",
|
||||
version: "1.2.3",
|
||||
description: "Portable review helpers.",
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
join(skillRoot, "SKILL.md"),
|
||||
`---
|
||||
name: review
|
||||
description: Review a change.
|
||||
---
|
||||
Review the change.`,
|
||||
);
|
||||
await writeFile(
|
||||
join(pluginRoot, "mcp.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
|
||||
mcpServers: {
|
||||
docs: {
|
||||
type: "streamable-http",
|
||||
url: "https://mcp.example.test/mcp",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
const resolvedPluginRoot = await realpath(pluginRoot);
|
||||
|
||||
const service = new CoreSettingsService();
|
||||
const snapshot = await service.list({
|
||||
cwd: workspaceRoot,
|
||||
workspaceRoot,
|
||||
includePluginTools: false,
|
||||
});
|
||||
|
||||
expect(snapshot.plugins).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "agent-plugin:portable-review",
|
||||
name: "portable-review",
|
||||
path: resolvedPluginRoot,
|
||||
agentPlugin: true,
|
||||
toggleable: true,
|
||||
source: "global-plugin",
|
||||
contributions: expect.objectContaining({
|
||||
skills: ["review"],
|
||||
mcpServers: ["portable-review.docs"],
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
snapshot.plugins.some((plugin) => plugin.name === "workspace-owned"),
|
||||
).toBe(false);
|
||||
|
||||
const disabled = await service.toggle({
|
||||
type: "plugins",
|
||||
id: "agent-plugin:portable-review",
|
||||
enabled: false,
|
||||
cwd: workspaceRoot,
|
||||
workspaceRoot,
|
||||
includePluginTools: false,
|
||||
});
|
||||
|
||||
expect(disabled.changedTypes).toEqual(["plugins", "skills", "mcp"]);
|
||||
expect(
|
||||
disabled.snapshot.plugins.find(
|
||||
(plugin) => plugin.id === "agent-plugin:portable-review",
|
||||
),
|
||||
).toMatchObject({
|
||||
enabled: false,
|
||||
contributions: { inspectionStatus: "disabled" },
|
||||
});
|
||||
expect(
|
||||
disabled.snapshot.skills.some(
|
||||
(skill) => skill.pluginName === "portable-review",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
disabled.snapshot.mcp.some(
|
||||
(server) => server.pluginName === "portable-review",
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
JSON.parse(
|
||||
await readFile(process.env.CLINE_GLOBAL_SETTINGS_PATH, "utf8"),
|
||||
),
|
||||
).toMatchObject({ disabledAgentPlugins: ["portable-review"] });
|
||||
|
||||
const reenabled = await service.toggle({
|
||||
type: "plugins",
|
||||
path: resolvedPluginRoot,
|
||||
enabled: true,
|
||||
cwd: workspaceRoot,
|
||||
workspaceRoot,
|
||||
includePluginTools: false,
|
||||
});
|
||||
expect(
|
||||
reenabled.snapshot.plugins.find(
|
||||
(plugin) => plugin.id === "agent-plugin:portable-review",
|
||||
)?.enabled,
|
||||
).toBe(true);
|
||||
expect(reenabled.snapshot.skills).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: "portable-review:review" }),
|
||||
]),
|
||||
);
|
||||
expect(snapshot.skills).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "portable-review:review",
|
||||
name: "review",
|
||||
pluginName: "portable-review",
|
||||
agentPlugin: true,
|
||||
toggleable: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(snapshot.mcp).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "portable-review.docs",
|
||||
name: "portable-review.docs",
|
||||
pluginName: "portable-review",
|
||||
agentPlugin: true,
|
||||
toggleable: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects toggling an individual Agent Plugin skill instead of rewriting its frontmatter", async () => {
|
||||
const tempRoot = await mkdtemp(
|
||||
join(tmpdir(), "core-settings-agent-plugin-skill-toggle-"),
|
||||
);
|
||||
tempRoots.push(tempRoot);
|
||||
process.env.HOME = tempRoot;
|
||||
setHomeDir(tempRoot);
|
||||
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
|
||||
tempRoot,
|
||||
"global-settings.json",
|
||||
);
|
||||
const workspaceRoot = join(tempRoot, "workspace");
|
||||
await mkdir(workspaceRoot, { recursive: true });
|
||||
const pluginRoot = join(tempRoot, ".agents", "plugins", "portable-review");
|
||||
const skillRoot = join(pluginRoot, "skills", "review");
|
||||
await mkdir(skillRoot, { recursive: true });
|
||||
await writeFile(
|
||||
join(pluginRoot, "plugin.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
name: "portable-review",
|
||||
}),
|
||||
);
|
||||
const skillMarkdown = `---
|
||||
name: review
|
||||
description: Review a change.
|
||||
---
|
||||
Review the change.`;
|
||||
const skillPath = join(skillRoot, "SKILL.md");
|
||||
await writeFile(skillPath, skillMarkdown);
|
||||
|
||||
// Mirrors how a host builds its shared instruction service once
|
||||
// Agent Plugin skills are merged into it (e.g. runtime-builder's
|
||||
// combineUserInstructionConfigServices), rather than the ad-hoc,
|
||||
// agent-plugin-unaware service `toggle()` builds on its own when no
|
||||
// `userInstructionService` is supplied.
|
||||
const { skills: agentPluginSkills } = await loadAgentPluginPackages({
|
||||
searchPaths: [join(tempRoot, ".agents", "plugins")],
|
||||
});
|
||||
expect(agentPluginSkills).toHaveLength(1);
|
||||
const userInstructionService = createUserInstructionConfigService({
|
||||
skills: { directories: [], agentPluginSkills },
|
||||
rules: { directories: [] },
|
||||
workflows: { directories: [] },
|
||||
});
|
||||
await userInstructionService.start();
|
||||
|
||||
const service = new CoreSettingsService();
|
||||
try {
|
||||
await expect(
|
||||
service.toggle({
|
||||
type: "skills",
|
||||
id: "portable-review:review",
|
||||
enabled: false,
|
||||
cwd: workspaceRoot,
|
||||
workspaceRoot,
|
||||
userInstructionService,
|
||||
includePluginTools: false,
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
"Skill 'review' is contributed by the Agent Plugin 'portable-review' and cannot be toggled individually; toggle the plugin instead.",
|
||||
);
|
||||
} finally {
|
||||
userInstructionService.stop();
|
||||
}
|
||||
|
||||
// The frontmatter must be untouched: writing a `disabled` key would fail
|
||||
// the closed-field-set Agent Skills parser on the very next load.
|
||||
expect(await readFile(skillPath, "utf8")).toBe(skillMarkdown);
|
||||
});
|
||||
|
||||
it("does not list skills from disabled plugins", async () => {
|
||||
const tempRoot = await mkdtemp(
|
||||
join(tmpdir(), "core-settings-plugin-skills-"),
|
||||
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
relative,
|
||||
resolve,
|
||||
} from "node:path";
|
||||
import {
|
||||
type AgentPluginPackageLoadReport,
|
||||
loadAgentPluginPackages,
|
||||
} from "../extensions/agent-plugin";
|
||||
import {
|
||||
createUserInstructionConfigService,
|
||||
type RuleConfig,
|
||||
@@ -30,6 +34,8 @@ import {
|
||||
} from "../extensions/plugin/plugin-config-loader";
|
||||
import {
|
||||
readGlobalSettings,
|
||||
resolveDisabledAgentPluginNames,
|
||||
setDisabledAgentPlugin,
|
||||
setDisabledPlugin,
|
||||
setToolDisabledGlobally,
|
||||
toggleDisabledTool,
|
||||
@@ -241,6 +247,154 @@ function toSorted<T extends CoreSettingsItem>(items: T[]): T[] {
|
||||
});
|
||||
}
|
||||
|
||||
function agentPluginItemSource(
|
||||
pluginRoot: string,
|
||||
workspaceRoot: string,
|
||||
): "global-plugin" | "workspace-plugin" {
|
||||
return workspaceRoot &&
|
||||
detectSource(pluginRoot, workspaceRoot) === "workspace"
|
||||
? "workspace-plugin"
|
||||
: "global-plugin";
|
||||
}
|
||||
|
||||
function joinAgentPluginDiagnostics(
|
||||
report: AgentPluginPackageLoadReport,
|
||||
pluginRoot: string,
|
||||
): string | undefined {
|
||||
const messages = report.diagnostics
|
||||
.filter((entry) => entry.pluginPath === pluginRoot)
|
||||
.map((entry) => entry.message);
|
||||
return messages.length > 0 ? messages.join("\n") : undefined;
|
||||
}
|
||||
|
||||
function appendAgentPluginSettings(input: {
|
||||
report: AgentPluginPackageLoadReport;
|
||||
workspaceRoot: string;
|
||||
disabledPluginNames: ReadonlySet<string>;
|
||||
plugins: CoreSettingsItem[];
|
||||
skills: CoreSettingsItem[];
|
||||
mcp: CoreSettingsItem[];
|
||||
}): void {
|
||||
const loadedRoots = new Set<string>();
|
||||
for (const plugin of input.report.plugins) {
|
||||
loadedRoots.add(plugin.rootPath);
|
||||
const enabled = !input.disabledPluginNames.has(plugin.manifest.name);
|
||||
const source = agentPluginItemSource(plugin.rootPath, input.workspaceRoot);
|
||||
const skillNames = plugin.skills.map((skill) => skill.metadata.name).sort();
|
||||
const mcpServerNames = plugin.mcpServers
|
||||
.map((server) => server.registration.name)
|
||||
.sort();
|
||||
const componentSummary = [
|
||||
`${skillNames.length} skill${skillNames.length === 1 ? "" : "s"}`,
|
||||
`${mcpServerNames.length} MCP server${mcpServerNames.length === 1 ? "" : "s"}`,
|
||||
].join(", ");
|
||||
input.plugins.push({
|
||||
id: `agent-plugin:${plugin.manifest.name}`,
|
||||
name: plugin.manifest.name,
|
||||
path: plugin.rootPath,
|
||||
enabled,
|
||||
kind: "plugin",
|
||||
source,
|
||||
toggleable: true,
|
||||
agentPlugin: true,
|
||||
pluginName: plugin.manifest.name,
|
||||
pluginPath: plugin.rootPath,
|
||||
description: [
|
||||
plugin.manifest.description,
|
||||
`Portable Agent Plugin${plugin.manifest.version ? ` v${plugin.manifest.version}` : ""} (${componentSummary})`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
loadError: joinAgentPluginDiagnostics(input.report, plugin.rootPath),
|
||||
contributions: {
|
||||
inspectionStatus: enabled ? "available" : "disabled",
|
||||
capabilities: [
|
||||
...(skillNames.length > 0 ? ["skills"] : []),
|
||||
...(mcpServerNames.length > 0 ? ["mcp"] : []),
|
||||
],
|
||||
tools: [],
|
||||
skills: skillNames,
|
||||
rules: [],
|
||||
hooks: [],
|
||||
commands: [],
|
||||
mcpServers: mcpServerNames,
|
||||
providers: [],
|
||||
},
|
||||
});
|
||||
if (!enabled) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const skill of plugin.skills) {
|
||||
input.skills.push({
|
||||
id: `${plugin.manifest.name}:${skill.metadata.name}`,
|
||||
name: skill.metadata.name,
|
||||
path: skill.filePath,
|
||||
enabled: true,
|
||||
kind: "skill",
|
||||
source,
|
||||
description: skill.metadata.description,
|
||||
toggleable: false,
|
||||
agentPlugin: true,
|
||||
pluginName: plugin.manifest.name,
|
||||
pluginPath: plugin.rootPath,
|
||||
});
|
||||
}
|
||||
|
||||
for (const server of plugin.mcpServers) {
|
||||
input.mcp.push({
|
||||
id: server.registration.name,
|
||||
name: server.registration.name,
|
||||
path: join(plugin.rootPath, "mcp.json"),
|
||||
enabled: true,
|
||||
kind: "mcp",
|
||||
source,
|
||||
description: `${server.registration.transport.type}, portable Agent Plugin`,
|
||||
toggleable: false,
|
||||
agentPlugin: true,
|
||||
pluginName: plugin.manifest.name,
|
||||
pluginPath: plugin.rootPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const rejectedRoots = new Set(
|
||||
input.report.diagnostics
|
||||
.filter(
|
||||
(entry) =>
|
||||
!loadedRoots.has(entry.pluginPath) &&
|
||||
(entry.scope === "plugin" || entry.scope === "manifest"),
|
||||
)
|
||||
.map((entry) => entry.pluginPath),
|
||||
);
|
||||
for (const pluginRoot of rejectedRoots) {
|
||||
input.plugins.push({
|
||||
id: `agent-plugin:invalid:${pluginRoot}`,
|
||||
name: basename(pluginRoot) || pluginRoot,
|
||||
path: pluginRoot,
|
||||
enabled: false,
|
||||
kind: "plugin",
|
||||
source: agentPluginItemSource(pluginRoot, input.workspaceRoot),
|
||||
toggleable: false,
|
||||
agentPlugin: true,
|
||||
pluginPath: pluginRoot,
|
||||
description: "Invalid portable Agent Plugin package.",
|
||||
loadError: joinAgentPluginDiagnostics(input.report, pluginRoot),
|
||||
contributions: {
|
||||
inspectionStatus: "failed",
|
||||
capabilities: [],
|
||||
tools: [],
|
||||
skills: [],
|
||||
rules: [],
|
||||
hooks: [],
|
||||
commands: [],
|
||||
mcpServers: [],
|
||||
providers: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function resolveWorkspaceRoot(input: CoreSettingsListInput): string {
|
||||
return input.workspaceRoot?.trim() || input.cwd?.trim() || "";
|
||||
}
|
||||
@@ -331,6 +485,26 @@ export class CoreSettingsService {
|
||||
plugins.push(...supplied.plugins);
|
||||
tools.push(...supplied.tools);
|
||||
}
|
||||
try {
|
||||
const report = await loadAgentPluginPackages({
|
||||
pluginPaths: input.agentPluginPaths,
|
||||
cwd: resolveCwd(input, workspaceRoot),
|
||||
});
|
||||
appendAgentPluginSettings({
|
||||
report,
|
||||
workspaceRoot,
|
||||
disabledPluginNames: resolveDisabledAgentPluginNames(),
|
||||
plugins,
|
||||
skills,
|
||||
mcp,
|
||||
});
|
||||
} catch (error) {
|
||||
// Portable package discovery is isolated from all other settings.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(
|
||||
`[agent-plugins] Package discovery failed; listing settings without Agent Plugins: ${message}`,
|
||||
);
|
||||
}
|
||||
const enabledPluginPaths = new Set(
|
||||
plugins
|
||||
.filter((plugin) => plugin.enabled !== false)
|
||||
@@ -387,7 +561,11 @@ export class CoreSettingsService {
|
||||
}
|
||||
}
|
||||
|
||||
if (workspaceRoot && !this.pluginSource) {
|
||||
if (
|
||||
workspaceRoot &&
|
||||
!this.pluginSource &&
|
||||
input.includePluginTools !== false
|
||||
) {
|
||||
try {
|
||||
const pluginReport = await listPluginToolsWithDiagnostics({
|
||||
workspacePath: workspaceRoot,
|
||||
@@ -414,6 +592,9 @@ export class CoreSettingsService {
|
||||
pluginReport.plugins.map((plugin) => [plugin.path, plugin]),
|
||||
);
|
||||
for (const plugin of plugins) {
|
||||
if (plugin.agentPlugin === true) {
|
||||
continue;
|
||||
}
|
||||
const contribution = contributionByPath.get(plugin.path);
|
||||
plugin.contributions = {
|
||||
inspectionStatus:
|
||||
@@ -485,6 +666,16 @@ export class CoreSettingsService {
|
||||
if (input.type === "skills") {
|
||||
return await withUserInstructionService(input, async (service) => {
|
||||
const record = findSkillRecord(service, input);
|
||||
if (record?.item.source?.type === "agent-plugin") {
|
||||
// Agent Plugin skills are only toggleable as part of their
|
||||
// plugin (`type: "plugins"`). Writing `disabled` into the
|
||||
// skill's frontmatter would corrupt it: the strict Agent
|
||||
// Skills parser rejects any field outside its closed set,
|
||||
// so the skill would fail to load on the very next refresh.
|
||||
throw new Error(
|
||||
`Skill '${record.item.name}' is contributed by the Agent Plugin '${record.item.source.pluginName}' and cannot be toggled individually; toggle the plugin instead.`,
|
||||
);
|
||||
}
|
||||
const filePath = record?.filePath;
|
||||
if (!filePath) {
|
||||
throw new Error(
|
||||
@@ -531,20 +722,38 @@ export class CoreSettingsService {
|
||||
}
|
||||
|
||||
if (input.type === "plugins") {
|
||||
const pluginPath = input.path?.trim() || input.id?.trim();
|
||||
if (!pluginPath) {
|
||||
throw new Error("Plugin settings toggle requires a plugin path.");
|
||||
const snapshotBeforeMutation = await this.list(input);
|
||||
const requestedId = input.id?.trim();
|
||||
const requestedPath = input.path?.trim();
|
||||
const requestedName = input.name?.trim();
|
||||
const plugin = snapshotBeforeMutation.plugins.find(
|
||||
(item) =>
|
||||
(requestedId && item.id === requestedId) ||
|
||||
(requestedPath && item.path === requestedPath) ||
|
||||
(requestedName && item.name === requestedName),
|
||||
);
|
||||
if (!plugin) {
|
||||
throw new Error(
|
||||
`Unknown plugin: ${requestedPath ?? requestedId ?? requestedName ?? "(missing target)"}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (plugin.agentPlugin === true) {
|
||||
if (plugin.toggleable !== true) {
|
||||
throw new Error(`Agent Plugin '${plugin.name}' cannot be toggled.`);
|
||||
}
|
||||
const enabled = input.enabled ?? plugin.enabled === false;
|
||||
setDisabledAgentPlugin(plugin.pluginName ?? plugin.name, !enabled);
|
||||
return {
|
||||
snapshot: await this.list(input),
|
||||
changedTypes: ["plugins", "skills", "mcp"],
|
||||
};
|
||||
}
|
||||
|
||||
const pluginPath = plugin.path;
|
||||
if (this.pluginSource) {
|
||||
const snapshotBeforeMutation = await this.list(input);
|
||||
let enabled = input.enabled;
|
||||
if (enabled === undefined) {
|
||||
const plugin = snapshotBeforeMutation.plugins.find(
|
||||
(item) => item.path === pluginPath,
|
||||
);
|
||||
if (!plugin) {
|
||||
throw new Error(`Unknown plugin: ${pluginPath}`);
|
||||
}
|
||||
enabled = !plugin.enabled;
|
||||
}
|
||||
const sourceSnapshot = await this.pluginSource.setEnabled({
|
||||
@@ -556,7 +765,12 @@ export class CoreSettingsService {
|
||||
return {
|
||||
snapshot: {
|
||||
...snapshotBeforeMutation,
|
||||
plugins: sourceSnapshot.plugins,
|
||||
plugins: [
|
||||
...sourceSnapshot.plugins,
|
||||
...snapshotBeforeMutation.plugins.filter(
|
||||
(item) => item.agentPlugin === true,
|
||||
),
|
||||
],
|
||||
tools: toSorted(sourceSnapshot.tools),
|
||||
},
|
||||
changedTypes: ["plugins"],
|
||||
@@ -565,12 +779,6 @@ export class CoreSettingsService {
|
||||
|
||||
let enabled = input.enabled;
|
||||
if (enabled === undefined) {
|
||||
const plugin = listPluginSettings({
|
||||
workspaceRoot: resolveWorkspaceRoot(input),
|
||||
}).find((item) => item.path === pluginPath);
|
||||
if (!plugin) {
|
||||
throw new Error(`Unknown plugin: ${pluginPath}`);
|
||||
}
|
||||
enabled = !plugin.enabled;
|
||||
}
|
||||
setDisabledPlugin(pluginPath, !enabled);
|
||||
|
||||
@@ -35,6 +35,10 @@ export interface CoreSettingsItem {
|
||||
toggleable?: boolean;
|
||||
pluginName?: string;
|
||||
pluginPath?: string;
|
||||
/** True when this item comes from the portable agent-plugins.org format. */
|
||||
agentPlugin?: boolean;
|
||||
/** Loader diagnostics associated with this item, if any. */
|
||||
loadError?: string;
|
||||
contributions?: CorePluginContributions;
|
||||
}
|
||||
|
||||
@@ -82,6 +86,10 @@ export interface CoreSettingsSnapshot {
|
||||
export interface CoreSettingsListInput {
|
||||
cwd?: string;
|
||||
workspaceRoot?: string;
|
||||
/** Optional explicit portable Agent Plugin roots, resolved by the host. */
|
||||
agentPluginPaths?: string[];
|
||||
/** Whether inspecting Cline plugins should execute their setup for tool metadata. */
|
||||
includePluginTools?: boolean;
|
||||
userInstructionService?: UserInstructionConfigService;
|
||||
availabilityContext?: BuiltinToolAvailabilityContext;
|
||||
}
|
||||
|
||||
@@ -23,13 +23,27 @@ export type {
|
||||
RemoteOptions,
|
||||
} from "./cline-core/types";
|
||||
export type {
|
||||
AgentPluginPackageDiagnostic,
|
||||
AgentPluginPackageDiagnosticScope,
|
||||
AgentPluginPackageLoadReport,
|
||||
AgentPluginPackageManifest,
|
||||
AgentPluginPackageMcpServer,
|
||||
AgentPluginPackageSkill,
|
||||
AgentSkillMetadata,
|
||||
LoadAgentPluginFromPathOptions,
|
||||
LoadAgentPluginPackagesOptions,
|
||||
LoadedAgentPluginPackage,
|
||||
ParsedAgentSkill,
|
||||
ResolveAgentPluginPathsOptions,
|
||||
} from "./extensions";
|
||||
export {
|
||||
AGENT_PLUGINS_V1_MANIFEST_SCHEMA,
|
||||
AGENT_PLUGINS_V1_MCP_SCHEMA,
|
||||
discoverPluginModulePaths,
|
||||
loadAgentPluginFromPath,
|
||||
loadAgentPluginPackages,
|
||||
loadAgentPluginsFromPaths,
|
||||
parseAgentSkillMarkdown,
|
||||
resolveAgentPluginPaths,
|
||||
resolveAndLoadAgentPlugins,
|
||||
resolvePluginConfigSearchPaths,
|
||||
@@ -57,6 +71,7 @@ export type {
|
||||
WorkflowConfig,
|
||||
} from "./extensions/config";
|
||||
export {
|
||||
combineUserInstructionConfigServices,
|
||||
createRulesConfigDefinition,
|
||||
createSkillsConfigDefinition,
|
||||
createUserInstructionConfigService,
|
||||
@@ -130,6 +145,7 @@ export {
|
||||
filterDisabledTools,
|
||||
filterExtensionToolRegistrations,
|
||||
GlobalSettingsSchema,
|
||||
isAgentPluginDisabledGlobally,
|
||||
isAutoUpdateEnabledGlobally,
|
||||
isModelToolEnabledGlobally,
|
||||
isPluginDisabledGlobally,
|
||||
@@ -140,12 +156,14 @@ export {
|
||||
readGlobalSettings,
|
||||
readPlanActModeGlobally,
|
||||
readToolAutoApproveGlobally,
|
||||
resolveDisabledAgentPluginNames,
|
||||
resolveDisabledPluginPaths,
|
||||
resolveDisabledToolNames,
|
||||
resolveModelToolSettings,
|
||||
setAutoUpdateEnabledGlobally,
|
||||
setCompactionModeGlobally,
|
||||
setCompactionStrategyGlobally,
|
||||
setDisabledAgentPlugin,
|
||||
setDisabledPlugin,
|
||||
setDisabledTools,
|
||||
setModelToolEnabledGlobally,
|
||||
|
||||
@@ -271,6 +271,11 @@ export interface CoreSessionConfig
|
||||
extensionContext?: ExtensionContext;
|
||||
extraTools?: AgentTool[];
|
||||
pluginPaths?: string[];
|
||||
/**
|
||||
* Additional Agent Plugins v1 package roots. Paths are resolved by the
|
||||
* execution host, so hub clients do not load package contents themselves.
|
||||
*/
|
||||
agentPluginPaths?: string[];
|
||||
extensions?: AgentConfig["extensions"];
|
||||
execution?: AgentConfig["execution"];
|
||||
compaction?: CoreCompactionConfig;
|
||||
|
||||
@@ -20,6 +20,8 @@ export interface ChatRuntimeConfig extends SessionPromptConfig {
|
||||
enableSpawn?: boolean;
|
||||
enableTeams?: boolean;
|
||||
disableMcpSettingsTools?: boolean;
|
||||
/** Additional Agent Plugins package roots resolved by the hub runtime. */
|
||||
agentPluginPaths?: string[];
|
||||
autoApproveTools?: boolean;
|
||||
missionStepInterval?: number;
|
||||
missionTimeIntervalMs?: number;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
isAgentPluginDirectory,
|
||||
isChatWorkspacePath,
|
||||
RULES_CONFIG_DIRECTORY_NAME,
|
||||
resolveAgentPluginSearchPaths,
|
||||
resolveAgentsConfigDirPath,
|
||||
resolveChatWorkspacePath,
|
||||
resolveClineDataDir,
|
||||
@@ -31,6 +32,7 @@ import {
|
||||
resolveSessionDataDir,
|
||||
resolveTeamDataDir,
|
||||
resolveWorkflowsConfigSearchPaths,
|
||||
setHomeDir,
|
||||
} from "./paths";
|
||||
|
||||
type EnvSnapshot = {
|
||||
@@ -83,6 +85,20 @@ describe("storage path resolution", () => {
|
||||
restoreEnv(snapshot);
|
||||
});
|
||||
|
||||
it("only auto-discovers Agent Plugins from the user home", () => {
|
||||
const homeRoot = mkdtempSync(join(tmpdir(), "cline-agent-plugin-home-"));
|
||||
const previousHome = process.env.HOME ?? "~";
|
||||
try {
|
||||
setHomeDir(homeRoot);
|
||||
expect(resolveAgentPluginSearchPaths()).toEqual([
|
||||
join(homeRoot, ".agents", "plugins"),
|
||||
]);
|
||||
} finally {
|
||||
setHomeDir(previousHome);
|
||||
rmSync(homeRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("uses CLINE_DATA_DIR as-is when set", () => {
|
||||
snapshot = captureEnv();
|
||||
process.env.CLINE_DATA_DIR = "/tmp/cline-data";
|
||||
|
||||
@@ -570,23 +570,17 @@ export function resolvePluginConfigSearchPaths(
|
||||
}
|
||||
|
||||
/**
|
||||
* Roots searched for Agent Plugins (agent-plugins.org). Kept separate from
|
||||
* Root searched for Agent Plugins (agent-plugins.org). Kept separate from
|
||||
* {@link resolvePluginConfigSearchPaths} so the two package lanes never share a
|
||||
* directory: `.agents/plugins` is the vendor-neutral location, matching the
|
||||
* `.agents/skills` convention already honored by skill discovery.
|
||||
*
|
||||
* Discovery roots are explicitly client-defined by the specification, so these
|
||||
* are our choice rather than a conformance requirement.
|
||||
* Discovery roots are explicitly client-defined by the specification. Cline
|
||||
* only auto-discovers user-installed packages from the Hub host's home so
|
||||
* opening a repository cannot activate repository-controlled MCP servers.
|
||||
*/
|
||||
export function resolveAgentPluginSearchPaths(
|
||||
workspacePath?: string,
|
||||
): string[] {
|
||||
return dedupePaths([
|
||||
workspacePath
|
||||
? join(workspacePath, AGENTS_CONFIG_DIR, PLUGINS_DIRECTORY_NAME)
|
||||
: "",
|
||||
join(HOME_DIR, AGENTS_CONFIG_DIR, PLUGINS_DIRECTORY_NAME),
|
||||
]);
|
||||
export function resolveAgentPluginSearchPaths(): string[] {
|
||||
return [join(HOME_DIR, AGENTS_CONFIG_DIR, PLUGINS_DIRECTORY_NAME)];
|
||||
}
|
||||
|
||||
const PLUGIN_MODULE_EXTENSIONS = new Set([".js", ".ts"]);
|
||||
|
||||
Reference in New Issue
Block a user