magus-cli 7.3.1 → 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 (123) 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/c2-settings-parse.test.ts +350 -0
  13. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  14. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  15. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  16. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  17. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  18. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  19. package/src/__tests__/blind/child-run.ts +114 -0
  20. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  21. package/src/__tests__/component-badge.test.ts +20 -36
  22. package/src/__tests__/footer-hints.test.ts +1 -1
  23. package/src/__tests__/manifest.test.ts +25 -17
  24. package/src/__tests__/marketplace-badge.test.ts +4 -4
  25. package/src/__tests__/models-screen-state.test.ts +36 -13
  26. package/src/__tests__/scope-squares.test.tsx +28 -114
  27. package/src/__tests__/shutdown.test.ts +578 -0
  28. package/src/__tests__/styles-manager.test.ts +4 -4
  29. package/src/__tests__/styles-screen-state.test.ts +26 -12
  30. package/src/__tests__/tabbar-layout.test.ts +44 -32
  31. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  32. package/src/__tests__/tool-probe.test.ts +47 -22
  33. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  34. package/src/cli/ansi.ts +2 -2
  35. package/src/cli/bootstrap.ts +33 -2
  36. package/src/cli/doctor.ts +105 -14
  37. package/src/cli/install.ts +8 -2
  38. package/src/cli/profile.ts +18 -1
  39. package/src/cli/router.ts +31 -18
  40. package/src/cli/update-view.ts +1 -1
  41. package/src/data/cli-tools.ts +9 -1
  42. package/src/services/claude-settings.ts +151 -73
  43. package/src/services/cli-tool-commands.ts +15 -5
  44. package/src/services/cli-tool-version.ts +101 -0
  45. package/src/services/hook-registration.ts +23 -6
  46. package/src/services/manifest.ts +117 -52
  47. package/src/services/plugin-manager.ts +19 -5
  48. package/src/services/profile-adopt.ts +7 -2
  49. package/src/services/profile-startup.ts +87 -7
  50. package/src/services/profiles.ts +36 -23
  51. package/src/services/settings-file.ts +158 -4
  52. package/src/services/settings-manager.ts +19 -5
  53. package/src/services/skills-manager.ts +46 -0
  54. package/src/services/tool-probe.ts +272 -45
  55. package/src/services/toolchain.ts +39 -0
  56. package/src/services/update-engine.ts +5 -0
  57. package/src/services/update-plan.ts +9 -0
  58. package/src/tui.tsx +48 -21
  59. package/src/ui/App.tsx +58 -197
  60. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  61. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  62. package/src/ui/adapters/settingsAdapter.ts +0 -21
  63. package/src/ui/adapters/skillsAdapter.ts +27 -8
  64. package/src/ui/adapters/stylesAdapter.ts +15 -11
  65. package/src/ui/components/CategoryHeader.tsx +7 -1
  66. package/src/ui/components/EmptyFilterState.tsx +2 -2
  67. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  68. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  69. package/src/ui/components/TabBar.tsx +93 -21
  70. package/src/ui/components/layout/FooterHints.tsx +6 -27
  71. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  72. package/src/ui/components/layout/index.ts +1 -5
  73. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  74. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  75. package/src/ui/components/modals/MessageModal.tsx +3 -15
  76. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  77. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  78. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  79. package/src/ui/components/primitives/MetaText.tsx +1 -1
  80. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  81. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  82. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  83. package/src/ui/components/primitives/index.ts +4 -3
  84. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  85. package/src/ui/input/KeyRouter.tsx +180 -0
  86. package/src/ui/input/key-dispatch.ts +149 -0
  87. package/src/ui/keymap.ts +898 -0
  88. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  89. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  90. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  91. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  92. package/src/ui/renderers/modelRenderers.tsx +12 -13
  93. package/src/ui/renderers/modelVisuals.tsx +0 -2
  94. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  95. package/src/ui/renderers/profileRenderers.tsx +32 -37
  96. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  97. package/src/ui/renderers/skillRenderers.tsx +237 -190
  98. package/src/ui/renderers/styleRenderers.tsx +13 -2
  99. package/src/ui/runtime/shutdown.ts +268 -0
  100. package/src/ui/screens/AliasScreen.tsx +118 -100
  101. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  102. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  103. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  104. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  105. package/src/ui/screens/McpScreen.tsx +47 -33
  106. package/src/ui/screens/ModelsScreen.tsx +59 -104
  107. package/src/ui/screens/PluginsScreen.tsx +123 -127
  108. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  109. package/src/ui/screens/SkillsScreen.tsx +184 -204
  110. package/src/ui/screens/StylesScreen.tsx +104 -157
  111. package/src/ui/state/AppContext.tsx +6 -0
  112. package/src/ui/state/DimensionsContext.tsx +1 -10
  113. package/src/ui/state/reducer.ts +5 -8
  114. package/src/ui/state/types.ts +11 -9
  115. package/src/ui/text-width.ts +341 -0
  116. package/src/ui/theme-mode.ts +34 -71
  117. package/src/ui/theme.ts +50 -28
  118. package/src/utils/request-gate.ts +37 -0
  119. package/src/ui/components/ScopeIndicator.tsx +0 -63
  120. package/src/ui/components/SearchInput.tsx +0 -57
  121. package/src/ui/hooks/index.ts +0 -2
  122. package/src/ui/hooks/useKeyboard.ts +0 -24
  123. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Blind tests — requirement R2.8.5 (wrong shape ONE LEVEL DOWN), through
