diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 19e251635d5..113c9928a1d 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -5,6 +5,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; import { resolveStateDir } from "../config/paths.js"; import type { PluginInstallRecord } from "../config/types.plugins.js"; +import { parseClawHubPluginSpec } from "../infra/clawhub.js"; import { enablePluginInConfig } from "../plugins/enable.js"; import { listMarketplacePlugins } from "../plugins/marketplace.js"; import type { PluginRecord } from "../plugins/registry.js"; @@ -59,6 +60,44 @@ export type PluginUninstallOptions = { dryRun?: boolean; }; +function resolvePluginUninstallId(params: { + rawId: string; + config: OpenClawConfig; + plugins: PluginRecord[]; +}): { pluginId: string; plugin?: PluginRecord } { + const rawId = params.rawId.trim(); + const plugin = params.plugins.find((entry) => entry.id === rawId || entry.name === rawId); + if (plugin) { + return { pluginId: plugin.id, plugin }; + } + + for (const [pluginId, install] of Object.entries(params.config.plugins?.installs ?? {})) { + if ( + install.spec === rawId || + install.resolvedSpec === rawId || + install.resolvedName === rawId || + install.marketplacePlugin === rawId + ) { + return { pluginId }; + } + } + + const requestedClawHub = parseClawHubPluginSpec(rawId); + if (requestedClawHub) { + for (const [pluginId, install] of Object.entries(params.config.plugins?.installs ?? {})) { + const installedClawHubName = + install.clawhubPackage ?? + parseClawHubPluginSpec(install.spec ?? "")?.name ?? + parseClawHubPluginSpec(install.resolvedSpec ?? "")?.name; + if (installedClawHubName === requestedClawHub.name) { + return { pluginId }; + } + } + } + + return { pluginId: rawId }; +} + function formatPluginLine(plugin: PluginRecord, verbose = false): string { const status = plugin.status === "loaded" @@ -546,8 +585,11 @@ export function registerPluginsCli(program: Command) { defaultRuntime.log(theme.warn("`--keep-config` is deprecated, use `--keep-files`.")); } - const plugin = report.plugins.find((p) => p.id === id || p.name === id); - const pluginId = plugin?.id ?? id; + const { plugin, pluginId } = resolvePluginUninstallId({ + rawId: id, + config: cfg, + plugins: report.plugins, + }); const hasEntry = pluginId in (cfg.plugins?.entries ?? {}); const hasInstall = pluginId in (cfg.plugins?.installs ?? {}); diff --git a/src/cli/plugins-cli.uninstall.test.ts b/src/cli/plugins-cli.uninstall.test.ts index a038eff6492..4dfeb02d922 100644 --- a/src/cli/plugins-cli.uninstall.test.ts +++ b/src/cli/plugins-cli.uninstall.test.ts @@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../config/config.js"; import { buildPluginStatusReport, loadConfig, + parseClawHubPluginSpec, promptYesNo, resetPluginsCliTestState, runPluginsCommand, @@ -118,4 +119,73 @@ describe("plugins cli uninstall", () => { expect(runtimeErrors.at(-1)).toContain("is not managed by plugins config/install records"); expect(uninstallPlugin).not.toHaveBeenCalled(); }); + + it("accepts the recorded ClawHub spec as an uninstall target", async () => { + loadConfig.mockReturnValue({ + plugins: { + entries: { + "linkmind-context": { enabled: true }, + }, + installs: { + "linkmind-context": { + source: "npm", + spec: "clawhub:linkmind-context", + clawhubPackage: "linkmind-context", + }, + }, + }, + } as OpenClawConfig); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "linkmind-context", name: "linkmind-context" }], + diagnostics: [], + }); + parseClawHubPluginSpec.mockImplementation((raw: string) => + raw === "clawhub:linkmind-context" ? { name: "linkmind-context" } : null, + ); + + await runPluginsCommand(["plugins", "uninstall", "clawhub:linkmind-context", "--force"]); + + expect(uninstallPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: "linkmind-context", + }), + ); + }); + + it("accepts a versionless ClawHub spec when the install was pinned", async () => { + loadConfig.mockReturnValue({ + plugins: { + entries: { + "linkmind-context": { enabled: true }, + }, + installs: { + "linkmind-context": { + source: "npm", + spec: "clawhub:linkmind-context@1.2.3", + }, + }, + }, + } as OpenClawConfig); + buildPluginStatusReport.mockReturnValue({ + plugins: [{ id: "linkmind-context", name: "linkmind-context" }], + diagnostics: [], + }); + parseClawHubPluginSpec.mockImplementation((raw: string) => { + if (raw === "clawhub:linkmind-context") { + return { name: "linkmind-context" }; + } + if (raw === "clawhub:linkmind-context@1.2.3") { + return { name: "linkmind-context", version: "1.2.3" }; + } + return null; + }); + + await runPluginsCommand(["plugins", "uninstall", "clawhub:linkmind-context", "--force"]); + + expect(uninstallPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + pluginId: "linkmind-context", + }), + ); + }); });