magus-cli 7.4.0 → 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 (67) hide show
  1. package/README.md +9 -8
  2. package/package.json +4 -4
  3. package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
  4. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  5. package/src/__tests__/install-command.test.ts +4 -3
  6. package/src/__tests__/models-cli.test.ts +4 -7
  7. package/src/__tests__/models-manager.test.ts +52 -40
  8. package/src/__tests__/profile-absorb.test.ts +1138 -0
  9. package/src/__tests__/profile-command.test.ts +90 -32
  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 +425 -0
  14. package/src/__tests__/profile-startup.test.ts +44 -14
  15. package/src/__tests__/profile-state.test.ts +795 -0
  16. package/src/__tests__/settings-file.test.ts +5 -7
  17. package/src/__tests__/stale-load-repair.test.ts +5 -4
  18. package/src/__tests__/styles-manager.test.ts +47 -74
  19. package/src/__tests__/styles-screen-state.test.ts +11 -10
  20. package/src/cli/bootstrap.ts +87 -27
  21. package/src/cli/doctor.ts +152 -44
  22. package/src/cli/install.ts +92 -127
  23. package/src/cli/models.ts +21 -1
  24. package/src/cli/profile.ts +48 -77
  25. package/src/cli/router.ts +32 -6
  26. package/src/cli/update.ts +43 -6
  27. package/src/data/gitignore-defaults.ts +3 -3
  28. package/src/services/active-closure.ts +3 -3
  29. package/src/services/claude-settings.ts +283 -125
  30. package/src/services/cli-tool-commands.ts +30 -0
  31. package/src/services/conventions-manager.ts +88 -0
  32. package/src/services/git-worktree.ts +21 -0
  33. package/src/services/gitignore-service.ts +39 -22
  34. package/src/services/manifest.ts +4 -5
  35. package/src/services/models-manager.ts +36 -73
  36. package/src/services/profile-absorb.ts +1532 -0
  37. package/src/services/profile-activate.ts +348 -96
  38. package/src/services/profile-live.ts +825 -0
  39. package/src/services/profile-render.ts +115 -0
  40. package/src/services/profile-skills.ts +462 -0
  41. package/src/services/profile-startup.ts +88 -16
  42. package/src/services/profile-state.ts +622 -0
  43. package/src/services/profiles.ts +20 -32
  44. package/src/services/resolver.ts +6 -1
  45. package/src/services/settings-file.ts +14 -8
  46. package/src/services/skills-manager.ts +14 -8
  47. package/src/services/styles-manager.ts +45 -28
  48. package/src/services/tool-probe.ts +48 -3
  49. package/src/types/index.ts +15 -9
  50. package/src/ui/App.tsx +25 -2
  51. package/src/ui/components/modals/ModalContainer.tsx +9 -1
  52. package/src/ui/hooks/useProfileStartupModal.ts +20 -15
  53. package/src/ui/input/KeyRouter.tsx +147 -23
  54. package/src/ui/input/key-dispatch.ts +48 -0
  55. package/src/ui/keymap.ts +8 -1
  56. package/src/ui/renderers/cliToolRenderers.tsx +65 -27
  57. package/src/ui/screens/CliToolsScreen.tsx +74 -13
  58. package/src/ui/screens/PluginsScreen.tsx +6 -2
  59. package/src/ui/screens/ProfilesScreen.tsx +75 -27
  60. package/src/ui/screens/StylesScreen.tsx +2 -1
  61. package/src/ui/state/types.ts +1 -1
  62. package/src/__tests__/profile-materializer.test.ts +0 -238
  63. package/src/__tests__/profile-sync.test.ts +0 -154
  64. package/src/__tests__/symlink-manager.test.ts +0 -180
  65. package/src/services/profile-materializer.ts +0 -133
  66. package/src/services/profile-sync.ts +0 -158
  67. package/src/services/symlink-manager.ts +0 -343
