Skip to content

Commit 3cfc083

Browse files
committed
Add plugin uninstall and a how-to header to /plugins
Operators could enable and disable plugins but not uninstall them, and the screen opened with no orientation. Alt+X removes owned installs from disk, drops path entries, and disables bundled plugins that cannot be deleted.
1 parent 5c065bb commit 3cfc083

13 files changed

Lines changed: 960 additions & 20 deletions

docs/IMPLEMENTATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ Mid-run queue/steer/interrupt state is a pure state machine in `src/tui/session-
423423
See `docs/PLUGINS.md` for the full design. Summary:
424424

425425
- Every installable plugin exports a `manifest` (`{ id, name, kind, description?, credentials? }`) with `kind` one of `web | command | workflow | tool | agent`.
426-
- Plugins are auto-discovered from `plugins/`, `<cwd>/.corbits/plugins/`, and `~/.corbits/plugins/`, plus any explicit file/dir paths in `settings.pluginPaths`. When `settings.discoverClaudePlugins` is true, plugins listed in `~/.claude/plugins/installed_plugins.json` are also loaded (install paths only; still require enable). The `/plugins` UI's "add by path" action (`a`) loads a plugin from anywhere on disk, validates its manifest, and persists the path. Discovery resolves relative imports to absolute first (`loadPluginEntry`). Project-local plugins require per-cwd trust (`~/.corbits/trust/<hash>.json`); path plugins use global path trust (`~/.corbits/trust/path-plugins.json`) so they keep working across project directories. Untrusted origins load metadata-only until granted.
426+
- Plugins are auto-discovered from `plugins/`, `<cwd>/.corbits/plugins/`, and `~/.corbits/plugins/`, plus any explicit file/dir paths in `settings.pluginPaths`. When `settings.discoverClaudePlugins` is true, plugins listed in `~/.claude/plugins/installed_plugins.json` are also loaded (install paths only; still require enable). The `/plugins` UI's "add by path" action (Alt+A) loads a plugin from anywhere on disk, validates its manifest, and persists the path. Alt+X removes by stamped origin: owned user/project installs (and path-origin plugins whose directory sits under those roots) delete the discovered directory under `~/.corbits/plugins` or `<cwd>/.corbits/plugins` after confirmation and write `settings.plugins[id].enabled: false` (the key is never dropped, so in-session command gating holds); path plugins revoke trust and drop unique `pluginPaths` entries (a shared marketplace root is kept); bundled repo plugins write `enabled: false` and stay listed; Claude marketplace installs write `enabled: false` and never touch `~/.claude`. Disk/path entries are still removed so the plugin is gone after restart. Removal takes effect in-session (commands re-gate live; tools stay until restart). Discovery resolves relative imports to absolute first (`loadPluginEntry`). Project-local plugins require per-cwd trust (`~/.corbits/trust/<hash>.json`); path plugins use global path trust (`~/.corbits/trust/path-plugins.json`) so they keep working across project directories. Untrusted origins load metadata-only until granted.
427427

428428
- **Explicit enable:** nothing is wired in until `settings.plugins[id].enabled` is true, except the repo-origin `defaultEnabled` case: when `origin === "repo"` AND `manifest.defaultEnabled` is true AND `settings.plugins[id]` is missing, the plugin auto-enables (this is how first-party `corbits-skills` is on out of the gate). An explicit `enabled: false` still disables. Marketplace (user / project / path / claude) `defaultEnabled` is ignored — those plugins stay opt-in. `command` → `registerCommandPlugins` registers slash commands (live on enable); `tool` → `resolveToolPlugins` instantiates `createToolPlugin(credentials)` and appends the tools to the posix toolset assembled in `src/tui/runner.ts` (via `tools.ts` helpers). `web` → `web_search`/`web_fetch` are now always-on core built-ins (`src/tools/web-search.ts`, `src/tools/web-fetch.ts`), not plugin-backed; a discovered `kind: "web"` plugin is retained for brand-display resolution only (`resolveWebProviderFromPlugins`/`webBrand` in `src/web/plugin-provider.ts`) and no longer supplies the tool implementation.
429429
- **Tool consent:** a `tool` plugin runs in-process, so it is wired in only when enabled AND `consented`. The `/plugins` UI prompts a one-time y/n consent recorded in `settings.plugins[id].consented`.

docs/PLUGINS.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,14 @@ plugin is still metadata-only.
4545
for a directory they registered, not for a snapshot of its bytes — and keeps
4646
grants stable across plugin updates. Anyone who can write to a trusted path
4747
can execute code; register paths you control.
48-
- **Revocable.** Press `r` on a trusted path plugin in `/plugins` to withdraw
49-
its grant; the plugin drops to metadata-only and is disabled. Revocation
50-
rewrites the store file in place — the file itself always survives.
48+
- **Revocable.** Alt+X on a path plugin in `/plugins` removes it from the
49+
session and drops its unique `pluginPaths` entry (and withdraws its grant);
50+
the plugin is gone after restart. Revocation rewrites the store file in
51+
place — the file itself always survives.
5152
- **Deleting the file re-seeds.** A missing (or invalid) `path-plugins.json`
5253
re-triggers the one-shot migration below, which re-grants every registered
5354
`pluginPaths` entry. Deleting the file is therefore **not** a revocation
54-
mechanism — use `/plugins``r`, or remove the entry from `pluginPaths`.
55+
mechanism — use `/plugins`Alt+X, or remove the entry from `pluginPaths`.
5556

5657
Only absolute paths are accepted: non-absolute store entries are dropped at
5758
load and grant calls reject them, so nothing ever resolves against an
@@ -206,11 +207,19 @@ web `collectWebPlugins` call. One place to read, one place to extend.
206207

207208
### One UI
208209

209-
`/plugins` lists **every** discovered plugin grouped by kind, and for each:
210+
`/plugins` lists **every** discovered plugin in a flat list, and for each:
210211
enable/disable, edit declared credentials (masked), verify (kind-specific:
211212
web = trial search; others = load/contract check), and the kind-selector toggle
212-
where relevant (web override). Add-by-path (`a`) already exists. Everything
213-
persists to global settings immediately.
213+
where relevant (web override). Add-by-path (Alt+A) and remove (Alt+X) persist
214+
immediately. Owned user/project installs (and path-origin plugins whose
215+
directory sits under those roots) confirm before deleting from disk;
216+
bundled Corbits plugins cannot be uninstalled (Alt+X writes `enabled: false`
217+
and they stay listed); Claude marketplace installs write `enabled: false`
218+
and never delete `~/.claude`. Every remove writes `enabled: false` rather
219+
than dropping `settings.plugins[id]` so in-session command gating holds;
220+
disk and unique `pluginPaths` entries are still removed so the plugin is
221+
gone after restart. Everything persists to global settings
222+
immediately.
214223

215224
## Implemented capabilities
216225

docs/TUI.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -361,6 +361,18 @@ or transcript. The shortcut list it used to open is still reachable, as
361361
via `openCommandSurface`'s `"help"` case, `command-surfaces.ts`); the `/` row
362362
in `SHELL_SHORTCUTS` documents that in place of a dedicated `?` row.
363363

364+
`/plugins` lists every discovered plugin in a flat list. The title line carries
365+
how-to hints (`Esc cancel · Enter toggle · Alt+A add path · Alt+X remove`,
366+
falling back to shorter forms as the terminal narrows). Enter toggles
367+
enablement. Alt+A adds a plugin by path. Alt+X removes a user, project, or
368+
path plugin (owned user/project installs, including a path-origin plugin
369+
whose directory sits under those roots, confirm before deleting from disk).
370+
Every remove writes `enabled: false` rather than dropping `settings.plugins[id]`
371+
so in-session command gating holds; disk and unique `pluginPaths` entries are
372+
still removed so the plugin is gone after restart. Bundled Corbits plugins
373+
cannot be uninstalled — Alt+X disables them instead and they stay listed.
374+
Claude marketplace installs write `enabled: false` and never delete `~/.claude`.
375+
364376
The idle landing paints two doors beside the mark, keys aligned so the
365377
descriptions share a column (`LANDING_HINTS` in `src/tui/landing.ts`): `/`
366378
for commands, and `/yolo` so Corbits Code does not have to ask for

src/plugins/admin.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import type { PluginConfig } from "../config/settings.js";
2+
import type { PluginOrigin } from "../trust/project-trust.js";
23
import type { PluginCredentialField, PluginKind } from "./manifest.js";
34

5+
export type { PluginOrigin };
6+
47
export interface PluginDescriptor {
58
id: string;
69
name: string;
@@ -17,6 +20,12 @@ export interface PluginDescriptor {
1720
needsTrust?: boolean;
1821
/** True for a trusted path-origin plugin, whose global grant can be withdrawn. */
1922
canRevokeTrust?: boolean;
23+
/** Discovery origin stamped at load — never inferred from id. */
24+
origin: PluginOrigin;
25+
/** Absolute path the plugin was discovered at. */
26+
pluginPath?: string;
27+
/** Provenance label (e.g. "claude"), distinct from origin. */
28+
source?: string;
2029
}
2130

2231
export interface VerifyResult {
@@ -44,4 +53,11 @@ export interface PluginsAdmin {
4453
addPath: (path: string) => Promise<AddPathResult>;
4554
// Withdraw the global trust grant for a path-origin plugin and disable it.
4655
revokeTrust: (id: string) => Promise<VerifyResult>;
56+
// Uninstall or disable by stamped origin. Every persist path writes
57+
// enabled:false (never deletes settings.plugins[id]) so in-session command
58+
// gating holds. Owned disk installs delete the directory; path also drops
59+
// unique pluginPaths; bundled stays listed; Claude-unowned never deletes
60+
// ~/.claude. Disk/path entries are still removed so the plugin is gone after
61+
// restart.
62+
remove: (id: string) => Promise<VerifyResult>;
4763
}

src/plugins/loader.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, test, expect } from "bun:test";
22
import { dedupePluginModules, type PluginModule } from "./loader.js";
33
import { isPluginModuleEnabled } from "./register.js";
4+
import { disableBundledPluginSettings } from "./uninstall.js";
45

56
function repoDefaultEnabled(id: string): PluginModule {
67
return {
@@ -81,6 +82,16 @@ describe("isPluginModuleEnabled with dedupe shadowing", () => {
8182
expect(isPluginModuleEnabled(survivor!, { scout: { enabled: false } })).toBe(false);
8283
});
8384

85+
test("disableBundledPluginSettings then isPluginModuleEnabled is false for shadowedRepoDefaultEnabled", () => {
86+
const repo = repoDefaultEnabled("scout");
87+
const user = userInstall("scout");
88+
const [survivor] = dedupePluginModules([repo, user]);
89+
expect(survivor!.shadowedRepoDefaultEnabled).toBe(true);
90+
const plugins = disableBundledPluginSettings({}, "scout");
91+
expect(plugins.scout?.enabled).toBe(false);
92+
expect(isPluginModuleEnabled(survivor!, plugins)).toBe(false);
93+
});
94+
8495
test("without dedupe shadowing, a plain user-origin module needs an explicit enable", () => {
8596
const user = userInstall("scout");
8697
expect(isPluginModuleEnabled(user, {})).toBe(false);

src/plugins/uninstall.test.ts

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { mkdir, mkdtemp, rm, stat, writeFile } from "node:fs/promises";
3+
import { tmpdir } from "node:os";
4+
import { join } from "node:path";
5+
6+
import type { PluginConfig } from "../config/settings.js";
7+
import {
8+
classifyPluginRemove,
9+
deleteOwnedPluginDir,
10+
disableBundledPluginSettings,
11+
isOwnedDiskInstall,
12+
nextPluginPathsAfterRemove,
13+
ownedDiskOriginRoot,
14+
projectPluginsRoot,
15+
userPluginsRoot,
16+
} from "./uninstall.js";
17+
18+
const temps: string[] = [];
19+
20+
afterEach(async () => {
21+
await Promise.all(temps.splice(0).map((d) => rm(d, { recursive: true, force: true })));
22+
});
23+
24+
async function tempDir(prefix: string): Promise<string> {
25+
const dir = await mkdtemp(join(tmpdir(), prefix));
26+
temps.push(dir);
27+
return dir;
28+
}
29+
30+
async function exists(path: string): Promise<boolean> {
31+
try {
32+
await stat(path);
33+
return true;
34+
} catch {
35+
return false;
36+
}
37+
}
38+
39+
describe("deleteOwnedPluginDir", () => {
40+
test("owned dir under fake user root is deleted", async () => {
41+
const home = await tempDir("uninstall-user-");
42+
const root = userPluginsRoot(home);
43+
const plugin = join(root, "exa");
44+
await mkdir(plugin, { recursive: true });
45+
await writeFile(join(plugin, "manifest.json"), "{}");
46+
const result = await deleteOwnedPluginDir({
47+
pluginPath: plugin,
48+
originRoot: root,
49+
claudeRoot: join(home, ".claude"),
50+
});
51+
expect(result).toEqual({ ok: true });
52+
expect(await exists(plugin)).toBe(false);
53+
expect(await exists(root)).toBe(true);
54+
});
55+
56+
test("path outside origin root is refused", async () => {
57+
const home = await tempDir("uninstall-outside-");
58+
const root = userPluginsRoot(home);
59+
await mkdir(root, { recursive: true });
60+
const outside = join(home, "other", "exa");
61+
await mkdir(outside, { recursive: true });
62+
const result = await deleteOwnedPluginDir({
63+
pluginPath: outside,
64+
originRoot: root,
65+
claudeRoot: join(home, ".claude"),
66+
});
67+
expect(result.ok).toBe(false);
68+
expect(await exists(outside)).toBe(true);
69+
});
70+
71+
test("path equal to plugins root is refused", async () => {
72+
const home = await tempDir("uninstall-root-");
73+
const root = userPluginsRoot(home);
74+
await mkdir(root, { recursive: true });
75+
const result = await deleteOwnedPluginDir({
76+
pluginPath: root,
77+
originRoot: root,
78+
claudeRoot: join(home, ".claude"),
79+
});
80+
expect(result.ok).toBe(false);
81+
expect(await exists(root)).toBe(true);
82+
});
83+
84+
test("path under .claude/plugins is refused", async () => {
85+
const home = await tempDir("uninstall-claude-");
86+
const root = userPluginsRoot(home);
87+
const claudeRoot = join(home, ".claude");
88+
const plugin = join(claudeRoot, "plugins", "market");
89+
await mkdir(root, { recursive: true });
90+
await mkdir(plugin, { recursive: true });
91+
const result = await deleteOwnedPluginDir({
92+
pluginPath: plugin,
93+
originRoot: root,
94+
claudeRoot,
95+
});
96+
expect(result.ok).toBe(false);
97+
if (!result.ok) expect(result.message).toContain("~/.claude");
98+
expect(await exists(plugin)).toBe(true);
99+
});
100+
101+
test("project: delete only inside <cwd>/.corbits/plugins/", async () => {
102+
const cwd = await tempDir("uninstall-project-");
103+
const root = projectPluginsRoot(cwd);
104+
const inside = join(root, "local");
105+
const outside = join(cwd, "not-plugins", "local");
106+
await mkdir(inside, { recursive: true });
107+
await mkdir(outside, { recursive: true });
108+
const claudeRoot = join(cwd, "home", ".claude");
109+
expect(
110+
await deleteOwnedPluginDir({ pluginPath: inside, originRoot: root, claudeRoot }),
111+
).toEqual({ ok: true });
112+
expect(await exists(inside)).toBe(false);
113+
const refused = await deleteOwnedPluginDir({
114+
pluginPath: outside,
115+
originRoot: root,
116+
claudeRoot,
117+
});
118+
expect(refused.ok).toBe(false);
119+
expect(await exists(outside)).toBe(true);
120+
});
121+
122+
test("already-missing path returns { ok: true }", async () => {
123+
const home = await tempDir("uninstall-missing-");
124+
const root = userPluginsRoot(home);
125+
await mkdir(root, { recursive: true });
126+
const missing = join(root, "gone");
127+
const result = await deleteOwnedPluginDir({
128+
pluginPath: missing,
129+
originRoot: root,
130+
claudeRoot: join(home, ".claude"),
131+
});
132+
expect(result).toEqual({ ok: true });
133+
expect(await exists(missing)).toBe(false);
134+
});
135+
});
136+
137+
describe("isOwnedDiskInstall", () => {
138+
test("project path is owned only for the passed cwd", () => {
139+
const cwd = "/tmp/project-a";
140+
const pluginPath = join(projectPluginsRoot(cwd), "local");
141+
expect(isOwnedDiskInstall({ origin: "project", pluginPath, home: "/tmp/home", cwd })).toBe(
142+
true,
143+
);
144+
expect(
145+
isOwnedDiskInstall({
146+
origin: "project",
147+
pluginPath,
148+
home: "/tmp/home",
149+
cwd: "/tmp/other",
150+
}),
151+
).toBe(false);
152+
});
153+
154+
test("~/.claude is never owned", () => {
155+
const home = "/tmp/home";
156+
const cwd = "/tmp/cwd";
157+
const pluginPath = join(home, ".claude", "plugins", "exa");
158+
expect(isOwnedDiskInstall({ origin: "user", pluginPath, home, cwd })).toBe(false);
159+
expect(isOwnedDiskInstall({ origin: "project", pluginPath, home, cwd })).toBe(false);
160+
});
161+
162+
test("path origin under userPluginsRoot is owned", () => {
163+
const home = "/tmp/home";
164+
const cwd = "/tmp/cwd";
165+
const pluginPath = join(userPluginsRoot(home), "exa");
166+
expect(isOwnedDiskInstall({ origin: "path", pluginPath, home, cwd })).toBe(true);
167+
expect(ownedDiskOriginRoot({ pluginPath, home, cwd })).toBe(userPluginsRoot(home));
168+
});
169+
170+
test("path origin under /tmp is not owned", () => {
171+
const home = "/tmp/home";
172+
const cwd = "/tmp/cwd";
173+
const pluginPath = "/tmp/elsewhere/my-plugin";
174+
expect(isOwnedDiskInstall({ origin: "path", pluginPath, home, cwd })).toBe(false);
175+
expect(ownedDiskOriginRoot({ pluginPath, home, cwd })).toBeUndefined();
176+
});
177+
});
178+
179+
describe("classifyPluginRemove", () => {
180+
test("origin and owned classify into one shared action", () => {
181+
expect(classifyPluginRemove({ origin: "repo", owned: false })).toBe("disable-bundled");
182+
expect(classifyPluginRemove({ origin: "repo", owned: true })).toBe("disable-bundled");
183+
expect(classifyPluginRemove({ origin: "user", owned: false })).toBe("disable-unowned-user");
184+
expect(classifyPluginRemove({ origin: "user", owned: true })).toBe("delete-owned");
185+
expect(classifyPluginRemove({ origin: "project", owned: true })).toBe("delete-owned");
186+
expect(classifyPluginRemove({ origin: "project", owned: false })).toBe("cannot");
187+
expect(classifyPluginRemove({ origin: "path", owned: false })).toBe("remove-path");
188+
expect(classifyPluginRemove({ origin: "path", owned: true })).toBe("delete-owned");
189+
});
190+
});
191+
192+
describe("plugin remove settings policy", () => {
193+
test("owned remove writes enabled:false and keeps plugins[id]", () => {
194+
const plugins: Record<string, PluginConfig> = {
195+
exa: { enabled: true, credentials: { apiKey: "k" } },
196+
other: { enabled: true },
197+
};
198+
const next = disableBundledPluginSettings(plugins, "exa");
199+
expect(next.exa?.enabled).toBe(false);
200+
expect(next.exa?.credentials).toEqual({ apiKey: "k" });
201+
expect(next.other).toEqual({ enabled: true });
202+
expect("exa" in next).toBe(true);
203+
});
204+
205+
test("repo writes enabled:false", () => {
206+
const plugins: Record<string, PluginConfig> = {
207+
"corbits-skills": { enabled: true },
208+
};
209+
expect(disableBundledPluginSettings(plugins, "corbits-skills")).toEqual({
210+
"corbits-skills": { enabled: false },
211+
});
212+
expect(disableBundledPluginSettings({}, "corbits-skills")).toEqual({
213+
"corbits-skills": { enabled: false },
214+
});
215+
});
216+
217+
test("disableBundledPluginSettings keeps plugins[id].enabled === false", () => {
218+
const plugins: Record<string, PluginConfig> = {
219+
exa: { enabled: true, credentials: { apiKey: "k" } },
220+
};
221+
const next = disableBundledPluginSettings(plugins, "exa");
222+
expect(next.exa?.enabled).toBe(false);
223+
expect(next.exa?.credentials).toEqual({ apiKey: "k" });
224+
expect("exa" in next).toBe(true);
225+
});
226+
227+
test("path drops unique pluginPaths and keeps a shared marketplace root", async () => {
228+
const unique = await nextPluginPathsAfterRemove({
229+
pluginPaths: ["/tmp/my-plugin"],
230+
pluginPath: "/tmp/my-plugin",
231+
cwd: "/tmp",
232+
otherLivePluginPaths: [],
233+
expandMembers: async (abs) => [abs],
234+
});
235+
expect(unique.pluginPaths).toEqual([]);
236+
expect(unique.keptSharedRoot).toBe(false);
237+
238+
const market = "/tmp/market";
239+
const a = join(market, "plugins", "a");
240+
const b = join(market, "plugins", "b");
241+
const shared = await nextPluginPathsAfterRemove({
242+
pluginPaths: [market, "/tmp/other"],
243+
pluginPath: a,
244+
cwd: "/tmp",
245+
otherLivePluginPaths: [b],
246+
expandMembers: async (abs) => (abs === market ? [a, b] : [abs]),
247+
});
248+
expect(shared.pluginPaths).toEqual([market, "/tmp/other"]);
249+
expect(shared.keptSharedRoot).toBe(true);
250+
});
251+
});

0 commit comments

Comments
 (0)