magus-cli 7.5.0 → 7.5.2

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 (33) hide show
  1. package/package.json +5 -4
  2. package/src/__tests__/profile-absorb.test.ts +72 -0
  3. package/src/__tests__/profile-dependencies.test.ts +442 -0
  4. package/src/__tests__/profile-manifest-write.test.ts +231 -0
  5. package/src/__tests__/profile-skills.test.ts +99 -0
  6. package/src/__tests__/profile-state.test.ts +328 -13
  7. package/src/__tests__/stale-load-repair.test.ts +265 -0
  8. package/src/__tests__/support/preload.ts +14 -0
  9. package/src/__tests__/update-apply.test.ts +342 -1
  10. package/src/cli/bootstrap.ts +4 -0
  11. package/src/cli/doctor.ts +39 -6
  12. package/src/cli/install.ts +68 -4
  13. package/src/cli/router.ts +7 -0
  14. package/src/cli/update.ts +3 -0
  15. package/src/services/claude-cli.ts +24 -0
  16. package/src/services/claude-settings.ts +45 -0
  17. package/src/services/git-worktree.ts +92 -9
  18. package/src/services/manifest.ts +220 -3
  19. package/src/services/plugin-dependencies.ts +48 -0
  20. package/src/services/plugin-requires.ts +137 -14
  21. package/src/services/profile-absorb.ts +69 -15
  22. package/src/services/profile-activate.ts +179 -33
  23. package/src/services/profile-live.ts +66 -7
  24. package/src/services/profile-render.ts +73 -6
  25. package/src/services/profile-skills.ts +32 -7
  26. package/src/services/profile-state.ts +89 -5
  27. package/src/services/profiles.ts +6 -5
  28. package/src/services/resolver.ts +96 -3
  29. package/src/services/tracked-live-files.ts +125 -0
  30. package/src/services/update-engine.ts +266 -8
  31. package/src/types/index.ts +19 -0
  32. package/src/ui/screens/PluginsScreen.tsx +11 -2
  33. package/src/ui/screens/ProfilesScreen.tsx +10 -3
@@ -49,7 +49,7 @@ import { createHash } from "node:crypto";
49
49
  import type { Stats } from "node:fs";
50
50
  import path from "node:path";
51
51
  import fs from "fs-extra";
52
- import type { ProfileManifestEntry } from "../types/index.js";
52
+ import type { DependencyReport, ProfileManifestEntry } from "../types/index.js";
53
53
  import { readManifest } from "./manifest.js";
54
54
  import { SettingsParseError } from "./settings-file.js";
55
55
 
@@ -174,6 +174,12 @@ export interface RenderedProfile {
174
174
  * profile does not enable was written as `false` by the render itself.
175
175
  */
176
176
  allPluginIds: string[];
177
+ /**
178
+ * What the profile's plugins depend on — derived ids (already enabled in
179
+ * `settings`), unreadable plugin.json files, and dependencies the profile
180
+ * turns off. Absent when there is nothing to say.
181
+ */
182
+ dependencies?: DependencyReport;
177
183
  }
178
184
 
179
185
  /** The exact bytes magus writes for a rendered file. */
