magus-cli 7.3.0 → 7.4.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 (130) hide show
  1. package/package.json +4 -4
  2. package/scripts/build-binaries.ts +4 -2
  3. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  4. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  5. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  6. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  7. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  8. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  9. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  10. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  11. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  12. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  15. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  16. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  17. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  18. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  19. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  20. package/src/__tests__/blind/child-run.ts +114 -0
  21. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  22. package/src/__tests__/component-badge.test.ts +20 -36
  23. package/src/__tests__/footer-hints.test.ts +1 -1
  24. package/src/__tests__/manifest.test.ts +25 -17
  25. package/src/__tests__/marketplace-badge.test.ts +4 -4
  26. package/src/__tests__/models-screen-state.test.ts +36 -13
  27. package/src/__tests__/npm-package-name-api.test.ts +111 -0
  28. package/src/__tests__/npm-package-name.test.ts +280 -0
  29. package/src/__tests__/scope-squares.test.tsx +28 -114
  30. package/src/__tests__/shutdown.test.ts +578 -0
  31. package/src/__tests__/styles-manager.test.ts +4 -4
  32. package/src/__tests__/styles-screen-state.test.ts +26 -12
  33. package/src/__tests__/tabbar-layout.test.ts +44 -32
  34. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  35. package/src/__tests__/tool-probe.test.ts +47 -22
  36. package/src/__tests__/upgrade-spawn.test.ts +355 -0
  37. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  38. package/src/cli/ansi.ts +2 -2
  39. package/src/cli/bootstrap.ts +33 -2
  40. package/src/cli/doctor.ts +105 -14
  41. package/src/cli/install.ts +8 -2
  42. package/src/cli/profile.ts +18 -1
  43. package/src/cli/router.ts +31 -18
  44. package/src/cli/update-view.ts +1 -1
  45. package/src/cli/upgrade.ts +37 -9
  46. package/src/data/cli-tools.ts +13 -2
  47. package/src/data/npm-package.ts +15 -0
  48. package/src/services/claude-settings.ts +151 -73
  49. package/src/services/cli-tool-commands.ts +15 -5
  50. package/src/services/cli-tool-version.ts +101 -0
  51. package/src/services/hook-registration.ts +23 -6
  52. package/src/services/manifest.ts +117 -52
  53. package/src/services/plugin-manager.ts +19 -5
  54. package/src/services/profile-adopt.ts +7 -2
  55. package/src/services/profile-startup.ts +87 -7
  56. package/src/services/profiles.ts +36 -23
  57. package/src/services/settings-file.ts +158 -4
  58. package/src/services/settings-manager.ts +19 -5
  59. package/src/services/skills-manager.ts +46 -0
  60. package/src/services/tool-probe.ts +272 -45
  61. package/src/services/toolchain.ts +39 -0
  62. package/src/services/update-engine.ts +5 -0
  63. package/src/services/update-plan.ts +9 -0
  64. package/src/services/version-check.ts +20 -10
  65. package/src/tui.tsx +48 -21
  66. package/src/ui/App.tsx +58 -197
  67. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  68. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  69. package/src/ui/adapters/settingsAdapter.ts +0 -21
  70. package/src/ui/adapters/skillsAdapter.ts +27 -8
  71. package/src/ui/adapters/stylesAdapter.ts +15 -11
  72. package/src/ui/components/CategoryHeader.tsx +7 -1
  73. package/src/ui/components/EmptyFilterState.tsx +2 -2
  74. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  75. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  76. package/src/ui/components/TabBar.tsx +93 -21
  77. package/src/ui/components/layout/FooterHints.tsx +6 -27
  78. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  79. package/src/ui/components/layout/index.ts +1 -5
  80. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  81. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  82. package/src/ui/components/modals/MessageModal.tsx +3 -15
  83. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  84. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  85. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  86. package/src/ui/components/primitives/MetaText.tsx +1 -1
  87. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  88. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  89. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  90. package/src/ui/components/primitives/index.ts +4 -3
  91. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  92. package/src/ui/input/KeyRouter.tsx +180 -0
  93. package/src/ui/input/key-dispatch.ts +149 -0
  94. package/src/ui/keymap.ts +898 -0
  95. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  96. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  97. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  98. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  99. package/src/ui/renderers/modelRenderers.tsx +12 -13
  100. package/src/ui/renderers/modelVisuals.tsx +0 -2
  101. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  102. package/src/ui/renderers/profileRenderers.tsx +32 -37
  103. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  104. package/src/ui/renderers/skillRenderers.tsx +237 -190
  105. package/src/ui/renderers/styleRenderers.tsx +13 -2
  106. package/src/ui/runtime/shutdown.ts +268 -0
  107. package/src/ui/screens/AliasScreen.tsx +118 -100
  108. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  109. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  110. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  111. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  112. package/src/ui/screens/McpScreen.tsx +47 -33
  113. package/src/ui/screens/ModelsScreen.tsx +59 -104
  114. package/src/ui/screens/PluginsScreen.tsx +123 -127
  115. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  116. package/src/ui/screens/SkillsScreen.tsx +184 -204
  117. package/src/ui/screens/StylesScreen.tsx +104 -157
  118. package/src/ui/state/AppContext.tsx +6 -0
  119. package/src/ui/state/DimensionsContext.tsx +1 -10
  120. package/src/ui/state/reducer.ts +5 -8
  121. package/src/ui/state/types.ts +11 -9
  122. package/src/ui/text-width.ts +341 -0
  123. package/src/ui/theme-mode.ts +34 -71
  124. package/src/ui/theme.ts +50 -28
  125. package/src/utils/request-gate.ts +37 -0
  126. package/src/ui/components/ScopeIndicator.tsx +0 -63
  127. package/src/ui/components/SearchInput.tsx +0 -57
  128. package/src/ui/hooks/index.ts +0 -2
  129. package/src/ui/hooks/useKeyboard.ts +0 -24
  130. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Run a contract call in a child `bun` process whose HOME is a temp dir.
