From b771a2439833d966ef7ce0d43faf393550e7c1f8 Mon Sep 17 00:00:00 2001 From: Joshua Mitchell Date: Sun, 25 Jan 2026 14:15:07 -0600 Subject: [PATCH] feat(cli): add plugins uninstall command Add 'clawdbot plugins uninstall' command to properly remove installed plugins. Features: - Removes plugin install directory (npm/archive installs) - Preserves linked paths (doesn't delete source for --link installs) - Removes from plugins.installs config record - Removes from plugins.load.paths (for linked plugins) - Removes from plugins.entries (disables plugin) - --keep-config flag to preserve plugin settings while removing install This addresses the gap where users had to manually rm extension directories and edit config files to uninstall plugins. Related: Previously there was no programmatic way to uninstall plugins --- src/cli/plugins-cli.ts | 88 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 303231bdb..0c31c8d7d 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -445,6 +445,94 @@ export function registerPluginsCli(program: Command) { defaultRuntime.log(`Restart the gateway to load plugins.`); }); + plugins + .command("uninstall") + .description("Uninstall a plugin") + .argument("", "Plugin id") + .option("--keep-config", "Keep plugin config in entries (only remove install)", false) + .action(async (id: string, opts: { keepConfig?: boolean }) => { + const cfg = loadConfig(); + const report = buildPluginStatusReport({ config: cfg }); + const plugin = report.plugins.find((p) => p.id === id || p.name === id); + + if (!plugin) { + defaultRuntime.error(`Plugin not found: ${id}`); + process.exit(1); + } + + const install = cfg.plugins?.installs?.[plugin.id]; + if (!install) { + defaultRuntime.error( + `No install record for plugin "${plugin.id}". It may have been manually installed.`, + ); + process.exit(1); + } + + // Remove the install directory (but not if it's a linked path) + if (install.installPath && install.source !== "path") { + try { + if (fs.existsSync(install.installPath)) { + await fs.promises.rm(install.installPath, { recursive: true, force: true }); + defaultRuntime.log(`Removed: ${shortenHomePath(install.installPath)}`); + } + } catch (err) { + defaultRuntime.log(theme.warn(`Failed to remove directory: ${String(err)}`)); + } + } + + // Build new config + let next = { ...cfg }; + + // Remove from installs record + const { [plugin.id]: _removed, ...remainingInstalls } = cfg.plugins?.installs ?? {}; + next = { + ...next, + plugins: { + ...next.plugins, + installs: remainingInstalls, + }, + }; + + // Remove from load paths if it was linked + if (install.source === "path" && install.installPath) { + const paths = cfg.plugins?.load?.paths ?? []; + const installPath = install.installPath; + const filtered = paths.filter((p) => path.resolve(p) !== path.resolve(installPath)); + if (filtered.length !== paths.length) { + next = { + ...next, + plugins: { + ...next.plugins, + load: { + ...next.plugins?.load, + paths: filtered, + }, + }, + }; + defaultRuntime.log(`Removed from load paths: ${shortenHomePath(installPath)}`); + } + } + + // Disable plugin in entries unless --keep-config + if (!opts.keepConfig) { + const { [plugin.id]: _removedEntry, ...remainingEntries } = next.plugins?.entries ?? {}; + next = { + ...next, + plugins: { + ...next.plugins, + entries: remainingEntries, + }, + }; + } + + await writeConfigFile(next); + defaultRuntime.log(`Uninstalled plugin: ${plugin.id}`); + if (opts.keepConfig) { + defaultRuntime.log("Plugin config preserved. Use 'plugins disable' to disable it."); + } + defaultRuntime.log("Restart the gateway to apply changes."); + }); + plugins .command("update") .description("Update installed plugins (npm installs only)")