3
+ * contracts C2 and C13.
4
+ *
5
+ * Written from requirements.md and architecture.md only.
6
+ *
7
+ * R2.8.5: when `enabledPlugins` (a map magus writes an entry into) is present
8
+ * but is not a JSON object — `[]`, `["dev@magus"]`, `null`, a string, a
9
+ * number — `enablePlugin` must refuse the write with a `SettingsParseError`-
10
+ * class error naming the file AND the key, and leave the file byte-identical.
11
+ * It must never report success while `JSON.stringify` silently drops the
12
+ * entry (an array with a string property serialises as the bare array).
13
+ *
14
+ * Controls prove the refusals are refusals and not an `enablePlugin` that
15
+ * always fails: `{"enabledPlugins": {}}` and a file with no `enabledPlugins`
16
+ * key both resolve, record the entry, and keep every other top-level key.
17
+ *
18
+ * Isolation: `enablePlugin` lives in claude-settings, which can resolve
19
+ * `~/.claude`, so every call runs in a child `bun` with a temp HOME
20
+ * (architecture.md C2 correction; see ./child-run.ts). The child reports
21
+ * booleans and class names only — never an error message or a value read from
22
+ * a file. The settings files read back here are this test's own temp testdata.
23
+ */
24
+ import { afterEach, describe, expect, test } from "bun:test";
25
+ import {
26
+ mkdirSync,
27
+ mkdtempSync,
28
+ readFileSync,
29
+ realpathSync,
30
+ rmSync,
31
+ writeFileSync,
32
+ } from "node:fs";
33
+ import os from "node:os";
34
+ import path from "node:path";
35
+ import { type ChildResult, runInChild } from "./child-run.js";
36
+
37
+ const SERVICES = path.resolve(import.meta.dir, "../../services");
38
+ const CLAUDE_SETTINGS = path.join(SERVICES, "claude-settings.ts");
39
+ const SETTINGS_FILE = path.join(SERVICES, "settings-file.ts");
40
+ const T = 30_000; // per-test timeout: each test spawns one child bun
41
+
42
+ const PLUGIN = "dev@magus";
43
+ const KEY = "enabledPlugins";
44
+
45
+ const dirs: string[] = [];
46
+ afterEach(() => {
47
+ for (const dir of dirs.splice(0))
48
+ rmSync(dir, { recursive: true, force: true });
49
+ });
50
+
51
+ function tempDir(tag: string): string {
52
+ // realpath: on macOS tmpdir is /var/… → /private/var/…; one spelling only.
53
+ const dir = realpathSync(
54
+ mkdtempSync(path.join(os.tmpdir(), `magus-blind-c16-${tag}-`)),
55
+ );
56
+ dirs.push(dir);
57
+ return dir;
58
+ }
59
+
60
+ interface Case {
61
+ home: string;
62
+ project: string;
63
+ /** <project>/.claude/settings.json — the file enablePlugin writes. */
64
+ file: string;
65
+ }
66
+
67
+ function newCase(content: string): Case {
68
+ const home = tempDir("home");
69
+ const project = tempDir("proj");
70
+ const file = path.join(project, ".claude", "settings.json");
71
+ mkdirSync(path.dirname(file), { recursive: true });
72
+ writeFileSync(file, content);
73
+ return { home, project, file };
74
+ }
75
+
76
+ // ── the child call ──────────────────────────────────────────────────────────
77
+
78
+ /**
79
+ * Child body: `await mod.enablePlugin(...job.args)`.
80
+ * Success → `{ ok: true }`. Failure → whether the error is a
81
+ * SettingsParseError (by name, by a constructor named SettingsParseError on
82
+ * its prototype chain — i.e. a subclass — or by instanceof an exported
83
+ * class), and whether its message names the file and the key. The message
84
+ * itself is never reported.
85
+ */
86
+ const ENABLE_BODY = `
87
+ const fs = await import("node:fs");
88
+ const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } };
89
+ const classes = [];
90
+ for (const m of job.errorModules || []) {
91
+ try {
92
+ const x = await import(m);
93
+ if (typeof x.SettingsParseError === "function") classes.push(x.SettingsParseError);
94
+ } catch {}
95
+ }
96
+ try {
97
+ await mod.enablePlugin(...job.args);
98
+ await out({ stage: "done", ok: true });
99
+ } catch (e) {
100
+ const isErr = e instanceof Error;
101
+ const chain = [];
102
+ for (let p = e !== null && typeof e === "object" ? Object.getPrototypeOf(e) : null; p; p = Object.getPrototypeOf(p)) {
103
+ const c = Object.prototype.hasOwnProperty.call(p, "constructor") ? p.constructor : null;
104
+ if (c && typeof c.name === "string") chain.push(c.name);
105
+ }
106
+ const msg = isErr && typeof e.message === "string" ? e.message : "";
107
+ const want = real(job.file);
108
+ const errPath = isErr && typeof e.path === "string" ? e.path : null;
109
+ await out({
110
+ stage: "done",
111
+ ok: false,
112
+ isError: isErr,
113
+ errorName: isErr ? String(e.name) : typeof e,
114
+ nameIsSettingsParseError: isErr && e.name === "SettingsParseError",
115
+ chainHasSettingsParseError: chain.includes("SettingsParseError"),
116
+ instanceOfSettingsParseError: classes.some((C) => e instanceof C),
117
+ messageNamesFile: msg.includes(job.file) || (!!want && msg.includes(want)),
118
+ messageNamesKey: msg.includes(job.key),
119
+ pathIsFile: errPath !== null && (errPath === job.file || (!!want && real(errPath) === want)),
120
+ });
121
+ }
122
+ `;
123
+
124
+ function enable(c: Case, enabled: boolean): ChildResult {
125
+ return runInChild({
126
+ home: c.home,
127
+ cwd: c.project,
128
+ module: CLAUDE_SETTINGS,
129
+ job: {
130
+ args: [PLUGIN, enabled, c.project],
131
+ file: c.file,
132
+ key: KEY,
133
+ errorModules: [SETTINGS_FILE, CLAUDE_SETTINGS],
134
+ },
135
+ body: ENABLE_BODY,
136
+ });
137
+ }
138
+
139
+ /** The refusal R2.8.5 asks for, reduced to what the tests assert on. */
140
+ function refusal(r: ChildResult) {
141
+ return {
142
+ stage: r.stage,
143
+ ok: r.ok,
144
+ settingsParseError:
145
+ r.nameIsSettingsParseError === true ||
146
+ r.chainHasSettingsParseError === true ||
147
+ r.instanceOfSettingsParseError === true,
148
+ messageNamesFile: r.messageNamesFile,
149
+ messageNamesKey: r.messageNamesKey,
150
+ // Diagnostic only (e.g. a raw TypeError, R2.8.4). Not asserted.
151
+ errorName: r.errorName,
152
+ };
153
+ }
154
+
155
+ // ── wrong shapes: refused, for enabled = true AND enabled = false ───────────
156
+
157
+ /** R2.8.5: `enabledPlugins` present but not a JSON object. */
158
+ const WRONG_SHAPES: Record<string, string> = {
159
+ "an empty array []": '{"enabledPlugins": []}\n',
160
+ 'an array ["dev@magus"]': '{"enabledPlugins": ["dev@magus"]}\n',
161
+ null: '{"enabledPlugins": null}\n',
162
+ 'a string "dev@magus"': '{"enabledPlugins": "dev@magus"}\n',
163
+ "a number 5": '{"enabledPlugins": 5}\n',
164
+ };
165
+
166
+ describe("C16 — enablePlugin when <project>/.claude/settings.json has a wrong-shape enabledPlugins", () => {
167
+ for (const [shape, content] of Object.entries(WRONG_SHAPES)) {
168
+ for (const enabled of [true, false]) {
169
+ const call = `enablePlugin("${PLUGIN}", ${enabled})`;
170
+
171
+ test(
172
+ `R2.8.5 ${call} rejects when enabledPlugins is ${shape}, with a SettingsParseError-class error, and never resolves`,
173
+ () => {
174
+ const c = newCase(content);
175
+ expect(refusal(enable(c, enabled))).toMatchObject({
176
+ stage: "done",
177
+ ok: false,
178
+ settingsParseError: true,
179
+ });
180
+ },
181
+ T,
182
+ );
183
+
184
+ test(
185
+ `R2.8.5 ${call} on enabledPlugins = ${shape}: the error message names the file and the key "enabledPlugins"`,
186
+ () => {
187
+ const c = newCase(content);
188
+ expect(refusal(enable(c, enabled))).toMatchObject({
189
+ stage: "done",
190
+ ok: false,
191
+ messageNamesFile: true,
192
+ messageNamesKey: true,
193
+ });
194
+ },
195
+ T,
196
+ );
197
+
198
+ test(
199
+ `R2.8.5 ${call} leaves a settings.json whose enabledPlugins is ${shape} byte-identical`,
200
+ () => {
201
+ const c = newCase(content);
202
+ const before = readFileSync(c.file);
203
+ const r = enable(c, enabled);
204
+ expect(r.stage).toBe("done");
205
+ expect(readFileSync(c.file).equals(before)).toBe(true);
206
+ },
207
+ T,
208
+ );
209
+ }
210
+ }
211
+
212
+ test(
213
+ "R2.8.5 the refusal's path property is the settings file (C2: SettingsParseError carries path)",
214
+ () => {
215
+ const c = newCase(WRONG_SHAPES["an empty array []"] as string);
216
+ expect(enable(c, true)).toMatchObject({
217
+ stage: "done",
218
+ ok: false,
219
+ pathIsFile: true,
220
+ });
221
+ },
222
+ T,
223
+ );
224
+ });
225
+
226
+ // ── controls: a well-shaped map, or no key at all, is written normally ──────
227
+
228
+ /** Top-level keys that must survive the read-modify-write untouched. */
229
+ const OTHER_KEYS = {
230
+ blindMarker: [1, "two", null, { three: 3 }],
231
+ permissions: { allow: ["Bash(ls:*)"] },
232
+ includeCoAuthoredBy: false,
233
+ };
234
+
235
+ const CONTROLS: Record<string, Record<string, unknown>> = {
236
+ '{"enabledPlugins": {}}': { enabledPlugins: {}, ...OTHER_KEYS },
237
+ "a file without the enabledPlugins key": { ...OTHER_KEYS },
238
+ };
239
+
240
+ function readBack(c: Case): Record<string, unknown> {
241
+ // This test's own temp testdata — safe to read.
242
+ return JSON.parse(readFileSync(c.file, "utf8")) as Record<string, unknown>;
243
+ }
244
+
245
+ describe("C16 — enablePlugin controls (well-shaped or absent enabledPlugins)", () => {
246
+ for (const [label, settings] of Object.entries(CONTROLS)) {
247
+ const content = `${JSON.stringify(settings, null, 2)}\n`;
248
+
249
+ test(
250
+ `R2.8.5 control: enablePlugin("${PLUGIN}", true) on ${label} resolves and records enabledPlugins["${PLUGIN}"] === true`,
251
+ () => {
252
+ const c = newCase(content);
253
+ expect(enable(c, true)).toMatchObject({ stage: "done", ok: true });
254
+ const after = readBack(c);
255
+ const map = after[KEY] as Record<string, unknown> | undefined;
256
+ expect(map?.[PLUGIN]).toBe(true);
257
+ },
258
+ T,
259
+ );
260
+
261
+ test(
262
+ `R2.8.5 control: enablePlugin("${PLUGIN}", true) on ${label} preserves the other top-level keys`,
263
+ () => {
264
+ const c = newCase(content);
265
+ expect(enable(c, true)).toMatchObject({ stage: "done", ok: true });
266
+ const after = readBack(c);
267
+ for (const [k, v] of Object.entries(OTHER_KEYS)) {
268
+ expect(after[k]).toEqual(v);
269
+ }
270
+ },
271
+ T,
272
+ );
273
+
274
+ test(
275
+ `R2.8.5 control: enablePlugin("${PLUGIN}", false) on ${label} resolves and records enabledPlugins["${PLUGIN}"] === false`,
276
+ () => {
277
+ const c = newCase(content);
278
+ expect(enable(c, false)).toMatchObject({ stage: "done", ok: true });
279
+ const map = readBack(c)[KEY] as Record<string, unknown> | undefined;
280
+ expect(map?.[PLUGIN]).toBe(false);
281
+ },
282
+ T,
283
+ );
284
+ }
285
+
286
+ test(
287
+ "R2.8.5 control: another plugin's existing enabledPlugins entry is kept",
288
+ () => {
289
+ const c = newCase(
290
+ `${JSON.stringify({ enabledPlugins: { "other@blind": true } }, null, 2)}\n`,
291
+ );
292
+ expect(enable(c, true)).toMatchObject({ stage: "done", ok: true });
293
+ expect(readBack(c)[KEY]).toEqual({ "other@blind": true, [PLUGIN]: true });
294
+ },
295
+ T,
296
+ );
297
+ });