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
@@ -64,7 +64,6 @@ describe("REQ-12 formatUpdateOutcome: what the TUI shows after Update it now", (
64
64
  test('REQ-12a a problem makes the outcome "failed" with the fixed title and the problem in the message, even when the state reads ok', () => {
65
65
  const result: ProfileStartup = {
66
66
  need: needIn("ok"),
67
- notices: [],
68
67
  problems: ["boom"],
69
68
  };
70
69
 
@@ -79,7 +78,7 @@ describe("REQ-12 formatUpdateOutcome: what the TUI shows after Update it now", (
79
78
 
80
79
  test('REQ-12b no problems and a need still to answer gives "ask" carrying the same need object', () => {
81
80
  const need = needIn("inactive");
82
- const result: ProfileStartup = { need, notices: [], problems: [] };
81
+ const result: ProfileStartup = { need, problems: [] };
83
82
 
84
83
  const outcome = formatUpdateOutcome(result);
85
84
 
@@ -87,34 +86,12 @@ describe("REQ-12 formatUpdateOutcome: what the TUI shows after Update it now", (
87
86
  expect((outcome as { need?: unknown }).need).toBe(need);
88
87
  });
89
88
 
90
- test('REQ-12c no problems, state ok and a notice gives "updated" with one notice line, not the gate\'s working log', () => {
91
- const result: ProfileStartup = {
92
- need: needIn("ok"),
93
- notices: ["absorbed 1 edit"],
94
- problems: [],
95
- };
89
+ test('REQ-12c no problems and state ok gives "updated", and nothing to say about it', () => {
90
+ const result: ProfileStartup = { need: needIn("ok"), problems: [] };
96
91
 
97
92
  const outcome = formatUpdateOutcome(result);
98
93
 
99
- expect(outcome).toEqual({
100
- kind: "updated",
101
- notice: "Profile format updated",
102
- });
103
- });
104
-
105
- test('REQ-12d no problems, state ok and no notices gives the same "updated" notice', () => {
106
- const result: ProfileStartup = {
107
- need: needIn("ok"),
108
- notices: [],
109
- problems: [],
110
- };
111
-
112
- const outcome = formatUpdateOutcome(result);
113
-
114
- expect(outcome).toEqual({
115
- kind: "updated",
116
- notice: "Profile format updated",
117
- });
94
+ expect(outcome).toEqual({ kind: "updated" });
118
95
  });
119
96
  });
120
97
 
@@ -137,7 +114,6 @@ for (const stream of [process.stdin, process.stdout, process.stderr]) {
137
114
 
138
115
  const out = {
139
116
  state: null,
140
- notices: null,
141
117
  problems: null,
142
118
  outcome: null,
143
119
  error: null,
@@ -148,14 +124,13 @@ try {
148
124
  ? await startup.prepareProfileStartup(project, { convert: true })
149
125
  : await startup.prepareProfileStartup(project);
150
126
  out.state = prepared.need.state;
151
- out.notices = prepared.notices;
152
127
  out.problems = Array.isArray(prepared.problems) ? prepared.problems : null;
153
128
  const outcome = startup.formatUpdateOutcome(prepared);
154
129
  out.outcome =
155
130
  outcome.kind === "ask"
156
131
  ? { kind: "ask", needState: outcome.need.state }
157
132
  : outcome.kind === "updated"
158
- ? { kind: "updated", notice: outcome.notice }
133
+ ? { kind: "updated" }
159
134
  : { kind: outcome.kind, title: outcome.title, message: outcome.message };
160
135
  } catch (e) {
161
136
  out.error = e instanceof Error ? (e.stack ?? e.message) : String(e);
@@ -166,12 +141,11 @@ process.exit(0);
166
141
 
167
142
  interface StartupRun {
168
143
  state: ProfileStartupState | null;
169
- notices: string[] | null;
170
144
  /** Null when the result carried no `problems` array at all. */
171
145
  problems: string[] | null;
172
146
  outcome:
173
147
  | { kind: "ask"; needState: ProfileStartupState }
174
- | { kind: "updated"; notice: string }
148
+ | { kind: "updated" }
175
149
  | { kind: "failed"; title: string; message: string }
176
150
  | null;
177
151
  error: string | null;
@@ -31,10 +31,7 @@ import {
31
31
  prepareProfileState,
32
32
  } from "../services/profile-state.js";
33
33
  import type { ProfileManifest } from "../types/index.js";
34
- import {
35
- buildGitignoreModal,
36
- startupFixNotices,
37
- } from "../ui/hooks/useGitignoreModal.js";
34
+ import { buildGitignoreModal } from "../ui/hooks/useGitignoreModal.js";
38
35
 
39
36
  const MANIFEST: ProfileManifest = {
40
37
  version: 2,
@@ -155,11 +152,6 @@ describe("the startup fix untracks generated files in place", () => {
155
152
  expect(await fs.readFile(join(project, ".gitignore"), "utf8")).toContain(
156
153
  ".claude/settings.json",
157
154
  );
158
- // Untracking is unfinished until the user commits, so it is a warning.
159
- expect(startupFixNotices(result)).toContainEqual({
160
- text: "Stopped tracking .claude/settings.json — commit the staged removal",
161
- tone: "warning",
162
- });
163
155
  // Asked again on the next open: nothing generated is tracked any more.
164
156
  expect((await startupGitignoreNeed(project))?.generated ?? []).toEqual([]);
165
157
  });
@@ -51,10 +51,9 @@ const roundTrip: AppAction[] = [
51
51
  ];
52
52
 
53
53
  describe("models screen initial state", () => {
54
- test("starts idle, unfiltered, and with no message", () => {
54
+ test("starts idle, unfiltered, and not writing", () => {
55
55
  expect(initialState.models.data.status).toBe("idle");
56
56
  expect(initialState.models.searchQuery).toBe("");
57
- expect(initialState.models.status).toBeNull();
58
57
  expect(initialState.models.isApplying).toBe(false);
59
58
  // The mate slots are not drawn until something says they can be served. Starting
60
59
  // true would put three rows on screen before anything had been asked.
@@ -166,39 +165,6 @@ describe("models data state", () => {
166
165
  });
167
166
  });
168
167
 
169
- describe("models status line", () => {
170
- test("survives the screen unmounting, because it lives in app state", () => {
171
- // `Router` swaps the component type on a tab change, so a message held in
172
- // the screen's own useState is destroyed by 0 → 1 → 0 — and the message is
173
- // the only record of what the last apply did.
174
- const messaged = run([
175
- {
176
- type: "MODELS_STATUS_SET",
177
- status: { text: "Routed opus-lead", tone: "success" },
178
- },
179
- ]);
180
- expect(run(roundTrip, messaged).models.status?.text).toBe(
181
- "Routed opus-lead",
182
- );
183
- });
184
-
185
- test("is replaced by the next message, not appended to", () => {
186
- const state = run([
187
- { type: "MODELS_STATUS_SET", status: { text: "one", tone: "success" } },
188
- { type: "MODELS_STATUS_SET", status: { text: "two", tone: "error" } },
189
- ]);
190
- expect(state.models.status).toEqual({ text: "two", tone: "error" });
191
- });
192
-
193
- test("clearing removes it", () => {
194
- const state = run([
195
- { type: "MODELS_STATUS_SET", status: { text: "one", tone: "success" } },
196
- { type: "MODELS_STATUS_CLEAR" },
197
- ]);
198
- expect(state.models.status).toBeNull();
199
- });
200
- });
201
-
202
168
  describe("apply-in-flight state", () => {
203
169
  test("survives the screen unmounting", () => {
204
170
  // An apply rewrites the profile, re-materializes it and registers a hook.
@@ -297,11 +263,10 @@ describe("ModelsScreen keybindings", () => {
297
263
  expect(src.slice(apply, apply + 120)).toContain("handleApply()");
298
264
  });
299
265
 
300
- test("digits and Tab never reach the screen, so a message survives a tab round trip", () => {
301
- // The status line is cleared only by this screen's own bindings. Every
302
- // digit (0 included) and Tab navigate, and they belong to the global key
303
- // layer: no Models binding may claim one, or the message would be wiped
304
- // on the way OUT of the tab — the loss the state was moved to prevent.
266
+ test("digits and Tab never reach the screen", () => {
267
+ // Every digit (0 included) and Tab navigate, and they belong to the global
268
+ // key layer: a Models binding claiming one would make the tab unleavable
269
+ // by that key.
305
270
  for (const digit of "1234567890") {
306
271
  expect(
307
272
  matchBinding(SCREEN_BINDINGS.models, press(digit)),
@@ -319,6 +284,19 @@ describe("ModelsScreen keybindings", () => {
319
284
  expect(styles).not.toContain("/^[1-9]$/");
320
285
  });
321
286
 
287
+ test("no status line: a failure goes to the failure popup, a success says nothing", async () => {
288
+ // The TUI shows no status lines, notices or success messages; the screen
289
+ // reloads and shows state. The header row carries state only.
290
+ const src = stripComments(await fs.readFile(SCREEN, "utf8"));
291
+ expect(src).not.toContain("notice=");
292
+ expect(src).not.toContain("MODELS_STATUS");
293
+ expect(src).not.toMatch(/\bApplied\b/);
294
+ // Negative control: the failure path is really wired, so the guard is
295
+ // not passing over a screen that reports nothing at all.
296
+ expect(src).toContain("useFailurePopup()");
297
+ expect(src).toContain("stepFailureReport(");
298
+ });
299
+
322
300
  test("screen state is held in the reducer, never in useState", async () => {
323
301
  // The rule the two "survives unmounting" tests above enforce at runtime,
324
302
  // pinned at the source so a new piece of state cannot quietly opt out.
@@ -191,17 +191,11 @@ describe("isRelevantInstallation", () => {
191
191
  ).toBe(false);
192
192
  });
193
193
 
194
- test("two rootless paths DO match each other, as the original's compare does", () => {
195
- // `repoRoot(entry.projectPath) === repoRoot(originalCwd)` with both sides
196
- // null is true in the decompiled filter, so a row from any directory in no
197
- // repository competes when Claude Code runs from another such directory —
198
- // a scratch dir, a home directory, a plain non-git project.
199
- //
200
- // The port used to answer false here, which made magus name a
201
- // `loadedVersion` the loader would not pick in any non-git directory. It is
202
- // the loader's answer this module owes, not the more cautious one; what
203
- // magus may WRITE is a separate question, pinned in stale-load-repair.test.ts
204
- // ("a rootless row from another directory is relevant but NOT rewritten").
194
+ test("two rootless paths do NOT match each other (Claude Code 2.1.284)", () => {
195
+ // 2.1.284 guards the root comparison with `cwdRoot !== null`. Before it,
196
+ // two nulls compared equal and every rootless folder shared every other
197
+ // rootless folder's rows. Measured with `--debug-file` in a rootless folder:
198
+ // a row from an existing non-git folder did not load there.
205
199
  const loose = path.join(base, "loose");
206
200
  expect(
207
201
  isRelevantInstallation(
@@ -212,12 +206,57 @@ describe("isRelevantInstallation", () => {
212
206
  loose,
213
207
  repoRootForPath(loose),
214
208
  ),
209
+ ).toBe(false);
210
+ });
211
+
212
+ test("in a rootless folder its own row and USER rows still count", () => {
213
+ const loose = path.join(base, "loose");
214
+ const looseRoot = repoRootForPath(loose);
215
+ expect(looseRoot).toBeNull();
216
+ expect(
217
+ isRelevantInstallation(
218
+ entry({ version: "1.0.0", projectPath: loose }),
219
+ loose,
220
+ looseRoot,
221
+ ),
222
+ ).toBe(true);
223
+ expect(
224
+ isRelevantInstallation(
225
+ entry({ version: "1.0.0", scope: "user", projectPath: undefined }),
226
+ loose,
227
+ looseRoot,
228
+ ),
229
+ ).toBe(true);
230
+ });
231
+
232
+ test("a row's path is compared RAW, as the binary compares it: '/p/a/' is not '/p/a' in a rootless folder", () => {
233
+ // Normalising the row's path made this pair relevant — and in a rootless
234
+ // folder nothing else could, since the root comparison matches nothing —
235
+ // so the port named a row the loader rejects.
236
+ const loose = path.join(base, "loose");
237
+ const looseRoot = repoRootForPath(loose);
238
+ expect(looseRoot).toBeNull();
239
+ expect(
240
+ isRelevantInstallation(
241
+ entry({ version: "1.0.0", projectPath: `${loose}/` }),
242
+ loose,
243
+ looseRoot,
244
+ ),
245
+ ).toBe(false);
246
+ // The caller's directory stands in for the binary's own working
247
+ // directory, which is always absolute with no trailing slash.
248
+ expect(
249
+ isRelevantInstallation(
250
+ entry({ version: "1.0.0", projectPath: loose }),
251
+ `${loose}/`,
252
+ looseRoot,
253
+ ),
215
254
  ).toBe(true);
216
255
  });
217
256
 
218
257
  test("a rootless row is still irrelevant to a directory inside a repository", () => {
219
- // The null-matches-null rule must not leak the other way: `cwdRoot` is a
220
- // real root here, so a rootless row's null root does not equal it.
258
+ // `cwdRoot` is a real root here, so a rootless row's null root does not
259
+ // equal it.
221
260
  expect(
222
261
  isRelevantInstallation(
223
262
  entry({ version: "1.0.0", projectPath: path.join(base, "loose") }),
@@ -140,7 +140,7 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
140
140
  // match, and it cannot tell a move from a dual publication: claudish ships
141
141
  // to two marketplaces with both copies installed, and the row announced
142
142
  // "moved → magus" about a plugin that had not moved anywhere.
143
- test("an orphan is 'not offered', and never names a destination", () => {
143
+ test("an orphan is deprecated, and never names a destination", () => {
144
144
  const seo = info({
145
145
  id: "seo@magus",
146
146
  version: "1.8.0",
@@ -149,16 +149,15 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
149
149
  projectScope: { installed: true, version: "1.8.0" },
150
150
  });
151
151
  const row = rows([seo]).get(seo.id) as PluginPluginItem;
152
- expect(cliNote([seo], seo.id)).toBe(
153
- "not offered by any configured marketplace",
154
- );
152
+ expect(cliNote([seo], seo.id)).toBe("no longer published");
153
+ // The row's own words, not the note: the verdict and the key that acts.
155
154
  expect(pluginRowText(row).text).toBe(
156
- " v1.8.0 ? not offered by any configured marketplace",
155
+ " v1.8.0 deprecated — press a to remove",
157
156
  );
158
157
  expect(pluginRowText(row).text).not.toMatch(/moved/i);
159
158
  });
160
159
 
161
- test("an orphan from a marketplace we DID read is 'not offered'", () => {
160
+ test("an orphan from a marketplace we DID read is deprecated", () => {
162
161
  const gone = info({
163
162
  id: "gone@magus",
164
163
  version: "2.0.0",
@@ -168,14 +167,12 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
168
167
  });
169
168
  const row = rows([gone]).get(gone.id) as PluginPluginItem;
170
169
  expect(pluginRowText(row).text).toBe(
171
- ` v2.0.0 ? ${cliNote([gone], gone.id)}`,
172
- );
173
- expect(pluginRowText(row).text).toContain(
174
- "not offered by any configured marketplace",
170
+ " v2.0.0 deprecated — press a to remove",
175
171
  );
172
+ expect(row.status?.action).toBe("remove");
176
173
  });
177
174
 
178
- test("the old words are gone: no 'deprecated', no 'moved →'", () => {
175
+ test("the old words are gone: no 'moved →', and no marketplace wording", () => {
179
176
  const seo = info({
180
177
  id: "seo@magus",
181
178
  installedVersion: "1.8.0",
@@ -183,14 +180,14 @@ describe("pluginRowText — orphans say what the CLI says (F9)", () => {
183
180
  projectScope: { installed: true, version: "1.8.0" },
184
181
  });
185
182
  const { text } = pluginRowText(rows([seo]).get(seo.id) as PluginPluginItem);
186
- expect(text).not.toContain("deprecated");
183
+ expect(text).not.toMatch(/marketplace/i);
187
184
  expect(text).not.toContain("moved →");
188
185
  });
189
186
  });
190
187
 
191
- describe("pluginRowText — history badges combine with the state", () => {
192
- const updated = (over: Partial<PluginInfo>) =>
193
- info({
188
+ describe("pluginRowText — the current version and the state, no history", () => {
189
+ const row = (over: Partial<PluginInfo>) => {
190
+ const p = info({
194
191
  id: "terminal@magus",
195
192
  version: "3.3.1",
196
193
  installedVersion: "3.0.1",
@@ -198,40 +195,36 @@ describe("pluginRowText — history badges combine with the state", () => {
198
195
  author: { name: "MadAppGang" },
199
196
  ...over,
200
197
  });
198
+ return rows([p]).get(p.id) as PluginPluginItem;
199
+ };
201
200
 
202
- test("'updated' and a pending update are both shown", () => {
203
- // An external update moved 2.9.0 → 3.0.1 while 3.3.1 is already out.
204
- const p = updated({ recentlyUpdatedFrom: "2.9.0" });
205
- const row = rows([p]).get(p.id) as PluginPluginItem;
206
- const { text, segments } = pluginRowText(row);
207
- expect(text).toBe(" v2.9.0 → v3.0.1 updated → v3.3.1 available");
208
- expect(segments.map((s) => s.tone)).toEqual(["success", "warning"]);
201
+ test("a pending update: the installed version, then what is available", () => {
202
+ const { text, segments } = pluginRowText(row({}));
203
+ expect(text).toBe(" v3.0.1 → v3.3.1 available");
204
+ expect(segments.map((s) => s.tone)).toEqual(["muted", "warning"]);
209
205
  });
210
206
 
211
- test("'new' and a pending update are both shown", () => {
212
- const p = updated({ recentlyInstalled: true });
213
- const row = rows([p]).get(p.id) as PluginPluginItem;
214
- expect(pluginRowText(row).text).toBe(" v3.0.1 new → v3.3.1 available");
207
+ test("NEGATIVE CONTROL: no 'updated' or 'new' badge — the row has no memory of earlier versions", () => {
208
+ // The badge rendered "v1.8.0 → v1.7.4 updated → v1.8.0 available": a
209
+ // downgrade called "updated". A row now says nothing about what changed.
210
+ const text = pluginRowText(row({})).text;
211
+ expect(text).not.toContain("updated");
212
+ expect(text).not.toContain(" new");
215
213
  });
216
214
 
217
- test("'new' with no displayable version still says new", () => {
218
- const p = updated({
219
- recentlyInstalled: true,
215
+ test("an unknown version is not printed", () => {
216
+ const r = row({
220
217
  version: "0.0.0",
221
218
  installedVersion: "0.0.0",
222
219
  projectScope: { installed: true, version: "0.0.0" },
223
220
  });
224
- const row = rows([p]).get(p.id) as PluginPluginItem;
225
- expect(pluginRowText(row).text).toBe(" new");
221
+ expect(pluginRowText(r).text).not.toContain("v0.0.0");
226
222
  });
227
223
 
228
224
  test("a current row shows only its version", () => {
229
- const p = updated({
230
- version: "3.0.1",
231
- });
232
- const row = rows([p]).get(p.id) as PluginPluginItem;
233
- expect(row.status?.action).toBe("current");
234
- expect(pluginRowText(row).text).toBe(" v3.0.1");
225
+ const r = row({ version: "3.0.1" });
226
+ expect(r.status?.action).toBe("current");
227
+ expect(pluginRowText(r).text).toBe(" v3.0.1");
235
228
  });
236
229
  });
237
230
 
@@ -141,6 +141,13 @@ describe("the render enables what the profile's plugins depend on", () => {
141
141
  "lib@mkt",
142
142
  "shared@other",
143
143
  ]);
144
+ // Which plugin asked for each — what `magus update` names when a derived
145
+ // dependency is no longer published.
146
+ expect(closure.dependencies?.requiredBy).toEqual({
147
+ "lib@mkt": "app@mkt",
148
+ "core@mkt": "lib@mkt",
149
+ "shared@other": "core@mkt",
150
+ });
144
151
  await start(manifest, "only");
145
152
  expect(await enabled()).toEqual({
146
153
  "app@mkt": true,
@@ -60,7 +60,6 @@ const out = {
60
60
  described: null,
61
61
  options: null,
62
62
  updateFormatValue: startup.UPDATE_FORMAT_VALUE,
63
- notices: null,
64
63
  error: null,
65
64
  };
66
65
  try {
@@ -78,7 +77,6 @@ try {
78
77
  mode === "convert" ? { convert: true } : undefined,
79
78
  );
80
79
  out.need = { state: prepared.need.state };
81
- out.notices = prepared.notices;
82
80
  }
83
81
  } catch (e) {
84
82
  out.error = e instanceof Error ? (e.stack ?? e.message) : String(e);
@@ -93,7 +91,6 @@ interface OldFormatRun {
93
91
  described: { title: string; message: string } | null;
94
92
  options: Array<{ label: string; value: string; meta?: string }> | null;
95
93
  updateFormatValue: string;
96
- notices: string[] | null;
97
94
  error: string | null;
98
95
  }
99
96
 
@@ -137,7 +137,7 @@ describe("detectProfileStartupNeed", () => {
137
137
  expect(describeNeed(need).message).toContain('"gone"');
138
138
  });
139
139
 
140
- test("the TUI startup runs the gate first and reports what it absorbed", async () => {
140
+ test("the TUI startup runs the gate first, absorbing edits into the profile", async () => {
141
141
  await fs.outputJson(join(project, ".claude", "profiles.json"), LEAN);
142
142
  await activateProfile(LEAN, "lean", project, { onDisplace: () => {} });
143
143
  const live = join(project, ".claude", "settings.json");
@@ -145,10 +145,13 @@ describe("detectProfileStartupNeed", () => {
145
145
  settings.enabledPlugins["code-search@magus"] = true;
146
146
  await fs.writeFile(live, JSON.stringify(settings));
147
147
 
148
- const { need, notices } = await prepareProfileStartup(project);
148
+ const { need } = await prepareProfileStartup(project);
149
149
 
150
150
  expect(need.state).toBe("ok");
151
- expect(notices).toEqual(['Added code-search@magus to profile "lean"']);
151
+ const manifest = await fs.readJson(
152
+ join(project, ".claude", "profiles.json"),
153
+ );
154
+ expect(manifest.profiles.lean.plugins["code-search@magus"]).toBeTruthy();
152
155
  });
153
156
 
154
157
  test("what is enabled never changes the answer", async () => {
@@ -403,46 +406,71 @@ describe("a failure that already moved something", () => {
403
406
  link: `/p/.claude/${name}`,
404
407
  savedTo: `/p/.claude/.magus-backups/S/${name}`,
405
408
  });
409
+ const lean = { kind: "activate" as const, id: "lean" };
410
+ /** Everything the failure popup says, as one string. */
411
+ const said = (report: ReturnType<typeof describeFailure>): string =>
412
+ [
413
+ report.headline,
414
+ ...report.rows.map((r) => `${r.subject} ${r.reason} ${r.fix ?? ""}`),
415
+ report.fix ?? "",
416
+ ].join("\n");
406
417
 
407
418
  test("a true no-op says so", () => {
408
- const msg = describeFailure(new Error("boom"), [], "/p", null);
419
+ const report = describeFailure(new Error("boom"), [], "/p", null, lean);
420
+ expect(report.headline).toBe('Profile "lean" was not activated');
421
+ const msg = said(report);
409
422
  expect(msg).toContain("boom");
410
423
  expect(msg).toContain("exactly as it was");
411
424
  });
412
425
 
426
+ test("an adoption that failed says the profile was not created", () => {
427
+ const report = describeFailure(new Error("boom"), [], "/p", null, {
428
+ kind: "adopt",
429
+ id: "default",
430
+ name: "Mine",
431
+ });
432
+ expect(report.headline).toBe('Profile "Mine" was not created');
433
+ });
434
+
413
435
  test("a partial failure never claims nothing happened", () => {
414
436
  // Activation moves up to four artifacts in sequence and settings.json goes
415
437
  // FIRST, so a throw on the second leaves it already in the backup. The old
416
438
  // message said "Nothing was activated" unconditionally — false about the
417
439
  // user's own file, and `.magus-backups/` is gitignored, so git status
418
440
  // showed the deletion with no sign of the copy.
419
- const msg = describeFailure(
441
+ const report = describeFailure(
420
442
  new Error("EACCES: skills"),
421
443
  [at("settings.json")],
422
444
  "/p",
423
445
  "lean",
446
+ lean,
424
447
  );
448
+ const msg = said(report);
425
449
 
426
- expect(msg).not.toContain("Nothing was moved");
450
+ expect(msg.toLowerCase()).not.toContain("nothing was moved");
427
451
  expect(msg).not.toContain("exactly as it was");
428
- // It must name where the file went, project-relative.
429
- expect(msg).toContain(".claude/settings.json");
430
- expect(msg).toContain(".claude/.magus-backups/S/settings.json");
452
+ // It must name where the file went, project-relative: one row per file.
453
+ expect(report.rows).toContainEqual({
454
+ subject: ".claude/settings.json",
455
+ reason: "is kept at .claude/.magus-backups/S/settings.json",
456
+ });
431
457
  });
432
458
 
433
459
  test("it warns against the re-attempt that would capture an empty set", () => {
434
460
  // Re-choosing save-what-you-have runs adoption again, and adoption reads
435
461
  // settings.json — now in the backup. It would capture an empty plugin set
436
462
  // into a second profile and call that the user's setup.
437
- const msg = describeFailure(
463
+ const report = describeFailure(
438
464
  new Error("boom"),
439
465
  [at("settings.json")],
440
466
  "/p",
441
467
  "lean",
468
+ lean,
442
469
  );
443
470
 
444
- expect(msg).toContain("Do NOT save-what-you-have");
445
- expect(msg).toContain("Activating lean again is safe");
471
+ // The fix line, said once under the rows.
472
+ expect(report.fix).toContain("Do NOT save-what-you-have");
473
+ expect(report.fix).toContain("Activating lean again is safe");
446
474
  });
447
475
  });
448
476