From 57d0f65e7db44b22f3a234835961ea77be1fd819 Mon Sep 17 00:00:00 2001 From: JustasM <59362982+JustasMonkev@users.noreply.github.com> Date: Fri, 13 Feb 2026 04:11:26 +0200 Subject: [PATCH] CLI: add plugins uninstall command (#5985) (openclaw#6141) thanks @JustasMonkev Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test Co-authored-by: JustasMonkev <59362982+JustasMonkev@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/cli/plugins.md | 21 +- src/cli/plugins-cli.ts | 146 +++++++++ src/plugins/uninstall.test.ts | 538 ++++++++++++++++++++++++++++++++++ src/plugins/uninstall.ts | 237 +++++++++++++++ 5 files changed, 942 insertions(+), 1 deletion(-) create mode 100644 src/plugins/uninstall.test.ts create mode 100644 src/plugins/uninstall.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index efba4fe5139..e3244bb8f6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- CLI/Plugins: add `openclaw plugins uninstall ` with `--dry-run`, `--force`, and `--keep-files` options, including safe uninstall path handling and plugin uninstall docs. (#5985) Thanks @JustasMonkev. - CLI: add `openclaw logs --local-time` to display log timestamps in local timezone. (#13818) Thanks @xialonglee. - Telegram: render blockquotes as native `
` tags instead of stripping them. (#14608) - Discord: add role-based allowlists and role-based agent routing. (#10650) Thanks @Minidoracat. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 19e56ab1c1f..0dc21fc7af3 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -1,5 +1,5 @@ --- -summary: "CLI reference for `openclaw plugins` (list, install, enable/disable, doctor)" +summary: "CLI reference for `openclaw plugins` (list, install, uninstall, enable/disable, doctor)" read_when: - You want to install or manage in-process Gateway plugins - You want to debug plugin load failures @@ -23,6 +23,7 @@ openclaw plugins list openclaw plugins info openclaw plugins enable openclaw plugins disable +openclaw plugins uninstall openclaw plugins doctor openclaw plugins update openclaw plugins update --all @@ -51,6 +52,24 @@ Use `--link` to avoid copying a local directory (adds to `plugins.load.paths`): openclaw plugins install -l ./my-plugin ``` +### Uninstall + +```bash +openclaw plugins uninstall +openclaw plugins uninstall --dry-run +openclaw plugins uninstall --keep-files +``` + +`uninstall` removes plugin records from `plugins.entries`, `plugins.installs`, +the plugin allowlist, and linked `plugins.load.paths` entries when applicable. +For active memory plugins, the memory slot resets to `memory-core`. + +By default, uninstall also removes the plugin install directory under the active +state dir extensions root (`$OPENCLAW_STATE_DIR/extensions/`). Use +`--keep-files` to keep files on disk. + +`--keep-config` is supported as a deprecated alias for `--keep-files`. + ### Update ```bash diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 21bc6a5cc35..09ce204354d 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -1,21 +1,25 @@ import type { Command } from "commander"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import type { OpenClawConfig } from "../config/config.js"; import type { PluginRecord } from "../plugins/registry.js"; import { loadConfig, writeConfigFile } from "../config/config.js"; +import { resolveStateDir } from "../config/paths.js"; import { resolveArchiveKind } from "../infra/archive.js"; import { installPluginFromNpmSpec, installPluginFromPath } from "../plugins/install.js"; import { recordPluginInstall } from "../plugins/installs.js"; import { applyExclusiveSlotSelection } from "../plugins/slots.js"; import { resolvePluginSourceRoots, formatPluginSourceForTable } from "../plugins/source-display.js"; import { buildPluginStatusReport } from "../plugins/status.js"; +import { resolveUninstallDirectoryTarget, uninstallPlugin } from "../plugins/uninstall.js"; import { updateNpmInstalledPlugins } from "../plugins/update.js"; import { defaultRuntime } from "../runtime.js"; import { formatDocsLink } from "../terminal/links.js"; import { renderTable } from "../terminal/table.js"; import { theme } from "../terminal/theme.js"; import { resolveUserPath, shortenHomeInString, shortenHomePath } from "../utils.js"; +import { promptYesNo } from "./prompt.js"; export type PluginsListOptions = { json?: boolean; @@ -32,6 +36,13 @@ export type PluginUpdateOptions = { dryRun?: boolean; }; +export type PluginUninstallOptions = { + keepFiles?: boolean; + keepConfig?: boolean; + force?: boolean; + dryRun?: boolean; +}; + function formatPluginLine(plugin: PluginRecord, verbose = false): string { const status = plugin.status === "loaded" @@ -332,6 +343,141 @@ export function registerPluginsCli(program: Command) { defaultRuntime.log(`Disabled plugin "${id}". Restart the gateway to apply.`); }); + plugins + .command("uninstall") + .description("Uninstall a plugin") + .argument("", "Plugin id") + .option("--keep-files", "Keep installed files on disk", false) + .option("--keep-config", "Deprecated alias for --keep-files", false) + .option("--force", "Skip confirmation prompt", false) + .option("--dry-run", "Show what would be removed without making changes", false) + .action(async (id: string, opts: PluginUninstallOptions) => { + const cfg = loadConfig(); + const report = buildPluginStatusReport({ config: cfg }); + const extensionsDir = path.join(resolveStateDir(process.env, os.homedir), "extensions"); + const keepFiles = Boolean(opts.keepFiles || opts.keepConfig); + + if (opts.keepConfig) { + defaultRuntime.log(theme.warn("`--keep-config` is deprecated, use `--keep-files`.")); + } + + // Find plugin by id or name + const plugin = report.plugins.find((p) => p.id === id || p.name === id); + const pluginId = plugin?.id ?? id; + + // Check if plugin exists in config + const hasEntry = pluginId in (cfg.plugins?.entries ?? {}); + const hasInstall = pluginId in (cfg.plugins?.installs ?? {}); + + if (!hasEntry && !hasInstall) { + if (plugin) { + defaultRuntime.error( + `Plugin "${pluginId}" is not managed by plugins config/install records and cannot be uninstalled.`, + ); + } else { + defaultRuntime.error(`Plugin not found: ${id}`); + } + process.exit(1); + } + + const install = cfg.plugins?.installs?.[pluginId]; + const isLinked = install?.source === "path"; + + // Build preview of what will be removed + const preview: string[] = []; + if (hasEntry) { + preview.push("config entry"); + } + if (hasInstall) { + preview.push("install record"); + } + if (cfg.plugins?.allow?.includes(pluginId)) { + preview.push("allowlist entry"); + } + if ( + isLinked && + install?.sourcePath && + cfg.plugins?.load?.paths?.includes(install.sourcePath) + ) { + preview.push("load path"); + } + if (cfg.plugins?.slots?.memory === pluginId) { + preview.push(`memory slot (will reset to "memory-core")`); + } + const deleteTarget = !keepFiles + ? resolveUninstallDirectoryTarget({ + pluginId, + hasInstall, + installRecord: install, + extensionsDir, + }) + : null; + if (deleteTarget) { + preview.push(`directory: ${shortenHomePath(deleteTarget)}`); + } + + const pluginName = plugin?.name || pluginId; + defaultRuntime.log( + `Plugin: ${theme.command(pluginName)}${pluginName !== pluginId ? theme.muted(` (${pluginId})`) : ""}`, + ); + defaultRuntime.log(`Will remove: ${preview.length > 0 ? preview.join(", ") : "(nothing)"}`); + + if (opts.dryRun) { + defaultRuntime.log(theme.muted("Dry run, no changes made.")); + return; + } + + if (!opts.force) { + const confirmed = await promptYesNo(`Uninstall plugin "${pluginId}"?`); + if (!confirmed) { + defaultRuntime.log("Cancelled."); + return; + } + } + + const result = await uninstallPlugin({ + config: cfg, + pluginId, + deleteFiles: !keepFiles, + extensionsDir, + }); + + if (!result.ok) { + defaultRuntime.error(result.error); + process.exit(1); + } + for (const warning of result.warnings) { + defaultRuntime.log(theme.warn(warning)); + } + + await writeConfigFile(result.config); + + const removed: string[] = []; + if (result.actions.entry) { + removed.push("config entry"); + } + if (result.actions.install) { + removed.push("install record"); + } + if (result.actions.allowlist) { + removed.push("allowlist"); + } + if (result.actions.loadPath) { + removed.push("load path"); + } + if (result.actions.memorySlot) { + removed.push("memory slot"); + } + if (result.actions.directory) { + removed.push("directory"); + } + + defaultRuntime.log( + `Uninstalled plugin "${pluginId}". Removed: ${removed.length > 0 ? removed.join(", ") : "nothing"}.`, + ); + defaultRuntime.log("Restart the gateway to apply changes."); + }); + plugins .command("install") .description("Install a plugin (path, archive, or npm spec)") diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts new file mode 100644 index 00000000000..ec1129f9c4f --- /dev/null +++ b/src/plugins/uninstall.test.ts @@ -0,0 +1,538 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolvePluginInstallDir } from "./install.js"; +import { + removePluginFromConfig, + resolveUninstallDirectoryTarget, + uninstallPlugin, +} from "./uninstall.js"; + +describe("removePluginFromConfig", () => { + it("removes plugin from entries", () => { + const config: OpenClawConfig = { + plugins: { + entries: { + "my-plugin": { enabled: true }, + "other-plugin": { enabled: true }, + }, + }, + }; + + const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); + + expect(result.plugins?.entries).toEqual({ "other-plugin": { enabled: true } }); + expect(actions.entry).toBe(true); + }); + + it("removes plugin from installs", () => { + const config: OpenClawConfig = { + plugins: { + installs: { + "my-plugin": { source: "npm", spec: "my-plugin@1.0.0" }, + "other-plugin": { source: "npm", spec: "other-plugin@1.0.0" }, + }, + }, + }; + + const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); + + expect(result.plugins?.installs).toEqual({ + "other-plugin": { source: "npm", spec: "other-plugin@1.0.0" }, + }); + expect(actions.install).toBe(true); + }); + + it("removes plugin from allowlist", () => { + const config: OpenClawConfig = { + plugins: { + allow: ["my-plugin", "other-plugin"], + }, + }; + + const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); + + expect(result.plugins?.allow).toEqual(["other-plugin"]); + expect(actions.allowlist).toBe(true); + }); + + it("removes linked path from load.paths", () => { + const config: OpenClawConfig = { + plugins: { + installs: { + "my-plugin": { + source: "path", + sourcePath: "/path/to/plugin", + installPath: "/path/to/plugin", + }, + }, + load: { + paths: ["/path/to/plugin", "/other/path"], + }, + }, + }; + + const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); + + expect(result.plugins?.load?.paths).toEqual(["/other/path"]); + expect(actions.loadPath).toBe(true); + }); + + it("cleans up load when removing the only linked path", () => { + const config: OpenClawConfig = { + plugins: { + installs: { + "my-plugin": { + source: "path", + sourcePath: "/path/to/plugin", + installPath: "/path/to/plugin", + }, + }, + load: { + paths: ["/path/to/plugin"], + }, + }, + }; + + const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); + + expect(result.plugins?.load).toBeUndefined(); + expect(actions.loadPath).toBe(true); + }); + + it("clears memory slot when uninstalling active memory plugin", () => { + const config: OpenClawConfig = { + plugins: { + entries: { + "memory-plugin": { enabled: true }, + }, + slots: { + memory: "memory-plugin", + }, + }, + }; + + const { config: result, actions } = removePluginFromConfig(config, "memory-plugin"); + + expect(result.plugins?.slots?.memory).toBe("memory-core"); + expect(actions.memorySlot).toBe(true); + }); + + it("does not modify memory slot when uninstalling non-memory plugin", () => { + const config: OpenClawConfig = { + plugins: { + entries: { + "my-plugin": { enabled: true }, + }, + slots: { + memory: "memory-core", + }, + }, + }; + + const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); + + expect(result.plugins?.slots?.memory).toBe("memory-core"); + expect(actions.memorySlot).toBe(false); + }); + + it("removes plugins object when uninstall leaves only empty slots", () => { + const config: OpenClawConfig = { + plugins: { + entries: { + "my-plugin": { enabled: true }, + }, + slots: {}, + }, + }; + + const { config: result } = removePluginFromConfig(config, "my-plugin"); + + expect(result.plugins?.slots).toBeUndefined(); + }); + + it("cleans up empty slots object", () => { + const config: OpenClawConfig = { + plugins: { + entries: { + "my-plugin": { enabled: true }, + }, + slots: {}, + }, + }; + + const { config: result } = removePluginFromConfig(config, "my-plugin"); + + expect(result.plugins).toBeUndefined(); + }); + + it("handles plugin that only exists in entries", () => { + const config: OpenClawConfig = { + plugins: { + entries: { + "my-plugin": { enabled: true }, + }, + }, + }; + + const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); + + expect(result.plugins?.entries).toBeUndefined(); + expect(actions.entry).toBe(true); + expect(actions.install).toBe(false); + }); + + it("handles plugin that only exists in installs", () => { + const config: OpenClawConfig = { + plugins: { + installs: { + "my-plugin": { source: "npm", spec: "my-plugin@1.0.0" }, + }, + }, + }; + + const { config: result, actions } = removePluginFromConfig(config, "my-plugin"); + + expect(result.plugins?.installs).toBeUndefined(); + expect(actions.install).toBe(true); + expect(actions.entry).toBe(false); + }); + + it("cleans up empty plugins object", () => { + const config: OpenClawConfig = { + plugins: { + entries: { + "my-plugin": { enabled: true }, + }, + }, + }; + + const { config: result } = removePluginFromConfig(config, "my-plugin"); + + // After removing the only entry, entries should be undefined + expect(result.plugins?.entries).toBeUndefined(); + }); + + it("preserves other config values", () => { + const config: OpenClawConfig = { + plugins: { + enabled: true, + deny: ["denied-plugin"], + entries: { + "my-plugin": { enabled: true }, + }, + }, + }; + + const { config: result } = removePluginFromConfig(config, "my-plugin"); + + expect(result.plugins?.enabled).toBe(true); + expect(result.plugins?.deny).toEqual(["denied-plugin"]); + }); +}); + +describe("uninstallPlugin", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "uninstall-test-")); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + }); + + it("returns error when plugin not found", async () => { + const config: OpenClawConfig = {}; + + const result = await uninstallPlugin({ + config, + pluginId: "nonexistent", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe("Plugin not found: nonexistent"); + } + }); + + it("removes config entries", async () => { + const config: OpenClawConfig = { + plugins: { + entries: { + "my-plugin": { enabled: true }, + }, + installs: { + "my-plugin": { source: "npm", spec: "my-plugin@1.0.0" }, + }, + }, + }; + + const result = await uninstallPlugin({ + config, + pluginId: "my-plugin", + deleteFiles: false, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.config.plugins?.entries).toBeUndefined(); + expect(result.config.plugins?.installs).toBeUndefined(); + expect(result.actions.entry).toBe(true); + expect(result.actions.install).toBe(true); + } + }); + + it("deletes directory when deleteFiles is true", async () => { + const pluginId = "my-plugin"; + const extensionsDir = path.join(tempDir, "extensions"); + const pluginDir = resolvePluginInstallDir(pluginId, extensionsDir); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin"); + + const config: OpenClawConfig = { + plugins: { + entries: { + [pluginId]: { enabled: true }, + }, + installs: { + [pluginId]: { + source: "npm", + spec: `${pluginId}@1.0.0`, + installPath: pluginDir, + }, + }, + }, + }; + + try { + const result = await uninstallPlugin({ + config, + pluginId, + deleteFiles: true, + extensionsDir, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.actions.directory).toBe(true); + await expect(fs.access(pluginDir)).rejects.toThrow(); + } + } finally { + await fs.rm(pluginDir, { recursive: true, force: true }); + } + }); + + it("preserves directory for linked plugins", async () => { + const pluginDir = path.join(tempDir, "my-plugin"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin"); + + const config: OpenClawConfig = { + plugins: { + entries: { + "my-plugin": { enabled: true }, + }, + installs: { + "my-plugin": { + source: "path", + sourcePath: pluginDir, + installPath: pluginDir, + }, + }, + load: { + paths: [pluginDir], + }, + }, + }; + + const result = await uninstallPlugin({ + config, + pluginId: "my-plugin", + deleteFiles: true, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.actions.directory).toBe(false); + expect(result.actions.loadPath).toBe(true); + // Directory should still exist + await expect(fs.access(pluginDir)).resolves.toBeUndefined(); + } + }); + + it("does not delete directory when deleteFiles is false", async () => { + const pluginDir = path.join(tempDir, "my-plugin"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin"); + + const config: OpenClawConfig = { + plugins: { + entries: { + "my-plugin": { enabled: true }, + }, + installs: { + "my-plugin": { + source: "npm", + spec: "my-plugin@1.0.0", + installPath: pluginDir, + }, + }, + }, + }; + + const result = await uninstallPlugin({ + config, + pluginId: "my-plugin", + deleteFiles: false, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.actions.directory).toBe(false); + // Directory should still exist + await expect(fs.access(pluginDir)).resolves.toBeUndefined(); + } + }); + + it("succeeds even if directory does not exist", async () => { + const config: OpenClawConfig = { + plugins: { + entries: { + "my-plugin": { enabled: true }, + }, + installs: { + "my-plugin": { + source: "npm", + spec: "my-plugin@1.0.0", + installPath: "/nonexistent/path", + }, + }, + }, + }; + + const result = await uninstallPlugin({ + config, + pluginId: "my-plugin", + deleteFiles: true, + }); + + // Should succeed; directory deletion failure is not fatal + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.actions.directory).toBe(false); + expect(result.warnings).toEqual([]); + } + }); + + it("returns a warning when directory deletion fails unexpectedly", async () => { + const pluginId = "my-plugin"; + const extensionsDir = path.join(tempDir, "extensions"); + const pluginDir = resolvePluginInstallDir(pluginId, extensionsDir); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin"); + + const config: OpenClawConfig = { + plugins: { + entries: { + [pluginId]: { enabled: true }, + }, + installs: { + [pluginId]: { + source: "npm", + spec: `${pluginId}@1.0.0`, + installPath: pluginDir, + }, + }, + }, + }; + + const rmSpy = vi.spyOn(fs, "rm").mockRejectedValueOnce(new Error("permission denied")); + try { + const result = await uninstallPlugin({ + config, + pluginId, + deleteFiles: true, + extensionsDir, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.actions.directory).toBe(false); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("Failed to remove plugin directory"); + } + } finally { + rmSpy.mockRestore(); + } + }); + + it("never deletes arbitrary configured install paths", async () => { + const outsideDir = path.join(tempDir, "outside-dir"); + const extensionsDir = path.join(tempDir, "extensions"); + await fs.mkdir(outsideDir, { recursive: true }); + await fs.writeFile(path.join(outsideDir, "index.js"), "// keep me"); + + const config: OpenClawConfig = { + plugins: { + entries: { + "my-plugin": { enabled: true }, + }, + installs: { + "my-plugin": { + source: "npm", + spec: "my-plugin@1.0.0", + installPath: outsideDir, + }, + }, + }, + }; + + const result = await uninstallPlugin({ + config, + pluginId: "my-plugin", + deleteFiles: true, + extensionsDir, + }); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.actions.directory).toBe(false); + await expect(fs.access(outsideDir)).resolves.toBeUndefined(); + } + }); +}); + +describe("resolveUninstallDirectoryTarget", () => { + it("returns null for linked plugins", () => { + expect( + resolveUninstallDirectoryTarget({ + pluginId: "my-plugin", + hasInstall: true, + installRecord: { + source: "path", + sourcePath: "/tmp/my-plugin", + installPath: "/tmp/my-plugin", + }, + }), + ).toBeNull(); + }); + + it("falls back to default path when configured installPath is untrusted", () => { + const extensionsDir = path.join(os.tmpdir(), "openclaw-uninstall-safe"); + const target = resolveUninstallDirectoryTarget({ + pluginId: "my-plugin", + hasInstall: true, + installRecord: { + source: "npm", + spec: "my-plugin@1.0.0", + installPath: "/tmp/not-openclaw-extensions/my-plugin", + }, + extensionsDir, + }); + + expect(target).toBe(resolvePluginInstallDir("my-plugin", extensionsDir)); + }); +}); diff --git a/src/plugins/uninstall.ts b/src/plugins/uninstall.ts new file mode 100644 index 00000000000..40fe5b90a59 --- /dev/null +++ b/src/plugins/uninstall.ts @@ -0,0 +1,237 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/config.js"; +import type { PluginInstallRecord } from "../config/types.plugins.js"; +import { resolvePluginInstallDir } from "./install.js"; +import { defaultSlotIdForKey } from "./slots.js"; + +export type UninstallActions = { + entry: boolean; + install: boolean; + allowlist: boolean; + loadPath: boolean; + memorySlot: boolean; + directory: boolean; +}; + +export type UninstallPluginResult = + | { + ok: true; + config: OpenClawConfig; + pluginId: string; + actions: UninstallActions; + warnings: string[]; + } + | { ok: false; error: string }; + +export function resolveUninstallDirectoryTarget(params: { + pluginId: string; + hasInstall: boolean; + installRecord?: PluginInstallRecord; + extensionsDir?: string; +}): string | null { + if (!params.hasInstall) { + return null; + } + + if (params.installRecord?.source === "path") { + return null; + } + + let defaultPath: string; + try { + defaultPath = resolvePluginInstallDir(params.pluginId, params.extensionsDir); + } catch { + return null; + } + + const configuredPath = params.installRecord?.installPath; + if (!configuredPath) { + return defaultPath; + } + + if (path.resolve(configuredPath) === path.resolve(defaultPath)) { + return configuredPath; + } + + // Never trust configured installPath blindly for recursive deletes. + return defaultPath; +} + +/** + * Remove plugin references from config (pure config mutation). + * Returns a new config with the plugin removed from entries, installs, allow, load.paths, and slots. + */ +export function removePluginFromConfig( + cfg: OpenClawConfig, + pluginId: string, +): { config: OpenClawConfig; actions: Omit } { + const actions: Omit = { + entry: false, + install: false, + allowlist: false, + loadPath: false, + memorySlot: false, + }; + + const pluginsConfig = cfg.plugins ?? {}; + + // Remove from entries + let entries = pluginsConfig.entries; + if (entries && pluginId in entries) { + const { [pluginId]: _, ...rest } = entries; + entries = Object.keys(rest).length > 0 ? rest : undefined; + actions.entry = true; + } + + // Remove from installs + let installs = pluginsConfig.installs; + const installRecord = installs?.[pluginId]; + if (installs && pluginId in installs) { + const { [pluginId]: _, ...rest } = installs; + installs = Object.keys(rest).length > 0 ? rest : undefined; + actions.install = true; + } + + // Remove from allowlist + let allow = pluginsConfig.allow; + if (Array.isArray(allow) && allow.includes(pluginId)) { + allow = allow.filter((id) => id !== pluginId); + if (allow.length === 0) { + allow = undefined; + } + actions.allowlist = true; + } + + // Remove linked path from load.paths (for source === "path" plugins) + let load = pluginsConfig.load; + if (installRecord?.source === "path" && installRecord.sourcePath) { + const sourcePath = installRecord.sourcePath; + const loadPaths = load?.paths; + if (Array.isArray(loadPaths) && loadPaths.includes(sourcePath)) { + const nextLoadPaths = loadPaths.filter((p) => p !== sourcePath); + load = nextLoadPaths.length > 0 ? { ...load, paths: nextLoadPaths } : undefined; + actions.loadPath = true; + } + } + + // Reset memory slot if this plugin was selected + let slots = pluginsConfig.slots; + if (slots?.memory === pluginId) { + slots = { + ...slots, + memory: defaultSlotIdForKey("memory"), + }; + actions.memorySlot = true; + } + if (slots && Object.keys(slots).length === 0) { + slots = undefined; + } + + const newPlugins = { + ...pluginsConfig, + entries, + installs, + allow, + load, + slots, + }; + + // Clean up undefined properties from newPlugins + const cleanedPlugins: typeof newPlugins = { ...newPlugins }; + if (cleanedPlugins.entries === undefined) { + delete cleanedPlugins.entries; + } + if (cleanedPlugins.installs === undefined) { + delete cleanedPlugins.installs; + } + if (cleanedPlugins.allow === undefined) { + delete cleanedPlugins.allow; + } + if (cleanedPlugins.load === undefined) { + delete cleanedPlugins.load; + } + if (cleanedPlugins.slots === undefined) { + delete cleanedPlugins.slots; + } + + const config: OpenClawConfig = { + ...cfg, + plugins: Object.keys(cleanedPlugins).length > 0 ? cleanedPlugins : undefined, + }; + + return { config, actions }; +} + +export type UninstallPluginParams = { + config: OpenClawConfig; + pluginId: string; + deleteFiles?: boolean; + extensionsDir?: string; +}; + +/** + * Uninstall a plugin by removing it from config and optionally deleting installed files. + * Linked plugins (source === "path") never have their source directory deleted. + */ +export async function uninstallPlugin( + params: UninstallPluginParams, +): Promise { + const { config, pluginId, deleteFiles = true, extensionsDir } = params; + + // Validate plugin exists + const hasEntry = pluginId in (config.plugins?.entries ?? {}); + const hasInstall = pluginId in (config.plugins?.installs ?? {}); + + if (!hasEntry && !hasInstall) { + return { ok: false, error: `Plugin not found: ${pluginId}` }; + } + + const installRecord = config.plugins?.installs?.[pluginId]; + const isLinked = installRecord?.source === "path"; + + // Remove from config + const { config: newConfig, actions: configActions } = removePluginFromConfig(config, pluginId); + + const actions: UninstallActions = { + ...configActions, + directory: false, + }; + const warnings: string[] = []; + + const deleteTarget = + deleteFiles && !isLinked + ? resolveUninstallDirectoryTarget({ + pluginId, + hasInstall, + installRecord, + extensionsDir, + }) + : null; + + // Delete installed directory if requested and safe. + if (deleteTarget) { + const existed = + (await fs + .access(deleteTarget) + .then(() => true) + .catch(() => false)) ?? false; + try { + await fs.rm(deleteTarget, { recursive: true, force: true }); + actions.directory = existed; + } catch (error) { + warnings.push( + `Failed to remove plugin directory ${deleteTarget}: ${error instanceof Error ? error.message : String(error)}`, + ); + // Directory deletion failure is not fatal; config is the source of truth. + } + } + + return { + ok: true, + config: newConfig, + pluginId, + actions, + warnings, + }; +}