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
@@ -2,8 +2,8 @@ import os from "node:os";
2
2
  import path from "node:path";
3
3
  import fs from "fs-extra";
4
4
  import type { ProfileEntry, ProfileManifest } from "../types/index.js";
5
- import { readProfilesFile } from "./manifest.js";
6
- import { activeProfile } from "./symlink-manager.js";
5
+ import { readProfilesFile, writeJsonMinimal } from "./manifest.js";
6
+ import { activeProfile } from "./profile-live.js";
7
7
 
8
8
  const PROFILES_FILE = "profiles.json";
9
9
 
@@ -64,10 +64,11 @@ async function writeProfiles(
64
64
  projectPath?: string,
65
65
  ): Promise<void> {
66
66
  const filePath = profilesPath(scope, projectPath);
67
- // Read for the throw alone; the contents are about to be replaced.
68
- await readProfilesFile(filePath);
69
- await fs.ensureDir(path.dirname(filePath));
70
- await fs.writeJson(filePath, data, { spaces: 2 });
67
+ // The refusal, and the file as read: only what the caller changed is
68
+ // written, every other byte kept. Diffed against the file's own `version`,
69
+ // so a v1 file is recorded as v2.
70
+ const current = await readProfilesFile(filePath);
71
+ await writeJsonMinimal(filePath, data, current);
71
72
  }
72
73
 
73
74
  // ─── CRUD ──────────────────────────────────────────────────────────────────────
@@ -150,38 +151,26 @@ export function toEnabledPlugins(
150
151
  }
151
152
 
152
153
  /**
153
- * Apply a profile: replaces enabledPlugins in the target settings scope.
154
- * targetScope is where the settings will be written, independent of where
155
- * the profile is stored.
154
+ * Apply a USER-scope profile — a template in `~/.claude/profiles.json` — to
155
+ * `~/.claude/settings.json`, replacing its enabledPlugins.
156
+ *
157
+ * User scope only, by construction. A project's live `.claude/settings.json`
158
+ * is rendered from its active profile in `.claude/profiles.json`; writing
159
+ * enabledPlugins into it directly is what made a TUI "apply" invisible to the
160
+ * profile and undone by the next render. A PROJECT profile is applied by
161
+ * switching to it (`switchToProfile` in `profile-state.ts`), the same path as
162
+ * `magus profile switch`.
156
163
  */
