magus-cli 7.4.0 → 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.
- package/package.json +4 -4
- package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
- package/src/__tests__/stale-load-repair.test.ts +5 -4
- package/src/cli/doctor.ts +20 -6
- package/src/cli/install.ts +34 -9
- package/src/services/claude-settings.ts +283 -125
- package/src/services/cli-tool-commands.ts +30 -0
- package/src/services/gitignore-service.ts +39 -22
- package/src/services/settings-file.ts +9 -3
- package/src/services/tool-probe.ts +48 -3
- package/src/ui/components/modals/ModalContainer.tsx +9 -1
- package/src/ui/input/KeyRouter.tsx +147 -23
- package/src/ui/input/key-dispatch.ts +48 -0
- package/src/ui/keymap.ts +8 -1
- package/src/ui/renderers/cliToolRenderers.tsx +65 -27
- package/src/ui/screens/CliToolsScreen.tsx +74 -13
- package/src/ui/screens/PluginsScreen.tsx +6 -2
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blind tests — contract C20 (requirements R2.8.7, R2.8.8).
|
|
3
|
+
*
|
|
4
|
+
* Written from requirements.md and architecture.md only.
|
|
5
|
+
*
|
|
6
|
+
* NOTHING under test runs in this process: both modules can resolve
|
|
7
|
+
* `~/.claude`, and under Bun a runtime HOME change does not move
|
|
8
|
+
* `os.homedir()` (architecture.md C2 correction). Every call therefore runs in
|
|
9
|
+
* a child `bun` spawned with a temp HOME (see ./child-run.ts). The child
|
|
10
|
+
* reports only booleans and class names — never a value read from a file and
|
|
11
|
+
* never an error message — so no failure can print a real user file. The files
|
|
12
|
+
* this test compares byte-for-byte are its own temp testdata.
|
|
13
|
+
*
|
|
14
|
+
* Two things C20 leaves open, and how they are handled here rather than guessed:
|
|
15
|
+
*
|
|
16
|
+
* - `readInstalledPluginsRegistry(root)` / `writeInstalledPluginsRegistry(…, root, …)`
|
|
17
|
+
* do not say where `root` points. Each case therefore seeds IDENTICAL bytes
|
|
18
|
+
* at every plausible location (`<root>/installed_plugins.json`,
|
|
19
|
+
* `<root>/plugins/…`, `<root>/.claude/plugins/…`), so the assertions hold
|
|
20
|
+
* whichever one the contract means: a corrupt file is corrupt either way,
|
|
21
|
+
* and `expectBytes` is the same string either way. Byte-identity is checked
|
|
22
|
+
* on all of them.
|
|
23
|
+
* - C20 gives `applyTemplate(/* existing signature *\/)`. The call shape used
|
|
24
|
+
* here — `applyTemplate(projectDir, templateId)` with the id "common-dev" —
|
|
25
|
+
* is taken from the existing test `src/__tests__/gitignore-service.test.ts`
|
|
26
|
+
* (a test file, the only source consulted besides the two documents). The
|
|
27
|
+
* "healthy file still applies" case is the control that catches it if that
|
|
28
|
+
* id ever stops being a real template.
|
|
29
|
+
*/
|
|
30
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
31
|
+
import { randomUUID } from "node:crypto";
|
|
32
|
+
import {
|
|
33
|
+
existsSync,
|
|
34
|
+
mkdirSync,
|
|
35
|
+
mkdtempSync,
|
|
36
|
+
readFileSync,
|
|
37
|
+
rmSync,
|
|
38
|
+
writeFileSync,
|
|
39
|
+
} from "node:fs";
|
|
40
|
+
import os from "node:os";
|
|
41
|
+
import path from "node:path";
|
|
42
|
+
import { type ChildResult, runInChild } from "./child-run.js";
|
|
43
|
+
|
|
44
|
+
const SETTINGS_MODULE = path.resolve(
|
|
45
|
+
import.meta.dir,
|
|
46
|
+
"../../services/claude-settings.ts",
|
|
47
|
+
);
|
|
48
|
+
const GITIGNORE_MODULE = path.resolve(
|
|
49
|
+
import.meta.dir,
|
|
50
|
+
"../../services/gitignore-service.ts",
|
|
51
|
+
);
|
|
52
|
+
/** Modules that may export the SettingsParseError class (C2, C13). */
|
|
53
|
+
const CLASS_MODULES = [
|
|
54
|
+
SETTINGS_MODULE,
|
|
55
|
+
path.resolve(import.meta.dir, "../../services/settings-file.ts"),
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const T = 30_000; // per-test timeout: each test spawns one child bun
|
|
59
|
+
|
|
60
|
+
const dirs: string[] = [];
|
|
61
|
+
afterEach(() => {
|
|
62
|
+
for (const dir of dirs.splice(0))
|
|
63
|
+
rmSync(dir, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
function tempDir(tag: string): string {
|
|
67
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), `magus-blind-c20-${tag}-`));
|
|
68
|
+
dirs.push(dir);
|
|
69
|
+
return dir;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function write(file: string, content: string): void {
|
|
73
|
+
mkdirSync(path.dirname(file), { recursive: true });
|
|
74
|
+
writeFileSync(file, content);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Three distinct ways a file can exist and not be JSON. */
|
|
78
|
+
const INVALID: Record<string, string> = {
|
|
79
|
+
truncated: '{\n "version": 2,\n "plugins": {\n',
|
|
80
|
+
"plain text": "this is not json at all\n",
|
|
81
|
+
"trailing comma": '{ "version": 2, "plugins": { }, }\n',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Three ways a file can parse and not be a JSON object (R2.8.1). */
|
|
85
|
+
const NOT_AN_OBJECT: Record<string, string> = {
|
|
86
|
+
"an array": "[]\n",
|
|
87
|
+
null: "null\n",
|
|
88
|
+
"a number": "5\n",
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Child body: call `mod[job.call](...job.args)` and report a summary.
|
|
93
|
+
* On success, reports only whether the value is an object — never the value.
|
|
94
|
+
* On a throw, reports the error's class and whether it names one of
|
|
95
|
+
* `job.files` — never its message.
|
|
96
|
+
*/
|
|
97
|
+
const CALL_BODY = `
|
|
98
|
+
const fs = await import("node:fs");
|
|
99
|
+
const nodePath = await import("node:path");
|
|
100
|
+
const real = (p) => { try { return fs.realpathSync(p); } catch { return null; } };
|
|
101
|
+
|
|
102
|
+
const ctors = [];
|
|
103
|
+
for (const m of (job.classModules || [])) {
|
|
104
|
+
try {
|
|
105
|
+
const mm = await import(m);
|
|
106
|
+
if (typeof mm.SettingsParseError === "function") ctors.push(mm.SettingsParseError);
|
|
107
|
+
} catch {}
|
|
108
|
+
}
|
|
109
|
+
if (mod && typeof mod.SettingsParseError === "function") ctors.push(mod.SettingsParseError);
|
|
110
|
+
/** A subclass counts: walk the constructor chain by name as well as instanceof. */
|
|
111
|
+
const namedChain = (e) => {
|
|
112
|
+
let c = e && e.constructor;
|
|
113
|
+
while (typeof c === "function") {
|
|
114
|
+
if (c.name === "SettingsParseError") return true;
|
|
115
|
+
c = Object.getPrototypeOf(c);
|
|
116
|
+
}
|
|
117
|
+
return false;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
const value = await mod[job.call](...job.args);
|
|
122
|
+
await out({
|
|
123
|
+
stage: "done",
|
|
124
|
+
ok: true,
|
|
125
|
+
isObject: value !== null && typeof value === "object" && !Array.isArray(value),
|
|
126
|
+
});
|
|
127
|
+
} catch (e) {
|
|
128
|
+
const isErr = e instanceof Error;
|
|
129
|
+
const msg = isErr && typeof e.message === "string" ? e.message : "";
|
|
130
|
+
const p = e && typeof e.path === "string" ? e.path : null;
|
|
131
|
+
const files = job.files || [];
|
|
132
|
+
const names = (f) => {
|
|
133
|
+
const r = real(f);
|
|
134
|
+
return msg.includes(f) || (!!r && msg.includes(r)) || msg.includes(nodePath.basename(f));
|
|
135
|
+
};
|
|
136
|
+
await out({
|
|
137
|
+
stage: "done",
|
|
138
|
+
ok: false,
|
|
139
|
+
isError: isErr,
|
|
140
|
+
name: isErr ? e.name : typeof e,
|
|
141
|
+
isSettingsParseError: ctors.some((C) => e instanceof C) || namedChain(e),
|
|
142
|
+
pathMatchesAFile: p !== null && files.some((f) => { const r = real(f); return !!r && real(p) === r; }),
|
|
143
|
+
messageNamesFile: files.length > 0 && files.some(names),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
interface CallOpts {
|
|
149
|
+
files?: string[];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function callIn(
|
|
153
|
+
home: string,
|
|
154
|
+
cwd: string,
|
|
155
|
+
module: string,
|
|
156
|
+
fn: string,
|
|
157
|
+
args: unknown[],
|
|
158
|
+
opts: CallOpts = {},
|
|
159
|
+
): ChildResult {
|
|
160
|
+
return runInChild({
|
|
161
|
+
home,
|
|
162
|
+
cwd,
|
|
163
|
+
module,
|
|
164
|
+
job: {
|
|
165
|
+
call: fn,
|
|
166
|
+
args,
|
|
167
|
+
classModules: CLASS_MODULES,
|
|
168
|
+
files: opts.files ?? [],
|
|
169
|
+
},
|
|
170
|
+
body: CALL_BODY,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// installed_plugins.json (R2.8.8)
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
interface RegistryCase {
|
|
179
|
+
home: string;
|
|
180
|
+
cwd: string;
|
|
181
|
+
root: string;
|
|
182
|
+
/** Every plausible location of installed_plugins.json under `root`. */
|
|
183
|
+
files: string[];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function newRegistryCase(): RegistryCase {
|
|
187
|
+
const root = tempDir("root");
|
|
188
|
+
return {
|
|
189
|
+
home: tempDir("home"),
|
|
190
|
+
cwd: tempDir("cwd"),
|
|
191
|
+
root,
|
|
192
|
+
files: [
|
|
193
|
+
path.join(root, "installed_plugins.json"),
|
|
194
|
+
path.join(root, "plugins", "installed_plugins.json"),
|
|
195
|
+
path.join(root, ".claude", "plugins", "installed_plugins.json"),
|
|
196
|
+
],
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Seed identical bytes at every candidate location. */
|
|
201
|
+
function seedRegistry(c: RegistryCase, content: string): void {
|
|
202
|
+
for (const f of c.files) write(f, content);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Snapshot the bytes of every candidate that exists. */
|
|
206
|
+
function snapshot(files: string[]): Map<string, Buffer> {
|
|
207
|
+
const m = new Map<string, Buffer>();
|
|
208
|
+
for (const f of files) if (existsSync(f)) m.set(f, readFileSync(f));
|
|
209
|
+
return m;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function expectUnchanged(before: Map<string, Buffer>): void {
|
|
213
|
+
expect(before.size).toBeGreaterThan(0);
|
|
214
|
+
for (const [f, bytes] of before) {
|
|
215
|
+
expect(existsSync(f)).toBe(true);
|
|
216
|
+
expect(readFileSync(f).equals(bytes)).toBe(true);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const healthyRegistry = () =>
|
|
221
|
+
JSON.stringify({ version: 2, plugins: {} }, null, 2);
|
|
222
|
+
|
|
223
|
+
describe("C20 — readInstalledPluginsRegistry", () => {
|
|
224
|
+
test(
|
|
225
|
+
"R2.8.8 a missing installed_plugins.json reads without throwing (unchanged behaviour)",
|
|
226
|
+
() => {
|
|
227
|
+
const c = newRegistryCase();
|
|
228
|
+
expect(
|
|
229
|
+
callIn(c.home, c.cwd, SETTINGS_MODULE, "readInstalledPluginsRegistry", [
|
|
230
|
+
c.root,
|
|
231
|
+
]),
|
|
232
|
+
).toMatchObject({ stage: "done", ok: true, isObject: true });
|
|
233
|
+
},
|
|
234
|
+
T,
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
test(
|
|
238
|
+
"R2.8.8 control: a healthy installed_plugins.json reads without throwing (unchanged behaviour)",
|
|
239
|
+
() => {
|
|
240
|
+
const c = newRegistryCase();
|
|
241
|
+
seedRegistry(c, healthyRegistry());
|
|
242
|
+
expect(
|
|
243
|
+
callIn(c.home, c.cwd, SETTINGS_MODULE, "readInstalledPluginsRegistry", [
|
|
244
|
+
c.root,
|
|
245
|
+
]),
|
|
246
|
+
).toMatchObject({ stage: "done", ok: true, isObject: true });
|
|
247
|
+
},
|
|
248
|
+
T,
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
for (const [kind, content] of Object.entries(INVALID)) {
|
|
252
|
+
test(
|
|
253
|
+
`R2.8.8 an installed_plugins.json that does not parse (${kind}) is refused, names the file, and stays byte-identical`,
|
|
254
|
+
() => {
|
|
255
|
+
const c = newRegistryCase();
|
|
256
|
+
seedRegistry(c, content);
|
|
257
|
+
const before = snapshot(c.files);
|
|
258
|
+
const r = callIn(
|
|
259
|
+
c.home,
|
|
260
|
+
c.cwd,
|
|
261
|
+
SETTINGS_MODULE,
|
|
262
|
+
"readInstalledPluginsRegistry",
|
|
263
|
+
[c.root],
|
|
264
|
+
{ files: c.files },
|
|
265
|
+
);
|
|
266
|
+
expect(r).toMatchObject({
|
|
267
|
+
stage: "done",
|
|
268
|
+
ok: false,
|
|
269
|
+
isError: true,
|
|
270
|
+
isSettingsParseError: true,
|
|
271
|
+
messageNamesFile: true,
|
|
272
|
+
});
|
|
273
|
+
expectUnchanged(before);
|
|
274
|
+
},
|
|
275
|
+
T,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (const [kind, content] of Object.entries(NOT_AN_OBJECT)) {
|
|
280
|
+
test(
|
|
281
|
+
`R2.8.8 an installed_plugins.json that parses to ${kind} is refused, names the file, and stays byte-identical`,
|
|
282
|
+
() => {
|
|
283
|
+
const c = newRegistryCase();
|
|
284
|
+
seedRegistry(c, content);
|
|
285
|
+
const before = snapshot(c.files);
|
|
286
|
+
const r = callIn(
|
|
287
|
+
c.home,
|
|
288
|
+
c.cwd,
|
|
289
|
+
SETTINGS_MODULE,
|
|
290
|
+
"readInstalledPluginsRegistry",
|
|
291
|
+
[c.root],
|
|
292
|
+
{ files: c.files },
|
|
293
|
+
);
|
|
294
|
+
expect(r).toMatchObject({
|
|
295
|
+
stage: "done",
|
|
296
|
+
ok: false,
|
|
297
|
+
isError: true,
|
|
298
|
+
isSettingsParseError: true,
|
|
299
|
+
messageNamesFile: true,
|
|
300
|
+
});
|
|
301
|
+
expectUnchanged(before);
|
|
302
|
+
},
|
|
303
|
+
T,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
test(
|
|
308
|
+
"R2.8.8 a corrupt registry never reads as an empty registry",
|
|
309
|
+
() => {
|
|
310
|
+
const c = newRegistryCase();
|
|
311
|
+
seedRegistry(c, INVALID["plain text"] as string);
|
|
312
|
+
const r = callIn(
|
|
313
|
+
c.home,
|
|
314
|
+
c.cwd,
|
|
315
|
+
SETTINGS_MODULE,
|
|
316
|
+
"readInstalledPluginsRegistry",
|
|
317
|
+
[c.root],
|
|
318
|
+
);
|
|
319
|
+
expect(r.stage).toBe("done");
|
|
320
|
+
expect(r.ok).toBe(false);
|
|
321
|
+
},
|
|
322
|
+
T,
|
|
323
|
+
);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe("C20 — writeInstalledPluginsRegistry", () => {
|
|
327
|
+
const TOKEN = () => `blind-${randomUUID()}@magus`;
|
|
328
|
+
|
|
329
|
+
test(
|
|
330
|
+
"R2.8.8 control: with expectBytes equal to the bytes on disk the write succeeds and lands",
|
|
331
|
+
() => {
|
|
332
|
+
const c = newRegistryCase();
|
|
333
|
+
seedRegistry(c, healthyRegistry());
|
|
334
|
+
const current = healthyRegistry();
|
|
335
|
+
const id = TOKEN();
|
|
336
|
+
const r = callIn(
|
|
337
|
+
c.home,
|
|
338
|
+
c.cwd,
|
|
339
|
+
SETTINGS_MODULE,
|
|
340
|
+
"writeInstalledPluginsRegistry",
|
|
341
|
+
[{ version: 2, plugins: { [id]: [] } }, c.root, current],
|
|
342
|
+
);
|
|
343
|
+
expect(r).toMatchObject({ stage: "done", ok: true });
|
|
344
|
+
// The file is this test's own temp testdata, so reading it here is safe.
|
|
345
|
+
const landed = c.files
|
|
346
|
+
.filter((f) => existsSync(f))
|
|
347
|
+
.some((f) => {
|
|
348
|
+
const parsed = JSON.parse(readFileSync(f, "utf8"));
|
|
349
|
+
return (
|
|
350
|
+
parsed !== null &&
|
|
351
|
+
typeof parsed === "object" &&
|
|
352
|
+
Object.keys(parsed.plugins ?? {}).includes(id)
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
expect(landed).toBe(true);
|
|
356
|
+
},
|
|
357
|
+
T,
|
|
358
|
+
);
|
|
359
|
+
|
|
360
|
+
test(
|
|
361
|
+
"R2.8.8 a write whose expectBytes no longer match the file is refused and leaves it byte-identical",
|
|
362
|
+
() => {
|
|
363
|
+
const c = newRegistryCase();
|
|
364
|
+
// Read … then someone else rewrites the file … then we write.
|
|
365
|
+
const asRead = healthyRegistry();
|
|
366
|
+
seedRegistry(c, asRead);
|
|
367
|
+
const concurrent = JSON.stringify(
|
|
368
|
+
{ version: 2, plugins: {}, note: "written by someone else" },
|
|
369
|
+
null,
|
|
370
|
+
2,
|
|
371
|
+
);
|
|
372
|
+
seedRegistry(c, concurrent);
|
|
373
|
+
const before = snapshot(c.files);
|
|
374
|
+
|
|
375
|
+
const r = callIn(
|
|
376
|
+
c.home,
|
|
377
|
+
c.cwd,
|
|
378
|
+
SETTINGS_MODULE,
|
|
379
|
+
"writeInstalledPluginsRegistry",
|
|
380
|
+
[{ version: 2, plugins: { [TOKEN()]: [] } }, c.root, asRead],
|
|
381
|
+
{ files: c.files },
|
|
382
|
+
);
|
|
383
|
+
expect(r).toMatchObject({ stage: "done", ok: false, isError: true });
|
|
384
|
+
expectUnchanged(before);
|
|
385
|
+
},
|
|
386
|
+
T,
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
for (const [kind, content] of Object.entries(INVALID)) {
|
|
390
|
+
test(
|
|
391
|
+
`R2.8.8 a write is refused when the registry on disk does not parse (${kind}), even with matching expectBytes, and leaves it byte-identical`,
|
|
392
|
+
() => {
|
|
393
|
+
const c = newRegistryCase();
|
|
394
|
+
seedRegistry(c, content);
|
|
395
|
+
const before = snapshot(c.files);
|
|
396
|
+
const r = callIn(
|
|
397
|
+
c.home,
|
|
398
|
+
c.cwd,
|
|
399
|
+
SETTINGS_MODULE,
|
|
400
|
+
"writeInstalledPluginsRegistry",
|
|
401
|
+
[{ version: 2, plugins: { [TOKEN()]: [] } }, c.root, content],
|
|
402
|
+
{ files: c.files },
|
|
403
|
+
);
|
|
404
|
+
expect(r).toMatchObject({ stage: "done", ok: false, isError: true });
|
|
405
|
+
// Never replaced by a fresh file holding only the plugin being written.
|
|
406
|
+
expectUnchanged(before);
|
|
407
|
+
},
|
|
408
|
+
T,
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
test(
|
|
413
|
+
"R2.8.8 a write against a corrupt registry is refused when no expectBytes is passed at all",
|
|
414
|
+
() => {
|
|
415
|
+
const c = newRegistryCase();
|
|
416
|
+
seedRegistry(c, INVALID.truncated as string);
|
|
417
|
+
const before = snapshot(c.files);
|
|
418
|
+
const r = callIn(
|
|
419
|
+
c.home,
|
|
420
|
+
c.cwd,
|
|
421
|
+
SETTINGS_MODULE,
|
|
422
|
+
"writeInstalledPluginsRegistry",
|
|
423
|
+
[{ version: 2, plugins: { [TOKEN()]: [] } }, c.root],
|
|
424
|
+
{ files: c.files },
|
|
425
|
+
);
|
|
426
|
+
expect(r).toMatchObject({ stage: "done", ok: false, isError: true });
|
|
427
|
+
expectUnchanged(before);
|
|
428
|
+
},
|
|
429
|
+
T,
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
for (const [kind, content] of Object.entries(NOT_AN_OBJECT)) {
|
|
433
|
+
test(
|
|
434
|
+
`R2.8.8 a write is refused when the registry on disk parses to ${kind}, and leaves it byte-identical`,
|
|
435
|
+
() => {
|
|
436
|
+
const c = newRegistryCase();
|
|
437
|
+
seedRegistry(c, content);
|
|
438
|
+
const before = snapshot(c.files);
|
|
439
|
+
const r = callIn(
|
|
440
|
+
c.home,
|
|
441
|
+
c.cwd,
|
|
442
|
+
SETTINGS_MODULE,
|
|
443
|
+
"writeInstalledPluginsRegistry",
|
|
444
|
+
[{ version: 2, plugins: { [TOKEN()]: [] } }, c.root, content],
|
|
445
|
+
{ files: c.files },
|
|
446
|
+
);
|
|
447
|
+
expect(r).toMatchObject({ stage: "done", ok: false, isError: true });
|
|
448
|
+
expectUnchanged(before);
|
|
449
|
+
},
|
|
450
|
+
T,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
// ---------------------------------------------------------------------------
|
|
456
|
+
// .claude/gitignore.json (R2.8.7)
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* A template id the Gitignore tab offers. C20 does not name one; this is taken
|
|
461
|
+
* from the existing test `src/__tests__/gitignore-service.test.ts`. The control
|
|
462
|
+
* below fails loudly if it stops being a real template.
|
|
463
|
+
*/
|
|
464
|
+
const TEMPLATE = "common-dev";
|
|
465
|
+
|
|
466
|
+
interface TemplateCase {
|
|
467
|
+
home: string;
|
|
468
|
+
project: string;
|
|
469
|
+
file: string;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function newTemplateCase(): TemplateCase {
|
|
473
|
+
const project = tempDir("proj");
|
|
474
|
+
mkdirSync(path.join(project, ".claude"), { recursive: true });
|
|
475
|
+
return {
|
|
476
|
+
home: tempDir("home"),
|
|
477
|
+
project,
|
|
478
|
+
file: path.join(project, ".claude", "gitignore.json"),
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
describe("C20 — applyTemplate refuses a damaged .claude/gitignore.json", () => {
|
|
483
|
+
test(
|
|
484
|
+
"R2.8.7 control: a healthy .claude/gitignore.json still applies",
|
|
485
|
+
() => {
|
|
486
|
+
const c = newTemplateCase();
|
|
487
|
+
const own = `blind-${randomUUID()}/`;
|
|
488
|
+
write(c.file, JSON.stringify({ ignore: [own], track: [] }, null, 2));
|
|
489
|
+
const r = callIn(c.home, c.project, GITIGNORE_MODULE, "applyTemplate", [
|
|
490
|
+
c.project,
|
|
491
|
+
TEMPLATE,
|
|
492
|
+
]);
|
|
493
|
+
expect(r).toMatchObject({ stage: "done", ok: true });
|
|
494
|
+
// The file is this test's own temp testdata, so reading it here is safe.
|
|
495
|
+
const after = JSON.parse(readFileSync(c.file, "utf8"));
|
|
496
|
+
expect(after !== null && typeof after === "object").toBe(true);
|
|
497
|
+
// R2.8.7's whole point: the user's own rules survive an apply.
|
|
498
|
+
expect(after.ignore).toContain(own);
|
|
499
|
+
},
|
|
500
|
+
T,
|
|
501
|
+
);
|
|
502
|
+
|
|
503
|
+
for (const [kind, content] of Object.entries(INVALID)) {
|
|
504
|
+
test(
|
|
505
|
+
`R2.8.7 a .claude/gitignore.json that does not parse (${kind}) is refused, names the file, and stays byte-identical`,
|
|
506
|
+
() => {
|
|
507
|
+
const c = newTemplateCase();
|
|
508
|
+
write(c.file, content);
|
|
509
|
+
const before = readFileSync(c.file);
|
|
510
|
+
const r = callIn(
|
|
511
|
+
c.home,
|
|
512
|
+
c.project,
|
|
513
|
+
GITIGNORE_MODULE,
|
|
514
|
+
"applyTemplate",
|
|
515
|
+
[c.project, TEMPLATE],
|
|
516
|
+
{ files: [c.file] },
|
|
517
|
+
);
|
|
518
|
+
expect(r).toMatchObject({
|
|
519
|
+
stage: "done",
|
|
520
|
+
ok: false,
|
|
521
|
+
isError: true,
|
|
522
|
+
isSettingsParseError: true,
|
|
523
|
+
messageNamesFile: true,
|
|
524
|
+
});
|
|
525
|
+
// Never read as "no entries" and rewritten — that drops the user's
|
|
526
|
+
// own ignore rules, which is how a secret later reaches a commit.
|
|
527
|
+
expect(readFileSync(c.file).equals(before)).toBe(true);
|
|
528
|
+
},
|
|
529
|
+
T,
|
|
530
|
+
);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
for (const [kind, content] of Object.entries(NOT_AN_OBJECT)) {
|
|
534
|
+
test(
|
|
535
|
+
`R2.8.7 a .claude/gitignore.json that parses to ${kind} is refused, names the file, and stays byte-identical`,
|
|
536
|
+
() => {
|
|
537
|
+
const c = newTemplateCase();
|
|
538
|
+
write(c.file, content);
|
|
539
|
+
const before = readFileSync(c.file);
|
|
540
|
+
const r = callIn(
|
|
541
|
+
c.home,
|
|
542
|
+
c.project,
|
|
543
|
+
GITIGNORE_MODULE,
|
|
544
|
+
"applyTemplate",
|
|
545
|
+
[c.project, TEMPLATE],
|
|
546
|
+
{ files: [c.file] },
|
|
547
|
+
);
|
|
548
|
+
expect(r).toMatchObject({
|
|
549
|
+
stage: "done",
|
|
550
|
+
ok: false,
|
|
551
|
+
isError: true,
|
|
552
|
+
isSettingsParseError: true,
|
|
553
|
+
messageNamesFile: true,
|
|
554
|
+
});
|
|
555
|
+
expect(readFileSync(c.file).equals(before)).toBe(true);
|
|
556
|
+
},
|
|
557
|
+
T,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
});
|
|
@@ -344,10 +344,11 @@ describe("repairStaleLoad — refusals", () => {
|
|
|
344
344
|
});
|
|
345
345
|
|
|
346
346
|
test("an unreadable registry is refused rather than replaced", async () => {
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
347
|
+
// Writing over a corrupt registry would erase every project's installs on
|
|
348
|
+
// the machine. The repair reads and parses the same bytes it will check
|
|
349
|
+
// before writing, so a document it cannot parse stops it here — and it
|
|
350
|
+
// reports that as a result rather than throwing, because one item in a
|
|
351
|
+
// plan of repairs must not end the plan.
|
|
351
352
|
await fs.outputFile(registryFile, "{ not json");
|
|
352
353
|
|
|
353
354
|
const result = await repairStaleLoad(PLUGIN, "0.5.0", here, root);
|
package/src/cli/doctor.ts
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
* 5. Plugin loads — what Claude Code will load here vs what is installed.
|
|
12
12
|
*
|
|
13
13
|
* A file that exists and does not parse — the global settings, the project's
|
|
14
|
-
* `.claude/profiles.json` or its `.claude/settings.json
|
|
15
|
-
*
|
|
16
|
-
* having. `--fix` repairs none of
|
|
17
|
-
* cannot parse.
|
|
14
|
+
* `.claude/profiles.json` or its `.claude/settings.json`, Claude Code's
|
|
15
|
+
* `installed_plugins.json` — is a finding in the section that meets it, never an
|
|
16
|
+
* abort: the rest of the report is still worth having. `--fix` repairs none of
|
|
17
|
+
* them, because magus never rewrites a file it cannot parse.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import path from "node:path";
|
|
@@ -44,7 +44,11 @@ import {
|
|
|
44
44
|
} from "../services/plugin-resolution.js";
|
|
45
45
|
import { resolveAllProfiles } from "../services/resolver.js";
|
|
46
46
|
import { activeProfile, profileDir } from "../services/symlink-manager.js";
|
|
47
|
-
import type {
|
|
47
|
+
import type {
|
|
48
|
+
ClaudeSettings,
|
|
49
|
+
InstalledPluginsRegistry,
|
|
50
|
+
ProfileManifest,
|
|
51
|
+
} from "../types/index.js";
|
|
48
52
|
import { resolveExecutable } from "../utils/command-utils.js";
|
|
49
53
|
|
|
50
54
|
export async function runDoctorCommand(args: string[]): Promise<number> {
|
|
@@ -232,7 +236,17 @@ export function doctorExitCode(args: {
|
|
|
232
236
|
* loads — the loader's, `pickLoadedEntry`
|
|
233
237
|
*/
|
|
234
238
|
async function checkPluginLoads(projectPath: string): Promise<number> {
|
|
235
|
-
|
|
239
|
+
let registry: InstalledPluginsRegistry;
|
|
240
|
+
try {
|
|
241
|
+
registry = await readInstalledPluginsRegistry();
|
|
242
|
+
} catch (error) {
|
|
243
|
+
// A registry nothing can read is a finding, not an abort: doctor is the
|
|
244
|
+
// command someone runs BECAUSE something is wrong, and it has to reach its
|
|
245
|
+
// remaining sections and its exit code.
|
|
246
|
+
if (!(error instanceof SettingsParseError)) throw error;
|
|
247
|
+
reportUnparseable("Plugin loads", error);
|
|
248
|
+
return 1;
|
|
249
|
+
}
|
|
236
250
|
let settings: ClaudeSettings;
|
|
237
251
|
try {
|
|
238
252
|
settings = await readSettings(projectPath);
|
package/src/cli/install.ts
CHANGED
|
@@ -32,7 +32,7 @@ import { reportDisplaced } from "../services/profile-activate.js";
|
|
|
32
32
|
import { materializeProfile } from "../services/profile-materializer.js";
|
|
33
33
|
import { detectProfileDrift } from "../services/profile-sync.js";
|
|
34
34
|
import { resolveAllProfiles, resolveProfile } from "../services/resolver.js";
|
|
35
|
-
import { writableMap } from "../services/settings-file.js";
|
|
35
|
+
import { SettingsParseError, writableMap } from "../services/settings-file.js";
|
|
36
36
|
import { installSkill } from "../services/skills-manager.js";
|
|
37
37
|
import {
|
|
38
38
|
PROFILE_GITIGNORE_ENTRIES,
|
|
@@ -230,17 +230,42 @@ async function collectEnv(
|
|
|
230
230
|
return;
|
|
231
231
|
}
|
|
232
232
|
|
|
233
|
-
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
233
|
+
// EVERY answer first, then one read-modify-write.
|
|
234
|
+
//
|
|
235
|
+
// The read has to happen AFTER the last prompt. Claude Code writes
|
|
236
|
+
// settings.local.json while these prompts are on screen — it is where a
|
|
237
|
+
// "don't ask again" permission rule lands — and this used to read the file
|
|
238
|
+
// before the first question and write that copy back at the end, so every
|
|
239
|
+
// rule the user granted in between was erased. The prompts are the slow part,
|
|
240
|
+
// and a read from before them describes a file that no longer exists.
|
|
241
|
+
const answers: Array<[string, string]> = [];
|
|
239
242
|
for (const name of status.missingRequired) {
|
|
240
243
|
const value = await promptValue(` ${name} = `);
|
|
241
|
-
if (value)
|
|
244
|
+
if (value) answers.push([name, value]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const localPath = getLocalSettingsPath(projectPath);
|
|
248
|
+
try {
|
|
249
|
+
// Refuses a file that stopped parsing while the prompts were up, and a
|
|
250
|
+
// malformed `env` map, before anything is written. Either way the file is
|
|
251
|
+
// left exactly as it is.
|
|
252
|
+
const local = await readLocalSettings(projectPath);
|
|
253
|
+
const env = writableMap(local, "env", localPath) ?? {};
|
|
254
|
+
local.env = env;
|
|
255
|
+
for (const [name, value] of answers) env[name] = value;
|
|
256
|
+
await writeLocalSettings(local, projectPath);
|
|
257
|
+
} catch (error) {
|
|
258
|
+
// Which variables went unsaved, by NAME — never the values. They are
|
|
259
|
+
// tokens, and a refusal is not a reason to print one to a terminal.
|
|
260
|
+
if (error instanceof SettingsParseError && answers.length > 0) {
|
|
261
|
+
console.error(
|
|
262
|
+
`Not saved: ${answers
|
|
263
|
+
.map(([name]) => name)
|
|
264
|
+
.join(", ")} — set them again once the file is fixed.`,
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
throw error;
|
|
242
268
|
}
|
|
243
|
-
await writeLocalSettings(local, projectPath);
|
|
244
269
|
console.log("Saved to .claude/settings.local.json (personal, gitignored).");
|
|
245
270
|
}
|
|
246
271
|
|