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
@@ -1,65 +1,118 @@
1
1
  /**
2
- * Finish a migration: put a profile on disk and make the project run it.
2
+ * Put a project on a profile: render it, write it, install its skills.
3
3
  *
4
- * Adoption used to end at `writeManifest`, which is the whole reason migrating
5
- * felt like it did nothing. "Active" is not a field anywhere — it is derived
6
- * from whether `.claude/settings.json` is a symlink into `_profiles/<id>/`
7
- * (see `activeProfile`). A manifest that nothing links to leaves the project in
8
- * exactly the state it was in, so the next command still asks which profile to
9
- * use, having just been told.
4
+ * "Active" is not a field in the manifest — it is the per-developer marker
5
+ * `.claude/profiles/active.json` that activation writes (see `activeProfile`).
6
+ * A manifest nothing has activated leaves the project exactly as it was.
10
7
  *
11
- * The three steps have to happen together and in this order, which is why they
12
- * live in one function rather than at each call site:
8
+ * Activation, in order:
13
9
  *
14
- * 1. materialize — write `_profiles/<id>/`, or activation throws
15
- * 2. gitignore — before the links exist, or a symlink into generated state
16
- * lands in `git status` (`.claude/` is committed)
17
- * 3. activate — point the four links at the profile
10
+ * 1. refuse, changing nothing, when a live file magus manages exists and
11
+ * does not parse — magus never writes over a file it cannot read
12
+ * 2. convert an older layout's skills (`.claude/_profiles/`), restoring any
13
+ * committed custom skills it displaced
14
+ * 3. refuse — before anything else changes — when the profile lists a skill
15
+ * whose folder is already a custom skill
16
+ * 4. gitignore the generated files, before they exist, in magus's own block
17
+ * 5. write settings.json, .mcp.json and models.json from the manifest; only
18
+ * at first adoption (no marker) is a hand-written file moved aside first
19
+ * 6. install/remove magus-installed skill folders; custom ones are untouched
20
+ * 7. record the marker, then move what is left of `.claude/_profiles/` into
21
+ * `.claude/.magus-backups/<stamp>-layout/` — kept, never deleted
18
22
  *
19
- * No network and no installs: `resolveProfile` reads the manifest, and binary
20
- * resolution is skipped outright because nothing here materializes bins. That
21
- * matters — this runs inside a prompt the user answered, not inside `install`.
23
+ * Callers absorb the live files into the PREVIOUS profile before switching
24
+ * away (`profile-state.ts`) — including, for an older layout, what Claude
25
+ * Code wrote through its links — so what step 5 replaces is already in the
26
+ * manifest. No network except skill installs.
22
27
  */
23
28
 
29
+ import { lstat } from "node:fs/promises";
24
30
  import path from "node:path";
25
- import type { ProfileManifest } from "../types/index.js";
26
- import { ensureGitignoreEntries } from "./conventions-manager.js";
27
- import { writeManifest } from "./manifest.js";
28
- import { adoptCurrentProject } from "./profile-adopt.js";
29
- import { materializeProfile } from "./profile-materializer.js";
30
- import { resolveAllProfiles, resolveProfile } from "./resolver.js";
31
+ import type {
32
+ DependencyReport,
33
+ ProfileManifest,
34
+ ProfileSkillRef,
35
+ } from "../types/index.js";
36
+ import { updateManagedGitignoreBlock } from "./conventions-manager.js";
37
+ import { readManifest } from "./manifest.js";
31
38
  import {
39
+ type ActiveMarker,
32
40
  type DisplacedArtifact,
41
+ LIVE_FILE_KEYS,
42
+ OBSOLETE_GITIGNORE_ENTRIES,
43
+ PROFILE_GITIGNORE_COMMENT,
33
44
  PROFILE_GITIGNORE_ENTRIES,
34
- activateProfile,
35
- } from "./symlink-manager.js";
36
-
37
- /**
38
- * Bins are declared by plugins and read from the marketplace cache, one
39
- * `plugin.json` per plugin. Materialization writes settings, mcp, models and
40
- * skills — never bins — so resolving them here would be pure latency, and on a
41
- * machine with a cold cache it is latency inside a prompt.
42
- */
43
- const noBins = async () => [];
45
+ assertLiveFilesParse,
46
+ backupRoot,
47
+ backupStamp,
48
+ displace,
49
+ folderHash,
50
+ generatedPaths,
51
+ hasCurrentMarker,
52
+ liveKind,
53
+ livePath,
54
+ machineLocalSettings,
55
+ markerFor,
56
+ oldLayoutProfile,
57
+ readActiveMarker,
58
+ skillsRoot,
59
+ writeActiveMarker,
60
+ writeLiveFile,
61
+ writeRenderedFiles,
62
+ } from "./profile-live.js";
63
+ import { describeDependencies, renderProfile } from "./profile-render.js";
64
+ import {
65
+ type LegacySkillConversion,
66
+ SkillClashError,
67
+ type SkillInstaller,
68
+ type SkillSyncResult,
69
+ archiveOlderLayout,
70
+ convertLegacySkills,
71
+ findSkillClashes,
72
+ installProfileSkill,
73
+ skillFolder,
74
+ syncSkills,
75
+ updateSkillIgnoreLines,
76
+ } from "./profile-skills.js";
77
+ import {
78
+ LiveFileRefusal,
79
+ TrackedLiveFileError,
80
+ trackedPaths,
81
+ untrackCommand,
82
+ } from "./tracked-live-files.js";
83
+ import type { ApplyReporter } from "./update-engine.js";
44
84
 
