magus-cli 7.6.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 +9 -34
  16. package/src/__tests__/gitignore-profile.test.ts +0 -4
  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 -42
  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/gitignore-service.ts +0 -19
  36. package/src/services/plugin-env-needs.ts +69 -0
  37. package/src/services/plugin-manager.ts +31 -19
  38. package/src/services/plugin-resolution.ts +34 -42
  39. package/src/services/profile-absorb.ts +9 -0
  40. package/src/services/profile-activate.ts +1 -1
  41. package/src/services/profile-startup.ts +30 -27
  42. package/src/services/profile-state.ts +81 -2
  43. package/src/services/resolver.ts +95 -1
  44. package/src/services/update-engine.ts +326 -31
  45. package/src/services/update-plan.ts +293 -33
  46. package/src/types/index.ts +7 -0
  47. package/src/ui/App.tsx +186 -123
  48. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  49. package/src/ui/adapters/failureReport.ts +137 -0
  50. package/src/ui/adapters/pluginsAdapter.ts +299 -28
  51. package/src/ui/adapters/startupReports.ts +282 -0
  52. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  53. package/src/ui/components/modals/MessageModal.tsx +7 -13
  54. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  55. package/src/ui/components/modals/index.ts +1 -0
  56. package/src/ui/hooks/startupQueue.ts +56 -0
  57. package/src/ui/hooks/useGitignoreModal.ts +31 -31
  58. package/src/ui/hooks/useProfileStartupModal.ts +127 -122
  59. package/src/ui/hooks/useStaleLoadModal.ts +33 -53
  60. package/src/ui/keymap.ts +14 -1
  61. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  62. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  63. package/src/ui/renderers/pluginRowText.ts +25 -25
  64. package/src/ui/renderers/pluginStatusText.ts +18 -1
  65. package/src/ui/screens/AliasScreen.tsx +38 -44
  66. package/src/ui/screens/CliToolsScreen.tsx +65 -51
  67. package/src/ui/screens/EnvVarsScreen.tsx +21 -6
  68. package/src/ui/screens/GitignoreScreen.tsx +49 -75
  69. package/src/ui/screens/McpRegistryScreen.tsx +72 -40
  70. package/src/ui/screens/McpScreen.tsx +55 -24
  71. package/src/ui/screens/ModelsScreen.tsx +46 -86
  72. package/src/ui/screens/PluginsScreen.tsx +316 -245
  73. package/src/ui/screens/ProfilesScreen.tsx +88 -73
  74. package/src/ui/screens/SkillsScreen.tsx +83 -80
  75. package/src/ui/screens/StylesScreen.tsx +211 -275
  76. package/src/ui/state/AppContext.tsx +72 -7
  77. package/src/ui/state/DimensionsContext.tsx +3 -22
  78. package/src/ui/state/reducer.ts +25 -14
  79. package/src/ui/state/types.ts +61 -31
  80. package/src/utils/user-path.ts +60 -0
  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
@@ -59,6 +59,7 @@ import { readManifest, writeManifest } from "./manifest.js";
59
59
  import { validateModelsConfig } from "./models-core.js";
