mirror of
https://github.com/cline/cline.git
synced 2026-09-13 09:50:12 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
218b5a9b6a | ||
|
|
0df78f5e13 | ||
|
|
fc48f16c04 |
@@ -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 },
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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=="],
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user