magus-cli 7.5.2 → 7.5.4

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 (68) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-convergence.test.ts +1 -2
  3. package/src/__tests__/blackbox-parity.test.ts +16 -14
  4. package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
  5. package/src/__tests__/cli-apply-seams.test.ts +3 -3
  6. package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
  7. package/src/__tests__/content-drift.test.ts +0 -1
  8. package/src/__tests__/files-missing-scope.test.ts +235 -0
  9. package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
  10. package/src/__tests__/format-update-outcome.test.ts +367 -0
  11. package/src/__tests__/gitignore-check.test.ts +11 -29
  12. package/src/__tests__/gitignore-profile.test.ts +248 -0
  13. package/src/__tests__/installed-version-scope.test.tsx +425 -0
  14. package/src/__tests__/mate-availability.test.ts +11 -13
  15. package/src/__tests__/models-cli.test.ts +11 -0
  16. package/src/__tests__/plugin-contents.test.ts +0 -1
  17. package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
  18. package/src/__tests__/plugin-row-text.test.ts +18 -21
  19. package/src/__tests__/profile-command.test.ts +2 -2
  20. package/src/__tests__/profile-migration-offer.test.ts +5 -4
  21. package/src/__tests__/profile-skills.test.ts +17 -22
  22. package/src/__tests__/profile-startup-nested.test.ts +329 -0
  23. package/src/__tests__/profile-startup-old-format.test.ts +390 -0
  24. package/src/__tests__/profile-state.test.ts +152 -164
  25. package/src/__tests__/scope-action.test.ts +35 -38
  26. package/src/__tests__/scope-row-lookup.test.ts +126 -0
  27. package/src/__tests__/select-restore.test.ts +209 -0
  28. package/src/__tests__/stale-load-repair.test.ts +1 -2
  29. package/src/__tests__/update-apply.test.ts +18 -2
  30. package/src/__tests__/update-null-readback.test.ts +300 -0
  31. package/src/__tests__/update-plan-parity.test.ts +249 -0
  32. package/src/__tests__/update-plan.test.ts +66 -67
  33. package/src/__tests__/version-unknown-state.test.ts +38 -39
  34. package/src/cli/bootstrap.ts +239 -70
  35. package/src/cli/doctor.ts +7 -20
  36. package/src/cli/install.ts +83 -27
  37. package/src/cli/prompt.ts +7 -3
  38. package/src/cli/router.ts +6 -4
  39. package/src/cli/select.ts +168 -0
  40. package/src/cli/update-view.ts +118 -6
  41. package/src/cli/update.ts +10 -1
  42. package/src/services/claude-cli.ts +0 -24
  43. package/src/services/claude-settings.ts +28 -7
  44. package/src/services/gitignore-check.ts +37 -49
  45. package/src/services/gitignore-resolver.ts +10 -1
  46. package/src/services/gitignore-service.ts +67 -0
  47. package/src/services/manifest.ts +31 -0
  48. package/src/services/mate-availability.ts +23 -29
  49. package/src/services/plugin-manager.ts +247 -105
  50. package/src/services/profile-absorb.ts +41 -24
  51. package/src/services/profile-activate.ts +102 -127
  52. package/src/services/profile-startup.ts +160 -4
  53. package/src/services/profile-state.ts +7 -68
  54. package/src/services/tracked-live-files.ts +109 -74
  55. package/src/services/update-engine.ts +28 -10
  56. package/src/services/update-plan.ts +78 -94
  57. package/src/ui/App.tsx +41 -35
  58. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  59. package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
  60. package/src/ui/hooks/useGitignoreModal.ts +63 -17
  61. package/src/ui/hooks/useProfileStartupModal.ts +94 -9
  62. package/src/ui/keymap.ts +1 -1
  63. package/src/ui/renderers/pluginRenderers.tsx +39 -45
  64. package/src/ui/renderers/pluginRowText.ts +6 -2
  65. package/src/ui/renderers/pluginStatusText.ts +4 -3
  66. package/src/ui/screens/PluginsScreen.tsx +30 -52
  67. package/src/ui/screens/ProfilesScreen.tsx +3 -10
  68. package/src/cli/tui-prompt.tsx +0 -143
@@ -26,6 +26,12 @@
26
26
  *