@@ -65,6 +65,27 @@ export function clearWorktreeCache(): void {
65
65
  cache.clear();
66
66
  }
67
67
 
68
+ /**
69
+ * Is `relPath` (relative to `projectPath`) tracked by git? For a directory,
70
+ * true when any file under it is tracked.
71
+ *
72
+ * `git ls-files --error-unmatch` exits 0 only for a tracked path. Not a
73
+ * repository, git missing, or git not replying all answer false: a project
74
+ * outside git has nothing tracked to report.
75
+ */
76
+ export async function isTrackedByGit(
77
+ projectPath: string,
78
+ relPath: string,
79
+ ): Promise<boolean> {
80
+ const { code } = await git(projectPath, [
81
+ "ls-files",
82
+ "--error-unmatch",
83
+ "--",
84
+ relPath,
85
+ ]);
86
+ return code === 0;
87
+ }
88
+
68
89
  /**
69
90
  * Directories whose registry rows this project may inherit, nearest first.
70
91
  *
@@ -3,6 +3,11 @@
3
3
  * the Gitignore screen needs in one call, and exposes a small set of
4
4
  * action helpers (apply safe fixes, apply one destructive fix, apply a
5
5
  * built-in template).
6
+ *
7
+ * `.claude/gitignore.json` is read through `settings-file.ts`, the same refusal
8
+ * every settings file gets: applying a template REWRITES that file, so a copy
9
+ * we could not parse must never be the one it is rewritten from — see
10
+ * {@link readProjectManifest}.
6
11
  */
7
12
 
8
13
  import { existsSync } from "node:fs";
@@ -22,6 +27,7 @@ import {
22
27
  applySafeFixes as coreApplySafeFixes,
23
28
  } from "./gitignore-fixer.js";
24
29
  import { resolveGitignore } from "./gitignore-resolver.js";
30
+ import { readSettingsFile, writableList } from "./settings-file.js";
25
31
 
