magus-cli 7.3.1 → 7.4.1

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 (127) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  3. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  4. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  5. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  6. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  7. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  8. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  9. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  10. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  11. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  12. package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  15. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  16. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  17. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  18. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  19. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  20. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  21. package/src/__tests__/blind/child-run.ts +114 -0
  22. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  23. package/src/__tests__/component-badge.test.ts +20 -36
  24. package/src/__tests__/footer-hints.test.ts +1 -1
  25. package/src/__tests__/manifest.test.ts +25 -17
  26. package/src/__tests__/marketplace-badge.test.ts +4 -4
  27. package/src/__tests__/models-screen-state.test.ts +36 -13
  28. package/src/__tests__/scope-squares.test.tsx +28 -114
  29. package/src/__tests__/shutdown.test.ts +578 -0
  30. package/src/__tests__/stale-load-repair.test.ts +5 -4
  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__/uppercase-keybindings.test.ts +25 -12
  37. package/src/cli/ansi.ts +2 -2
  38. package/src/cli/bootstrap.ts +33 -2
  39. package/src/cli/doctor.ts +120 -15
  40. package/src/cli/install.ts +35 -4
  41. package/src/cli/profile.ts +18 -1
  42. package/src/cli/router.ts +31 -18
  43. package/src/cli/update-view.ts +1 -1
  44. package/src/data/cli-tools.ts +9 -1
  45. package/src/services/claude-settings.ts +434 -198
  46. package/src/services/cli-tool-commands.ts +45 -5
  47. package/src/services/cli-tool-version.ts +101 -0
  48. package/src/services/gitignore-service.ts +39 -22
  49. package/src/services/hook-registration.ts +23 -6
  50. package/src/services/manifest.ts +117 -52
  51. package/src/services/plugin-manager.ts +19 -5
  52. package/src/services/profile-adopt.ts +7 -2
  53. package/src/services/profile-startup.ts +87 -7
  54. package/src/services/profiles.ts +36 -23
  55. package/src/services/settings-file.ts +164 -4
  56. package/src/services/settings-manager.ts +19 -5
  57. package/src/services/skills-manager.ts +46 -0
  58. package/src/services/tool-probe.ts +316 -44
  59. package/src/services/toolchain.ts +39 -0
  60. package/src/services/update-engine.ts +5 -0
  61. package/src/services/update-plan.ts +9 -0
  62. package/src/tui.tsx +48 -21
  63. package/src/ui/App.tsx +58 -197
  64. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  65. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  66. package/src/ui/adapters/settingsAdapter.ts +0 -21
  67. package/src/ui/adapters/skillsAdapter.ts +27 -8
  68. package/src/ui/adapters/stylesAdapter.ts +15 -11
  69. package/src/ui/components/CategoryHeader.tsx +7 -1
  70. package/src/ui/components/EmptyFilterState.tsx +2 -2
  71. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  72. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  73. package/src/ui/components/TabBar.tsx +93 -21
  74. package/src/ui/components/layout/FooterHints.tsx +6 -27
  75. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  76. package/src/ui/components/layout/index.ts +1 -5
  77. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  78. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  79. package/src/ui/components/modals/MessageModal.tsx +3 -15
  80. package/src/ui/components/modals/ModalContainer.tsx +107 -60
  81. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  82. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  83. package/src/ui/components/primitives/MetaText.tsx +1 -1
  84. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  85. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  86. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  87. package/src/ui/components/primitives/index.ts +4 -3
  88. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  89. package/src/ui/input/KeyRouter.tsx +304 -0
  90. package/src/ui/input/key-dispatch.ts +197 -0
  91. package/src/ui/keymap.ts +905 -0
  92. package/src/ui/renderers/cliToolRenderers.tsx +192 -92
  93. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  94. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  95. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  96. package/src/ui/renderers/modelRenderers.tsx +12 -13
  97. package/src/ui/renderers/modelVisuals.tsx +0 -2
  98. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  99. package/src/ui/renderers/profileRenderers.tsx +32 -37
  100. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  101. package/src/ui/renderers/skillRenderers.tsx +237 -190
  102. package/src/ui/renderers/styleRenderers.tsx +13 -2
  103. package/src/ui/runtime/shutdown.ts +268 -0
  104. package/src/ui/screens/AliasScreen.tsx +118 -100
  105. package/src/ui/screens/CliToolsScreen.tsx +173 -101
  106. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  107. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  108. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  109. package/src/ui/screens/McpScreen.tsx +47 -33
  110. package/src/ui/screens/ModelsScreen.tsx +59 -104
  111. package/src/ui/screens/PluginsScreen.tsx +129 -129
  112. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  113. package/src/ui/screens/SkillsScreen.tsx +184 -204
  114. package/src/ui/screens/StylesScreen.tsx +104 -157
  115. package/src/ui/state/AppContext.tsx +6 -0
  116. package/src/ui/state/DimensionsContext.tsx +1 -10
  117. package/src/ui/state/reducer.ts +5 -8
  118. package/src/ui/state/types.ts +11 -9
  119. package/src/ui/text-width.ts +341 -0
  120. package/src/ui/theme-mode.ts +34 -71
  121. package/src/ui/theme.ts +50 -28
  122. package/src/utils/request-gate.ts +37 -0
  123. package/src/ui/components/ScopeIndicator.tsx +0 -63
  124. package/src/ui/components/SearchInput.tsx +0 -57
  125. package/src/ui/hooks/index.ts +0 -2
  126. package/src/ui/hooks/useKeyboard.ts +0 -24
  127. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -1,71 +1,83 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { TABS, barWidth, layoutTabs } from "../ui/components/TabBar.js";
