Skip to content

Commit 951101f

Browse files
committed
Record plugin uninstall and keep Alt+A on empty lists
The how-to header advertises Alt+A even when no plugin row is focused. Disable settings writes share one helper, and path-drop no longer duplicates between owned and unowned path removes.
1 parent 3cfc083 commit 951101f

7 files changed

Lines changed: 90 additions & 64 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ matching `## [X.Y.Z]` section (plus install instructions). Do not maintain
1111
parallel copies under `docs/` or `scripts/notes/`. At cut time: rename
1212
`## [Unreleased]` to `## [X.Y.Z] - YYYY-MM-DD`, then run the release script.
1313

14+
## [Unreleased]
15+
16+
### TUI
17+
18+
- `/plugins` uninstalls user, project, and path-installed plugins with Alt+X.
19+
Bundled plugins disable only and stay listed. Claude marketplace plugins
20+
disable in settings without deleting `~/.claude`. The screen opens with a
21+
how-to header.
22+
1423
## [0.3.14] - 2026-09-03
1524

1625
### Changed

src/plugins/loader.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +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";
4+
import { disablePluginSettings } from "./uninstall.js";
55

66
function repoDefaultEnabled(id: string): PluginModule {
77
return {
@@ -82,12 +82,12 @@ describe("isPluginModuleEnabled with dedupe shadowing", () => {
8282
expect(isPluginModuleEnabled(survivor!, { scout: { enabled: false } })).toBe(false);
8383
});
8484

85-
test("disableBundledPluginSettings then isPluginModuleEnabled is false for shadowedRepoDefaultEnabled", () => {
85+
test("disablePluginSettings then isPluginModuleEnabled is false for shadowedRepoDefaultEnabled", () => {
8686
const repo = repoDefaultEnabled("scout");
8787
const user = userInstall("scout");
8888
const [survivor] = dedupePluginModules([repo, user]);
8989
expect(survivor!.shadowedRepoDefaultEnabled).toBe(true);
90-
const plugins = disableBundledPluginSettings({}, "scout");
90+
const plugins = disablePluginSettings({}, "scout");
9191
expect(plugins.scout?.enabled).toBe(false);
9292
expect(isPluginModuleEnabled(survivor!, plugins)).toBe(false);
9393
});

src/plugins/uninstall.test.ts

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import type { PluginConfig } from "../config/settings.js";
77
import {
88
classifyPluginRemove,
99
deleteOwnedPluginDir,
10-
disableBundledPluginSettings,
10+
disablePluginSettings,
1111
isOwnedDiskInstall,
1212
nextPluginPathsAfterRemove,
1313
ownedDiskOriginRoot,
@@ -195,7 +195,7 @@ describe("plugin remove settings policy", () => {
195195
exa: { enabled: true, credentials: { apiKey: "k" } },
196196
other: { enabled: true },
197197
};
198-
const next = disableBundledPluginSettings(plugins, "exa");
198+
const next = disablePluginSettings(plugins, "exa");
199199
expect(next.exa?.enabled).toBe(false);
200200
expect(next.exa?.credentials).toEqual({ apiKey: "k" });
201201
expect(next.other).toEqual({ enabled: true });
@@ -206,24 +206,14 @@ describe("plugin remove settings policy", () => {
206206
const plugins: Record<string, PluginConfig> = {
207207
"corbits-skills": { enabled: true },
208208
};
209-
expect(disableBundledPluginSettings(plugins, "corbits-skills")).toEqual({
209+
expect(disablePluginSettings(plugins, "corbits-skills")).toEqual({
210210
"corbits-skills": { enabled: false },
211211
});
212-
expect(disableBundledPluginSettings({}, "corbits-skills")).toEqual({
212+
expect(disablePluginSettings({}, "corbits-skills")).toEqual({
213213
"corbits-skills": { enabled: false },
214214
});
215215
});
216216

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-
227217
test("path drops unique pluginPaths and keeps a shared marketplace root", async () => {
228218
const unique = await nextPluginPathsAfterRemove({
229219
pluginPaths: ["/tmp/my-plugin"],

src/plugins/uninstall.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ export async function deleteOwnedPluginDir(
126126
return { ok: true };
127127
}
128128

129-
export function disableBundledPluginSettings(
129+
export function disablePluginSettings(
130130
plugins: Record<string, PluginConfig>,
131131
id: string,
132132
): Record<string, PluginConfig> {

src/tui/command-surfaces.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -684,6 +684,45 @@ describe("plugins surface admin actions", () => {
684684
});
685685
});
686686

687+
test("empty plugin list Alt+A still opens add-path", async () => {
688+
await withShell(async (shell) => {
689+
const { deps, calls } = pluginActionDeps();
690+
const plugins = deps.plugins!;
691+
const empty: CommandSurfaceDeps = {
692+
...deps,
693+
plugins: { ...plugins, list: () => [] },
694+
};
695+
openCommandSurface(shell, "plugins", empty);
696+
expect(runOverlayAction(shell, altKey("a"))).toBe(true);
697+
for (const ch of "/tmp/my-plugin") runOverlayAction(shell, charKey(ch));
698+
acceptOverlaySelection(shell);
699+
await Promise.resolve();
700+
expect(calls.addPath).toEqual(["/tmp/my-plugin"]);
701+
});
702+
});
703+
704+
test("Alt+A on warnings/Close still opens add-path", async () => {
705+
await withShell(async (shell) => {
706+
const { deps, calls } = pluginActionDeps();
707+
const plugins = deps.plugins!;
708+
const withWarnings: CommandSurfaceDeps = {
709+
...deps,
710+
plugins: {
711+
...plugins,
712+
loadWarnings: () => [
713+
'agent a: skill "style" referenced but not found in skill search path',
714+
],
715+
},
716+
};
717+
openCommandSurface(shell, "plugins", withWarnings);
718+
expect(runOverlayAction(shell, altKey("a"))).toBe(true);
719+
for (const ch of "/tmp/from-warnings") runOverlayAction(shell, charKey(ch));
720+
acceptOverlaySelection(shell);
721+
await Promise.resolve();
722+
expect(calls.addPath).toEqual(["/tmp/from-warnings"]);
723+
});
724+
});
725+
687726
test("Alt+X on warnings/Close is a no-op", async () => {
688727
await withShell(async (shell) => {
689728
const { deps, calls } = pluginActionDeps();

src/tui/command-surfaces.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -946,10 +946,20 @@ export function openPluginsSurface(shell: AppShell, deps: CommandSurfaceDeps): v
946946
// branch returns before that handler is reached (see shell.ts's
947947
// top-level onKey), so exactly one of the two can ever fire.
948948
if (key.ctrl || !(key.meta || key.option)) return false;
949+
const name = typeof key.name === "string" ? key.name.toLowerCase() : "";
950+
// Surface-level chords — advertised in the how-to even when focus is on
951+
// Close, the empty-list row, or load warnings.
952+
if (name === "a") {
953+
openAddPathPane(shell, deps, plugins);
954+
return true;
955+
}
956+
if (name === "w") {
957+
openWebProviderChooser(shell, deps, plugins);
958+
return true;
959+
}
949960
if (id === PLUGIN_LOAD_WARNINGS_ID) return false;
950961
const target = byId.get(id);
951962
if (target === undefined) return false;
952-
const name = typeof key.name === "string" ? key.name.toLowerCase() : "";
953963
switch (name) {
954964
case "c":
955965
if (target.credentials.length === 0) return false;
@@ -976,19 +986,13 @@ export function openPluginsSurface(shell: AppShell, deps: CommandSurfaceDeps): v
976986
(err: unknown) => deps.notify(`Trust failed: ${errorText(err)}`),
977987
);
978988
return true;
979-
case "a":
980-
openAddPathPane(shell, deps, plugins);
981-
return true;
982989
case "x":
983990
if (pluginNeedsDiskConfirm(target, plugins)) {
984991
openRemoveConfirmPane(shell, deps, plugins, target);
985992
} else {
986993
applyPluginRemove(shell, deps, plugins, target);
987994
}
988995
return true;
989-
case "w":
990-
openWebProviderChooser(shell, deps, plugins);
991-
return true;
992996
default:
993997
return false;
994998
}

src/tui/runner.ts

Lines changed: 23 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ import {
9898
claudeHomeRoot,
9999
classifyPluginRemove,
100100
deleteOwnedPluginDir,
101-
disableBundledPluginSettings,
101+
disablePluginSettings,
102102
isOwnedDiskInstall,
103103
nextPluginPathsAfterRemove,
104104
ownedDiskOriginRoot,
@@ -1303,10 +1303,28 @@ export async function runTUI(initialConfig: Config): Promise<number> {
13031303
};
13041304

13051305
const disableConfig = (): void => {
1306-
livePluginConfig = disableBundledPluginSettings(livePluginConfig, id);
1306+
livePluginConfig = disablePluginSettings(livePluginConfig, id);
13071307
if (liveWebOverride === id) liveWebOverride = undefined;
13081308
};
13091309

1310+
const dropPathRegistration = async (path: string): Promise<string> => {
1311+
pathTrust = await revokePathPlugin(path);
1312+
const planned = await nextPluginPathsAfterRemove({
1313+
pluginPaths: livePluginPaths,
1314+
pluginPath: path,
1315+
cwd: config.cwd,
1316+
otherLivePluginPaths: livePluginModules.flatMap((m) =>
1317+
m.manifest?.id !== id && m.pluginPath !== undefined ? [m.pluginPath] : [],
1318+
),
1319+
expandMembers: (abs) => expandPluginPath(abs, { onSkip: () => {} }),
1320+
});
1321+
livePluginPaths.length = 0;
1322+
livePluginPaths.push(...planned.pluginPaths);
1323+
return planned.keptSharedRoot
1324+
? " Other plugins remain at that marketplace path; this one may return untrusted on restart."
1325+
: "";
1326+
};
1327+
13101328
const home = homedir();
13111329
const owned = isOwnedDiskInstall({
13121330
origin,
@@ -1358,21 +1376,7 @@ export async function runTUI(initialConfig: Config): Promise<number> {
13581376
if (!disk.ok) return disk;
13591377
let extra = "";
13601378
if (origin === "path") {
1361-
pathTrust = await revokePathPlugin(pluginPath);
1362-
const planned = await nextPluginPathsAfterRemove({
1363-
pluginPaths: livePluginPaths,
1364-
pluginPath,
1365-
cwd: config.cwd,
1366-
otherLivePluginPaths: livePluginModules.flatMap((m) =>
1367-
m.manifest?.id !== id && m.pluginPath !== undefined ? [m.pluginPath] : [],
1368-
),
1369-
expandMembers: (abs) => expandPluginPath(abs, { onSkip: () => {} }),
1370-
});
1371-
livePluginPaths.length = 0;
1372-
livePluginPaths.push(...planned.pluginPaths);
1373-
extra = planned.keptSharedRoot
1374-
? " Other plugins remain at that marketplace path; this one may return untrusted on restart."
1375-
: "";
1379+
extra = await dropPathRegistration(pluginPath);
13761380
}
13771381
spliceLive();
13781382
disableConfig();
@@ -1381,31 +1385,11 @@ export async function runTUI(initialConfig: Config): Promise<number> {
13811385
}
13821386

13831387
if (action === "remove-path") {
1384-
if (pluginPath !== undefined) {
1385-
pathTrust = await revokePathPlugin(pluginPath);
1386-
const planned = await nextPluginPathsAfterRemove({
1387-
pluginPaths: livePluginPaths,
1388-
pluginPath,
1389-
cwd: config.cwd,
1390-
otherLivePluginPaths: livePluginModules.flatMap((m) =>
1391-
m.manifest?.id !== id && m.pluginPath !== undefined ? [m.pluginPath] : [],
1392-
),
1393-
expandMembers: (abs) => expandPluginPath(abs, { onSkip: () => {} }),
1394-
});
1395-
livePluginPaths.length = 0;
1396-
livePluginPaths.push(...planned.pluginPaths);
1397-
spliceLive();
1398-
disableConfig();
1399-
await persistPluginSettings();
1400-
const extra = planned.keptSharedRoot
1401-
? " Other plugins remain at that marketplace path; this one may return untrusted on restart."
1402-
: "";
1403-
return { ok: true, message: withToolsNote(`Removed ${desc.name}.${extra}`) };
1404-
}
1388+
const extra = pluginPath !== undefined ? await dropPathRegistration(pluginPath) : "";
14051389
spliceLive();
14061390
disableConfig();
14071391
await persistPluginSettings();
1408-
return { ok: true, message: withToolsNote(`Removed ${desc.name}.`) };
1392+
return { ok: true, message: withToolsNote(`Removed ${desc.name}.${extra}`) };
14091393
}
14101394

14111395
return { ok: false, message: `Cannot remove ${desc.name}` };

0 commit comments

Comments
 (0)