157
- export async function applyProfile(
158
- profileId: string,
159
- profileScope: "user" | "project",
160
- targetScope: "user" | "project" | "local",
161
- projectPath?: string,
162
- ): Promise<void> {
163
- const data = await readProfiles(profileScope, projectPath);
164
- const profile = data.profiles[profileId];
165
- if (!profile) throw new Error(`Profile "${profileId}" not found`);
166
-
167
- // Import settings functions dynamically to avoid circular deps
168
- const {
169
- readSettings,
170
- writeSettings,
171
- readGlobalSettings,
172
- writeGlobalSettings,
173
- } = await import("./claude-settings.js");
174
-
175
- if (targetScope === "user") {
176
- const settings = await readGlobalSettings();
177
- settings.enabledPlugins = toEnabledPlugins(profile.plugins);
178
- await writeGlobalSettings(settings);
179
- } else {
180
- // project or local both write to project settings.json
181
- const settings = await readSettings(projectPath);
182
- settings.enabledPlugins = toEnabledPlugins(profile.plugins);
183
- await writeSettings(settings, projectPath);
184
- }
164
+ export async function applyUserProfile(profile: {
165
+ plugins?: Record<string, string | boolean>;
166
+ }): Promise<void> {
167
+ // Dynamic import: claude-settings reaches back into this module's callers.
168
+ const { readGlobalSettings, writeGlobalSettings } = await import(
169
+ "./claude-settings.js"
170
+ );
171
+ const settings = await readGlobalSettings();
172
+ settings.enabledPlugins = toEnabledPlugins(profile.plugins);
173
+ await writeGlobalSettings(settings);
185
174
  }
186
175
 
187
176
  /** Rename a profile in-place (preserves id, createdAt) */
@@ -5,7 +5,8 @@
5
5
  * marketplaces, plugins, MCP servers, binary deps, skills, settings, and env a
6
6
  * machine must have to match the profile. Binary deps are the union of the
7
7
  * profile's own `cliTools` and every enabled plugin's declared `requires.bin`,
8
- * deduped by executable name with the project's pin winning.
8
+ * deduped by executable name with the project's pin winning. Plugins include
9
+ * every plugin an enabled one declares as a dependency, transitively.
9
10
  */
10
11
 
11
12
  import semver from "semver";
@@ -14,14 +15,19 @@ import { getMarketplaceByName } from "../data/marketplaces.js";
14
15
  import { PREDEFINED_PROFILES } from "../data/predefined-profiles.js";
15
16
  import type {
16
17
  BinRequirement,
18
+ DependencyReport,
17
19
  ProfileManifest,
18
20
  ProfileManifestEntry,
19
21
  ResolvedBin,
20
22
  ResolvedClosure,
21
23
  } from "../types/index.js";
22
24
  import { readManifest } from "./manifest.js";
25
+ import {
26
+ type PluginDependencyReader,
27
+ readPluginDependencies,
28
+ } from "./plugin-dependencies.js";
23
29
  import { resolvePluginBinRequirements } from "./plugin-requires.js";
24
- import { activeProfile } from "./symlink-manager.js";
30
+ import { activeProfile } from "./profile-live.js";
25
31
 
26
32
  export interface ResolveOptions {
27
33
  /**
@@ -29,6 +35,61 @@ export interface ResolveOptions {
29
35
  * tests; defaults to reading plugin.json from the marketplace cache.
30
36
  */
31
37
  binResolver?: (pluginId: string) => Promise<BinRequirement[]>;
38
+ /**
39
+ * Resolve a plugin id to the plugin ids its plugin.json declares as
40
+ * dependencies, or null when it cannot be read. Defaults to reading the
41
+ * plugin.json on this machine (`readPluginDependencies`).
42
+ */
43
+ dependencyResolver?: PluginDependencyReader;
44
+ }
45
+
46
+ /**
47
+ * Every plugin the `enabled` plugins depend on, transitively, as their own
48
+ * plugin.json files declare it.
49
+ *
50
+ * Claude Code refuses to load a plugin whose dependency is not enabled, so a
51
+ * dependency is part of what a profile enables — derived, never written into
52
+ * profiles.json. A dependency the profile itself turns off (`off`) is left
53
+ * off and reported as `blocked`; nothing it depends on is followed. A plugin
54
+ * whose plugin.json cannot be read is reported as `unreadable` rather than
55
+ * guessed at.
56
+ */
57
+ export async function resolveDependencies(
58
+ enabled: readonly string[],
59
+ off: ReadonlySet<string>,
60
+ read: PluginDependencyReader,
61
+ ): Promise<DependencyReport> {
62
+ const derived: string[] = [];
63
+ const unreadable: string[] = [];
64
+ const blocked: DependencyReport["blocked"] = [];
65
+ const queue = [...enabled];
66
+ const seen = new Set(enabled);
67
+ for (let id = queue.shift(); id !== undefined; id = queue.shift()) {
68
+ const dependencies = await read(id);
69
+ if (dependencies === null) {
70
+ unreadable.push(id);
71
+ continue;
72
+ }
73
+ for (const dependency of dependencies) {
74
+ if (off.has(dependency)) {
75
+ blocked.push({ plugin: id, dependency });
76
+ continue;
77
+ }
78
+ if (seen.has(dependency)) continue;
79
+ seen.add(dependency);
80
+ derived.push(dependency);
81
+ queue.push(dependency);
82
+ }
83
+ }
84
+ return { derived: derived.sort(), unreadable: unreadable.sort(), blocked };
85
+ }
86
+
87
+ function hasDependencies(report: DependencyReport): boolean {
88
+ return (
89
+ report.derived.length > 0 ||
90
+ report.unreadable.length > 0 ||
91
+ report.blocked.length > 0
92
+ );
32
93
  }
33
94
 
34
95
  /** Normalize a manifest plugins map to pluginId -> version ("latest" for true). */
@@ -234,11 +295,25 @@ export async function resolveProfile(
234
295
  const binResolver = opts.binResolver ?? resolvePluginBinRequirements;
235
296
 
236
297
  const plugins = normalizePlugins(entry.plugins);
298
+ const disabledPlugins = Object.entries(entry.plugins ?? {})
299
+ .filter(([, ver]) => ver === false)
300
+ .map(([id]) => id);
301
+
302
+ // The plugins the enabled ones need in order to load at all.
303
+ const dependencies = await resolveDependencies(
304
+ Object.keys(plugins),
305
+ new Set(disabledPlugins),
306
+ opts.dependencyResolver ?? readPluginDependencies,
307
+ );
308
+ for (const id of dependencies.derived) plugins[id] = "latest";
309
+
237
310
  const bins = await resolveBins(entry, Object.keys(plugins), binResolver);
238
311
 
239
312
  return {
240
313
  marketplaces: withDerivedMarketplaces(entry.marketplaces, plugins),
241
314
  plugins,
315
+ ...(disabledPlugins.length > 0 ? { disabledPlugins } : {}),
316
+ ...(hasDependencies(dependencies) ? { dependencies } : {}),
242
317
  mcpServers: { ...(entry.mcpServers ?? {}) },
243
318
  bins,
244
319
  skills: [...(entry.skills ?? [])],
@@ -328,6 +403,9 @@ function mergeClosures(
328
403
  }
329
404
  }
330
405
 
406
+ // Dependency reports: union, for `install` and `doctor` to report.
407
+ const dependencies = mergeDependencyReports(a.dependencies, b.dependencies);
408
+
331
409
  // Skills: union by repo+path.
332
410
  const skillKey = (s: { repo: string; path: string }) => `${s.repo}#${s.path}`;
333
411
  const skillMap = new Map(b.skills.map((s) => [skillKey(s), s]));
@@ -348,6 +426,26 @@ function mergeClosures(
348
426
  optional: [...new Set([...a.env.optional, ...b.env.optional])],
349
427
  },
350
428
  ...(conflicts.length > 0 ? { conflicts: [...new Set(conflicts)] } : {}),
429
+ ...(dependencies ? { dependencies } : {}),
430
+ };
431
+ }
432
+
433
+ function mergeDependencyReports(
434
+ a: DependencyReport | undefined,
435
+ b: DependencyReport | undefined,
436
+ ): DependencyReport | undefined {
437
+ if (!a) return b;
438
+ if (!b) return a;
439
+ const blocked = new Map(
440
+ [...a.blocked, ...b.blocked].map((x) => [
441
+ `${x.plugin}\u0000${x.dependency}`,
442
+ x,
443
+ ]),
444
+ );
445
+ return {
446
+ derived: [...new Set([...a.derived, ...b.derived])].sort(),
447
+ unreadable: [...new Set([...a.unreadable, ...b.unreadable])].sort(),
448
+ blocked: [...blocked.values()],
351
449
  };
352
450
  }
