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
@@ -0,0 +1,323 @@
1
+ /**
2
+ * Blind tests — contract C12 (requirement R9.5): a skill's frontmatter is read
3
+ * as YAML, with a line-by-line reading as the fallback when the block is not
4
+ * valid YAML.
5
+ *
6
+ * Written from requirements.md and architecture.md only. No implementation
7
+ * file was read. The module is loaded with a dynamic import inside each test,
8
+ * so a missing export fails its own tests one by one instead of failing the
9
+ * whole file at load time.
10
+ *
11
+ * readLocalSkillFrontmatter takes an absolute SKILL.md path, so every test
12
+ * writes its own SKILL.md into a fresh temp dir and passes that path. Nothing
13
+ * depends on HOME.
14
+ */
15
+ import { afterEach, describe, expect, test } from "bun:test";
16
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
17
+ import os from "node:os";
18
+ import path from "node:path";
19
+
20
+ const load = () => import("../../services/skills-manager.js");
21
+
22
+ const FALLBACK = "fallback-skill-name";
23
+
24
+ const dirs: string[] = [];
25
+ afterEach(() => {
26
+ while (dirs.length > 0) {
27
+ const dir = dirs.pop();
28
+ if (dir) rmSync(dir, { recursive: true, force: true });
29
+ }
30
+ });
31
+
32
+ function tempDir(): string {
33
+ const dir = mkdtempSync(path.join(os.tmpdir(), "magus-c12-"));
34
+ dirs.push(dir);
35
+ return dir;
36
+ }
37
+
38
+ /** Writes `lines` joined by "\n" (plus a trailing newline) as SKILL.md; returns its absolute path. */
39
+ function writeSkill(lines: string[]): string {
40
+ const dir = tempDir();
41
+ const file = path.join(dir, "SKILL.md");
42
+ writeFileSync(file, `${lines.join("\n")}\n`, "utf8");
43
+ return file;
44
+ }
45
+
46
+ /** Collapses every whitespace run to one space and trims — compares text independent of line breaks. */
47
+ const squash = (s: string): string => s.replace(/\s+/g, " ").trim();
48
+
49
+ async function read(file: string, fallback = FALLBACK) {
50
+ const { readLocalSkillFrontmatter } = await load();
51
+ return readLocalSkillFrontmatter(file, fallback);
52
+ }
53
+
54
+ describe("C12 — block-scalar descriptions yield the text", () => {
55
+ test("R9.5 folded `>-` over three indented lines yields the lines joined by single spaces", async () => {
56
+ const file = writeSkill([
57
+ "---",
58
+ "name: folded-skill",
59
+ "description: >-",
60
+ " Reviews pull requests for style.",
61
+ " Use when the user asks for a review,",
62
+ " or mentions lint.",
63
+ "---",
64
+ "",
65
+ "# Body",
66
+ ]);
67
+ const fm = await read(file);
68
+ expect(fm).not.toBeNull();
69
+ expect(fm?.name).toBe("folded-skill");
70
+ expect(fm?.description).toBe(
71
+ "Reviews pull requests for style. Use when the user asks for a review, or mentions lint.",
72
+ );
73
+ expect(fm?.description.startsWith(">")).toBe(false);
74
+ });
75
+
76
+ test("R9.5 folded `>-` over two indented lines yields the joined text, never the literal `>-`", async () => {
77
+ const file = writeSkill([
78
+ "---",
79
+ "name: two-line",
80
+ "description: >-",
81
+ " First half of the text",
82
+ " second half of the text",
83
+ "---",
84
+ ]);
85
+ const fm = await read(file);
86
+ expect(fm?.description).toBe(
87
+ "First half of the text second half of the text",
88
+ );
89
+ expect(fm?.description).not.toContain(">-");
90
+ });
91
+
92
+ test("R9.5 folded `>` (clip) yields the text, never a leading `>`", async () => {
93
+ const file = writeSkill([
94
+ "---",
95
+ "name: clip-folded",
96
+ "description: >",
97
+ " Alpha beta",
98
+ " gamma delta",
99
+ "---",
100
+ ]);
101
+ const fm = await read(file);
102
+ expect(fm?.description.startsWith(">")).toBe(false);
103
+ expect(squash(fm?.description ?? "")).toBe("Alpha beta gamma delta");
104
+ });
105
+
106
+ test("R9.5 literal `|` yields the text (line breaks allowed), never the literal `|`", async () => {
107
+ const file = writeSkill([
108
+ "---",
109
+ "name: literal-skill",
110
+ "description: |",
111
+ " First line of the description.",
112
+ " Second line of the description.",
113
+ "---",
114
+ ]);
115
+ const fm = await read(file);
116
+ expect(fm).not.toBeNull();
117
+ expect(fm?.name).toBe("literal-skill");
118
+ expect(fm?.description.startsWith("|")).toBe(false);
119
+ expect(fm?.description).not.toBe("|");
120
+ expect(squash(fm?.description ?? "")).toBe(
121
+ "First line of the description. Second line of the description.",
122
+ );
123
+ });
124
+
125
+ test("R9.5 literal `|-` yields the text, never the literal `|-`", async () => {
126
+ const file = writeSkill([
127
+ "---",
128
+ "name: literal-strip",
129
+ "description: |-",
130
+ " One",
131
+ " Two",
132
+ "---",
133
+ ]);
134
+ const fm = await read(file);
135
+ expect(fm?.description).not.toContain("|-");
136
+ expect(squash(fm?.description ?? "")).toBe("One Two");
137
+ });
138
+
139
+ test("R9.5 a block scalar ends at the next key — the following key's text is not in the description", async () => {
140
+ const file = writeSkill([
141
+ "---",
142
+ "description: >-",
143
+ " Text of the description",
144
+ " continues here",
145
+ "name: key-after-block",
146
+ "version: 1.2.3",
147
+ "---",
148
+ ]);
149
+ const fm = await read(file);
150
+ expect(fm?.name).toBe("key-after-block");
151
+ expect(fm?.description).toBe("Text of the description continues here");
152
+ expect(fm?.description).not.toContain("key-after-block");
153
+ expect(fm?.description).not.toContain("1.2.3");
154
+ });
155
+ });
156
+
157
+ describe("C12 — plain and quoted descriptions", () => {
158
+ test("R9.5 a plain one-line description is returned as written", async () => {
159
+ const file = writeSkill([
160
+ "---",
161
+ "name: plain-skill",
162
+ "description: foo bar",
163
+ "---",
164
+ ]);
165
+ const fm = await read(file);
166
+ expect(fm).not.toBeNull();
167
+ expect(fm?.name).toBe("plain-skill");
168
+ expect(fm?.description).toBe("foo bar");
169
+ });
170
+
171
+ test("R9.5 a double-quoted description containing `: ` yields the unquoted text", async () => {
172
+ const file = writeSkill([
173
+ "---",
174
+ "name: dq-skill",
175
+ 'description: "Use when: the user asks for help"',
176
+ "---",
177
+ ]);
178
+ const fm = await read(file);
179
+ expect(fm?.name).toBe("dq-skill");
180
+ expect(fm?.description).toBe("Use when: the user asks for help");
181
+ });
182
+
183
+ test("R9.5 a single-quoted description containing `: ` yields the unquoted text", async () => {
184
+ const file = writeSkill([
185
+ "---",
186
+ "name: sq-skill",
187
+ "description: 'Triggers: review, lint'",
188
+ "---",
189
+ ]);
190
+ const fm = await read(file);
191
+ expect(fm?.name).toBe("sq-skill");
192
+ expect(fm?.description).toBe("Triggers: review, lint");
193
+ });
194
+ });
195
+
196
+ describe("C12 — frontmatter that is not valid YAML falls back to a line-by-line reading", () => {
197
+ test("R9.5 an unquoted `: ` inside the description still yields the description and the name", async () => {
198
+ const file = writeSkill([
199
+ "---",
200
+ "name: colon-skill",
201
+ "description: Use when: the user asks",
202
+ "---",
203
+ ]);
204
+ const fm = await read(file);
205
+ expect(fm).not.toBeNull();
206
+ expect(fm?.name).toBe("colon-skill");
207
+ expect(fm?.description).toContain("Use when");
208
+ expect(fm?.description).toContain("the user asks");
209
+ });
210
+
211
+ test("R9.5 invalid YAML with the name AFTER the description still yields both", async () => {
212
+ const file = writeSkill([
213
+ "---",
214
+ "description: Reviews code. Use when: the user asks for a review",
215
+ "name: name-after-bad-line",
216
+ "---",
217
+ ]);
218
+ const fm = await read(file);
219
+ expect(fm?.name).toBe("name-after-bad-line");
220
+ expect(fm?.description).toContain("Reviews code. Use when");
221
+ expect(fm?.description).toContain("the user asks for a review");
222
+ });
223
+
224
+ test("R9.5 invalid YAML with no name falls back to the caller's name and keeps the description", async () => {
225
+ const file = writeSkill([
226
+ "---",
227
+ "description: Use when: the user asks",
228
+ "---",
229
+ ]);
230
+ const fm = await read(file, "caller-name");
231
+ expect(fm?.name).toBe("caller-name");
232
+ expect(fm?.description).toContain("Use when");
233
+ expect(fm?.description).toContain("the user asks");
234
+ });
235
+ });
236
+
237
+ describe("C12 — missing fields", () => {
238
+ test("R9.5 a missing name falls back to the caller's name", async () => {
239
+ const file = writeSkill(["---", "description: has no name", "---"]);
240
+ const fm = await read(file, "from-the-caller");
241
+ expect(fm).not.toBeNull();
242
+ expect(fm?.name).toBe("from-the-caller");
243
+ expect(fm?.description).toBe("has no name");
244
+ });
245
+
246
+ test("R9.5 a missing description yields the empty string", async () => {
247
+ const file = writeSkill(["---", "name: no-description", "---"]);
248
+ const fm = await read(file);
249
+ expect(fm).not.toBeNull();
250
+ expect(fm?.name).toBe("no-description");
251
+ expect(fm?.description).toBe("");
252
+ });
253
+
254
+ test("R9.5 a missing name and description yields the fallback name and the empty string", async () => {
255
+ const file = writeSkill(["---", "version: 1.0.0", "---"]);
256
+ const fm = await read(file);
257
+ expect(fm?.name).toBe(FALLBACK);
258
+ expect(fm?.description).toBe("");
259
+ });
260
+ });
261
+
262
+ describe("C12 — no frontmatter", () => {
263
+ test("R9.5 a file with no frontmatter yields the fallback name and the empty string", async () => {
264
+ const file = writeSkill(["# A skill", "", "Just a body, no frontmatter."]);
265
+ const fm = await read(file);
266
+ expect(fm).not.toBeNull();
267
+ expect(fm?.name).toBe(FALLBACK);
268
+ expect(fm?.description).toBe("");
269
+ });
270
+
271
+ test("R9.5 an empty file yields the fallback name and the empty string", async () => {
272
+ const dir = tempDir();
273
+ const file = path.join(dir, "SKILL.md");
274
+ writeFileSync(file, "", "utf8");
275
+ const fm = await read(file);
276
+ expect(fm).not.toBeNull();
277
+ expect(fm?.name).toBe(FALLBACK);
278
+ expect(fm?.description).toBe("");
279
+ });
280
+
281
+ test("R9.5 `name:`/`description:` lines in the body, outside the frontmatter, are not read", async () => {
282
+ const file = writeSkill([
283
+ "---",
284
+ "version: 2.0.0",
285
+ "---",
286
+ "",
287
+ "name: body-name",
288
+ "description: body description",
289
+ ]);
290
+ const fm = await read(file);
291
+ expect(fm?.name).toBe(FALLBACK);
292
+ expect(fm?.description).toBe("");
293
+ });
294
+
295
+ test("R9.5 a `---` block that does not start the file is not frontmatter", async () => {
296
+ const file = writeSkill([
297
+ "# Title first",
298
+ "---",
299
+ "name: not-leading",
300
+ "description: not leading",
301
+ "---",
302
+ ]);
303
+ const fm = await read(file);
304
+ expect(fm?.name).toBe(FALLBACK);
305
+ expect(fm?.description).toBe("");
306
+ });
307
+ });
308
+
309
+ describe("C12 — unreadable path", () => {
310
+ test("R9.5 a path that does not exist yields null", async () => {
311
+ const dir = tempDir();
312
+ const fm = await read(path.join(dir, "missing", "SKILL.md"));
313
+ expect(fm).toBeNull();
314
+ });
315
+
316
+ test("R9.5 a path that is a directory (cannot be read as a file) yields null", async () => {
317
+ const dir = tempDir();
318
+ const asDir = path.join(dir, "SKILL.md");
319
+ mkdirSync(asDir);
320
+ const fm = await read(asDir);
321
+ expect(fm).toBeNull();
322
+ });
323
+ });