3
+ *
4
+ * Why a child: under Bun, `os.homedir()` ignores a `process.env.HOME` change
5
+ * made at runtime, but honours HOME set when the process starts
6
+ * (architecture.md C2, correction of 2026-09-18). Anything that could resolve
7
+ * `~/.claude` therefore runs here, never in the test process.
8
+ *
9
+ * Safety, in order:
10
+ * 1. the child's env pins HOME, CLAUDE_CONFIG_DIR and XDG_CONFIG_HOME inside
11
+ * the temp home, and its cwd is a temp dir;
12
+ * 2. before importing anything under test, the child checks that
13
+ * `os.homedir()` IS the temp home, and stops if not ("guard" stage);
14
+ * 3. the child reports only a JSON summary chosen by the test (booleans,
15
+ * names, URLs) on a marker line. Values read from files and error
16
+ * messages are never printed, and the child's raw stdout/stderr is never
17
+ * surfaced — so no test output can echo the contents of a user file.
18
+ */
19
+ import path from "node:path";
20
+
21
+ const MARK = "__BLIND_RESULT__";
22
+
23
+ export type ChildResult = Record<string, unknown> & { stage: string };
24
+
25
+ export interface ChildRun {
26
+ /** Temp HOME for the child. */
27
+ home: string;
28
+ /** Working directory for the child — always a temp dir. */
29
+ cwd: string;
30
+ /** Absolute path of the module under test, or undefined for none. */
31
+ module?: string;
32
+ /** Passed to the child as `job` (JSON). */
33
+ job?: Record<string, unknown>;
34
+ /** JS run BEFORE the module is imported (e.g. install a fetch mock). */
35
+ setup?: string;
36
+ /** JS run after import. Has `job`, `mod`, `out(summary)` in scope. */
37
+ body: string;
38
+ timeoutMs?: number;
39
+ }
40
+
41
+ function childScript(setup: string, body: string): string {
42
+ return `
43
+ const MARK = ${JSON.stringify(MARK)};
44
+ const out = async (o) => { await Bun.write(Bun.stdout, "\\n" + MARK + JSON.stringify(o) + "\\n"); };
45
+ (async () => {
46
+ try {
47
+ const os = await import("node:os");
48
+ const job = JSON.parse(process.env.BLIND_JOB || "{}");
49
+ if (os.homedir() !== job.__home || process.env.HOME !== job.__home) {
50
+ await out({ stage: "guard", homeRedirected: false });
51
+ return;
52
+ }
53
+ ${setup}
54
+ let mod = null;
55
+ if (job.__module) {
56
+ try {
57
+ mod = await import(job.__module);
58
+ } catch (e) {
59
+ await out({ stage: "import", error: String((e && e.message) || e).split("\\n")[0] });
60
+ return;
61
+ }
62
+ }
63
+ ${body}
64
+ } catch (e) {
65
+ await out({ stage: "harness", name: (e && e.name) || typeof e });
66
+ }
67
+ })().then(() => process.exit(0));
68
+ `;
69
+ }
70
+
71
+ export function runInChild(run: ChildRun): ChildResult {
72
+ const job = {
73
+ ...(run.job ?? {}),
74
+ __home: run.home,
75
+ __module: run.module ?? null,
76
+ };
77
+ const proc = Bun.spawnSync(
78
+ [
79
+ process.execPath,
80
+ "--no-env-file",
81
+ "-e",
82
+ childScript(run.setup ?? "", run.body),
83
+ ],
84
+ {
85
+ cwd: run.cwd,
86
+ env: {
87
+ ...process.env,
88
+ HOME: run.home,
89
+ CLAUDE_CONFIG_DIR: path.join(run.home, ".claude"),
90
+ XDG_CONFIG_HOME: path.join(run.home, ".config"),
91
+ BLIND_JOB: JSON.stringify(job),
92
+ },
93
+ stdout: "pipe",
94
+ stderr: "pipe",
95
+ timeout: run.timeoutMs ?? 25_000,
96
+ },
97
+ );
98
+ const line = proc.stdout
99
+ .toString()
100
+ .split("\n")
101
+ .reverse()
102
+ .find((l) => l.startsWith(MARK));
103
+ if (!line) {
104
+ // Withhold the child's output: it could echo a file's contents. A
105
+ // module-resolution line names paths only, so that one is safe to show.
106
+ const moduleLine = proc.stderr
107
+ .toString()
108
+ .match(/Cannot find module[^\n]*/)?.[0];
109
+ throw new Error(
110
+ `child bun produced no result (exit ${proc.exitCode}${moduleLine ? `; ${moduleLine}` : ""}); its raw output is withheld`,
111
+ );
112
+ }
113
+ return JSON.parse(line.slice(MARK.length)) as ChildResult;
114
+ }
@@ -240,7 +240,7 @@ describe("buildToolRow — which installer a catalogue row is judged through", (
240
240
  });
