magus-cli 7.5.3 → 7.6.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 (68) 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__/key-focus-guard.test.tsx +260 -0
  13. package/src/__tests__/mate-availability.test.ts +11 -13
  14. package/src/__tests__/plugin-contents.test.ts +0 -1
  15. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  16. package/src/__tests__/plugin-row-text.test.ts +18 -21
  17. package/src/__tests__/profile-command.test.ts +2 -2
  18. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  19. package/src/__tests__/profile-name-on-save.test.ts +443 -0
  20. package/src/__tests__/profile-skills.test.ts +3 -3
  21. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  22. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  23. package/src/__tests__/profile-startup.test.ts +1 -1
  24. package/src/__tests__/profile-state.test.ts +27 -22
  25. package/src/__tests__/prompt-eof.test.ts +245 -0
  26. package/src/__tests__/scope-action.test.ts +35 -38
  27. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  28. package/src/__tests__/select-restore.test.ts +209 -0
  29. package/src/__tests__/stale-load-repair.test.ts +1 -2
  30. package/src/__tests__/update-apply.test.ts +18 -2
  31. package/src/__tests__/update-null-readback.test.ts +300 -0
  32. package/src/__tests__/update-plan-parity.test.ts +249 -0
  33. package/src/__tests__/update-plan.test.ts +66 -67
  34. package/src/__tests__/version-unknown-state.test.ts +38 -39
  35. package/src/cli/bootstrap.ts +271 -66
  36. package/src/cli/doctor.ts +4 -3
  37. package/src/cli/install.ts +83 -27
  38. package/src/cli/prompt.ts +19 -5
  39. package/src/cli/router.ts +1 -0
  40. package/src/cli/select.ts +168 -0
  41. package/src/cli/update-view.ts +120 -6
  42. package/src/cli/update.ts +6 -4
  43. package/src/services/claude-settings.ts +28 -0
  44. package/src/services/manifest.ts +31 -0
  45. package/src/services/mate-availability.ts +23 -29
  46. package/src/services/plugin-manager.ts +247 -105
  47. package/src/services/profile-absorb.ts +10 -7
  48. package/src/services/profile-activate.ts +41 -19
  49. package/src/services/profile-adopt.ts +9 -2
  50. package/src/services/profile-startup.ts +206 -5
  51. package/src/services/profile-state.ts +8 -7
  52. package/src/services/tracked-live-files.ts +24 -3
  53. package/src/services/update-engine.ts +28 -10
  54. package/src/services/update-plan.ts +78 -94
  55. package/src/ui/App.tsx +96 -56
  56. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  57. package/src/ui/components/modals/ModalContainer.tsx +3 -0
  58. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  59. package/src/ui/hooks/useGitignoreModal.ts +21 -3
  60. package/src/ui/hooks/useProfileStartupModal.ts +214 -15
  61. package/src/ui/hooks/useStaleLoadModal.ts +5 -1
  62. package/src/ui/input/KeyRouter.tsx +43 -2
  63. package/src/ui/keymap.ts +1 -1
  64. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  65. package/src/ui/renderers/pluginRowText.ts +6 -2
  66. package/src/ui/renderers/pluginStatusText.ts +4 -3
  67. package/src/ui/screens/PluginsScreen.tsx +28 -41
  68. package/src/cli/tui-prompt.tsx +0 -143
@@ -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
- }