magus-cli 7.4.1 → 7.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +9 -8
  2. package/package.json +4 -4
  3. package/src/__tests__/install-command.test.ts +4 -3
  4. package/src/__tests__/models-cli.test.ts +4 -7
  5. package/src/__tests__/models-manager.test.ts +52 -40
  6. package/src/__tests__/profile-absorb.test.ts +1138 -0
  7. package/src/__tests__/profile-command.test.ts +90 -32
  8. package/src/__tests__/profile-migration-offer.test.ts +18 -15
  9. package/src/__tests__/profile-migration.test.ts +246 -117
  10. package/src/__tests__/profile-render.test.ts +141 -0
  11. package/src/__tests__/profile-skills.test.ts +425 -0
  12. package/src/__tests__/profile-startup.test.ts +44 -14
  13. package/src/__tests__/profile-state.test.ts +795 -0
  14. package/src/__tests__/settings-file.test.ts +5 -7
  15. package/src/__tests__/styles-manager.test.ts +47 -74
  16. package/src/__tests__/styles-screen-state.test.ts +11 -10
  17. package/src/cli/bootstrap.ts +87 -27
  18. package/src/cli/doctor.ts +132 -38
  19. package/src/cli/install.ts +58 -118
  20. package/src/cli/models.ts +21 -1
  21. package/src/cli/profile.ts +48 -77
  22. package/src/cli/router.ts +32 -6
  23. package/src/cli/update.ts +43 -6
  24. package/src/data/gitignore-defaults.ts +3 -3
  25. package/src/services/active-closure.ts +3 -3
  26. package/src/services/conventions-manager.ts +88 -0
  27. package/src/services/git-worktree.ts +21 -0
  28. package/src/services/manifest.ts +4 -5
  29. package/src/services/models-manager.ts +36 -73
  30. package/src/services/profile-absorb.ts +1532 -0
  31. package/src/services/profile-activate.ts +348 -96
  32. package/src/services/profile-live.ts +825 -0
  33. package/src/services/profile-render.ts +115 -0
  34. package/src/services/profile-skills.ts +462 -0
  35. package/src/services/profile-startup.ts +88 -16
  36. package/src/services/profile-state.ts +622 -0
  37. package/src/services/profiles.ts +20 -32
  38. package/src/services/resolver.ts +6 -1
  39. package/src/services/settings-file.ts +5 -5
  40. package/src/services/skills-manager.ts +14 -8
  41. package/src/services/styles-manager.ts +45 -28
  42. package/src/types/index.ts +15 -9
  43. package/src/ui/App.tsx +25 -2
  44. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  45. package/src/ui/screens/ProfilesScreen.tsx +75 -27
  46. package/src/ui/screens/StylesScreen.tsx +2 -1
  47. package/src/ui/state/types.ts +1 -1
  48. package/src/__tests__/profile-materializer.test.ts +0 -238
  49. package/src/__tests__/profile-sync.test.ts +0 -154
  50. package/src/__tests__/symlink-manager.test.ts +0 -180
  51. package/src/services/profile-materializer.ts +0 -133
  52. package/src/services/profile-sync.ts +0 -158
  53. package/src/services/symlink-manager.ts +0 -343
