magus-cli 7.5.3 → 7.6.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 (68) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +1 -2
  3. package/src/__tests__/blackbox-parity.test.ts +16 -14
  4. package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
  5. package/src/__tests__/cli-apply-seams.test.ts +3 -3
  6. package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
  7. package/src/__tests__/content-drift.test.ts +0 -1
  8. package/src/__tests__/files-missing-scope.test.ts +235 -0
  9. package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
  10. package/src/__tests__/format-update-outcome.test.ts +367 -0
  11. package/src/__tests__/installed-version-scope.test.tsx +425 -0
  12. package/src/__tests__/key-focus-guard.test.tsx +260 -0
  13. package/src/__tests__/mate-availability.test.ts +11 -13
  14. package/src/__tests__/plugin-contents.test.ts +0 -1
  15. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  16. package/src/__tests__/plugin-row-text.test.ts +18 -21
  17. package/src/__tests__/profile-command.test.ts +2 -2
  18. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  19. package/src/__tests__/profile-name-on-save.test.ts +443 -0
  20. package/src/__tests__/profile-skills.test.ts +3 -3
  21. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  22. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  23. package/src/__tests__/profile-startup.test.ts +1 -1
  24. package/src/__tests__/profile-state.test.ts +27 -22
  25. package/src/__tests__/prompt-eof.test.ts +245 -0
  26. package/src/__tests__/scope-action.test.ts +35 -38
  27. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  28. package/src/__tests__/select-restore.test.ts +209 -0
  29. package/src/__tests__/stale-load-repair.test.ts +1 -2
  30. package/src/__tests__/update-apply.test.ts +18 -2
  31. package/src/__tests__/update-null-readback.test.ts +300 -0
  32. package/src/__tests__/update-plan-parity.test.ts +249 -0
  33. package/src/__tests__/update-plan.test.ts +66 -67
  34. package/src/__tests__/version-unknown-state.test.ts +38 -39
  35. package/src/cli/bootstrap.ts +271 -66
  36. package/src/cli/doctor.ts +4 -3
  37. package/src/cli/install.ts +83 -27
  38. package/src/cli/prompt.ts +19 -5
  39. package/src/cli/router.ts +1 -0
  40. package/src/cli/select.ts +168 -0
  41. package/src/cli/update-view.ts +120 -6
  42. package/src/cli/update.ts +6 -4
  43. package/src/services/claude-settings.ts +28 -0
  44. package/src/services/manifest.ts +31 -0
  45. package/src/services/mate-availability.ts +23 -29
  46. package/src/services/plugin-manager.ts +247 -105
  47. package/src/services/profile-absorb.ts +10 -7
  48. package/src/services/profile-activate.ts +41 -19
  49. package/src/services/profile-adopt.ts +9 -2
  50. package/src/services/profile-startup.ts +206 -5
  51. package/src/services/profile-state.ts +8 -7
  52. package/src/services/tracked-live-files.ts +24 -3
  53. package/src/services/update-engine.ts +28 -10
  54. package/src/services/update-plan.ts +78 -94
  55. package/src/ui/App.tsx +96 -56
  56. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  57. package/src/ui/components/modals/ModalContainer.tsx +3 -0
  58. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  59. package/src/ui/hooks/useGitignoreModal.ts +21 -3
  60. package/src/ui/hooks/useProfileStartupModal.ts +214 -15
  61. package/src/ui/hooks/useStaleLoadModal.ts +5 -1
  62. package/src/ui/input/KeyRouter.tsx +43 -2
  63. package/src/ui/keymap.ts +1 -1
  64. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  65. package/src/ui/renderers/pluginRowText.ts +6 -2
  66. package/src/ui/renderers/pluginStatusText.ts +4 -3
  67. package/src/ui/screens/PluginsScreen.tsx +28 -41
  68. package/src/cli/tui-prompt.tsx +0 -143