27
27
  * ok a profile is active; the manifest is in effect; ask nothing
28
28
  * unmigrated no manifest — the project is not using profiles at all
29
+ * old-format the project is on an older magus profile format; asked about
30
+ * before it is updated, in the CLI and the TUI alike
31
+ * nested no manifest here, but this folder is inside a project that
32
+ * has one — Claude Code started here would read this folder's
33
+ * settings, not that project's, so nothing is offered: the way
34
+ * on is running magus in that project, or `magus profile init`
29
35
  * inactive a manifest with profiles, none of them activated
30
36
  * unknown the active-profile marker names a profile the manifest does
31
37
  * not define; the only way on is activating one that exists
@@ -50,18 +56,25 @@
50
56
  * detects.
51
57
  */
52
58
 
59
+ import path from "node:path";
53
60
  import type { ProfileManifest } from "../types/index.js";
54
61
  // Type-only, so it is erased: this module stays free of the UI and of React,
55
62
  // which is what lets `magus hook` and the CLI import it. The shape of a
56
63
  // selectable option is presentation-neutral data, and both surfaces render the
57
64
  // same list from these builders rather than each composing its own.
58
65
  import type { SelectOption } from "../ui/state/types.js";
59
- import { MANIFEST_FILE, emptyManifest, readManifest } from "./manifest.js";
66
+ import {
67
+ MANIFEST_FILE,
68
+ emptyManifest,
69
+ findEnclosingManifest,
70
+ readManifest,
71
+ } from "./manifest.js";
60
72
  import type { ProfileChoice } from "./profile-activate.js";
61
73
  import { freeProfileId } from "./profile-adopt.js";
62
74
  import {
63
75
  activeProfile,
64
76
  displaceableArtifacts,
77
+ hasOlderLayout,
65
78
  namedActiveProfile,
66
79
  } from "./profile-live.js";
67
80
  import { prepareProfileState } from "./profile-state.js";
@@ -71,6 +84,8 @@ import { SettingsParseError } from "./settings-file.js";
71
84
  export type ProfileStartupState =
72
85
  | "ok"
73
86
  | "unmigrated"
87
+ | "old-format"
88
+ | "nested"
74
89
  | "inactive"
75
90
  | "unknown"
76
91
  | "unparseable";
