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
@@ -2,8 +2,11 @@ import type React from "react";
2
2
  import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
3
3
  import {
4
4
  compareVersions,
5
- isEnabledButNotInstalled,
5
+ hasMissingFiles,
6
+ isInstalledAnywhere,
6
7
  isInstalledInScope,
8
+ scopeFilesMissing,
9
+ versionInScope,
7
10
  } from "../../services/plugin-manager.js";
8
11
  import type { ScopeStatus } from "../../services/plugin-manager.js";
9
12
  import type { PluginComponentKind } from "../../services/skills-manager.js";
@@ -195,9 +198,12 @@ function pluginRow(
195
198
  isSelected: boolean,
196
199
  ): React.ReactNode {
197
200
  const { plugin } = item;
198
- const hasUser = !!plugin.userScope?.enabled;
199
- const hasProject = !!plugin.projectScope?.enabled;
200
- const hasLocal = !!plugin.localScope?.enabled;
201
+ // The squares and the name colour read the one installed-at-scope test, the
202
+ // same one the detail panel's dots read, so the row and the panel beside it
203
+ // cannot show two different states.
204
+ const hasUser = isInstalledInScope(plugin.userScope);
205
+ const hasProject = isInstalledInScope(plugin.projectScope);
206
+ const hasLocal = isInstalledInScope(plugin.localScope);
201
207
  const hasAnyScope = hasUser || hasProject || hasLocal;
202
208
 
203
209
  // Everything after the name — version, history badge, status — comes from
@@ -257,24 +263,13 @@ function pluginRow(
257
263
  );
258
264
  }
259
265
 