@@ -23,7 +23,6 @@ const plugin = (overrides: Partial<PluginInfo> = {}): PluginInfo => ({
23
23
  description: "",
24
24
  marketplace: "magus",
25
25
  marketplaceDisplay: "Magus",
26
- enabled: true,
27
26
  ...overrides,
28
27
  });
29
28
 
@@ -43,9 +42,9 @@ describe("areMatesAvailable", () => {
43
42
  test("true when it is installed at ANY scope", async () => {
44
43
  // Any scope, because a mate is served by whatever Claude Code has loaded when the
45
44
  // agent spawns — the union of the three. A user-scope install serves a project that
46
- // enables nothing of its own.
45
+ // installs nothing of its own.
47
46
  for (const scope of ["userScope", "projectScope", "localScope"] as const) {
48
- const one = plugin({ [scope]: { enabled: true, version: "1.0.0" } });
47
+ const one = plugin({ [scope]: { installed: true, version: "1.0.0" } });
49
48
  expect({
50
49
  scope,
51
50
  available: await areMatesAvailable(undefined, lister([one])),
@@ -54,27 +53,26 @@ describe("areMatesAvailable", () => {
54
53
  });
55
54
 
56
55
  /**
57
- * The enabled-but-not-installed state is NOT installed.
56
+ * Files missing does not serve a mate.
58
57
  *
59
- * `enabledPlugins` is a flag only magus and the user maintain; the version comes from
60
- * the registry, which is what Claude Code actually loaded. Flag set with no version is the
61
- * broken state the plugin list already renders as "not installed" — and a mate row drawn
62
- * on the strength of it would promise routing through a plugin that never loaded.
58
+ * Installed with no version is the broken state the plugin list renders as "files
59
+ * missing": Claude Code wants to load the plugin and has nothing to load — and a mate row
60
+ * drawn on the strength of it would promise routing through a plugin that never loaded.
63
61
  */
64
- test("false when it is enabled but never actually installed", async () => {
65
- const flagged = plugin({ userScope: { enabled: true } });
62
+ test("false when it is installed but its files are missing", async () => {
63
+ const flagged = plugin({ userScope: { installed: true } });
66
64
  expect(await areMatesAvailable(undefined, lister([flagged]))).toBe(false);
67
65
  });
68
66
 
69
- test("false when it is installed but disabled", async () => {
67
+ test("false when only a registry row exists and it is not installed", async () => {
70
68
  const off = plugin({
71
- userScope: { enabled: false, version: "1.0.0" },
69
+ userScope: { installed: false, version: "1.0.0" },
72
70
  });
73
71
  expect(await areMatesAvailable(undefined, lister([off]))).toBe(false);
74
72
  });
75
73
 
76
74
  test('a version of "0.0.0" still counts — that is how some plugins record themselves', async () => {
77
- const zero = plugin({ userScope: { enabled: true, version: "0.0.0" } });
75
+ const zero = plugin({ userScope: { installed: true, version: "0.0.0" } });
78
76
  expect(await areMatesAvailable(undefined, lister([zero]))).toBe(true);
79
77
  });
80
78
 
@@ -200,7 +200,6 @@ function makePlugin(overrides: Partial<PluginInfo> = {}): PluginInfo {
200
200
  description: "Matt Pocock's agent skills",
201
201
  marketplace: "mattpocock",
202
202
  marketplaceDisplay: "Matt Pocock",
203
- enabled: false,
204
203
  source: "./",
205
204
  ...overrides,
206
205
  };
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Spec 7.5.4 v3 — REQ-4: at each scope a plugin is installed or not, and the list row
3
+ * and the detail pane say the same thing.
4
+ *
5
+ * Written blind: from the spec and the exported signatures and interfaces only. Both
6
+ * surfaces are rendered into a character frame and read the way a user reads them.
7
+ *
8
+ * The item's `status` is `null` throughout: `PluginUpdateItem` is not part of the
9
+ * contract this test was given, and the spec derives every scope's display from its
10
+ * `ScopeStatus` alone.
11
+ */
12
+ import { describe, expect, test } from "bun:test";
13
+ import { testRender } from "@opentui/react/test-utils";
14
+ import { type ReactNode, act } from "react";
15
+ import type { PluginInfo, ScopeStatus } from "../services/plugin-manager.js";
16
+ import type { PluginPluginItem } from "../ui/adapters/pluginsAdapter.js";
17
+ import {
18
+ renderPluginDetail,
19
+ renderPluginRow,
20
+ } from "../ui/renderers/pluginRenderers.js";
21
+
22
+ /** Newer than any scope's version, so an "update available" claim would be plausible. */
23
+ const CATALOG_VERSION = "0.9.0";
24
+
25
+ interface Scopes {
26
+ user?: ScopeStatus;
27
+ project?: ScopeStatus;
28
+ local?: ScopeStatus;
29
+ }
30
+
31
+ const SCOPE_NAMES = ["User", "Project", "Local"] as const;
32
+ type ScopeName = (typeof SCOPE_NAMES)[number];
33
+ const CHIP: Record<ScopeName, string> = { User: "u", Project: "p", Local: "l" };
34
+
35
+ function pluginItem(scopes: Scopes): PluginPluginItem {
36
+ // The version that loads here: undefined unless some scope has a real install.
37
+ const loaded = [scopes.user, scopes.project, scopes.local].find(
38
+ (s) => s?.installed === true && s.version !== undefined,
39
+ );
40
+ const plugin: PluginInfo = {
41
+ id: "scopeprobe@magus",
42
+ name: "scopeprobe",
43
+ version: CATALOG_VERSION,
44
+ description: "Probe plugin for scope rendering.",
45
+ marketplace: "magus",
46
+ marketplaceDisplay: "Magus",
47
+ installedVersion: loaded?.version,
48
+ userScope: scopes.user,
49
+ projectScope: scopes.project,
50
+ localScope: scopes.local,
51
+ };
52
+ return {
53
+ id: `plugin:${plugin.id}`,
54
+ kind: "plugin",
55
+ label: plugin.name,
56
+ plugin,
57
+ isExpandable: false,
58
+ isExpanded: false,
59
+ status: null,
60
+ };
61
+ }
62
+
63
+ async function frameOf(
64
+ node: ReactNode,
65
+ width: number,
66
+ height: number,
67
+ ): Promise<string[]> {
68
+ const { captureCharFrame, renderOnce, renderer } = await testRender(
69
+ <box flexDirection="column">{node}</box>,
70
+ { width, height },
71
+ );
72
+ await act(async () => {
73
+ await renderOnce();
74
+ await renderOnce();
75
+ });
76
+ const lines = captureCharFrame().split("\n");
77
+ await act(async () => renderer.destroy());
78
+ return lines;
79
+ }
80
+
81
+ function detailFrame(item: PluginPluginItem): Promise<string[]> {
82
+ return frameOf(renderPluginDetail(item, new Set<string>()), 120, 80);
83
+ }
84
+
85
+ function rowFrame(
86
+ item: PluginPluginItem,
87
+ isSelected: boolean,
88
+ ): Promise<string[]> {
89
+ return frameOf(renderPluginRow(item, 0, isSelected), 120, 3);
90
+ }
91
+
92
+ /** The `User` / `Project` / `Local` line under `Scopes:`. */
93
+ function scopeLine(frame: string[], name: ScopeName): string {
94
+ const header = frame.findIndex((l) => l.includes("Scopes:"));
95
+ if (header === -1) {
96
+ throw new Error(
97
+ `no "Scopes:" section in the detail pane:\n${frame.join("\n")}`,
98
+ );
99
+ }
100
+ const pattern = new RegExp(`\\b${name}\\b`);
101
+ const line = frame.slice(header + 1).find((l) => pattern.test(l));
102
+ if (line === undefined) {
103
+ throw new Error(`no ${name} line under "Scopes:":\n${frame.join("\n")}`);
104
+ }
105
+ return line;
106
+ }
107
+
108
+ function dotOf(line: string): string | undefined {
109
+ return /[●○]/.exec(line)?.[0];
110
+ }
111
+
112
+ const V = "0.8.1";
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Detail pane, one scope at a time (REQ-4a..d on the Project line)
116
+ // ---------------------------------------------------------------------------
117
+
118
+ describe("REQ-4 the detail pane's Project line", () => {
119
+ test("REQ-4a installed with a version: ● and v0.8.1, not files missing", async () => {
120
+ const frame = await detailFrame(
121
+ pluginItem({ project: { installed: true, version: V } }),
122
+ );
123
+
124
+ const line = scopeLine(frame, "Project");
125
+ expect(dotOf(line)).toBe("●");
126
+ expect(line).toContain(`v${V}`);
127
+ expect(line).not.toContain("files missing");
128
+ });
129
+
130
+ test("REQ-4b not installed but a version recorded: ○, no 0.8.1, and never the word installed", async () => {
131
+ const frame = await detailFrame(
132
+ pluginItem({ project: { installed: false, version: V } }),
133
+ );
134
+
135
+ const line = scopeLine(frame, "Project");
136
+ expect(dotOf(line)).toBe("○");
137
+ expect(line).not.toContain(V);
138
+ expect(line).not.toMatch(/\binstalled\b/i);
139
+ });
140
+
141
+ test("REQ-4c installed with no version: ● and files missing", async () => {
142
+ const frame = await detailFrame(
143
+ pluginItem({ project: { installed: true } }),
144
+ );
145
+
146
+ const line = scopeLine(frame, "Project");
147
+ expect(dotOf(line)).toBe("●");
148
+ expect(line).toContain("files missing");
149
+ });
150
+
151
+ test("REQ-4d { installed: false } with no version: ○ and none of version, files missing, installed", async () => {
152
+ const frame = await detailFrame(
153
+ pluginItem({ project: { installed: false } }),
154
+ );
155
+
156
+ const line = scopeLine(frame, "Project");
157
+ expect(dotOf(line)).toBe("○");
158
+ expect(line).not.toContain(V);
159
+ expect(line).not.toContain("files missing");
160
+ expect(line).not.toMatch(/\binstalled\b/i);
161
+ });
162
+
163
+ test("REQ-4d an undefined scope: ○ and none of version, files missing, installed", async () => {
164
+ const frame = await detailFrame(pluginItem({}));
165
+
166
+ const line = scopeLine(frame, "Project");
167
+ expect(dotOf(line)).toBe("○");
168
+ expect(line).not.toContain(V);
169
+ expect(line).not.toContain("files missing");
170
+ expect(line).not.toMatch(/\binstalled\b/i);
171
+ });
172
+ });
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Structure and vocabulary of every scope line (REQ-4 layout, REQ-4e)
176
+ // ---------------------------------------------------------------------------
177
+
178
+ const STATES: Array<[label: string, status: ScopeStatus | undefined]> = [
179
+ ["installed with version", { installed: true, version: V }],
180
+ ["not installed, version recorded", { installed: false, version: V }],
181
+ ["installed, no version", { installed: true }],
182
+ ["not installed, no version", { installed: false }],
183
+ ["undefined", undefined],
184
+ ];
185
+
186
+ describe("REQ-4 every scope line is chip, dot, name — and never says enabled", () => {
187
+ for (const [label, status] of STATES) {
188
+ test(`REQ-4 / REQ-4e scope lines for "${label}" at every scope`, async () => {
189
+ for (const scope of SCOPE_NAMES) {
190
+ const scopes: Scopes = {};
191
+ if (scope === "User") scopes.user = status;
192
+ if (scope === "Project") scopes.project = status;
193
+ if (scope === "Local") scopes.local = status;
194
+
195
+ const frame = await detailFrame(pluginItem(scopes));
196
+
197
+ for (const name of SCOPE_NAMES) {
198
+ const line = scopeLine(frame, name);
199
+ const layout = new RegExp(
200
+ `(?:^|[^A-Za-z0-9])${CHIP[name]}[^A-Za-z0-9●○]*[●○]\\s*${name}\\b`,
201
+ );
202
+ expect({ scope, name, line, layout: layout.test(line) }).toEqual({
203
+ scope,
204
+ name,
205
+ line,
206
+ layout: true,
207
+ });
208
+ expect({ scope, name, line, enabled: /enabled/i.test(line) }).toEqual(
209
+ { scope, name, line, enabled: false },
210
+ );
211
+ }
212
+ }
213
+ });
214
+ }
215
+ });
216
+
217
+ // ---------------------------------------------------------------------------
218
+ // The list row agrees with the detail pane (REQ-4f)
219
+ // ---------------------------------------------------------------------------
220
+
221
+ describe("REQ-4f the list row's squares equal the detail pane's dots", () => {
222
+ const combos: Array<[boolean, boolean, boolean]> = [];
223
+ for (const u of [false, true]) {
224
+ for (const p of [false, true]) {
225
+ for (const l of [false, true]) combos.push([u, p, l]);
226
+ }
227
+ }
228
+
229
+ for (const isSelected of [false, true]) {
230
+ for (const [u, p, l] of combos) {
231
+ const name = `user=${u} project=${p} local=${l}`;
232
+ test(`REQ-4f ${name}, isSelected=${isSelected}`, async () => {
233
+ const item = pluginItem({
234
+ user: { installed: u, version: V },
235
+ project: { installed: p, version: V },
236
+ local: { installed: l, version: V },
237
+ });
238
+ const expectedSquares = [u, p, l].map((on) => (on ? "■" : "□"));
239
+ const expectedDots = [u, p, l].map((on) => (on ? "●" : "○"));
240
+
241
+ const row = await rowFrame(item, isSelected);
242
+ const detail = await detailFrame(item);
243
+
244
+ const squares = row.join("\n").match(/[■□]/g) ?? [];
245
+ const dots = SCOPE_NAMES.map((scope) =>
246
+ dotOf(scopeLine(detail, scope)),
247
+ );
248
+ expect(squares).toEqual(expectedSquares);
249
+ expect(dots).toEqual(expectedDots);
250
+ expect(squares.map((s) => (s === "■" ? "●" : "○"))).toEqual(dots);
251
+ });
252
+ }
253
+ }
254
+ });
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // No phantom version claim (REQ-4g)
258
+ // ---------------------------------------------------------------------------
259
+
260
+ describe("REQ-4g a recorded-but-not-installed version is not presented as installed", () => {
261
+ test("REQ-4g projectScope { installed: false, version: 0.8.1 } alone: no UPDATE AVAILABLE, no 0.8.1 installed", async () => {
262
+ const item = pluginItem({ project: { installed: false, version: V } });
263
+ expect(item.plugin.installedVersion).toBeUndefined();
264
+
265
+ const frame = (await detailFrame(item)).join("\n");
266
+
267
+ expect(frame).not.toMatch(/update available/i);
268
+ expect(frame).not.toContain(`${V} installed`);
269
+ });
270
+ });
@@ -47,7 +47,6 @@ function info(over: Partial<PluginInfo> & { id: string }): PluginInfo {
47
47
  description: "",
48
48
  marketplace,
49
49
  marketplaceDisplay: "",
50
- enabled: true,
51
50
  author: { name: "Anthropic" },
52
51
  ...over,
53
52
  } as PluginInfo;
@@ -58,7 +57,7 @@ const CODE_REVIEW = info({
58
57
  id: "code-review@claude-plugins-official",
59
58
  version: null, // the official catalog publishes no version for it
60
59
  installedVersion: "3ea32df27be7",
61
- projectScope: { enabled: true, version: "3ea32df27be7" },
60
+ projectScope: { installed: true, version: "3ea32df27be7" },
62
61
  });
63
62
 
64
63
  /** A registry "0.0.0" — the value the old row hid every status behind. */
@@ -66,7 +65,7 @@ const ZERO = info({
66
65
  id: "zero-version@claude-plugins-official",
67
66
  version: null,
68
67
  installedVersion: "0.0.0",
69
- projectScope: { enabled: true, version: "0.0.0" },
68
+ projectScope: { installed: true, version: "0.0.0" },
70
69
  });
71
70
 
72
71
  /** Other listed ids, so each marketplace demonstrably was read. */
@@ -147,7 +146,7 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
147
146
  version: "1.8.0",
148
147
  installedVersion: "1.8.0",
149
148
  isOrphaned: true,
150
- projectScope: { enabled: true, version: "1.8.0" },
149
+ projectScope: { installed: true, version: "1.8.0" },
151
150
  });
152
151
  const row = rows([seo]).get(seo.id) as PluginPluginItem;
153
152
  expect(cliNote([seo], seo.id)).toBe(
@@ -165,7 +164,7 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
165
164
  version: "2.0.0",
166
165
  installedVersion: "2.0.0",
167
166
  isOrphaned: true,
168
- projectScope: { enabled: true, version: "2.0.0" },
167
+ projectScope: { installed: true, version: "2.0.0" },
169
168
  });
170
169
  const row = rows([gone]).get(gone.id) as PluginPluginItem;
171
170
  expect(pluginRowText(row).text).toBe(
@@ -181,7 +180,7 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
181
180
  id: "seo@magus",
182
181
  installedVersion: "1.8.0",
183
182
  isOrphaned: true,
184
- projectScope: { enabled: true, version: "1.8.0" },
183
+ projectScope: { installed: true, version: "1.8.0" },
185
184
  });
186
185
  const { text } = pluginRowText(rows([seo]).get(seo.id) as PluginPluginItem);
187
186
  expect(text).not.toContain("deprecated");
@@ -195,7 +194,7 @@ describe("pluginRowText — history badges combine with the state", () => {
195
194
  id: "terminal@magus",
196
195
  version: "3.3.1",
197
196
  installedVersion: "3.0.1",
198
- projectScope: { enabled: true, version: "3.0.1" },
197
+ projectScope: { installed: true, version: "3.0.1" },
199
198
  author: { name: "MadAppGang" },
200
199
  ...over,
201
200
  });
@@ -220,7 +219,7 @@ describe("pluginRowText — history badges combine with the state", () => {
220
219
  recentlyInstalled: true,
221
220
  version: "0.0.0",
222
221
  installedVersion: "0.0.0",
223
- projectScope: { enabled: true, version: "0.0.0" },
222
+ projectScope: { installed: true, version: "0.0.0" },
224
223
  });
225
224
  const row = rows([p]).get(p.id) as PluginPluginItem;
226
225
  expect(pluginRowText(row).text).toBe(" new");
@@ -263,7 +262,7 @@ describe("pluginRowText — rows with no state claim none", () => {
263
262
  });
264
263
  });
265
264
 
266
- describe("pluginStatusText — absent is not enabled-not-installed (LOW 6)", () => {
265
+ describe("pluginStatusText — absent is not files-missing (LOW 6)", () => {
267
266
  const status = (over: Partial<PluginUpdateItem>): PluginUpdateItem => ({
268
267
  pluginId: "x@magus",
269
268
  pinned: "latest",
@@ -285,32 +284,30 @@ describe("pluginStatusText — absent is not enabled-not-installed (LOW 6)", ()
285
284
  });
286
285
  });
287
286
 
288
- test("enabled-not-installed shows its note and never claims a declaration", () => {
287
+ test("files-missing shows its note and never claims a declaration", () => {
289
288
  const got = pluginStatusText(
290
289
  status({
291
- reason: "enabled-not-installed",
292
- note: "enabled in settings, but no files on disk",
290
+ reason: "files-missing",
291
+ note: "files missing",
293
292
  }),
294
293
  );
295
294
  expect(got).toEqual({
296
- suffix: " not installed (enabled in settings, but no files on disk)",
295
+ suffix: " files missing",
297
296
  tone: "danger",
298
297
  });
299
298
  expect(got.suffix).not.toContain("profile");
300
299
  });
301
300
 
302
- test("an undeclared enabled-but-not-installed ROW gets the enabled text", () => {
301
+ test("an undeclared files-missing ROW gets the files-missing text", () => {
303
302
  const broken = info({
304
303
  id: "kanban@magus",
305
304
  version: "1.6.2",
306
305
  author: { name: "MadAppGang" },
307
- localScope: { enabled: true },
306
+ localScope: { installed: true },
308
307
  });
309
308
  const row = rows([broken]).get(broken.id) as PluginPluginItem;
310
- expect(row.status?.reason).toBe("enabled-not-installed");
311
- expect(pluginRowText(row).text).toBe(
312
- " not installed (enabled in settings, but no files on disk)",
313
- );
309
+ expect(row.status?.reason).toBe("files-missing");
310
+ expect(pluginRowText(row).text).toBe(" files missing");
314
311
  });
315
312
 
316
313
  test("null status says nothing", () => {
@@ -357,7 +354,7 @@ describe("pluginRowText — a broken plugin says so, and names the key", () => {
357
354
  version: "0.5.0",
358
355
  installedVersion: "0.5.0",
359
356
  loadedVersion: "0.3.0",
360
- projectScope: { enabled: true, version: "0.5.0" },
357
+ projectScope: { installed: true, version: "0.5.0" },
361
358
  author: { name: "MadAppGang" },
362
359
  });
363
360
 
@@ -399,7 +396,7 @@ describe("pluginRowText — a broken plugin says so, and names the key", () => {
399
396
  version: "0.5.0",
400
397
  installedVersion: "0.5.0",
401
398
  loadedVersion: "0.5.0",
402
- projectScope: { enabled: true, version: "0.5.0" },
399
+ projectScope: { installed: true, version: "0.5.0" },
403
400
  author: { name: "MadAppGang" },
404
401
  });
405
402
  const row = rows([healthy]).get("madbench@magus");
@@ -136,7 +136,7 @@ describe("profile switch", () => {
136
136
  expect((await fs.readJson(live)).enabledPlugins["terminal@magus"]).toBe(
137
137
  true,
138
138
  );
139
- expect(logs.some((l) => l.includes("kept at"))).toBe(false);
139
+ expect(logs.some((l) => l.includes("Backed up"))).toBe(false);
140
140
  expect(await fs.pathExists(join(dir, ".claude", ".magus-backups"))).toBe(
141
141
  false,
142
142
  );
@@ -147,7 +147,7 @@ describe("profile switch", () => {
147
147
  await switchProfile("frontend", dir);
148
148
  await switchProfile("backend", dir);
149
149
  await switchProfile("frontend", dir);
150
- expect(logs.some((l) => l.includes("kept at"))).toBe(false);
150
+ expect(logs.some((l) => l.includes("Backed up"))).toBe(false);
151
151
  expect(await fs.pathExists(join(dir, ".claude", ".magus-backups"))).toBe(
152
152
  false,
153
153
  );
@@ -6,10 +6,11 @@
6
6
  * update` carry on with a profile "for this run only" — resolving against a
7
7
  * manifest the project was not running. Two worlds live at once.
8
8
  *
9
- * The interactive path mounts a real OpenTUI renderer and cannot run here; what
10
- * IS testable is every path that must refuse, which is the behaviour that
11
- * changed. `selectDialog` returns null without a TTY, so a non-TTY run takes
12
- * the refusal branch by construction — which is also what CI does.
9
+ * The interactive path reads a numbered answer from a terminal and cannot run
10
+ * here; what IS testable is every path that must refuse, which is the
11
+ * behaviour that changed. Without a TTY the gate refuses before it asks, so a
12
+ * non-TTY run takes the refusal branch by construction — which is also what
13
+ * CI does.
13
14
  */
14
15
 
15
16
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";