magus-cli 7.4.1 → 7.5.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.
Files changed (65) hide show
  1. package/README.md +9 -8
  2. package/package.json +5 -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 +1210 -0
  7. package/src/__tests__/profile-command.test.ts +90 -32
  8. package/src/__tests__/profile-dependencies.test.ts +442 -0
  9. package/src/__tests__/profile-manifest-write.test.ts +231 -0
  10. package/src/__tests__/profile-migration-offer.test.ts +18 -15
  11. package/src/__tests__/profile-migration.test.ts +246 -117
  12. package/src/__tests__/profile-render.test.ts +141 -0
  13. package/src/__tests__/profile-skills.test.ts +524 -0
  14. package/src/__tests__/profile-startup.test.ts +44 -14
  15. package/src/__tests__/profile-state.test.ts +1110 -0
  16. package/src/__tests__/settings-file.test.ts +5 -7
  17. package/src/__tests__/stale-load-repair.test.ts +105 -0
  18. package/src/__tests__/styles-manager.test.ts +47 -74
  19. package/src/__tests__/styles-screen-state.test.ts +11 -10
  20. package/src/__tests__/support/preload.ts +14 -0
  21. package/src/__tests__/update-apply.test.ts +145 -0
  22. package/src/cli/bootstrap.ts +91 -27
  23. package/src/cli/doctor.ts +165 -38
  24. package/src/cli/install.ts +126 -122
  25. package/src/cli/models.ts +21 -1
  26. package/src/cli/profile.ts +48 -77
  27. package/src/cli/router.ts +39 -6
  28. package/src/cli/update.ts +46 -6
  29. package/src/data/gitignore-defaults.ts +3 -3
  30. package/src/services/active-closure.ts +3 -3
  31. package/src/services/claude-cli.ts +24 -0
  32. package/src/services/claude-settings.ts +27 -0
  33. package/src/services/conventions-manager.ts +88 -0
  34. package/src/services/git-worktree.ts +113 -9
  35. package/src/services/manifest.ts +224 -8
  36. package/src/services/models-manager.ts +36 -73
  37. package/src/services/plugin-dependencies.ts +48 -0
  38. package/src/services/plugin-requires.ts +137 -14
  39. package/src/services/profile-absorb.ts +1586 -0
  40. package/src/services/profile-activate.ts +492 -94
  41. package/src/services/profile-live.ts +884 -0
  42. package/src/services/profile-render.ts +182 -0
  43. package/src/services/profile-skills.ts +487 -0
  44. package/src/services/profile-startup.ts +88 -16
  45. package/src/services/profile-state.ts +706 -0
  46. package/src/services/profiles.ts +26 -37
  47. package/src/services/resolver.ts +100 -2
  48. package/src/services/settings-file.ts +5 -5
  49. package/src/services/skills-manager.ts +14 -8
  50. package/src/services/styles-manager.ts +45 -28
  51. package/src/services/tracked-live-files.ts +125 -0
  52. package/src/services/update-engine.ts +87 -2
  53. package/src/types/index.ts +34 -9
  54. package/src/ui/App.tsx +25 -2
  55. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  56. package/src/ui/screens/PluginsScreen.tsx +11 -2
  57. package/src/ui/screens/ProfilesScreen.tsx +84 -29
  58. package/src/ui/screens/StylesScreen.tsx +2 -1
  59. package/src/ui/state/types.ts +1 -1
  60. package/src/__tests__/profile-materializer.test.ts +0 -238
  61. package/src/__tests__/profile-sync.test.ts +0 -154
  62. package/src/__tests__/symlink-manager.test.ts +0 -180
  63. package/src/services/profile-materializer.ts +0 -133
  64. package/src/services/profile-sync.ts +0 -158
  65. package/src/services/symlink-manager.ts +0 -343
@@ -15,8 +15,10 @@ import {
15
15
  writeManifest,
16
16
  } from "../services/manifest.js";
17
17
  import {
18
- applyProfileChoice,
19
- materializeAndActivate,
18
+ type ActivationResult,
19
+ activateProfile,
20
+ activationNotes,
21
+ assertNoTrackedWriteTargets,
20
22
  reportDisplaced,
21
23
  } from "../services/profile-activate.js";