@@ -102,6 +117,11 @@ export interface ProfileStartupNeed {
102
117
  activate: ActivatableProfile[];
103
118
  /** For `unknown`: the profile the marker names. */
104
119
  unknownProfile?: string;
120
+ /**
121
+ * For `nested`: the enclosing project — its path relative to this folder
122
+ * and its folder name, which is how the user knows it.
123
+ */
124
+ enclosing?: { rel: string; name: string };
105
125
  /**
106
126
  * The profile this project declares it wants, if the manifest names one that
107
127
  * exists. Used to preselect the row so Enter does the intended thing.
@@ -167,6 +187,24 @@ export async function detectProfileStartupNeed(
167
187
  const ids = Object.keys(manifest.profiles);
168
188
 
169
189
  if (ids.length === 0) {
190
+ // One decision for the CLI and the TUI alike. It lived in the CLI alone,
191
+ // so `magus update` in a subfolder explained and stopped while the TUI in
192
+ // the same folder offered to save a second, separate profile.
193
+ const enclosing = await findEnclosingManifest(projectPath);
194
+ if (enclosing !== null) {
195
+ return {
196
+ state: "nested",
197
+ adopt: null,
198
+ activate: [],
199
+ declaredDefault: null,
200
+ autoActivate: false,
201
+ manifest,
202
+ enclosing: {
203
+ rel: path.relative(projectPath, enclosing),
204
+ name: path.basename(enclosing),
205
+ },
206
+ };
207
+ }
170
208
  return {
171
209
  state: "unmigrated",
172
210
  adopt: freeProfileId(manifest),
@@ -242,11 +280,45 @@ export async function detectProfileStartupNeed(
242
280
  };
243
281
  }
244
282
 
283
+ /**
284
+ * The `old-format` question, or null when the project is not on an older
285
+ * magus profile format. One source for the CLI's gate and the TUI's startup,
286
+ * so both ask the same thing — the TUI used to convert without asking while
287
+ * the CLI asked.
288
+ */
289
+ export async function oldFormatNeed(
290
+ projectPath: string,
291
+ ): Promise<ProfileStartupNeed | null> {
292
+ if (!(await hasOlderLayout(projectPath))) return null;
293
+ let manifest: ProfileManifest;
294
+ try {
295
+ manifest = await readManifest(projectPath);
296
+ } catch (error) {
297
+ if (!(error instanceof SettingsParseError)) throw error;
298
+ return unparseableNeed(error);
299
+ }
300
+ return {
301
+ state: "old-format",
302
+ adopt: null,
303
+ activate: [],
304
+ declaredDefault: null,
305
+ autoActivate: false,
306
+ manifest,
307
+ };
308
+ }
309
+
245
310
  /** What the TUI shows at startup, beyond the question (if any). */
246
311
  export interface ProfileStartup {
247
312
  need: ProfileStartupNeed;
248
313
  /** One line per thing the gate did: absorbed edits, regeneration, repairs. */
249
314
  notices: string[];
315
+ /**
316
+ * One line per thing the gate REFUSED — the lines the CLI stops on. Kept
317
+ * apart from `notices` because a refusal can leave detection reading `ok`:
318
+ * an old layout whose settings file does not parse is not converted, yet
319
+ * still names its profile, so only this says the update did not happen.
320
+ */
321
+ problems: string[];
250
322
  }
251
323
 
252
324
  /**
@@ -257,17 +329,66 @@ export interface ProfileStartup {
257
329
  */
258
330
  export async function prepareProfileStartup(
259
331
  projectPath: string,
332
+ /**
333
+ * Update an older profile format. Without it, a project on one is answered
334
+ * with the `old-format` question and NOTHING is converted — the TUI asks
335
+ * first, as the CLI does; the modal's "Update it now" calls back with this.
336
+ */
337
+ opts: { convert?: boolean } = {},
260
338
  ): Promise<ProfileStartup> {
339
+ if (!opts.convert) {
340
+ const format = await oldFormatNeed(projectPath);
341
+ if (format !== null) return { need: format, notices: [], problems: [] };
342
+ }
261
343
  const notices: string[] = [];
344
+ const problems: string[] = [];
262
345
  try {
263
346
  const prep = await prepareProfileState(projectPath);
264
- notices.push(...prep.lines, ...prep.problems);
347
+ notices.push(...prep.lines);
348
+ problems.push(...prep.problems);
265
349
  } catch (error) {
266
350
  if (!(error instanceof SettingsParseError)) {
267
- notices.push(error instanceof Error ? error.message : String(error));
351
+ problems.push(error instanceof Error ? error.message : String(error));
268
352
  }
269
353
  }
270
- return { need: await detectProfileStartupNeed(projectPath), notices };
354
+ return {
355
+ need: await detectProfileStartupNeed(projectPath),
356
+ notices,
357
+ problems,
358
+ };
359
+ }
360
+
361
+ /** What the TUI shows once "Update it now" has run. */
362
+ export type FormatUpdateOutcome =
363
+ | { kind: "ask"; need: ProfileStartupNeed }
364
+ | { kind: "updated"; title: string; message: string }
365
+ | { kind: "failed"; title: string; message: string };
366
+
367
+ /**
368
+ * The verdict on an old-format update, from {@link prepareProfileStartup}'s
369
+ * result. A refusal is a failure even when detection reads `ok` — the CLI
370
+ * stops on the same lines, and the TUI used to show "Profile format updated"
371
+ * over a message saying magus had not updated it.
372
+ */
373
+ export function formatUpdateOutcome(
374
+ result: ProfileStartup,
375
+ ): FormatUpdateOutcome {
376
+ if (result.problems.length > 0) {
377
+ return {
378
+ kind: "failed",
379
+ title: "Could not update the profile format",
380
+ message: [...result.problems, "", "Your profiles are kept."].join("\n"),
381
+ };
382
+ }
383
+ if (!needsNothing(result.need)) return { kind: "ask", need: result.need };
384
+ return {
385
+ kind: "updated",
386
+ title: "Profile format updated",
387
+ message:
388
+ result.notices.length > 0
389
+ ? result.notices.join("\n")
390
+ : "This project now uses the current profile format.",
391
+ };
271
392
  }
272
393
 
273
394
  // ─── What to offer, as data both surfaces render ─────────────────────────────
@@ -276,6 +397,8 @@ export async function prepareProfileStartup(
276
397
  export const ADOPT_VALUE = "adopt";
277
398
  export const QUIT_VALUE = "quit";
278
399
  export const ACTIVATE_PREFIX = "activate:";
400
+ /** The `old-format` question's "Update it now" row. Not a profile choice. */
401
+ export const UPDATE_FORMAT_VALUE = "update-format";
279
402
 
280
403
  /**
281
404
  * Title and message for the state we are in.
@@ -349,6 +472,35 @@ export function describeNeed(need: ProfileStartupNeed): {
349
472
  };
350
473
  }
351
474
 
475
+ if (need.state === "old-format") {
476
+ return {
477
+ title: "This project uses an old magus profile format",
478
+ message:
479
+ "magus has to update it before it can manage this project.\n\nYour profiles, plugins and custom skills are kept.",
480
+ };
481
+ }
482
+
483
+ if (need.state === "nested") {
484
+ // The one other place a path appears, and for the same reason as
485
+ // `unparseable`: the folder IS the message. It sits on a line of its own,
486
+ // short, so the modal never wraps it at a `.`. The reason is stated as
487
+ // Claude Code's behaviour, which it is — magus follows it.
488
+ const at = need.enclosing;
489
+ return {
490
+ title: "Inside another Claude Code project",
491
+ message: [
492
+ "This folder is inside a Claude Code project that already has profiles:",
493
+ "",
494
+ ` ${at?.rel ?? ".."} (${at?.name ?? "?"})`,
495
+ "",
496
+ "Claude Code treats each folder you start it in as a separate project, with its own plugins and settings, and magus follows it.",
497
+ "",
498
+ "To manage plugins for the whole project, run magus there.",
499
+ "To give this folder a setup of its own, run magus profile init here.",
500
+ ].join("\n"),
501
+ };
502
+ }
503
+
352
504
  if (need.state === "unknown") {
353
505
  return {
354
506
  title: "Active profile no longer exists",
@@ -390,6 +542,10 @@ export function describeNeed(need: ProfileStartupNeed): {
390
542
  export function buildProfileOptions(need: ProfileStartupNeed): SelectOption[] {
391
543
  const options: SelectOption[] = [];
392
544
 
545
+ if (need.state === "old-format") {
546
+ options.push({ label: "Update it now", value: UPDATE_FORMAT_VALUE });
547
+ }
548
+
393
549
  if (need.adopt !== null) {
394
550
  options.push({
395
551
  // Says what it PRESERVES, not what it creates. "Create <id> from this
@@ -41,10 +41,6 @@ import {
41
41
  type ProfileChoice,
42
42
  activateProfile,
43
43
  activationNotes,
44
- activeWritableSkillFolders,
45
- assertNoTrackedWriteTargets,
46
- writableSkillFolders,
47
- writeRefusal,
48
44
  } from "./profile-activate.js";
49
45
  import { adoptCurrentProject } from "./profile-adopt.js";
50
46
  import {
@@ -73,7 +69,6 @@ import {
73
69
  skillFolder,
74
70
  } from "./profile-skills.js";
75
71
  import type { SettingsParseError } from "./settings-file.js";
76
- import type { LiveFileRefusal } from "./tracked-live-files.js";
77
72
 
78
73
  export type ActiveProfileCheck =
79
74
  | { state: "no-profiles" }
@@ -266,7 +261,7 @@ export function describeUnknownProfile(
266
261
  export function describeUnmarked(
267
262
  check: Extract<ActiveProfileCheck, { state: "unmarked" }>,
268
263
  ): string {
269
- return `No profile is active in this project, and its live config matches no single profile. Run ${switchHint(check.available, check.suggested)}.`;
264
+ return `No profile is active in this project, and its plugins match none of the profiles. Run ${switchHint(check.available, check.suggested)}.`;
270
265
  }
271
266
 
272
267
  /**
@@ -304,11 +299,6 @@ export interface ProfileStatePrep {
304
299
  problems: string[];
305
300
  /** The files behind `problems` that could not be parsed. */
306
301
  unreadable: SettingsParseError[];
307
- /**
308
- * Set when a file magus would write is tracked by git, or git could not
309
- * say: nothing ran, and `problems` holds its message.
310
- */
311
- refusal?: LiveFileRefusal;
312
302
  }
313
303
 
314
304
  /**
@@ -322,27 +312,6 @@ export async function prepareProfileState(
322
312
  projectPath: string,
323
313
  opts: Pick<ActivateOptions, "installSkill"> = {},
324
314
  ): Promise<ProfileStatePrep> {
325
- // A project with profiles whose live files — or the active profile's
326
- // magus-installed skill folders — git tracks: every path below would write
327
- // one of them (or the marker, or the manifest on the way), so nothing runs,
328
- // not even the marker a unique match would restore.
329
- const manifest = await readManifest(projectPath);
330
- if (Object.keys(manifest.profiles).length > 0) {
331
- const refusal = await writeRefusal(
332
- projectPath,
333
- await activeWritableSkillFolders(projectPath, manifest),
334
- );
335
- if (refusal) {
336
- return {
337
- check: await checkActiveProfile(projectPath, { restore: false }),
338
- reconcile: null,
339
- lines: [],
340
- problems: [refusal.message],
341
- unreadable: [],
342
- refusal,
343
- };
344
- }
345
- }
346
315
  const check = await checkActiveProfile(projectPath);
347
316
  const out: ProfileStatePrep = {
348
317
  check,
@@ -359,7 +328,7 @@ export async function prepareProfileState(
359
328
 
360
329
  if (check.state === "active" && check.restored) {
361
330
  out.lines.push(
362
- `No active-profile marker, but the live config matches profile "${check.profile}" exactly — restored it`,
331
+ `Using profile "${check.profile}" — this project's plugins match it`,
363
332
  );
364
333
  }
365
334
 
@@ -384,10 +353,11 @@ export async function prepareProfileState(
384
353
  );
385
354
  if (check.state === "old-layout") {
386
355
  out.lines.push(
387
- `Converted profile "${profile}" from the older layout to real files`,
356
+ `Updated profile "${profile}" to the current profile format`,
388
357
  );
389
358
  }
390
- out.lines.push(...activationNotes(result));
359
+ // Tracked files are the command's to report, once (see profile-absorb).
360
+ out.lines.push(...activationNotes(result, { tracked: false }));
391
361
  }
392
362
  out.check = { state: "active", profile, restored: false };
393
363
  }
@@ -417,11 +387,11 @@ export async function inspectProfileState(
417
387
 
418
388
  if (check.state === "old-layout") {
419
389
  pending.push(
420
- `Profile "${check.profile}" is on an older magus layout (links into .claude/_profiles/). The next magus command, or \`magus doctor --fix\`, adds any changes Claude Code made under it to profiles.json, converts it to real files, and keeps .claude/_profiles/ in .claude/.magus-backups/.`,
390
+ `Profile "${check.profile}" uses an old magus profile format. The next magus command, or \`magus doctor --fix\`, updates it — keeping any changes Claude Code made, and a backup of the old format.`,
421
391
  );
422
392
  } else if (check.state === "unmarked" && check.match !== null) {
423
393
  pending.push(
424
- `No active-profile marker, but the live config matches profile "${check.match}" exactly — the next magus command, or \`magus doctor --fix\`, restores it.`,
394
+ `This project's plugins match profile "${check.match}" — the next magus command, or \`magus doctor --fix\`, switches to it.`,
425
395
  );
426
396
  } else if (
427
397
  check.state === "active" &&
@@ -524,22 +494,6 @@ export async function switchToProfile(
524
494
  );
525
495
  return result;
526
496
  }
527
- // Before the absorbs below write profiles.json: nothing at all is written
528
- // when a live file this switch would write is tracked by git.
529
- const marker = await readActiveMarker(projectPath);
530
- const refusal = await writeRefusal(projectPath, [
531
- ...(await writableSkillFolders(
532
- projectPath,
533
- target.skills ?? [],
534
- marker?.skills,
535
- )),
536
- ...(await activeWritableSkillFolders(projectPath, before)),
537
- ]);
538
- if (refusal) {
539
- result.problems.push(refusal.message);
540
- return result;
541
- }
542
-
543
497
  if (!take(await absorbBeforeConversion(projectPath, name))) return result;
544
498
  const installSkill = { installSkill: opts.installSkill };
545
499
  if (!take(await reconcileActiveProfile(projectPath, installSkill))) {
@@ -580,20 +534,6 @@ export async function applyProfileChoice(
580
534
  projectPath: string,
581
535
  opts: ActivateOptions = {},
582
536
  ): Promise<ActivationResult> {
583
- // Nothing is written — not the adopted manifest, not an absorbed edit —
584
- // when a live file this choice would write is tracked by git.
585
- const skills =
586
- choice.kind === "activate"
587
- ? (manifest.profiles[choice.id]?.skills ?? [])
588
- : [];
589
- await assertNoTrackedWriteTargets(
590
- projectPath,
591
- await writableSkillFolders(
592
- projectPath,
593
- skills,
594
- (await readActiveMarker(projectPath))?.skills,
595
- ),
596
- );
597
537
  if (choice.kind === "activate") {
598
538
  const absorbed: string[] = [];
599
539
  let changed = false;
@@ -635,7 +575,6 @@ export async function prepareProfileWrite(
635
575
  projectPath: string,
636
576
  ): Promise<ProfileStatePrep> {
637
577
  const prep = await prepareProfileState(projectPath);
638
- if (prep.refusal) throw prep.refusal;
639
578
  refuseOnProblems(prep);
640
579
  return prep;
641
580
  }
@@ -1,76 +1,75 @@
1
1
  /**
2
- * magus never writes a generated file that git tracks.
2
+ * Generated files that git tracks: reported, never refused.
3
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.
4
+ * In a project with profiles, `.claude/settings.json`, `.mcp.json`,
5
+ * `.claude/models.json` and the magus-installed skill folders are generated
6
+ * per developer from `.claude/profiles.json`, and are expected to be
7
+ * gitignored. A repository that committed one before adopting profiles keeps
8
+ * tracking it — `.gitignore` does not untrack a committed file.
12
9
  *
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`.
10
+ * magus writes those files anyway: every command that writes them proceeds
11
+ * exactly as for an untracked file. What changes is what it says. The CLI
12
+ * prints one warning per tracked file with the fix (`git rm --cached <path>`,
13
+ * then commit) and never runs git itself; `magus doctor` reports it as a note;
14
+ * the TUI asks on every open and, when told to, untracks them in place
15
+ * (`gitignore-check.ts`, `profileGitignoreManifest`).
17
16
  */
18
17
 
19
18
  import path from "node:path";
19
+ import type { GitignoreManifest } from "../types/gitignore.js";
20
20
  import { GitQueryError, trackedByGit } from "./git-worktree.js";
21
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
- }
22
+ import {
23
+ LIVE_FILE_KEYS,
24
+ livePath,
25
+ readActiveMarker,
26
+ skillsRoot,
27
+ } from "./profile-live.js";
45
28
 