@@ -245,6 +251,20 @@ export interface ActiveMarker {
245
251
  * enabledPlugins as that bookkeeping rather than as the user's edits.
246
252
  */
247
253
  cliBookkeeping?: true;
254
+ /**
255
+ * The plugin ids the flagged run installs — only a `true` for one of these
256
+ * can be the CLI's record. `magus update` installs the active profile's
257
+ * plugins alone, so a plugin of another profile's that the user enabled
258
+ * during the run is theirs. Absent (an older flag), every profile's
259
+ * recorded ids (`allPluginIds`) stand in.
260
+ */
261
+ cliInstallable?: string[];
262
+ /**
263
+ * The dependencies the last render enabled (`true`) though no profile entry
264
+ * names them. A live `false` for one of these is the user's own disable —
265
+ * the render never wrote it. Absent in a marker an older release wrote.
266
+ */
267
+ derived?: string[];
248
268
  /** sha256 of each live file exactly as magus last wrote or absorbed it. */
249
269
  files: Partial<Record<LiveFileKey, string>>;
250
270
  /** Magus-installed skill folders under `.claude/skills/`, with content hash. */
@@ -276,6 +296,8 @@ function parseMarker(raw: unknown): ActiveMarker | null {
276
296
  skills,
277
297
  allPluginIds,
278
298
  cliBookkeeping,
299
+ cliInstallable,
300
+ derived,
279
301
  } = raw;
280
302
  if (!isSegment(profile)) return null;
281
303
  const hashes: ActiveMarker["files"] = {};
@@ -308,6 +330,15 @@ function parseMarker(raw: unknown): ActiveMarker | null {
308
330
  marker.allPluginIds = allPluginIds as string[];
309
331
  }
310
332
  if (cliBookkeeping === true) marker.cliBookkeeping = true;
333
+ if (
334
+ Array.isArray(cliInstallable) &&
335
+ cliInstallable.every((id) => typeof id === "string")
336
+ ) {
337
+ marker.cliInstallable = cliInstallable as string[];
338
+ }
339
+ if (Array.isArray(derived) && derived.every((id) => typeof id === "string")) {
340
+ marker.derived = derived as string[];
341
+ }
311
342
  return marker;
312
343
  }
313
344
 