26
32
  export interface GitignoreState {
27
33
  resolved: ResolvedManifest;
@@ -97,7 +103,9 @@ export async function applyTemplate(
97
103
  if (!tpl) throw new Error(`Unknown template: ${templateName}`);
98
104
 
99
105
  const projectManifestPath = join(cwd, ".claude", "gitignore.json");
100
- const existing = await readManifestOrEmpty(projectManifestPath);
106
+ // Before anything is merged: a manifest we cannot read is refused here, so
107
+ // the file is never rewritten from a misreading of it.
108
+ const existing = await readProjectManifest(projectManifestPath);
101
109
 
102
110
  const existingIgnore = new Set(existing.ignore);
103
111
  const existingTrack = new Set(existing.track);
@@ -143,25 +151,34 @@ export async function applyTemplate(
143
151
  };
144
152
  }
145
153
 
146
- async function readManifestOrEmpty(path: string): Promise<GitignoreManifest> {
147
- if (!existsSync(path)) return { ignore: [], track: [] };
148
- try {
149
- const raw = await readFile(path, "utf8");
150
- const parsed = JSON.parse(raw);
151
- if (!isManifest(parsed)) return { ignore: [], track: [] };
152
- return parsed;
153
- } catch {
154
- return { ignore: [], track: [] };
155
- }
156
- }
157
-
158
- function isManifest(x: unknown): x is GitignoreManifest {
159
- if (!x || typeof x !== "object") return false;
160
- const m = x as Record<string, unknown>;
161
- return (
162
- Array.isArray(m.ignore) &&
163
- m.ignore.every((p) => typeof p === "string") &&
164
- Array.isArray(m.track) &&
165
- m.track.every((p) => typeof p === "string")
166
- );
154
+ /**
155
+ * The project manifest, as something safe to merge into and write back.
156
+ *
157
+ * Missing or empty is an empty manifest — nothing is lost by starting from one.
158
+ * A file that EXISTS and cannot be used is a `SettingsParseError` naming it, so
159
+ * the merge never runs and the file stays byte-for-byte as it was:
160
+ *
161
+ * - it does not parse, or its top level is not a JSON object
162
+ * ({@link readSettingsFile});
163
+ * - `ignore`, `track` or `extends` is present but is not an array
164
+ * ({@link writableList}).
165
+ *
166
+ * Reading any of those as "no entries" is what made `t` (apply template)
167
+ * destructive: a trailing comma left by a hand edit read as an empty manifest,
168
+ * the template merged into nothing, and the write replaced the user's own rules
169
+ * with the template's. A dropped `secrets/` line is how a secret reaches a
170
+ * commit, so this refuses rather than guesses.
171
+ *
172
+ * Entries INSIDE those arrays are carried through as they are, not filtered:
173
+ * the merge only appends, so anything already in the file comes back out of it
174
+ * unchanged. Dropping an entry we do not recognise would be the same data loss
175
+ * by a smaller door.
176
+ */
177
+ async function readProjectManifest(path: string): Promise<GitignoreManifest> {
178
+ const raw = await readSettingsFile(path);
179
+ return {
180
+ ignore: writableList(raw, "ignore", path) ?? [],
181
+ track: writableList(raw, "track", path) ?? [],
182
+ extends: writableList(raw, "extends", path),
183
+ } as GitignoreManifest;
167
184
  }
@@ -311,11 +311,10 @@ export async function updateProfileEntry(
311
311
  * Merge `patch` into `profiles.<profile>.settings`, optionally removing the
312
312
  * keys named in `unset`.
313
313
  *
314
- * Why this exists: `.claude/settings.json` is a symlink into the active
315
- * profile's materialized directory, so a live write lands in the right place —
316
- * but `materializeProfile` REWRITES that file from the manifest on every
317
- * `magus install`, so the live write alone silently regresses. The manifest
318
- * is the durable half of every settings write magus makes.
314
+ * Why this exists: `.claude/settings.json` is rendered from the active
315
+ * profile's entry, so a setting magus writes belongs in the entry — the next
316
+ * render reads it from there. The manifest is the durable half of every
317
+ * settings write magus makes.
319
318
  */
320
319
  export async function mergeManifestSettings(
321
320
  projectPath: string,
@@ -11,15 +11,13 @@
11
11
  *
12
12
  * The routing is a PROFILE property, committed in `.claude/profiles.json` under
13
13
  * `profiles.<name>.models`. `.claude/models.json` is the generated view of it —
14
- * a symlink into `_profiles/<active>/models.json` when a profile is active, a
15
- * plain file when the project has no profiles. The hook reads the generated
16
- * file (it walks up from the session's cwd looking for exactly that name); a
17
- * teammate gets the routing by pulling profiles.json and running `magus
18
- * install`.
14
+ * a real file rendered from the active profile when there is one, a plain
15
+ * hand-kept file when the project has no profiles. The hook reads the
16
+ * generated file (it walks up from the session's cwd looking for exactly that
17
+ * name); a teammate gets the routing by pulling profiles.json.
19
18
  *
20
- * Writing only the generated file would be silently temporary: `magus
21
- * install` rewrites `_profiles/<name>/` from the manifest, so the routing would
22
- * disappear on the next install with nothing to explain why.
19
+ * Writing only the generated file would be silently temporary: every
20
+ * activation renders it again from the manifest.
23
21
  *
24
22
  * ## Why the full model id is resolved live
25
23
  *
@@ -61,17 +59,17 @@ import {
61
59
  computeModelsStatus,
62
60
  validateModelsConfig,
63
61
  } from "./models-core.js";
64
- import { materializeProfile } from "./profile-materializer.js";
65
- import { resolveAllProfiles, resolveProfile } from "./resolver.js";
62
+ import { regenerateLiveFiles } from "./profile-activate.js";
63
+ import { activeProfile } from "./profile-live.js";
64
+ import { prepareProfileWrite } from "./profile-state.js";
66
65
  import { readSettingsFile, updateSettingsFile } from "./settings-file.js";
67
- import { activeProfile, syncProfileLink } from "./symlink-manager.js";
68
66
 
69
67
  /** `<projectPath>/.claude/models.json` — what the hook looks for. */
70
68
  export function configPath(projectPath: string): string {
71
69
  return path.join(projectPath, ".claude", "models.json");
72
70
  }
73
71
 
74
- /** `<projectPath>/.claude/settings.json` — a symlink when a profile is active. */
72
+ /** `<projectPath>/.claude/settings.json` — rendered from the profile when one is active. */
75
73
  function settingsPathFor(projectPath: string): string {
76
74
  return path.join(projectPath, ".claude", "settings.json");
77
75
  }
@@ -368,8 +366,9 @@ export interface ApplyModelPresetResult {
368
366
  *
369
367
  * Validation comes first: an invalid config makes every subagent spawn fail, so
370
368
  * it must never reach disk. After that the order of the numbered steps below is
371
- * load-bearing — the manifest is written before materialization because
372
- * materialization regenerates `_profiles/<n>/` FROM the manifest, and the hook
369
+ * load-bearing — Claude Code's edits are absorbed before the manifest is
370
+ * written, the manifest is written before the live files because they are
371
+ * rendered FROM it, and the hook
373
372
  * is registered last because a hook pointing at a config that failed to write is
374
373
  * worse than no hook at all.
375
374
  */
@@ -405,10 +404,14 @@ export async function applyModelPreset(
405
404
  if (Object.keys(manifest.profiles).length === 0 && args.ensureManifest) {
406
405
  manifest = (await args.ensureManifest(projectPath)) ?? manifest;
407
406
  }
407
+ // 2. The gate: anything Claude Code wrote to the live files goes into the
408
+ // profile first, so the regeneration below replaces nothing unrecorded —
409
+ // and a live file that does not parse stops everything, unwritten.
410
+ await prepareProfileWrite(projectPath);
408
411
  const profile = await activeProfile(projectPath);
409
412
 
410
- // 2. Record BOTH halves in the manifest: the routing, and the settings it
411
- // implies. Without the settings half the next `magus install` rewrites
413
+ // 3. Record BOTH halves in the manifest: the routing, and the settings it
414
+ // implies. Without the settings half the next activation renders
412
415
  // settings.json from the manifest and silently reverts the main thread's
413
416
  // model — the routing would keep working and the orchestrator would not.
414
417
  const recordedInManifest = await updateProfileEntry(
@@ -420,38 +423,23 @@ export async function applyModelPreset(
420
423
  },
421
424
  );
422
425
 
423
- // 3. Regenerate the profile dir from the manifest, then point
424
- // .claude/models.json at it. Only the models link is touched: repointing all
425
- // four here would replace a project's real .mcp.json with a symlink as a side
426
- // effect of choosing a model.
426
+ // 4. The live files. With a profile active, settings.json and models.json
427
+ // are rendered from the manifest just written. Without one, models.json is
428
+ // a plain file and settings.json is edited in place.
427
429
  if (recordedInManifest && profile) {
428
- // Re-read: `manifest` above predates the write, so resolving from it would
429
- // materialize the OLD entry — which, having no `models`, would delete the
430
- // file this call exists to create.
431
- const written = await readManifest(projectPath);
432
- await materializeProfile(
433
- profile,
434
- await resolveProfile(written, profile, { binResolver: noBins }),
435
- projectPath,
436
- await unionPluginIds(projectPath),
437
- );
438
- await syncProfileLink(profile, "models", projectPath);
430
+ await regenerateLiveFiles(projectPath);
439
431
  } else {
440
- // No profile: models.json is a plain committed-alongside file.
441
432
  await fs.outputFile(
442
433
  configPath(projectPath),
443
434
  `${JSON.stringify(config, null, 2)}\n`,
444
435
  );
436
+ await updateSettingsFile(settingsPath, (settings) => {
437
+ for (const [key, value] of Object.entries(patch.set)) {
438
+ settings[key] = value;
439
+ }
440
+ });
445
441
  }
446
442
 
447
- // 4. The live settings file (a symlink into the profile when one is active,
448
- // so this lands in the same place materialization just wrote).
449
- await updateSettingsFile(settingsPath, (settings) => {
450
- for (const [key, value] of Object.entries(patch.set)) {
451
- settings[key] = value;
452
- }
453
- });
454
-
455
443
  // 5. Without this, nothing routes.
456
444
  const hook = await registerAgentModelHook();
457
445
 
@@ -480,6 +468,8 @@ export async function clearModels(projectPath: string): Promise<void> {
480
468
  )
481
469
  : [];
482
470
 
471
+ // The gate first; a live file that does not parse stops everything.
472
+ await prepareProfileWrite(projectPath);
483
473
  const profile = await activeProfile(projectPath);
484
474
  const removed = await updateProfileEntry(projectPath, profile, (entry) => {
485
475
  // biome-ignore lint/performance/noDelete: absence is the state, not undefined
@@ -493,21 +483,14 @@ export async function clearModels(projectPath: string): Promise<void> {
493
483
  }
494
484
  });
495
485
 
486
+ // With a profile active, rendering from an entry with no `models` REMOVES
487
+ // .claude/models.json — that is the whole mechanism by which `off` is off.
496
488
  if (removed && profile) {
497
- // Re-materializing with no `models` in the closure DELETES
498
- // _profiles/<n>/models.json, and the relink then removes the now-dangling
499
- // .claude/models.json. That is the whole mechanism by which `off` is off.
500
- const manifest = await readManifest(projectPath);
501
- await materializeProfile(
502
- profile,
503
- await resolveProfile(manifest, profile, { binResolver: noBins }),
504
- projectPath,
505
- await unionPluginIds(projectPath),
506
- );
507
- await syncProfileLink(profile, "models", projectPath);
489
+ await regenerateLiveFiles(projectPath);
490
+ return;
508
491
  }
509
- await fs.remove(configPath(projectPath));
510
492
 
493
+ await fs.remove(configPath(projectPath));
511
494
  const settingsPath = settingsPathFor(projectPath);
512
495
  if (await fs.pathExists(settingsPath)) {
513
496
  await updateSettingsFile(settingsPath, (settings) => {
@@ -543,26 +526,6 @@ function unclaimModelSettings(
543
526
  }
544
527
  }
545
528
 
546
- /**
547
- * Materialization needs no binary requirements — it writes settings, mcp,
548
- * models and skills, and reads none of `closure.bins`. Skipping the resolver's
549
- * per-plugin `plugin.json` reads keeps `models use` off the marketplace cache
550
- * entirely.
551
- */
552
- const noBins = async () => [];
553
-
554
- /**
555
- * The manifest-wide union of plugin ids, which `materializeProfile` needs to
556
- * keep writing the explicit `false` entries that make `profile switch`
557
- * exclusive. Re-materializing without it would silently make switching additive
558
- * again — a routing change must not have that side effect.
559
- */
560
- async function unionPluginIds(projectPath: string): Promise<string[]> {
561
- const manifest = await readManifest(projectPath);
562
- const union = await resolveAllProfiles(manifest, { binResolver: noBins });
563
- return Object.keys(union.plugins);
564
- }
565
-
566
529
  // ─── Status ───────────────────────────────────────────────────────────────────
567
530
 
568
531
  /** What `magus models status` and `magus doctor` report. */
@@ -596,7 +559,7 @@ async function readSettingsFileSafely(
596
559
  }
597
560
  }
598
561
 
599
- /** Re-apply the config already on disk. Used by `doctor --fix` to clear drift. */
562
+ /** Re-apply the config already on disk. Used by `doctor --fix` to repair settings the config implies. */
600
563
  export async function reapplyModels(
601
564
  projectPath: string,
602
565
  ): Promise<ApplyModelPresetResult | null> {