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
@@ -69,11 +69,7 @@ import {
69
69
  findEnclosingManifest,
70
70
  readManifest,
71
71
  } from "./manifest.js";
72
- import {
73
- type ActivationResult,
74
- type ProfileChoice,
75
- activationNotes,
76
- } from "./profile-activate.js";
72
+ import type { ActivationResult, ProfileChoice } from "./profile-activate.js";
77
73
  import { freeProfileId } from "./profile-adopt.js";
78
74
  import {
79
75
  activeProfile,
@@ -311,36 +307,29 @@ export async function oldFormatNeed(
311
307
  };
312
308
  }
313
309
 
314
- /** 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. */
315
311
  export interface ProfileStartup {
316
312
  need: ProfileStartupNeed;
317
313
  /**
318
- * One line per thing the gate did: absorbed edits, regeneration, repairs.
319
- * Not what an activation reported — see `activations`.
320
- */
321
- notices: string[];
322
- /**
323
- * One line per thing the gate REFUSED — the lines the CLI stops on. Kept
324
- * apart from `notices` because a refusal can leave detection reading `ok`:
325
- * an old layout whose settings file does not parse is not converted, yet
326
- * 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.
327
318
  */
328
319
  problems: string[];
329
320
  /**
330
- * The activations the gate ran (a re-render, a format update). Handed over
331
- * as results rather than lines because the CLI's lines are the wrong words
332
- * for the TUI: they name plugin.json and backups, and they say "will not
333
- * load" in a line nothing marks as a warning. The TUI words them itself, in
334
- * user terms and with a tone (`activationWarnings` in the startup hook).
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.
335
324
  */
336
- activations: ActivationResult[];
325
+ activations?: ActivationResult[];
337
326
  }
338
327
 
339
328
  /**
340
329
  * The TUI's startup: the same gate every CLI command runs, then detection.
341
330
  *
342
- * A failure in the gate never blocks the TUI — its message becomes a notice
343
- * 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.
344
333
  */
345
334
  export async function prepareProfileStartup(
346
335
  projectPath: string,
@@ -354,21 +343,13 @@ export async function prepareProfileStartup(
354
343
  if (!opts.convert) {
355
344
  const format = await oldFormatNeed(projectPath);
356
345
  if (format !== null) {
357
- return { need: format, notices: [], problems: [], activations: [] };
346
+ return { need: format, problems: [] };
358
347
  }
359
348
  }
360
- const notices: string[] = [];
361
349
  const problems: string[] = [];
362
350
  const activations: ActivationResult[] = [];
363
351
  try {
364
352
  const prep = await prepareProfileState(projectPath);
365
- // The gate's lines, less the ones its activations contributed: those
366
- // reach the TUI as `activations`, and passing both would say the same
367
- // thing twice — once in the CLI's words, as if it were a success.
368
- const fromActivations = new Set(
369
- prep.activations.flatMap((a) => activationNotes(a, { tracked: false })),
370
- );
371
- notices.push(...prep.lines.filter((line) => !fromActivations.has(line)));
372
353
  problems.push(...prep.problems);
373
354
  activations.push(...prep.activations);
374
355
  } catch (error) {
@@ -378,19 +359,18 @@ export async function prepareProfileStartup(
378
359
  }
379
360
  return {
380
361
  need: await detectProfileStartupNeed(projectPath),
381
- notices,
382
362
  problems,
383
- activations,
363
+ ...(activations.length > 0 ? { activations } : {}),
384
364
  };
385
365
  }
386
366
 
387
367
  /**
388
- * What the TUI shows once "Update it now" has run: another question, a notice
389
- * line, or an error modal.
368
+ * What the TUI does once "Update it now" has run: ask another question, carry
369
+ * on, or show an error modal.
390
370
  */
391
371
  export type FormatUpdateOutcome =
392
372
  | { kind: "ask"; need: ProfileStartupNeed }
393
- | { kind: "updated"; notice: string }
373
+ | { kind: "updated" }
394
374
  | { kind: "failed"; title: string; message: string };
395
375
 
396
376
  /**
@@ -410,12 +390,7 @@ export function formatUpdateOutcome(
410
390
  };
411
391
  }
412
392
  if (!needsNothing(result.need)) return { kind: "ask", need: result.need };
413
- // One line, not the gate's notices: those are the conversion's working log
414
- // (edits carried over, the old format's backup), and the result the user
415
- // cares about is only that the update is done. What the conversion's
416
- // activation warns about is NOT dropped: the caller words it from
417
- // `result.activations`, as it does for any activation.
418
- return { kind: "updated", notice: "Profile format updated" };
393
+ return { kind: "updated" };
419
394
  }
420
395
 
421
396
  // ─── What to offer, as data both surfaces render ─────────────────────────────
@@ -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 =
@@ -652,3 +655,70 @@ export async function withCliBookkeeping<T>(
652
655
  for (const line of reconciled.lines) opts.report?.(line);
653
656
  }
654
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
  };