46
29
  /**
47
30
  * The project is a git work tree, but git could not say what it tracks — it
48
31
  * 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.
32
+ * ownership`). Reported with git's own words wherever the check is made; it
33
+ * never stops a write.
51
34
  */
52
- export class GitCheckError extends LiveFileRefusal {
35
+ export class GitCheckError extends Error {
53
36
  constructor(readonly detail: string) {
54
37
  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"),
38
+ `magus could not check which files git tracks in this project. git said: ${detail.trim()}`,
63
39
  );
64
40
  this.name = "GitCheckError";
65
41
  }
66
42
  }
67
43
 
68
- /** How to untrack one generated path — `-r` for a skill folder. */
44
+ /** How to untrack one generated path. */
69
45
  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}`;
46
+ return `git rm --cached ${rel}`;
47
+ }
48
+
49
+ /** A tracked generated path, as reported: the problem, then the fix beneath. */
50
+ export interface TrackedNotice {
51
+ problem: string;
52
+ /** Lines under the problem. Empty when git itself could not be asked. */
53
+ fix: string[];
54
+ }
55
+
56
+ /**
57
+ * The notice for one tracked generated path.
58
+ *
59
+ * Short lines, not one long one. The single line this replaced ran the
60
+ * problem, the cause and the command together and wrapped mid-command at 80
61
+ * columns; the owner read it as confusing. Now: what is wrong, what the user
62
+ * will see because of it, and the command alone on its line to be copied —
63
+ * each under 80 columns with the report's indent.
64
+ */
65
+ export function trackedNotice(rel: string): TrackedNotice {
66
+ return {
67
+ problem: `git tracks ${rel}, but magus rewrites it`,
68
+ fix: [
69
+ "It will keep showing as changed. Untrack it — the file stays on disk:",
70
+ `${untrackCommand(rel)}, then commit`,
71
+ ],
72
+ };
74
73
  }
75
74
 
76
75
  /**
@@ -92,34 +91,70 @@ export async function trackedPaths(
92
91
  }
93
92
 
94
93
  /**
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.
94
+ * Of `folders` under `.claude/skills/`, the ones git tracks — the team's own
95
+ * copies. A skill folder git tracks is the team's, never magus's: magus does
96
+ * not install over it, manage it, flag it or untrack it. Empty when git cannot
97
+ * say, so a failed git call never turns a team skill into one magus removes —
98
+ * it only leaves a folder magus recorded as its own until git answers.
98
99
  */
99
- async function hasProfiles(projectPath: string): Promise<boolean> {
100
+ export async function trackedSkillFolders(
101
+ projectPath: string,
102
+ folders: readonly string[],
103
+ ): Promise<Set<string>> {
104
+ if (folders.length === 0) return new Set();
105
+ const root = path.relative(projectPath, skillsRoot(projectPath));
106
+ let tracked: Set<string>;
100
107
  try {
101
- const file = await readProfilesFile(getManifestPath(projectPath));
102
- return Object.keys(file.profiles).length > 0;
103
- } catch {
104
- return true;
108
+ tracked = await trackedPaths(
109
+ projectPath,
110
+ [],
111
+ folders.map((folder) => path.join(root, folder)),
112
+ );
113
+ } catch (error) {
114
+ if (error instanceof GitCheckError) return new Set();
115
+ throw error;
105
116
  }
117
+ return new Set(folders.filter((f) => tracked.has(path.join(root, f))));
106
118
  }
107
119
 
108
120
  /**
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.
121
+ * The gitignore rules the profile model implies, for the gitignore manager's
122
+ * `profile` tier — or null in a project without profiles.
123
+ *
124
+ * In a project with profiles — profiles.json defines at least one — the three
125
+ * live files and the magus-installed skill folders are generated per
126
+ * developer, so they must be ignored. The built-in defaults say to TRACK
127
+ * `.claude/settings.json` and `.mcp.json`, which is right for a project that
128
+ * writes them by hand; this tier overrides that for a profiled one, so a
129
+ * committed copy is reported as "tracked but should be ignored". A left-over
130
+ * marker in a repo that dropped profiles.json does not make it profiled.
131
+ *
132
+ * A skill folder git tracks is left out even when the marker records it:
133
+ * markers written by 7.5.0's conversion recorded committed skills as
134
+ * magus-installed, and the team's copy must never be flagged or untracked.
113
135
  */
114
- export async function refuseTrackedLiveFiles(
136
+ export async function profileGitignoreManifest(
115
137
  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);
138
+ ): Promise<GitignoreManifest | null> {
139
+ try {
140
+ const file = await readProfilesFile(getManifestPath(projectPath));
141
+ if (Object.keys(file.profiles).length === 0) return null;
142
+ } catch {
143
+ return null; // An unreadable profiles.json is reported by every profile command.
144
+ }
145
+ const marker = await readActiveMarker(projectPath);
146
+ const managed = Object.keys(marker?.skills ?? {});
147
+ const committed = await trackedSkillFolders(projectPath, managed);
148
+ const posix = (abs: string) =>
149
+ path.relative(projectPath, abs).split(path.sep).join("/");
150
+ const skills = posix(skillsRoot(projectPath));
151
+ return {
152
+ ignore: [
153
+ ...LIVE_FILE_KEYS.map((key) => posix(livePath(key, projectPath))),
154
+ ...managed
155
+ .filter((folder) => !committed.has(folder))
156
+ .map((folder) => `${skills}/${folder}/`),
157
+ ],
158
+ track: [],
159
+ };
125
160
  }