magus-cli 7.5.0 → 7.5.2

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 (33) hide show
  1. package/package.json +5 -4
  2. package/src/__tests__/profile-absorb.test.ts +72 -0
  3. package/src/__tests__/profile-dependencies.test.ts +442 -0
  4. package/src/__tests__/profile-manifest-write.test.ts +231 -0
  5. package/src/__tests__/profile-skills.test.ts +99 -0
  6. package/src/__tests__/profile-state.test.ts +328 -13
  7. package/src/__tests__/stale-load-repair.test.ts +265 -0
  8. package/src/__tests__/support/preload.ts +14 -0
  9. package/src/__tests__/update-apply.test.ts +342 -1
  10. package/src/cli/bootstrap.ts +4 -0
  11. package/src/cli/doctor.ts +39 -6
  12. package/src/cli/install.ts +68 -4
  13. package/src/cli/router.ts +7 -0
  14. package/src/cli/update.ts +3 -0
  15. package/src/services/claude-cli.ts +24 -0
  16. package/src/services/claude-settings.ts +45 -0
  17. package/src/services/git-worktree.ts +92 -9
  18. package/src/services/manifest.ts +220 -3
  19. package/src/services/plugin-dependencies.ts +48 -0
  20. package/src/services/plugin-requires.ts +137 -14
  21. package/src/services/profile-absorb.ts +69 -15
  22. package/src/services/profile-activate.ts +179 -33
  23. package/src/services/profile-live.ts +66 -7
  24. package/src/services/profile-render.ts +73 -6
  25. package/src/services/profile-skills.ts +32 -7
  26. package/src/services/profile-state.ts +89 -5
  27. package/src/services/profiles.ts +6 -5
  28. package/src/services/resolver.ts +96 -3
  29. package/src/services/tracked-live-files.ts +125 -0
  30. package/src/services/update-engine.ts +266 -8
  31. package/src/types/index.ts +19 -0
  32. package/src/ui/screens/PluginsScreen.tsx +11 -2
  33. package/src/ui/screens/ProfilesScreen.tsx +10 -3
@@ -61,6 +61,8 @@ import {
61
61
  type ActivateOptions,
62
62
  activateProfile,
63
63
  activationNotes,
64
+ activeWritableSkillFolders,
65
+ writeRefusal,
64
66
  } from "./profile-activate.js";
