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
@@ -5,11 +5,9 @@
5
5
  * their configuration:
6
6
  *
7
7
  * - an unparseable file must be REFUSED, and left byte-for-byte alone;
8
- * - a symlinked settings.json must be written THROUGH the link, because that
9
- * is the shape an active profile leaves behind (`.claude/settings.json` →
10
- * `.claude/_profiles/<name>/settings.json`). Remove-then-create would
11
- * replace the link with a plain file and detach the project from its
12
- * profile, which nothing downstream would notice.
8
+ * - a symlinked settings.json must be written THROUGH the link: a symlink a
9
+ * person set up deliberately is theirs. Remove-then-create would replace
10
+ * the link with a plain file, which nothing downstream would notice.
13
11
  */
14
12
 
15
13
  import { afterEach, describe, expect, test } from "bun:test";
@@ -154,7 +152,7 @@ describe("updateSettingsFile", () => {
154
152
  expect(readFileSync(file, "utf8")).toBe(original);
155
153
  });
156
154
 
157
- test("S-6: a symlinked settings.json is written THROUGH the link — the profile case", async () => {
155
+ test("S-6: a symlinked settings.json is written THROUGH the link", async () => {
158
156
  const dir = tempDir();
159
157
  const target = path.join(dir, "profile-settings.json");
160
158
  const link = path.join(dir, "settings.json");
@@ -168,7 +166,7 @@ describe("updateSettingsFile", () => {
168
166
  settings.outputStyle = "composed";
169
167
  });
170
168
 
171
- // The profile's real file received the write …
169
+ // The link target received the write …
172
170
  expect(JSON.parse(readFileSync(target, "utf8"))).toEqual({
173
171
  enabledPlugins: { "dev@magus": true },
174
172
  outputStyle: "composed",
@@ -4,6 +4,13 @@ import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import fs from "fs-extra";
6
6
  import { EMBEDDED_PRESETS } from "../data/styles/index.js";
7
+ import { writeManifest } from "../services/manifest.js";
8
+ import { activateProfile } from "../services/profile-activate.js";
9
+ import {
10
+ liveHash,
11
+ readActiveMarker,
12
+ writeActiveMarker,
13
+ } from "../services/profile-live.js";
7
14
  import {
8
15
  INTEGRITY_BLOCK,
9
16
  type ImportedStyle,
@@ -19,6 +26,7 @@ import {
19
26
  splitFrontmatter,
20
27
  validateSelection,
21
28
  } from "../services/styles-manager.js";
29
+ import type { ProfileManifest } from "../types/index.js";
22
30
  import {
23
31
  buildStyleBrowserItems,
24
32
  firstSelectableIndex,
@@ -405,20 +413,26 @@ describe("applyStyles", () => {
405
413
  ).rejects.toThrow(/Nothing selected/);
406
414
  });
407
415
 
408
- test("writes THROUGH an active profile's settings symlink without breaking it", async () => {
409
- // The regression this guards: removing-then-recreating settings.json
410
- // silently detaches the project from its profile, and every later profile
411
- // switch then writes to a file nothing reads.
412
- const profileSettings = join(
416
+ const DEV: ProfileManifest = {
417
+ version: 2,
418
+ profiles: { dev: { name: "dev", plugins: {} } },
419
+ };
420
+
421
+ test("an older settings symlink is converted to a real file, not written through", async () => {
422
+ // Claude Code refuses to write through a symlink, so the style must not
423
+ // be applied by writing through one either: the file is rendered from
424
+ // the manifest in place of the link.
425
+ const oldSettings = join(
413
426
  dir,
414
427
  ".claude",
415
428
  "_profiles",
416
429
  "dev",
417
430
  "settings.json",
418
431
  );
419
- await fs.outputJson(profileSettings, { enabledPlugins: {} });
432
+ await fs.outputJson(oldSettings, { enabledPlugins: {} });
420
433
  const link = join(dir, ".claude", "settings.json");
421
434
  await symlink("_profiles/dev/settings.json", link);
435
+ await writeManifest(DEV, dir);
422
436
 
423
437
  const result = await applyStyles({
424
438
  projectPath: dir,
@@ -428,33 +442,13 @@ describe("applyStyles", () => {
428
442
 
429
443
  expect(result.profile).toBe("dev");
430
444
  expect(result.styleName).toBe("composed-dev");
431
- expect((await fs.lstat(link)).isSymbolicLink()).toBe(true);
432
- // The write landed in the profile, which is what "apply to the current
433
- // profile" means.
434
- expect((await fs.readJson(profileSettings)).outputStyle).toBe(
435
- "composed-dev",
436
- );
445
+ expect((await fs.lstat(link)).isSymbolicLink()).toBe(false);
446
+ expect((await fs.readJson(link)).outputStyle).toBe("composed-dev");
437
447
  });
438
448
 
439
- test("records the style in profiles.json so re-materializing cannot drop it", async () => {
440
- // materializeProfile rewrites settings.json from the manifest, so a live
441
- // write alone regresses on the next `magus install`.
442
- const profileSettings = join(
443
- dir,
444
- ".claude",
445
- "_profiles",
446
- "dev",
447
- "settings.json",
448
- );
449
- await fs.outputJson(profileSettings, { enabledPlugins: {} });
450
- await symlink(
451
- "_profiles/dev/settings.json",
452
- join(dir, ".claude", "settings.json"),
453
- );
454
- await fs.outputJson(join(dir, ".claude", "profiles.json"), {
455
- version: 2,
456
- profiles: { dev: { name: "dev", plugins: {} } },
457
- });
449
+ test("with an activated profile, the live file stays real and is recorded", async () => {
450
+ await writeManifest(DEV, dir);
451
+ await activateProfile(DEV, "dev", dir, { onDisplace: () => {} });
458
452
 
459
453
  const result = await applyStyles({
460
454
  projectPath: dir,
@@ -462,67 +456,46 @@ describe("applyStyles", () => {
462
456
  imports: [],
463
457
  });
464
458
 
459
+ const live = join(dir, ".claude", "settings.json");
465
460
  expect(result.recordedInManifest).toBe(true);
466
- const manifest = await fs.readJson(join(dir, ".claude", "profiles.json"));
467
- expect(manifest.profiles.dev.settings.outputStyle).toBe("composed-dev");
461
+ expect((await fs.lstat(live)).isFile()).toBe(true);
462
+ expect((await fs.readJson(live)).outputStyle).toBe("composed-dev");
463
+ // Rendered from the manifest and recorded, so it reads as unchanged.
464
+ expect((await readActiveMarker(dir))?.files.settings).toBe(
465
+ (await liveHash("settings", dir)) ?? "",
466
+ );
468
467
  });
469
468
 
470
- test("reports honestly when there is a profile but no manifest entry", async () => {
471
- const profileSettings = join(
472
- dir,
473
- ".claude",
474
- "_profiles",
475
- "dev",
476
- "settings.json",
477
- );
478
- await fs.outputJson(profileSettings, {});
479
- await symlink(
480
- "_profiles/dev/settings.json",
481
- join(dir, ".claude", "settings.json"),
482
- );
469
+ test("records the style in profiles.json, where rendering reads it", async () => {
470
+ await writeManifest(DEV, dir);
471
+ await activateProfile(DEV, "dev", dir, { onDisplace: () => {} });
483
472
 
484
473
  const result = await applyStyles({
485
474
  projectPath: dir,
486
475
  presets: [preset({ name: "direct" })],
487
476
  imports: [],
488
477
  });
489
- expect(result.profile).toBe("dev");
490
- expect(result.recordedInManifest).toBe(false);
491
- });
492
- });
493
478
 
494
- describe("clearStyle", () => {
495
- test("removes the key rather than setting it undefined", async () => {
496
- await applyStyles({
497
- projectPath: dir,
498
- presets: [preset({ name: "direct" })],
499
- imports: [],
500
- });
501
- await clearStyle(dir);
479
+ expect(result.recordedInManifest).toBe(true);
480
+ const manifest = await fs.readJson(join(dir, ".claude", "profiles.json"));
481
+ expect(manifest.profiles.dev.settings.outputStyle).toBe("composed-dev");
482
+ });
502
483
 
503
- const raw = await fs.readFile(
504
- join(dir, ".claude", "settings.json"),
505
- "utf8",
484
+ test("a marker naming a profile profiles.json lacks is no active profile", async () => {
485
+ await writeManifest(DEV, dir);
486
+ await writeActiveMarker(
487
+ { version: 1, profile: "gone", files: {}, skills: {} },
488
+ dir,
506
489
  );
507
- expect(raw).not.toContain("outputStyle");
508
- expect(JSON.parse(raw)).toEqual({});
509
- });
490
+ await fs.outputJson(join(dir, ".claude", "settings.json"), {});
510
491
 
511
- test("leaves the generated file on disk so a re-apply is one keypress away", async () => {
512
492
  const result = await applyStyles({
513
493
  projectPath: dir,
514
494
  presets: [preset({ name: "direct" })],
515
495
  imports: [],
516
496
  });
517
- await clearStyle(dir);
518
- expect(await fs.pathExists(result.stylePath)).toBe(true);
519
- });
520
-
521
- test("is a no-op on a project that never had a style", async () => {
522
- await clearStyle(dir);
523
- expect(await fs.pathExists(join(dir, ".claude", "settings.json"))).toBe(
524
- false,
525
- );
497
+ expect(result.profile).toBeNull();
498
+ expect(result.recordedInManifest).toBe(false);
526
499
  });
527
500
  });
528
501
 
@@ -1,15 +1,18 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { mkdtemp, rm, symlink } from "node:fs/promises";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { parseKeypress } from "@opentui/core";
6
6
  import fs from "fs-extra";
7
+ import { writeManifest } from "../services/manifest.js";
8
+ import { activateProfile } from "../services/profile-activate.js";
7
9
  import {
8
10
  applyStyles,
9
11
  clearStyle,
10
12
  communityCacheDirOrNull,
11
13
  loadStyles,
12
14
  } from "../services/styles-manager.js";
15
+ import type { ProfileManifest } from "../types/index.js";
13
16
  import type { KeyPress } from "../ui/input/key-dispatch.js";
14
17
  import { SCREEN_BINDINGS, matchBinding } from "../ui/keymap.js";
15
18
  import { appReducer, initialState } from "../ui/state/reducer.js";
@@ -291,16 +294,14 @@ describe("loadStyles reports what is actually in force", () => {
291
294
  });
292
295
 
293
296
  test("looks for the per-profile style when a profile is active", async () => {
294
- await fs.outputJson(
295
- join(dir, ".claude", "_profiles", "dev", "settings.json"),
296
- {
297
- outputStyle: "composed-dev",
297
+ const manifest: ProfileManifest = {
298
+ version: 2,
299
+ profiles: {
300
+ dev: { name: "dev", settings: { outputStyle: "composed-dev" } },
298
301
  },
299
- );
300
- await symlink(
301
- "_profiles/dev/settings.json",
302
- join(dir, ".claude", "settings.json"),
303
- );
302
+ };
303
+ await writeManifest(manifest, dir);
304
+ await activateProfile(manifest, "dev", dir, { onDisplace: () => {} });
304
305
  await fs.outputFile(
305
306
  join(dir, ".claude", "output-styles", "composed-dev.md"),
306
307
  "---\nname: composed-dev\nstyle-presets: terse\nstyle-imports: none\n---\n\nbody",
@@ -15,8 +15,9 @@ import {
15
15
  writeManifest,
16
16
  } from "../services/manifest.js";
17
17
  import {
18
- applyProfileChoice,
19
- materializeAndActivate,
18
+ type ActivationResult,
19
+ activateProfile,
20
+ activationNotes,
20
21
  reportDisplaced,
21
22
  } from "../services/profile-activate.js";
22
23
  import {
@@ -31,6 +32,13 @@ import {
31
32
  needsNothing,
32
33
  parseProfileChoice,
33
34
  } from "../services/profile-startup.js";
35
+ import {
36
+ applyProfileChoice,
37
+ describeUnknownProfile,
38
+ prepareProfileState,
39
+ suggestedProfile,
40
+ switchHint,
41
+ } from "../services/profile-state.js";
34
42
  import { SettingsParseError } from "../services/settings-file.js";
35
43
  import type { ProfileManifest } from "../types/index.js";
36
44
  import { dim } from "./ansi.js";
@@ -57,8 +65,8 @@ export function reportUnparseable(error: SettingsParseError): void {
57
65
  * Write the adopted manifest, then put the project ON the profile it describes.
58
66
  *
59
67
  * Both halves or neither. Writing alone is what made migrating feel like a
60
- * no-op: `activeProfile()` is derived from the symlink, so a manifest nothing
61
- * links to changes no behaviour, and the run that just migrated still has to
68
+ * no-op: `activeProfile()` reads the marker activation writes, so a manifest
69
+ * nothing activated changes no behaviour, and the run that just migrated still has to
62
70
  * ask which profile to use.
63
71
  *
64
72
  * Activation is reported in full, including anything it moved aside, because it
@@ -79,20 +87,73 @@ async function writeAndActivate(
79
87
  console.log(`✓ Wrote ${rel(getManifestPath(projectPath))}`);
80
88
 
81
89
  // Each move is printed as it happens, not from the returned list afterwards.
82
- // Activation touches up to four artifacts in sequence, so a throw on the
83
- // third would discard the record of the first two — and the first of those
84
- // is the user's `.claude/settings.json`, now absent from where they left it.
85
- // Printing first means a failed activation still says where everything went.
86
- await materializeAndActivate(
90
+ // Activation writes three files in sequence, so a throw on the third would
91
+ // discard the record of the first two — and the first of those is the
92
+ // user's `.claude/settings.json`, now absent from where they left it.
93
+ const activation = await activateProfile(
87
94
  result.manifest,
88
95
  result.profileId,
89
96
  projectPath,
90
- reportDisplaced(projectPath),
97
+ { onDisplace: reportDisplaced(projectPath) },
91
98
  );
92
99
  console.log(`✓ Active profile: ${result.profileId}`);
100
+ printActivationNotes(activation);
93
101
  console.log();
94
102
  }
95
103
 
104
+ /** Print what activation did beyond the switch itself, one line each. */
105
+ export function printActivationNotes(result: ActivationResult): void {
106
+ for (const line of activationNotes(result)) console.log(noteLine(line));
107
+ }
108
+
109
+ export interface ProfileGateOptions {
110
+ /**
111
+ * Carry on when the marker names a profile profiles.json does not define.
112
+ * Only for commands that fix exactly that by activating a profile that
113
+ * exists — `profile switch`, `install`.
114
+ */
115
+ allowUnknown?: boolean;
116
+ }
117
+
118
+ /**
119
+ * The gate every CLI command that reads or writes profile state passes first.
120
+ *
121
+ * Restores a lost marker when the live config matches exactly one profile,
122
+ * converts an older layout, absorbs what Claude Code wrote to the live config
123
+ * into the active profile, and regenerates the live config when profiles.json
124
+ * changed — printing one line for each. Returns false, having said why, when
125
+ * the command must not act: a marker naming a profile that does not exist, or
126
+ * a live file that cannot be read (it would be overwritten).
127
+ */
128
+ export async function profileGate(
129
+ projectPath: string,
130
+ opts: ProfileGateOptions = {},
131
+ ): Promise<boolean> {
132
+ let prep: Awaited<ReturnType<typeof prepareProfileState>>;
133
+ try {
134
+ prep = await prepareProfileState(projectPath);
135
+ } catch (error) {
136
+ if (error instanceof SettingsParseError) {
137
+ reportUnparseable(error);
138
+ return false;
139
+ }
140
+ console.error(
141
+ failLine(error instanceof Error ? error.message : String(error)),
142
+ );
143
+ return false;
144
+ }
145
+ for (const line of prep.lines) console.log(noteLine(line));
146
+ if (prep.problems.length > 0) {
147
+ for (const problem of prep.problems) console.error(failLine(problem));
148
+ return false;
149
+ }
150
+ if (prep.check.state === "unknown" && !opts.allowUnknown) {
151
+ console.error(failLine(describeUnknownProfile(prep.check)));
152
+ return false;
153
+ }
154
+ return true;
155
+ }
156
+
96
157
  /** Print what adoption would capture, so the user can judge it before writing. */
97
158
  function printAdoptionPlan(result: AdoptResult): void {
98
159
  console.log(
@@ -141,12 +202,11 @@ function printAdoptionPlan(result: AdoptResult): void {
141
202
  // onto the profile, which replaces two files that are in the working tree
142
203
  // right now. The prompt below is the only place to decline.
143
204
  console.log(
144
- "The project then runs this profile: .claude/settings.json, .mcp.json and",
205
+ "The project then runs this profile: .claude/settings.json and .mcp.json are",
145
206
  );
146
207
  console.log(
147
- ".claude/skills become links into it, and your current copies are kept where",
208
+ "generated from it, and your current copies are kept where the run reports.",
148
209
  );
149
- console.log("the run reports.");
150
210
  }
151
211
 
152
212
  export interface EnsureManifestOptions {
@@ -231,7 +291,7 @@ export async function ensureManifest(
231
291
  * state is read from and where updates are written. With none there is nothing
232
292
  * to read and nowhere to write, so there is no useful work to do — and the
233
293
  * previous version of this offered exactly that anyway. It asked only when the
234
- * manifest had drifted, let the user decline, and `update` then carried on with
294
+ * manifest disagreed with `enabledPlugins`, let the user decline, and `update` then carried on with
235
295
  * a profile "for this run only", resolving against a manifest the project was
236
296
  * not running. Two worlds live at once, which is the confusion being removed.
237
297
  *
@@ -262,15 +322,16 @@ export async function requireActiveProfile(
262
322
  // `--check` and with no TTY: this is the committed answer being applied, not
263
323
  // a decision being made, and it is exactly the case CI hits.
264
324
  if (need.autoActivate && need.declaredDefault !== null && !opts.check) {
265
- const { profileId } = await applyProfileChoice(
325
+ const result = await applyProfileChoice(
266
326
  { kind: "activate", id: need.declaredDefault },
267
327
  need.manifest,
268
328
  projectPath,
269
- reportDisplaced(projectPath),
329
+ { onDisplace: reportDisplaced(projectPath) },
270
330
  );
271
331
  console.log(
272
- `✓ Active profile: ${profileId} ${dim("(declared by this project)")}`,
332
+ `✓ Active profile: ${result.profileId} ${dim("(declared by this project)")}`,
273
333
  );
334
+ printActivationNotes(result);
274
335
  console.log("");
275
336
  return readManifest(projectPath);
276
337
  }
@@ -292,9 +353,10 @@ export async function requireActiveProfile(
292
353
  noteLine(
293
354
  need.state === "unmigrated"
294
355
  ? "Run `magus profile init` to create one deliberately."
295
- : `Run \`magus profile switch <${need.activate
296
- .map((p) => p.id)
297
- .join("|")}>\` first.`,
356
+ : `Run ${switchHint(
357
+ need.activate.map((p) => p.id),
358
+ need.declaredDefault ?? suggestedProfile(need.manifest),
359
+ )} first.`,
298
360
  ),
299
361
  );
300
362
  return null;
@@ -316,13 +378,11 @@ export async function requireActiveProfile(
316
378
  return null;
317
379
  }
318
380
 
319
- const { profileId } = await applyProfileChoice(
320
- choice,
321
- need.manifest,
322
- projectPath,
323
- reportDisplaced(projectPath),
324
- );
325
- console.log(`✓ Active profile: ${profileId}`);
381
+ const result = await applyProfileChoice(choice, need.manifest, projectPath, {
382
+ onDisplace: reportDisplaced(projectPath),
383
+ });
384
+ console.log(`✓ Active profile: ${result.profileId}`);
385
+ printActivationNotes(result);
326
386
  console.log("");
327
387
 
328
388
  // Re-read rather than return `need.manifest`: adopting wrote a new profile
package/src/cli/doctor.ts CHANGED
@@ -5,7 +5,11 @@
5
5
  * 0. Global settings — `~/.claude/settings.json` parses.
6
6
  * 1. Binary dependencies — every requires.bin / cliTools binary on PATH.
7
7
  * This is the check that would have caught a dead `tmux-mcp`.
8
- * 2. Profile symlink integrity — the active profile's links resolve.
8
+ * 2. Profile state — which profile is active (strictly), what the next magus
9
+ * command would record or regenerate (with `--fix`, it does it now and
10
+ * says what it did), generated files git still tracks, and
11
+ * magus-installed skills that are missing. Without `--fix` this section
12
+ * writes nothing: no absorbing, no converting an older layout.
9
13
  * 3. Convention compliance — .gitignore + CLAUDE.md blocks (ported engine).
10
14
  * 4. Model routing — config validity, hook registration, and drift.
11
15
  * 5. Plugin loads — what Claude Code will load here vs what is installed.
@@ -42,8 +46,26 @@ import {
42
46
  type RepoRootCache,
43
47
  pickLoadedEntry,
44
48
  } from "../services/plugin-resolution.js";
49
+ import {
50
+ trackedGeneratedFiles,
51
+ untrackCommand,
52
+ } from "../services/profile-activate.js";
53
+ import {
54
+ LIVE_FILE_KEYS,
55
+ liveKind,
56
+ livePath,
57
+ readActiveMarker,
58
+ } from "../services/profile-live.js";
59
+ import { inspectManagedSkills } from "../services/profile-skills.js";
60
+ import {
61
+ type ActiveProfileCheck,
62
+ describeUnknownProfile,
63
+ describeUnmarked,
64
+ inspectProfileState,
65
+ prepareProfileState,
66
+ switchHint,
67
+ } from "../services/profile-state.js";
45
68
  import { resolveAllProfiles } from "../services/resolver.js";
46
- import { activeProfile, profileDir } from "../services/symlink-manager.js";
47
69
  import type {
48
70
  ClaudeSettings,
49
71
  InstalledPluginsRegistry,
@@ -98,18 +120,11 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
98
120
  }
99
121
  }
100
122
 
101
- // 2. Profile symlink integrity.
102
- const active = await activeProfile(projectPath);
103
- if (active) {
104
- const dangling = await danglingLinks(active, projectPath);
105
- if (dangling.length > 0) {
106
- console.log(`Profile "${active}" has broken symlinks:`);
107
- for (const l of dangling) console.log(` ✗ ${l}`);
108
- console.log(` Fix: magus install ${active}`);
109
- problems += dangling.length;
110
- } else {
111
- console.log(`✓ Active profile "${active}" symlinks resolve.`);
112
- }
123
+ // 2. Profile state.
124
+ if (manifest) {
125
+ const profile = await reportProfileState(projectPath, fix);
126
+ problems += profile.fixable;
127
+ unfixable += profile.unfixable;
113
128
  }
114
129
 
115
130
  // 3. Convention compliance — scoped to THIS project only (the ported engine
@@ -418,32 +433,111 @@ async function checkModels(
418
433
  return { fixable: problems, unfixable };
419
434
  }
420
435
 
421
- /** Which of the active profile's expected symlinks are missing or dangling. */
422
- async function danglingLinks(
423
- name: string,
436
+ /**
437
+ * Section 2: which profile is active, and whether its live config is whole.
438
+ *
439
+ * Runs the same gate as every other command first — restoring a lost marker,
440
+ * converting an older layout, absorbing what Claude Code wrote into the live
441
+ * config and regenerating from a changed profiles.json — and reports what it
442
+ * did. What is left is reported plainly, each with its fix.
443
+ */
444
+ export async function reportProfileState(
424
445
  projectPath: string,
425
- ): Promise<string[]> {
426
- const dir = profileDir(name, projectPath);
427
- const checks: Array<{ link: string; target: string }> = [
428
- {
429
- link: path.join(projectPath, ".claude", "settings.json"),
430
- target: path.join(dir, "settings.json"),
431
- },
432
- {
433
- link: path.join(projectPath, ".mcp.json"),
434
- target: path.join(dir, "mcp.json"),
435
- },
436
- ];
437
- const broken: string[] = [];
438
- for (const { link, target } of checks) {
439
- try {
440
- const stat = await fs.lstat(link);
441
- if (stat.isSymbolicLink() && !(await fs.pathExists(target))) {
442
- broken.push(path.relative(projectPath, link));
446
+ fix: boolean,
447
+ ): Promise<{ fixable: number; unfixable: number }> {
448
+ const rel = (p: string) => path.relative(projectPath, p);
449
+ let fixable = 0;
450
+ let unfixable = 0;
451
+ let check: ActiveProfileCheck;
452
+ try {
453
+ if (fix) {
454
+ // `--fix`: the same gate every other command runs, reported.
455
+ const prep = await prepareProfileState(projectPath);
456
+ check = prep.check;
457
+ if (check.state === "no-profiles") return { fixable, unfixable };
458
+ for (const line of prep.lines) console.log(`• ${line}`);
459
+ for (const problem of prep.problems) {
460
+ console.log(`✗ ${problem}`);
461
+ unfixable++;
462
+ }
463
+ } else {
464
+ // Report only. Nothing here writes — not an absorb, not a marker, not
465
+ // a conversion: a diagnostic that changes the project is not one.
466
+ const report = await inspectProfileState(projectPath);
467
+ check = report.check;
468
+ if (check.state === "no-profiles") return { fixable, unfixable };
469
+ for (const error of report.unreadable) {
470
+ console.log(`✗ ${error.message}`);
471
+ console.log(
472
+ " Fix the JSON by hand; magus leaves the file as it is.",
473
+ );
474
+ unfixable++;
475
+ }
476
+ // What the next magus command will do on its own — record an edit,
477
+ // regenerate, convert, restore a lost marker — is information, not
478
+ // a fault: none of it makes doctor exit non-zero.
479
+ for (const line of report.pending) console.log(`• ${line}`);
480
+ }
481
+ } catch (error) {
482
+ console.log("\nProfile:");
483
+ console.log(
484
+ ` ✗ ${error instanceof Error ? error.message : String(error)}`,
485
+ );
486
+ return { fixable: 0, unfixable: 1 };
487
+ }
488
+
489
+ if (check.state === "unknown") {
490
+ console.log(`✗ ${describeUnknownProfile(check)}`);
491
+ unfixable++;
492
+ } else if (check.state === "unmarked" && check.match !== null) {
493
+ // The live config matches exactly one profile: the pending line above
494
+ // already says the next command restores the marker. Not a fault.
495
+ } else if (check.state === "unmarked") {
496
+ console.log(
497
+ check.liveFiles
498
+ ? `✗ ${describeUnmarked(check)}`
499
+ : `✗ No profile is active yet. Run \`magus install\` or ${switchHint(check.available, check.suggested)}.`,
500
+ );
501
+ unfixable++;
502
+ } else if (check.state === "active" || check.state === "old-layout") {
503
+ const broken: string[] = [];
504
+ for (const key of LIVE_FILE_KEYS) {
505
+ const kind = await liveKind(key, projectPath);
506
+ // An older layout's links are reported as pending conversion above.
507
+ if (
508
+ kind === "other" ||
509
+ (kind === "symlink" && check.state === "active")
510
+ ) {
511
+ broken.push(`${rel(livePath(key, projectPath))} is not a regular file`);
443
512
  }
444
- } catch {
445
- // link absent — not "broken", the profile may not provide it.
446
513
  }
514
+ const marker = await readActiveMarker(projectPath);
515
+ if (marker) {
516
+ const { missing } = await inspectManagedSkills(
517
+ marker.skills,
518
+ projectPath,
519
+ );
520
+ for (const folder of missing) {
521
+ broken.push(`skill .claude/skills/${folder} is missing`);
522
+ }
523
+ }
524
+ if (broken.length > 0) {
525
+ console.log(`Profile "${check.profile}" is not fully in place:`);
526
+ for (const line of broken) console.log(` ✗ ${line}`);
527
+ console.log(` Fix: magus profile switch ${check.profile}`);
528
+ fixable += broken.length;
529
+ } else if (check.state === "active") {
530
+ console.log(`✓ Active profile "${check.profile}" is in place.`);
531
+ }
532
+ }
533
+
534
+ // A .gitignore entry does not untrack a file committed before it. Report
535
+ // only: magus never runs git rm.
536
+ for (const tracked of await trackedGeneratedFiles(projectPath)) {
537
+ console.log(
538
+ `✗ ${tracked} is generated from profiles.json but tracked by git. Fix: \`${untrackCommand(tracked)}\`, then commit.`,
539
+ );
540
+ unfixable++;
447
541
  }
448
- return broken;
542
+ return { fixable, unfixable };
449
543
  }