45
85
  export interface ActivationResult {
46
86
  /** The profile now live. */
47
87
  profileId: string;
48
- /** Hand-written artifacts moved aside to make room for the symlinks. */
88
+ /** Hand-written files moved aside at first adoption. */
49
89
  displaced: DisplacedArtifact[];
90
+ /** What happened to the profile's skill folders. */
91
+ skills: SkillSyncResult;
92
+ /** What converting an older layout did, if there was one. */
93
+ conversion: LegacySkillConversion;
94
+ /** Generated paths git still tracks (see {@link trackedGeneratedFiles}). */
95
+ tracked: string[];
96
+ /** Where `.claude/_profiles/` was kept, project-relative, or null. */
97
+ keptAt: string | null;
98
+ /** Unsaved changes added to a profile before activating, one line each. */
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[];
104
+ }
105
+
106
+ export interface ActivateOptions {
107
+ /** Called as each hand-written file moves; defaults to printing it. */
108
+ onDisplace?: (artifact: DisplacedArtifact) => void;
109
+ /** Skill installer; the real one fetches from GitHub. */
110
+ installSkill?: SkillInstaller;
50
111
  }
51
112
 
52
113
  /**
53
- * Announce one displaced artifact, in project-relative terms.
54
- *
55
- * Shared by every command that activates, because a hand-written file moving is
56
- * the same event whichever command caused it, and the one that is easiest to
57
- * leave out: `activateProfile` returns the list, and a caller that discards the
58
- * return value still compiles. `magus install` and `magus profile switch` both
59
- * did exactly that.
60
- *
61
- * Paths are relative because absolute ones wrap over two terminal lines each,
62
- * and this is the notice a user must not skim.
114
+ * Announce one displaced artifact, in project-relative terms, the instant it
115
+ * moves — so a throw partway through still says where the first one went.
63
116
  */
64
117
  export function reportDisplaced(
65
118
  projectPath: string,
@@ -70,78 +123,423 @@ export function reportDisplaced(
70
123
  }
71
124
 
72
125
  /**
73
- * Materialize `profileId` and make it the active profile.
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.
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. */
171
+ export async function trackedGeneratedFiles(
172
+ projectPath: string,
173
+ ): Promise<string[]> {
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);
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(
219
+ projectPath,
220
+ manifest.profiles[marker.profile]?.skills ?? [],
221
+ marker.skills,
222
+ );
223
+ }
224
+
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);
253
+ }
254
+
255
+ /**
256
+ * Lines describing what activation did beyond the switch itself, phrased for
257
+ * both the terminal and the TUI's outcome modal.
258
+ */
259
+ export function activationNotes(result: ActivationResult): string[] {
260
+ const lines: string[] = [
261
+ ...(result.absorbed ?? []),
262
+ ...describeDependencies(result.dependencies, result.profileId),
263
+ ];
264
+ 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
+ );
268
+ }
269
+ for (const c of result.conversion.conflicts) {
270
+ lines.push(
271
+ `Skill "${c.folder}" exists in two versions; kept ${c.kept}, the other is at ${c.other}`,
272
+ );
273
+ }
274
+ for (const f of result.skills.failed) {
275
+ lines.push(`Skill "${f.skill}" could not be installed: ${f.reason}`);
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
+ }
282
+ for (const rel of result.tracked) {
283
+ lines.push(
284
+ `${rel} is generated but tracked by git — run \`${untrackCommand(rel)}\`, then commit`,
285
+ );
286
+ }
287
+ if (result.keptAt) {
288
+ lines.push(
289
+ `The older layout's .claude/_profiles/ was kept at ${result.keptAt}/`,
290
+ );
291
+ }
292
+ return lines;
293
+ }
294
+
295
+ /**
296
+ * Render `profileId` from `manifest` and make it the active profile.
74
297
  *
75
- * The union of plugin ids across EVERY profile is passed to materialization, so
76
- * the profile writes an explicit `false` for plugins other profiles own. Without
77
- * it an omitted id falls through to user scope and stays enabled, which would
78
- * make the freshly adopted profile additive rather than a description of this
79
- * project — the exact drift adoption was run to end.
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
302
+ * changed nothing but moving an older layout's skills into `.claude/skills/` —
303
+ * when a listed skill's folder is already a custom skill.
80
304
  */
