magus-cli 7.5.3 → 7.5.4

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 (59) 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__/mate-availability.test.ts +11 -13
  13. package/src/__tests__/plugin-contents.test.ts +0 -1
  14. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  15. package/src/__tests__/plugin-row-text.test.ts +18 -21
  16. package/src/__tests__/profile-command.test.ts +2 -2
  17. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  18. package/src/__tests__/profile-skills.test.ts +3 -3
  19. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  20. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  21. package/src/__tests__/profile-state.test.ts +27 -22
  22. package/src/__tests__/scope-action.test.ts +35 -38
  23. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  24. package/src/__tests__/select-restore.test.ts +209 -0
  25. package/src/__tests__/stale-load-repair.test.ts +1 -2
  26. package/src/__tests__/update-apply.test.ts +18 -2
  27. package/src/__tests__/update-null-readback.test.ts +300 -0
  28. package/src/__tests__/update-plan-parity.test.ts +249 -0
  29. package/src/__tests__/update-plan.test.ts +66 -67
  30. package/src/__tests__/version-unknown-state.test.ts +38 -39
  31. package/src/cli/bootstrap.ts +239 -66
  32. package/src/cli/doctor.ts +4 -3
  33. package/src/cli/install.ts +83 -27
  34. package/src/cli/prompt.ts +7 -3
  35. package/src/cli/router.ts +1 -0
  36. package/src/cli/select.ts +168 -0
  37. package/src/cli/update-view.ts +118 -6
  38. package/src/cli/update.ts +6 -4
  39. package/src/services/claude-settings.ts +28 -0
  40. package/src/services/manifest.ts +31 -0
  41. package/src/services/mate-availability.ts +23 -29
  42. package/src/services/plugin-manager.ts +247 -105
  43. package/src/services/profile-absorb.ts +10 -7
  44. package/src/services/profile-activate.ts +39 -18
  45. package/src/services/profile-startup.ts +160 -4
  46. package/src/services/profile-state.ts +7 -6
  47. package/src/services/tracked-live-files.ts +24 -3
  48. package/src/services/update-engine.ts +28 -10
  49. package/src/services/update-plan.ts +78 -94
  50. package/src/ui/App.tsx +7 -2
  51. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  52. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  53. package/src/ui/hooks/useProfileStartupModal.ts +87 -7
  54. package/src/ui/keymap.ts +1 -1
  55. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  56. package/src/ui/renderers/pluginRowText.ts +6 -2
  57. package/src/ui/renderers/pluginStatusText.ts +4 -3
  58. package/src/ui/screens/PluginsScreen.tsx +28 -41
  59. package/src/cli/tui-prompt.tsx +0 -143
@@ -34,7 +34,11 @@ import type { PluginScope } from "./claude-cli.js";
34
34
  import {
35
35
  type PluginInfo,
36
36
  compareVersions,
37
+ filesMissingScopes,
38
+ highestInstalledVersion,
39
+ installedScopeVersions,
37
40
  isBehind,
41
+ isInstalledAnywhere,
38
42
  isRepairablyBehind,
39
43
  } from "./plugin-manager.js";