353
451
 
@@ -3,11 +3,11 @@
3
3
  *
4
4
  * Two rules, and both exist because of damage they prevent:
5
5
  *
6
- * 1. **Write IN PLACE, never remove-and-recreate.** `.claude/settings.json` is
7
- * a SYMLINK into `.claude/_profiles/<name>/` whenever a profile is active,
8
- * and `writeFile` follows it — which is what we want. Unlinking the file
9
- * first would break the link and silently detach the project from its
10
- * profile, leaving the next read looking at a fresh, empty file.
6
+ * 1. **Write IN PLACE, never remove-and-recreate.** A settings file may be a
7
+ * symlink somebody set up deliberately, and `writeFile` follows it — which
8
+ * is what we want. Unlinking the file first would break the link and leave
9
+ * the next read looking at a fresh, empty file. (magus's own profile files
10
+ * are real files; `profile-live.ts` replaces them via a rename.)
11
11
  * 2. **Unparseable is an ERROR, not an empty object.** A settings file that
12
12
  * fails to parse is a file we do not understand, usually because a human is
13
13
  * mid-edit. Treating it as `{}` and writing our one key back is how a
@@ -40,6 +40,18 @@ export function getProjectSkillsDir(projectPath?: string): string {
40
40
  return path.join(projectPath ?? process.cwd(), ".claude", "skills");
41
41
  }
42
42
 
43
+ /**
44
+ * The folder a skill installs into: a slug of its display name, since display
45
+ * names can carry slashes and spaces. One definition, because the profile code
46
+ * must predict exactly where `installSkill` will write.
47
+ */
48
+ export function skillFolderName(name: string): string {
49
+ return name
50
+ .toLowerCase()
51
+ .replace(/[^a-z0-9]+/g, "-")
52
+ .replace(/^-|-$/g, "");
53
+ }
54
+
43
55
  // ─── GitHub Tree API ──────────────────────────────────────────────────────────
44
56
 