260
- const installedSomewhere =
261
- isInstalledInScope(plugin.userScope) ||
262
- isInstalledInScope(plugin.projectScope) ||
263
- isInstalledInScope(plugin.localScope);
264
-
265
266
  return (
266
267
  <text fg={theme.colors.text}>
267
268
  <span> </span>
268
269
  <ScopeSquares user={hasUser} project={hasProject} local={hasLocal} />
269
270
  <span> </span>
270
271
  <span fg={theme.colors.dim}>{arrow}</span>
271
- <span
272
- fg={
273
- hasAnyScope && installedSomewhere
274
- ? theme.colors.text
275
- : theme.colors.muted
276
- }
277
- >
272
+ <span fg={hasAnyScope ? theme.colors.text : theme.colors.muted}>
278
273
  {displayName}
279
274
  </span>
280
275
  <RowSegments segments={segments} selected={false} />
@@ -344,14 +339,10 @@ function pluginDetail(
344
339
  fetched?: PluginContentsEntry,
345
340
  ): React.ReactNode {
346
341
  const { plugin, status } = item;
347
- // "Installed" has to mean the same thing here as in the list row. Reading the
348
- // `enabledPlugins` flag alone made this panel print "● Installed" for the
349
- // exact plugin the row beside it was labelling "not installed".
350
- const isInstalled =
351
- isInstalledInScope(plugin.userScope) ||
352
- isInstalledInScope(plugin.projectScope) ||
353
- isInstalledInScope(plugin.localScope);
354
- const brokenInstall = isEnabledButNotInstalled(plugin);
342
+ // "Installed" means the same thing here as in the list row: the one
343
+ // installed-at-scope test, at any scope.
344
+ const isInstalled = isInstalledAnywhere(plugin);
345
+ const filesMissing = hasMissingFiles(plugin);
355
346
  // The ⬆ marker and the U hint claim that pressing U does something. That is
356
347
  // exactly the classifier's update/repair, and nothing else.
357
348
  const needsWork = status?.action === "update" || status?.action === "repair";
@@ -453,25 +444,25 @@ function pluginDetail(
453
444
  <box marginTop={1}>
454
445
  <text
455
446
  fg={
456
- isInstalled
457
- ? theme.colors.success
458
- : brokenInstall
459
- ? theme.colors.danger
447
+ filesMissing
448
+ ? theme.colors.danger
449
+ : isInstalled
450
+ ? theme.colors.success
460
451
  : theme.colors.muted
461
452
  }
462
453
  >
463
- {isInstalled
464
- ? "● Installed"
465
- : brokenInstall
466
- ? "○ Not installed — enabled in settings, no files on disk"
454
+ {filesMissing
455
+ ? "● Installed — files missing"
456
+ : isInstalled
457
+ ? "● Installed"
467
458
  : "○ Not installed"}
468
459
  </text>
469
460
  </box>
470
- {brokenInstall ? (
461
+ {filesMissing ? (
471
462
  <box>
472
463
  <text fg={theme.colors.muted}>
473
464
  Claude Code cannot load it in this state. Press the scope key to
474
- repair.
465
+ reinstall.
475
466
  </text>
476
467
  </box>
477
468
  ) : null}
@@ -631,10 +622,15 @@ function pluginDetail(
631
622
  /**
632
623
  * One line of the detail panel's scope breakdown.
633
624
  *
634
- * The filled dot means installed — registry-backed, same as everywhere else.
635
- * A scope that is enabled with nothing installed is called out explicitly
636
- * rather than shown as a plain empty dot, because those two states need
637
- * different actions from the user and used to look identical.
625
+ * The filled dot means what the list row's square means — installed at this
626
+ * scope — because both read `isInstalledInScope`. The version shows only where
627
+ * the plugin is installed: a registry row can be inherited from another
628
+ * directory of the same repository, and magus run in `tools/magus` once showed
629
+ * "Project team v0.8.1" for a plugin that directory's settings never turn on and
630
+ * Claude Code started there never loads.
631
+ *
632
+ * Installed with no version is named, in the danger colour, because it needs an
633
+ * action — the scope key reinstalls — and a plain dot would read as healthy.
638
634
  */
639
635
  function ScopeLine({
640
636
  keyHint,
@@ -647,18 +643,16 @@ function ScopeLine({
647
643
  qualifier: string;
648
644
  scope?: ScopeStatus;
649
645
  }): React.ReactNode {
650
- const installed = isInstalledInScope(scope);
646
+ const version = versionInScope(scope);
651
647
  return (
652
648
  <text fg={theme.colors.text}>
653
649
  <ScopeKeyChip scopeKey={keyHint} />
654
- <ScopeDot installed={installed} />
650
+ <ScopeDot installed={isInstalledInScope(scope)} />
655
651
  <span>{name}</span>
656
652
  <span fg={theme.colors.muted}> {qualifier}</span>
657
- {scope?.version ? (
658
- <span fg={theme.colors.info}> v{scope.version}</span>
659
- ) : null}
660
- {!installed && scope?.enabled ? (
661
- <span fg={theme.colors.danger}> enabled, not installed</span>
653
+ {version ? <span fg={theme.colors.info}> v{version}</span> : null}
654
+ {scopeFilesMissing(scope) ? (
655
+ <span fg={theme.colors.danger}> files missing</span>
662
656
  ) : null}
663
657
  </text>
664
658
  );
@@ -38,8 +38,12 @@ export function pluginRowText(item: PluginPluginItem): PluginRowText {
38
38
  // A version is shown only when it carries information. "0.0.0" means
39
39
  // installed with an unknown version (Anthropic's official plugins record
40
40
  // exactly that) — it is never printed, and nothing below depends on it.
41
- const installed = status?.installed ?? plugin.installedVersion;
42
- const shown = isKnownVersion(installed ?? undefined) ? installed : undefined;
41
+ //
42
+ // The classifier's version and nothing else. A fallback to
43
+ // `plugin.installedVersion` printed a version where the classifier had none —
44
+ // "v0.8.1 files missing", with 0.8.1 read off a scope not installed here.
45
+ const installed = status?.installed ?? undefined;
46
+ const shown = isKnownVersion(installed) ? installed : undefined;
43
47
 
44
48
  // "What just changed" and "what state it is in now" are independent facts
45
49
  // and both can be true at once: an external update can move a plugin
@@ -57,13 +57,14 @@ export function pluginStatusText(
57
57
  return { suffix: " BROKEN — press a to fix", tone: "danger" };
58
58
  case "install":
59
59
  // `absent` only ever comes from a profile pin (see `pinForRow`), so it
60
- // may say so. An undeclared row that is merely enabled must not.
60
+ // may say so. An undeclared row whose files are missing must not.
61
61
  return status.reason === "absent"
62
62
  ? { suffix: " not installed — profile declares it", tone: "warning" }
63
- : // Enabled with nothing on disk is a broken state; a missing
63
+ : // Files missing is installed, and broken — never "not installed",
64
+ // which would contradict the filled square beside it. A missing
64
65
  // declared plugin is routine work. Red against amber is the
65
66
  // distinction that matters.
66
- { suffix: ` not installed${withNote}`, tone: "danger" };
67
+ { suffix: ` ${status.note ?? "files missing"}`, tone: "danger" };
67
68
  case "current":
68
69
  return { suffix: withNote, tone: "muted" };
69
70
  }
@@ -26,9 +26,12 @@ import {
26
26
  getAvailablePlugins,
27
27
  getLocalMarketplacesInfo,
28
28
  getMarketplaceFetchFailures,
29
+ isInstalledAnywhere,
29
30
  isInstalledInScope,
30
31
  refreshAllMarketplaces,
31
32
  resolveScopeAction,
33
+ scopeFilesMissing,
34
+ versionInScope,
32
35
  } from "../../services/plugin-manager.js";
33
36
  import {
34
37
  getPluginEnvRequirements,
@@ -871,17 +874,16 @@ export function PluginsScreen() {
871
874
  scope: ScopeStatus | undefined,
872
875
  desc: string,
873
876
  ) => {
874
- const installed = isInstalledInScope(scope);
875
- const ver = scope?.version;
876
- let label = installed ? `● ${name}` : `○ ${name}`;
877
+ const ver = versionInScope(scope);
878
+ let label = isInstalledInScope(scope) ? `● ${name}` : `○ ${name}`;
877
879
  label += ` (${desc})`;
878
880
  if (ver) label += ` v${ver}`;
879
881
  if (scopeIsBehind(status, scopeKey) && status?.target)
880
882
  label += ` → v${status.target}`;
881
- // Enabled in settings with nothing installed. Without saying so the
882
- // row reads exactly like a scope that was never enabled, and the
883
- // user cannot tell that picking it repairs rather than adds.
884
- if (!installed && scope?.enabled) label += " — not installed, repairs";
883
+ // Installed with no version. Without saying so the row reads like a
884
+ // healthy install, and the user cannot tell that picking it
885
+ // reinstalls rather than uninstalls.
886
+ if (scopeFilesMissing(scope)) label += " — files missing, reinstalls";
885
887
  return label;
886
888
  };
887
889
 
@@ -930,9 +932,9 @@ export function PluginsScreen() {
930
932
  ? "Project"
931
933
  : "Local";
932
934
 
933
- // Decided from the registry-backed version, not the `enabledPlugins`
934
- // flag alone — see resolveScopeAction. Update-versus-uninstall is the
935
- // same `scopeIsBehind` the label above used.
935
+ // Files missing reinstalls rather than uninstalls — see
936
+ // resolveScopeAction. Update-versus-uninstall is the same
937
+ // `scopeIsBehind` the label above used.
936
938
  const action = resolveScopeAction(
937
939
  selectedScope,
938
940
  scopeIsBehind(status, scopeValue as PluginScope),
@@ -1060,10 +1062,12 @@ export function PluginsScreen() {
1060
1062
  };
1061
1063
 
1062
1064
  /**
1063
- * "Update all" — the SAME planner and the SAME apply engine `magus
1064
- * update` runs. Only the selection differs, and only because a screen and a
1065
- * profile genuinely mean different sets: here it is "everything listed with
1066
- * an update", there it is "what the profile declares".
1065
+ * "Update all" — the SAME plan, the SAME selection and the SAME apply engine
1066
+ * `magus update` runs: every item of {@link planPluginUpdates} handed to
1067
+ * `applyPlugins`, which does the work each one needs (advance, repair,
1068
+ * reinstall missing files) and skips the rest. There used to be a different
1069
+ * selection here — "rows that say update" against the CLI's "what the profile
1070
+ * declares" — and the two surfaces disagreed about the same machine.
1067
1071
  *
1068
1072
  * This used to be a hand-rolled loop, and it had drifted from the CLI in
1069
1073
  * three ways that were bugs, not choices — it touched one scope derived from
@@ -1074,20 +1078,11 @@ export function PluginsScreen() {
1074
1078
  const handleUpdateAll = async () => {
1075
1079
  if (pluginsState.plugins.status !== "success") return;
1076
1080
 
1077
- // Exactly the rows that say "update", "stale — reinstall" or "BROKEN — press
1078
- // a to fix", each planned under its real pin. Content-stale plugins are
1079
- // included: they need repairing, and excluding them meant "update all" left
1080
- // the machine with silently outdated files. Broken (`stale-load`) rows are
1081
- // included because the row itself now names this key — leaving them out
1082
- // would make that hint a lie. `install` stays out: a plugin nothing has
1083
- // installed is `s`'s business, per the note above.
1081
+ // Every item that needs work, by the same `pluginNeedsWork` the CLI's
1082
+ // apply uses: updates, content repairs, broken loads, missing files, and a
1083
+ // profile plugin not installed yet — exactly what `magus update` would do.
1084
1084
  const items = [...statuses.values()]
1085
- .filter(
1086
- (s) =>
1087
- s.action === "update" ||
1088
- s.action === "repair" ||
1089
- s.action === "stale-load",
1090
- )
1085
+ .filter(pluginNeedsWork)
1091
1086
  .sort((a, b) => a.pluginId.localeCompare(b.pluginId));
1092
1087
  if (items.length === 0) return;
1093
1088
 
@@ -1155,12 +1150,10 @@ export function PluginsScreen() {
1155
1150
  ? plugin.projectScope
1156
1151
  : plugin.localScope;
1157
1152
 
1158
- // This used to key off `scopeData.enabled` alone, so a plugin the list
1159
- // had just labelled "not installed" answered its own scope key by
1160
- // uninstalling itself — the `enabledPlugins` flag was set, the registry
1161
- // entry was missing, and only the flag was consulted. Update-versus-
1162
- // uninstall is the classifier's `scopeIsBehind`, the same rule the Enter
1163
- // scope picker labels with.
1153
+ // A scope whose files are missing answers its key with a reinstall. It once
1154
+ // answered with an uninstall, removing the plugin the row was reporting as
1155
+ // broken. Update-versus-uninstall is the classifier's `scopeIsBehind`, the
1156
+ // same rule the Enter scope picker labels with.
1164
1157
  const action = resolveScopeAction(
1165
1158
  scopeData,
1166
1159
  scopeIsBehind(statuses.get(plugin.id), scope),
@@ -1355,14 +1348,8 @@ export function PluginsScreen() {
1355
1348
 
1356
1349
  const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
1357
1350
  const plugins: PluginInfo[] = shownPlugins ?? [];
1358
- // "installed" must mean the same thing here as in the rows beside it. This
1359
- // counted `p.enabled` — the settings flag — so the header read "11 installed"
1360
- // on a screen whose every row said "not installed". Same predicate as the
1361
- // row and the detail panel now.
1362
- const isInstalledAnywhere = (p: PluginInfo) =>
1363
- isInstalledInScope(p.userScope) ||
1364
- isInstalledInScope(p.projectScope) ||
1365
- isInstalledInScope(p.localScope);
1351
+ // "installed" means the same thing here as in the rows beside it: the one
1352
+ // installed-at-scope test the row and the detail panel read.
1366
1353
  const installedCount = plugins.filter(isInstalledAnywhere).length;
1367
1354
  // Every state count is the classifier's, over the same map the rows read,
1368
1355
  // so a header can never count a row the list beside it does not show.
@@ -1,143 +0,0 @@
1
- /**
2
- * One OpenTUI dialog, for a command that is otherwise not a TUI.
3
- *
4
- * `magus update` prints a report and exits; it is not the interactive app. But
5
- * a question it must ask deserves the same dialog the TUI asks with, so this
6
- * mounts the REAL `SelectModal` — the component tab 5 and tab 0 use — for the
7
- * length of one question, then tears the renderer down and lets the command
8
- * carry on printing. Nothing here re-draws a list, a border or a highlight: the
9
- * component and the theme own all of that, and a second implementation is how
10
- * the two surfaces drift apart.
11
- *
12
- * Every import of `@opentui/*` and `ui/` is DYNAMIC. `main.tsx` splits the TUI
13
- * out precisely so `magus hook …`, which Claude Code runs on every matching
14
- * tool call, never pays to load the renderer — a static import here would undo
15
- * that for every command that imports this module's file, whether or not it
16
- * ever asks a question.
17
- *
18
- * `screenMode: "main-screen"` keeps the dialog in the scrollback beside the
19
- * report. The alternate screen would take the terminal over and then hand back
20
- * a screen with no trace the question was ever asked.
21
- */
22
-
23
- import type { SelectOption } from "../ui/state/types.js";
24
-
25
- export interface SelectDialogInput {
26
- title: string;
27
- message: string;
28
- options: SelectOption[];
29
- /** Row to start on. Defaults to the first. */
30
- defaultIndex?: number;
31
- }
32
-
33
- /**
34
- * Ask the user to pick one option. Resolves to the chosen value, or null when
35
- * they cancel with Esc or Ctrl+C — the caller decides whether that is fatal.
36
- *
37
- * Falls back to `null` when stdin or stdout is not a TTY. A renderer needs both:
38
- * `magus update > file` has a TTY stdin and a pipe for stdout, and mounting
39
- * there writes escape sequences into the file.
40
- */
41
- export async function selectDialog(
42
- input: SelectDialogInput,
43
- ): Promise<string | null> {
44
- if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
45
- return null;
46
- }
47
-
48
- const [{ createCliRenderer }, { createRoot }, React, modals, dimensions] =
49
- await Promise.all([
50
- import("@opentui/core"),
51
- import("@opentui/react"),
52
- import("react"),
53
- import("../ui/components/modals/index.js"),
54
- import("../ui/state/DimensionsContext.js"),
55
- ]);
56
-
57
- const renderer = await createCliRenderer({
58
- screenMode: "main-screen",
59
- // BOTH, so the renderer registers no handler of its own and teardown
60
- // below is the only path. With either left at its default, a Ctrl+C
61
- // destroys the renderer underneath this promise and it never settles.
62
- exitSignals: [],
63
- exitOnCtrlC: false,
64
- });
65
-
66
- const root = createRoot(renderer);
67
-
68
- return await new Promise<string | null>((resolve) => {
69
- // Clamped, not trusted: a caller computing an index from a list it built
70
- // separately could hand over one that is out of range, and a cursor
71
- // pointing past the end makes Enter resolve to null.
72
- let index = Math.min(
73
- Math.max(0, input.defaultIndex ?? 0),
74
- Math.max(0, input.options.length - 1),
75
- );
76
- let done = false;
77
-
78
- const finish = (value: string | null): void => {
79
- if (done) return;
80
- done = true;
81
- renderer.keyInput.off("keypress", onKey);
82
- root.unmount();
83
- renderer.destroy();
84
- resolve(value);
85
- };
86
-
87
- function onKey(key: {
88
- name?: string;
89
- ctrl?: boolean;
90
- sequence?: string;
91
- }): void {
92
- if ((key.ctrl && key.name === "c") || key.name === "escape") {
93
- finish(null);
94
- return;
95
- }
96
- if (key.name === "return" || key.name === "enter") {
97
- finish(input.options[index]?.value ?? null);
98
- return;
99
- }
100
- if (key.name === "up" || key.name === "k") {
101
- index = (index - 1 + input.options.length) % input.options.length;
102
- draw();
103
- return;
104
- }
105
- if (key.name === "down" || key.name === "j") {
106
- index = (index + 1) % input.options.length;
107
- draw();
108
- return;
109
- }
110
- // The numbers stay live: the list is short, and the plain-text prompt
111
- // this replaced numbered it, so the habit already exists.
112
- const digit = Number(key.sequence);
113
- if (
114
- Number.isInteger(digit) &&
115
- digit >= 1 &&
116
- digit <= input.options.length
117
- ) {
118
- index = digit - 1;
119
- finish(input.options[index]?.value ?? null);
120
- }
121
- }
122
-
123
- const draw = (): void => {
124
- root.render(
125
- React.createElement(
126
- dimensions.DimensionsProvider,
127
- null,
128
- React.createElement(modals.SelectModal, {
129
- title: input.title,
130
- message: input.message,
131
- options: input.options,
132
- defaultIndex: index,
133
- onSelect: finish,
134
- onCancel: () => finish(null),
135
- }),
136
- ),
137
- );
138
- };
139
-
140
- renderer.keyInput.on("keypress", onKey);
141
- draw();
142
- });
143
- }