81
- export async function materializeAndActivate(
305
+ export async function activateProfile(
82
306
  manifest: ProfileManifest,
83
307
  profileId: string,
84
308
  projectPath: string,
85
- /** Called as each artifact moves; defaults to printing it. */
86
- onDisplace: (artifact: DisplacedArtifact) => void = reportDisplaced(
87
- projectPath,
88
- ),
309
+ opts: ActivateOptions = {},
89
310
  ): Promise<ActivationResult> {
90
- const closure = await resolveProfile(manifest, profileId, {
91
- binResolver: noBins,
92
- });
93
- const union = await resolveAllProfiles(manifest, { binResolver: noBins });
311
+ const onDisplace = opts.onDisplace ?? reportDisplaced(projectPath);
312
+ const rendered = await renderProfile(manifest, profileId, projectPath);
313
+ const skillRefs = rendered.entry.skills ?? [];
94
314
 
95
- await materializeProfile(
96
- profileId,
97
- closure,
315
+ // Read before converting: the older marker lives in the directory the
316
+ // conversion moves away, and an older layout is recognised by its links.
317
+ const previous = await readActiveMarker(projectPath);
318
+ // Before anything else is touched — the conversion below writes too.
319
+ await assertNoTrackedWriteTargets(
98
320
  projectPath,
99
- Object.keys(union.plugins),
321
+ await writableSkillFolders(projectPath, skillRefs, previous?.skills),
100
322
  );
323
+ const wasOldLayout = (await oldLayoutProfile(projectPath)) !== null;
324
+ // A managed project's live files are the profile's; one that does not
325
+ // parse is somebody mid-edit, and replacing it would lose their work. (At
326
+ // first adoption a hand-written file is moved to a backup, not replaced.)
327
+ if (previous !== null) await assertLiveFilesParse(projectPath);
328
+ const conversion = await convertLegacySkills(manifest, projectPath);
329
+ const managedBefore: Record<string, string> = {
330
+ ...(previous?.skills ?? {}),
331
+ ...conversion.managed,
332
+ };
101
333
 
102
- await ensureGitignoreEntries(
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
+ );
346
+ if (clashes.length > 0) throw new SkillClashError(profileId, clashes);
347
+
348
+ await updateManagedGitignoreBlock(
103
349
  path.join(projectPath, ".gitignore"),
104
- PROFILE_GITIGNORE_ENTRIES,
105
- "# magus: generated profile build output + active-profile symlinks",
350
+ PROFILE_GITIGNORE_COMMENT,
351
+ {
352
+ add: PROFILE_GITIGNORE_ENTRIES,
353
+ // Only while converting an older layout, and only from magus's own
354
+ // block: the blanket `.claude/skills` line it wrote hid committed
355
+ // custom skills. The same line a person wrote elsewhere is theirs.
356
+ remove: conversion.converted ? OBSOLETE_GITIGNORE_ENTRIES : [],
357
+ },
106
358
  );
107
359
 
108
- const displaced = await activateProfile(profileId, projectPath, onDisplace);
109
- return { profileId, displaced };
110
- }
360
+ const backupDir = path.join(backupRoot(projectPath), backupStamp());
361
+ const displaced: DisplacedArtifact[] = [];
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);
367
+ for (const key of LIVE_FILE_KEYS) {
368
+ // First adoption only: nothing marks this project as magus's and it is
369
+ // not an older magus layout, so a real file here is somebody's.
370
+ if (
371
+ previous === null &&
372
+ !wasOldLayout &&
373
+ (await liveKind(key, projectPath)) !== "missing"
374
+ ) {
375
+ const moved = await displace(livePath(key, projectPath), backupDir);
376
+ if (moved) {
377
+ displaced.push(moved);
378
+ onDisplace(moved);
379
+ }
380
+ }
381
+ const hash = await writeLiveFile(key, rendered[key], projectPath, carried);
382
+ if (hash) files[key] = hash;
383
+ // Recorded after EACH file, so a throw on the next cannot leave a file
384
+ // magus just wrote looking like an edit to absorb.
385
+ await writeActiveMarker(
386
+ markerFor(profileId, rendered, files, managedBefore),
387
+ projectPath,
388
+ );
389
+ }
111
390
 
112
- /** The two ways a project can be put onto a profile. */
113
- export type ProfileChoice =
114
- | { kind: "adopt"; id: string }
115
- | { kind: "activate"; id: string };
391
+ const skills = await syncSkills(
392
+ skillRefs,
393
+ managedBefore,
394
+ projectPath,
395
+ opts.installSkill ?? installProfileSkill,
396
+ committedSkills,
397
+ );
398
+ await updateSkillIgnoreLines(
399
+ projectPath,
400
+ Object.keys(managedBefore),
401
+ Object.keys(skills.skills),
402
+ );
403
+ await writeActiveMarker(
404
+ markerFor(profileId, rendered, files, skills.skills),
405
+ projectPath,
406
+ );
407
+ // Last, so a failure above leaves the older layout readable.
408
+ await archiveOlderLayout(projectPath, conversion.keptAt);
409
+
410
+ return {
411
+ profileId,
412
+ displaced,
413
+ skills,
414
+ conversion,
415
+ tracked: await trackedGeneratedFiles(projectPath),
416
+ keptAt: conversion.keptAt
417
+ ? path.relative(projectPath, conversion.keptAt)
418
+ : null,
419
+ ...(rendered.dependencies ? { dependencies: rendered.dependencies } : {}),
420
+ ...(committedSkills.size > 0
421
+ ? { committedSkills: [...committedSkills].sort() }
422
+ : {}),
423
+ };
424
+ }
116
425
 
