magus-cli 7.7.0 → 7.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-parity.test.ts +29 -15
  3. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  4. package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
  5. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  6. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  7. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  8. package/src/__tests__/blind-780-review2.test.ts +1203 -0
  9. package/src/__tests__/cli-update-view.test.ts +3 -3
  10. package/src/__tests__/deprecated-remove.test.ts +827 -0
  11. package/src/__tests__/doctor.test.ts +159 -1
  12. package/src/__tests__/failure-popup-review2.test.tsx +920 -0
  13. package/src/__tests__/failure-popup.test.tsx +419 -0
  14. package/src/__tests__/final-fixes-780.test.tsx +724 -0
  15. package/src/__tests__/format-update-outcome.test.ts +6 -32
  16. package/src/__tests__/gitignore-profile.test.ts +1 -9
  17. package/src/__tests__/models-screen-state.test.ts +18 -40
  18. package/src/__tests__/plugin-resolution.test.ts +52 -13
  19. package/src/__tests__/plugin-row-text.test.ts +30 -37
  20. package/src/__tests__/profile-dependencies.test.ts +7 -0
  21. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  22. package/src/__tests__/profile-startup.test.ts +40 -12
  23. package/src/__tests__/stale-load-repair.test.ts +31 -23
  24. package/src/__tests__/update-apply.test.ts +13 -29
  25. package/src/__tests__/update-plan.test.ts +23 -17
  26. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  27. package/src/__tests__/version-unknown-state.test.ts +15 -17
  28. package/src/cli/doctor.ts +151 -30
  29. package/src/cli/install.ts +5 -6
  30. package/src/cli/update-view.ts +58 -15
  31. package/src/cli/update.ts +27 -5
  32. package/src/services/catalog-cache-store.ts +8 -6
  33. package/src/services/claude-cli.ts +106 -19
  34. package/src/services/claude-settings.ts +84 -36
  35. package/src/services/plugin-env-needs.ts +69 -0
  36. package/src/services/plugin-manager.ts +31 -19
  37. package/src/services/plugin-resolution.ts +34 -42
  38. package/src/services/profile-activate.ts +1 -2
  39. package/src/services/profile-startup.ts +18 -43
  40. package/src/services/profile-state.ts +72 -2
  41. package/src/services/resolver.ts +95 -1
  42. package/src/services/update-engine.ts +326 -31
  43. package/src/services/update-plan.ts +293 -33
  44. package/src/types/index.ts +7 -0
  45. package/src/ui/App.tsx +90 -146
  46. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  47. package/src/ui/adapters/failureReport.ts +137 -0
  48. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  49. package/src/ui/adapters/startupReports.ts +282 -0
  50. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  51. package/src/ui/components/modals/MessageModal.tsx +7 -12
  52. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  53. package/src/ui/components/modals/index.ts +1 -0
  54. package/src/ui/hooks/startupQueue.ts +56 -0
  55. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  56. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  57. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  58. package/src/ui/keymap.ts +14 -1
  59. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  60. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  61. package/src/ui/renderers/pluginRowText.ts +25 -25
  62. package/src/ui/renderers/pluginStatusText.ts +18 -1
  63. package/src/ui/screens/AliasScreen.tsx +38 -45
  64. package/src/ui/screens/CliToolsScreen.tsx +65 -54
  65. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  66. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  67. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  68. package/src/ui/screens/McpScreen.tsx +46 -19
  69. package/src/ui/screens/ModelsScreen.tsx +46 -86
  70. package/src/ui/screens/PluginsScreen.tsx +313 -221
  71. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  72. package/src/ui/screens/SkillsScreen.tsx +83 -80
  73. package/src/ui/screens/StylesScreen.tsx +211 -275
  74. package/src/ui/state/AppContext.tsx +66 -44
  75. package/src/ui/state/DimensionsContext.tsx +3 -22
  76. package/src/ui/state/reducer.ts +12 -100
  77. package/src/ui/state/types.ts +48 -69
  78. package/src/utils/user-path.ts +60 -0
  79. package/src/__tests__/notices-rules.test.ts +0 -476
  80. package/src/__tests__/notices.test.tsx +0 -604
  81. package/src/__tests__/styles-status-line.test.ts +0 -72
  82. package/src/__tests__/version-snapshot.test.ts +0 -212
  83. package/src/services/version-snapshot.ts +0 -189