2
+ import {
3
+ TABS,
4
+ type TabLayout,
5
+ barWidth,
6
+ layoutTabs,
7
+ } from "../ui/components/TabBar.js";
3
8
 
4
9
  const FULL = TABS.map((t) => `${t.key}:${t.label}`);
5
10
  const FULL_WIDTH = barWidth(FULL);
6
11
 
7
- /** The widest the compacted bar ever gets — whichever active label is longest. */
12
+ /** Columns a layout actually paints. */
13
+ const painted = (layout: TabLayout) => barWidth(layout.texts, layout.padded);
14
+
15
+ /** The widest the key-only bar ever gets — whichever active label is longest. */
8
16
  const WIDEST_COMPACT = Math.max(
9
- ...TABS.map((tab) => barWidth(layoutTabs(TABS, tab.screen, 0))),
17
+ ...TABS.map((tab) => painted(layoutTabs(TABS, tab.screen, 0))),
10
18
  );
11
19
 
12
20
  describe("tab bar layout", () => {
13
- test("shows every label when the bar fits", () => {
14
- expect(layoutTabs(TABS, "plugins", FULL_WIDTH)).toEqual(FULL);
21
+ test("shows every label, padded, when the full bar fits", () => {
22
+ expect(layoutTabs(TABS, "plugins", FULL_WIDTH)).toEqual({
23
+ texts: FULL,
24
+ padded: true,
25
+ });
15
26
  });
16
27
 
17
- test("drops inactive labels when the bar does not fit", () => {
18
- // A 94-column pane leaves ~90 usable, and ten full tabs need ~110 — the
19
- // width at which OpenTUI clipped each cell into a meaningless stub.
20
- const texts = layoutTabs(TABS, "styles", 90);
21
- expect(texts).toContain("9:Styles");
22
- expect(texts).toContain("1");
23
- expect(texts).not.toContain("1:Plugins");
28
+ test("drops the padding before it drops any label", () => {
29
+ // A 94-column pane leaves 90 usable. Ten padded tabs need 110, but the
30
+ // same ten names unpadded need exactly 90 — so every tab keeps its name.
31
+ // The old rule jumped straight to bare numbers here.
32
+ expect(layoutTabs(TABS, "styles", 90)).toEqual({
33
+ texts: FULL,
34
+ padded: false,
35
+ });
24
36
  });
25
37
 
26
- test("the compact bar actually fits a narrow pane", () => {
27
- // The point of compacting is that the result fits. If it did not, the
28
- // fallback would just be a differently-truncated mess.
29
- expect(barWidth(layoutTabs(TABS, "styles", 90))).toBeLessThanOrEqual(90);
38
+ test("shortens inactive labels to prefixes before dropping them", () => {
39
+ const layout = layoutTabs(TABS, "styles", 70);
40
+ expect(layout.texts).toContain("9:Styles");
41
+ expect(layout.texts).not.toContain("1");
42
+ expect(layout.texts.find((t) => t.startsWith("1:"))).toMatch(/^1:Plu/);
43
+ expect(painted(layout)).toBeLessThanOrEqual(70);
30
44
  });
31
45
 
32
- test("the full bar no longer fits a standard window, so compacting is the norm", () => {
33
- // The tenth tab pushed the full bar past 100 columns. That is not a
34
- // regression — it is why the compact form has to stay readable, and why
35
- // the test below measures it rather than assuming.
46
+ test("a 100-column window names every tab", () => {
47
+ // The tenth tab pushed the padded bar past 100 columns. That no longer
48
+ // costs the names: at 96 usable columns the unpadded bar fits whole.
36
49
  expect(FULL_WIDTH).toBeGreaterThan(100);
37
- expect(layoutTabs(TABS, "models", 100)).not.toEqual(FULL);
50
+ expect(layoutTabs(TABS, "models", 96).texts).toEqual(FULL);
38
51
  });
39
52
 
40
- test("the compact bar fits an 80-column terminal, on every tab", () => {
41
- // The real question a tenth tab raises. ScreenLayout pads the bar by 1
42
- // each side inside a container padded by 1, so an 80-column terminal
43
- // leaves 76 — and the widest compact bar is well under it.
53
+ test("the bar fits an 80-column terminal, on every tab", () => {
54
+ // ScreenLayout pads the bar by 1 each side inside a container padded by
55
+ // 1, so an 80-column terminal leaves 76.
44
56
  expect(WIDEST_COMPACT).toBeLessThanOrEqual(76);
45
57
  for (const tab of TABS) {
46
- expect(barWidth(layoutTabs(TABS, tab.screen, 76))).toBeLessThanOrEqual(
47
- 76,
48
- );
58
+ expect(painted(layoutTabs(TABS, tab.screen, 76))).toBeLessThanOrEqual(76);
49
59
  }
50
60
  });
51
61
 
52
62
  test("keeps the label of whichever tab is active", () => {
53
63
  for (const tab of TABS) {
54
- const texts = layoutTabs(TABS, tab.screen, 40);
64
+ const { texts } = layoutTabs(TABS, tab.screen, 40);
55
65
  expect(texts).toContain(`${tab.key}:${tab.label}`);
56
- // Exactly one tab keeps its label.
66
+ // At 40 columns only the active tab keeps a label.
57
67
  expect(texts.filter((t) => t.includes(":"))).toHaveLength(1);
58
68
  }
59
69
  });
60
70
 
61
71
  test("compacting never changes how many tabs are drawn", () => {
62
- expect(layoutTabs(TABS, "plugins", 20)).toHaveLength(TABS.length);
63
- expect(layoutTabs(TABS, "plugins", 10_000)).toHaveLength(TABS.length);
72
+ expect(layoutTabs(TABS, "plugins", 20).texts).toHaveLength(TABS.length);
73
+ expect(layoutTabs(TABS, "plugins", 10_000).texts).toHaveLength(TABS.length);
64
74
  });
65
75
 
66
76
  test("barWidth counts padding and separators", () => {
67
77
  // " a " + "│" + " bb " = 3 + 1 + 4
68
78
  expect(barWidth(["a", "bb"])).toBe(8);
79
+ // "a" + "│" + "bb" = 1 + 1 + 2
80
+ expect(barWidth(["a", "bb"], false)).toBe(4);
69
81
  expect(barWidth([])).toBe(0);
70
82
  });
71
83
 
@@ -24,8 +24,10 @@ import { CONTRAST_REFERENCE, brand, theme } from "../ui/theme.js";
24
24
  * can clear a text-grade ratio against that terminal's own background).
25
25
  * 2. Every accent clears 3:1 against BOTH reference backgrounds.
26
26
  * 3. Ink on our own badges clears 4.5:1 against that badge.
27
- * 4. The three scope colours are far enough apart in hue to read as squares.
28
- * 5. No component invents a colour outside the palette.
27
+ * 4. No component invents a colour outside the palette.
28
+ *
29
+ * The scope squares no longer own a hue each (see theme.ts): position names the
30
+ * scope and shape says the state, so there is no scope palette to measure here.
29
31
  */
30
32
 
31
33
  // ─── Contrast maths (WCAG 2.1 relative luminance) ─────────────────────────────
@@ -48,37 +50,6 @@ function contrastRatio(a: string, b: string): number {
48
50
  return (hi + 0.05) / (lo + 0.05);
49
51
  }
50
52
 
51
- /**
52
- * Perceptual distance (CIE76 ΔE) in L*a*b*.
53
- *
54
- * Contrast ratio is the wrong tool for "are these two swatches telling apart":
55
- * it only compares luminance, and the scope colours are deliberately close in
56
- * luminance — that is precisely what lets each of them clear 3:1 against both a
57
- * light and a dark background. Measured, they sit at ratios of 1.00-1.03 to each
58
- * other while being obviously different colours. ΔE sees the hue difference that
59
- * a luminance ratio is blind to.
60
- */
61
- function deltaE(aHex: string, bHex: string): number {
62
- const toLab = (hex: string): [number, number, number] => {
63
- const n = hex.replace("#", "");
64
- const [r, g, b] = [0, 2, 4].map((i) => {
65
- const c = Number.parseInt(n.slice(i, i + 2), 16) / 255;
66
- return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
67
- });
68
- // sRGB -> XYZ (D65), then XYZ -> Lab
69
- const x = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047;
70
- const y = r * 0.2126 + g * 0.7152 + b * 0.0722;
71
- const z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883;
72
- const f = (t: number) =>
73
- t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116;
74
- const [fx, fy, fz] = [f(x), f(y), f(z)];
75
- return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
76
- };
77
- const [l1, a1, b1] = toLab(aHex);
78
- const [l2, a2, b2] = toLab(bHex);
79
- return Math.hypot(l1 - l2, a1 - a2, b1 - b2);
80
- }
81
-
82
53
  /** WCAG threshold for UI components and large text. 4.5 is unreachable against
83
54
  * both a light and a dark background at once — the luminance bands do not overlap. */
84
55
  const UI_CONTRAST = 3;
@@ -129,9 +100,9 @@ describe("accents are legible on light AND dark terminals", () => {
129
100
 
130
101
  test("badge ink clears 4.5:1 on every badge colour we paint", () => {
131
102
  const failures: string[] = [];
132
- for (const [tone, colors] of Object.entries(theme.category)) {
103
+ for (const [tone, colors] of Object.entries(theme.badge)) {
133
104
  const r = contrastRatio(brand.ink, colors.bg as string);
134
- if (r < INK_CONTRAST) failures.push(`category.${tone} ${r.toFixed(2)}`);
105
+ if (r < INK_CONTRAST) failures.push(`badge.${tone} ${r.toFixed(2)}`);
135
106
  }
136
107
  for (const key of ["defaultBg", "primaryBg", "dangerBg"] as const) {
137
108
  const r = contrastRatio(brand.ink, theme.hints[key]);
@@ -177,30 +148,6 @@ describe("accents are legible on light AND dark terminals", () => {
177
148
  Object.keys(theme.meta).sort(),
178
149
  );
179
150
  });
180
-
181
- test("the three scope colours are perceptually far apart", () => {
182
- // They render as single-character squares, where a subtle shift is not
183
- // readable at all. Deferring to ANSI put project and local on slots 2 and 3,
184
- // which one real theme drew as two near-identical olives — the reason this
185
- // rule exists. ΔE > 25 is comfortably past "different colour" (~2.3 is the
186
- // just-noticeable difference); the bar is set high because these are tiny.
187
- const scopes = Object.entries(theme.scopes) as [string, string][];
188
- const tooClose: string[] = [];
189
- for (let i = 0; i < scopes.length; i++) {
190
- for (let j = i + 1; j < scopes.length; j++) {
191
- const d = deltaE(scopes[i][1], scopes[j][1]);
192
- if (d < 25)
193
- tooClose.push(`${scopes[i][0]}~${scopes[j][0]} ΔE=${d.toFixed(1)}`);
194
- }
195
- }
196
- expect(tooClose).toEqual([]);
197
- expect(new Set(scopes.map(([, hex]) => hex)).size).toBe(scopes.length);
198
- });
199
-
200
- test("ΔE agrees that identical colours are identical (guards the metric)", () => {
201
- expect(deltaE(brand.success, brand.success)).toBeCloseTo(0, 5);
202
- expect(deltaE("#000000", "#FFFFFF")).toBeGreaterThan(90);
203
- });
204
151
  });
205
152
 
206
153
  describe("the canvas stays the terminal's", () => {
@@ -605,7 +605,7 @@ describe("isInside — containment in the package's OWN directory", () => {
605
605
  // ── ownership through the probe, with a fake IO ─────────────────────────────
606
606
 
607
607
  describe("probeTool — ownership decides managed", () => {
608
- test("native claude is NOT npm-owned: not-managed, never an upgrade", async () => {
608
+ test("native claude is its own installer's: managed, `claude update`, never the npm veto", async () => {
609
609
  const io = fakeIO({
610
610
  onPath: { claude: "/Users/j/.local/bin/claude" },
611
611
  real: {
@@ -616,6 +616,10 @@ describe("probeTool — ownership decides managed", () => {
616
616
  "npm root -g": { stdout: "/Users/j/.local/lib/node_modules\n" },
617
617
  "claude --version": { stdout: "2.1.268 (Claude Code)\n" },
618
618
  },
619
+ fetch: {
620
+ "https://registry.npmjs.org/-/package/@anthropic-ai%2Fclaude-code/dist-tags":
621
+ { ok: true, body: { latest: "2.1.270" } },
622
+ },
619
623
  });
620
624
  const s = spec({
621
625
  name: "claude",
@@ -623,16 +627,21 @@ describe("probeTool — ownership decides managed", () => {
623
627
  package: "@anthropic-ai/claude-code",
624
628
  check: "claude --version",
625
629
  });
626
- // The read-back path's opt-in. Planning never runs a check command —
627
- // see "a check command is never run while planning" below.
628
630
  const f = await probeTool(s, io, { allowCheckCommand: true });
629
631
  expect(f).toMatchObject({
630
632
  present: true,
631
- managed: false,
633
+ managed: true,
632
634
  installed: "2.1.268",
635
+ latest: "2.1.270",
636
+ upgradeCommand: { cmd: "claude", args: ["update"] },
633
637
  });
634
- expect(classifyTool(s, f).reason).toBe("not-managed");
635
- expect(io.fetches).toEqual([]);
638
+ expect(classifyTool(s, f)).toMatchObject({
639
+ state: "upgrade",
640
+ reason: "behind",
641
+ target: "2.1.270",
642
+ });
643
+ // The version is the directory entry's name: nothing ran, npm was not asked.
644
+ expect(io.calls).toEqual([]);
636
645
  });
637
646
 
638
647
  test("bun-owned codex: version from its package.json, latest from dist-tags", async () => {
@@ -732,27 +741,41 @@ describe("probeTool — ownership decides managed", () => {
732
741
  expect(classifyTool(s, await probeTool(s, io)).reason).toBe("held-back");
733
742
  });
734
743
 
735
- test("crush under nvm while PATH's npm writes elsewhere: not-managed", async () => {
744
+ test("crush under nvm while PATH's npm writes elsewhere: managed through --prefix", async () => {
745
+ const prefix = "/Users/j/.nvm/versions/node/v24.14.0";
746
+ const pkgDir = `${prefix}/lib/node_modules/@charmland/crush`;
736
747
  const io = fakeIO({
737
- onPath: { crush: "/Users/j/.nvm/versions/node/v24.14.0/bin/crush" },
738
- real: {
739
- "/Users/j/.nvm/versions/node/v24.14.0/bin/crush":
740
- "/Users/j/.nvm/versions/node/v24.14.0/lib/node_modules/@charmland/crush/run-crush.js",
741
- },
748
+ onPath: { crush: `${prefix}/bin/crush` },
749
+ real: { [`${prefix}/bin/crush`]: `${pkgDir}/run-crush.js` },
750
+ files: { [`${pkgDir}/package.json`]: '{"version":"0.7.1"}' },
742
751
  exec: {
743
752
  "npm root -g": { stdout: "/Users/j/.local/lib/node_modules\n" },
744
753
  "crush --version": { stdout: "crush version v0.7.1\n" },
745
754
  },
755
+ fetch: {
756
+ "https://registry.npmjs.org/-/package/@charmland%2Fcrush/dist-tags": {
757
+ ok: true,
758
+ body: { latest: "0.8.0" },
759
+ },
760
+ },
746
761
  });
747
762
  const s = spec({ name: "crush", via: "npm", package: "@charmland/crush" });
748
- const status = classifyTool(
749
- s,
750
- await probeTool(s, io, { allowCheckCommand: true }),
751
- );
752
- expect(status.reason).toBe("not-managed");
753
- expect(status.note).toContain(
754
- "npm (root /Users/j/.local/lib/node_modules)",
755
- );
763
+ const f = await probeTool(s, io, { allowCheckCommand: true });
764
+ expect(f).toMatchObject({
765
+ managed: true,
766
+ installed: "0.7.1",
767
+ latest: "0.8.0",
768
+ upgradeCommand: {
769
+ cmd: "npm",
770
+ args: ["install", "-g", "--prefix", prefix, "@charmland/crush@latest"],
771
+ },
772
+ });
773
+ expect(classifyTool(s, f)).toMatchObject({
774
+ state: "upgrade",
775
+ target: "0.8.0",
776
+ });
777
+ // Version from the package.json, not by running the tool.
778
+ expect(io.calls).toEqual(["npm root -g"]);
756
779
  });
757
780
 
758
781
  test("go: tmux-mcp pinned v2.0.0 reads build info only — no network", async () => {
@@ -843,10 +866,12 @@ describe("probeTool — ownership decides managed", () => {
843
866
  const [f] = await probeTools([hostile], io);
844
867
  expect(io.calls).toEqual(["npm root -g"]);
845
868
  expect(f?.installed).toBeNull();
846
- // Cannot tell ⇒ unknown, so it is reported and never applied.
869
+ // Cannot tell ⇒ unknown, so it is reported and never applied. The file
870
+ // is outside npm's tree, so the reason is ownership (R9.9), not the
871
+ // unread version that follows from it.
847
872
  expect(classifyTool(hostile, f!)).toMatchObject({
848
873
  state: "unknown",
849
- reason: "version-unreadable",
874
+ reason: "not-managed",
850
875
  });
851
876
 
852
877
  // Even an honest check command stays unrun while planning.
@@ -2,6 +2,8 @@ import { describe, expect, test } from "bun:test";
2
2
  import path from "node:path";
3
3
  import { parseKeypress } from "@opentui/core";
4
4
  import fs from "fs-extra";
5
+ import type { KeyPress } from "../ui/input/key-dispatch.js";
6
+ import { SCREEN_BINDINGS, matchBinding } from "../ui/keymap.js";
5
7
 
6
8
  /**
7
9
  * A shifted key never arrives as an uppercase `name`.
@@ -81,21 +83,32 @@ describe("no binding compares name to an uppercase letter", () => {
81
83
  expect(offenders).toEqual([]);
82
84
  });
83
85
 
84
- test("an uppercase binding is reachable only via the shift flag", async () => {
86
+ test("an uppercase binding is reachable only via the shift flag", () => {
85
87
  // The positive half: Shift+U must actually route to update. Asserting only
86
88
  // the absence of `=== "U"` would also pass if someone deleted the binding.
87
- const src = await fs.readFile(
88
- path.join(UI_DIR, "screens", "PluginsScreen.tsx"),
89
- "utf8",
89
+ // Keys are matched through the keymap now, so the check is on what a
90
+ // parsed keypress resolves to — and plain `u` must NOT resolve to update,
91
+ // which is the original bug the other way round.
92
+ const press = (raw: string): KeyPress => {
93
+ const k = parseKeypress(raw);
94
+ return {
95
+ name: k?.name ?? "",
96
+ sequence: k?.sequence ?? "",
97
+ ctrl: k?.ctrl === true,
98
+ meta: k?.meta === true,
99
+ shift: k?.shift === true,
100
+ };
101
+ };
102
+ expect(matchBinding(SCREEN_BINDINGS.plugins, press("U"))?.id).toBe(
103
+ "update",
90
104
  );
91
- const update = src.indexOf('event.name === "u" && event.shift');
92
- const userScope = src.indexOf(
93
- 'event.name === "u") handleScopeToggle("user")',
105
+ expect(matchBinding(SCREEN_BINDINGS.plugins, press("u"))?.id).toBe("scope");
106
+ // The registry's refresh, the one that silently did nothing.
107
+ expect(matchBinding(SCREEN_BINDINGS["mcp-registry"], press("R"))?.id).toBe(
108
+ "reload",
94
109
  );
95
- expect(update).toBeGreaterThan(-1);
96
- expect(userScope).toBeGreaterThan(-1);
97
- // Order matters: the shifted branch has to be tested first, or the plain
98
- // "u" branch swallows it again — which is exactly the original bug.
99
- expect(update).toBeLessThan(userScope);
110
+ expect(
111
+ matchBinding(SCREEN_BINDINGS["mcp-registry"], press("r"))?.id,
112
+ ).toBeUndefined();
100
113
  });
101
114
  });
package/src/cli/ansi.ts CHANGED
@@ -346,12 +346,12 @@ export function truncate(text: string, n: number): string {
346
346
  export const ramps = {
347
347
  /**
348
348
  * Work in progress to done. Purple is magus's brand, green is "settled",
349
- * and the blue midtone is the chosen control point that stops a two-hue
349
+ * and the teal midtone is the chosen control point that stops a two-hue
350
350
  * blend desaturating into grey at the halfway mark.
351
351
  */
352
352
  progress: [
353
353
  lighten(brand.accent, 0.15),
354
- lighten(brand.link, 0.2),
354
+ lighten(brand.info, 0.2),
355
355
  lighten(brand.success, 0.25),
356
356
  ],
357
357
  /**
@@ -31,12 +31,28 @@ import {
31
31
  needsNothing,
32
32
  parseProfileChoice,
33
33
  } from "../services/profile-startup.js";
34
+ import { SettingsParseError } from "../services/settings-file.js";
34
35
  import type { ProfileManifest } from "../types/index.js";
35
36
  import { dim } from "./ansi.js";
36
37
  import { confirm } from "./prompt.js";
37
38
  import { selectDialog } from "./tui-prompt.js";
38
39
  import { fail as failLine, note as noteLine } from "./update-view.js";
39
40
 
41
+ /**
42
+ * Report a file that exists and cannot be used — it does not parse, or parses
43
+ * into the wrong shape — and that magus left it alone.
44
+ *
45
+ * One wording for every command that meets one — the message names the file
46
+ * and says what is wrong with it; the note says what the user has to do, since
47
+ * magus will not do it for them.
48
+ */
49
+ export function reportUnparseable(error: SettingsParseError): void {
50
+ console.error(failLine(error.message));
51
+ console.error(
52
+ noteLine("Fix the JSON by hand; magus leaves the file as it is."),
53
+ );
54
+ }
55
+
40
56
  /**
41
57
  * Write the adopted manifest, then put the project ON the profile it describes.
42
58
  *
@@ -147,13 +163,22 @@ export interface EnsureManifestOptions {
147
163
  * Return the project's manifest, creating one by adoption if it has none.
148
164
  *
149
165
  * Resolves to null when there is no manifest and none was created — the caller
150
- * should exit non-zero. Anything it prints has already explained why.
166
+ * should exit non-zero. Anything it prints has already explained why. A
167
+ * manifest that exists and does not parse is one of those: it is reported, and
168
+ * never read as "none" — adoption below would write a new one over it.
151
169
  */
152
170
  export async function ensureManifest(
153
171
  projectPath: string,
154
172
  opts: EnsureManifestOptions = {},
155
173
  ): Promise<ProfileManifest | null> {
156
- const manifest = await readManifest(projectPath);
174
+ let manifest: ProfileManifest;
175
+ try {
176
+ manifest = await readManifest(projectPath);
177
+ } catch (error) {
178
+ if (!(error instanceof SettingsParseError)) throw error;
179
+ reportUnparseable(error);
180
+ return null;
181
+ }
157
182
  if (Object.keys(manifest.profiles).length > 0) return manifest;
158
183
 
159
184
  const result = await adoptCurrentProject(projectPath);
@@ -226,6 +251,12 @@ export async function requireActiveProfile(
226
251
  const need = await detectProfileStartupNeed(projectPath);
227
252
  if (needsNothing(need)) return manifest;
228
253
 
254
+ // Nothing to ask: every option would read or write a file that does not parse.
255
+ if (need.parseError) {
256
+ reportUnparseable(need.parseError);
257
+ return null;
258
+ }
259
+
229
260
  // The repo declares which profile it wants and there is nothing of the
230
261
  // user's to replace — a fresh clone. Activate it and carry on, even under
231
262
  // `--check` and with no TTY: this is the committed answer being applied, not