Compare commits

...
14 changed files with 525 additions and 50 deletions
+30
View File
@@ -41,6 +41,14 @@ const expectedPlatformPackages = [
"@cline/cli-windows-x64",
] as const;
const hostSdkPackages = [
{ name: "@cline/sdk", directory: "sdk" },
{ name: "@cline/core", directory: "core" },
{ name: "@cline/agents", directory: "agents" },
{ name: "@cline/llms", directory: "llms" },
{ name: "@cline/shared", directory: "shared" },
] as const;
interface PlatformPackageManifest {
name: string;
version: string;
@@ -68,6 +76,26 @@ function isPlatformPackageManifest(
);
}
function readPackageVersion(name: string, packageJsonPath: string): string {
const pkg: unknown = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
if (!isRecord(pkg) || pkg.name !== name || typeof pkg.version !== "string") {
console.error(`Invalid package manifest for ${name}: ${packageJsonPath}`);
process.exit(1);
}
return pkg.version;
}
function buildHostSdkDependencies(): Record<string, string> {
const dependencies: Record<string, string> = {};
for (const pkg of hostSdkPackages) {
dependencies[pkg.name] = readPackageVersion(
pkg.name,
join(cliDir, "../../packages", pkg.directory, "package.json"),
);
}
return dependencies;
}
function removePackedTarballs(dir: string): void {
for (const entry of readdirSync(dir)) {
if (entry.endsWith(".tgz")) {
@@ -175,6 +203,7 @@ if (sourceVersion !== version) {
}
const sourceRepository =
"repository" in sourcePkgRecord ? sourcePkgRecord.repository : undefined;
const hostSdkDependencies = buildHostSdkDependencies();
console.log(`Publishing ${wrapperPackageName} v${version}`);
console.log(` Tag: ${npmTag}`);
@@ -270,6 +299,7 @@ const wrapperPackageJson = {
scripts: {
postinstall: "node ./postinstall.mjs || true",
},
dependencies: hostSdkDependencies,
optionalDependencies: binaries,
};
@@ -45,6 +45,30 @@ describe("interactive config data loader", () => {
tempRoots.length = 0;
});
async function writeSettingsPlugin(tempRoot: string): Promise<string> {
const pluginsDir = join(tempRoot, ".cline", "plugins");
await mkdir(pluginsDir, { recursive: true });
const pluginPath = join(pluginsDir, "settings-plugin.js");
await writeFile(
pluginPath,
[
"export default {",
" name: 'settings-plugin',",
" manifest: { capabilities: ['tools'] },",
" setup(api) {",
" api.registerTool({",
" name: 'settings_plugin_tool',",
" description: 'Settings plugin tool',",
" inputSchema: { type: 'object', properties: {} },",
" execute: async () => 'ok',",
" });",
" },",
"};",
].join("\n"),
);
return pluginPath;
}
it("toggles a skill item to the opposite enabled state and refreshes before reload", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -139,6 +163,49 @@ Use this skill.`,
expect(data).toBeDefined();
});
it("can skip plugin tool imports for fast settings open", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
const pluginPath = await writeSettingsPlugin(tempRoot);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData({ includePluginTools: false });
expect(data.plugins.some((item) => item.path === pluginPath)).toBe(true);
expect(
data.tools.some((item) => item.pluginName === "settings-plugin"),
).toBe(false);
});
it("loads plugin tools when requested", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
process.env.CLINE_GLOBAL_SETTINGS_PATH = join(
tempRoot,
"global-settings.json",
);
await writeSettingsPlugin(tempRoot);
const loader = createInteractiveConfigDataLoader({
config: createConfig(tempRoot),
});
const data = await loader.loadConfigData({ includePluginTools: true });
expect(
data.tools.some(
(item) =>
item.pluginName === "settings-plugin" &&
item.name === "settings_plugin_tool",
),
).toBe(true);
});
it("toggles every SDK tool name for a displayed built-in tool", async () => {
const tempRoot = await mkdtemp(join(tmpdir(), "cli-config-data-"));
tempRoots.push(tempRoot);
@@ -191,7 +258,11 @@ Use this skill.`,
const plugin = data.plugins.find((item) => item.path === pluginPath);
expect(plugin?.enabled).toBe(false);
const nextData = await loader.onToggleConfigItem(plugin!);
expect(plugin).toBeDefined();
if (!plugin) {
throw new Error("Expected plugin config item");
}
const nextData = await loader.onToggleConfigItem(plugin);
const settings = JSON.parse(
await readFile(process.env.CLINE_GLOBAL_SETTINGS_PATH, "utf8"),
) as { disabledPlugins?: string[] };
@@ -6,6 +6,7 @@ import {
import {
type InteractiveConfigData,
type InteractiveConfigItem,
type LoadInteractiveConfigDataOptions,
loadInteractiveConfigData,
} from "../../tui/interactive-config";
import type { Config } from "../../utils/types";
@@ -23,16 +24,20 @@ export function createInteractiveConfigDataLoader(input: {
enableSpawnAgent: input.config.enableSpawnAgent,
enableAgentTeams: input.config.enableAgentTeams,
});
const loadConfigData = async (): Promise<InteractiveConfigData> =>
const loadConfigData = async (
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData> =>
await loadInteractiveConfigData({
userInstructionService: input.userInstructionService,
cwd: input.config.cwd,
workspaceRoot: workspaceRoot(),
availabilityContext: availabilityContext(),
includePluginTools: options.includePluginTools,
});
const onToggleConfigItem = async (
item: InteractiveConfigItem,
options: LoadInteractiveConfigDataOptions = {},
): Promise<InteractiveConfigData | undefined> => {
const settings = createCoreSettingsService();
if (item.kind === "skill" && typeof item.enabled === "boolean") {
@@ -47,12 +52,12 @@ export function createInteractiveConfigDataLoader(input: {
userInstructionService: input.userInstructionService,
availabilityContext: availabilityContext(),
});
return await loadConfigData();
return await loadConfigData(options);
}
if (item.kind === "plugin" && typeof item.enabled === "boolean") {
setDisabledPlugin(item.path, item.enabled);
return await loadConfigData();
return await loadConfigData(options);
}
if (item.kind === "mcp" && typeof item.enabled === "boolean") {
@@ -66,7 +71,7 @@ export function createInteractiveConfigDataLoader(input: {
workspaceRoot: workspaceRoot(),
availabilityContext: availabilityContext(),
});
return await loadConfigData();
return await loadConfigData(options);
}
if (
@@ -93,7 +98,7 @@ export function createInteractiveConfigDataLoader(input: {
availabilityContext: availabilityContext(),
});
}
return await loadConfigData();
return await loadConfigData(options);
};
return {
@@ -4,6 +4,7 @@ import { useState } from "react";
import type {
InteractiveConfigData,
InteractiveConfigItem,
LoadInteractiveConfigDataOptions,
} from "../../interactive-config";
import { palette } from "../../palette";
import {
@@ -18,6 +19,7 @@ export function ExtDetailContent(
item: InteractiveConfigItem;
onToggleConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
},
) {
@@ -33,7 +35,9 @@ export function ExtDetailContent(
}
setToggleError(undefined);
try {
const nextData = await props.onToggleConfigItem(item);
const nextData = await props.onToggleConfigItem(item, {
includePluginTools: false,
});
const nextItem = [
...(nextData?.workflows ?? []),
...(nextData?.rules ?? []),
@@ -5,6 +5,7 @@ import { useCallback, useMemo } from "react";
import type {
InteractiveConfigData,
InteractiveConfigItem,
LoadInteractiveConfigDataOptions,
} from "../../tui/interactive-config";
import type { CliCompactionMode, Config } from "../../utils/types";
import { ExtDetailContent } from "../components/dialogs/config-dialogs";
@@ -21,9 +22,12 @@ export function useConfigPanel(opts: {
toggleAutoApprove: () => void;
setCompactionMode: (mode: CliCompactionMode) => void;
termHeight: number;
loadConfigData: () => Promise<InteractiveConfigData>;
loadConfigData: (
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData>;
onToggleConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
openModelSelector: (options?: OpenModelSelectorOptions) => Promise<void>;
openMcpManager: (options?: { refocus?: boolean }) => Promise<boolean>;
@@ -47,7 +51,9 @@ export function useConfigPanel(opts: {
let keepOpen = true;
while (keepOpen) {
const [data, providerInfo] = await Promise.all([
opts.loadConfigData().catch(() => emptyConfigData),
opts
.loadConfigData({ includePluginTools: false })
.catch(() => emptyConfigData),
Llms.getProvider(opts.config.providerId).catch(() => undefined),
]);
const providerDisplayName = providerInfo?.name ?? opts.config.providerId;
@@ -60,6 +66,7 @@ export function useConfigPanel(opts: {
{...ctx}
config={opts.config}
configData={data}
loadConfigData={opts.loadConfigData}
providerDisplayName={providerDisplayName}
currentMode={opts.sessionUiMode}
currentCompactionMode={opts.compactionMode}
@@ -4,6 +4,7 @@ import { useCallback } from "react";
import type {
InteractiveConfigData,
InteractiveConfigItem,
LoadInteractiveConfigDataOptions,
} from "../../tui/interactive-config";
import {
type McpEntry,
@@ -21,13 +22,17 @@ function toMcpEntries(items: InteractiveConfigItem[]): McpEntry[] {
export function useMcpManager(opts: {
dialog: DialogActions;
termHeight: number;
loadConfigData: () => Promise<InteractiveConfigData>;
loadConfigData: (
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData>;
onSessionRestart: () => Promise<void>;
refocusTextarea: () => void;
}) {
return useCallback(
async (options?: { refocus?: boolean }) => {
const data = await opts.loadConfigData().catch(() => undefined);
const data = await opts
.loadConfigData({ includePluginTools: false })
.catch(() => undefined);
const servers = toMcpEntries(data?.mcp ?? []);
const changed = await opts.dialog.choice<boolean>({
style: { maxHeight: opts.termHeight - 2 },
+29 -22
View File
@@ -69,6 +69,10 @@ export interface InteractiveConfigData {
tools: InteractiveConfigItem[];
}
export interface LoadInteractiveConfigDataOptions {
includePluginTools?: boolean;
}
export function isToggleableInteractiveConfigItem(
item: Pick<InteractiveConfigItem, "kind" | "source">,
): boolean {
@@ -217,6 +221,7 @@ export async function loadInteractiveConfigData(input: {
cwd: string;
workspaceRoot: string;
availabilityContext?: BuiltinToolAvailabilityContext;
includePluginTools?: boolean;
}): Promise<InteractiveConfigData> {
const workflows: InteractiveConfigItem[] = [];
const rules: InteractiveConfigItem[] = [];
@@ -349,29 +354,31 @@ export async function loadInteractiveConfigData(input: {
description: tool.description,
})),
);
try {
for (const pluginTool of await listPluginTools({
workspacePath: input.workspaceRoot,
cwd: input.cwd,
providerId: input.availabilityContext?.providerId,
modelId: input.availabilityContext?.modelId,
})) {
tools.push({
id: `${pluginTool.pluginName}:${pluginTool.name}:${pluginTool.path}`,
name: pluginTool.name,
path: pluginTool.path,
enabled: pluginTool.enabled,
enabledState: pluginTool.enabled ? "enabled" : "disabled",
kind: "tool" as const,
toolNames: [pluginTool.name],
configKind: "tool",
pluginName: pluginTool.pluginName,
source: pluginTool.source,
description: pluginTool.description,
});
if (input.includePluginTools !== false) {
try {
for (const pluginTool of await listPluginTools({
workspacePath: input.workspaceRoot,
cwd: input.cwd,
providerId: input.availabilityContext?.providerId,
modelId: input.availabilityContext?.modelId,
})) {
tools.push({
id: `${pluginTool.pluginName}:${pluginTool.name}:${pluginTool.path}`,
name: pluginTool.name,
path: pluginTool.path,
enabled: pluginTool.enabled,
enabledState: pluginTool.enabled ? "enabled" : "disabled",
kind: "tool" as const,
toolNames: [pluginTool.name],
configKind: "tool",
pluginName: pluginTool.pluginName,
source: pluginTool.source,
description: pluginTool.description,
});
}
} catch {
// Best effort: built-in tools and instruction config should still render.
}
} catch {
// Best effort: built-in tools and instruction config should still render.
}
return {
+5 -1
View File
@@ -20,6 +20,7 @@ import type { ClineAccountSnapshot } from "./cline-account";
import type {
InteractiveConfigData,
InteractiveConfigItem,
LoadInteractiveConfigDataOptions,
} from "./interactive-config";
import type { InteractiveSlashCommand } from "./interactive-welcome";
@@ -116,9 +117,12 @@ export interface TuiProps {
loadWelcomeLine?: () => Promise<string | undefined>;
loadClineAccount: () => Promise<ClineAccountSnapshot>;
switchClineAccount: (organizationId?: string | null) => Promise<void>;
loadConfigData: () => Promise<InteractiveConfigData>;
loadConfigData: (
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData>;
onToggleConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
subscribeToEvents: (handlers: {
onAgentEvent: (event: AgentEvent) => void;
+80 -4
View File
@@ -1,11 +1,12 @@
import { useTerminalDimensions } from "@opentui/react";
import type { ChoiceContext } from "@opentui-ui/dialog";
import { useDialogKeyboard } from "@opentui-ui/dialog/react";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import type {
InteractiveConfigData,
InteractiveConfigItem,
InteractiveConfigTab,
LoadInteractiveConfigDataOptions,
} from "../../tui/interactive-config";
import {
formatCliCompactionMode,
@@ -117,11 +118,15 @@ const COMPACTION_MODE_COLORS: Record<CliCompactionMode, string> = {
export interface ConfigPanelProps extends ChoiceContext<ConfigAction> {
config: Config;
configData: InteractiveConfigData;
loadConfigData?: (
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData>;
providerDisplayName: string;
currentMode: string;
currentCompactionMode: CliCompactionMode;
onToggleConfigItem?: (
item: InteractiveConfigItem,
options?: LoadInteractiveConfigDataOptions,
) => Promise<InteractiveConfigData | undefined>;
onToggleMode: () => void;
onToggleAutoApprove: () => void;
@@ -244,12 +249,66 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
);
const [activeTab, setActiveTab] = useState<InteractiveConfigTab>("general");
const [configData, setConfigData] = useState(props.configData);
const [pluginToolsLoaded, setPluginToolsLoaded] = useState(
props.configData.tools.some((item) => item.pluginName),
);
const [pluginToolsLoading, setPluginToolsLoading] = useState(false);
const [pluginToolsError, setPluginToolsError] = useState<
string | undefined
>();
const [togglingItemId, setTogglingItemId] = useState<string | null>(null);
const [toggleError, setToggleError] = useState<string | undefined>();
const [navPos, setNavPos] = useState(0);
const displayName = resolveModelDisplayName(config);
useEffect(() => {
if (
activeTab !== "tools" ||
pluginToolsLoaded ||
pluginToolsLoading ||
pluginToolsError ||
!props.loadConfigData
) {
return;
}
let cancelled = false;
setPluginToolsLoading(true);
setPluginToolsError(undefined);
props
.loadConfigData({ includePluginTools: true })
.then((nextData) => {
if (cancelled) {
return;
}
setConfigData(nextData);
setPluginToolsLoaded(true);
})
.catch((error) => {
if (cancelled) {
return;
}
const message = error instanceof Error ? error.message : String(error);
setPluginToolsError(`Failed to load plugin tools: ${message}`);
})
.finally(() => {
if (!cancelled) {
setPluginToolsLoading(false);
}
});
return () => {
cancelled = true;
};
}, [
activeTab,
pluginToolsError,
pluginToolsLoaded,
pluginToolsLoading,
props.loadConfigData,
]);
const rows = useMemo(() => {
const r: ConfigRow[] = [];
@@ -271,13 +330,25 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
label: `${toTabLabel(activeTab)} (${activeItems.length})`,
});
if (activeItems.length === 0) {
if (activeItems.length === 0 && !pluginToolsLoading) {
r.push({
kind: "detail",
text: `No ${toTabLabel(activeTab).toLowerCase()} found.`,
});
} else if (activeTab === "tools") {
appendToolRows(r, activeItems);
if (pluginToolsLoading) {
r.push({
kind: "detail",
text: "Loading plugin tools...",
});
}
if (pluginToolsError) {
r.push({
kind: "detail",
text: pluginToolsError,
});
}
} else {
for (const item of activeItems) {
r.push({
@@ -298,7 +369,7 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
}
return r;
}, [activeTab, configData]);
}, [activeTab, configData, pluginToolsError, pluginToolsLoading]);
const navIndices = useMemo(
() => rows.map((r, i) => (isNavigable(r) ? i : -1)).filter((i) => i >= 0),
@@ -317,9 +388,14 @@ export function ConfigPanelContent(props: ConfigPanelProps) {
setTogglingItemId(item.id);
setToggleError(undefined);
try {
const nextData = await props.onToggleConfigItem(item);
const nextData = await props.onToggleConfigItem(item, {
includePluginTools: activeTab === "tools" && pluginToolsLoaded,
});
if (nextData) {
setConfigData(nextData);
setPluginToolsLoaded(
pluginToolsLoaded || nextData.tools.some((tool) => tool.pluginName),
);
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
+3 -5
View File
@@ -19,7 +19,7 @@
},
"apps/cli": {
"name": "@cline/cli",
"version": "3.0.1",
"version": "3.0.3",
"bin": {
"cline": "src/index.ts",
},
@@ -313,7 +313,7 @@
"@opentelemetry/sdk-trace-base": "^2.6.1",
"@opentelemetry/sdk-trace-node": "^2.6.1",
"@opentelemetry/semantic-conventions": "^1.40.0",
"jiti": "^1.21.7",
"jiti": "^2.7.0",
"nanoid": "^5.1.7",
"node-machine-id": "^1.1.12",
"simple-git": "3.36.0",
@@ -2271,7 +2271,7 @@
"jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="],
"jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
@@ -3285,8 +3285,6 @@
"@streamdown/code/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
"@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="],
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="],
+1 -1
View File
@@ -59,7 +59,7 @@
"@opentelemetry/sdk-trace-base": "^2.6.1",
"@opentelemetry/sdk-trace-node": "^2.6.1",
"@opentelemetry/semantic-conventions": "^1.40.0",
"jiti": "^1.21.7",
"jiti": "^2.7.0",
"node-machine-id": "^1.1.12",
"nanoid": "^5.1.7",
"simple-git": "3.36.0",
@@ -312,6 +312,54 @@ describe("plugin-loader", () => {
expect(plugin.name).toMatch(/ok: true/i);
});
it("resolves standalone plugin dependencies from the npm wrapper path", async () => {
const previousWrapperPath = process.env.CLINE_WRAPPER_PATH;
const wrapperRoot = join(dir, "wrapper-root");
const wrapperBinDir = join(wrapperRoot, "bin");
const depDir = join(wrapperRoot, "node_modules", "wrapper-host-dep");
const pluginPath = join(dir, "plugin-with-wrapper-dep.ts");
await mkdir(wrapperBinDir, { recursive: true });
await mkdir(depDir, { recursive: true });
await writeFile(join(wrapperBinDir, "cline"), "#!/usr/bin/env node\n");
await writeFile(
join(depDir, "package.json"),
JSON.stringify({
name: "wrapper-host-dep",
type: "module",
exports: "./index.js",
}),
"utf8",
);
await writeFile(
join(depDir, "index.js"),
"export const depName = 'wrapper-host-dep';\n",
"utf8",
);
await writeFile(
pluginPath,
[
"import { depName } from 'wrapper-host-dep';",
"export default {",
" name: depName,",
" manifest: { capabilities: ['tools'] },",
"};",
].join("\n"),
"utf8",
);
try {
process.env.CLINE_WRAPPER_PATH = join(wrapperBinDir, "cline");
const plugin = await loadAgentPluginFromPath(pluginPath);
expect(plugin.name).toBe("wrapper-host-dep");
} finally {
if (previousWrapperPath === undefined) {
delete process.env.CLINE_WRAPPER_PATH;
} else {
process.env.CLINE_WRAPPER_PATH = previousWrapperPath;
}
}
});
it("requires package-based plugins to provide their own non-SDK dependencies", async () => {
await expect(
loadAgentPluginFromPath(join(copyDir, "packaged-plugin", "index.ts"), {
@@ -11,6 +11,7 @@ const HOST_REQUIRE = createRequire(import.meta.url);
const WORKSPACE_ROOT = resolve(MODULE_DIR, "..", "..", "..", "..", "..");
const WORKSPACE_ALIASES = collectWorkspaceAliases(WORKSPACE_ROOT);
const HOST_PROVIDED_SDK_SPECIFIERS = [
"@cline/sdk",
"@cline/agents",
"@cline/core",
"@cline/core/hub",
@@ -44,6 +45,7 @@ export interface ImportPluginModuleOptions {
function collectWorkspaceAliases(root: string): Record<string, string> {
const aliases: Record<string, string> = {};
const candidates: Record<string, string> = {
"@cline/sdk": resolve(root, "packages/sdk/src/index.ts"),
"@cline/agents": resolve(root, "packages/agents/src/index.ts"),
"@cline/core": resolve(root, "packages/core/src/index.ts"),
"@cline/llms": resolve(root, "packages/llms/src/index.ts"),
@@ -288,8 +290,24 @@ function resolveHostPackageExport(specifier: string): string | null {
}
}
function findHostPackageRoot(packageName: string): string | null {
let current = MODULE_DIR;
function getHostPackageSearchRoots(): string[] {
const roots = [MODULE_DIR];
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
if (wrapperPath) {
roots.push(dirname(dirname(wrapperPath)));
}
const execPath = process.execPath?.trim();
if (execPath) {
roots.push(dirname(execPath));
}
return [...new Set(roots.map((root) => resolve(root)))];
}
function findHostPackageRootFrom(
startDir: string,
packageName: string,
): string | null {
let current = startDir;
while (true) {
const packageJsonPath = resolve(current, "package.json");
if (existsSync(packageJsonPath)) {
@@ -321,6 +339,16 @@ function findHostPackageRoot(packageName: string): string | null {
}
}
function findHostPackageRoot(packageName: string): string | null {
for (const root of getHostPackageSearchRoots()) {
const packageRoot = findHostPackageRootFrom(root, packageName);
if (packageRoot) {
return packageRoot;
}
}
return null;
}
function isPackageBasedPlugin(pluginFilePath: string): boolean {
// Walk up from the plugin file looking for a package.json with a `cline`
// manifest. Stop at the first package.json we encounter; if it doesn't
@@ -537,6 +565,86 @@ function collectPluginImportAliases(
return aliases;
}
type JitiTransform = (opts: {
source: string;
filename?: string;
ts?: boolean;
async?: boolean;
jsx?: unknown;
[key: string]: unknown;
}) => { code: string; error?: unknown };
let cachedJitiTransform: JitiTransform | null | undefined;
function loadJitiBabelTransform(): JitiTransform | null {
if (cachedJitiTransform !== undefined) {
return cachedJitiTransform;
}
// jiti's default lazyTransform path is
// createRequire(import.meta.url)("../dist/babel.cjs")
// which fails in a `bun build --compile` binary: `import.meta.url` is
// `bunfs:/root/chunk-XXXX.js`, so the relative resolve has nothing to
// walk through. The wrapper install layout still has the real file at
// <wrapper>/node_modules/jiti/dist/babel.cjs, so locate it on disk via
// our host-package resolver and createRequire from an actual on-disk
// path. Returning null falls back to jiti's own loader (works in dev).
const jitiRoot = findHostPackageRoot("jiti");
if (!jitiRoot) {
cachedJitiTransform = null;
return null;
}
const babelPath = resolve(jitiRoot, "dist", "babel.cjs");
if (!existsSync(babelPath)) {
cachedJitiTransform = null;
return null;
}
try {
const requireFromBabel = createRequire(babelPath);
const transform = requireFromBabel(babelPath) as unknown;
cachedJitiTransform =
typeof transform === "function" ? (transform as JitiTransform) : null;
} catch {
cachedJitiTransform = null;
}
return cachedJitiTransform;
}
let cachedHostVirtualModules: Record<string, unknown> | undefined;
function tryRequireFromPath(fromPath: string, specifier: string): unknown {
try {
return createRequire(fromPath)(specifier);
} catch {
return undefined;
}
}
function requireHostModule(specifier: string): unknown {
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
if (wrapperPath) {
const module = tryRequireFromPath(wrapperPath, specifier);
if (module) {
return module;
}
}
return tryRequireFromPath(import.meta.url, specifier);
}
function collectHostVirtualModules(): Record<string, unknown> {
if (cachedHostVirtualModules) {
return cachedHostVirtualModules;
}
const modules: Record<string, unknown> = {};
for (const specifier of HOST_PROVIDED_SDK_SPECIFIERS) {
const value = requireHostModule(specifier);
if (value && Object.keys(value).length > 0) {
modules[specifier] = value;
}
}
cachedHostVirtualModules = modules;
return modules;
}
export async function importPluginModule(
pluginPath: string,
options: ImportPluginModuleOptions = {},
@@ -558,14 +666,83 @@ export async function importPluginModule(
if (!createJiti) {
throw new Error("Unable to load jiti");
}
// The host packages (@cline/core, @cline/shared, etc.) are already loaded
// inside this process; the cline binary bundles them. Hand jiti those live
// module instances as virtual modules so a plugin's `import "@cline/core"`
// resolves to an object lookup instead of jiti walking + transforming the
// entire shipped package tree on every load (8s -> ~300ms for `cline config
// tools` in packaged installs). `virtualModules` lookup keys on the bare
// specifier before alias rewriting, so we strip any alias entry we'll
// satisfy virtually. Otherwise the alias rewrites the specifier to an
// absolute path and we pay the full file-load cost anyway.
//
// A plugin that ships its own installed copy of a host package (e.g. a
// pinned `@cline/shared` in its node_modules) must still see that copy, not
// our bundled one. `collectPluginImportAliases` already drops workspace
// aliases for plugin-installed deps; mirror that here so virtualModules
// behaves the same way.
const pluginRequire = createRequire(pluginPath);
const virtualModules: Record<string, unknown> = {};
for (const [specifier, value] of Object.entries(
collectHostVirtualModules(),
)) {
try {
pluginRequire.resolve(specifier);
continue;
} catch {
// Plugin doesn't ship its own copy; the bundled host module is
// what jiti should hand back for this specifier.
}
virtualModules[specifier] = value;
}
const jitiAliases: Record<string, string> = {};
for (const [specifier, target] of Object.entries(sortedAliases)) {
if (!Object.hasOwn(virtualModules, specifier)) {
jitiAliases[specifier] = target;
}
}
// jiti's lazyTransform uses `createRequire(import.meta.url)("../dist/babel.cjs")`
// to load its babel transformer on demand. In a `bun build --compile`
// binary that fails because `import.meta.url` points inside the bunfs
// bundle; the wrapper install still has the file on real disk though, so
// we locate it via our host-package resolver and inject the transform.
//
// jiti threads its top-level `interopDefault` into babel via the transform
// call's options (babel uses `noInterop: !interopDefault`). We want babel
// to emit the CJS interop wrapper so `import YAML from "yaml"` works for
// CJS deps, but we do not want jiti's runtime to wrap returned modules
// in a default-synthesizing Proxy (that proxy makes `moduleExports.default`
// truthy even for namespace-only modules, which breaks named-export
// plugins). Pin `interopDefault: true` going into babel by overriding it
// in the transform call, while keeping `interopDefault: false` on the jiti
// instance so the loader sees raw exports.
const baseBabelTransform = loadJitiBabelTransform();
const babelTransform: JitiTransform | undefined = baseBabelTransform
? (opts) => baseBabelTransform({ ...opts, interopDefault: true })
: undefined;
const jiti = createJiti(pluginPath, {
alias: sortedAliases,
alias: jitiAliases,
cache: options.useCache,
requireCache: options.useCache,
esmResolve: true,
interopDefault: false,
nativeModules: [...BUILTIN_MODULES],
transformModules: Object.keys(sortedAliases),
transformModules: Object.keys(jitiAliases),
virtualModules,
// On Bun (the packaged binary), tryNative defaults to true, which makes
// jiti hand the plugin path straight to Bun's `import()`. Bun then owns
// every nested import in the plugin, sees `import "@cline/core"` with no
// node_modules adjacent to the drop-in plugin, and throws ResolveMessage.
// Forcing tryNative off keeps jiti in charge so bare specifiers route
// through `virtualModules` first.
tryNative: false,
...(babelTransform ? { transform: babelTransform } : {}),
});
return (await jiti.import(pluginPath, {})) as Record<string, unknown>;
// Use the synchronous jiti(path) call rather than `jiti.import(path)`.
// The async path emits ESM, which `vm.runInThisContext` can't compile, so
// jiti falls back to `nativeImport(data:URL)`, and that Bun-side import
// has no way to consult our virtualModules map. The sync path emits CJS,
// wraps it in a function with jiti's own `require` injected, and routes
// every `require("@cline/core")` back through jitiRequire -> virtualModules.
return jiti(pluginPath) as Record<string, unknown>;
}
@@ -108,6 +108,45 @@ function isUnknownPluginIdError(error: unknown): boolean {
return message.includes("Unknown sandbox plugin id:");
}
function getPlatformPackageName(): string {
const platform = process.platform === "win32" ? "windows" : process.platform;
return `@cline/cli-${platform}-${process.arch}`;
}
function resolveBootstrapFromWrapper(): string | undefined {
const wrapperPath = process.env.CLINE_WRAPPER_PATH?.trim();
if (!wrapperPath) {
return undefined;
}
try {
const requireFromWrapper = createRequire(wrapperPath);
const packageJsonPath = requireFromWrapper.resolve(
`${getPlatformPackageName()}/package.json`,
);
const candidate = join(
dirname(packageJsonPath),
"extensions",
"plugin-sandbox-bootstrap.js",
);
return existsSync(candidate) ? candidate : undefined;
} catch {
return undefined;
}
}
function resolveBootstrapFromExecutable(): string | undefined {
const execPath = process.execPath?.trim();
if (!execPath) {
return undefined;
}
const candidate = join(
dirname(dirname(execPath)),
"extensions",
"plugin-sandbox-bootstrap.js",
);
return existsSync(candidate) ? candidate : undefined;
}
/**
* Resolve the bootstrap for the sandbox subprocess.
*
@@ -127,8 +166,12 @@ function resolveBootstrap(): { file: string } | { script: string } {
join(dir, "plugin-sandbox-bootstrap.js"),
join(dir, "extensions", "plugin-sandbox-bootstrap.js"),
join(dir, "agents", "plugin-sandbox-bootstrap.js"),
resolveBootstrapFromWrapper(),
resolveBootstrapFromExecutable(),
];
for (const candidate of candidates) {
for (const candidate of candidates.filter(
(candidate): candidate is string => typeof candidate === "string",
)) {
if (existsSync(candidate)) return { file: candidate };
}
const tsPath = join(dir, "plugin-sandbox-bootstrap.ts");