@@ -0,0 +1,827 @@
1
+ /**
2
+ * Deprecated plugins are removed, and failed updates say why.
3
+ *
4
+ * A plugin whose marketplace was READ and does not list it is deprecated:
5
+ * Claude Code refuses to load it ("Plugin X not found in marketplace Y",
6
+ * measured on 2.1.284), so it is dead weight. The ONE plan both front ends share
7
+ * gives it the action `remove`; the ONE apply uninstalls it at every scope it
8
+ * is installed at here and drops it from the profile, so the next activation
9
+ * does not add it back.
10
+ *
11
+ * The profile half is proven against real files in a temp project: that the
12
+ * reconcile would absorb a project-scope uninstall into the ACTIVE profile on
13
+ * its own, that it would NOT reach a profile that is not active (so the edit
14
+ * cannot be left to it), and that the engine's edit and the reconcile agree.
15
+ *
16
+ * Two unlisted plugins are NOT removed, because removing them would lose
17
+ * something or would not stick: one another marketplace lists under the same
18
+ * name (it moved), and one the profile has only because another plugin
19
+ * requires it (the next resolve derives it again).
20
+ *
21
+ * A removal that fails reaches the Plugins screen's failure popup in the same
22
+ * words the CLI prints (failure-popup.test.tsx).
23
+ */
24
+
25
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
26
+ import { mkdtemp, rm } from "node:fs/promises";
27
+ import { tmpdir } from "node:os";
28
+ import { join } from "node:path";
29
+ import fs from "fs-extra";
30
+ import { setColorEnabled, stripAnsi } from "../cli/ansi.js";
31
+ import { pendingHeadline, planReport } from "../cli/update-view.js";
32
+ import {
33
+ type PluginScope,
34
+ uninstallPluginViaCli,
35
+ } from "../services/claude-cli.js";
36
+ import { readManifest, writeManifest } from "../services/manifest.js";
37
+ import type { PluginInfo } from "../services/plugin-manager.js";
38
+ import { reconcileActiveProfile } from "../services/profile-absorb.js";
39
+ import { activateProfile } from "../services/profile-activate.js";
40
+ import {
41
+ removePluginFromProfile,
42
+ withCliBookkeeping,
43
+ } from "../services/profile-state.js";
44
+ import {
45
+ type ApplyReporter,
46
+ type FinishCause,
47
+ type PluginApplyDeps,
48
+ type PluginRemoveDeps,
49
+ REAL_REMOVE_DEPS,
50
+ applyPlan,
51
+ applyPlugins,
52
+ describeRemoved,
53
+ planWorkItems,
54
+ pluginNeedsWork,
55
+ } from "../services/update-engine.js";
56
+ import {
57
+ type PluginUpdateItem,
58
+ type UpdatePlan,
59
+ classifyPluginRows,
60
+ countPluginStates,
61
+ planIsBehind,
62
+ planPluginUpdates,
63
+ } from "../services/update-plan.js";
64
+ import type { ProfileManifest } from "../types/index.js";
65
+ import { failureReasons } from "../ui/adapters/pluginsAdapter.js";
66
+ import { pluginStatusText } from "../ui/renderers/pluginStatusText.js";
67
+
68
+ // ── builders ────────────────────────────────────────────────────────────────
69
+
70
+ function info(over: Partial<PluginInfo> & { id: string }): PluginInfo {
71
+ const [name = over.id, marketplace = ""] = over.id.split("@");
72
+ return {
73
+ name,
74
+ version: null,
75
+ description: "",
76
+ marketplace,
77
+ marketplaceDisplay: marketplace,
78
+ ...over,
79
+ };
80
+ }
81
+
82
+ /** A plugin its marketplace still lists — proof that marketplace was read. */
83
+ const LISTED = info({
84
+ id: "dev@magus",
85
+ version: "9.0.0",
86
+ projectScope: { installed: true, version: "9.0.0" },
87
+ });
88
+
89
+ /** Installed here, and no longer listed by a marketplace we read. */
90
+ function orphan(id: string, over: Partial<PluginInfo> = {}): PluginInfo {
91
+ return info({
92
+ id,
93
+ version: "1.6.2",
94
+ installedVersion: "1.6.2",
95
+ isOrphaned: true,
96
+ projectScope: { installed: true, version: "1.6.2" },
97
+ ...over,
98
+ });
99
+ }
100
+
101
+ function plan(plugins: PluginUpdateItem[], profileId = "team"): UpdatePlan {
102
+ return {
103
+ profileId,
104
+ plugins,
105
+ bins: [],
106
+ skills: [],
107
+ binSpecs: new Map(),
108
+ };
109
+ }
110
+
111
+ // ── A. the plan ─────────────────────────────────────────────────────────────
112
+
113
+ describe("the plan: a deprecated plugin is work, not a shrug", () => {
114
+ test("installed and unlisted, marketplace read: remove / deprecated at every installed scope", () => {
115
+ const kanban = orphan("kanban@magus", {
116
+ userScope: { installed: true, version: "1.6.2" },
117
+ // A registry version at a scope that does NOT turn it on is not an install.
118
+ localScope: { installed: false, version: "1.6.2" },
119
+ });
120
+ const [item] = planPluginUpdates({}, [LISTED, kanban]).filter(
121
+ (i) => i.pluginId === "kanban@magus",
122
+ );
123
+
124
+ expect(item).toMatchObject({
125
+ action: "remove",
126
+ reason: "deprecated",
127
+ target: null,
128
+ scopes: ["user", "project"],
129
+ note: "no longer published",
130
+ });
131
+ expect(pluginNeedsWork(item as PluginUpdateItem)).toBe(true);
132
+ });
133
+
134
+ test("declared by the profile, installed nowhere: remove with no scopes, and still work", () => {
135
+ const [item] = planPluginUpdates({ "kanban@magus": "latest" }, [
136
+ LISTED,
137
+ ]).filter((i) => i.pluginId === "kanban@magus");
138
+
139
+ expect(item).toMatchObject({
140
+ action: "remove",
141
+ reason: "deprecated",
142
+ scopes: [],
143
+ });
144
+ // The profile edit is the whole of the work, so it is counted as work.
145
+ expect(pluginNeedsWork(item as PluginUpdateItem)).toBe(true);
146
+ });
147
+
148
+ test("installed with its files missing and unlisted: its scope is uninstalled", () => {
149
+ const gone = orphan("gone@magus", {
150
+ installedVersion: undefined,
151
+ projectScope: { installed: true },
152
+ });
153
+ const [item] = planPluginUpdates({}, [LISTED, gone]).filter(
154
+ (i) => i.pluginId === "gone@magus",
155
+ );
156
+ expect(item).toMatchObject({ action: "remove", scopes: ["project"] });
157
+ });
158
+
159
+ test("NEGATIVE CONTROL: a marketplace that was not read proves nothing — unknown, never applied", () => {
160
+ const ghost = orphan("ghost@unread-mp");
161
+ const [item] = planPluginUpdates({}, [LISTED, ghost]).filter(
162
+ (i) => i.pluginId === "ghost@unread-mp",
163
+ );
164
+ expect(item).toMatchObject({
165
+ action: "unknown",
166
+ reason: "marketplace-unread",
167
+ });
168
+ expect(pluginNeedsWork(item as PluginUpdateItem)).toBe(false);
169
+ });
170
+
171
+ test("counted as work by --dry-run, the meter, --check and the header", () => {
172
+ const items = planPluginUpdates({ "agentdev@magus": "latest" }, [
173
+ LISTED,
174
+ orphan("kanban@magus"),
175
+ orphan("ghost@unread-mp"),
176
+ ]);
177
+ const p = plan(items);
178
+
179
+ expect(planWorkItems(p).sort()).toEqual(["agentdev@magus", "kanban@magus"]);
180
+ expect(planIsBehind(p)).toBe(true);
181
+ expect(countPluginStates(items)).toMatchObject({ remove: 2, unknown: 1 });
182
+ // The TUI's rows come from the same plan.
183
+ expect(
184
+ classifyPluginRows([LISTED, orphan("kanban@magus")], {}).get(
185
+ "kanban@magus",
186
+ )?.action,
187
+ ).toBe("remove");
188
+ });
189
+
190
+ test("--check says 'Changes pending' when removals are the only work, 'Updates available' otherwise", () => {
191
+ const removals = plan(
192
+ planPluginUpdates({}, [LISTED, orphan("kanban@magus")]),
193
+ );
194
+ expect(pendingHeadline(removals)).toBe("Changes pending");
195
+ const behind = info({
196
+ id: "mnemex@magus",
197
+ version: "2.0.0",
198
+ projectScope: { installed: true, version: "1.0.0" },
199
+ });
200
+ const mixed = plan(
201
+ planPluginUpdates({}, [LISTED, orphan("kanban@magus"), behind]),
202
+ );
203
+ expect(pendingHeadline(mixed)).toBe("Updates available");
204
+ });
205
+ });
206
+
207
+ describe("M4: a plugin that moved to another marketplace is not removed", () => {
208
+ /** magus-marketing still publishes seo — the plugin moved, it was not retired. */
209
+ const MOVED_TO = info({
210
+ id: "seo@magus-marketing",
211
+ version: "1.1.0",
212
+ marketplaceDisplay: "Magus Marketing",
213
+ });
214
+
215
+ test("installed under the old marketplace: unknown, 'moved to <display name>', never applied", () => {
216
+ const [item] = planPluginUpdates({}, [
217
+ LISTED,
218
+ MOVED_TO,
219
+ orphan("seo@magus"),
220
+ ]).filter((i) => i.pluginId === "seo@magus");
221
+ expect(item).toMatchObject({
222
+ action: "unknown",
223
+ reason: "moved",
224
+ scopes: [],
225
+ note: "moved to Magus Marketing",
226
+ });
227
+ expect(pluginNeedsWork(item as PluginUpdateItem)).toBe(false);
228
+ // Kept on purpose, which is known: muted with the note, never the "?"
229
+ // of a check that could not tell.
230
+ expect(pluginStatusText(item as PluginUpdateItem)).toEqual({
231
+ suffix: " moved to Magus Marketing",
232
+ tone: "muted",
233
+ });
234
+ });
235
+
236
+ test("declared by the profile and installed nowhere: the same, so the pin is not dropped", () => {
237
+ const [item] = planPluginUpdates({ "seo@magus": "latest" }, [
238
+ LISTED,
239
+ MOVED_TO,
240
+ ]).filter((i) => i.pluginId === "seo@magus");
241
+ expect(item).toMatchObject({ action: "unknown", reason: "moved" });
242
+ });
243
+
244
+ test("NEGATIVE CONTROL: the same name in the SAME marketplace is not a move", () => {
245
+ const [item] = planPluginUpdates({}, [
246
+ LISTED,
247
+ info({ id: "seo-tools@magus", version: "1.0.0" }),
248
+ orphan("seo@magus"),
249
+ ]).filter((i) => i.pluginId === "seo@magus");
250
+ expect(item).toMatchObject({ action: "remove", reason: "deprecated" });
251
+ });
252
+
253
+ test("NEGATIVE CONTROL: an orphan of the other marketplace does not count as listed there", () => {
254
+ const [item] = planPluginUpdates({}, [
255
+ LISTED,
256
+ orphan("seo@magus-marketing"),
257
+ orphan("seo@magus"),
258
+ ]).filter((i) => i.pluginId === "seo@magus");
259
+ expect(item).toMatchObject({ action: "remove", reason: "deprecated" });
260
+ });
261
+ });
262
+
263
+ describe("M3: a deprecated plugin the profile has only as a dependency is not removed", () => {
264
+ test("derived pin: unknown, naming the plugin that still requires it", () => {
265
+ const items = planPluginUpdates(
266
+ { "dev@magus": "latest", "kanban@magus": "latest" },
267
+ [LISTED, orphan("kanban@magus")],
268
+ { "kanban@magus": "dev@magus" },
269
+ );
270
+ const item = items.find((i) => i.pluginId === "kanban@magus");
271
+ expect(item).toMatchObject({
272
+ action: "unknown",
273
+ reason: "still-required",
274
+ note: "no longer published, but dev still requires it",
275
+ });
276
+ expect(pluginNeedsWork(item as PluginUpdateItem)).toBe(false);
277
+ // Not work, so --check does not fail forever on it.
278
+ expect(planIsBehind(plan(items))).toBe(false);
279
+ });
280
+
281
+ test("installed nowhere: the same — dropping it from the profile would drop nothing", () => {
282
+ const [item] = planPluginUpdates({ "kanban@magus": "latest" }, [LISTED], {
283
+ "kanban@magus": "dev@magus",
284
+ }).filter((i) => i.pluginId === "kanban@magus");
285
+ expect(item).toMatchObject({ action: "unknown", reason: "still-required" });
286
+ });
287
+
288
+ test("the TUI's rows read the same map", () => {
289
+ expect(
290
+ classifyPluginRows(
291
+ [LISTED, orphan("kanban@magus")],
292
+ {},
293
+ {
294
+ "kanban@magus": "dev@magus",
295
+ },
296
+ ).get("kanban@magus")?.reason,
297
+ ).toBe("still-required");
298
+ });
299
+
300
+ test("NEGATIVE CONTROL: a pin the profile names itself is still removed", () => {
301
+ const [item] = planPluginUpdates(
302
+ { "kanban@magus": "latest" },
303
+ [LISTED, orphan("kanban@magus")],
304
+ { "other@magus": "dev@magus" },
305
+ ).filter((i) => i.pluginId === "kanban@magus");
306
+ expect(item).toMatchObject({ action: "remove", reason: "deprecated" });
307
+ });
308
+ });
309
+
310
+ // ── B. the apply ────────────────────────────────────────────────────────────
311
+
312
+ interface Calls {
313
+ uninstalled: Array<{ id: string; scope: PluginScope }>;
314
+ dropped: Array<{ id: string; profile: string | null | undefined }>;
315
+ }
316
+
317
+ /**
318
+ * A `remove` double over a tiny in-memory world: which scopes each plugin is
319
+ * installed at, and which plugins the profile lists.
320
+ */
321
+ function removeDeps(
322
+ installed: Record<string, PluginScope[]>,
323
+ listed: Set<string>,
324
+ opts: {
325
+ uninstallLeaves?: PluginScope[];
326
+ uninstallThrows?: string;
327
+ profileSticks?: boolean;
328
+ } = {},
329
+ ): { deps: PluginApplyDeps; calls: Calls } {
330
+ const calls: Calls = { uninstalled: [], dropped: [] };
331
+ const remove: PluginRemoveDeps = {
332
+ uninstall: async (id, scope) => {
333
+ calls.uninstalled.push({ id, scope });
334
+ if (opts.uninstallThrows) throw new Error(opts.uninstallThrows);
335
+ if (opts.uninstallLeaves?.includes(scope)) return;
336
+ installed[id] = (installed[id] ?? []).filter((s) => s !== scope);
337
+ },
338
+ isInstalled: async (id, scope) => (installed[id] ?? []).includes(scope),
339
+ dropFromProfile: async (id, _projectPath, profile) => {
340
+ calls.dropped.push({ id, profile });
341
+ if (!opts.profileSticks) listed.delete(id);
342
+ return !listed.has(id);
343
+ },
344
+ };
345
+ const unused = async () => {
346
+ throw new Error("a removal must not install, update or repair anything");
347
+ };
348
+ return {
349
+ calls,
350
+ deps: {
351
+ install: unused,
352
+ update: unused,
353
+ repair: unused,
354
+ readInstalled: unused,
355
+ saveInstalled: unused,
356
+ repairStaleLoad: unused,
357
+ remove,
358
+ },
359
+ };
360
+ }
361
+
362
+ function recorder(): {
363
+ reporter: ApplyReporter;
364
+ rows: Array<{ ok: boolean; name: string; detail: string; cause?: string }>;
365
+ } {
366
+ const rows: Array<{
367
+ ok: boolean;
368
+ name: string;
369
+ detail: string;
370
+ cause?: string;
371
+ }> = [];
372
+ return {
373
+ rows,
374
+ reporter: {
375
+ begin: () => {},
376
+ finish: (ok, name, detail, _ms, cause?: FinishCause) => {
377
+ rows.push({ ok, name, detail, ...(cause ? { cause } : {}) });
378
+ },
379
+ detach: (work) => work(),
380
+ },
381
+ };
382
+ }
383
+
384
+ function removeItem(pluginId: string, scopes: PluginScope[]): PluginUpdateItem {
385
+ return {
386
+ pluginId,
387
+ pinned: "latest",
388
+ installed: scopes.length > 0 ? "1.6.2" : null,
389
+ loaded: null,
390
+ available: null,
391
+ target: null,
392
+ action: "remove",
393
+ reason: "deprecated",
394
+ scopes,
395
+ beforeVersions: {},
396
+ note: "no longer published",
397
+ };
398
+ }
399
+
400
+ describe("applyPlugins: remove", () => {
401
+ test("uninstalls at every planned scope, drops it from the profile, reads both back", async () => {
402
+ const { deps, calls } = removeDeps(
403
+ { "kanban@magus": ["user", "project"] },
404
+ new Set(["kanban@magus"]),
405
+ );
406
+ const { reporter, rows } = recorder();
407
+
408
+ const result = await applyPlugins(
409
+ [removeItem("kanban@magus", ["user", "project"])],
410
+ "/tmp/project",
411
+ deps,
412
+ reporter,
413
+ { profile: "team" },
414
+ );
415
+
416
+ expect(calls.uninstalled).toEqual([
417
+ { id: "kanban@magus", scope: "user" },
418
+ { id: "kanban@magus", scope: "project" },
419
+ ]);
420
+ expect(calls.dropped).toEqual([{ id: "kanban@magus", profile: "team" }]);
421
+ expect(result).toMatchObject({
422
+ ok: 1,
423
+ failed: [],
424
+ removed: ["kanban@magus"],
425
+ });
426
+ expect(rows).toEqual([
427
+ {
428
+ ok: true,
429
+ name: "kanban@magus",
430
+ detail: "removed at USER, PROJECT · no longer published",
431
+ },
432
+ ]);
433
+ });
434
+
435
+ test("declared and installed nowhere: no uninstall, the profile edit alone", async () => {
436
+ const { deps, calls } = removeDeps({}, new Set(["agentdev@magus"]));
437
+ const { reporter, rows } = recorder();
438
+
439
+ const result = await applyPlugins(
440
+ [removeItem("agentdev@magus", [])],
441
+ "/tmp/project",
442
+ deps,
443
+ reporter,
444
+ );
445
+
446
+ expect(calls.uninstalled).toEqual([]);
447
+ // No profile named: the removal chooses the active one itself.
448
+ expect(calls.dropped).toEqual([
449
+ { id: "agentdev@magus", profile: undefined },
450
+ ]);
451
+ expect(result.removed).toEqual(["agentdev@magus"]);
452
+ expect(rows[0]?.detail).toBe(
453
+ "removed from the profile · no longer published",
454
+ );
455
+ });
456
+
457
+ test("still installed afterwards is a FAILURE naming the scope, never 'removed'", async () => {
458
+ const { deps } = removeDeps(
459
+ { "kanban@magus": ["user", "project"] },
460
+ new Set(),
461
+ { uninstallLeaves: ["user"] },
462
+ );
463
+ const { reporter, rows } = recorder();
464
+
465
+ const result = await applyPlugins(
466
+ [removeItem("kanban@magus", ["user", "project"])],
467
+ "/tmp/project",
468
+ deps,
469
+ reporter,
470
+ );
471
+
472
+ expect(result).toMatchObject({
473
+ ok: 0,
474
+ failed: ["kanban@magus"],
475
+ removed: [],
476
+ });
477
+ expect(rows).toEqual([
478
+ {
479
+ ok: false,
480
+ name: "kanban@magus",
481
+ detail: "still installed at USER scope",
482
+ },
483
+ ]);
484
+ });
485
+
486
+ test("installed at a scope the plan did not name is caught by the read-back", async () => {
487
+ // The read-back checks all three scopes, not just the planned ones.
488
+ const { deps } = removeDeps(
489
+ { "kanban@magus": ["project", "local"] },
490
+ new Set(),
491
+ );
492
+ const { reporter, rows } = recorder();
493
+ const result = await applyPlugins(
494
+ [removeItem("kanban@magus", ["project"])],
495
+ "/tmp/project",
496
+ deps,
497
+ reporter,
498
+ );
499
+ expect(result.failed).toEqual(["kanban@magus"]);
500
+ expect(rows[0]?.detail).toBe("still installed at LOCAL scope");
501
+ });
502
+
503
+ test("a profile that still lists it is a failure", async () => {
504
+ const { deps } = removeDeps(
505
+ { "kanban@magus": ["project"] },
506
+ new Set(["kanban@magus"]),
507
+ { profileSticks: true },
508
+ );
509
+ const { reporter, rows } = recorder();
510
+ const result = await applyPlugins(
511
+ [removeItem("kanban@magus", ["project"])],
512
+ "/tmp/project",
513
+ deps,
514
+ reporter,
515
+ );
516
+ expect(result.removed).toEqual([]);
517
+ expect(rows[0]).toEqual({
518
+ ok: false,
519
+ name: "kanban@magus",
520
+ detail: "the profile still lists it",
521
+ });
522
+ });
523
+
524
+ test("the Claude Code command's own error is an exception, kept as its message", async () => {
525
+ const { deps, calls } = removeDeps(
526
+ { "kanban@magus": ["project"] },
527
+ new Set(["kanban@magus"]),
528
+ { uninstallThrows: "claude CLI not found in PATH" },
529
+ );
530
+ const { reporter, rows } = recorder();
531
+ await applyPlugins(
532
+ [removeItem("kanban@magus", ["project"])],
533
+ "/tmp/project",
534
+ deps,
535
+ reporter,
536
+ );
537
+ // The profile half still ran: a pin nothing publishes is dropped anyway.
538
+ expect(calls.dropped).toHaveLength(1);
539
+ expect(rows[0]).toEqual({
540
+ ok: false,
541
+ name: "kanban@magus",
542
+ detail: "claude CLI not found in PATH",
543
+ cause: "exception",
544
+ });
545
+ });
546
+
547
+ test("M6: the real removal uninstalls through the Claude Code command only, and a failure throws", async () => {
548
+ // Not `uninstallPlugin`, whose fallback edits the settings files itself on
549
+ // failure — which also hid the command's error from the read-back.
550
+ expect(REAL_REMOVE_DEPS.uninstall).toBe(uninstallPluginViaCli);
551
+
552
+ // A command that cannot run: PATH holds no `claude`. The function must
553
+ // throw, and write nothing, in a scratch project.
554
+ const scratch = await mkdtemp(join(tmpdir(), "magus-m6-"));
555
+ const settings = join(scratch, ".claude", "settings.json");
556
+ await fs.outputJson(settings, { enabledPlugins: { "kanban@magus": true } });
557
+ const savedPath = process.env.PATH;
558
+ process.env.PATH = join(scratch, "no-bin");
559
+ try {
560
+ await expect(
561
+ uninstallPluginViaCli("kanban@magus", "project", scratch),
562
+ ).rejects.toThrow("claude CLI not found in PATH");
563
+ } finally {
564
+ process.env.PATH = savedPath;
565
+ }
566
+ expect(await fs.readJson(settings)).toEqual({
567
+ enabledPlugins: { "kanban@magus": true },
568
+ });
569
+ await rm(scratch, { recursive: true, force: true });
570
+ });
571
+
572
+ test("M6: an uninstall that throws reaches the engine as a failure with the command's words", async () => {
573
+ // The `uninstallError` branch: the double throws and leaves the plugin
574
+ // installed, the read-back finds it, and the command's own message is
575
+ // the reason — not a generic "still installed".
576
+ const { deps } = removeDeps(
577
+ { "kanban@magus": ["user"] },
578
+ new Set(["kanban@magus"]),
579
+ { uninstallThrows: "Plugin kanban@magus is not installed at scope user" },
580
+ );
581
+ const { reporter, rows } = recorder();
582
+ const result = await applyPlugins(
583
+ [removeItem("kanban@magus", ["user"])],
584
+ "/tmp/project",
585
+ deps,
586
+ reporter,
587
+ );
588
+ expect(result).toMatchObject({ failed: ["kanban@magus"], removed: [] });
589
+ expect(rows).toEqual([
590
+ {
591
+ ok: false,
592
+ name: "kanban@magus",
593
+ detail: "Plugin kanban@magus is not installed at scope user",
594
+ cause: "exception",
595
+ },
596
+ ]);
597
+ });
598
+
599
+ test("deps that cannot remove fail the item instead of reporting a removal", async () => {
600
+ const { deps } = removeDeps({}, new Set());
601
+ const { remove: _gone, ...without } = deps;
602
+ const result = await applyPlugins(
603
+ [removeItem("kanban@magus", ["project"])],
604
+ "/tmp/project",
605
+ without,
606
+ );
607
+ expect(result).toMatchObject({ ok: 0, failed: ["kanban@magus"] });
608
+ });
609
+
610
+ test("applyPlan drops it from the profile the plan was made from, not a guess", async () => {
611
+ const { deps, calls } = removeDeps(
612
+ { "kanban@magus": ["project"] },
613
+ new Set(["kanban@magus"]),
614
+ );
615
+ const outcome = await applyPlan(
616
+ plan([removeItem("kanban@magus", ["project"])], "lean"),
617
+ "/tmp/project",
618
+ { deps },
619
+ );
620
+ expect(calls.dropped).toEqual([{ id: "kanban@magus", profile: "lean" }]);
621
+ expect(outcome.plugins.removed).toEqual(["kanban@magus"]);
622
+ expect(outcome.changed).toBe(1);
623
+ });
624
+ });
625
+
626
+ // ── C. the profile half, on real files ──────────────────────────────────────
627
+
628
+ describe("dropping a deprecated plugin from the profile", () => {
629
+ let project: string;
630
+ let log: ReturnType<typeof spyOn>;
631
+ const settingsPath = () => join(project, ".claude", "settings.json");
632
+
633
+ const manifest = (): ProfileManifest => ({
634
+ version: 2,
635
+ profiles: {
636
+ team: {
637
+ name: "Team",
638
+ plugins: { "dev@magus": "latest", "kanban@magus": "latest" },
639
+ },
640
+ lean: { name: "Lean", plugins: { "kanban@magus": "latest" } },
641
+ },
642
+ });
643
+
644
+ async function start(m: ProfileManifest, profile = "team") {
645
+ await writeManifest(m, project);
646
+ await activateProfile(m, profile, project, { onDisplace: () => {} });
647
+ }
648
+
649
+ /**
650
+ * What `claude plugin uninstall kanban@magus --scope project` does to this
651
+ * project's settings. MEASURED on Claude Code 2.1.284 in a sandbox HOME: it
652
+ * exits 0 for a plugin its marketplace no longer lists, DELETES the key from
653
+ * `enabledPlugins` (it does not write `false`), and drops the registry row.
654
+ */
655
+ async function claudeUninstallAtProject(id: string) {
656
+ const json = await fs.readJson(settingsPath());
657
+ delete json.enabledPlugins[id];
658
+ await fs.writeFile(settingsPath(), JSON.stringify(json));
659
+ }
660
+
661
+ async function plugins(profile: string) {
662
+ return (await readManifest(project)).profiles[profile]?.plugins;
663
+ }
664
+
665
+ beforeEach(async () => {
666
+ project = await mkdtemp(join(tmpdir(), "deprecated-remove-"));
667
+ log = spyOn(console, "log").mockImplementation(() => {});
668
+ });
669
+ afterEach(async () => {
670
+ log.mockRestore();
671
+ await rm(project, { recursive: true, force: true });
672
+ });
673
+
674
+ test("the reconcile ALONE absorbs a project-scope uninstall into the active profile", async () => {
675
+ await start(manifest());
676
+ await claudeUninstallAtProject("kanban@magus");
677
+
678
+ const result = await reconcileActiveProfile(project);
679
+
680
+ expect(result.lines).toEqual(['Removed kanban@magus from profile "team"']);
681
+ expect(await plugins("team")).toEqual({ "dev@magus": "latest" });
682
+ });
683
+
684
+ test("...but it never reaches a profile that is not active, so the engine edits it", async () => {
685
+ await start(manifest());
686
+ await claudeUninstallAtProject("kanban@magus");
687
+ await reconcileActiveProfile(project);
688
+ // `magus update lean` plans lean. The reconcile left it declaring kanban.
689
+ expect(await plugins("lean")).toEqual({ "kanban@magus": "latest" });
690
+
691
+ expect(await removePluginFromProfile(project, "kanban@magus", "lean")).toBe(
692
+ true,
693
+ );
694
+ // An entry with nothing left drops `plugins` rather than writing `{}`.
695
+ expect(await plugins("lean")).toBeUndefined();
696
+ });
697
+
698
+ test("the engine's edit and the reconcile after it agree: removed, and not added back", async () => {
699
+ await start(manifest());
700
+
701
+ // What `magus update` runs: the apply inside the CLI bookkeeping, whose
702
+ // reconcile runs when it finishes.
703
+ const lines: string[] = [];
704
+ const unlisted = await withCliBookkeeping(
705
+ project,
706
+ async () => {
707
+ await claudeUninstallAtProject("kanban@magus");
708
+ return removePluginFromProfile(project, "kanban@magus", "team");
709
+ },
710
+ {
711
+ installable: ["dev@magus", "kanban@magus"],
712
+ report: (l) => lines.push(l),
713
+ },
714
+ );
715
+
716
+ expect(unlisted).toBe(true);
717
+ expect(await plugins("team")).toEqual({ "dev@magus": "latest" });
718
+ const live = await fs.readJson(settingsPath());
719
+ // `lean` still names it, so this profile renders it OFF, never on.
720
+ expect(live.enabledPlugins["kanban@magus"]).not.toBe(true);
721
+ expect(live.enabledPlugins["dev@magus"]).toBe(true);
722
+
723
+ // Settled: the next entry point finds nothing to absorb or regenerate.
724
+ expect((await reconcileActiveProfile(project)).lines).toEqual([]);
725
+ // And activating the profile again does not bring it back.
726
+ await activateProfile(await readManifest(project), "team", project, {
727
+ onDisplace: () => {},
728
+ });
729
+ expect(
730
+ (await fs.readJson(settingsPath())).enabledPlugins["kanban@magus"],
731
+ ).not.toBe(true);
732
+ });
733
+
734
+ test("undefined profile means the active one", async () => {
735
+ await start(manifest());
736
+ expect(await removePluginFromProfile(project, "kanban@magus")).toBe(true);
737
+ expect(await plugins("team")).toEqual({ "dev@magus": "latest" });
738
+ expect(await plugins("lean")).toEqual({ "kanban@magus": "latest" });
739
+ });
740
+
741
+ test("a plugin the profile inherits through `extends` is turned off with false", async () => {
742
+ await writeManifest(
743
+ {
744
+ version: 2,
745
+ profiles: { team: { name: "Team", extends: "must-have" } },
746
+ },
747
+ project,
748
+ );
749
+ expect(await removePluginFromProfile(project, "setup@magus", "team")).toBe(
750
+ true,
751
+ );
752
+ expect(await plugins("team")).toEqual({ "setup@magus": false });
753
+ });
754
+
755
+ test("a profile that never listed it is left untouched", async () => {
756
+ await writeManifest(manifest(), project);
757
+ const before = await fs.readFile(join(project, ".claude", "profiles.json"));
758
+ expect(await removePluginFromProfile(project, "seo@magus", "team")).toBe(
759
+ true,
760
+ );
761
+ expect(
762
+ await fs.readFile(join(project, ".claude", "profiles.json")),
763
+ ).toEqual(before);
764
+ });
765
+ });
766
+
767
+ // ── D. the words ────────────────────────────────────────────────────────────
768
+
769
+ describe("words: the same on both surfaces", () => {
770
+ afterEach(() => setColorEnabled(null));
771
+
772
+ test("TUI row: deprecated — press a to remove, in danger, no marketplace wording", () => {
773
+ expect(pluginStatusText(removeItem("kanban@magus", ["project"]))).toEqual({
774
+ suffix: " deprecated — press a to remove",
775
+ tone: "danger",
776
+ });
777
+ });
778
+
779
+ test("CLI: the Deprecated group with a DEPRECATED chip and a ✗ glyph, counted as work", () => {
780
+ setColorEnabled(false);
781
+ const out = planReport(
782
+ plan([
783
+ removeItem("code-analysis@magus", ["project"]),
784
+ removeItem("kanban@magus", ["project"]),
785
+ ]),
786
+ ).map(stripAnsi);
787
+
788
+ expect(out.some((l) => /^\s+Deprecated\s+2$/.test(l))).toBe(true);
789
+ const row = out.find((l) => l.includes("kanban@magus")) ?? "";
790
+ expect(row.trimStart().startsWith("✗")).toBe(true);
791
+ expect(row).toContain("DEPRECATED");
792
+ // The heading and chip carry the fact; the note is not printed again.
793
+ expect(row).not.toContain("no longer published");
794
+ });
795
+
796
+ test("the applied line", () => {
797
+ expect(describeRemoved(["code-analysis@magus", "kanban@magus"])).toBe(
798
+ "Removed code-analysis, kanban — no longer published",
799
+ );
800
+ expect(describeRemoved([])).toBeNull();
801
+ });
802
+ });
803
+
804
+ // ── E. a failed removal, in the popup ───────────────────────────────────────
805
+
806
+ describe("a failed removal reaches the popup", () => {
807
+ test("its reason is the engine's, and the headline says 'removed'", async () => {
808
+ const popup = failureReasons();
809
+ const { deps } = removeDeps({ "kanban@magus": ["user"] }, new Set(), {
810
+ uninstallLeaves: ["user"],
811
+ });
812
+ const items = [removeItem("kanban@magus", ["user"])];
813
+ const result = await applyPlugins(
814
+ items,
815
+ "/tmp/project",
816
+ deps,
817
+ popup.reporter,
818
+ );
819
+ expect(
820
+ popup.report(result.failed, new Map([["kanban@magus", "remove"]])),
821
+ ).toEqual({
822
+ headline: "kanban was not removed",
823
+ rows: [{ subject: "kanban", reason: "still installed at USER scope" }],
824
+ fix: null,
825
+ });
826
+ });
827
+ });