65
67
  import {
66
68
  type ActiveMarker,
@@ -169,12 +171,22 @@ function setOrDelete<K extends "plugins" | "settings" | "mcpServers">(
169
171
  * `otherPluginIds` are the plugins other profiles enable — rendering writes
170
172
  * those as `false` in this profile on its own, so a `false` for one of them
171
173
  * says nothing new.
174
+ *
175
+ * `derived` are the plugins the render enables because one of the entry's
176
+ * plugins depends on them. A `true` for one equals the render, so it is not
177
+ * an edit and never becomes a member: dependencies are derived, not recorded.
178
+ * Only a `false` the render could not have written — for a dependency no
179
+ * other profile names — is the user turning it off, and is kept as `false`.
180
+ *
181
+ * `updatedAt` is not touched here or anywhere absorption writes: the file is
182
+ * committed, and a timestamp that changes with every absorbed edit is a line
183
+ * in every diff and a conflict between every two teammates.
172
184
  */
173
185
  export function computeAbsorb(
174
186
  entry: ProfileManifestEntry,
175
187
  live: LiveView,
176
188
  otherPluginIds: ReadonlySet<string>,
177
- now: string = new Date().toISOString(),
189
+ derived: ReadonlySet<string> = new Set(),
178
190
  ): { entry: ProfileManifestEntry; changes: AbsorbChange[] } {
179
191
  const changes: AbsorbChange[] = [];
180
192
  const next: ProfileManifestEntry = { ...entry };
@@ -195,6 +207,13 @@ export function computeAbsorb(
195
207
  const liveValue = enabled[id];
196
208
  const on = isOn(resolved.plugins?.[id]);
197
209
  const inheritedOn = isOn(inherited.plugins?.[id]);
210
+ if (derived.has(id) && !on) {
211
+ if (liveValue === false && !otherPluginIds.has(id)) {
212
+ own[id] = false;
213
+ changes.push({ kind: "plugin-disabled", plugin: id });
214
+ }
215
+ continue;
216
+ }
198
217
  if (liveValue === true && !on) {
199
218
  // An own `false` over an inherited plugin is what kept it off.
200
219
  if (own[id] === false && inheritedOn) delete own[id];
@@ -259,7 +278,6 @@ export function computeAbsorb(
259
278
  changes.push({ kind: "models-changed" });
260
279
  }
261
280
 
262
- if (changes.length > 0) next.updatedAt = now;
263
281
  return { entry: next, changes };
264
282
  }
265
283
 
@@ -539,9 +557,6 @@ export function merge3(
539
557
  );
540
558
  put("skills", kept.length > 0 ? kept : undefined);
541
559
  }
542
- if (!jsonEqual(local, base) && local.updatedAt) {
543
- out.updatedAt = local.updatedAt;
544
- }
545
560
  return { entry: out, conflicts };
546
561
  }
547
562
 
@@ -609,17 +624,22 @@ export function refuseOnProblems(result: {
609
624
  * rendered (`marker.allPluginIds`) that `base` does not enable. The recorded
610
625
  * ids matter when a teammate removes a plugin from every profile — the live
611
626
  * `false` for it is left over from the old render, not a deliberate disable.
627
+ * A dependency the last render enabled (`renderedTrue`) is never one: it was
628
+ * written `true`, so a `false` for it is the user's, whoever else uses it.
612
629
  */
613
630
  function renderedFalseIds(
614
631
  others: ReadonlySet<string>,
615
632
  recorded: readonly string[] | undefined,
616
633
  base: ProfileManifestEntry,
634
+ /** Dependencies the last render wrote `true` (`marker.derived`). */
635
+ renderedTrue: readonly string[] = [],
617
636
  ): Set<string> {
618
637
  const out = new Set(others);
619
638
  const enabled = resolveExtends(base).plugins ?? {};
620
639
  for (const id of recorded ?? []) {
621
640
  if (!isOn(enabled[id])) out.add(id);
622
641
  }
642
+ for (const id of renderedTrue) out.delete(id);
623
643
  return out;
624
644
  }
625
645
 
@@ -829,6 +849,7 @@ export async function absorbOrphanedProfile(
829
849
  ? await renderProfile(
830
850
  { ...manifest, profiles: { ...manifest.profiles, [orphan]: base } },
831
851
  orphan,
852
+ projectPath,
832
853
  )
833
854
  : null;
834
855
  const live: Record<LiveFileKey, Record<string, unknown> | null> = {
@@ -872,7 +893,12 @@ export async function absorbOrphanedProfile(
872
893
 
873
894
  let merged: ProfileManifestEntry;
874
895
  if (base) {
875
- const local = computeAbsorb(base, view, union).entry;
896
+ const local = computeAbsorb(
897
+ base,
898
+ view,
899
+ union,
900
+ new Set(renderedBase?.dependencies?.derived ?? []),
901
+ ).entry;
876
902
  const merge = merge3(base, local, targetEntry);
877
903
  merged = merge.entry;
878
904
  result.conflicts.push(...merge.conflicts);
@@ -889,7 +915,6 @@ export async function absorbOrphanedProfile(
889
915
 
890
916
  const changes = diffEntries(targetEntry, merged);
891
917
  if (changes.length > 0) {
892
- merged.updatedAt = new Date().toISOString();
893
918
  await writeManifest(
894
919
  { ...manifest, profiles: { ...manifest.profiles, [target]: merged } },
895
920
  projectPath,
@@ -970,7 +995,7 @@ export async function absorbOlderLayout(
970
995
  if (!entry) return emptyResult(null);
971
996
  const result = emptyResult(into);
972
997
  const legacyHashes = await readLegacyMarkerHashes(projectPath);
973
- const rendered = await renderProfile(manifest, into);
998
+ const rendered = await renderProfile(manifest, into, projectPath);
974
999
  const view: Record<LiveFileKey, Record<string, unknown> | null> = {
975
1000
  settings: rendered.settings,
976
1001
  mcp: rendered.mcp,
@@ -1019,11 +1044,11 @@ export async function absorbOlderLayout(
1019
1044
  models: view.models,
1020
1045
  },
1021
1046
  await olderRenderIds(projectPath, named, rendered.allPluginIds),
1047
+ new Set(rendered.dependencies?.derived ?? []),
1022
1048
  );
1023
1049
  const next = absorbed.entry;
1024
1050
  const changes = diffEntries(entry, next);
1025
1051
  if (changes.length > 0) {
1026
- next.updatedAt = new Date().toISOString();
1027
1052
  await writeManifest(
1028
1053
  { ...manifest, profiles: { ...manifest.profiles, [into]: next } },
1029
1054
  projectPath,
@@ -1150,7 +1175,8 @@ function addMissing(
1150
1175
  * {@link absorbOlderLayout} for why nothing is removed.
1151
1176
  *
1152
1177
  * plugins a plugin the old file enables and the entry does not name is
1153
- * added ("latest"); a `false` for a plugin the entry does not
1178
+ * added ("latest") — unless it is in `derived`, a dependency
1179
+ * the render enables anyway; a `false` for a plugin the entry does not
1154
1180
  * name is skipped when `renderIds` holds it (the render wrote
1155
1181
  * it) and added as `false` when it does not (the user wrote
1156
1182
  * it); where the old file and the entry disagree — one
@@ -1166,6 +1192,7 @@ export function absorbAdditively(
1166
1192
  entry: ProfileManifestEntry,
1167
1193
  old: LiveView,
1168
1194
  renderIds: ReadonlySet<string>,
1195
+ derived: ReadonlySet<string> = new Set(),
1169
1196
  ): { entry: ProfileManifestEntry; conflicts: string[]; keptMore: boolean } {
1170
1197
  const conflicts: string[] = [];
1171
1198
  const flags = { keptMore: false };
@@ -1180,7 +1207,9 @@ export function absorbAdditively(
1180
1207
  for (const [id, value] of Object.entries(enabled)) {
1181
1208
  const declared = resolved.plugins?.[id];
1182
1209
  if (value === true) {
1183
- if (isOn(declared)) continue;
1210
+ if (isOn(declared) || (derived.has(id) && declared === undefined)) {
1211
+ continue;
1212
+ }
1184
1213
  if (declared === false) conflicts.push(`plugins.${id}`);
1185
1214
  else own[id] = "latest";
1186
1215
  } else if (value === false && isOn(declared)) {
@@ -1300,7 +1329,8 @@ export async function reconcileActiveProfile(
1300
1329
  // teammate's change to ANOTHER profile's plugins changes which `false`
1301
1330
  // entries this profile writes.
1302
1331
  const definitionChanged =
1303
- (await renderProfile(manifest, name)).definition !== marker.definition;
1332
+ (await renderProfile(manifest, name, projectPath)).definition !==
1333
+ marker.definition;
1304
1334
  // Whether THIS entry changed underneath the live files — the case that
1305
1335
  // needs merging against `base`, and the one the message names.
1306
1336
  const entryChanged =
@@ -1318,11 +1348,25 @@ export async function reconcileActiveProfile(
1318
1348
 
1319
1349
  const result = emptyResult(name);
1320
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
+ }
1363
+
1321
1364
  // ── a. absorb ────────────────────────────────────────────────────────────
1322
1365
  const base = marker.base ?? current;
1323
1366
  const renderedBase = await renderProfile(
1324
1367
  { ...manifest, profiles: { ...manifest.profiles, [name]: base } },
1325
1368
  name,
1369
+ projectPath,
1326
1370
  );
1327
1371
  const live: Record<LiveFileKey, Record<string, unknown> | null> = {
1328
1372
  settings: renderedBase.settings,
@@ -1345,7 +1389,11 @@ export async function reconcileActiveProfile(
1345
1389
  if (key === "settings" && marker.cliBookkeeping) {
1346
1390
  value = withoutCliRecords(
1347
1391
  value,
1348
- new Set(marker.allPluginIds ?? (await allPluginIdsOf(manifest))),
1392
+ new Set(
1393
+ marker.cliInstallable ??
1394
+ marker.allPluginIds ??
1395
+ (await allPluginIdsOf(manifest)),
1396
+ ),
1349
1397
  base,
1350
1398
  renderedBase,
1351
1399
  discarded,
@@ -1390,7 +1438,9 @@ export async function reconcileActiveProfile(
1390
1438
  await otherProfilesPlugins(manifest, name),
1391
1439
  marker.allPluginIds,
1392
1440
  base,
1441
+ marker.derived,
1393
1442
  ),
1443
+ new Set(renderedBase.dependencies?.derived ?? []),
1394
1444
  );
1395
1445
  local = absorbed.entry;
1396
1446
  result.changes.push(...absorbed.changes);
@@ -1408,7 +1458,7 @@ export async function reconcileActiveProfile(
1408
1458
  });
1409
1459
  }
1410
1460
  const kept = refs.filter((r) => !released.has(skillFolder(r)));
1411
- local = { ...local, updatedAt: new Date().toISOString() };
1461
+ local = { ...local };
1412
1462
  if (kept.length > 0) local.skills = kept;
1413
1463
  // biome-ignore lint/performance/noDelete: absent, not undefined, in profiles.json
1414
1464
  else delete local.skills;
@@ -1473,7 +1523,11 @@ export async function reconcileActiveProfile(
1473
1523
  }
1474
1524
  }
1475
1525
  } else {
1476
- const rendered: RenderedProfile = await renderProfile(nextManifest, name);
1526
+ const rendered: RenderedProfile = await renderProfile(
1527
+ nextManifest,
1528
+ name,
1529
+ projectPath,
1530
+ );
1477
1531
  const files: ActiveMarker["files"] = { ...marker.files };
1478
1532
  for (const key of LIVE_FILE_KEYS) {
1479
1533
  if (missing.includes(key)) {
@@ -28,9 +28,12 @@
28
28
 
29
29
  import { lstat } from "node:fs/promises";
30
30
  import path from "node:path";
31
- import type { ProfileManifest, ProfileSkillRef } from "../types/index.js";
31
+ import type {
32
+ DependencyReport,
33
+ ProfileManifest,
34
+ ProfileSkillRef,
35
+ } from "../types/index.js";
32
36
  import { updateManagedGitignoreBlock } from "./conventions-manager.js";
33
- import { isTrackedByGit } from "./git-worktree.js";
34
37
  import { readManifest } from "./manifest.js";
35
38
  import {
36
39
  type ActiveMarker,
@@ -48,6 +51,7 @@ import {
48
51
  hasCurrentMarker,
49
52
  liveKind,
50
53
  livePath,
54
+ machineLocalSettings,
51
55
  markerFor,
52
56
  oldLayoutProfile,
53
57
  readActiveMarker,
@@ -56,7 +60,7 @@ import {
56
60
  writeLiveFile,
57
61
  writeRenderedFiles,
58
62
  } from "./profile-live.js";
59
- import { renderProfile } from "./profile-render.js";
63
+ import { describeDependencies, renderProfile } from "./profile-render.js";
60
64
  import {
61
65
  type LegacySkillConversion,
62
66
  SkillClashError,
@@ -70,6 +74,12 @@ import {
70
74
  syncSkills,
71
75
  updateSkillIgnoreLines,
72
76
  } from "./profile-skills.js";
77
+ import {
78
+ LiveFileRefusal,
79
+ TrackedLiveFileError,
80
+ trackedPaths,
81
+ untrackCommand,
82
+ } from "./tracked-live-files.js";
73
83
  import type { ApplyReporter } from "./update-engine.js";
74
84
 
75
85
  export interface ActivationResult {
@@ -87,6 +97,10 @@ export interface ActivationResult {
87
97
  keptAt: string | null;
88
98
  /** Unsaved changes added to a profile before activating, one line each. */
89
99
  absorbed?: string[];
100
+ /** What the profile's plugins depend on — see `describeDependencies`. */
101
+ dependencies?: DependencyReport;
102
+ /** Listed skills whose folder the team committed: that copy is used. */
103
+ committedSkills?: string[];
90
104
  }
91
105
 
92
106
  export interface ActivateOptions {
@@ -109,37 +123,133 @@ export function reportDisplaced(
109
123
  }
110
124
 
111
125
  /**
112
- * Generated paths that git tracks. A .gitignore entry does not untrack a file
113
- * that was committed before it, so each one is reported with the fix. magus
114
- * never runs `git rm` itself.
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.
131
+ */
132
+ export async function trackedWriteTargets(
133
+ projectPath: string,
134
+ skillFolders: readonly string[] = [],
135
+ ): Promise<string[]> {
136
+ const rel = (abs: string) => path.relative(projectPath, abs);
137
+ const live = LIVE_FILE_KEYS.map((key) => rel(livePath(key, projectPath)));
138
+ const skills = rel(skillsRoot(projectPath));
139
+ const folders = skillFolders.map((folder) => path.join(skills, folder));
140
+ let skillsIsLink = false;
141
+ try {
142
+ skillsIsLink = (await lstat(skillsRoot(projectPath))).isSymbolicLink();
143
+ } catch {
144
+ // No .claude/skills at all.
145
+ }
146
+ const files = [...live, ...(skillsIsLink ? [skills] : [])];
147
+ const tracked = await trackedPaths(projectPath, files, folders);
148
+ return [...files, ...folders].filter((c) => tracked.has(c));
149
+ }
150
+
151
+ /**
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.
115
155
  */
156
+ export async function committedSkillFolders(
157
+ 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))));
168
+ }
169
+
170
+ /** {@link trackedWriteTargets} for the live files alone — what doctor reports. */
116
171
  export async function trackedGeneratedFiles(
117
172
  projectPath: string,
118
173
  ): Promise<string[]> {
119
- const out: string[] = [];
120
- for (const abs of generatedPaths(projectPath)) {
121
- const rel = path.relative(projectPath, abs);
122
- // `.claude/skills` holds committed custom skills by design; only a
123
- // tracked symlink there is generated state.
124
- if (rel === path.join(".claude", "skills")) {
125
- if (await isTrackedByGit(projectPath, rel)) {
126
- try {
127
- if ((await lstat(abs)).isSymbolicLink()) out.push(rel);
128
- } catch {
129
- // Deleted in the working tree but still tracked as a link.
130
- out.push(rel);
131
- }
132
- }
133
- continue;
174
+ return trackedWriteTargets(projectPath);
175
+ }
176
+
177
+ /**
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.
183
+ */
184
+ export async function writableSkillFolders(
185
+ projectPath: string,
186
+ 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);
134
198
  }
135
- if (await isTrackedByGit(projectPath, rel)) out.push(rel);
136
199
  }
137
- return out;
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(
219
+ projectPath,
220
+ manifest.profiles[marker.profile]?.skills ?? [],
221
+ marker.skills,
222
+ );
138
223
  }
139
224
 
140
- /** How to untrack one generated path. */
141
- export function untrackCommand(rel: string): string {
142
- return `git rm --cached ${rel}`;
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);
143
253
  }
144
254
 
145
255
  /**
@@ -147,7 +257,10 @@ export function untrackCommand(rel: string): string {
147
257
  * both the terminal and the TUI's outcome modal.
148
258
  */
149
259
  export function activationNotes(result: ActivationResult): string[] {
150
- const lines: string[] = [...(result.absorbed ?? [])];
260
+ const lines: string[] = [
261
+ ...(result.absorbed ?? []),
262
+ ...describeDependencies(result.dependencies, result.profileId),
263
+ ];
151
264
  for (const folder of result.conversion.restored) {
152
265
  lines.push(
153
266
  `Restored custom skill .claude/skills/${folder} (the older profile layout had moved it)`,
@@ -161,6 +274,11 @@ export function activationNotes(result: ActivationResult): string[] {
161
274
  for (const f of result.skills.failed) {
162
275
  lines.push(`Skill "${f.skill}" could not be installed: ${f.reason}`);
163
276
  }
277
+ for (const folder of result.committedSkills ?? []) {
278
+ lines.push(
279
+ `Skill .claude/skills/${folder} is committed to git — the team's copy is used; magus does not install or manage it`,
280
+ );
281
+ }
164
282
  for (const rel of result.tracked) {
165
283
  lines.push(
166
284
  `${rel} is generated but tracked by git — run \`${untrackCommand(rel)}\`, then commit`,
@@ -177,8 +295,10 @@ export function activationNotes(result: ActivationResult): string[] {
177
295
  /**
178
296
  * Render `profileId` from `manifest` and make it the active profile.
179
297
  *
180
- * Throws `SettingsParseError`, having changed nothing, when a live file of a
181
- * managed project does not parse. Throws {@link SkillClashError} — having
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
301
+ * {@link SkillClashError} — having
182
302
  * changed nothing but moving an older layout's skills into `.claude/skills/` —
183
303
  * when a listed skill's folder is already a custom skill.
184
304
  */
@@ -189,12 +309,17 @@ export async function activateProfile(
189
309
  opts: ActivateOptions = {},
190
310
  ): Promise<ActivationResult> {
191
311
  const onDisplace = opts.onDisplace ?? reportDisplaced(projectPath);
192
- const rendered = await renderProfile(manifest, profileId);
312
+ const rendered = await renderProfile(manifest, profileId, projectPath);
193
313
  const skillRefs = rendered.entry.skills ?? [];
194
314
 
195
315
  // Read before converting: the older marker lives in the directory the
196
316
  // conversion moves away, and an older layout is recognised by its links.
197
317
  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
+ );
198
323
  const wasOldLayout = (await oldLayoutProfile(projectPath)) !== null;
199
324
  // A managed project's live files are the profile's; one that does not
200
325
  // parse is somebody mid-edit, and replacing it would lose their work. (At
@@ -206,7 +331,18 @@ export async function activateProfile(
206
331
  ...conversion.managed,
207
332
  };
208
333
 
209
- const clashes = await findSkillClashes(skillRefs, managedBefore, projectPath);
334
+ // Listed skills the team committed and magus does not manage: that copy
335
+ // stands, and magus neither installs over it nor reports it as a clash.
336
+ const committedSkills = await committedSkillFolders(
337
+ projectPath,
338
+ skillRefs.filter((ref) => !(skillFolder(ref) in managedBefore)),
339
+ );
340
+ const clashes = await findSkillClashes(
341
+ skillRefs,
342
+ managedBefore,
343
+ projectPath,
344
+ committedSkills,
345
+ );
210
346
  if (clashes.length > 0) throw new SkillClashError(profileId, clashes);
211
347
 
212
348
  await updateManagedGitignoreBlock(
@@ -224,6 +360,10 @@ export async function activateProfile(
224
360
  const backupDir = path.join(backupRoot(projectPath), backupStamp());
225
361
  const displaced: DisplacedArtifact[] = [];
226
362
  const files: ActiveMarker["files"] = {};
363
+ // Read BEFORE a first adoption moves the file to a backup: this machine's
364
+ // `installedPluginVersions` must reach the new settings.json, and after the
365
+ // move there is nothing at the live path to read it from.
366
+ const carried = await machineLocalSettings(projectPath);
227
367
  for (const key of LIVE_FILE_KEYS) {
228
368
  // First adoption only: nothing marks this project as magus's and it is
229
369
  // not an older magus layout, so a real file here is somebody's.
@@ -238,7 +378,7 @@ export async function activateProfile(
238
378
  onDisplace(moved);
239
379
  }
240
380
  }
241
- const hash = await writeLiveFile(key, rendered[key], projectPath);
381
+ const hash = await writeLiveFile(key, rendered[key], projectPath, carried);
242
382
  if (hash) files[key] = hash;
243
383
  // Recorded after EACH file, so a throw on the next cannot leave a file
244
384
  // magus just wrote looking like an edit to absorb.
@@ -253,6 +393,7 @@ export async function activateProfile(
253
393
  managedBefore,
254
394
  projectPath,
255
395
  opts.installSkill ?? installProfileSkill,
396
+ committedSkills,
256
397
  );
257
398
  await updateSkillIgnoreLines(
258
399
  projectPath,
@@ -275,6 +416,10 @@ export async function activateProfile(
275
416
  keptAt: conversion.keptAt
276
417
  ? path.relative(projectPath, conversion.keptAt)
277
418
  : null,
419
+ ...(rendered.dependencies ? { dependencies: rendered.dependencies } : {}),
420
+ ...(committedSkills.size > 0
421
+ ? { committedSkills: [...committedSkills].sort() }
422
+ : {}),
278
423
  };
279
424
  }
280
425
 
@@ -300,8 +445,9 @@ export async function regenerateLiveFiles(
300
445
  const marker = await readActiveMarker(projectPath);
301
446
  const manifest = await readManifest(projectPath);
302
447
  if (!marker || !manifest.profiles[marker.profile]) return false;
448
+ await assertNoTrackedWriteTargets(projectPath);
303
449
  await assertLiveFilesParse(projectPath);
304
- const rendered = await renderProfile(manifest, marker.profile);
450
+ const rendered = await renderProfile(manifest, marker.profile, projectPath);
305
451
  const files = await writeRenderedFiles(rendered, projectPath);
306
452
  await writeActiveMarker(
307
453
  markerFor(marker.profile, rendered, files, marker.skills),