45
57
  async function fetchGitTree(repo: string): Promise<GitTreeResponse> {
@@ -1003,10 +1015,7 @@ export async function installSkill(
1003
1015
  }
1004
1016
 
1005
1017
  // Use slug for directory name — display names can have slashes/spaces
1006
- const dirName = skill.name
1007
- .toLowerCase()
1008
- .replace(/[^a-z0-9]+/g, "-")
1009
- .replace(/^-|-$/g, "");
1018
+ const dirName = skillFolderName(skill.name);
1010
1019
 
1011
1020
  const installDir =
1012
1021
  scope === "user"
@@ -1090,10 +1099,7 @@ export async function uninstallSkill(
1090
1099
  scope: "user" | "project",
1091
1100
  projectPath?: string,
1092
1101
  ): Promise<void> {
1093
- const dirName = skillName
1094
- .toLowerCase()
1095
- .replace(/[^a-z0-9]+/g, "-")
1096
- .replace(/^-|-$/g, "");
1102
+ const dirName = skillFolderName(skillName);
1097
1103
 
1098
1104
  const installDir =
1099
1105
  scope === "user"
@@ -19,12 +19,11 @@
19
19
  *
20
20
  * ## Profile awareness
21
21
  *
22
- * `.claude/settings.json` is a symlink into `.claude/_profiles/<name>/` when a
23
- * profile is active, so writing `outputStyle` through it lands in that
24
- * profile. But `materializeProfile` REWRITES that file from the manifest on
25
- * every `magus install`, so the live write alone silently regresses. The
26
- * selection is therefore also recorded in `.claude/profiles.json` under
27
- * `profiles.<name>.settings.outputStyle`, which is the durable source.
22
+ * `.claude/settings.json` is rendered from the active profile when there is
23
+ * one, so the selection is recorded in `.claude/profiles.json` under
24
+ * `profiles.<name>.settings.outputStyle` — the durable source — and the live
25
+ * file is rendered again from it. Without a profile the live file is edited in
26
+ * place.
28
27
  *
29
28
  * The generated style file is named per-profile (`composed-<name>`) so two
30
29
  * profiles can hold different styles without clobbering each other's file
@@ -43,8 +42,10 @@ import {
43
42
  readManifest,
44
43
  writeManifest,
45
44
  } from "./manifest.js";
45
+ import { regenerateLiveFiles } from "./profile-activate.js";
46
+ import { activeProfile } from "./profile-live.js";
47
+ import { prepareProfileWrite } from "./profile-state.js";
46
48
  import { updateSettingsFile } from "./settings-file.js";
47
- import { activeProfile } from "./symlink-manager.js";
48
49
 
49
50
  // ─── Types ────────────────────────────────────────────────────────────────────
50
51
 
@@ -188,7 +189,7 @@ export interface StylesSnapshot {
188
189
  settingsPath: string;
189
190
  /** Name of the generated style, e.g. "composed" or "composed-dev". */
190
191
  styleName: string;
191
- /** Active profile name, when .claude/settings.json is a profile symlink. */
192
+ /** Active profile name, when a profile is active (see `activeProfile`). */
192
193
  profile: string | null;
193
194
  /** The value currently in settings.json, whatever set it. */
194
195
  currentOutputStyle: string | null;
@@ -1112,6 +1113,10 @@ export async function applyStyles({
1112
1113
  throw new Error("Nothing selected. Pick at least one style.");
1113
1114
  }
1114
1115
 
1116
+ // The gate before anything is written: whatever Claude Code wrote to the
1117
+ // live config goes into the profile, so the render below replaces nothing
1118
+ // unrecorded — and a live file that does not parse stops everything.
1119
+ await prepareProfileWrite(projectPath);
1115
1120
  const profile = await activeProfile(projectPath);
1116
1121
  const styleName = generatedStyleName(profile);
1117
1122
  const stylePath = stylePathFor(projectPath, styleName);
@@ -1122,7 +1127,19 @@ export async function applyStyles({
1122
1127
 
1123
1128
  await fs.ensureDir(path.dirname(stylePath));
1124
1129
  await fs.writeFile(stylePath, contents, "utf8");
1125
- await setOutputStyle(settingsPath, styleName);
1130
+
1131
+ // With a profile active, the manifest is where the selection lives and the
1132
+ // live file is rendered from it.
1133
+ let recordedInManifest = false;
1134
+ if (profile !== null) {
1135
+ recordedInManifest = await recordInManifest(
1136
+ projectPath,
1137
+ profile,
1138
+ styleName,
1139
+ );
1140
+ }
1141
+ if (recordedInManifest) await regenerateLiveFiles(projectPath);
1142
+ else await setOutputStyle(settingsPath, styleName);
1126
1143
 
1127
1144
  // Verify by re-reading, not by trusting the write.
1128
1145
  const verify = splitFrontmatter(await fs.readFile(stylePath, "utf8"));
@@ -1144,12 +1161,6 @@ export async function applyStyles({
1144
1161
  throw new Error(`Verification failed — ${problems.join("; ")}`);
1145
1162
  }
1146
1163
 
1147
- const recordedInManifest = await recordInManifest(
1148
- projectPath,
1149
- profile,
1150
- styleName,
1151
- );
1152
-
1153
1164
  // Write the declaration LAST, once the artifact is verified. It is the thing
1154
1165
  // that gets committed and that teammates are told to match, so it must never
1155
1166
  // claim a composition that failed to produce a valid file.
@@ -1268,6 +1279,25 @@ export async function createTeamStyle(
1268
1279
  */
1269
1280
  export async function clearStyle(projectPath: string): Promise<void> {
1270
1281
  const settingsPath = settingsPathFor(projectPath);
1282
+ // The gate first; a live file that does not parse stops everything.
1283
+ await prepareProfileWrite(projectPath);
1284
+ const profile = await activeProfile(projectPath);
1285
+
1286
+ if (profile !== null && (await fs.pathExists(getManifestPath(projectPath)))) {
1287
+ const manifest = await readManifest(projectPath);
1288
+ const entry = manifest.profiles[profile];
1289
+ if (entry?.settings && "outputStyle" in entry.settings) {
1290
+ // The guard above uses `in`, so assigning undefined would make a second
1291
+ // call a no-op that still leaves the key serialized into profiles.json.
1292
+ // biome-ignore lint/performance/noDelete: removal is the intent, not a shortcut
1293
+ delete entry.settings.outputStyle;
1294
+ entry.updatedAt = new Date().toISOString();
1295
+ await writeManifest(manifest, projectPath);
1296
+ }
1297
+ await regenerateLiveFiles(projectPath);
1298
+ return;
1299
+ }
1300
+
1271
1301
  // The guard stays: a project that never had a style must not gain a
1272
1302
  // settings.json just because the style was cleared.
1273
1303
  if (await fs.pathExists(settingsPath)) {
@@ -1278,17 +1308,4 @@ export async function clearStyle(projectPath: string): Promise<void> {
1278
1308
  delete settings.outputStyle;
1279
1309
  });
1280
1310
  }
1281
-
1282
- const profile = await activeProfile(projectPath);
1283
- if (!profile) return;
1284
- if (!(await fs.pathExists(getManifestPath(projectPath)))) return;
1285
- const manifest = await readManifest(projectPath);
1286
- const entry = manifest.profiles[profile];
1287
- if (!entry?.settings || !("outputStyle" in entry.settings)) return;
1288
- // The guard above uses `in`, so assigning undefined would make a second call
1289
- // a no-op that still leaves the key serialized into profiles.json.
1290
- // biome-ignore lint/performance/noDelete: removal is the intent, not a shortcut
1291
- delete entry.settings.outputStyle;
1292
- entry.updatedAt = new Date().toISOString();
1293
- await writeManifest(manifest, projectPath);
1294
1311
  }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * magus never writes a generated file that git tracks.
3
+ *
4
+ * In a project with profiles, `.claude/settings.json`, `.mcp.json` and
5
+ * `.claude/models.json` are generated per developer from `.claude/profiles.json`.
6
+ * A committed copy is the team's hand-kept file: writing it leaves git
7
+ * tracking a file that now differs per developer, so every write shows as a
8
+ * modified tracked file and one developer's plugin set gets committed for
9
+ * everyone. `.gitignore` cannot fix that — it does not untrack a file already
10
+ * committed. So every writer refuses, having written nothing, and says how to
11
+ * stop tracking it; the user decides.
12
+ *
13
+ * Activation checks every path it would write up front (`profile-activate.ts`).
14
+ * This module holds the refusal itself, and the check for the writers outside
15
+ * activation: the TUI's direct settings edits, `.mcp.json` edits, and the
16
+ * Claude CLI's project-scope installs, which write `.claude/settings.json`.
17
+ */
18
+
19
+ import path from "node:path";
20
+ import { GitQueryError, trackedByGit } from "./git-worktree.js";
21
+ import { getManifestPath, readProfilesFile } from "./manifest.js";
22
+
23
+ /** magus refused to write a live file, and changed nothing. */
24
+ export class LiveFileRefusal extends Error {}
25
+
26
+ /** A live file magus would write is tracked by git. */
27
+ export class TrackedLiveFileError extends LiveFileRefusal {
28
+ constructor(readonly paths: readonly string[]) {
29
+ const one = paths.length === 1;
30
+ super(
31
+ [
32
+ `magus will not write ${one ? "a file" : "files"} git tracks in this project — ${
33
+ one ? "it is" : "they are"
34
+ } generated from .claude/profiles.json and ${one ? "differs" : "differ"} per developer:`,
35
+ ...paths.map((p) => ` ${p}`),
36
+ `Stop tracking ${one ? "it" : "them"}, commit, then run the command again:`,
37
+ ...paths.map((p) => ` ${untrackCommand(p)}`),
38
+ ' git commit -m "Stop tracking generated Claude Code config"',
39
+ "Nothing was changed.",
40
+ ].join("\n"),
41
+ );
42
+ this.name = "TrackedLiveFileError";
43
+ }
44
+ }
45
+
46
+ /**
47
+ * The project is a git work tree, but git could not say what it tracks — it
48
+ * timed out, is missing, or refused the repository (`detected dubious
49
+ * ownership`). Answering "nothing is tracked" would let magus write a tracked
50
+ * file, so it refuses instead and passes on git's own words.
51
+ */
52
+ export class GitCheckError extends LiveFileRefusal {
53
+ constructor(readonly detail: string) {
54
+ super(
55
+ [
56
+ "magus could not check which files git tracks in this project, so it wrote nothing. git said:",
57
+ ...detail
58
+ .trim()
59
+ .split("\n")
60
+ .map((line) => ` ${line}`),
61
+ "Fix that, then run the command again. Nothing was changed.",
62
+ ].join("\n"),
63
+ );
64
+ this.name = "GitCheckError";
65
+ }
66
+ }
67
+
68
+ /** How to untrack one generated path — `-r` for a skill folder. */
69
+ export function untrackCommand(rel: string): string {
70
+ const isDir =
71
+ rel.split(path.sep).length > 2 &&
72
+ rel.startsWith(path.join(".claude", "skills") + path.sep);
73
+ return isDir ? `git rm --cached -r ${rel}` : `git rm --cached ${rel}`;
74
+ }
75
+
76
+ /**
77
+ * Which of `files` (exact entries) and `dirs` (any file under them) git
78
+ * tracks, relative to `projectPath`. Empty outside a git work tree; throws
79
+ * {@link GitCheckError} when inside one and git cannot answer.
80
+ */
81
+ export async function trackedPaths(
82
+ projectPath: string,
83
+ files: readonly string[],
84
+ dirs: readonly string[] = [],
85
+ ): Promise<Set<string>> {
86
+ try {
87
+ return await trackedByGit(projectPath, files, dirs);
88
+ } catch (error) {
89
+ if (error instanceof GitQueryError) throw new GitCheckError(error.detail);
90
+ throw error;
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Does magus generate this project's live files — does its profiles.json
96
+ * define a profile? A profiles.json that cannot be read counts as yes: the
97
+ * check that follows only ever stops a write.
98
+ */
99
+ async function hasProfiles(projectPath: string): Promise<boolean> {
100
+ try {
101
+ const file = await readProfilesFile(getManifestPath(projectPath));
102
+ return Object.keys(file.profiles).length > 0;
103
+ } catch {
104
+ return true;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Throw {@link TrackedLiveFileError} — before anything is written — when
110
+ * `projectPath` has profiles and git tracks any of `files` (absolute, or
111
+ * relative to the project). A project without profiles owns its files by
112
+ * hand, and writing them is not magus's to refuse.
113
+ */
114
+ export async function refuseTrackedLiveFiles(
115
+ projectPath: string,
116
+ files: readonly string[],
117
+ ): Promise<void> {
118
+ if (!(await hasProfiles(projectPath))) return;
119
+ const rel = files.map((f) =>
120
+ path.isAbsolute(f) ? path.relative(projectPath, f) : f,
121
+ );
122
+ const tracked = await trackedPaths(projectPath, rel);
123
+ const hit = rel.filter((r) => tracked.has(r));
124
+ if (hit.length > 0) throw new TrackedLiveFileError(hit);
125
+ }