241
241
 
242
242
  describe("cliToolStatusText — the row's words", () => {
243
- test("unknown renders as ? <note>, the note verbatim", () => {
243
+ test("unknown renders as ? <short label>; the full note stays on the status", () => {
244
244
  const tool = catalogue("crush");
245
245
  const spec = specForCatalogTool(tool, null, {
246
246
  primary: "npm",
@@ -250,7 +250,10 @@ describe("cliToolStatusText — the row's words", () => {
250
250
  const row = buildToolRow(tool, spec, f);
251
251
  const text = cliToolStatusText(row);
252
252
  expect(text.tone).toBe("warning");
253
- expect(text.suffix).toBe(` ? ${classifyTool(spec, f).note}`);
253
+ expect(text.suffix).toBe(" ? not managed by npm");
254
+ // The note (with its path) is for the detail pane's "Why", not the row.
255
+ expect(row.status?.note).toBe(classifyTool(spec, f).note);
256
+ expect(text.suffix).not.toContain("/");
254
257
  });
255
258
 
256
259
  test("upgrade names the target once, with one v", () => {
@@ -1,8 +1,8 @@
1
1
  import { afterEach, describe, expect, it } from "bun:test";
2
2
  import {
3
- COMPONENT_BADGE,
3
+ COMPONENT_CHIP,
4
4
  CONTRAST_REFERENCE_KEYS,
5
- componentBadge,
5
+ componentChip,
6
6
  resetThemeMode,
7
7
  setThemeMode,
8
8
  } from "../ui/theme-mode.js";
@@ -30,51 +30,44 @@ function contrast(a: string, b: string): number {
30
30
  return (hi + 0.05) / (lo + 0.05);
31
31
  }
32
32
 
33
- const KINDS = ["skill", "command", "agent", "mcp"] as const;
34
-
35
- describe("componentBadge", () => {
33
+ /**
34
+ * The chip behind a plugin component's type glyph. It used to be a wash in a
35
+ * different hue per kind; the glyph already says the kind, so it is now one
36
+ * neutral chip for all of them (see `COMPONENT_CHIP`).
37
+ */
38
+ describe("componentChip", () => {
36
39
  it("serves the dark pair before any resolution", () => {
37
40
  // There is no unknown: dark is the stated default, so a chip is always a
38
41
  // pair, never null, and the caller has no bare-glyph branch to keep.
39
- for (const kind of KINDS) {
40
- expect(componentBadge(kind)).toEqual(COMPONENT_BADGE[kind].dark);
41
- }
42
+ expect(componentChip()).toEqual(COMPONENT_CHIP.dark);
42
43
  });
43
44
 
44
45
  it("serves the light pair on a light terminal", () => {
45
46
  setThemeMode("light");
46
-
47
- expect(componentBadge("skill")).toEqual(COMPONENT_BADGE.skill.light);
48
- expect(componentBadge("mcp")).toEqual(COMPONENT_BADGE.mcp.light);
47
+ expect(componentChip()).toEqual(COMPONENT_CHIP.light);
49
48
  });
50
49
 
51
50
  it("serves the dark pair on a dark terminal", () => {
52
51
  setThemeMode("dark");
53
-
54
- expect(componentBadge("skill")).toEqual(COMPONENT_BADGE.skill.dark);
55
- expect(componentBadge("mcp")).toEqual(COMPONENT_BADGE.mcp.dark);
52
+ expect(componentChip()).toEqual(COMPONENT_CHIP.dark);
56
53
  });
57
54
 
58
- it("keeps every glyph legible on its own chip", () => {
55
+ it("keeps the glyph legible on its own chip", () => {
59
56
  // 3:1 is the floor the rest of the palette is measured to. A chip whose
60
57
  // glyph disappears into its wash is worse than no chip at all.
61
- for (const kind of KINDS) {
62
- for (const mode of ["light", "dark"] as const) {
63
- const { bg, fg } = COMPONENT_BADGE[kind][mode];
64
- expect(contrast(bg, fg)).toBeGreaterThanOrEqual(3);
65
- }
58
+ for (const mode of ["light", "dark"] as const) {
59
+ const { bg, fg } = COMPONENT_CHIP[mode];
60
+ expect(contrast(bg, fg)).toBeGreaterThanOrEqual(3);
66
61
  }
67
62
  });
68
63
 
69
- it("keeps every chip quiet against the page it is drawn on", () => {
64
+ it("keeps the chip quiet against the page it is drawn on", () => {
70
65
  // The wash must read as a tint of the page, not as a block on it. Above
71
66
  // ~1.6 it stops being a chip and starts being a highlight bar.
72
- for (const kind of KINDS) {
73
- for (const mode of ["light", "dark"] as const) {
74
- const { bg } = COMPONENT_BADGE[kind][mode];
75
- const page = CONTRAST_REFERENCE_KEYS[mode];
76
- expect(contrast(bg, page)).toBeLessThan(1.6);
77
- }
67
+ for (const mode of ["light", "dark"] as const) {
68
+ const { bg } = COMPONENT_CHIP[mode];
69
+ const page = CONTRAST_REFERENCE_KEYS[mode];
70
+ expect(contrast(bg, page)).toBeLessThan(1.6);
78
71
  }
79
72
  });
80
73
 
@@ -86,13 +79,4 @@ describe("componentBadge", () => {
86
79
  expect(CONTRAST_REFERENCE_KEYS.light).toBe(CONTRAST_REFERENCE.light);
87
80
  expect(CONTRAST_REFERENCE_KEYS.dark).toBe(CONTRAST_REFERENCE.dark);
88
81
  });
89
-
90
- it("gives each kind a distinguishable hue", () => {
91
- // Single-character colour carries no readable hue *shift*, only a readable
92
- // hue *difference* — the same reason the scope squares are far apart.
93
- for (const mode of ["light", "dark"] as const) {
94
- const fills = KINDS.map((k) => COMPONENT_BADGE[k][mode].bg);
95
- expect(new Set(fills).size).toBe(KINDS.length);
96
- }
97
- });
98
82
  });
@@ -7,7 +7,7 @@
7
7
  * does not exist.
8
8
  */
9
9
  import { describe, expect, test } from "bun:test";
10
- import { joinKeys } from "../ui/components/layout/FooterHints.js";
10
+ import { joinKeys } from "../ui/keymap.js";
11
11
 
12
12
  describe("joinKeys", () => {
13
13
  test("single-character keys butt together, the way an arrow pair is written", () => {
@@ -32,20 +32,6 @@ describe("migrateToV2", () => {
32
32
  expect(v2.profiles.mine!.plugins).toEqual({ "dev@magus": true });
33
33
  });
34
34
 
35
- test("null/garbage input degrades to an empty manifest", () => {
36
- expect(migrateToV2(null)).toEqual(emptyManifest());
37
- expect(migrateToV2(42)).toEqual(emptyManifest());
38
- expect(migrateToV2({})).toEqual(emptyManifest());
39
- });
40
-
41
- test("non-object profile entries are dropped", () => {
42
- const v2 = migrateToV2({
43
- version: 2,
44
- profiles: { good: { name: "G" }, bad: 5 },
45
- });
46
- expect(Object.keys(v2.profiles)).toEqual(["good"]);
47
- });
48
-
49
35
  test("strictVersions is carried through as a boolean", () => {
50
36
  expect(
51
37
  migrateToV2({ version: 2, strictVersions: true, profiles: {} })
@@ -226,9 +212,9 @@ describe("mergeManifestSettings", () => {
226
212
  );
227
213
  });
228
214
 
229
- // readManifest degrades a corrupt file to an empty manifest. Reusing it here
230
- // would record nothing and report success, leaving the caller's live setting
231
- // to be reverted by the next install with no way to learn why.
215
+ // A merge that quietly treated a corrupt file as empty would record nothing and
216
+ // report success, leaving the caller's live setting to be reverted by the next
217
+ // install with no way to learn why.
232
218
  test("throws on an unreadable manifest instead of silently doing nothing", async () => {
233
219
  await fs.outputFile(join(dir, ".claude", "profiles.json"), "{ not json");
234
220
 
@@ -278,4 +264,26 @@ describe("readManifest / writeManifest", () => {
278
264
  expect(m.version).toBe(2);
279
265
  expect(m.profiles.legacy!.name).toBe("L");
280
266
  });
267
+
268
+ // These inputs used to degrade to an empty manifest (garbage) or lose the
269
+ // bad entry (non-object profile). "Empty" is the state that offers to save a
270
+ // first profile over the file, so both are now refusals.
271
+ test("null/garbage input is refused, not read as an empty manifest", async () => {
272
+ const file = join(dir, ".claude", "profiles.json");
273
+ for (const garbage of ["null", "42"]) {
274
+ await fs.outputFile(file, garbage);
275
+ await expect(readManifest(dir)).rejects.toThrow(file);
276
+ }
277
+ await fs.outputFile(file, "{}");
278
+ expect(await readManifest(dir)).toEqual(emptyManifest());
279
+ });
280
+
281
+ test("a non-object profile entry is refused, not dropped", async () => {
282
+ const file = join(dir, ".claude", "profiles.json");
283
+ await fs.outputJson(file, {
284
+ version: 2,
285
+ profiles: { good: { name: "G" }, bad: 5 },
286
+ });
287
+ await expect(readManifest(dir)).rejects.toThrow(file);
288
+ });
281
289
  });
@@ -52,8 +52,8 @@ function toneFor(marketplace: Marketplace): string | undefined {
52
52
 
53
53
  describe("marketplace category badge", () => {
54
54
  test("the byline uses a low-key tone, not an attention-grabbing one", () => {
55
- // It is a signature, not a status. Green reads as "action succeeded" and
56
- // competes with the plugin rows for attention.
55
+ // It is a signature, not a status. The success role reads as "action
56
+ // succeeded" and competes with the plugin rows for attention.
57
57
  expect(
58
58
  toneFor(
59
59
  mp({
@@ -63,7 +63,7 @@ describe("marketplace category badge", () => {
63
63
  ),
64
64
  ).toBe("byline");
65
65
 
66
- // A third-party marketplace still uses the green "Added" status tone.
66
+ // A third-party marketplace still uses the "Added" status tone, by role.
67
67
  expect(
68
68
  toneFor(
69
69
  mp({
@@ -71,7 +71,7 @@ describe("marketplace category badge", () => {
71
71
  source: { source: "github", repo: "obra/superpowers-marketplace" },
72
72
  }),
73
73
  ),
74
- ).toBe("green");
74
+ ).toBe("success");
75
75
  });
76
76
 
77
77
  test("MadAppGang marketplaces carry the byline instead of a generic label", () => {
@@ -1,8 +1,11 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { join } from "node:path";
3
+ import { parseKeypress } from "@opentui/core";
3
4
  import fs from "fs-extra";
4
5
  import { DEFAULT_PRESET, findPreset } from "../data/models-presets.js";
5
6
  import type { ModelsStatus } from "../services/models-core.js";
7
+ import type { KeyPress } from "../ui/input/key-dispatch.js";
8
+ import { SCREEN_BINDINGS, matchBinding } from "../ui/keymap.js";
6
9
  import { appReducer, initialState } from "../ui/state/reducer.js";
7
10
  import type { AppAction, AppState, ModelsSnapshot } from "../ui/state/types.js";
8
11
  import { asyncValue } from "../ui/state/types.js";
@@ -11,6 +14,18 @@ function run(actions: AppAction[], from: AppState = initialState): AppState {
11
14
  return actions.reduce(appReducer, from);
12
15
  }
13
16
 
17
+ /** A real parsed keypress, in the shape the keymap matches. */
18
+ function press(raw: string): KeyPress {
19
+ const k = parseKeypress(raw);
20
+ return {
21
+ name: k?.name ?? "",
22
+ sequence: k?.sequence ?? "",
23
+ ctrl: k?.ctrl === true,
24
+ meta: k?.meta === true,
25
+ shift: k?.shift === true,
26
+ };
27
+ }
28
+
14
29
  function status(over: Partial<ModelsStatus> = {}): ModelsStatus {
15
30
  return { state: "off", preset: null, drift: [], warnings: [], ...over };
16
31
  }
@@ -268,23 +283,31 @@ const SCREEN = join(import.meta.dir, "..", "ui", "screens", "ModelsScreen.tsx");
268
283
 
269
284
  describe("ModelsScreen keybindings", () => {
270
285
  test("a and Enter both apply, and c clears", async () => {
271
- const src = await fs.readFile(SCREEN, "utf8");
272
- expect(src).toContain('event.name === "a"');
273
- expect(src).toContain('event.name === "c"');
274
- expect(src).toContain('event.name === "r"');
275
- expect(src).toContain('event.name === "/"');
286
+ const models = SCREEN_BINDINGS.models;
287
+ expect(matchBinding(models, press("a"))?.id).toBe("apply");
288
+ expect(matchBinding(models, press("c"))?.id).toBe("off");
289
+ expect(matchBinding(models, press("r"))?.id).toBe("reload");
290
+ expect(matchBinding(models, press("/"))?.id).toBe("search");
276
291
  // Enter is the same action as `a`. There is nothing to "open" here, so
277
292
  // binding the most obvious key to nothing would be the wrong lesson.
278
- const enter = src.indexOf('event.name === "return"');
279
- expect(enter).toBeGreaterThan(-1);
280
- expect(src.slice(enter, enter + 400)).toContain("handleApply()");
293
+ expect(matchBinding(models, press("\r"))?.id).toBe("apply");
294
+ const src = await fs.readFile(SCREEN, "utf8");
295
+ const apply = src.indexOf("apply: () =>");
296
+ expect(apply).toBeGreaterThan(-1);
297
+ expect(src.slice(apply, apply + 120)).toContain("handleApply()");
281
298
  });
282
299
 
283
- test("digits are treated as navigation, so a message survives a tab round trip", async () => {
284
- // Every digit now navigates, 0 included. Clearing the status line on the
285
- // way OUT of the tab loses the message the state was changed to preserve.
286
- const src = await fs.readFile(SCREEN, "utf8");
287
- expect(src).toContain("/^[0-9]$/.test(event.name");
300
+ test("digits and Tab never reach the screen, so a message survives a tab round trip", () => {
301
+ // The status line is cleared only by this screen's own bindings. Every
302
+ // digit (0 included) and Tab navigate, and they belong to the global key
303
+ // layer: no Models binding may claim one, or the message would be wiped
304
+ // on the way OUT of the tab — the loss the state was moved to prevent.
305
+ for (const digit of "1234567890") {
306
+ expect(
307
+ matchBinding(SCREEN_BINDINGS.models, press(digit)),
308
+ ).toBeUndefined();
309
+ }
310
+ expect(matchBinding(SCREEN_BINDINGS.models, press("\t"))).toBeUndefined();
288
311
  });
289
312
 
290
313
  test("no screen still treats 0 as a non-navigation key", async () => {
@@ -0,0 +1,111 @@
1
+ /**
2
+ * The npm package name has one source of truth, and the self-upgrade builders
3
+ * read it.
4
+ *
5
+ * `MAGUS_NPM_PACKAGE` is `package.json` `name` (`magus-cli`). It is the npm
6
+ * package, NOT the command: the command is the `bin` key (`magus`), and the
7
+ * bare npm name belongs to an unrelated package. `selfUpgradeCommand` and
8
+ * `duplicateInstallRemoval` are the pure builders behind `magus upgrade`.
9
+ *
10
+ * Written black-box from the API contract. On the old tree this file fails at
11
+ * import, because none of these exports existed.
12
+ */
13
+
14
+ import { describe, expect, test } from "bun:test";
15
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import {
19
+ type SelfUpgradeManager,
20
+ duplicateInstallRemoval,
21
+ selfUpgradeCommand,
22
+ } from "../cli/upgrade.js";
23
+ import { MAGUS_NPM_PACKAGE } from "../data/npm-package.js";
24
+
25
+ // Read at test time: the single source of truth the requirements name.
26
+ const PKG = JSON.parse(
27
+ readFileSync(join(import.meta.dir, "..", "..", "package.json"), "utf8"),
28
+ ) as { name: string; version: string; bin: Record<string, string> };
29
+
30
+ const COMMAND = "magus";
31
+ const PACKAGE = "magus-cli";
32
+ const MANAGERS: SelfUpgradeManager[] = ["bun", "npm"];
33
+
34
+ describe("MAGUS_NPM_PACKAGE", () => {
35
+ test("is package.json `name`", () => {
36
+ expect(typeof MAGUS_NPM_PACKAGE).toBe("string");
37
+ expect(MAGUS_NPM_PACKAGE).toBe(PKG.name);
38
+ expect(MAGUS_NPM_PACKAGE).toBe(PACKAGE);
39
+ });
40
+
41
+ test("is not the command", () => {
42
+ const commands = Object.keys(PKG.bin);
43
+ expect(commands).toContain(COMMAND);
44
+ expect(commands).not.toContain(MAGUS_NPM_PACKAGE);
45
+ expect(MAGUS_NPM_PACKAGE).not.toBe(COMMAND);
46
+ });
47
+ });
48
+
49
+ describe("selfUpgradeCommand", () => {
50
+ test("bun installs `<package>@latest` globally", () => {
51
+ expect(selfUpgradeCommand("bun")).toEqual({
52
+ cmd: "bun",
53
+ args: ["install", "-g", `${PACKAGE}@latest`],
54
+ });
55
+ });
56
+
57
+ test("npm installs `<package>@latest` globally", () => {
58
+ expect(selfUpgradeCommand("npm")).toEqual({
59
+ cmd: "npm",
60
+ args: ["install", "-g", `${PACKAGE}@latest`],
61
+ });
62
+ });
63
+
64
+ test("is pure: the answer does not depend on PATH", () => {
65
+ const expected = MANAGERS.map((m) => selfUpgradeCommand(m));
66
+ const emptyDir = mkdtempSync(join(tmpdir(), "magus-selfupgrade-path-"));
67
+ const savedPath = process.env.PATH;
68
+ try {
69
+ process.env.PATH = emptyDir;
70
+ const again = MANAGERS.map((m) => selfUpgradeCommand(m));
71
+ expect(again).toEqual(expected);
72
+ } finally {
73
+ process.env.PATH = savedPath;
74
+ rmSync(emptyDir, { recursive: true, force: true });
75
+ }
76
+ expect(MANAGERS.map((m) => selfUpgradeCommand(m))).toEqual(expected);
77
+ });
78
+
79
+ test("reads the package name from the single source", () => {
80
+ for (const m of MANAGERS) {
81
+ const { cmd, args } = selfUpgradeCommand(m);
82
+ expect(cmd).toBe(m);
83
+ expect(args[args.length - 1]).toBe(`${MAGUS_NPM_PACKAGE}@latest`);
84
+ }
85
+ });
86
+ });
87
+
88
+ describe("duplicateInstallRemoval", () => {
89
+ test("bun names the package", () => {
90
+ expect(duplicateInstallRemoval("bun")).toBe(`bun uninstall -g ${PACKAGE}`);
91
+ });
92
+
93
+ test("npm names the package", () => {
94
+ expect(duplicateInstallRemoval("npm")).toBe(`npm uninstall -g ${PACKAGE}`);
95
+ });
96
+
97
+ test("carries no leading indentation (the caller indents)", () => {
98
+ for (const m of MANAGERS) {
99
+ const hint = duplicateInstallRemoval(m);
100
+ expect(hint).toBe(hint.trimStart());
101
+ }
102
+ });
103
+
104
+ test("reads the package name from the single source", () => {
105
+ for (const m of MANAGERS) {
106
+ const tokens = duplicateInstallRemoval(m).split(" ");
107
+ expect(tokens[0]).toBe(m);
108
+ expect(tokens[tokens.indexOf("-g") + 1]).toBe(MAGUS_NPM_PACKAGE);
109
+ }
110
+ });
111
+ });