40
44
  import {
@@ -48,7 +52,7 @@ import {
48
52
  // ── Plugins ──────────────────────────────────────────────────────────────────
49
53
 
50
54
  export type PluginUpdateAction =
51
- /** Declared by the profile, installed nowhere. */
55
+ /** Declared by the profile and installed nowhere, or installed with its files missing. */
52
56
  | "install"
53
57
  /** Installed at a version other than the one we should be on. */
54
58
  | "update"
@@ -83,8 +87,8 @@ export type PluginUpdateAction =
83
87
  export type PluginReason =
84
88
  /** install: declared by the profile, installed nowhere. */
85
89
  | "absent"
86
- /** install: enabled in a settings file, but no registry row in any scope. */
87
- | "enabled-not-installed"
90
+ /** install: installed at a scope, but no version resolves at any installed scope. */
91
+ | "files-missing"
88
92
  /** unknown: a marketplace we READ does not list it. */
89
93
  | "not-offered"
90
94
  /** unknown: installed, unlisted, and its marketplace contributed no listed ids. */
@@ -127,7 +131,11 @@ export interface PluginUpdateItem {
127
131
  action: PluginUpdateAction;
128
132
  /** Why it is in this state. */
129
133
  reason: PluginReason;
130
- /** Scopes holding an install this action must touch. Empty for a fresh install. */
134
+ /**
135
+ * Scopes holding an install this action must touch — for `files-missing`, the
136
+ * scopes installed with no version. Empty for an `absent` install, which the
137
+ * engine puts at project scope.
138
+ */
131
139
  scopes: PluginScope[];
132
140
  /**
133
141
  * The version installed in each scope BEFORE any work, so an apply can tell
@@ -141,44 +149,12 @@ export interface PluginUpdateItem {
141
149
  note?: string;
142
150
  }
143
151
 
144
- /** Scope → installed version, for the three scopes a PluginInfo carries. */
145
- function scopeVersions(
146
- info: PluginInfo | undefined,
147
- ): Array<[PluginScope, string]> {
148
- if (!info) return [];
149
- const out: Array<[PluginScope, string]> = [];
150
- for (const [scope, status] of [
151
- ["user", info.userScope],
152
- ["project", info.projectScope],
153
- ["local", info.localScope],
154
- ] as const) {
155
- if (status?.version) out.push([scope, status.version]);
156
- }
157
- return out;
158
- }
159
-
160
- /**
161
- * The highest version installed across scopes, or null.
162
- *
163
- * `compareVersions` answers null for an unorderable pair, and null is not
164
- * `>= 0`, so the reduce keeps the right-hand side when two versions cannot be
165
- * ranked. "Highest" is not meaningful across commit ids; picking one
166
- * deterministically is, and every caller only needs *an* installed version.
167
- */
168
- function highestInstalled(info: PluginInfo | undefined): string | null {
169
- const versions = scopeVersions(info).map(([, v]) => v);
170
- if (versions.length === 0) return null;
171
- return versions.reduce((a, b) =>
172
- (compareVersions(a, b) ?? -1) >= 0 ? a : b,
173
- );
174
- }
175
-
176
152
  /**
177
153
  * The highest installed version THE PIN PERMITS — the ceiling a stale-load
178
154
  * repair may aim at. Null when no installed version qualifies.
179
155
  *
180
- * Under "latest" that is simply {@link highestInstalled}: nothing caps it, and
181
- * the newest version on this machine is the one to converge on.
156
+ * Under "latest" that is simply {@link highestInstalledVersion}: nothing caps
157
+ * it, and the newest version on this machine is the one to converge on.
182
158
  *
183
159
  * Under an EXACT pin it is the highest installed version at or below the pin.
184
160
  * `installed` reaches across every scope and `scopesNeeding` never downgrades, so
@@ -196,8 +172,8 @@ function highestPermittedInstalled(
196
172
  info: PluginInfo | undefined,
197
173
  pin: string,
198
174
  ): string | null {
199
- if (pin === "latest") return highestInstalled(info);
200
- const permitted = scopeVersions(info)
175
+ if (pin === "latest") return highestInstalledVersion(info);
176
+ const permitted = installedScopeVersions(info)
201
177
  .map(([, v]) => v)
202
178
  // pin >= v, i.e. v is at or below the pin. Null (unorderable) fails this.
203
179
  .filter((v) => (compareVersions(pin, v) ?? -1) >= 0);
@@ -238,7 +214,7 @@ export function scopesNeeding(
238
214
  // A raw `compareVersions(...) > 0` reported false for that pair and the
239
215
  // scope was silently left alone. An unknown (null) is still left alone —
240
216
  // moving a scope we cannot rank would be a guess.
241
- return scopeVersions(info)
217
+ return installedScopeVersions(info)
242
218
  .filter(([, version]) => isBehind(target, version) === true)
243
219
  .map(([scope]) => scope);
244
220
  }
@@ -255,11 +231,11 @@ function marketplaceSuffix(id: string): string | null {
255
231
  * The catalog, indexed for the questions the classifier asks of it.
256
232
  *
257
233
  * "Listed" is narrower than "present". `getAvailablePlugins` puts ORPHANS —
258
- * installed or enabled, but published by no marketplace we read — into the same
259
- * list with `isOrphaned: true`, so a plain `has(id)` is true for a plugin that
260
- * nothing publishes. Treating an orphan as listed is what let an orphan plan as
261
- * `current`: its `version` is filled in from its own installed version, so the
262
- * compare found them equal.
234
+ * in this project's settings or registry, but published by no marketplace we
235
+ * read — into the same list with `isOrphaned: true`, so a plain `has(id)` is
236
+ * true for a plugin that nothing publishes. Treating an orphan as listed is
237
+ * what let an orphan plan as `current`: its `version` is filled in from its own
238
+ * installed version, so the compare found them equal.
263
239
  */
264
240
  export interface CatalogIndex {
265
241
  get(pluginId: string): PluginInfo | undefined;
@@ -306,15 +282,6 @@ export function indexCatalog(
306
282
  };
307
283
  }
308
284
 
309
- /** True when a settings file enables the plugin in at least one scope. */
310
- function enabledAnywhere(info: PluginInfo | undefined): boolean {
311
- return (
312
- !!info?.userScope?.enabled ||
313
- !!info?.projectScope?.enabled ||
314
- !!info?.localScope?.enabled
315
- );
316
- }
317
-
318
285
  /**
319
286
  * THE plugin classifier. Pure.
320
287
  *
@@ -332,7 +299,7 @@ export function classifyPlugin(
332
299
  ): PluginUpdateItem {
333
300
  const info = index.get(pluginId);
334
301
  const listed = index.isListed(pluginId);
335
- const installed = highestInstalled(info);
302
+ const installed = highestInstalledVersion(info);
336
303
  // An orphan's `version` is its own installed version (or a stale local
337
304
  // clone's), not something a marketplace we read offers — so it is not
338
305
  // "available" from anywhere.
@@ -344,7 +311,7 @@ export function classifyPlugin(
344
311
  installed,
345
312
  loaded: info?.loadedVersion ?? null,
346
313
  available,
347
- beforeVersions: Object.fromEntries(scopeVersions(info)) as Partial<
314
+ beforeVersions: Object.fromEntries(installedScopeVersions(info)) as Partial<
348
315
  Record<PluginScope, string>
349
316
  >,
350
317
  };
@@ -388,24 +355,29 @@ export function classifyPlugin(
388
355
  };
389
356
  }
390
357
 
391
- // Not installed anywhere — the "checked but not installed" case.
358
+ // No installed scope has a version — the "checked but not installed" case.
392
359
  //
393
- // Nothing is installed in any scope here, so "enabled somewhere" is exactly
394
- // "enabled but not installed". It is tested directly rather than through
395
- // `isEnabledButNotInstalled`, which excludes orphans: an orphan that is only
396
- // a settings key would otherwise read as `absent`, and the TUI renders
397
- // `absent` as "the profile declares it", which an undeclared orphan does not.
360
+ // So a scope that IS installed here has its files missing. It is tested
361
+ // directly rather than through `hasMissingFiles`, which excludes orphans: an
362
+ // orphan that is only a settings key would otherwise read as `absent`, and the
363
+ // TUI renders `absent` as "the profile declares it", which an undeclared
364
+ // orphan does not.
365
+ //
366
+ // Files missing carries the scopes to reinstall at: exactly the ones installed
367
+ // with no version. It used to carry none, and the engine read "install" as
368
+ // "project scope" — so a user-scope plugin with its files missing was
369
+ // reinstalled at project scope, turning it on in the team's settings.json.
370
+ // `absent` still carries none: the engine installs it at project scope.
398
371
  if (!installed) {
399
- const brokenEnable = enabledAnywhere(info);
372
+ const missingAt = filesMissingScopes(info);
373
+ const filesMissing = missingAt.length > 0;
400
374
  return {
401
375
  ...base,
402
376
  target: pin === "latest" ? available : pin,
403
377
  action: "install",
404
- reason: brokenEnable ? "enabled-not-installed" : "absent",
405
- scopes: [],
406
- ...(brokenEnable
407
- ? { note: "enabled in settings, but no files on disk" }
408
- : {}),
378
+ reason: filesMissing ? "files-missing" : "absent",
379
+ scopes: missingAt,
380
+ ...(filesMissing ? { note: "files missing" } : {}),
409
381
  };
410
382
  }
411
383
 
@@ -622,7 +594,7 @@ export function classifyPlugin(
622
594
  target: installed,
623
595
  action: "repair",
624
596
  reason: "content-stale",
625
- scopes: scopeVersions(info).map(([scope]) => scope),
597
+ scopes: installedScopeVersions(info).map(([scope]) => scope),
626
598
  note: "files changed without a version bump",
627
599
  };
628
600
  }
@@ -669,17 +641,38 @@ export function classifyPlugin(
669
641
  }
670
642
 
671
643
  /**
672
- * Decide what to do with every plugin the profile declares.
644
+ * THE plugin plan — for `magus update` and the Plugins screen alike.
645
+ *
646
+ * Every plugin the active profile declares, under its pin, and every plugin
647
+ * installed in any scope (user, project, local), under "latest" — the rule
648
+ * {@link pinForRow} states — each classified by {@link classifyPlugin}, sorted.
649
+ *
650
+ * ONE list, read by both surfaces: the screen's rows, header counts and
651
+ * "update all", and the CLI's report and apply. It used to be two — the CLI
652
+ * planned the profile's plugins only, the screen every installed plugin — so a
653
+ * USER-scope plugin the screen showed as "update available" was never seen by
654
+ * `magus update`, which then reported everything up to date. The owner asked
655
+ * for "absolutely the same code" on both sides; this is it.
673
656
  *
674
657
  * @param pinned closure.plugins — pluginId -> exact version | "latest"
675
- * @param catalog getAvailablePlugins() output, keyed by plugin id
658
+ * @param catalog getAvailablePlugins() output — keyed by id, or as a list
676
659
  */
677
660
  export function planPluginUpdates(
678
- pinned: Record<string, string>,
679
- catalog: Map<string, PluginInfo>,
661
+ pinned: Readonly<Record<string, string>>,
662
+ catalog: ReadonlyMap<string, PluginInfo> | readonly PluginInfo[],
680
663
  ): PluginUpdateItem[] {
681
664
  const index = indexCatalog(catalog);
682
- return Object.entries(pinned)
665
+ const plugins =
666
+ catalog instanceof Map
667
+ ? [...catalog.values()]
668
+ : (catalog as readonly PluginInfo[]);
669
+ const pins = new Map(Object.entries(pinned));
670
+ for (const info of plugins) {
671
+ if (pins.has(info.id)) continue;
672
+ const pin = pinForRow(info, pinned);
673
+ if (pin !== null) pins.set(info.id, pin);
674
+ }
675
+ return [...pins]
683
676
  .map(([pluginId, pin]) => classifyPlugin(pluginId, pin, index))
684
677
  .sort((a, b) => a.pluginId.localeCompare(b.pluginId));
685
678
  }
@@ -689,45 +682,36 @@ export function planPluginUpdates(
689
682
  *
690
683
  * 1. The active profile's pin, when it declares the plugin — the SAME value
691
684
  * `magus update` plans with, which is what makes the two agree.
692
- * 2. Otherwise "latest", when the plugin is installed in any scope or enabled
693
- * in one — what the TUI's own update actions have always planned with, so
694
- * the row and the action agree.
685
+ * 2. Otherwise "latest", when the plugin is installed in any scope, files
686
+ * missing included — what the TUI's own update actions have always planned
687
+ * with, so the row and the action agree.
695
688
  * 3. Otherwise null: an undeclared catalog entry the user is only browsing. It
696
689
  * gets no status at all. `install` means "the profile wants it", and an
697
690
  * undeclared plugin carries no such claim.
698
691
  *
699
692
  * A consequence the renderer relies on: reason `absent` can only come from
700
- * rule 1, because rule 2 never assigns a pin to a row that is neither installed
701
- * nor enabled.
693
+ * rule 1, because rule 2 never assigns a pin to a row that is not installed.
702
694
  */
703
695
  export function pinForRow(
704
696
  info: PluginInfo,
705
697
  pins: Readonly<Record<string, string>>,
706
698
  ): string | null {
707
699
  if (Object.hasOwn(pins, info.id)) return pins[info.id] ?? null;
708
- if (scopeVersions(info).length > 0 || enabledAnywhere(info)) return "latest";
700
+ if (isInstalledAnywhere(info)) return "latest";
709
701
  return null;
710
702
  }
711
703
 
712
704
  /**
713
- * One status per row that has a pin. Rows whose {@link pinForRow} is null are
714
- * absent from the map.
715
- *
716
- * Computed once per data/pins change, and the same map feeds every consumer —
717
- * so the row, the detail panel, the header and the scope picker cannot disagree.
705
+ * {@link planPluginUpdates} keyed by plugin id, for the screen's lookups — the
706
+ * SAME plan `magus update` runs, not a second classification. Computed once
707
+ * per data/pins change, and the one map feeds the row, the detail panel, the
708
+ * header and the scope picker, so none of them can disagree.
718
709
  */
719
710
  export function classifyPluginRows(
720
711
  plugins: readonly PluginInfo[],
721
712
  pins: Readonly<Record<string, string>>,
722
713
  ): Map<string, PluginUpdateItem> {
723
- const index = indexCatalog(plugins);
724
- const out = new Map<string, PluginUpdateItem>();
725
- for (const info of plugins) {
726
- const pin = pinForRow(info, pins);
727
- if (pin === null) continue;
728
- out.set(info.id, classifyPlugin(info.id, pin, index));
729
- }
730
- return out;
714
+ return new Map(planPluginUpdates(pins, plugins).map((i) => [i.pluginId, i]));
731
715
  }
732
716
 
733
717
  export interface PluginStateCounts {
package/src/ui/App.tsx CHANGED
@@ -427,8 +427,13 @@ function AppContent({ onExit }: AppContentProps) {
427
427
  // edits into the active profile, regenerate from a changed
428
428
  // profiles.json, restore a lost marker — then the question, if one
429
429
  // is left to ask.
430
- const { need, notices } = await prepareProfileStartup(process.cwd());
431
- if (notices.length > 0) setProfileNotices(notices);
430
+ const { need, notices, problems } = await prepareProfileStartup(
431
+ process.cwd(),
432
+ );
433
+ // A refusal never blocks the TUI at startup: it is shown beside what
434
+ // the gate did, and the user is still asked whatever is left.
435
+ const shown = [...notices, ...problems];
436
+ if (shown.length > 0) setProfileNotices(shown);
432
437
  if (!needsNothing(need)) setProfileNeed(need);
433
438
  } catch {
434
439
  // non-fatal: the Profiles tab still works by hand
@@ -43,7 +43,7 @@ export interface PluginPluginItem {
43
43
  /**
44
44
  * The classifier's answer for this plugin — the same item `magus update`
45
45
  * plans with. Null for a catalog entry the user is only browsing (neither
46
- * declared by the profile, installed, nor enabled): it has no state to claim.
46
+ * declared by the profile nor installed): it has no state to claim.
47
47
  */
48
48
  status: PluginUpdateItem | null;
49
49
  }
@@ -8,23 +8,28 @@ interface ScopeSquaresProps {
8
8
  selected?: boolean;
9
9
  }
10
10
 
11
- /** Installed at that scope. */
11
+ /** On at that scope. */
12
12
  export const SCOPE_ON = "■";
13
- /** Not installed at that scope. */
13
+ /** Not on at that scope. */
14
14
  export const SCOPE_OFF = "□";
15
15
 
16
16
  /**
17
17
  * Scope bar: three positional squares, user / project / local.
18
18
  *
19
19
  * POSITION names the scope — the first square is always user, the second
20
- * project, the third local — and SHAPE says the state: `■` installed, `□` not.
21
- * That reads with no colour at all, which the bar never used to: every square
20
+ * project, the third local — and SHAPE says the state: `■` on, `□` not. "On"
21
+ * is the caller's to decide, and for a plugin and a skill alike it is
22
+ * installed. For a plugin that is `isInstalledInScope` — the scope's settings
23
+ * file turns it on, which is what Claude Code loads by — the same function the
24
+ * detail panel's dot reads, so the two cannot disagree.
25
+ *
26
+ * The shape reads with no colour at all, which the bar never used to: every square
22
27
  * was the same filled block, lit in a scope hue or unlit in a fill near the
23
28
  * page, so on a monochrome terminal, to a colour-blind reader, or on a theme
24
29
  * that drew two hues alike, all three states were one shape.
25
30
  *
26
31
  * Colour now only repeats what the shape says. A filled square wears `success`
27
- * (installed is what it means, on every screen); a hollow one wears `muted`,
32
+ * (on is what it means, on every screen); a hollow one wears `muted`,
28
33
  * which a thin outline needs to stay visible — the shape, not a pale fill, is
29
34
  * what makes it recede. On a selected row the selection owns the colour
30
35
  * channel: filled squares take its ink, hollow ones its dim ink.
@@ -25,10 +25,13 @@ import {
25
25
  import type { DisplacedArtifact } from "../../services/profile-live.js";
26
26
  import {
27
27
  type ProfileStartupNeed,
28
+ UPDATE_FORMAT_VALUE,
28
29
  buildProfileOptions,
29
30
  defaultOptionIndex,
30
31
  describeNeed,
32
+ formatUpdateOutcome,
31
33
  parseProfileChoice,
34
+ prepareProfileStartup,
32
35
  unparseableNeed,
33
36
  } from "../../services/profile-startup.js";
34
37
  import { applyProfileChoice } from "../../services/profile-state.js";
@@ -39,6 +42,8 @@ import type { ModalState } from "../state/types.js";
39
42
  export interface BuildProfileModalArgs {
40
43
  need: ProfileStartupNeed;
41
44
  onChoose: (choice: ProfileChoice) => void;
45
+ /** "Update it now" on the `old-format` question — not a profile choice. */
46
+ onUpdateFormat?: () => void;
42
47
  /**
43
48
  * Leave magus. NOT "dismiss" — there is deliberately no way to carry on
44
49
  * without a profile, because there is nothing to carry on with.
@@ -49,7 +54,7 @@ export interface BuildProfileModalArgs {
49
54
  export function buildProfileStartupModal(
50
55
  args: BuildProfileModalArgs,
51
56
  ): ModalState {
52
- const { need, onChoose, onQuit } = args;
57
+ const { need, onChoose, onUpdateFormat, onQuit } = args;
53
58
  const { title, message } = describeNeed(need);
54
59
  return {
55
60
  type: "select",
@@ -60,6 +65,10 @@ export function buildProfileStartupModal(
60
65
  // developer who has local work and therefore still gets asked.
61
66
  defaultIndex: defaultOptionIndex(need),
62
67
  onSelect: (value: string) => {
68
+ if (value === UPDATE_FORMAT_VALUE && onUpdateFormat) {
69
+ onUpdateFormat();
70
+ return;
71
+ }
63
72
  const choice = parseProfileChoice(value, need);
64
73
  if (choice) onChoose(choice);
65
74
  else onQuit();
@@ -168,8 +177,16 @@ export function useProfileStartupModal(
168
177
 
169
178
  const show = useCallback(
170
179
  (need: ProfileStartupNeed) => {
171
- /** Apply a choice, reporting the outcome either way. */
172
- const apply = (choice: ProfileChoice, automatic: boolean): void => {
180
+ /**
181
+ * Apply a choice, reporting the outcome either way. `current` is the
182
+ * question it answers — after "Update it now" that is a NEW question,
183
+ * whose manifest is the converted one, not the one magus opened with.
184
+ */
185
+ const apply = (
186
+ choice: ProfileChoice,
187
+ automatic: boolean,
188
+ current: ProfileStartupNeed = need,
189
+ ): void => {
173
190
  dispatch({
174
191
  type: "SHOW_MODAL",
175
192
  modal: {
@@ -191,7 +208,7 @@ export function useProfileStartupModal(
191
208
  // where they left it. `.magus-backups/` is gitignored, so git status
192
209
  // showed the deletion and no sign of the copy.
193
210
  const moved: DisplacedArtifact[] = [];
194
- void applyProfileChoice(choice, need.manifest, projectPath, {
211
+ void applyProfileChoice(choice, current.manifest, projectPath, {
195
212
  onDisplace: (artifact) => moved.push(artifact),
196
213
  })
197
214
  .then((result: ActivationResult) => {
@@ -228,7 +245,7 @@ export function useProfileStartupModal(
228
245
  error instanceof SettingsParseError &&
229
246
  error.path === getManifestPath(projectPath)
230
247
  ? unparseableNeed(error)
231
- : need;
248
+ : current;
232
249
  dispatch({
233
250
  type: "SHOW_MODAL",
234
251
  modal: {
@@ -241,7 +258,7 @@ export function useProfileStartupModal(
241
258
  error,
242
259
  moved,
243
260
  projectPath,
244
- need.declaredDefault,
261
+ current.declaredDefault,
245
262
  ),
246
263
  variant: "error",
247
264
  // A failure leaves the project without an active profile, so
@@ -254,12 +271,75 @@ export function useProfileStartupModal(
254
271
  });
255
272
  };
256
273
 
274
+ /**
275
+ * "Update it now" on an old profile format: update it — the same gate
276
+ * the CLI runs after its own question — then ask whatever is left, or
277
+ * say it is done.
278
+ */
279
+ const updateFormat = (): void => {
280
+ dispatch({
281
+ type: "SHOW_MODAL",
282
+ modal: { type: "loading", message: "Updating the profile format…" },
283
+ });
284
+ void prepareProfileStartup(projectPath, { convert: true })
285
+ .then((result) => {
286
+ const outcome = formatUpdateOutcome(result);
287
+ if (outcome.kind === "ask") {
288
+ onActivated?.();
289
+ ask(outcome.need);
290
+ return;
291
+ }
292
+ if (outcome.kind === "failed") {
293
+ // Refused, so still the old format: back to the question,
294
+ // as the CLI stops on the same lines.
295
+ dispatch({
296
+ type: "SHOW_MODAL",
297
+ modal: {
298
+ type: "message",
299
+ title: outcome.title,
300
+ message: outcome.message,
301
+ variant: "error",
302
+ onDismiss: () => ask(need),
303
+ },
304
+ });
305
+ return;
306
+ }
307
+ onActivated?.();
308
+ dispatch({
309
+ type: "SHOW_MODAL",
310
+ modal: {
311
+ type: "message",
312
+ title: outcome.title,
313
+ message: outcome.message,
314
+ variant: "success",
315
+ onDismiss: () => {
316
+ dispatch({ type: "HIDE_MODAL" });
317
+ navigateToScreen("profiles");
318
+ },
319
+ },
320
+ });
321
+ })
322
+ .catch((error: unknown) => {
323
+ dispatch({
324
+ type: "SHOW_MODAL",
325
+ modal: {
326
+ type: "message",
327
+ title: "Could not update the profile format",
328
+ message: `${error instanceof Error ? error.message : String(error)}\n\nYour profiles are kept. Open magus again to retry.`,
329
+ variant: "error",
330
+ onDismiss: () => ask(need),
331
+ },
332
+ });
333
+ });
334
+ };
335
+
257
336
  function ask(current: ProfileStartupNeed = need): void {
258
337
  dispatch({
259
338
  type: "SHOW_MODAL",
260
339
  modal: buildProfileStartupModal({
261
340
  need: current,
262
- onChoose: (choice) => apply(choice, false),
341
+ onChoose: (choice) => apply(choice, false, current),
342
+ onUpdateFormat: updateFormat,
263
343
  onQuit,
264
344
  }),
265
345
  });
package/src/ui/keymap.ts CHANGED
@@ -156,7 +156,7 @@ const PLUGINS = [
156
156
  "save-profile",
157
157
  ["s"],
158
158
  "profile",
159
- "Save the enabled plugins as a profile",
159
+ "Save the installed plugins as a profile",
160
160
  60,
161
161
  ),
162
162
  bind(