60
60
  import {
61
61
  type ActivateOptions,
62
+ type ActivationResult,
62
63
  activateProfile,
63
64
  activationNotes,
64
65
  releasedCommittedSkillNote,
@@ -588,6 +589,12 @@ export interface ReconcileResult {
588
589
  conflicts: string[];
589
590
  /** Everything worth printing, in order. */
590
591
  lines: string[];
592
+ /**
593
+ * The re-render, when profiles.json changed and the live config was
594
+ * generated again. `lines` already carries its CLI wording; this is for a
595
+ * surface that words it differently (the TUI's notices).
596
+ */
597
+ activation: ActivationResult | null;
591
598
  }
592
599
 
593
600
  function emptyResult(profile: string | null): ReconcileResult {
@@ -600,6 +607,7 @@ function emptyResult(profile: string | null): ReconcileResult {
600
607
  unreadable: [],
601
608
  conflicts: [],
602
609
  lines: [],
610
+ activation: null,
603
611
  };
604
612
  }
605
613
 
@@ -1529,6 +1537,7 @@ export async function reconcileActiveProfile(
1529
1537
  // command that ran it reports that once, itself, and repeating it here
1530
1538
  // printed the same warning three times in one `magus update`.
1531
1539
  notes.push(...activationNotes(activation, { tracked: false }));
1540
+ result.activation = activation;
1532
1541
  } catch (error) {
1533
1542
  result.problems.push(
1534
1543
  error instanceof Error ? error.message : String(error),
@@ -209,7 +209,7 @@ export function releasedCommittedSkillNote(folder: string): string {
209
209
 
210
210
  /**
211
211
  * Lines describing what activation did beyond the switch itself, phrased for
212
- * both the terminal and the TUI's outcome modal.
212
+ * the terminal.
213
213
  *
214
214
  * `tracked: false` leaves the tracked-path notices out, for the CLI, which
215
215
  * prints them itself as a warning with the fix beneath it
@@ -69,7 +69,7 @@ import {
69
69
  findEnclosingManifest,
70
70
  readManifest,
71
71
  } from "./manifest.js";
72
- import type { ProfileChoice } from "./profile-activate.js";
72
+ import type { ActivationResult, ProfileChoice } from "./profile-activate.js";
73
73
  import { freeProfileId } from "./profile-adopt.js";
74
74
  import {
75
75
  activeProfile,
@@ -307,25 +307,29 @@ export async function oldFormatNeed(
307
307
  };
308
308
  }
309
309
 
310
- /** What the TUI shows at startup, beyond the question (if any). */
310
+ /** The TUI's startup: the question (if any), and what the gate refused. */
311
311
  export interface ProfileStartup {
312
312
  need: ProfileStartupNeed;
313
- /** One line per thing the gate did: absorbed edits, regeneration, repairs. */
314
- notices: string[];
315
313
  /**
316
- * One line per thing the gate REFUSED — the lines the CLI stops on. Kept
317
- * apart from `notices` because a refusal can leave detection reading `ok`:
318
- * an old layout whose settings file does not parse is not converted, yet
319
- * still names its profile, so only this says the update did not happen.
314
+ * One line per thing the gate REFUSED — the lines the CLI stops on. A
315
+ * refusal can leave detection reading `ok`: an old layout whose settings
316
+ * file does not parse is not converted, yet still names its profile, so only
317
+ * this says the update did not happen.
320
318
  */
321
319
  problems: string[];
320
+ /**
321
+ * Every activation the gate ran — a re-render after profiles.json changed,
322
+ * an older format converted — so the TUI can say what one of them could
323
+ * not do. Absent is none.
324
+ */
325
+ activations?: ActivationResult[];
322
326
  }
323
327
 
324
328
  /**
325
329
  * The TUI's startup: the same gate every CLI command runs, then detection.
326
330
  *
327
- * A failure in the gate never blocks the TUI — its message becomes a notice
328
- * and detection still runs, so the user is asked whatever is left to ask.
331
+ * A failure in the gate never blocks the TUI — its message is kept in
332
+ * `problems` and detection still runs, so the user is asked whatever is left.
329
333
  */
330
334
  export async function prepareProfileStartup(
331
335
  projectPath: string,
@@ -338,14 +342,16 @@ export async function prepareProfileStartup(
338
342
  ): Promise<ProfileStartup> {
339
343
  if (!opts.convert) {
340
344
  const format = await oldFormatNeed(projectPath);
341
- if (format !== null) return { need: format, notices: [], problems: [] };
345
+ if (format !== null) {
346
+ return { need: format, problems: [] };
347
+ }
342
348
  }
343
- const notices: string[] = [];
344
349
  const problems: string[] = [];
350
+ const activations: ActivationResult[] = [];
345
351
  try {
346
352
  const prep = await prepareProfileState(projectPath);
347
- notices.push(...prep.lines);
348
353
  problems.push(...prep.problems);
354
+ activations.push(...prep.activations);
349
355
  } catch (error) {
350
356
  if (!(error instanceof SettingsParseError)) {
351
357
  problems.push(error instanceof Error ? error.message : String(error));
@@ -353,15 +359,18 @@ export async function prepareProfileStartup(
353
359
  }
354
360
  return {
355
361
  need: await detectProfileStartupNeed(projectPath),
356
- notices,
357
362
  problems,
363
+ ...(activations.length > 0 ? { activations } : {}),
358
364
  };
359
365
  }
360
366
 
361
- /** What the TUI shows once "Update it now" has run. */
367
+ /**
368
+ * What the TUI does once "Update it now" has run: ask another question, carry
369
+ * on, or show an error modal.
370
+ */
362
371
  export type FormatUpdateOutcome =
363
372
  | { kind: "ask"; need: ProfileStartupNeed }
364
- | { kind: "updated"; title: string; message: string }
373
+ | { kind: "updated" }
365
374
  | { kind: "failed"; title: string; message: string };
366
375
 
367
376
  /**
@@ -381,14 +390,7 @@ export function formatUpdateOutcome(
381
390
  };
382
391
  }
383
392
  if (!needsNothing(result.need)) return { kind: "ask", need: result.need };
384
- return {
385
- kind: "updated",
386
- title: "Profile format updated",
387
- message:
388
- result.notices.length > 0
389
- ? result.notices.join("\n")
390
- : "This project now uses the current profile format.",
391
- };
393
+ return { kind: "updated" };
392
394
  }
393
395
 
394
396
  // ─── What to offer, as data both surfaces render ─────────────────────────────
@@ -426,9 +428,10 @@ export const UPDATE_FORMAT_VALUE = "update-format";
426
428
  * Neither mentions the backup, deliberately. A sentence promising "nothing is
427
429
  * deleted — the old files are moved to a backup" raises a question the reader
428
430
  * did not have (which files? what was at risk?) and implies data loss, when what
429
- * changes is settings. Where the displaced files went belongs in the outcome
430
- * modal AFTER the switch, where it is something to act on rather than a worry to
431
- * plant beforehand.
431
+ * changes is settings. The TUI does not name the backup afterwards either: the
432
+ * backup is magus's own storage, and the user acts on nothing there. Only a
433
+ * failed activation lists what was moved, because then it is something to act
434
+ * on (see `describeFailure` in ui/hooks/useProfileStartupModal.ts).
432
435
  *
433
436
  * No file paths in these strings, deliberately. The TUI modal soft-wraps its
434
437
  * message at whatever width the terminal gives it, and OpenTUI treats `.` as a
@@ -26,8 +26,9 @@
26
26
  */
27
27
 
28
28
  import path from "node:path";
29
- import type { ProfileManifest } from "../types/index.js";
30
- import { readManifest, writeManifest } from "./manifest.js";
29
+ import type { ProfileManifest, ProfileManifestEntry } from "../types/index.js";
30
+ import { selectProfileId } from "./active-closure.js";
31
+ import { readManifest, updateProfileEntry, writeManifest } from "./manifest.js";
31
32
  import {
32
33
  type ReconcileResult,
33
34
  absorbOlderLayout,
@@ -47,6 +48,7 @@ import {
47
48
  type ActiveMarker,
48
49
  LIVE_FILE_KEYS,
49
50
  MACHINE_LOCAL_SETTINGS_KEYS,
51
+ activeProfile,
50
52
  folderHash,
51
53
  hasCurrentMarker,
52
54
  hasOlderLayout,
@@ -68,6 +70,7 @@ import {
68
70
  inspectManagedSkills,
69
71
  skillFolder,
70
72
  } from "./profile-skills.js";
73
+ import { resolveExtends } from "./resolver.js";
71
74
  import type { SettingsParseError } from "./settings-file.js";
72
75
 
73
76
  export type ActiveProfileCheck =
@@ -299,6 +302,12 @@ export interface ProfileStatePrep {
299
302
  problems: string[];
300
303
  /** The files behind `problems` that could not be parsed. */
301
304
  unreadable: SettingsParseError[];
305
+ /**
306
+ * Every activation the gate ran, in order: a re-render after profiles.json
307
+ * changed, an older layout converted. `lines` already carries them in the
308
+ * CLI's words; the TUI words them itself from these.
309
+ */
310
+ activations: ActivationResult[];
302
311
  }
303
312
 
304
313
  /**
@@ -319,11 +328,13 @@ export async function prepareProfileState(
319
328
  lines: [],
320
329
  problems: [],
321
330
  unreadable: [],
331
+ activations: [],
322
332
  };
323
333
  const take = (r: ReconcileResult) => {
324
334
  out.lines.push(...r.lines.filter((l) => !r.problems.includes(l)));
325
335
  out.problems.push(...r.problems);
326
336
  out.unreadable.push(...r.unreadable);
337
+ if (r.activation) out.activations.push(r.activation);
327
338
  };
328
339
 
329
340
  if (check.state === "active" && check.restored) {
@@ -358,6 +369,7 @@ export async function prepareProfileState(
358
369
  }
359
370
  // Tracked files are the command's to report, once (see profile-absorb).
360
371
  out.lines.push(...activationNotes(result, { tracked: false }));
372
+ out.activations.push(result);
361
373
  }
362
374
  out.check = { state: "active", profile, restored: false };
363
375
  }
@@ -643,3 +655,70 @@ export async function withCliBookkeeping<T>(
643
655
  for (const line of reconciled.lines) opts.report?.(line);
644
656
  }
645
657
  }
658
+
659
+ /** Whether a profile entry, `extends` included, turns the plugin on. */
660
+ function profileLists(
661
+ entry: ProfileManifestEntry | undefined,
662
+ pluginId: string,
663
+ ): boolean {
664
+ if (!entry) return false;
665
+ const value = resolveExtends(entry).plugins?.[pluginId];
666
+ return value !== undefined && value !== false;
667
+ }
668
+
669
+ /**
670
+ * Stop a profile listing `pluginId`, so its next activation does not turn the
671
+ * plugin back on. Answers whether the profile no longer lists it — read back
672
+ * from profiles.json after the write, never assumed.
673
+ *
674
+ * For a deprecated plugin `magus update` removes. The uninstall that runs first
675
+ * deletes the plugin from this project's settings (measured, Claude Code
676
+ * 2.1.284), and `reconcileActiveProfile` would absorb that into the ACTIVE
677
+ * profile at the next entry point — but only there, and only for the active
678
+ * one. The Plugins screen's `a` runs no reconcile afterwards, so the screen kept
679
+ * the plugin declared and planned the same removal again; `magus update <name>`
680
+ * plans a profile that is not active, whose live files nothing absorbs from;
681
+ * and a profile declaring a plugin that is installed nowhere has no settings
682
+ * change to absorb at all. So the entry is edited here, directly, through
683
+ * {@link updateProfileEntry} — the editor `magus models use` writes through.
684
+ * The reconcile that follows (`withCliBookkeeping`, or the next entry point)
685
+ * finds profiles.json and the live files agreeing, and regenerates from it.
686
+ *
687
+ * `profile` undefined means the active one, chosen as the Plugins screen and
688
+ * `magus update` choose it; null means no profile, which lists nothing.
689
+ *
690
+ * Deletes the entry's own pin. When `extends` supplies the plugin, the pin
691
+ * becomes `false`, which is how an entry turns an inherited plugin off.
692
+ * Throws `SettingsParseError` when profiles.json exists and does not parse.
693
+ */
694
+ export async function removePluginFromProfile(
695
+ projectPath: string,
696
+ pluginId: string,
697
+ profile?: string | null,
698
+ ): Promise<boolean> {
699
+ const manifest = await readManifest(projectPath);
700
+ const id =
701
+ profile === undefined
702
+ ? selectProfileId(
703
+ Object.keys(manifest.profiles),
704
+ undefined,
705
+ await activeProfile(projectPath),
706
+ )
707
+ : profile;
708
+ if (!id || !profileLists(manifest.profiles[id], pluginId)) return true;
709
+
710
+ await updateProfileEntry(projectPath, id, (entry) => {
711
+ const plugins = { ...(entry.plugins ?? {}) };
712
+ delete plugins[pluginId];
713
+ if (profileLists({ ...entry, plugins }, pluginId))
714
+ plugins[pluginId] = false;
715
+ if (Object.keys(plugins).length > 0) entry.plugins = plugins;
716
+ // biome-ignore lint/performance/noDelete: absent, not undefined, in profiles.json
717
+ else delete entry.plugins;
718
+ });
719
+
720
+ return !profileLists(
721
+ (await readManifest(projectPath)).profiles[id],
722
+ pluginId,
723
+ );
724
+ }
@@ -60,6 +60,7 @@ export async function resolveDependencies(
60
60
  read: PluginDependencyReader,
61
61
  ): Promise<DependencyReport> {
62
62
  const derived: string[] = [];
63
+ const requiredBy: Record<string, string> = {};
63
64
  const unreadable: string[] = [];
64
65
  const blocked: DependencyReport["blocked"] = [];
65
66
  const queue = [...enabled];
@@ -75,13 +76,105 @@ export async function resolveDependencies(
75
76
  blocked.push({ plugin: id, dependency });
76
77
  continue;
77
78
  }
79
+ // Every edge, including to a plugin the profile names itself: "still
80
+ // required" is a fact about the plugin that needs it, not about how
81
+ // the profile came to enable it. Recording only derived ones let a
82
+ // no-longer-published dependency the profile ALSO listed be planned
83
+ // for removal from under the plugin that needs it.
84
+ if (dependency !== id && !Object.hasOwn(requiredBy, dependency)) {
85
+ requiredBy[dependency] = id;
86
+ }
78
87
  if (seen.has(dependency)) continue;
79
88
  seen.add(dependency);
80
89
  derived.push(dependency);
81
90
  queue.push(dependency);
82
91
  }
83
92
  }
84
- return { derived: derived.sort(), unreadable: unreadable.sort(), blocked };
93
+ return {
94
+ derived: derived.sort(),
95
+ requiredBy,
96
+ unreadable: unreadable.sort(),
97
+ blocked,
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Which plugin requires each plugin, for the planner's STILL-REQUIRED rule:
103
+ * the profile's edges (`DependencyReport.requiredBy`) and, beside them, the
104
+ * edges every INSTALLED plugin's own plugin.json declares.
105
+ *
106
+ * The profile alone is not enough. A plugin installed at USER scope, or in a
107
+ * project with no profile at all, is planned too — and without its edges a
108
+ * dependency it needs that stopped being published was planned for removal
109
+ * from under it. One plugin.json read per installed plugin, the same read the
110
+ * profile's resolve makes. The profile's answer wins where both name one.
111
+ *
112
+ * ONE function for `magus update` and the Plugins screen, so the two plan the
113
+ * same removals.
114
+ */
115
+ export async function requiredByHere(
116
+ profileRequiredBy: Readonly<Record<string, string>> | undefined,
117
+ installedIds: readonly string[],
118
+ projectPath?: string,
119
+ read: PluginDependencyReader = readPluginDependencies,
120
+ ): Promise<Record<string, string>> {
121
+ const all = await requirersHere(
122
+ profileRequiredBy,
123
+ installedIds,
124
+ projectPath,
125
+ read,
126
+ );
127
+ const first: Record<string, string> = {};
128
+ for (const [dependency, requirers] of Object.entries(all)) {
129
+ const [requirer] = requirers;
130
+ if (requirer) first[dependency] = requirer;
131
+ }
132
+ return first;
133
+ }
134
+
135
+ /**
136
+ * {@link requiredByHere} with EVERY requirer of each plugin, not only the
137
+ * first — what the planner needs to remove deprecated plugins that require
138
+ * one another together, while keeping one that anything staying still needs.
139
+ * The profile's requirer comes first; the installed plugins' follow, sorted.
140
+ *
141
+ * This is what `magus update` and the Plugins screen plan with.
142
+ */
143
+ export async function requirersHere(
144
+ profileRequiredBy: Readonly<Record<string, string>> | undefined,
145
+ installedIds: readonly string[],
146
+ projectPath?: string,
147
+ read: PluginDependencyReader = readPluginDependencies,
148
+ ): Promise<Record<string, string[]>> {
149
+ const declared = await Promise.all(
150
+ [...installedIds].sort().map(async (id) => {
151
+ try {
152
+ return [id, await read(id, projectPath)] as const;
153
+ } catch {
154
+ return [id, null] as const;
155
+ }
156
+ }),
157
+ );
158
+ const edges: Record<string, string[]> = {};
159
+ const add = (dependency: string, requirer: string, front = false) => {
160
+ if (dependency === requirer) return;
161
+ const list = (
162
+ Object.hasOwn(edges, dependency) ? (edges[dependency] as string[]) : []
163
+ ).filter((r) => !front || r !== requirer);
164
+ if (list.includes(requirer)) return;
165
+ if (front) list.unshift(requirer);
166
+ else list.push(requirer);
167
+ edges[dependency] = list;
168
+ };
169
+ for (const [id, dependencies] of declared) {
170
+ for (const dependency of dependencies ?? []) add(dependency, id);
171
+ }
172
+ for (const [dependency, requirer] of Object.entries(
173
+ profileRequiredBy ?? {},
174
+ )) {
175
+ add(dependency, requirer, true);
176
+ }
177
+ return edges;
85
178
  }
86
179
 
87
180
  function hasDependencies(report: DependencyReport): boolean {
@@ -444,6 +537,7 @@ function mergeDependencyReports(
444
537
  );
445
538
  return {
446
539
  derived: [...new Set([...a.derived, ...b.derived])].sort(),
540
+ requiredBy: { ...b.requiredBy, ...a.requiredBy },
447
541
  unreadable: [...new Set([...a.unreadable, ...b.unreadable])].sort(),
448
542
  blocked: [...blocked.values()],
449
543
  };