@@ -0,0 +1,825 @@
1
+ /**
2
+ * The active profile's live config: where it is, what magus last wrote there,
3
+ * and the marker that records it.
4
+ *
5
+ * `.claude/profiles.json` is the ONE source of truth. Everything Claude Code
6
+ * reads is generated from the active profile's entry and written as real files
7
+ * — there are no symlinks:
8
+ *
9
+ * .claude/settings.json rendered from the entry (plugins + settings)
10
+ * .mcp.json rendered from the entry (mcpServers)
11
+ * .claude/models.json rendered from the entry (models); absent when
12
+ * the profile routes no models
13
+ * .claude/skills/<name>/ one folder per skill the entry lists, installed
14
+ * by magus. Any OTHER folder in .claude/skills is a
15
+ * custom skill, owned by git and the team; magus
16
+ * never moves, copies, backs up or deletes one.
17
+ *
18
+ * `.claude/profiles/` holds only the marker, `.claude/profiles/active.json`:
19
+ *
20
+ * { "version": 1, "profile": "<name>",
21
+ * "definition": "<sha256 of everything the live files are rendered from>",
22
+ * "base": { ...that entry... },
23
+ * "allPluginIds": [ ...every profile's plugin ids, as rendered... ],
24
+ * "files": { "settings": "<sha256>", "mcp": "<sha256>", "models": "<sha256>" },
25
+ * "skills": { "<folder>": "<sha256 of its contents>" } }
26
+ *
27
+ * A live file whose hash differs was changed since magus wrote it — by Claude
28
+ * Code or a person — and in a magus project that IS a change to the active
29
+ * profile: `profile-absorb.ts` writes it into profiles.json. An entry whose
30
+ * hash differs was changed in profiles.json — usually a teammate's commit
31
+ * arriving by `git pull` — and the live files are regenerated. Edits are
32
+ * absorbed first and merged against `base`, so neither side is lost. Once a
33
+ * marker exists, nothing live is ever backed up.
34
+ *
35
+ * The one thing that IS backed up is a genuine pre-magus, hand-written
36
+ * settings.json, .mcp.json or models.json met at first adoption (no marker, no
37
+ * older layout). It is not magus's, so it goes to `.claude/.magus-backups/`.
38
+ *
39
+ * Why real files: Claude Code writes settings.json and .mcp.json itself, and
40
+ * its atomic writer refuses to write through a symlink
41
+ * (`SymlinkWriteRefusedError`). Every write here goes through a temp file and a
42
+ * rename, so a symlink left by an older layout is REPLACED, never written
43
+ * through.
44
+ *
45
+ * settings.local.json is NEVER touched: it holds personal credentials.
46
+ */
47
+
48
+ import { createHash } from "node:crypto";
49
+ import type { Stats } from "node:fs";
50
+ import path from "node:path";
51
+ import fs from "fs-extra";
52
+ import type { ProfileManifestEntry } from "../types/index.js";
53
+ import { readManifest } from "./manifest.js";
54
+ import { SettingsParseError } from "./settings-file.js";
55
+
56
+ /**
57
+ * Paths that must be gitignored once a project uses profiles: the marker
58
+ * directory, backups and the generated live files. Only .claude/profiles.json
59
+ * is committed. `.claude/skills` is deliberately NOT here — custom skills in it
60
+ * are committed; each magus-installed skill folder gets its own line instead
61
+ * (see `profile-skills.ts`).
62
+ */
63
+ export const PROFILE_GITIGNORE_ENTRIES = [
64
+ ".claude/profiles/",
65
+ ".claude/.magus-backups/",
66
+ ".claude/settings.json",
67
+ ".claude/models.json",
68
+ ".mcp.json",
69
+ ];
70
+
71
+ /** The comment heading magus's lines in a project's .gitignore. */
72
+ export const PROFILE_GITIGNORE_COMMENT =
73
+ "# magus: generated profile config (source of truth: .claude/profiles.json)";
74
+
75
+ /**
76
+ * Lines older releases wrote into magus's managed `.gitignore` block that must
77
+ * go when an older layout is converted: the blanket skills ignore hid committed
78
+ * custom skills, and `_profiles/` no longer exists. Removed from magus's own
79
+ * block only, and only during that conversion — the same line written by a
80
+ * person elsewhere in the file is theirs.
81
+ */
82
+ export const OBSOLETE_GITIGNORE_ENTRIES = [
83
+ ".claude/skills",
84
+ ".claude/skills/",
85
+ ".claude/_profiles/",
86
+ ];
87
+
88
+ /**
89
+ * Settings keys that describe THIS MACHINE rather than the profile.
90
+ *
91
+ * magus records `installedPluginVersions` in the project settings file. It is
92
+ * never absorbed into profiles.json (that would commit one developer's install
93
+ * state as the team's contract), and it is carried across every re-render so
94
+ * writing the live file does not erase it.
95
+ */
96
+ export const MACHINE_LOCAL_SETTINGS_KEYS: readonly string[] = [
97
+ "installedPluginVersions",
98
+ ];
99
+
100
+ // ─── Paths ───────────────────────────────────────────────────────────────────
101
+
102
+ /** `.claude/profiles/` — holds only the marker. */
103
+ export function profilesRoot(projectPath?: string): string {
104
+ return path.join(projectPath ?? process.cwd(), ".claude", "profiles");
105
+ }
106
+
107
+ /** The directory an earlier release materialized profiles into. */
108
+ export function legacyProfilesRoot(projectPath?: string): string {
109
+ return path.join(projectPath ?? process.cwd(), ".claude", "_profiles");
110
+ }
111
+
112
+ /** Where the active-profile marker lives. */
113
+ export function activeMarkerPath(projectPath?: string): string {
114
+ return path.join(profilesRoot(projectPath), "active.json");
115
+ }
116
+
117
+ function legacyMarkerPath(projectPath?: string): string {
118
+ return path.join(legacyProfilesRoot(projectPath), ".active");
119
+ }
120
+
121
+ /** `.claude/skills/` — a real directory of skill folders. */
122
+ export function skillsRoot(projectPath?: string): string {
123
+ return path.join(projectPath ?? process.cwd(), ".claude", "skills");
124
+ }
125
+
126
+ /** The live files rendered from the manifest and tracked by hash. */
127
+ export type LiveFileKey = "settings" | "mcp" | "models";
128
+
129
+ export const LIVE_FILE_KEYS: readonly LiveFileKey[] = [
130
+ "settings",
131
+ "mcp",
132
+ "models",
133
+ ];
134
+
135
+ const LIVE_FILES: Record<LiveFileKey, string[]> = {
136
+ settings: [".claude", "settings.json"],
137
+ mcp: [".mcp.json"],
138
+ models: [".claude", "models.json"],
139
+ };
140
+
141
+ /** The live path of a rendered file. */
142
+ export function livePath(key: LiveFileKey, projectPath?: string): string {
143
+ return path.join(projectPath ?? process.cwd(), ...LIVE_FILES[key]);
144
+ }
145
+
146
+ /** Every generated path, for the git-tracked check and doctor. */
147
+ export function generatedPaths(projectPath?: string): string[] {
148
+ return [
149
+ livePath("settings", projectPath),
150
+ livePath("mcp", projectPath),
151
+ livePath("models", projectPath),
152
+ skillsRoot(projectPath),
153
+ ];
154
+ }
155
+
156
+ // ─── Content helpers ─────────────────────────────────────────────────────────
157
+
158
+ /** The live content of a profile, rendered in memory from the manifest. */
159
+ export interface RenderedProfile {
160
+ settings: Record<string, unknown>;
161
+ mcp: Record<string, unknown>;
162
+ /** The routing file's content, or null when the profile routes nothing. */
163
+ models: Record<string, unknown> | null;
164
+ /** The manifest entry they were rendered from, as written in profiles.json. */
165
+ entry: ProfileManifestEntry;
166
+ /**
167
+ * sha256 of everything the rendering read — see `renderProfile`. What the
168
+ * marker's `definition` records, so a change anywhere in profiles.json that
169
+ * alters this profile's output is noticed, including in another profile.
170
+ */
171
+ definition: string;
172
+ /**
173
+ * Every profile's plugin ids at render time, sorted. Any of them this
174
+ * profile does not enable was written as `false` by the render itself.
175
+ */
176
+ allPluginIds: string[];
177
+ }
178
+
179
+ /** The exact bytes magus writes for a rendered file. */
180
+ export function serializeLive(content: Record<string, unknown>): string {
181
+ return `${JSON.stringify(content, null, 2)}\n`;
182
+ }
183
+
184
+ export function sha256(bytes: Buffer | string): string {
185
+ return createHash("sha256").update(bytes).digest("hex");
186
+ }
187
+
188
+ function sortKeys(value: unknown): unknown {
189
+ if (Array.isArray(value)) return value.map(sortKeys);
190
+ if (typeof value === "object" && value !== null) {
191
+ const out: Record<string, unknown> = {};
192
+ for (const key of Object.keys(value).sort()) {
193
+ out[key] = sortKeys((value as Record<string, unknown>)[key]);
194
+ }
195
+ return out;
196
+ }
197
+ return value;
198
+ }
199
+
200
+ /** JSON with object keys sorted at every level — equal meaning, equal text. */
201
+ export function canonicalJson(value: unknown): string {
202
+ return JSON.stringify(sortKeys(value));
203
+ }
204
+
205
+ /** Semantic JSON equality: key order does not matter, everything else does. */
206
+ export function jsonEqual(a: unknown, b: unknown): boolean {
207
+ return canonicalJson(a) === canonicalJson(b);
208
+ }
209
+
210
+ export function isJsonRecord(value: unknown): value is Record<string, unknown> {
211
+ return typeof value === "object" && value !== null && !Array.isArray(value);
212
+ }
213
+
214
+ // ─── The marker ──────────────────────────────────────────────────────────────
215
+
216
+ /** The contents of the active-profile marker. */
217
+ export interface ActiveMarker {
218
+ version: 1;
219
+ /** The active profile's id. */
220
+ profile: string;
221
+ /**
222
+ * sha256 of the whole render input of the live files (`renderProfile`):
223
+ * this profile's resolved closure and the plugin ids of every profile.
224
+ */
225
+ definition?: string;
226
+ /**
227
+ * That entry itself: the common ancestor when Claude Code edited the live
228
+ * files AND profiles.json changed underneath them, so both can be merged.
229
+ */
230
+ base?: ProfileManifestEntry;
231
+ /**
232
+ * Every profile's plugin ids when the live files were rendered. A `false`
233
+ * in the live settings for one of these that `base` does not enable is the
234
+ * render's own output, not an edit — even after a teammate removes the
235
+ * plugin from every profile and the current manifest no longer names it.
236
+ */
237
+ allPluginIds?: string[];
238
+ /**
239
+ * Set while magus runs the Claude CLI's plugin installs (`magus install`,
240
+ * `magus update`). Each `claude plugin install --scope project` enables its
241
+ * plugin in the live settings.json — for every profile's plugins, not only
242
+ * the active one's. That is the CLI's bookkeeping, and it is rendered over
243
+ * when the installs end. If the run dies first (Ctrl-C, a crash), the flag
244
+ * is still here, and the next reconcile treats settings.json's
245
+ * enabledPlugins as that bookkeeping rather than as the user's edits.
246
+ */
247
+ cliBookkeeping?: true;
248
+ /** sha256 of each live file exactly as magus last wrote or absorbed it. */
249
+ files: Partial<Record<LiveFileKey, string>>;
250
+ /** Magus-installed skill folders under `.claude/skills/`, with content hash. */
251
+ skills: Record<string, string>;
252
+ }
253
+
254
+ /**
255
+ * A profile id or skill folder is one path segment. Anything else in the
256
+ * marker is not a name magus wrote, and following it could escape the tree.
257
+ */
258
+ export function isSegment(name: unknown): name is string {
259
+ return (
260
+ typeof name === "string" &&
261
+ name.length > 0 &&
262
+ name !== "." &&
263
+ name !== ".." &&
264
+ !name.includes("/") &&
265
+ !name.includes("\\")
266
+ );
267
+ }
268
+
269
+ function parseMarker(raw: unknown): ActiveMarker | null {
270
+ if (!isJsonRecord(raw)) return null;
271
+ const {
272
+ profile,
273
+ files,
274
+ definition,
275
+ base,
276
+ skills,
277
+ allPluginIds,
278
+ cliBookkeeping,
279
+ } = raw;
280
+ if (!isSegment(profile)) return null;
281
+ const hashes: ActiveMarker["files"] = {};
282
+ if (isJsonRecord(files)) {
283
+ for (const key of LIVE_FILE_KEYS) {
284
+ const value = files[key];
285
+ if (typeof value === "string") hashes[key] = value;
286
+ }
287
+ }
288
+ const skillHashes: Record<string, string> = {};
289
+ if (isJsonRecord(skills)) {
290
+ for (const [folder, hash] of Object.entries(skills)) {
291
+ if (isSegment(folder) && typeof hash === "string") {
292
+ skillHashes[folder] = hash;
293
+ }
294
+ }
295
+ }
296
+ const marker: ActiveMarker = {
297
+ version: 1,
298
+ profile,
299
+ files: hashes,
300
+ skills: skillHashes,
301
+ };
302
+ if (typeof definition === "string") marker.definition = definition;
303
+ if (isJsonRecord(base)) marker.base = base as unknown as ProfileManifestEntry;
304
+ if (
305
+ Array.isArray(allPluginIds) &&
306
+ allPluginIds.every((id) => typeof id === "string")
307
+ ) {
308
+ marker.allPluginIds = allPluginIds as string[];
309
+ }
310
+ if (cliBookkeeping === true) marker.cliBookkeeping = true;
311
+ return marker;
312
+ }
313
+
314
+ /**
315
+ * Read the marker. Absent, unparseable or wrongly shaped all answer null: the
316
+ * marker is magus's own bookkeeping, and a damaged one degrades to "no marker"
317
+ * — which the active-profile check then resolves — never to a throw.
318
+ *
319
+ * Reading and writing the marker go through this pair and nothing else, so
320
+ * where it lives is decided in one place. A marker an earlier release left at
321
+ * `.claude/_profiles/.active` is read too, while no current one exists.
322
+ */
323
+ export async function readActiveMarker(
324
+ projectPath?: string,
325
+ ): Promise<ActiveMarker | null> {
326
+ for (const file of [
327
+ activeMarkerPath(projectPath),
328
+ legacyMarkerPath(projectPath),
329
+ ]) {
330
+ try {
331
+ return parseMarker(await fs.readJson(file));
332
+ } catch {
333
+ // Try the next location.
334
+ }
335
+ }
336
+ return null;
337
+ }
338
+
339
+ /**
340
+ * Is there a marker in the current format at `.claude/profiles/active.json`?
341
+ * False for none, and for one an earlier release left at `_profiles/.active`,
342
+ * which belongs to an older layout and is converted with it.
343
+ */
344
+ export async function hasCurrentMarker(projectPath?: string): Promise<boolean> {
345
+ return fs.pathExists(activeMarkerPath(projectPath));
346
+ }
347
+
348
+ /**
349
+ * The hashes an earlier release recorded at `_profiles/.active`, or null. Used
350
+ * only to tell an unedited live file of that layout from an edited one.
351
+ */
352
+ export async function readLegacyMarkerHashes(
353
+ projectPath?: string,
354
+ ): Promise<ActiveMarker["files"] | null> {
355
+ try {
356
+ return (
357
+ parseMarker(await fs.readJson(legacyMarkerPath(projectPath)))?.files ??
358
+ null
359
+ );
360
+ } catch {
361
+ return null;
362
+ }
363
+ }
364
+
365
+ /** Write the marker. The other half of {@link readActiveMarker}. */
366
+ export async function writeActiveMarker(
367
+ marker: ActiveMarker,
368
+ projectPath?: string,
369
+ ): Promise<void> {
370
+ // The older marker at `_profiles/.active` is left where it is: the current
371
+ // one takes precedence, and conversion keeps `_profiles/` whole in a backup.
372
+ await writeRealFile(
373
+ activeMarkerPath(projectPath),
374
+ `${JSON.stringify(marker, null, 2)}\n`,
375
+ );
376
+ }
377
+
378
+ /**
379
+ * A marker for `profile`, recording what `rendered` was rendered from. Never
380
+ * carries `cliBookkeeping`: writing a fresh marker is what clears it.
381
+ */
382
+ export function markerFor(
383
+ profile: string,
384
+ rendered: Pick<RenderedProfile, "entry" | "definition" | "allPluginIds">,
385
+ files: ActiveMarker["files"],
386
+ skills: Record<string, string>,
387
+ ): ActiveMarker {
388
+ return {
389
+ version: 1,
390
+ profile,
391
+ definition: rendered.definition,
392
+ base: rendered.entry,
393
+ allPluginIds: rendered.allPluginIds,
394
+ files,
395
+ skills,
396
+ };
397
+ }
398
+
399
+ // ─── Live files ──────────────────────────────────────────────────────────────
400
+
401
+ /** What sits at a live path. */
402
+ export type LiveKind = "file" | "missing" | "symlink" | "other";
403
+
404
+ export async function liveKind(
405
+ key: LiveFileKey,
406
+ projectPath?: string,
407
+ ): Promise<LiveKind> {
408
+ try {
409
+ const stat = await fs.lstat(livePath(key, projectPath));
410
+ if (stat.isSymbolicLink()) return "symlink";
411
+ return stat.isFile() ? "file" : "other";
412
+ } catch {
413
+ return "missing";
414
+ }
415
+ }
416
+
417
+ /** The hash of a live file, or null when it is not a regular file. */
418
+ export async function liveHash(
419
+ key: LiveFileKey,
420
+ projectPath?: string,
421
+ ): Promise<string | null> {
422
+ if ((await liveKind(key, projectPath)) !== "file") return null;
423
+ return sha256(await fs.readFile(livePath(key, projectPath)));
424
+ }
425
+
426
+ /**
427
+ * The live file parsed as a JSON object. `null` when absent or not a regular
428
+ * file; throws `SyntaxError` when it exists and is not a JSON object.
429
+ */
430
+ export async function readLiveJson(
431
+ key: LiveFileKey,
432
+ projectPath?: string,
433
+ ): Promise<Record<string, unknown> | null> {
434
+ if ((await liveKind(key, projectPath)) !== "file") return null;
435
+ const parsed: unknown = JSON.parse(
436
+ await fs.readFile(livePath(key, projectPath), "utf8"),
437
+ );
438
+ if (!isJsonRecord(parsed)) throw new SyntaxError("not a JSON object");
439
+ return parsed;
440
+ }
441
+
442
+ /** {@link readLiveJson} that answers null instead of throwing. */
443
+ export async function readLiveJsonSafely(
444
+ key: LiveFileKey,
445
+ projectPath?: string,
446
+ ): Promise<Record<string, unknown> | null> {
447
+ try {
448
+ return await readLiveJson(key, projectPath);
449
+ } catch {
450
+ return null;
451
+ }
452
+ }
453
+
454
+ /** What a live path holds, read THROUGH a symlink if it is one. */
455
+ export interface LiveRead {
456
+ /** Nothing there, or a link whose target is gone. */
457
+ missing: boolean;
458
+ /** The path is a symlink (an older layout's). */
459
+ isLink: boolean;
460
+ /** The parsed object, when it parsed. */
461
+ value: Record<string, unknown> | null;
462
+ /** sha256 of the bytes, when there were any. */
463
+ hash: string | null;
464
+ /** Why it could not be used, when it could not. */
465
+ error: SettingsParseError | null;
466
+ }
467
+
468
+ /**
469
+ * Read a live file following a symlink — an older layout linked settings.json,
470
+ * .mcp.json and models.json into `.claude/_profiles/<name>/`, and Claude Code
471
+ * before 2.1.280 wrote its edits through those links. Never throws.
472
+ */
473
+ export async function readLiveThrough(
474
+ key: LiveFileKey,
475
+ projectPath?: string,
476
+ ): Promise<LiveRead> {
477
+ const file = livePath(key, projectPath);
478
+ const out: LiveRead = {
479
+ missing: true,
480
+ isLink: false,
481
+ value: null,
482
+ hash: null,
483
+ error: null,
484
+ };
485
+ try {
486
+ out.isLink = (await fs.lstat(file)).isSymbolicLink();
487
+ } catch {
488
+ return out;
489
+ }
490
+ let bytes: Buffer;
491
+ try {
492
+ bytes = await fs.readFile(file);
493
+ } catch {
494
+ return out; // A dangling link: nothing to read.
495
+ }
496
+ out.missing = false;
497
+ out.hash = sha256(bytes);
498
+ try {
499
+ const parsed: unknown = JSON.parse(bytes.toString("utf8"));
500
+ if (!isJsonRecord(parsed)) throw new SyntaxError("not a JSON object");
501
+ out.value = parsed;
502
+ } catch (error) {
503
+ out.error = new SettingsParseError(file, error);
504
+ }
505
+ return out;
506
+ }
507
+
508
+ /**
509
+ * Live files that exist as regular files and do not parse as a JSON object.
510
+ *
511
+ * magus never writes over a file it cannot parse — the same rule every
512
+ * settings writer follows — so every path that writes the live files checks
513
+ * this first and refuses, leaving the file byte for byte as it is.
514
+ */
515
+ export async function unreadableLiveFiles(
516
+ projectPath?: string,
517
+ ): Promise<SettingsParseError[]> {
518
+ const out: SettingsParseError[] = [];
519
+ for (const key of LIVE_FILE_KEYS) {
520
+ if ((await liveKind(key, projectPath)) !== "file") continue;
521
+ const read = await readLiveThrough(key, projectPath);
522
+ if (read.error) out.push(read.error);
523
+ }
524
+ return out;
525
+ }
526
+
527
+ /** Throw the first of {@link unreadableLiveFiles}, if there is one. */
528
+ export async function assertLiveFilesParse(
529
+ projectPath?: string,
530
+ ): Promise<void> {
531
+ const [first] = await unreadableLiveFiles(projectPath);
532
+ if (first) throw first;
533
+ }
534
+
535
+ /**
536
+ * Write `bytes` at `file` as a real file, replacing whatever entry is there.
537
+ *
538
+ * Via a temp file and a rename, so Claude Code never reads half a file, and so
539
+ * a symlink at `file` is REPLACED rather than written through.
540
+ */
541
+ async function writeRealFile(file: string, bytes: string): Promise<void> {
542
+ await fs.ensureDir(path.dirname(file));
543
+ const tmp = `${file}.magus-tmp-${process.pid}`;
544
+ await fs.writeFile(tmp, bytes);
545
+ await fs.rename(tmp, file);
546
+ }
547
+
548
+ /**
549
+ * Write one rendered live file (or remove it, for `null` content) and return
550
+ * the hash of what was written, or null when nothing is there now. Does NOT
551
+ * touch the marker — callers record the hash with the rest of their state.
552
+ *
553
+ * Machine-local settings keys in the current live file are carried into the
554
+ * new one (see {@link MACHINE_LOCAL_SETTINGS_KEYS}).
555
+ */
556
+ export async function writeLiveFile(
557
+ key: LiveFileKey,
558
+ content: Record<string, unknown> | null,
559
+ projectPath?: string,
560
+ ): Promise<string | null> {
561
+ const file = livePath(key, projectPath);
562
+ // Backstop for the callers' own checks: never replace a regular file that
563
+ // does not parse. A symlink (an older layout) is replaced by the rename.
564
+ if ((await liveKind(key, projectPath)) === "file") {
565
+ const current = await readLiveThrough(key, projectPath);
566
+ if (current.error) throw current.error;
567
+ }
568
+ if (content === null) {
569
+ await fs.remove(file);
570
+ return null;
571
+ }
572
+ let body = content;
573
+ 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) {
577
+ body = { ...content };
578
+ for (const k of MACHINE_LOCAL_SETTINGS_KEYS) {
579
+ if (k in live && !(k in body)) body[k] = live[k];
580
+ }
581
+ }
582
+ }
583
+ const bytes = serializeLive(body);
584
+ await writeRealFile(file, bytes);
585
+ return sha256(bytes);
586
+ }
587
+
588
+ /** Write all three live files of `rendered`; returns their hashes. */
589
+ export async function writeRenderedFiles(
590
+ rendered: RenderedProfile,
591
+ projectPath?: string,
592
+ ): Promise<ActiveMarker["files"]> {
593
+ const files: ActiveMarker["files"] = {};
594
+ for (const key of LIVE_FILE_KEYS) {
595
+ const hash = await writeLiveFile(key, rendered[key], projectPath);
596
+ if (hash) files[key] = hash;
597
+ }
598
+ return files;
599
+ }
600
+
601
+ // ─── Skill folders ───────────────────────────────────────────────────────────
602
+
603
+ /**
604
+ * A content hash of a skill folder: every file's path relative to the folder
605
+ * and its bytes, in sorted order. A symlink inside contributes its target text.
606
+ * Null when the folder does not exist.
607
+ */
608
+ export async function folderHash(dir: string): Promise<string | null> {
609
+ try {
610
+ if (!(await fs.lstat(dir)).isDirectory()) return null;
611
+ } catch {
612
+ return null;
613
+ }
614
+ const hash = createHash("sha256");
615
+ const walk = async (rel: string): Promise<void> => {
616
+ const entries = (await fs.readdir(path.join(dir, rel))).sort();
617
+ for (const name of entries) {
618
+ const childRel = rel ? `${rel}/${name}` : name;
619
+ const abs = path.join(dir, childRel);
620
+ const stat = await fs.lstat(abs);
621
+ if (stat.isDirectory()) {
622
+ await walk(childRel);
623
+ } else if (stat.isSymbolicLink()) {
624
+ hash.update(`L\0${childRel}\0${await fs.readlink(abs)}\0`);
625
+ } else if (stat.isFile()) {
626
+ hash.update(`F\0${childRel}\0`);
627
+ hash.update(await fs.readFile(abs));
628
+ hash.update("\0");
629
+ }
630
+ }
631
+ };
632
+ await walk("");
633
+ return hash.digest("hex");
634
+ }
635
+
636
+ // ─── Older layouts ───────────────────────────────────────────────────────────
637
+
638
+ /**
639
+ * If `link` is a symlink into `.claude/_profiles/<n>/…` — the layout magus
640
+ * 7.4.1 and earlier wrote — the profile it points into. No release linked into
641
+ * `.claude/profiles/`, so a link there is somebody's own and is left alone.
642
+ */
643
+ export async function profileLinkTarget(
644
+ link: string,
645
+ projectPath?: string,
646
+ ): Promise<string | null> {
647
+ try {
648
+ if (!(await fs.lstat(link)).isSymbolicLink()) return null;
649
+ const resolved = path.resolve(path.dirname(link), await fs.readlink(link));
650
+ const prefix = legacyProfilesRoot(projectPath) + path.sep;
651
+ if (!resolved.startsWith(prefix)) return null;
652
+ const name = resolved.slice(prefix.length).split(path.sep)[0];
653
+ return isSegment(name) ? name : null;
654
+ } catch {
655
+ return null;
656
+ }
657
+ }
658
+
659
+ /**
660
+ * The profile an older symlink layout names, or null. Any of the generated
661
+ * paths being a symlink into a profiles directory counts — the skills link
662
+ * outlived the settings link by one release.
663
+ */
664
+ export async function oldLayoutProfile(
665
+ projectPath?: string,
666
+ ): Promise<string | null> {
667
+ for (const p of generatedPaths(projectPath)) {
668
+ const name = await profileLinkTarget(p, projectPath);
669
+ if (name) return name;
670
+ }
671
+ return null;
672
+ }
673
+
674
+ /**
675
+ * The per-profile directories older releases left: `.claude/_profiles/<n>/`,
676
+ * written by magus 7.4.1 and earlier and by the release after it. Conversion
677
+ * takes the skills out and keeps the rest in `.claude/.magus-backups/`.
678
+ *
679
+ * `.claude/profiles/` is never scanned: no release wrote per-profile
680
+ * directories there, so anything in it besides `active.json` is somebody's.
681
+ */
682
+ export async function legacyProfileDirs(
683
+ projectPath?: string,
684
+ ): Promise<Array<{ name: string; dir: string }>> {
685
+ const out: Array<{ name: string; dir: string }> = [];
686
+ const root = legacyProfilesRoot(projectPath);
687
+ let names: string[];
688
+ try {
689
+ names = await fs.readdir(root);
690
+ } catch {
691
+ return out;
692
+ }
693
+ for (const name of names) {
694
+ if (!isSegment(name) || name.startsWith(".")) continue;
695
+ const dir = path.join(root, name);
696
+ try {
697
+ if ((await fs.lstat(dir)).isDirectory()) out.push({ name, dir });
698
+ } catch {
699
+ // Vanished between listing and stat.
700
+ }
701
+ }
702
+ return out;
703
+ }
704
+
705
+ /**
706
+ * Does anything of an older layout remain — a generated path that is a
707
+ * symlink into `.claude/_profiles/`, the `_profiles/` directory itself, or the
708
+ * older marker? Activation converts all of it.
709
+ */
710
+ export async function hasOlderLayout(projectPath?: string): Promise<boolean> {
711
+ if ((await oldLayoutProfile(projectPath)) !== null) return true;
712
+ return fs.pathExists(legacyProfilesRoot(projectPath));
713
+ }
714
+
715
+ // ─── Backups ─────────────────────────────────────────────────────────────────
716
+
717
+ /**
718
+ * Where first adoption moves a hand-written file it had to displace. Never
719
+ * inside `.claude/profiles/`, which is documented as regenerable.
720
+ */
721
+ export function backupRoot(projectPath?: string): string {
722
+ return path.join(projectPath ?? process.cwd(), ".claude", ".magus-backups");
723
+ }
724
+
725
+ /** One artifact first adoption moved aside to make room for the profile's. */
726
+ export interface DisplacedArtifact {
727
+ /** The live path the profile's artifact now occupies. */
728
+ link: string;
729
+ /** Where its previous contents were moved. */
730
+ savedTo: string;
731
+ }
732
+
733
+ /** A directory name that sorts chronologically and is legal on every platform. */
734
+ export function backupStamp(): string {
735
+ return new Date().toISOString().replace(/[:.]/g, "-");
736
+ }
737
+
738
+ /**
739
+ * Move whatever sits at `file` into `backupDir`. A symlink is moved as a link,
740
+ * rewritten as absolute so its meaning survives the move.
741
+ */
742
+ export async function displace(
743
+ file: string,
744
+ backupDir: string,
745
+ ): Promise<DisplacedArtifact | null> {
746
+ let stat: Stats;
747
+ try {
748
+ stat = await fs.lstat(file);
749
+ } catch {
750
+ return null;
751
+ }
752
+ const savedTo = path.join(backupDir, path.basename(file));
753
+ await fs.ensureDir(backupDir);
754
+ if (stat.isSymbolicLink()) {
755
+ const absolute = path.resolve(path.dirname(file), await fs.readlink(file));
756
+ await fs.remove(savedTo);
757
+ await fs.symlink(absolute, savedTo);
758
+ await fs.remove(file);
759
+ return { link: file, savedTo };
760
+ }
761
+ await fs.move(file, savedTo, { overwrite: true });
762
+ return { link: file, savedTo };
763
+ }
764
+
765
+ /**
766
+ * Hand-written files that activating ANY profile would have to move aside.
767
+ *
768
+ * Empty means activating is lossless — a fresh clone, a project magus already
769
+ * manages (a marker exists: its live files are absorbed, never displaced), or
770
+ * an older layout magus converts. That is what makes it safe to honour the
771
+ * manifest's declared default without asking.
772
+ */
773
+ export async function displaceableArtifacts(
774
+ projectPath?: string,
775
+ ): Promise<string[]> {
776
+ if ((await readActiveMarker(projectPath)) !== null) return [];
777
+ if ((await oldLayoutProfile(projectPath)) !== null) return [];
778
+ const found: string[] = [];
779
+ for (const key of LIVE_FILE_KEYS) {
780
+ if ((await liveKind(key, projectPath)) !== "missing") {
781
+ found.push(livePath(key, projectPath));
782
+ }
783
+ }
784
+ return found;
785
+ }
786
+
787
+ // ─── Which profile is active ─────────────────────────────────────────────────
788
+
789
+ /**
790
+ * The profile the marker (or an older symlink layout) NAMES, whether or not
791
+ * the manifest still defines it. For the strict check in `profile-state.ts`;
792
+ * everyone else wants {@link activeProfile}.
793
+ */
794
+ export async function namedActiveProfile(
795
+ projectPath?: string,
796
+ ): Promise<{ profile: string; source: "marker" | "old-layout" } | null> {
797
+ const marker = await readActiveMarker(projectPath);
798
+ if (marker) return { profile: marker.profile, source: "marker" };
799
+ const old = await oldLayoutProfile(projectPath);
800
+ return old ? { profile: old, source: "old-layout" } : null;
801
+ }
802
+
803
+ /**
804
+ * The name of the currently active profile, or null when none is.
805
+ *
806
+ * Strict: the marker (or an older layout) must name a profile that
807
+ * `.claude/profiles.json` defines. A marker naming a profile the manifest no
808
+ * longer has describes nothing magus can act on, so it answers null here and
809
+ * the entry-point check (`checkActiveProfile`) reports it with the fix.
810
+ */
811
+ export async function activeProfile(
812
+ projectPath?: string,
813
+ ): Promise<string | null> {
814
+ const named = await namedActiveProfile(projectPath);
815
+ if (!named) return null;
816
+ try {
817
+ const manifest = await readManifest(projectPath);
818
+ return Object.hasOwn(manifest.profiles, named.profile)
819
+ ? named.profile
820
+ : null;
821
+ } catch {
822
+ // An unreadable manifest: the commands that read it properly report why.
823
+ return null;
824
+ }
825
+ }