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
@@ -61,8 +61,7 @@ import {
61
61
  type ActivateOptions,
62
62
  activateProfile,
63
63
  activationNotes,
64
- activeWritableSkillFolders,
65
- writeRefusal,
64
+ releasedCommittedSkillNote,
66
65
  } from "./profile-activate.js";
67
66
  import {
68
67
  type ActiveMarker,
@@ -94,6 +93,7 @@ import {
94
93
  } from "./profile-skills.js";
95
94
  import { resolveExtends, resolveProfile } from "./resolver.js";
96
95
  import { SettingsParseError } from "./settings-file.js";
96
+ import { trackedSkillFolders } from "./tracked-live-files.js";
97
97
 
98
98
  // ─── Changes ─────────────────────────────────────────────────────────────────
99
99
 
@@ -947,7 +947,7 @@ export async function absorbOrphanedProfile(
947
947
  );
948
948
  if (!base && changes.length > 0) {
949
949
  result.lines.push(
950
- `There is no record of what "${orphan}" contained, so everything in the live config that "${target}" lacked was kept`,
950
+ `There is no record of what "${orphan}" contained, so every plugin and setting here that "${target}" lacked was kept`,
951
951
  );
952
952
  }
953
953
  result.lines.push(...result.changes.map((c) => describeChange(c, target)));
@@ -1022,7 +1022,7 @@ export async function absorbOlderLayout(
1022
1022
  : new SettingsParseError(livePath(key, projectPath), error);
1023
1023
  result.unreadable.push(parseError);
1024
1024
  result.problems.push(
1025
- `${rel(projectPath, key)} cannot be read (${parseError.detail}) — fix it by hand; magus left the older layout as it is and did not convert it`,
1025
+ `${rel(projectPath, key)} cannot be read (${parseError.detail}) — fix it by hand; magus did not update the old profile format`,
1026
1026
  );
1027
1027
  continue;
1028
1028
  }
@@ -1056,8 +1056,8 @@ export async function absorbOlderLayout(
1056
1056
  result.changes.push(...changes);
1057
1057
  result.lines.push(
1058
1058
  exact
1059
- ? `Changes Claude Code made under the older layout were added to profile "${into}"`
1060
- : `Profile "${named ?? "?"}" no longer exists in profiles.json — its unsaved changes under the older layout were added to profile "${into}"`,
1059
+ ? `Changes Claude Code made were added to profile "${into}"`
1060
+ : `Profile "${named ?? "?"}" no longer exists in profiles.json — its unsaved changes were added to profile "${into}"`,
1061
1061
  );
1062
1062
  if (!exact) {
1063
1063
  result.lines.push(
@@ -1072,7 +1072,7 @@ export async function absorbOlderLayout(
1072
1072
  );
1073
1073
  if (absorbed.keptMore) {
1074
1074
  result.lines.push(
1075
- `profiles.json has entries the older layout's files lack; they were kept — a removal made under the older layout is not applied (the older files are in the -layout backup under .claude/.magus-backups/)`,
1075
+ "profiles.json has entries the old profile format lacked; they were kept — a removal made in the old format is not applied (the old format is backed up)",
1076
1076
  );
1077
1077
  }
1078
1078
  return result;
@@ -1114,7 +1114,7 @@ async function olderRenderIds(
1114
1114
 
1115
1115
  /** One line for a value the older layout's file and profiles.json disagree on. */
1116
1116
  export function describeLayoutConflict(at: string, profile: string): string {
1117
- return `Profile "${profile}": ${at} differs between the older layout and profiles.json — kept profiles.json's value; the older copy is in the -layout backup under .claude/.magus-backups/`;
1117
+ return `Profile "${profile}": ${at} differs between the old profile format and profiles.json — kept profiles.json's value (the old one is backed up)`;
1118
1118
  }
1119
1119
 
1120
1120
  /**
@@ -1302,13 +1302,39 @@ export async function reconcileActiveProfile(
1302
1302
  opts: ReconcileOptions = {},
1303
1303
  ): Promise<ReconcileResult> {
1304
1304
  if (!(await hasCurrentMarker(projectPath))) return emptyResult(null);
1305
- const marker = await readActiveMarker(projectPath);
1305
+ let marker = await readActiveMarker(projectPath);
1306
1306
  if (!marker) return emptyResult(null);
1307
1307
  const manifest = await readManifest(projectPath);
1308
1308
  const current = manifest.profiles[marker.profile];
1309
1309
  if (!current) return emptyResult(null);
1310
1310
  const name = marker.profile;
1311
1311
 
1312
+ // A recorded skill folder git tracks is the team's copy: 7.5.0's conversion
1313
+ // recorded committed skills as magus-installed. Handed back — dropped from
1314
+ // the marker, and from magus's ignore lines — before anything below could
1315
+ // treat it as magus's to sync or remove, and said once.
1316
+ const handedBackLines: string[] = [];
1317
+ const handedBack = await trackedSkillFolders(
1318
+ projectPath,
1319
+ Object.keys(marker.skills),
1320
+ );
1321
+ if (handedBack.size > 0) {
1322
+ const skills: Record<string, string> = {};
1323
+ for (const [folder, hash] of Object.entries(marker.skills)) {
1324
+ if (!handedBack.has(folder)) skills[folder] = hash;
1325
+ }
1326
+ await updateSkillIgnoreLines(
1327
+ projectPath,
1328
+ [...handedBack],
1329
+ Object.keys(skills),
1330
+ );
1331
+ marker = { ...marker, skills };
1332
+ await writeActiveMarker(marker, projectPath);
1333
+ handedBackLines.push(
1334
+ ...[...handedBack].sort().map(releasedCommittedSkillNote),
1335
+ );
1336
+ }
1337
+
1312
1338
  const edited: LiveFileKey[] = [];
1313
1339
  const missing: LiveFileKey[] = [];
1314
1340
  for (const key of LIVE_FILE_KEYS) {
@@ -1343,23 +1369,11 @@ export async function reconcileActiveProfile(
1343
1369
  !definitionChanged &&
1344
1370
  !marker.cliBookkeeping
1345
1371
  ) {
1346
- return emptyResult(name);
1372
+ return { ...emptyResult(name), lines: handedBackLines };
1347
1373
  }
1348
1374
 
1349
1375
  const result = emptyResult(name);
1350
-
1351
- // Every write below lands in a live file, a skill folder the profile
1352
- // installs (or the manifest, on the way to one). None happens when git
1353
- // tracks one of them, or cannot say: see `tracked-live-files.ts`.
1354
- const refusal = await writeRefusal(
1355
- projectPath,
1356
- await activeWritableSkillFolders(projectPath, manifest),
1357
- );
1358
- if (refusal) {
1359
- result.problems.push(refusal.message);
1360
- result.lines.push(refusal.message);
1361
- return result;
1362
- }
1376
+ result.lines.push(...handedBackLines);
1363
1377
 
1364
1378
  // ── a. absorb ────────────────────────────────────────────────────────────
1365
1379
  const base = marker.base ?? current;
@@ -1511,7 +1525,10 @@ export async function reconcileActiveProfile(
1511
1525
  installSkill: opts.installSkill,
1512
1526
  },
1513
1527
  );
1514
- notes.push(...activationNotes(activation));
1528
+ // A re-render in the background never reports a tracked file: the
1529
+ // command that ran it reports that once, itself, and repeating it here
1530
+ // printed the same warning three times in one `magus update`.
1531
+ notes.push(...activationNotes(activation, { tracked: false }));
1515
1532
  } catch (error) {
1516
1533
  result.problems.push(
1517
1534
  error instanceof Error ? error.message : String(error),
@@ -75,10 +75,11 @@ import {
75
75
  updateSkillIgnoreLines,
76
76
  } from "./profile-skills.js";
77
77
  import {
78
- LiveFileRefusal,
79
- TrackedLiveFileError,
78
+ GitCheckError,
79
+ type TrackedNotice,
80
+ trackedNotice,
80
81
  trackedPaths,
81
- untrackCommand,
82
+ trackedSkillFolders,
82
83
  } from "./tracked-live-files.js";
83
84
  import type { ApplyReporter } from "./update-engine.js";
84
85
 
@@ -91,8 +92,12 @@ export interface ActivationResult {
91
92
  skills: SkillSyncResult;
92
93
  /** What converting an older layout did, if there was one. */
93
94
  conversion: LegacySkillConversion;
94
- /** Generated paths git still tracks (see {@link trackedGeneratedFiles}). */
95
- tracked: string[];
95
+ /**
96
+ * Generated paths git still tracks (see {@link trackedGeneratedFiles}) —
97
+ * written all the same, and each warned about. A string instead when git
98
+ * could not say (`GitCheckError`).
99
+ */
100
+ tracked: string[] | string;
96
101
  /** Where `.claude/_profiles/` was kept, project-relative, or null. */
97
102
  keptAt: string | null;
98
103
  /** Unsaved changes added to a profile before activating, one line each. */
@@ -101,6 +106,11 @@ export interface ActivationResult {
101
106
  dependencies?: DependencyReport;
102
107
  /** Listed skills whose folder the team committed: that copy is used. */
103
108
  committedSkills?: string[];
109
+ /**
110
+ * Skill folders the marker recorded as magus-installed that git tracks —
111
+ * handed back to the team (see `releasedCommittedSkillNote`).
112
+ */
113
+ releasedCommitted?: string[];
104
114
  }
105
115
 
106
116
  export interface ActivateOptions {
@@ -118,25 +128,27 @@ export function reportDisplaced(
118
128
  projectPath: string,
119
129
  ): (artifact: DisplacedArtifact) => void {
120
130
  const rel = (p: string) => path.relative(projectPath, p);
121
- return ({ link, savedTo }) =>
122
- console.log(` ${rel(link)} → kept at ${rel(savedTo)}`);
131
+ return ({ link }) =>
132
+ // The user's file, never where the backup went: that is magus's own
133
+ // storage, and the owner wants only what the user uses on screen.
134
+ // `savedTo` stays in the result for anything that needs to find it.
135
+ console.log(` Backed up your previous ${rel(link)}`);
123
136
  }
124
137
 
125
138
  /**
126
- * Generated paths that git tracks: the three live files, a committed
127
- * `.claude/skills` SYMLINK (an older layout committed by mistake), and any of
128
- * `skillFolders` — skill folders magus would install or remove. Committed
129
- * custom skills under a real `.claude/skills/` directory are not generated
130
- * state and are never reported. Empty outside a git repository.
139
+ * Generated files that git tracks: the three live files, and a committed
140
+ * `.claude/skills` SYMLINK (an older layout committed by mistake). A skill
141
+ * FOLDER git tracks is never one of them — it is the team's copy (see
142
+ * `trackedSkillFolders`). Empty outside a git repository; throws
143
+ * `GitCheckError` when git cannot say. Reported only — nothing refuses to
144
+ * write because of it (see `tracked-live-files.ts`).
131
145
  */
132
- export async function trackedWriteTargets(
146
+ export async function trackedGeneratedFiles(
133
147
  projectPath: string,
134
- skillFolders: readonly string[] = [],
135
148
  ): Promise<string[]> {
136
149
  const rel = (abs: string) => path.relative(projectPath, abs);
137
150
  const live = LIVE_FILE_KEYS.map((key) => rel(livePath(key, projectPath)));
138
151
  const skills = rel(skillsRoot(projectPath));
139
- const folders = skillFolders.map((folder) => path.join(skills, folder));
140
152
  let skillsIsLink = false;
141
153
  try {
142
154
  skillsIsLink = (await lstat(skillsRoot(projectPath))).isSymbolicLink();
@@ -144,127 +156,75 @@ export async function trackedWriteTargets(
144
156
  // No .claude/skills at all.
145
157
  }
146
158
  const files = [...live, ...(skillsIsLink ? [skills] : [])];
147
- const tracked = await trackedPaths(projectPath, files, folders);
148
- return [...files, ...folders].filter((c) => tracked.has(c));
159
+ const tracked = await trackedPaths(projectPath, files);
160
+ return files.filter((c) => tracked.has(c));
149
161
  }
150
162
 
151
163
  /**
152
- * The folders of `skills` the team committed under `.claude/skills/`. Such a
153
- * folder is the team's copy of the skill: magus neither installs over it nor
154
- * manages it, so it never has cause to suggest untracking it.
164
+ * {@link trackedGeneratedFiles} as the notices the CLI prints: one per tracked
165
+ * path, or one saying git could not be asked.
155
166
  */
156
- export async function committedSkillFolders(
167
+ export async function trackedGeneratedNotices(
157
168
  projectPath: string,
158
- skills: readonly ProfileSkillRef[],
159
- ): Promise<Set<string>> {
160
- const root = path.relative(projectPath, skillsRoot(projectPath));
161
- const folders = skills.map((ref) => skillFolder(ref));
162
- const tracked = await trackedPaths(
163
- projectPath,
164
- [],
165
- folders.map((folder) => path.join(root, folder)),
166
- );
167
- return new Set(folders.filter((f) => tracked.has(path.join(root, f))));
169
+ ): Promise<TrackedNotice[]> {
170
+ try {
171
+ return (await trackedGeneratedFiles(projectPath)).map(trackedNotice);
172
+ } catch (error) {
173
+ if (error instanceof GitCheckError) {
174
+ return [{ problem: error.message, fix: [] }];
175
+ }
176
+ throw error;
177
+ }
168
178
  }
169
179
 
170
- /** {@link trackedWriteTargets} for the live files alone — what doctor reports. */
171
- export async function trackedGeneratedFiles(
172
- projectPath: string,
173
- ): Promise<string[]> {
174
- return trackedWriteTargets(projectPath);
180
+ /** The tracked-path notices an activation result carries. */
181
+ export function activationTrackedNotices(
182
+ result: ActivationResult,
183
+ ): TrackedNotice[] {
184
+ return typeof result.tracked === "string"
185
+ ? [{ problem: result.tracked, fix: [] }]
186
+ : result.tracked.map(trackedNotice);
175
187
  }
176
188
 
177
189
  /**
178
- * The skill folders activating a profile with `skills` could write: those
179
- * magus manages now (kept or removed), and the profile's that are not on disk
180
- * yet — unless the team committed them (see {@link committedSkillFolders}).
181
- * An existing folder magus does not manage is a custom skill, which
182
- * activation refuses as a clash rather than writing.
190
+ * The folders of `skills` the team committed under `.claude/skills/`. Such a
191
+ * folder is the team's copy of the skill: magus neither installs over it nor
192
+ * manages it, so it never has cause to suggest untracking it. None when git
193
+ * cannot say — each existing folder is then judged as a custom skill.
183
194
  */
184
- export async function writableSkillFolders(
195
+ export async function committedSkillFolders(
185
196
  projectPath: string,
186
197
  skills: readonly ProfileSkillRef[],
187
- managed: Readonly<Record<string, string>> = {},
188
- ): Promise<string[]> {
189
- const out = new Set(Object.keys(managed));
190
- const missing: ProfileSkillRef[] = [];
191
- for (const ref of skills) {
192
- const folder = skillFolder(ref);
193
- if (out.has(folder)) continue;
194
- try {
195
- await lstat(path.join(skillsRoot(projectPath), folder));
196
- } catch {
197
- missing.push(ref);
198
- }
199
- }
200
- const committed = await committedSkillFolders(projectPath, missing);
201
- for (const ref of missing) {
202
- if (!committed.has(skillFolder(ref))) out.add(skillFolder(ref));
203
- }
204
- return [...out];
205
- }
206
-
207
- /**
208
- * The skill folders the ACTIVE profile's next render could write — see
209
- * {@link writableSkillFolders}. For the checks made before a reconcile, which
210
- * may re-render and so install or remove them.
211
- */
212
- export async function activeWritableSkillFolders(
213
- projectPath: string,
214
- manifest: ProfileManifest,
215
- ): Promise<string[]> {
216
- const marker = await readActiveMarker(projectPath);
217
- if (!marker) return [];
218
- return writableSkillFolders(
198
+ ): Promise<Set<string>> {
199
+ return trackedSkillFolders(
219
200
  projectPath,
220
- manifest.profiles[marker.profile]?.skills ?? [],
221
- marker.skills,
201
+ skills.map((ref) => skillFolder(ref)),
222
202
  );
223
203
  }
224
204
 
225
- /**
226
- * Why magus must not write here, or null: {@link assertNoTrackedWriteTargets}
227
- * as a value, for callers that report a refusal instead of throwing it.
228
- */
229
- export async function writeRefusal(
230
- projectPath: string,
231
- skillFolders: readonly string[] = [],
232
- ): Promise<LiveFileRefusal | null> {
233
- try {
234
- await assertNoTrackedWriteTargets(projectPath, skillFolders);
235
- return null;
236
- } catch (error) {
237
- if (error instanceof LiveFileRefusal) return error;
238
- throw error;
239
- }
240
- }
241
-
242
- /**
243
- * Throw {@link TrackedLiveFileError} — before anything is written — when any
244
- * of {@link trackedWriteTargets} is tracked, or `GitCheckError` when git
245
- * cannot say.
246
- */
247
- export async function assertNoTrackedWriteTargets(
248
- projectPath: string,
249
- skillFolders: readonly string[] = [],
250
- ): Promise<void> {
251
- const tracked = await trackedWriteTargets(projectPath, skillFolders);
252
- if (tracked.length > 0) throw new TrackedLiveFileError(tracked);
205
+ /** The line for a recorded skill folder handed back to the team. */
206
+ export function releasedCommittedSkillNote(folder: string): string {
207
+ return `Skill ${folder} is committed by the team — magus no longer manages it`;
253
208
  }
254
209
 
255
210
  /**
256
211
  * Lines describing what activation did beyond the switch itself, phrased for
257
212
  * both the terminal and the TUI's outcome modal.
213
+ *
214
+ * `tracked: false` leaves the tracked-path notices out, for the CLI, which
215
+ * prints them itself as a warning with the fix beneath it
216
+ * ({@link activationTrackedNotices}).
258
217
  */
259
- export function activationNotes(result: ActivationResult): string[] {
218
+ export function activationNotes(
219
+ result: ActivationResult,
220
+ opts: { tracked?: boolean } = {},
221
+ ): string[] {
260
222
  const lines: string[] = [
261
223
  ...(result.absorbed ?? []),
262
224
  ...describeDependencies(result.dependencies, result.profileId),
263
225
  ];
264
226
  for (const folder of result.conversion.restored) {
265
- lines.push(
266
- `Restored custom skill .claude/skills/${folder} (the older profile layout had moved it)`,
267
- );
227
+ lines.push(`Restored your custom skill "${folder}"`);
268
228
  }
269
229
  for (const c of result.conversion.conflicts) {
270
230
  lines.push(
@@ -274,20 +234,21 @@ export function activationNotes(result: ActivationResult): string[] {
274
234
  for (const f of result.skills.failed) {
275
235
  lines.push(`Skill "${f.skill}" could not be installed: ${f.reason}`);
276
236
  }
237
+ for (const folder of result.releasedCommitted ?? []) {
238
+ lines.push(releasedCommittedSkillNote(folder));
239
+ }
277
240
  for (const folder of result.committedSkills ?? []) {
278
241
  lines.push(
279
242
  `Skill .claude/skills/${folder} is committed to git — the team's copy is used; magus does not install or manage it`,
280
243
  );
281
244
  }
282
- for (const rel of result.tracked) {
283
- lines.push(
284
- `${rel} is generated but tracked by git — run \`${untrackCommand(rel)}\`, then commit`,
285
- );
245
+ if (opts.tracked !== false) {
246
+ for (const n of activationTrackedNotices(result)) {
247
+ lines.push(n.problem, ...n.fix);
248
+ }
286
249
  }
287
250
  if (result.keptAt) {
288
- lines.push(
289
- `The older layout's .claude/_profiles/ was kept at ${result.keptAt}/`,
290
- );
251
+ lines.push("A backup of the old profile format was kept");
291
252
  }
292
253
  return lines;
293
254
  }
@@ -295,9 +256,10 @@ export function activationNotes(result: ActivationResult): string[] {
295
256
  /**
296
257
  * Render `profileId` from `manifest` and make it the active profile.
297
258
  *
298
- * Throws {@link TrackedLiveFileError}, having changed nothing, when a path it
299
- * would write is tracked by git. Throws `SettingsParseError`, having changed
300
- * nothing, when a live file of a managed project does not parse. Throws
259
+ * A generated file git tracks is written like any other, and named in
260
+ * `tracked` so the caller can warn about it. Throws `SettingsParseError`,
261
+ * having changed nothing, when a live file of a managed project does not
262
+ * parse. Throws
301
263
  * {@link SkillClashError} — having
302
264
  * changed nothing but moving an older layout's skills into `.claude/skills/` —
303
265
  * when a listed skill's folder is already a custom skill.
@@ -315,21 +277,27 @@ export async function activateProfile(
315
277
  // Read before converting: the older marker lives in the directory the
316
278
  // conversion moves away, and an older layout is recognised by its links.
317
279
  const previous = await readActiveMarker(projectPath);
318
- // Before anything else is touched — the conversion below writes too.
319
- await assertNoTrackedWriteTargets(
320
- projectPath,
321
- await writableSkillFolders(projectPath, skillRefs, previous?.skills),
322
- );
323
280
  const wasOldLayout = (await oldLayoutProfile(projectPath)) !== null;
324
281
  // A managed project's live files are the profile's; one that does not
325
282
  // parse is somebody mid-edit, and replacing it would lose their work. (At
326
283
  // first adoption a hand-written file is moved to a backup, not replaced.)
327
284
  if (previous !== null) await assertLiveFilesParse(projectPath);
328
285
  const conversion = await convertLegacySkills(manifest, projectPath);
329
- const managedBefore: Record<string, string> = {
286
+ const recorded: Record<string, string> = {
330
287
  ...(previous?.skills ?? {}),
331
288
  ...conversion.managed,
332
289
  };
290
+ // A recorded folder git tracks is the team's copy — 7.5.0's conversion
291
+ // recorded committed skills as magus-installed. Dropped from the record
292
+ // here, before a sync could remove it, and said once.
293
+ const committedRecorded = await trackedSkillFolders(
294
+ projectPath,
295
+ Object.keys(recorded),
296
+ );
297
+ const managedBefore: Record<string, string> = {};
298
+ for (const [folder, hash] of Object.entries(recorded)) {
299
+ if (!committedRecorded.has(folder)) managedBefore[folder] = hash;
300
+ }
333
301
 
334
302
  // Listed skills the team committed and magus does not manage: that copy
335
303
  // stands, and magus neither installs over it nor reports it as a clash.
@@ -397,7 +365,7 @@ export async function activateProfile(
397
365
  );
398
366
  await updateSkillIgnoreLines(
399
367
  projectPath,
400
- Object.keys(managedBefore),
368
+ Object.keys(recorded),
401
369
  Object.keys(skills.skills),
402
370
  );
403
371
  await writeActiveMarker(
@@ -412,7 +380,12 @@ export async function activateProfile(
412
380
  displaced,
413
381
  skills,
414
382
  conversion,
415
- tracked: await trackedGeneratedFiles(projectPath),
383
+ tracked: await trackedGeneratedFiles(projectPath).catch(
384
+ (error: unknown) => {
385
+ if (error instanceof GitCheckError) return error.message;
386
+ throw error;
387
+ },
388
+ ),
416
389
  keptAt: conversion.keptAt
417
390
  ? path.relative(projectPath, conversion.keptAt)
418
391
  : null,
@@ -420,6 +393,9 @@ export async function activateProfile(
420
393
  ...(committedSkills.size > 0
421
394
  ? { committedSkills: [...committedSkills].sort() }
422
395
  : {}),
396
+ ...(committedRecorded.size > 0
397
+ ? { releasedCommitted: [...committedRecorded].sort() }
398
+ : {}),
423
399
  };
424
400
  }
425
401
 
@@ -445,7 +421,6 @@ export async function regenerateLiveFiles(
445
421
  const marker = await readActiveMarker(projectPath);
446
422
  const manifest = await readManifest(projectPath);
447
423
  if (!marker || !manifest.profiles[marker.profile]) return false;
448
- await assertNoTrackedWriteTargets(projectPath);
449
424
  await assertLiveFilesParse(projectPath);
450
425
  const rendered = await renderProfile(manifest, marker.profile, projectPath);
451
426
  const files = await writeRenderedFiles(rendered, projectPath);