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,259 @@
1
+ /**
2
+ * Blind tests — contract C14 (requirement R9.6).
3
+ *
4
+ * Written from requirements.md and architecture.md only.
5
+ *
6
+ * A CLI Tools row in state `unknown` shows a short label chosen by its
7
+ * reason: at most 24 terminal cells, never a filesystem path. The long note
8
+ * (today "on PATH at /Users/…", always cut off) belongs to the detail pane's
9
+ * "Why" line, not to the row.
10
+ *
11
+ * Pure: rows are built by hand to the CliToolRow contract. No IO.
12
+ */
13
+ import { describe, expect, test } from "bun:test";
14
+ import { type CliTool, cliTools } from "../../data/cli-tools.js";
15
+ import type { ToolStatus } from "../../services/tool-probe.js";
16
+ import type { BinRequirement } from "../../types/index.js";
17
+ import {
18
+ type CliToolRow,
19
+ cliToolStatusText,
20
+ } from "../../ui/adapters/cliToolsAdapter.js";
21
+
22
+ const MAX_CELLS = 24;
23
+
24
+ const LONG_PATH_NOTE =
25
+ "on PATH at /Users/someone/.nvm/versions/node/v24/lib/node_modules/x/run.js, which is not the copy npm manages (npm root -g is /Users/someone/.local/lib/node_modules)";
26
+ const OTHER_NOTE =
27
+ "a second copy was found at /opt/homebrew/lib/node_modules/x/bin/x.js and another under ~/.bun/install/global/node_modules/x";
28
+
29
+ // ── terminal cell width, independent of the code under test ────────────────
30
+
31
+ /** BMP code points that terminals draw two cells wide as emoji. */
32
+ const WIDE_BMP_EMOJI = new Set([
33
+ 0x231a, 0x231b, 0x23e9, 0x23ea, 0x23eb, 0x23ec, 0x23f0, 0x23f3, 0x25fd,
34
+ 0x25fe, 0x2614, 0x2615, 0x267f, 0x2693, 0x26a1, 0x26aa, 0x26ab, 0x26bd,
35
+ 0x26be, 0x26c4, 0x26c5, 0x26ce, 0x26d4, 0x26ea, 0x26f2, 0x26f3, 0x26f5,
36
+ 0x26fa, 0x26fd, 0x2705, 0x270a, 0x270b, 0x2728, 0x274c, 0x274e, 0x2753,
37
+ 0x2754, 0x2755, 0x2757, 0x2795, 0x2796, 0x2797, 0x27b0, 0x27bf, 0x2b1b,
38
+ 0x2b1c, 0x2b50, 0x2b55,
39
+ ]);
40
+
41
+ function isWide(cp: number): boolean {
42
+ return (
43
+ (cp >= 0x1100 && cp <= 0x115f) ||
44
+ (cp >= 0x2648 && cp <= 0x2653) ||
45
+ (cp >= 0x2e80 && cp <= 0x303e) ||
46
+ (cp >= 0x3041 && cp <= 0x33ff) ||
47
+ (cp >= 0x3400 && cp <= 0x4dbf) ||
48
+ (cp >= 0x4e00 && cp <= 0x9fff) ||
49
+ (cp >= 0xa000 && cp <= 0xa4cf) ||
50
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
51
+ (cp >= 0xf900 && cp <= 0xfaff) ||
52
+ (cp >= 0xfe30 && cp <= 0xfe4f) ||
53
+ (cp >= 0xff00 && cp <= 0xff60) ||
54
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
55
+ (cp >= 0x1f300 && cp <= 0x1f64f) ||
56
+ (cp >= 0x1f900 && cp <= 0x1f9ff) ||
57
+ (cp >= 0x1f680 && cp <= 0x1f6ff) ||
58
+ (cp >= 0x1fa70 && cp <= 0x1faff) ||
59
+ (cp >= 0x20000 && cp <= 0x3fffd) ||
60
+ WIDE_BMP_EMOJI.has(cp)
61
+ );
62
+ }
63
+
64
+ function isZeroWidth(cp: number): boolean {
65
+ return (
66
+ (cp >= 0x0300 && cp <= 0x036f) ||
67
+ (cp >= 0x200b && cp <= 0x200f) ||
68
+ (cp >= 0xfe00 && cp <= 0xfe0e) ||
69
+ cp === 0x2060
70
+ );
71
+ }
72
+
73
+ /** An ANSI CSI sequence (ESC [ … letter); built so no control byte sits in source. */
74
+ const ANSI_CSI = new RegExp(
75
+ `${String.fromCharCode(27)}\\[[0-9;?]*[A-Za-z]`,
76
+ "g",
77
+ );
78
+
79
+ /** Terminal cells: wide characters count 2, combining marks 0, ANSI 0. */
80
+ function cells(text: string): number {
81
+ const plain = text.replace(ANSI_CSI, "");
82
+ const cps = Array.from(plain, (ch) => ch.codePointAt(0) ?? 0);
83
+ let width = 0;
84
+ for (let i = 0; i < cps.length; i++) {
85
+ const cp = cps[i] as number;
86
+ if (cp === 0xfe0f) continue; // counted with its base, below
87
+ if (isZeroWidth(cp)) continue;
88
+ // A narrow base with emoji presentation (U+FE0F) is drawn two wide.
89
+ width += isWide(cp) || cps[i + 1] === 0xfe0f ? 2 : 1;
90
+ }
91
+ return width;
92
+ }
93
+
94
+ describe("C14 — the test's own cell counter", () => {
95
+ test("R9.6 control: ASCII counts 1, CJK and emoji count 2", () => {
96
+ expect(cells("abc")).toBe(3);
97
+ expect(cells("路径")).toBe(4);
98
+ expect(cells("⚠️")).toBe(2);
99
+ expect(cells("✅ ok")).toBe(5);
100
+ });
101
+ });
102
+
103
+ // ── rows ────────────────────────────────────────────────────────────────────
104
+
105
+ const TOOL: CliTool = (cliTools.find((t) => t.name === "crush") ??
106
+ cliTools[0]) as CliTool;
107
+
108
+ const SPEC: BinRequirement = {
109
+ name: TOOL.name,
110
+ via: "npm",
111
+ package: "@example/x",
112
+ };
113
+
114
+ type UnknownReason = Exclude<
115
+ ToolStatus["reason"],
116
+ "absent" | "up-to-date" | "behind" | "at-pin" | "behind-pin"
117
+ >;
118
+
119
+ /** Every reason that can put a row in `unknown`, with realistic versions. */
120
+ const UNKNOWN: Record<
121
+ UnknownReason,
122
+ Pick<ToolStatus, "installed" | "latest" | "target"> & { pin?: string }
123
+ > = {
124
+ "version-unreadable": { installed: null, latest: "2.0.0", target: null },
125
+ unorderable: {
126
+ installed: "nightly-2026-09-01",
127
+ latest: "2.0.0",
128
+ target: null,
129
+ },
130
+ "ahead-of-pin": {
131
+ installed: "2.1.0",
132
+ latest: "2.1.0",
133
+ target: null,
134
+ pin: "2.0.0",
135
+ },
136
+ "not-managed": { installed: "1.2.3", latest: "2.0.0", target: null },
137
+ "held-back": {
138
+ installed: "1.2.3",
139
+ latest: "2.0.0",
140
+ target: null,
141
+ pin: "1.2.3",
142
+ },
143
+ "pin-not-installable": {
144
+ installed: "1.2.3",
145
+ latest: "2.0.0",
146
+ target: null,
147
+ pin: "9.9.9",
148
+ },
149
+ "latest-unavailable": { installed: "1.2.3", latest: null, target: null },
150
+ "not-probed": { installed: null, latest: null, target: null },
151
+ };
152
+
153
+ function row(status: ToolStatus): CliToolRow {
154
+ return {
155
+ tool: TOOL,
156
+ spec: SPEC,
157
+ checking: false,
158
+ status,
159
+ installCommand: { cmd: "npm", args: ["install", "-g", "@example/x"] },
160
+ };
161
+ }
162
+
163
+ function unknownRow(reason: UnknownReason, note: string | undefined) {
164
+ const fields = UNKNOWN[reason];
165
+ const status: ToolStatus = {
166
+ name: TOOL.name,
167
+ state: "unknown",
168
+ reason,
169
+ installed: fields.installed,
170
+ latest: fields.latest,
171
+ target: fields.target,
172
+ ...(fields.pin ? { pin: fields.pin } : {}),
173
+ ...(note !== undefined ? { note } : {}),
174
+ };
175
+ return row(status);
176
+ }
177
+
178
+ const REASONS = Object.keys(UNKNOWN) as UnknownReason[];
179
+
180
+ describe("C14 — cliToolStatusText on an unknown row", () => {
181
+ test("R9.6 control: the test covers all eight unknown-capable reasons", () => {
182
+ expect([...REASONS].sort()).toEqual(
183
+ [
184
+ "ahead-of-pin",
185
+ "held-back",
186
+ "latest-unavailable",
187
+ "not-managed",
188
+ "not-probed",
189
+ "pin-not-installable",
190
+ "unorderable",
191
+ "version-unreadable",
192
+ ].sort(),
193
+ );
194
+ });
195
+
196
+ for (const reason of REASONS) {
197
+ test(`R9.6 unknown / ${reason} with a long path note: the suffix is at most ${MAX_CELLS} cells`, () => {
198
+ const { suffix } = cliToolStatusText(unknownRow(reason, LONG_PATH_NOTE));
199
+ // The suffix rides along so a failure shows what the row printed.
200
+ expect({ suffix, fits: cells(suffix) <= MAX_CELLS }).toEqual({
201
+ suffix,
202
+ fits: true,
203
+ });
204
+ });
205
+
206
+ test(`R9.6 unknown / ${reason} with a long path note: the suffix holds no filesystem path`, () => {
207
+ const { suffix } = cliToolStatusText(unknownRow(reason, LONG_PATH_NOTE));
208
+ expect(suffix).not.toContain("/");
209
+ expect(suffix).not.toContain("node_modules");
210
+ });
211
+
212
+ test(`R9.6 unknown / ${reason}: the row shows a label, not nothing`, () => {
213
+ const { suffix } = cliToolStatusText(unknownRow(reason, LONG_PATH_NOTE));
214
+ expect(suffix.trim().length).toBeGreaterThan(0);
215
+ });
216
+
217
+ test(`R9.6 unknown / ${reason}: the label is chosen by the reason, not by the note`, () => {
218
+ const a = cliToolStatusText(unknownRow(reason, LONG_PATH_NOTE)).suffix;
219
+ const b = cliToolStatusText(unknownRow(reason, OTHER_NOTE)).suffix;
220
+ expect(b).toBe(a);
221
+ });
222
+ }
223
+
224
+ test("R9.6 a note full of wide characters still yields a suffix within the cell budget", () => {
225
+ const note = "工具在路径 /opt/工具/bin/工具 找到,但不是 npm 管理的副本 ⚠️";
226
+ for (const reason of REASONS) {
227
+ const { suffix } = cliToolStatusText(unknownRow(reason, note));
228
+ expect({ reason, cells: cells(suffix) <= MAX_CELLS }).toEqual({
229
+ reason,
230
+ cells: true,
231
+ });
232
+ expect({ reason, slash: suffix.includes("/") }).toEqual({
233
+ reason,
234
+ slash: false,
235
+ });
236
+ }
237
+ });
238
+ });
239
+
240
+ describe("C14 — control: rows that are not unknown", () => {
241
+ test("R9.6 control: an upgrade row with a target still shows the target version", () => {
242
+ const status: ToolStatus = {
243
+ name: TOOL.name,
244
+ state: "upgrade",
245
+ reason: "behind",
246
+ installed: "1.0.0",
247
+ latest: "9.8.7",
248
+ target: "9.8.7",
249
+ };
250
+ const { suffix } = cliToolStatusText({
251
+ ...row(status),
252
+ updateCommand: {
253
+ cmd: "npm",
254
+ args: ["install", "-g", "@example/x@latest"],
255
+ },
256
+ });
257
+ expect(suffix).toContain("9.8.7");
258
+ });
259
+ });