@@ -381,16 +412,21 @@ export async function writeActiveMarker(
381
412
  */
382
413
  export function markerFor(
383
414
  profile: string,
384
- rendered: Pick<RenderedProfile, "entry" | "definition" | "allPluginIds">,
415
+ rendered: Pick<
416
+ RenderedProfile,
417
+ "entry" | "definition" | "allPluginIds" | "dependencies"
418
+ >,
385
419
  files: ActiveMarker["files"],
386
420
  skills: Record<string, string>,
387
421
  ): ActiveMarker {
422
+ const derived = rendered.dependencies?.derived ?? [];
388
423
  return {
389
424
  version: 1,
390
425
  profile,
391
426
  definition: rendered.definition,
392
427
  base: rendered.entry,
393
428
  allPluginIds: rendered.allPluginIds,
429
+ ...(derived.length > 0 ? { derived } : {}),
394
430
  files,
395
431
  skills,
396
432
  };
@@ -557,6 +593,13 @@ export async function writeLiveFile(
557
593
  key: LiveFileKey,
558
594
  content: Record<string, unknown> | null,
559
595
  projectPath?: string,
596
+ /**
597
+ * The machine-local settings keys to carry, when the caller read them
598
+ * before the live file moved — first adoption displaces it to a backup
599
+ * before this runs, and reading it here then would find nothing. Omitted,
600
+ * they are read from the live file as it is now.
601
+ */
602
+ carry?: Record<string, unknown>,
560
603
  ): Promise<string | null> {
561
604
  const file = livePath(key, projectPath);
562
605
  // Backstop for the callers' own checks: never replace a regular file that
@@ -571,12 +614,11 @@ export async function writeLiveFile(
571
614
  }
572
615
  let body = content;
573
616
  if (key === "settings") {
574
- // Through a link too: an older layout's settings.json carries them.
575
- const live = (await readLiveThrough("settings", projectPath)).value;
576
- if (live) {
617
+ const keep = carry ?? (await machineLocalSettings(projectPath));
618
+ if (Object.keys(keep).length > 0) {
577
619
  body = { ...content };
578
- for (const k of MACHINE_LOCAL_SETTINGS_KEYS) {
579
- if (k in live && !(k in body)) body[k] = live[k];
620
+ for (const [k, value] of Object.entries(keep)) {
621
+ if (!(k in body)) body[k] = value;
580
622
  }
581
623
  }
582
624
  }
@@ -585,6 +627,23 @@ export async function writeLiveFile(
585
627
  return sha256(bytes);
586
628
  }
587
629
 
630
+ /**
631
+ * The machine-local keys (see {@link MACHINE_LOCAL_SETTINGS_KEYS}) the live
632
+ * settings.json holds now — read through an older layout's link too. Empty
633
+ * when there is no readable file.
634
+ */
635
+ export async function machineLocalSettings(
636
+ projectPath?: string,
637
+ ): Promise<Record<string, unknown>> {
638
+ const live = (await readLiveThrough("settings", projectPath)).value;
639
+ const out: Record<string, unknown> = {};
640
+ if (!live) return out;
641
+ for (const k of MACHINE_LOCAL_SETTINGS_KEYS) {
642
+ if (k in live) out[k] = live[k];
643
+ }
644
+ return out;
645
+ }
646
+
588
647
  /** Write all three live files of `rendered`; returns their hashes. */
589
648
  export async function writeRenderedFiles(
590
649
  rendered: RenderedProfile,
@@ -9,7 +9,15 @@
9
9
  * No installs and no global state, so it is testable with a temp dir.
10
10
  */
11
11
 
12
- import type { ProfileManifest, ResolvedClosure } from "../types/index.js";
12
+ import type {
13
+ DependencyReport,
14
+ ProfileManifest,
15
+ ResolvedClosure,
16
+ } from "../types/index.js";
17
+ import {
18
+ type PluginDependencyReader,
19
+ readPluginDependencies,
20
+ } from "./plugin-dependencies.js";
13
21
  import { type RenderedProfile, canonicalJson, sha256 } from "./profile-live.js";
14
22
  import { resolveAllProfiles, resolveProfile } from "./resolver.js";
15
23
 
@@ -27,6 +35,12 @@ import { resolveAllProfiles, resolveProfile } from "./resolver.js";
27
35
  * the id — an id a scope omits falls through to the next one, so a non-member
28
36
  * enabled at user scope would otherwise stay enabled here. A plugin the
29
37
  * profile itself declares `false` is written as `false` for the same reason.
38
+ *
39
+ * `closure.plugins` already holds every plugin an enabled one depends on, so
40
+ * a dependency is written `true` even when another profile lists it — Claude
41
+ * Code will not load a plugin whose dependency is disabled. The one exception
42
+ * is a dependency the profile itself turns off: its `false` stands, and
43
+ * {@link describeDependencies} says which plugin will not load.
30
44
  */
31
45
  export function buildProfileSettings(
32
46
  closure: ResolvedClosure,
@@ -54,11 +68,53 @@ export function buildProfileMcp(closure: ResolvedClosure): {
54
68
 
55
69
  /**
56
70
  * Rendering needs no binary requirements — nothing it writes reads
57
- * `closure.bins` — and skipping the per-plugin `plugin.json` reads keeps it off
58
- * the marketplace cache entirely.
71
+ * `closure.bins` — so no plugin's `requires.bin` is read.
59
72
  */
60
73
  const noBins = async () => [];
61
74
 
75
+ /**
76
+ * One read per plugin per render: the render resolves this profile and then
77
+ * every profile, and most plugins appear in both.
78
+ */
79
+ function oncePerPlugin(read: PluginDependencyReader): PluginDependencyReader {
80
+ const seen = new Map<string, Promise<string[] | null>>();
81
+ return (pluginId) => {
82
+ let pending = seen.get(pluginId);
83
+ if (!pending) {
84
+ pending = read(pluginId);
85
+ seen.set(pluginId, pending);
86
+ }
87
+ return pending;
88
+ };
89
+ }
90
+
91
+ /**
92
+ * What a profile's dependencies mean for the person running it, one line
93
+ * each: a plugin that will not load because the profile turns off something
94
+ * it depends on, and plugins whose plugin.json could not be read here.
95
+ */
96
+ export function describeDependencies(
97
+ report: DependencyReport | undefined,
98
+ profile: string,
99
+ ): string[] {
100
+ const lines: string[] = [];
101
+ for (const { plugin, dependency } of report?.blocked ?? []) {
102
+ lines.push(
103
+ `${plugin} will not load: it depends on ${dependency}, which profile "${profile}" turns off — remove "${dependency}": false from the profile to let it load`,
104
+ );
105
+ }
106
+ const unreadable = report?.unreadable ?? [];
107
+ if (unreadable.length > 0) {
108
+ const one = unreadable.length === 1;
109
+ lines.push(
110
+ `Could not read the plugin.json of ${unreadable.join(", ")} on this machine, so nothing ${
111
+ one ? "it depends" : "they depend"
112
+ } on was enabled — run \`magus install\` to fetch ${one ? "it" : "them"}`,
113
+ );
114
+ }
115
+ return lines;
116
+ }
117
+
62
118
  /**
63
119
  * The hash of everything a profile's live config is rendered from.
64
120
  *
@@ -68,7 +124,9 @@ const noBins = async () => [];
68
124
  * teammate adding a plugin to another profile changes this profile's
69
125
  * settings.json. Hashing only the entry would leave that change unrendered
70
126
  * until the next switch. Install-only fields (`bins`, `env`, cross-profile
71
- * version conflicts) are left out: they change no live file.
127
+ * version conflicts) are left out: they change no live file. Dependencies are
128
+ * in: they are part of `closure.plugins`, so a plugin.json that starts or
129
+ * stops declaring one changes the definition and the live config regenerates.
72
130
  */
73
131
  function renderDefinition(
74
132
  closure: ResolvedClosure,
@@ -96,11 +154,19 @@ function renderDefinition(
96
154
  export async function renderProfile(
97
155
  manifest: ProfileManifest,
98
156
  name: string,
157
+ /** Whose installed plugins' dependencies count (default: the current directory). */
158
+ projectPath?: string,
99
159
  ): Promise<RenderedProfile> {
100
160
  const entry = manifest.profiles[name];
101
161
  if (!entry) throw new Error(`Profile "${name}" not found in manifest`);
102
- const closure = await resolveProfile(manifest, name, { binResolver: noBins });
103
- const union = await resolveAllProfiles(manifest, { binResolver: noBins });
162
+ const opts = {
163
+ binResolver: noBins,
164
+ dependencyResolver: oncePerPlugin((id) =>
165
+ readPluginDependencies(id, projectPath),
166
+ ),
167
+ };
168
+ const closure = await resolveProfile(manifest, name, opts);
169
+ const union = await resolveAllProfiles(manifest, opts);
104
170
  const allPluginIds = Object.keys(union.plugins);
105
171
  return {
106
172
  settings: buildProfileSettings(closure, allPluginIds),
@@ -111,5 +177,6 @@ export async function renderProfile(
111
177
  entry,
112
178
  definition: renderDefinition(closure, allPluginIds),
113
179
  allPluginIds: [...allPluginIds].sort(),
180
+ ...(closure.dependencies ? { dependencies: closure.dependencies } : {}),
114
181
  };
115
182
  }
@@ -38,6 +38,7 @@ import {
38
38
  skillsRoot,
39
39
  } from "./profile-live.js";
40
40
  import { installSkill, skillFolderName } from "./skills-manager.js";
41
+ import { trackedPaths } from "./tracked-live-files.js";
41
42
 
42
43
  /** Installs one profile skill into `.claude/skills/<folder>/`. */
43
44
  export type SkillInstaller = (
@@ -124,11 +125,13 @@ export async function findSkillClashes(
124
125
  skills: readonly ProfileSkillRef[],
125
126
  managed: Readonly<Record<string, string>>,
126
127
  projectPath: string,
128
+ /** Folders the team committed: the listed skill IS that copy, not a clash. */
129
+ committed: ReadonlySet<string> = new Set(),
127
130
  ): Promise<SkillClash[]> {
128
131
  const clashes: SkillClash[] = [];
129
132
  for (const ref of skills) {
130
133
  const folder = skillFolder(ref);
131
- if (folder in managed) continue;
134
+ if (folder in managed || committed.has(folder)) continue;
132
135
  if (await exists(path.join(skillsRoot(projectPath), folder))) {
133
136
  clashes.push({ skill: ref.name, folder });
134
137
  }
@@ -162,6 +165,8 @@ export async function syncSkills(
162
165
  managed: Readonly<Record<string, string>>,
163
166
  projectPath: string,
164
167
  install: SkillInstaller = installProfileSkill,
168
+ /** Folders the team committed: never installed into, even when missing. */
169
+ committed: ReadonlySet<string> = new Set(),
165
170
  ): Promise<SkillSyncResult> {
166
171
  const root = skillsRoot(projectPath);
167
172
  const wanted = new Map<string, ProfileSkillRef>();
@@ -197,6 +202,8 @@ export async function syncSkills(
197
202
  // Present but not ours (or ours and since edited): never overwritten.
198
203
  // Clashes are refused before this runs; this is the backstop.
199
204
  if (await exists(dir)) continue;
205
+ // The team's committed copy, deleted in this checkout: git restores it.
206
+ if (recorded === undefined && committed.has(folder)) continue;
200
207
  try {
201
208
  await fs.ensureDir(root);
202
209
  await install(ref, projectPath);
@@ -367,6 +374,24 @@ export async function convertLegacySkills(
367
374
  );
368
375
  }
369
376
 
377
+ const activeFolders =
378
+ linkedProfile !== null
379
+ ? manifestSkillFolders(manifest, linkedProfile)
380
+ : new Set<string>();
381
+ // A folder the team committed under .claude/skills/ is the team's copy
382
+ // even when the profile lists it: it is restored as a custom skill, never
383
+ // recorded as magus-installed — which would make magus gitignore it, remove
384
+ // it on a switch, and tell the user to untrack a skill they committed.
385
+ // Asked before anything moves, so a git failure leaves the layout intact.
386
+ const skillsRel = path.relative(projectPath, root);
387
+ const committedDirs = await trackedPaths(
388
+ projectPath,
389
+ [],
390
+ [...activeFolders].map((folder) => path.join(skillsRel, folder)),
391
+ );
392
+ const committed = (folder: string) =>
393
+ committedDirs.has(path.join(skillsRel, folder));
394
+
370
395
  // Where the link pointed: the skills the project was actually running.
371
396
  let linkedDir: string | null = null;
372
397
  if (linkedProfile !== null) {
@@ -416,14 +441,14 @@ export async function convertLegacySkills(
416
441
  result.conflicts.push({ folder, kept: dest, other: inKeptLayout(src) });
417
442
  };
418
443
 
419
- const activeFolders =
420
- linkedProfile !== null
421
- ? manifestSkillFolders(manifest, linkedProfile)
422
- : new Set<string>();
423
-
424
444
  if (linkedDir !== null) {
425
445
  for (const folder of await subfolders(linkedDir)) {
426
- await place(linkedDir, folder, "move", activeFolders.has(folder));
446
+ await place(
447
+ linkedDir,
448
+ folder,
449
+ "move",
450
+ activeFolders.has(folder) && !committed(folder),
451
+ );
427
452
  }
428
453
  }
429
454
  for (const dir of await backupSkillDirs(projectPath)) {
@@ -41,6 +41,10 @@ import {
41
41
  type ProfileChoice,
42
42
  activateProfile,
43
43
  activationNotes,
44
+ activeWritableSkillFolders,
45
+ assertNoTrackedWriteTargets,
46
+ writableSkillFolders,
47
+ writeRefusal,
44
48
  } from "./profile-activate.js";
45
49
  import { adoptCurrentProject } from "./profile-adopt.js";
46
50
  import {
@@ -69,6 +73,7 @@ import {
69
73
  skillFolder,
70
74
  } from "./profile-skills.js";
71
75
  import type { SettingsParseError } from "./settings-file.js";
76
+ import type { LiveFileRefusal } from "./tracked-live-files.js";
72
77
 
73
78
  export type ActiveProfileCheck =
74
79
  | { state: "no-profiles" }
@@ -148,7 +153,7 @@ async function matchingProfiles(
148
153
  const models = await readLiveJsonSafely("models", projectPath);
149
154
  const matches: string[] = [];
150
155
  for (const id of Object.keys(manifest.profiles)) {
151
- const rendered = await renderProfile(manifest, id);
156
+ const rendered = await renderProfile(manifest, id, projectPath);
152
157
  if (
153
158
  jsonEqual(withoutMachineKeys(settings), rendered.settings) &&
154
159
  jsonEqual(mcp, rendered.mcp) &&
@@ -184,7 +189,12 @@ async function restoreMarker(
184
189
  if (hash) skills[folder] = hash;
185
190
  }
186
191
  await writeActiveMarker(
187
- markerFor(profile, await renderProfile(manifest, profile), files, skills),
192
+ markerFor(
193
+ profile,
194
+ await renderProfile(manifest, profile, projectPath),
195
+ files,
196
+ skills,
197
+ ),
188
198
  projectPath,
189
199
  );
190
200
  }
@@ -294,6 +304,11 @@ export interface ProfileStatePrep {
294
304
  problems: string[];
295
305
  /** The files behind `problems` that could not be parsed. */
296
306
  unreadable: SettingsParseError[];
307
+ /**
308
+ * Set when a file magus would write is tracked by git, or git could not
309
+ * say: nothing ran, and `problems` holds its message.
310
+ */
311
+ refusal?: LiveFileRefusal;
297
312
  }
298
313
 
299
314
  /**
@@ -307,6 +322,27 @@ export async function prepareProfileState(
307
322
  projectPath: string,
308
323
  opts: Pick<ActivateOptions, "installSkill"> = {},
309
324
  ): Promise<ProfileStatePrep> {
325
+ // A project with profiles whose live files — or the active profile's
326
+ // magus-installed skill folders — git tracks: every path below would write
327
+ // one of them (or the marker, or the manifest on the way), so nothing runs,
328
+ // not even the marker a unique match would restore.
329
+ const manifest = await readManifest(projectPath);
330
+ if (Object.keys(manifest.profiles).length > 0) {
331
+ const refusal = await writeRefusal(
332
+ projectPath,
333
+ await activeWritableSkillFolders(projectPath, manifest),
334
+ );
335
+ if (refusal) {
336
+ return {
337
+ check: await checkActiveProfile(projectPath, { restore: false }),
338
+ reconcile: null,
339
+ lines: [],
340
+ problems: [refusal.message],
341
+ unreadable: [],
342
+ refusal,
343
+ };
344
+ }
345
+ }
310
346
  const check = await checkActiveProfile(projectPath);
311
347
  const out: ProfileStatePrep = {
312
348
  check,
@@ -415,7 +451,10 @@ export async function inspectProfileState(
415
451
  );
416
452
  }
417
453
  const manifest = await readManifest(projectPath);
418
- if ((await renderProfile(manifest, p)).definition !== marker.definition) {
454
+ if (
455
+ (await renderProfile(manifest, p, projectPath)).definition !==
456
+ marker.definition
457
+ ) {
419
458
  pending.push(
420
459
  "profiles.json changed since the live config was generated — the next magus command regenerates it.",
421
460
  );
@@ -478,12 +517,28 @@ export async function switchToProfile(
478
517
  };
479
518
 
480
519
  const before = await readManifest(projectPath);
481
- if (!before.profiles[name]) {
520
+ const target = before.profiles[name];
521
+ if (!target) {
482
522
  result.problems.push(
483
523
  `Profile "${name}" not found. Available: ${Object.keys(before.profiles).join(", ")}`,
484
524
  );
485
525
  return result;
486
526
  }
527
+ // Before the absorbs below write profiles.json: nothing at all is written
528
+ // when a live file this switch would write is tracked by git.
529
+ const marker = await readActiveMarker(projectPath);
530
+ const refusal = await writeRefusal(projectPath, [
531
+ ...(await writableSkillFolders(
532
+ projectPath,
533
+ target.skills ?? [],
534
+ marker?.skills,
535
+ )),
536
+ ...(await activeWritableSkillFolders(projectPath, before)),
537
+ ]);
538
+ if (refusal) {
539
+ result.problems.push(refusal.message);
540
+ return result;
541
+ }
487
542
 
488
543
  if (!take(await absorbBeforeConversion(projectPath, name))) return result;
489
544
  const installSkill = { installSkill: opts.installSkill };
@@ -525,6 +580,20 @@ export async function applyProfileChoice(
525
580
  projectPath: string,
526
581
  opts: ActivateOptions = {},
527
582
  ): Promise<ActivationResult> {
583
+ // Nothing is written — not the adopted manifest, not an absorbed edit —
584
+ // when a live file this choice would write is tracked by git.
585
+ const skills =
586
+ choice.kind === "activate"
587
+ ? (manifest.profiles[choice.id]?.skills ?? [])
588
+ : [];
589
+ await assertNoTrackedWriteTargets(
590
+ projectPath,
591
+ await writableSkillFolders(
592
+ projectPath,
593
+ skills,
594
+ (await readActiveMarker(projectPath))?.skills,
595
+ ),
596
+ );
528
597
  if (choice.kind === "activate") {
529
598
  const absorbed: string[] = [];
530
599
  let changed = false;
@@ -566,11 +635,19 @@ export async function prepareProfileWrite(
566
635
  projectPath: string,
567
636
  ): Promise<ProfileStatePrep> {
568
637
  const prep = await prepareProfileState(projectPath);
638
+ if (prep.refusal) throw prep.refusal;
569
639
  refuseOnProblems(prep);
570
640
  return prep;
571
641
  }
572
642
 
573
643
  export interface CliBookkeepingOptions {
644
+ /**
645
+ * The plugin ids `work` installs. Only a `true` for one of these is taken
646
+ * for the CLI's record; every other id in settings.json is the user's.
647
+ * Recorded in the marker, so a killed run's next command applies the same
648
+ * set. Omitted, every profile's plugins count.
649
+ */
650
+ installable?: readonly string[];
574
651
  /**
575
652
  * Runs after `work`, before the reconcile — for `magus update`, which must
576
653
  * record the skill folders it re-fetched before they are judged edited.
@@ -609,7 +686,14 @@ export async function withCliBookkeeping<T>(
609
686
  ? await readActiveMarker(projectPath)
610
687
  : null;
611
688
  if (!marker) return work();
612
- await writeActiveMarker({ ...marker, cliBookkeeping: true }, projectPath);
689
+ await writeActiveMarker(
690
+ {
691
+ ...marker,
692
+ cliBookkeeping: true,
693
+ ...(opts.installable ? { cliInstallable: [...opts.installable] } : {}),
694
+ },
695
+ projectPath,
696
+ );
613
697
  try {
614
698
  return await work();
615
699
  } finally {
@@ -2,7 +2,7 @@ import os from "node:os";
2
2
  import path from "node:path";
3
3
  import fs from "fs-extra";
4
4
  import type { ProfileEntry, ProfileManifest } from "../types/index.js";
5
- import { readProfilesFile } from "./manifest.js";
5
+ import { readProfilesFile, writeJsonMinimal } from "./manifest.js";
6
6
  import { activeProfile } from "./profile-live.js";
7
7
 
8
8
  const PROFILES_FILE = "profiles.json";
@@ -64,10 +64,11 @@ async function writeProfiles(
64
64
  projectPath?: string,
65
65
  ): Promise<void> {
66
66
  const filePath = profilesPath(scope, projectPath);
67
- // Read for the throw alone; the contents are about to be replaced.
68
- await readProfilesFile(filePath);
69
- await fs.ensureDir(path.dirname(filePath));
70
- await fs.writeJson(filePath, data, { spaces: 2 });
67
+ // The refusal, and the file as read: only what the caller changed is
68
+ // written, every other byte kept. Diffed against the file's own `version`,
69
+ // so a v1 file is recorded as v2.
70
+ const current = await readProfilesFile(filePath);
71
+ await writeJsonMinimal(filePath, data, current);
71
72
  }
72
73
 
73
74
  // ─── CRUD ──────────────────────────────────────────────────────────────────────