22
24
  import {
@@ -31,6 +33,13 @@ import {
31
33
  needsNothing,
32
34
  parseProfileChoice,
33
35
  } from "../services/profile-startup.js";
36
+ import {
37
+ applyProfileChoice,
38
+ describeUnknownProfile,
39
+ prepareProfileState,
40
+ suggestedProfile,
41
+ switchHint,
42
+ } from "../services/profile-state.js";
34
43
  import { SettingsParseError } from "../services/settings-file.js";
35
44
  import type { ProfileManifest } from "../types/index.js";
36
45
  import { dim } from "./ansi.js";
@@ -57,8 +66,8 @@ export function reportUnparseable(error: SettingsParseError): void {
57
66
  * Write the adopted manifest, then put the project ON the profile it describes.
58
67
  *
59
68
  * 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
69
+ * no-op: `activeProfile()` reads the marker activation writes, so a manifest
70
+ * nothing activated changes no behaviour, and the run that just migrated still has to
62
71
  * ask which profile to use.
63
72
  *
64
73
  * Activation is reported in full, including anything it moved aside, because it
@@ -75,24 +84,80 @@ async function writeAndActivate(
75
84
  // their hand-written files went.
76
85
  const rel = (p: string) => path.relative(projectPath, p);
77
86
 
87
+ // Before the manifest is written: adopting activates, and activating a
88
+ // project whose settings.json git tracks would take that file over.
89
+ await assertNoTrackedWriteTargets(projectPath);
78
90
  await writeManifest(result.manifest, projectPath);
79
91
  console.log(`✓ Wrote ${rel(getManifestPath(projectPath))}`);
80
92
 
81
93
  // 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(
94
+ // Activation writes three files in sequence, so a throw on the third would
95
+ // discard the record of the first two — and the first of those is the
96
+ // user's `.claude/settings.json`, now absent from where they left it.
97
+ const activation = await activateProfile(
87
98
  result.manifest,
88
99
  result.profileId,
89
100
  projectPath,
90
- reportDisplaced(projectPath),
101
+ { onDisplace: reportDisplaced(projectPath) },
91
102
  );
92
103
  console.log(`✓ Active profile: ${result.profileId}`);
104
+ printActivationNotes(activation);
93
105
  console.log();
94
106
  }
95
107
 
108
+ /** Print what activation did beyond the switch itself, one line each. */
109
+ export function printActivationNotes(result: ActivationResult): void {
110
+ for (const line of activationNotes(result)) console.log(noteLine(line));
111
+ }
112
+
113
+ export interface ProfileGateOptions {
114
+ /**
115
+ * Carry on when the marker names a profile profiles.json does not define.
116
+ * Only for commands that fix exactly that by activating a profile that
117
+ * exists — `profile switch`, `install`.
118
+ */
119
+ allowUnknown?: boolean;
120
+ }
121
+
122
+ /**
123
+ * The gate every CLI command that reads or writes profile state passes first.
124
+ *
125
+ * Restores a lost marker when the live config matches exactly one profile,
126
+ * converts an older layout, absorbs what Claude Code wrote to the live config
127
+ * into the active profile, and regenerates the live config when profiles.json
128
+ * changed — printing one line for each. Returns false, having said why, when
129
+ * the command must not act: a marker naming a profile that does not exist, or
130
+ * a live file that cannot be read (it would be overwritten).
131
+ */
132
+ export async function profileGate(
133
+ projectPath: string,
134
+ opts: ProfileGateOptions = {},
135
+ ): Promise<boolean> {
136
+ let prep: Awaited<ReturnType<typeof prepareProfileState>>;
137
+ try {
138
+ prep = await prepareProfileState(projectPath);
139
+ } catch (error) {
140
+ if (error instanceof SettingsParseError) {
141
+ reportUnparseable(error);
142
+ return false;
143
+ }
144
+ console.error(
145
+ failLine(error instanceof Error ? error.message : String(error)),
146
+ );
147
+ return false;
148
+ }
149
+ for (const line of prep.lines) console.log(noteLine(line));
150
+ if (prep.problems.length > 0) {
151
+ for (const problem of prep.problems) console.error(failLine(problem));
152
+ return false;
153
+ }
154
+ if (prep.check.state === "unknown" && !opts.allowUnknown) {
155
+ console.error(failLine(describeUnknownProfile(prep.check)));
156
+ return false;
157
+ }
158
+ return true;
159
+ }
160
+
96
161
  /** Print what adoption would capture, so the user can judge it before writing. */
97
162
  function printAdoptionPlan(result: AdoptResult): void {
98
163
  console.log(
@@ -141,12 +206,11 @@ function printAdoptionPlan(result: AdoptResult): void {
141
206
  // onto the profile, which replaces two files that are in the working tree
142
207
  // right now. The prompt below is the only place to decline.
143
208
  console.log(
144
- "The project then runs this profile: .claude/settings.json, .mcp.json and",
209
+ "The project then runs this profile: .claude/settings.json and .mcp.json are",
145
210
  );
146
211
  console.log(
147
- ".claude/skills become links into it, and your current copies are kept where",
212
+ "generated from it, and your current copies are kept where the run reports.",
148
213
  );
149
- console.log("the run reports.");
150
214
  }
151
215
 
152
216
  export interface EnsureManifestOptions {
@@ -231,7 +295,7 @@ export async function ensureManifest(
231
295
  * state is read from and where updates are written. With none there is nothing
232
296
  * to read and nowhere to write, so there is no useful work to do — and the
233
297
  * 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
298
+ * manifest disagreed with `enabledPlugins`, let the user decline, and `update` then carried on with
235
299
  * a profile "for this run only", resolving against a manifest the project was
236
300
  * not running. Two worlds live at once, which is the confusion being removed.
237
301
  *
@@ -262,15 +326,16 @@ export async function requireActiveProfile(
262
326
  // `--check` and with no TTY: this is the committed answer being applied, not
263
327
  // a decision being made, and it is exactly the case CI hits.
264
328
  if (need.autoActivate && need.declaredDefault !== null && !opts.check) {
265
- const { profileId } = await applyProfileChoice(
329
+ const result = await applyProfileChoice(
266
330
  { kind: "activate", id: need.declaredDefault },
267
331
  need.manifest,
268
332
  projectPath,
269
- reportDisplaced(projectPath),
333
+ { onDisplace: reportDisplaced(projectPath) },
270
334
  );
271
335
  console.log(
272
- `✓ Active profile: ${profileId} ${dim("(declared by this project)")}`,
336
+ `✓ Active profile: ${result.profileId} ${dim("(declared by this project)")}`,
273
337
  );
338
+ printActivationNotes(result);
274
339
  console.log("");
275
340
  return readManifest(projectPath);
276
341
  }
@@ -292,9 +357,10 @@ export async function requireActiveProfile(
292
357
  noteLine(
293
358
  need.state === "unmigrated"
294
359
  ? "Run `magus profile init` to create one deliberately."
295
- : `Run \`magus profile switch <${need.activate
296
- .map((p) => p.id)
297
- .join("|")}>\` first.`,
360
+ : `Run ${switchHint(
361
+ need.activate.map((p) => p.id),
362
+ need.declaredDefault ?? suggestedProfile(need.manifest),
363
+ )} first.`,
298
364
  ),
299
365
  );
300
366
  return null;
@@ -316,13 +382,11 @@ export async function requireActiveProfile(
316
382
  return null;
317
383
  }
318
384
 
319
- const { profileId } = await applyProfileChoice(
320
- choice,
321
- need.manifest,
322
- projectPath,
323
- reportDisplaced(projectPath),
324
- );
325
- console.log(`✓ Active profile: ${profileId}`);
385
+ const result = await applyProfileChoice(choice, need.manifest, projectPath, {
386
+ onDisplace: reportDisplaced(projectPath),
387
+ });
388
+ console.log(`✓ Active profile: ${result.profileId}`);
389
+ printActivationNotes(result);
326
390
  console.log("");
327
391
 
328
392
  // 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,31 @@ import {
42
46
  type RepoRootCache,
43
47
  pickLoadedEntry,
44
48
  } from "../services/plugin-resolution.js";
49
+ import { trackedGeneratedFiles } from "../services/profile-activate.js";
50
+ import {
51
+ LIVE_FILE_KEYS,
52
+ liveKind,
53
+ livePath,
54
+ readActiveMarker,
55
+ } from "../services/profile-live.js";
56
+ import {
57
+ describeDependencies,
58
+ renderProfile,
59
+ } from "../services/profile-render.js";
60
+ import { inspectManagedSkills } from "../services/profile-skills.js";
61
+ import {
62
+ type ActiveProfileCheck,
63
+ describeUnknownProfile,
64
+ describeUnmarked,
65
+ inspectProfileState,
66
+ prepareProfileState,
67
+ switchHint,
68
+ } from "../services/profile-state.js";
45
69
  import { resolveAllProfiles } from "../services/resolver.js";
46
- import { activeProfile, profileDir } from "../services/symlink-manager.js";
70
+ import {
71
+ GitCheckError,
72
+ untrackCommand,
73
+ } from "../services/tracked-live-files.js";
47
74
  import type {
48
75
  ClaudeSettings,
49
76
  InstalledPluginsRegistry,
@@ -98,18 +125,11 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
98
125
  }
99
126
  }
100
127
 
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
- }
128
+ // 2. Profile state.
129
+ if (manifest) {
130
+ const profile = await reportProfileState(projectPath, fix);
131
+ problems += profile.fixable;
132
+ unfixable += profile.unfixable;
113
133
  }
114
134
 
115
135
  // 3. Convention compliance — scoped to THIS project only (the ported engine
@@ -418,32 +438,139 @@ async function checkModels(
418
438
  return { fixable: problems, unfixable };
419
439
  }
420
440
 
421
- /** Which of the active profile's expected symlinks are missing or dangling. */
422
- async function danglingLinks(
423
- name: string,
441
+ /**
442
+ * Section 2: which profile is active, and whether its live config is whole.
443
+ *
444
+ * Runs the same gate as every other command first — restoring a lost marker,
445
+ * converting an older layout, absorbing what Claude Code wrote into the live
446
+ * config and regenerating from a changed profiles.json — and reports what it
447
+ * did. What is left is reported plainly, each with its fix.
448
+ */
449
+ export async function reportProfileState(
424
450
  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));
451
+ fix: boolean,
452
+ ): Promise<{ fixable: number; unfixable: number }> {
453
+ const rel = (p: string) => path.relative(projectPath, p);
454
+ let fixable = 0;
455
+ let unfixable = 0;
456
+ let check: ActiveProfileCheck;
457
+ try {
458
+ if (fix) {
459
+ // `--fix`: the same gate every other command runs, reported.
460
+ const prep = await prepareProfileState(projectPath);
461
+ check = prep.check;
462
+ if (check.state === "no-profiles") return { fixable, unfixable };
463
+ for (const line of prep.lines) console.log(`• ${line}`);
464
+ for (const problem of prep.problems) {
465
+ console.log(`✗ ${problem}`);
466
+ unfixable++;
443
467
  }
444
- } catch {
445
- // link absent — not "broken", the profile may not provide it.
468
+ } else {
469
+ // Report only. Nothing here writes — not an absorb, not a marker, not
470
+ // a conversion: a diagnostic that changes the project is not one.
471
+ const report = await inspectProfileState(projectPath);
472
+ check = report.check;
473
+ if (check.state === "no-profiles") return { fixable, unfixable };
474
+ for (const error of report.unreadable) {
475
+ console.log(`✗ ${error.message}`);
476
+ console.log(
477
+ " Fix the JSON by hand; magus leaves the file as it is.",
478
+ );
479
+ unfixable++;
480
+ }
481
+ // What the next magus command will do on its own — record an edit,
482
+ // regenerate, convert, restore a lost marker — is information, not
483
+ // a fault: none of it makes doctor exit non-zero.
484
+ for (const line of report.pending) console.log(`• ${line}`);
485
+ }
486
+ } catch (error) {
487
+ console.log("\nProfile:");
488
+ console.log(
489
+ ` ✗ ${error instanceof Error ? error.message : String(error)}`,
490
+ );
491
+ return { fixable: 0, unfixable: 1 };
492
+ }
493
+
494
+ if (check.state === "unknown") {
495
+ console.log(`✗ ${describeUnknownProfile(check)}`);
496
+ unfixable++;
497
+ } else if (check.state === "unmarked" && check.match !== null) {
498
+ // The live config matches exactly one profile: the pending line above
499
+ // already says the next command restores the marker. Not a fault.
500
+ } else if (check.state === "unmarked") {
501
+ console.log(
502
+ check.liveFiles
503
+ ? `✗ ${describeUnmarked(check)}`
504
+ : `✗ No profile is active yet. Run \`magus install\` or ${switchHint(check.available, check.suggested)}.`,
505
+ );
506
+ unfixable++;
507
+ } else if (check.state === "active" || check.state === "old-layout") {
508
+ const broken: string[] = [];
509
+ for (const key of LIVE_FILE_KEYS) {
510
+ const kind = await liveKind(key, projectPath);
511
+ // An older layout's links are reported as pending conversion above.
512
+ if (
513
+ kind === "other" ||
514
+ (kind === "symlink" && check.state === "active")
515
+ ) {
516
+ broken.push(`${rel(livePath(key, projectPath))} is not a regular file`);
517
+ }
518
+ }
519
+ const marker = await readActiveMarker(projectPath);
520
+ if (marker) {
521
+ const { missing } = await inspectManagedSkills(
522
+ marker.skills,
523
+ projectPath,
524
+ );
525
+ for (const folder of missing) {
526
+ broken.push(`skill .claude/skills/${folder} is missing`);
527
+ }
528
+ }
529
+ if (broken.length > 0) {
530
+ console.log(`Profile "${check.profile}" is not fully in place:`);
531
+ for (const line of broken) console.log(` ✗ ${line}`);
532
+ console.log(` Fix: magus profile switch ${check.profile}`);
533
+ fixable += broken.length;
534
+ } else if (check.state === "active") {
535
+ console.log(`✓ Active profile "${check.profile}" is in place.`);
446
536
  }
537
+ // Plugin dependencies: a dependency the profile turns off stops a plugin
538
+ // loading; a plugin.json that cannot be read leaves its dependencies
539
+ // unknown, and they were not enabled.
540
+ const manifest = await readManifest(projectPath);
541
+ const { dependencies } = manifest.profiles[check.profile]
542
+ ? await renderProfile(manifest, check.profile, projectPath)
543
+ : { dependencies: undefined };
544
+ for (const line of describeDependencies(
545
+ dependencies && { ...dependencies, unreadable: [] },
546
+ check.profile,
547
+ )) {
548
+ console.log(`✗ ${line}`);
549
+ unfixable++;
550
+ }
551
+ for (const line of describeDependencies(
552
+ dependencies && { ...dependencies, blocked: [] },
553
+ check.profile,
554
+ )) {
555
+ console.log(`⚠ ${line}`);
556
+ }
557
+ }
558
+
559
+ // A .gitignore entry does not untrack a file committed before it. Report
560
+ // only: magus never runs git rm.
561
+ let tracked: string[] = [];
562
+ try {
563
+ tracked = await trackedGeneratedFiles(projectPath);
564
+ } catch (error) {
565
+ if (!(error instanceof GitCheckError)) throw error;
566
+ console.log(`✗ ${error.message}`);
567
+ unfixable++;
568
+ }
569
+ for (const rel of tracked) {
570
+ console.log(
571
+ `✗ ${rel} is generated from profiles.json but tracked by git. Fix: \`${untrackCommand(rel)}\`, then commit.`,
572
+ );
573
+ unfixable++;
447
574
  }
448
- return broken;
575
+ return { fixable, unfixable };
449
576
  }