117
426
  /**
118
- * Carry out the user's choice, whichever surface asked.
427
+ * Re-render the ACTIVE profile's settings.json, .mcp.json and models.json from
428
+ * the manifest and record them, leaving skills alone.
429
+ *
430
+ * For a command that has just changed the manifest (`models use`, a style) or
431
+ * let the Claude CLI write its own bookkeeping into settings.json (`magus
432
+ * install`, `magus update`). Anything Claude Code wrote was absorbed before the
433
+ * command acted, so replacing the files loses nothing.
119
434
  *
120
- * One implementation because "migrate" is two writes that must not come apart —
121
- * write the manifest, then link the project to it — and a second copy of that
122
- * pairing in the other surface is how one of them ends up writing the manifest
123
- * and stopping, which is the defect this whole area was fixed for.
435
+ * Returns false, writing nothing, when there is no current-format marker
436
+ * naming a defined profile. An older layout is NOT converted here: conversion
437
+ * must first record what Claude Code wrote through the old links, which is the
438
+ * entry-point gate's job (`prepareProfileState`). Throws `SettingsParseError`,
439
+ * writing nothing, when a live file does not parse.
124
440
  */
125
- export async function applyProfileChoice(
126
- choice: ProfileChoice,
127
- manifest: ProfileManifest,
441
+ export async function regenerateLiveFiles(
128
442
  projectPath: string,
129
- onDisplace?: (artifact: DisplacedArtifact) => void,
130
- ): Promise<ActivationResult> {
131
- if (choice.kind === "activate") {
132
- return materializeAndActivate(manifest, choice.id, projectPath, onDisplace);
133
- }
134
- const result = await adoptCurrentProject(
443
+ ): Promise<boolean> {
444
+ if (!(await hasCurrentMarker(projectPath))) return false;
445
+ const marker = await readActiveMarker(projectPath);
446
+ const manifest = await readManifest(projectPath);
447
+ if (!marker || !manifest.profiles[marker.profile]) return false;
448
+ await assertNoTrackedWriteTargets(projectPath);
449
+ await assertLiveFilesParse(projectPath);
450
+ const rendered = await renderProfile(manifest, marker.profile, projectPath);
451
+ const files = await writeRenderedFiles(rendered, projectPath);
452
+ await writeActiveMarker(
453
+ markerFor(marker.profile, rendered, files, marker.skills),
135
454
  projectPath,
136
- undefined,
137
- manifest,
138
- choice.id,
139
455
  );
140
- await writeManifest(result.manifest, projectPath);
141
- return materializeAndActivate(
142
- result.manifest,
143
- choice.id,
456
+ return true;
457
+ }
458
+
459
+ /**
460
+ * The profile skills `magus update` may fetch into `.claude/skills/`: only for
461
+ * the ACTIVE profile (another profile's skills would land as folders nothing
462
+ * records, which read as custom skills), and only into a folder that is
463
+ * magus-installed or absent — never over a custom skill.
464
+ */
465
+ export async function updatableSkills(
466
+ projectPath: string,
467
+ profileId: string,
468
+ refs: readonly ProfileSkillRef[],
469
+ ): Promise<ProfileSkillRef[]> {
470
+ const marker = await readActiveMarker(projectPath);
471
+ if (!marker || marker.profile !== profileId) return [];
472
+ const out: ProfileSkillRef[] = [];
473
+ for (const ref of refs) {
474
+ const folder = skillFolder(ref);
475
+ if (folder in marker.skills) {
476
+ out.push(ref);
477
+ continue;
478
+ }
479
+ try {
480
+ await lstat(path.join(skillsRoot(projectPath), folder));
481
+ } catch {
482
+ out.push(ref);
483
+ }
484
+ }
485
+ return out;
486
+ }
487
+
488
+ /**
489
+ * Which skill folders an apply actually fetched, read off its reporter: a
490
+ * skill counts once its item finishes successfully. Pass the returned
491
+ * `report` to the apply and `fetched()` to {@link recordRefetchedSkills} — a
492
+ * skill that was not fetched keeps its old hash, so an edit someone made to it
493
+ * during the run is still seen as an edit, not recorded as magus's own.
494
+ */
495
+ export function trackFetchedSkills(
496
+ refs: readonly ProfileSkillRef[],
497
+ report: ApplyReporter,
498
+ ): { report: ApplyReporter; fetched: () => string[] } {
499
+ const folders = new Map(refs.map((ref) => [ref.name, skillFolder(ref)]));
500
+ const fetched = new Set<string>();
501
+ return {
502
+ report: {
503
+ begin: (name) => report.begin(name),
504
+ finish: (ok, name, detail, ms) => {
505
+ const folder = folders.get(name);
506
+ if (ok && folder) fetched.add(folder);
507
+ report.finish(ok, name, detail, ms);
508
+ },
509
+ detach: (work) => report.detach(work),
510
+ },
511
+ fetched: () => [...fetched],
512
+ };
513
+ }
514
+
515
+ /**
516
+ * After `magus update` re-fetched skills in place: record those folders as
517
+ * magus-installed with their new contents, so they do not read as edited.
518
+ * Only the folders it fetched (see {@link trackFetchedSkills}).
519
+ * Keeps every other part of the marker — including a `cliBookkeeping` flag,
520
+ * which the reconcile that follows (`withCliBookkeeping`) clears.
521
+ */
522
+ export async function recordRefetchedSkills(
523
+ projectPath: string,
524
+ skillFolders: readonly string[],
525
+ ): Promise<void> {
526
+ if (!(await hasCurrentMarker(projectPath))) return;
527
+ const marker = await readActiveMarker(projectPath);
528
+ if (!marker) return;
529
+ const skills = { ...marker.skills };
530
+ for (const folder of skillFolders) {
531
+ const hash = await folderHash(path.join(skillsRoot(projectPath), folder));
532
+ if (hash) skills[folder] = hash;
533
+ }
534
+ await writeActiveMarker({ ...marker, skills }, projectPath);
535
+ await updateSkillIgnoreLines(
144
536
  projectPath,
145
- onDisplace,
537
+ Object.keys(marker.skills),
538
+ Object.keys(skills),
146
539
  );
147
540
  }
541
+
542
+ /** The two ways a project can be put onto a profile. */
543
+ export type ProfileChoice =
544
+ | { kind: "adopt"; id: string }
545
+ | { kind: "activate"; id: string };