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.
- package/package.json +4 -4
- package/scripts/build-binaries.ts +4 -2
- package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
- package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
- package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
- package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
- package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
- package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
- package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
- package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
- package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
- package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
- package/src/__tests__/blind/c5-keymap.test.ts +461 -0
- package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
- package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
- package/src/__tests__/blind/c8-text-width.test.ts +137 -0
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
- package/src/__tests__/blind/child-run.ts +114 -0
- package/src/__tests__/cli-tools-adapter.test.ts +5 -2
- package/src/__tests__/component-badge.test.ts +20 -36
- package/src/__tests__/footer-hints.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +25 -17
- package/src/__tests__/marketplace-badge.test.ts +4 -4
- package/src/__tests__/models-screen-state.test.ts +36 -13
- package/src/__tests__/npm-package-name-api.test.ts +111 -0
- package/src/__tests__/npm-package-name.test.ts +280 -0
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/styles-manager.test.ts +4 -4
- package/src/__tests__/styles-screen-state.test.ts +26 -12
- package/src/__tests__/tabbar-layout.test.ts +44 -32
- package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
- package/src/__tests__/tool-probe.test.ts +47 -22
- package/src/__tests__/upgrade-spawn.test.ts +355 -0
- package/src/__tests__/uppercase-keybindings.test.ts +25 -12
- package/src/cli/ansi.ts +2 -2
- package/src/cli/bootstrap.ts +33 -2
- package/src/cli/doctor.ts +105 -14
- package/src/cli/install.ts +8 -2
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/cli/upgrade.ts +37 -9
- package/src/data/cli-tools.ts +13 -2
- package/src/data/npm-package.ts +15 -0
- package/src/services/claude-settings.ts +151 -73
- package/src/services/cli-tool-commands.ts +15 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/hook-registration.ts +23 -6
- package/src/services/manifest.ts +117 -52
- package/src/services/plugin-manager.ts +19 -5
- package/src/services/profile-adopt.ts +7 -2
- package/src/services/profile-startup.ts +87 -7
- package/src/services/profiles.ts +36 -23
- package/src/services/settings-file.ts +158 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +272 -45
- package/src/services/toolchain.ts +39 -0
- package/src/services/update-engine.ts +5 -0
- package/src/services/update-plan.ts +9 -0
- package/src/services/version-check.ts +20 -10
- package/src/tui.tsx +48 -21
- package/src/ui/App.tsx +58 -197
- package/src/ui/adapters/cliToolsAdapter.ts +141 -12
- package/src/ui/adapters/pluginsAdapter.ts +10 -7
- package/src/ui/adapters/settingsAdapter.ts +0 -21
- package/src/ui/adapters/skillsAdapter.ts +27 -8
- package/src/ui/adapters/stylesAdapter.ts +15 -11
- package/src/ui/components/CategoryHeader.tsx +7 -1
- package/src/ui/components/EmptyFilterState.tsx +2 -2
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
- package/src/ui/components/TabBar.tsx +93 -21
- package/src/ui/components/layout/FooterHints.tsx +6 -27
- package/src/ui/components/layout/ScreenLayout.tsx +68 -42
- package/src/ui/components/layout/index.ts +1 -5
- package/src/ui/components/modals/ConfirmModal.tsx +2 -19
- package/src/ui/components/modals/HelpOverlay.tsx +186 -0
- package/src/ui/components/modals/MessageModal.tsx +3 -15
- package/src/ui/components/modals/ModalContainer.tsx +99 -60
- package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
- package/src/ui/components/primitives/MetaText.tsx +1 -1
- package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
- package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
- package/src/ui/components/primitives/SelectableRow.tsx +24 -1
- package/src/ui/components/primitives/index.ts +4 -3
- package/src/ui/hooks/useProfileStartupModal.ts +15 -3
- package/src/ui/input/KeyRouter.tsx +180 -0
- package/src/ui/input/key-dispatch.ts +149 -0
- package/src/ui/keymap.ts +898 -0
- package/src/ui/renderers/cliToolRenderers.tsx +128 -66
- package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
- package/src/ui/renderers/markdownRenderer.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +28 -14
- package/src/ui/renderers/modelRenderers.tsx +12 -13
- package/src/ui/renderers/modelVisuals.tsx +0 -2
- package/src/ui/renderers/pluginRenderers.tsx +47 -54
- package/src/ui/renderers/profileRenderers.tsx +32 -37
- package/src/ui/renderers/settingsRenderers.tsx +84 -55
- package/src/ui/renderers/skillRenderers.tsx +237 -190
- package/src/ui/renderers/styleRenderers.tsx +13 -2
- package/src/ui/runtime/shutdown.ts +268 -0
- package/src/ui/screens/AliasScreen.tsx +118 -100
- package/src/ui/screens/CliToolsScreen.tsx +98 -87
- package/src/ui/screens/EnvVarsScreen.tsx +44 -42
- package/src/ui/screens/GitignoreScreen.tsx +50 -55
- package/src/ui/screens/McpRegistryScreen.tsx +52 -86
- package/src/ui/screens/McpScreen.tsx +47 -33
- package/src/ui/screens/ModelsScreen.tsx +59 -104
- package/src/ui/screens/PluginsScreen.tsx +123 -127
- package/src/ui/screens/ProfilesScreen.tsx +49 -46
- package/src/ui/screens/SkillsScreen.tsx +184 -204
- package/src/ui/screens/StylesScreen.tsx +104 -157
- package/src/ui/state/AppContext.tsx +6 -0
- package/src/ui/state/DimensionsContext.tsx +1 -10
- package/src/ui/state/reducer.ts +5 -8
- package/src/ui/state/types.ts +11 -9
- package/src/ui/text-width.ts +341 -0
- package/src/ui/theme-mode.ts +34 -71
- package/src/ui/theme.ts +50 -28
- package/src/utils/request-gate.ts +37 -0
- package/src/ui/components/ScopeIndicator.tsx +0 -63
- package/src/ui/components/SearchInput.tsx +0 -57
- package/src/ui/hooks/index.ts +0 -2
- package/src/ui/hooks/useKeyboard.ts +0 -24
- package/src/ui/hooks/useKeyboardHandler.ts +0 -63
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blind tests — contract C18 (requirement R2.8.6), with C2 / C13 for the
|
|
3
|
+
* SettingsParseError class.
|
|
4
|
+
*
|
|
5
|
+
* Written from requirements.md and architecture.md only.
|
|
6
|
+
*
|
|
7
|
+
* R2.8.6: registering the agent-model hook refuses a user settings file whose
|
|
8
|
+
* `hooks` is present but not a JSON object, or whose `hooks.PreToolUse` is
|
|
9
|
+
* present but not an array — with a `SettingsParseError`-class error naming
|
|
10
|
+
* the file — and leaves it byte-identical. It never replaces them with empty
|
|
11
|
+
* ones (the user's own hooks live there). Well-shaped `hooks` keep every
|
|
12
|
+
* existing entry and gain the magus one.
|
|
13
|
+
*
|
|
14
|
+
* C18: `registerAgentModelHook({ configDir })` writes `<configDir>/settings.json`
|
|
15
|
+
* and resolves "registered" | "already". It adds
|
|
16
|
+
* `{ matcher: "Agent", hooks: [{ type: "command", command: AGENT_MODEL_HOOK_COMMAND, … }] }`
|
|
17
|
+
* under `hooks.PreToolUse`. AGENT_MODEL_HOOK_COMMAND is "magus hook agent-model".
|
|
18
|
+
*
|
|
19
|
+
* Isolation: the function's default target is user scope (under HOME), and a
|
|
20
|
+
* black-box test cannot rule out a fallback to it despite `configDir`. So every
|
|
21
|
+
* call runs in a child `bun` with a temp HOME and a temp cwd (architecture.md
|
|
22
|
+
* C2 correction; see ./child-run.ts), and one control checks that nothing
|
|
23
|
+
* appears under that HOME. The child reports booleans, class names and the
|
|
24
|
+
* contract's own return value — never an error message or a value read from a
|
|
25
|
+
* file. The settings files read back here are this test's own temp testdata.
|
|
26
|
+
*/
|
|
27
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
28
|
+
import {
|
|
29
|
+
existsSync,
|
|
30
|
+
mkdtempSync,
|
|
31
|
+
readFileSync,
|
|
32
|
+
realpathSync,
|
|
33
|
+
rmSync,
|
|
34
|
+
writeFileSync,
|
|
35
|
+
} from "node:fs";
|
|
36
|
+
import os from "node:os";
|
|
37
|
+
import path from "node:path";
|
|
38
|
+
import { type ChildResult, runInChild } from "./child-run.js";
|
|
39
|
+
|
|
40
|
+
const SERVICES = path.resolve(import.meta.dir, "../../services");
|
|
41
|
+
const HOOK_REGISTRATION = path.join(SERVICES, "hook-registration.ts");
|
|
42
|
+
const SETTINGS_FILE = path.join(SERVICES, "settings-file.ts");
|
|
43
|
+
const CLAUDE_SETTINGS = path.join(SERVICES, "claude-settings.ts");
|
|
44
|
+
const T = 30_000; // per-test timeout: each child bun spawn gets up to 25s
|
|
45
|
+
|
|
46
|
+
const AGENT_MODEL_HOOK_COMMAND = "magus hook agent-model";
|
|
47
|
+
|
|
48
|
+
const dirs: string[] = [];
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
for (const dir of dirs.splice(0))
|
|
51
|
+
rmSync(dir, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function tempDir(tag: string): string {
|
|
55
|
+
// realpath: on macOS tmpdir is /var/… → /private/var/…; one spelling only.
|
|
56
|
+
const dir = realpathSync(
|
|
57
|
+
mkdtempSync(path.join(os.tmpdir(), `magus-blind-c18-${tag}-`)),
|
|
58
|
+
);
|
|
59
|
+
dirs.push(dir);
|
|
60
|
+
return dir;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface Case {
|
|
64
|
+
home: string;
|
|
65
|
+
cwd: string;
|
|
66
|
+
configDir: string;
|
|
67
|
+
/** <configDir>/settings.json — the only file the call may touch. */
|
|
68
|
+
file: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A fresh temp HOME, cwd and configDir; `content` null means no settings.json. */
|
|
72
|
+
function newCase(content: string | null): Case {
|
|
73
|
+
const home = tempDir("home");
|
|
74
|
+
const cwd = tempDir("cwd");
|
|
75
|
+
const configDir = tempDir("cfg");
|
|
76
|
+
const file = path.join(configDir, "settings.json");
|
|
77
|
+
if (content !== null) writeFileSync(file, content);
|
|
78
|
+
return { home, cwd, configDir, file };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── the child call ──────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Child body: `await mod.registerAgentModelHook({ configDir: job.configDir })`.
|
|
85
|
+
* Success → `{ ok: true, result }`, where `result` is reported only when it is
|
|
86
|
+
* one of the contract's two strings. Failure → whether the error is a
|
|
87
|
+
* SettingsParseError (by name, by a constructor named SettingsParseError on
|
|
88
|
+
* its prototype chain — i.e. a subclass — or by instanceof an exported
|
|
89
|
+
* class), and whether its message names the settings file. The message itself
|
|
90
|
+
* is never reported.
|
|
91
|
+
*/
|
|
92
|
+
const REGISTER_BODY = `
|
|
93
|
+
const fs = await import("node:fs");
|
|
94
|
+
const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } };
|
|
95
|
+
const classes = [];
|
|
96
|
+
for (const m of job.errorModules || []) {
|
|
97
|
+
try {
|
|
98
|
+
const x = await import(m);
|
|
99
|
+
if (typeof x.SettingsParseError === "function") classes.push(x.SettingsParseError);
|
|
100
|
+
} catch {}
|
|
101
|
+
}
|
|
102
|
+
const k = mod.AGENT_MODEL_HOOK_COMMAND;
|
|
103
|
+
const commandConst = typeof k === "string" && k.length < 200 ? k : "<" + typeof k + ">";
|
|
104
|
+
if (typeof mod.registerAgentModelHook !== "function") {
|
|
105
|
+
await out({ stage: "missing-export", commandConst });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
const r = await mod.registerAgentModelHook({ configDir: job.configDir });
|
|
110
|
+
await out({
|
|
111
|
+
stage: "done",
|
|
112
|
+
ok: true,
|
|
113
|
+
result: r === "registered" || r === "already" ? r : "<" + typeof r + ">",
|
|
114
|
+
commandConst,
|
|
115
|
+
});
|
|
116
|
+
} catch (e) {
|
|
117
|
+
const isErr = e instanceof Error;
|
|
118
|
+
const chain = [];
|
|
119
|
+
for (let p = e !== null && typeof e === "object" ? Object.getPrototypeOf(e) : null; p; p = Object.getPrototypeOf(p)) {
|
|
120
|
+
const c = Object.prototype.hasOwnProperty.call(p, "constructor") ? p.constructor : null;
|
|
121
|
+
if (c && typeof c.name === "string") chain.push(c.name);
|
|
122
|
+
}
|
|
123
|
+
const msg = isErr && typeof e.message === "string" ? e.message : "";
|
|
124
|
+
const want = real(job.file);
|
|
125
|
+
await out({
|
|
126
|
+
stage: "done",
|
|
127
|
+
ok: false,
|
|
128
|
+
isError: isErr,
|
|
129
|
+
errorName: isErr ? String(e.name) : typeof e,
|
|
130
|
+
nameIsSettingsParseError: isErr && e.name === "SettingsParseError",
|
|
131
|
+
chainHasSettingsParseError: chain.includes("SettingsParseError"),
|
|
132
|
+
instanceOfSettingsParseError: classes.some((C) => e instanceof C),
|
|
133
|
+
messageNamesFile: msg.includes(job.file) || (!!want && msg.includes(want)),
|
|
134
|
+
commandConst,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
`;
|
|
138
|
+
|
|
139
|
+
function register(c: Case): ChildResult {
|
|
140
|
+
return runInChild({
|
|
141
|
+
home: c.home,
|
|
142
|
+
cwd: c.cwd,
|
|
143
|
+
module: HOOK_REGISTRATION,
|
|
144
|
+
job: {
|
|
145
|
+
configDir: c.configDir,
|
|
146
|
+
file: c.file,
|
|
147
|
+
errorModules: [SETTINGS_FILE, CLAUDE_SETTINGS, HOOK_REGISTRATION],
|
|
148
|
+
},
|
|
149
|
+
body: REGISTER_BODY,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The refusal R2.8.6 asks for, reduced to what the tests assert on. */
|
|
154
|
+
function refusal(r: ChildResult) {
|
|
155
|
+
return {
|
|
156
|
+
stage: r.stage,
|
|
157
|
+
ok: r.ok,
|
|
158
|
+
settingsParseError:
|
|
159
|
+
r.nameIsSettingsParseError === true ||
|
|
160
|
+
r.chainHasSettingsParseError === true ||
|
|
161
|
+
r.instanceOfSettingsParseError === true,
|
|
162
|
+
messageNamesFile: r.messageNamesFile,
|
|
163
|
+
// Diagnostic only (e.g. a raw TypeError, R2.8.4). Not asserted.
|
|
164
|
+
errorName: r.errorName,
|
|
165
|
+
result: r.result,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ── wrong shapes: refused ───────────────────────────────────────────────────
|
|
170
|
+
|
|
171
|
+
const USER_BASH_BLOCK = {
|
|
172
|
+
matcher: "Bash",
|
|
173
|
+
hooks: [{ type: "command", command: "my-guard.sh" }],
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** R2.8.6: `hooks` not an object, or `hooks.PreToolUse` not an array. */
|
|
177
|
+
const WRONG_SHAPES: Record<string, string> = {
|
|
178
|
+
'hooks is an array {"hooks": []}': '{"hooks": []}\n',
|
|
179
|
+
'hooks is a string {"hooks": "x"}': '{"hooks": "x"}\n',
|
|
180
|
+
'hooks is a number {"hooks": 5}': '{"hooks": 5}\n',
|
|
181
|
+
"hooks.PreToolUse is one block object, not an array": `${JSON.stringify({
|
|
182
|
+
hooks: { PreToolUse: USER_BASH_BLOCK },
|
|
183
|
+
})}\n`,
|
|
184
|
+
'hooks.PreToolUse is a string {"hooks": {"PreToolUse": "x"}}':
|
|
185
|
+
'{"hooks": {"PreToolUse": "x"}}\n',
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
describe("C18 — registerAgentModelHook refuses a wrong-shape hooks in <configDir>/settings.json", () => {
|
|
189
|
+
for (const [shape, content] of Object.entries(WRONG_SHAPES)) {
|
|
190
|
+
test(
|
|
191
|
+
`R2.8.6 ${shape}: rejects with a SettingsParseError-class error and never resolves`,
|
|
192
|
+
() => {
|
|
193
|
+
const c = newCase(content);
|
|
194
|
+
expect(refusal(register(c))).toMatchObject({
|
|
195
|
+
stage: "done",
|
|
196
|
+
ok: false,
|
|
197
|
+
settingsParseError: true,
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
T,
|
|
201
|
+
);
|
|
202
|
+
|
|
203
|
+
test(
|
|
204
|
+
`R2.8.6 ${shape}: the error message names <configDir>/settings.json`,
|
|
205
|
+
() => {
|
|
206
|
+
const c = newCase(content);
|
|
207
|
+
expect(refusal(register(c))).toMatchObject({
|
|
208
|
+
stage: "done",
|
|
209
|
+
ok: false,
|
|
210
|
+
messageNamesFile: true,
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
T,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
test(
|
|
217
|
+
`R2.8.6 ${shape}: settings.json stays byte-identical`,
|
|
218
|
+
() => {
|
|
219
|
+
const c = newCase(content);
|
|
220
|
+
const before = readFileSync(c.file);
|
|
221
|
+
const r = register(c);
|
|
222
|
+
expect(r.stage).toBe("done");
|
|
223
|
+
expect(readFileSync(c.file).equals(before)).toBe(true);
|
|
224
|
+
},
|
|
225
|
+
T,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ── controls: absent or well-shaped hooks gain the magus Agent block ────────
|
|
231
|
+
|
|
232
|
+
const CONTROLS: Record<string, string | null> = {
|
|
233
|
+
"no settings.json": null,
|
|
234
|
+
"{}": "{}\n",
|
|
235
|
+
'{"hooks": {}}': '{"hooks": {}}\n',
|
|
236
|
+
"a user Bash hook in PreToolUse and other: 1": `${JSON.stringify({
|
|
237
|
+
hooks: { PreToolUse: [USER_BASH_BLOCK] },
|
|
238
|
+
other: 1,
|
|
239
|
+
})}\n`,
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
function readBack(c: Case): Record<string, unknown> {
|
|
243
|
+
// This test's own temp testdata — safe to read.
|
|
244
|
+
return JSON.parse(readFileSync(c.file, "utf8")) as Record<string, unknown>;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function preToolUse(settings: Record<string, unknown>): unknown[] | null {
|
|
248
|
+
const hooks = settings.hooks as Record<string, unknown> | undefined;
|
|
249
|
+
const pre = hooks?.PreToolUse;
|
|
250
|
+
return Array.isArray(pre) ? pre : null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Every hook entry of type "command" running the magus command in an Agent block. */
|
|
254
|
+
function magusAgentHooks(settings: Record<string, unknown>): unknown[] {
|
|
255
|
+
const found: unknown[] = [];
|
|
256
|
+
for (const block of preToolUse(settings) ?? []) {
|
|
257
|
+
const b = block as { matcher?: unknown; hooks?: unknown };
|
|
258
|
+
if (b?.matcher !== "Agent" || !Array.isArray(b.hooks)) continue;
|
|
259
|
+
for (const h of b.hooks as { type?: unknown; command?: unknown }[]) {
|
|
260
|
+
if (h?.type === "command" && h.command === AGENT_MODEL_HOOK_COMMAND)
|
|
261
|
+
found.push(h);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return found;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
describe("C18 — registerAgentModelHook controls (absent or well-shaped hooks)", () => {
|
|
268
|
+
test(
|
|
269
|
+
`R2.8.6 AGENT_MODEL_HOOK_COMMAND is "${AGENT_MODEL_HOOK_COMMAND}"`,
|
|
270
|
+
() => {
|
|
271
|
+
const c = newCase("{}\n");
|
|
272
|
+
expect(register(c).commandConst).toBe(AGENT_MODEL_HOOK_COMMAND);
|
|
273
|
+
},
|
|
274
|
+
T,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
for (const [label, content] of Object.entries(CONTROLS)) {
|
|
278
|
+
test(
|
|
279
|
+
`R2.8.6 control (${label}): resolves "registered"`,
|
|
280
|
+
() => {
|
|
281
|
+
const c = newCase(content);
|
|
282
|
+
expect(refusal(register(c))).toMatchObject({
|
|
283
|
+
stage: "done",
|
|
284
|
+
ok: true,
|
|
285
|
+
result: "registered",
|
|
286
|
+
});
|
|
287
|
+
},
|
|
288
|
+
T,
|
|
289
|
+
);
|
|
290
|
+
|
|
291
|
+
test(
|
|
292
|
+
`R2.8.6 control (${label}): hooks.PreToolUse gains one Agent block running "${AGENT_MODEL_HOOK_COMMAND}"`,
|
|
293
|
+
() => {
|
|
294
|
+
const c = newCase(content);
|
|
295
|
+
expect(register(c)).toMatchObject({ stage: "done", ok: true });
|
|
296
|
+
const after = readBack(c);
|
|
297
|
+
expect(Array.isArray(preToolUse(after))).toBe(true);
|
|
298
|
+
expect(magusAgentHooks(after).length).toBe(1);
|
|
299
|
+
},
|
|
300
|
+
T,
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
test(
|
|
304
|
+
`R2.8.6 control (${label}): a second call resolves "already" and leaves settings.json byte-identical`,
|
|
305
|
+
() => {
|
|
306
|
+
const c = newCase(content);
|
|
307
|
+
expect(register(c)).toMatchObject({
|
|
308
|
+
stage: "done",
|
|
309
|
+
ok: true,
|
|
310
|
+
result: "registered",
|
|
311
|
+
});
|
|
312
|
+
const afterFirst = readFileSync(c.file);
|
|
313
|
+
// A fresh process: "already" must come from the file, not memory.
|
|
314
|
+
expect(refusal(register(c))).toMatchObject({
|
|
315
|
+
stage: "done",
|
|
316
|
+
ok: true,
|
|
317
|
+
result: "already",
|
|
318
|
+
});
|
|
319
|
+
expect(readFileSync(c.file).equals(afterFirst)).toBe(true);
|
|
320
|
+
},
|
|
321
|
+
2 * T,
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
test(
|
|
326
|
+
"R2.8.6 control (a user Bash hook in PreToolUse and other: 1): the user's my-guard.sh block and other: 1 are kept",
|
|
327
|
+
() => {
|
|
328
|
+
const c = newCase(
|
|
329
|
+
CONTROLS["a user Bash hook in PreToolUse and other: 1"] as string,
|
|
330
|
+
);
|
|
331
|
+
expect(register(c)).toMatchObject({ stage: "done", ok: true });
|
|
332
|
+
const after = readBack(c);
|
|
333
|
+
expect(after.other).toBe(1);
|
|
334
|
+
const pre = preToolUse(after) ?? [];
|
|
335
|
+
const bash = pre.filter(
|
|
336
|
+
(b) => (b as { matcher?: unknown })?.matcher === "Bash",
|
|
337
|
+
);
|
|
338
|
+
expect(bash).toEqual([USER_BASH_BLOCK]);
|
|
339
|
+
expect(magusAgentHooks(after).length).toBe(1);
|
|
340
|
+
},
|
|
341
|
+
T,
|
|
342
|
+
);
|
|
343
|
+
|
|
344
|
+
test(
|
|
345
|
+
"R2.8.6 control (no settings.json): configDir is the only target — nothing appears under HOME/.claude",
|
|
346
|
+
() => {
|
|
347
|
+
const c = newCase(null);
|
|
348
|
+
expect(register(c)).toMatchObject({ stage: "done", ok: true });
|
|
349
|
+
expect(existsSync(c.file)).toBe(true);
|
|
350
|
+
expect(existsSync(path.join(c.home, ".claude"))).toBe(false);
|
|
351
|
+
},
|
|
352
|
+
T,
|
|
353
|
+
);
|
|
354
|
+
});
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blind tests — contract C2 (requirements R2.1, R2.2, R2.3).
|
|
3
|
+
*
|
|
4
|
+
* Written from requirements.md and architecture.md only.
|
|
5
|
+
*
|
|
6
|
+
* NOTHING from claude-settings runs in this process. Under Bun, a runtime
|
|
7
|
+
* HOME change does not move `os.homedir()` (architecture.md C2 correction),
|
|
8
|
+
* and this module can resolve `~/.claude` — so every call runs in a child
|
|
9
|
+
* `bun` spawned with a temp HOME (see ./child-run.ts). The child reports only
|
|
10
|
+
* booleans and names: never a value read from a file, never an error
|
|
11
|
+
* message, so no failure can print a real user file.
|
|
12
|
+
*
|
|
13
|
+
* Valid-file cases write a per-test random token into the file; the child
|
|
14
|
+
* reports whether the value it read contains that token, which proves the
|
|
15
|
+
* temp file — and not some other file — was the one read.
|
|
16
|
+
*/
|
|
17
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import {
|
|
20
|
+
mkdirSync,
|
|
21
|
+
mkdtempSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
rmSync,
|
|
24
|
+
writeFileSync,
|
|
25
|
+
} from "node:fs";
|
|
26
|
+
import os from "node:os";
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { type ChildResult, runInChild } from "./child-run.js";
|
|
29
|
+
|
|
30
|
+
const MODULE = path.resolve(
|
|
31
|
+
import.meta.dir,
|
|
32
|
+
"../../services/claude-settings.ts",
|
|
33
|
+
);
|
|
34
|
+
const T = 30_000; // per-test timeout: each test spawns one child bun
|
|
35
|
+
|
|
36
|
+
const dirs: string[] = [];
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
for (const dir of dirs.splice(0))
|
|
39
|
+
rmSync(dir, { recursive: true, force: true });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function tempDir(tag: string): string {
|
|
43
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), `magus-blind-c2-${tag}-`));
|
|
44
|
+
dirs.push(dir);
|
|
45
|
+
return dir;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function write(file: string, content: string): void {
|
|
49
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
50
|
+
writeFileSync(file, content);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Three distinct ways a file can exist and not be JSON. */
|
|
54
|
+
const INVALID: Record<string, string> = {
|
|
55
|
+
truncated: '{\n "enabledPlugins": {\n "a@b": true,\n',
|
|
56
|
+
"plain text": "this is not json at all\n",
|
|
57
|
+
"trailing comma": '{ "enabledPlugins": { "a@b": true, }, }\n',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Child body: call `mod[job.call](...job.args)` and report a summary.
|
|
62
|
+
* Reports whether a value is `{}` and whether it contains `job.expect`
|
|
63
|
+
* (subset match) — never the value itself. On a throw, reports the error's
|
|
64
|
+
* class and whether it carries/names `job.file` — never its message.
|
|
65
|
+
*/
|
|
66
|
+
const CALL_BODY = `
|
|
67
|
+
const fs = await import("node:fs");
|
|
68
|
+
const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } };
|
|
69
|
+
const subset = (a, e) => {
|
|
70
|
+
if (e === null || typeof e !== "object") return Object.is(a, e);
|
|
71
|
+
if (a === null || typeof a !== "object") return false;
|
|
72
|
+
if (Array.isArray(e)) return Array.isArray(a) && a.length === e.length && e.every((v, i) => subset(a[i], v));
|
|
73
|
+
return Object.keys(e).every((k) => subset(a[k], e[k]));
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
const value = await mod[job.call](...job.args);
|
|
77
|
+
const isObj = value !== null && typeof value === "object" && !Array.isArray(value);
|
|
78
|
+
await out({
|
|
79
|
+
stage: "done",
|
|
80
|
+
ok: true,
|
|
81
|
+
isEmptyObject: isObj && Object.keys(value).length === 0,
|
|
82
|
+
matchesExpected: job.expect === undefined ? null : subset(value, job.expect),
|
|
83
|
+
});
|
|
84
|
+
} catch (e) {
|
|
85
|
+
const isErr = e instanceof Error;
|
|
86
|
+
const P = mod.SettingsParseError;
|
|
87
|
+
const want = job.file ? real(job.file) : null;
|
|
88
|
+
const msg = isErr && typeof e.message === "string" ? e.message : "";
|
|
89
|
+
const p = e && typeof e.path === "string" ? e.path : null;
|
|
90
|
+
await out({
|
|
91
|
+
stage: "done",
|
|
92
|
+
ok: false,
|
|
93
|
+
isError: isErr,
|
|
94
|
+
name: isErr ? e.name : typeof e,
|
|
95
|
+
isSettingsParseError: typeof P === "function" && e instanceof P,
|
|
96
|
+
pathIsString: p !== null,
|
|
97
|
+
pathMatchesFile: !!want && p !== null && real(p) === want,
|
|
98
|
+
messageNamesFile: !!job.file && (msg.includes(job.file) || (!!want && msg.includes(want))),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
interface Case {
|
|
104
|
+
home: string;
|
|
105
|
+
project: string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function newCase(): Case {
|
|
109
|
+
return { home: tempDir("home"), project: tempDir("proj") };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function call(
|
|
113
|
+
c: Case,
|
|
114
|
+
fn: string,
|
|
115
|
+
args: unknown[],
|
|
116
|
+
extra: { file?: string; expect?: unknown } = {},
|
|
117
|
+
): ChildResult {
|
|
118
|
+
return runInChild({
|
|
119
|
+
home: c.home,
|
|
120
|
+
cwd: c.project,
|
|
121
|
+
module: MODULE,
|
|
122
|
+
job: { call: fn, args, ...extra },
|
|
123
|
+
body: CALL_BODY,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface Reader {
|
|
128
|
+
name: string;
|
|
129
|
+
call: string;
|
|
130
|
+
args: (c: Case) => unknown[];
|
|
131
|
+
file: (c: Case) => string;
|
|
132
|
+
/** Valid content carrying `token`, and the subset the reader must return. */
|
|
133
|
+
valid: (token: string) => Record<string, unknown>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const READERS: Reader[] = [
|
|
137
|
+
{
|
|
138
|
+
name: "readSettings (<project>/.claude/settings.json)",
|
|
139
|
+
call: "readSettings",
|
|
140
|
+
args: (c) => [c.project],
|
|
141
|
+
file: (c) => path.join(c.project, ".claude", "settings.json"),
|
|
142
|
+
valid: (t) => ({ enabledPlugins: { [`${t}@blind`]: true } }),
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: "readLocalSettings (<project>/.claude/settings.local.json)",
|
|
146
|
+
call: "readLocalSettings",
|
|
147
|
+
args: (c) => [c.project],
|
|
148
|
+
file: (c) => path.join(c.project, ".claude", "settings.local.json"),
|
|
149
|
+
valid: (t) => ({ enabledPlugins: { [`${t}@blind`]: false } }),
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
name: "readMcpConfig (<project>/.mcp.json)",
|
|
153
|
+
call: "readMcpConfig",
|
|
154
|
+
args: (c) => [c.project],
|
|
155
|
+
file: (c) => path.join(c.project, ".mcp.json"),
|
|
156
|
+
valid: (t) => ({ mcpServers: { [t]: { command: "echo", args: ["hi"] } } }),
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
name: "readGlobalSettings (<HOME>/.claude/settings.json, child process with temp HOME)",
|
|
160
|
+
call: "readGlobalSettings",
|
|
161
|
+
args: () => [],
|
|
162
|
+
file: (c) => path.join(c.home, ".claude", "settings.json"),
|
|
163
|
+
valid: (t) => ({ enabledPlugins: { [`${t}@global-blind`]: true } }),
|
|
164
|
+
},
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
describe("C2 — child-process harness", () => {
|
|
168
|
+
test(
|
|
169
|
+
"R2 control: the child's os.homedir() is the temp HOME",
|
|
170
|
+
() => {
|
|
171
|
+
const c = newCase();
|
|
172
|
+
const r = runInChild({
|
|
173
|
+
home: c.home,
|
|
174
|
+
cwd: c.project,
|
|
175
|
+
body: 'await out({ stage: "done", ok: true });',
|
|
176
|
+
});
|
|
177
|
+
expect(r).toEqual({ stage: "done", ok: true });
|
|
178
|
+
},
|
|
179
|
+
T,
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
for (const reader of READERS) {
|
|
184
|
+
describe(`C2 — ${reader.name}`, () => {
|
|
185
|
+
test(
|
|
186
|
+
"R2.1 a missing file reads as an empty object",
|
|
187
|
+
() => {
|
|
188
|
+
const c = newCase();
|
|
189
|
+
expect(call(c, reader.call, reader.args(c))).toEqual({
|
|
190
|
+
stage: "done",
|
|
191
|
+
ok: true,
|
|
192
|
+
isEmptyObject: true,
|
|
193
|
+
matchesExpected: null,
|
|
194
|
+
});
|
|
195
|
+
},
|
|
196
|
+
T,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
test(
|
|
200
|
+
"R2.1 control: a valid file reads as its content (the temp file, identified by a random token)",
|
|
201
|
+
() => {
|
|
202
|
+
const c = newCase();
|
|
203
|
+
const content = reader.valid(`blind-${randomUUID()}`);
|
|
204
|
+
write(reader.file(c), JSON.stringify(content, null, 2));
|
|
205
|
+
expect(
|
|
206
|
+
call(c, reader.call, reader.args(c), { expect: content }),
|
|
207
|
+
).toMatchObject({
|
|
208
|
+
stage: "done",
|
|
209
|
+
ok: true,
|
|
210
|
+
matchesExpected: true,
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
T,
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
for (const [kind, content] of Object.entries(INVALID)) {
|
|
217
|
+
test(
|
|
218
|
+
`R2.2 an existing file that is not valid JSON (${kind}) throws SettingsParseError`,
|
|
219
|
+
() => {
|
|
220
|
+
const c = newCase();
|
|
221
|
+
write(reader.file(c), content);
|
|
222
|
+
expect(call(c, reader.call, reader.args(c))).toMatchObject({
|
|
223
|
+
stage: "done",
|
|
224
|
+
ok: false,
|
|
225
|
+
isError: true,
|
|
226
|
+
isSettingsParseError: true,
|
|
227
|
+
});
|
|
228
|
+
},
|
|
229
|
+
T,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
test(
|
|
234
|
+
"R2.2 the SettingsParseError carries the path of the file that failed",
|
|
235
|
+
() => {
|
|
236
|
+
const c = newCase();
|
|
237
|
+
const file = reader.file(c);
|
|
238
|
+
write(file, INVALID.truncated as string);
|
|
239
|
+
// pathMatchesFile compares real paths: macOS tmpdir is /var -> /private/var.
|
|
240
|
+
expect(call(c, reader.call, reader.args(c), { file })).toMatchObject({
|
|
241
|
+
stage: "done",
|
|
242
|
+
ok: false,
|
|
243
|
+
isSettingsParseError: true,
|
|
244
|
+
pathIsString: true,
|
|
245
|
+
pathMatchesFile: true,
|
|
246
|
+
});
|
|
247
|
+
},
|
|
248
|
+
T,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
test(
|
|
252
|
+
"R2.2 an existing unparseable file never reads as {}",
|
|
253
|
+
() => {
|
|
254
|
+
const c = newCase();
|
|
255
|
+
write(reader.file(c), INVALID["plain text"] as string);
|
|
256
|
+
const r = call(c, reader.call, reader.args(c));
|
|
257
|
+
expect(r.stage).toBe("done");
|
|
258
|
+
expect(r.ok).toBe(false);
|
|
259
|
+
},
|
|
260
|
+
T,
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
describe("C2 — enablePlugin read-modify-write", () => {
|
|
266
|
+
const PLUGIN = "demo@magus-blind";
|
|
267
|
+
const settingsFile = (c: Case) =>
|
|
268
|
+
path.join(c.project, ".claude", "settings.json");
|
|
269
|
+
|
|
270
|
+
test(
|
|
271
|
+
"R2.3 control: enablePlugin on a valid settings.json sets enabledPlugins[id] and keeps other keys",
|
|
272
|
+
() => {
|
|
273
|
+
const c = newCase();
|
|
274
|
+
const file = settingsFile(c);
|
|
275
|
+
write(
|
|
276
|
+
file,
|
|
277
|
+
JSON.stringify(
|
|
278
|
+
{
|
|
279
|
+
enabledPlugins: { "other@x": true },
|
|
280
|
+
permissions: { allow: ["Read"] },
|
|
281
|
+
},
|
|
282
|
+
null,
|
|
283
|
+
2,
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
expect(call(c, "enablePlugin", [PLUGIN, true, c.project])).toMatchObject({
|
|
287
|
+
stage: "done",
|
|
288
|
+
ok: true,
|
|
289
|
+
});
|
|
290
|
+
// The file is this test's temp testdata, so reading it here is safe.
|
|
291
|
+
const after = JSON.parse(readFileSync(file, "utf8"));
|
|
292
|
+
expect(after.enabledPlugins[PLUGIN]).toBe(true);
|
|
293
|
+
expect(after.enabledPlugins["other@x"]).toBe(true);
|
|
294
|
+
expect(after.permissions).toEqual({ allow: ["Read"] });
|
|
295
|
+
},
|
|
296
|
+
T,
|
|
297
|
+
);
|
|
298
|
+
|
|
299
|
+
for (const enabled of [true, false]) {
|
|
300
|
+
for (const [kind, content] of Object.entries(INVALID)) {
|
|
301
|
+
test(
|
|
302
|
+
`R2.3 enablePlugin(…, ${enabled}) fails on an unparseable settings.json (${kind})`,
|
|
303
|
+
() => {
|
|
304
|
+
const c = newCase();
|
|
305
|
+
write(settingsFile(c), content);
|
|
306
|
+
expect(
|
|
307
|
+
call(c, "enablePlugin", [PLUGIN, enabled, c.project]),
|
|
308
|
+
).toMatchObject({
|
|
309
|
+
stage: "done",
|
|
310
|
+
ok: false,
|
|
311
|
+
isError: true,
|
|
312
|
+
});
|
|
313
|
+
},
|
|
314
|
+
T,
|
|
315
|
+
);
|
|
316
|
+
|
|
317
|
+
test(
|
|
318
|
+
`R2.3 enablePlugin(…, ${enabled}) leaves an unparseable settings.json (${kind}) byte-for-byte unchanged`,
|
|
319
|
+
() => {
|
|
320
|
+
const c = newCase();
|
|
321
|
+
const file = settingsFile(c);
|
|
322
|
+
write(file, content);
|
|
323
|
+
const before = readFileSync(file);
|
|
324
|
+
const r = call(c, "enablePlugin", [PLUGIN, enabled, c.project]);
|
|
325
|
+
expect(r.stage).toBe("done");
|
|
326
|
+
expect(readFileSync(file).equals(before)).toBe(true);
|
|
327
|
+
},
|
|
328
|
+
T,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
test(
|
|
334
|
+
"R2.3 the enablePlugin failure identifies the unparseable file",
|
|
335
|
+
() => {
|
|
336
|
+
// R2.4 needs the TUI to name the file, so the error that reaches it must
|
|
337
|
+
// carry the path: as a SettingsParseError, or in its message.
|
|
338
|
+
const c = newCase();
|
|
339
|
+
const file = settingsFile(c);
|
|
340
|
+
write(file, INVALID.truncated as string);
|
|
341
|
+
const r = call(c, "enablePlugin", [PLUGIN, true, c.project], { file });
|
|
342
|
+
expect(r).toMatchObject({ stage: "done", ok: false, isError: true });
|
|
343
|
+
const namesFile =
|
|
344
|
+
(r.isSettingsParseError === true && r.pathMatchesFile === true) ||
|
|
345
|
+
r.messageNamesFile === true;
|
|
346
|
+
expect(namesFile).toBe(true);
|
|
347
|
+
},
|
|
348
|
+
T,
|
|
349
|
+
);
|
|
350
|
+
});
|