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.
@@ -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
- // `readInstalledPluginsRegistry` answers an EMPTY registry for a corrupt
348
- // file, and writing that back would erase every project's installs on the
349
- // machine. The repair reads and parses the same bytes it will check before
350
- // writing, so a document it cannot parse stops it here.
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` — is a finding in the
15
- * section that meets it, never an abort: the rest of the report is still worth
16
- * having. `--fix` repairs none of them, because magus never rewrites a file it
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 { ClaudeSettings, ProfileManifest } from "../types/index.js";
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
- const registry = await readInstalledPluginsRegistry();
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);
@@ -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
- const local = await readLocalSettings(projectPath);
234
- // Checked before the first prompt, so a malformed `env` is refused before the
235
- // user types anything, not after — and never replaced.
236
- const env =
237
- writableMap(local, "env", getLocalSettingsPath(projectPath)) ?? {};
238
- local.env = env;
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) env[name] = 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