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.
- package/README.md +9 -8
- package/package.json +5 -4
- package/src/__tests__/install-command.test.ts +4 -3
- package/src/__tests__/models-cli.test.ts +4 -7
- package/src/__tests__/models-manager.test.ts +52 -40
- package/src/__tests__/profile-absorb.test.ts +1210 -0
- package/src/__tests__/profile-command.test.ts +90 -32
- package/src/__tests__/profile-dependencies.test.ts +442 -0
- package/src/__tests__/profile-manifest-write.test.ts +231 -0
- package/src/__tests__/profile-migration-offer.test.ts +18 -15
- package/src/__tests__/profile-migration.test.ts +246 -117
- package/src/__tests__/profile-render.test.ts +141 -0
- package/src/__tests__/profile-skills.test.ts +524 -0
- package/src/__tests__/profile-startup.test.ts +44 -14
- package/src/__tests__/profile-state.test.ts +1110 -0
- package/src/__tests__/settings-file.test.ts +5 -7
- package/src/__tests__/stale-load-repair.test.ts +105 -0
- package/src/__tests__/styles-manager.test.ts +47 -74
- package/src/__tests__/styles-screen-state.test.ts +11 -10
- package/src/__tests__/support/preload.ts +14 -0
- package/src/__tests__/update-apply.test.ts +145 -0
- package/src/cli/bootstrap.ts +91 -27
- package/src/cli/doctor.ts +165 -38
- package/src/cli/install.ts +126 -122
- package/src/cli/models.ts +21 -1
- package/src/cli/profile.ts +48 -77
- package/src/cli/router.ts +39 -6
- package/src/cli/update.ts +46 -6
- package/src/data/gitignore-defaults.ts +3 -3
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-cli.ts +24 -0
- package/src/services/claude-settings.ts +27 -0
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +113 -9
- package/src/services/manifest.ts +224 -8
- package/src/services/models-manager.ts +36 -73
- package/src/services/plugin-dependencies.ts +48 -0
- package/src/services/plugin-requires.ts +137 -14
- package/src/services/profile-absorb.ts +1586 -0
- package/src/services/profile-activate.ts +492 -94
- package/src/services/profile-live.ts +884 -0
- package/src/services/profile-render.ts +182 -0
- package/src/services/profile-skills.ts +487 -0
- package/src/services/profile-startup.ts +88 -16
- package/src/services/profile-state.ts +706 -0
- package/src/services/profiles.ts +26 -37
- package/src/services/resolver.ts +100 -2
- package/src/services/settings-file.ts +5 -5
- package/src/services/skills-manager.ts +14 -8
- package/src/services/styles-manager.ts +45 -28
- package/src/services/tracked-live-files.ts +125 -0
- package/src/services/update-engine.ts +87 -2
- package/src/types/index.ts +34 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/screens/PluginsScreen.tsx +11 -2
- package/src/ui/screens/ProfilesScreen.tsx +84 -29
- package/src/ui/screens/StylesScreen.tsx +2 -1
- package/src/ui/state/types.ts +1 -1
- package/src/__tests__/profile-materializer.test.ts +0 -238
- package/src/__tests__/profile-sync.test.ts +0 -154
- package/src/__tests__/symlink-manager.test.ts +0 -180
- package/src/services/profile-materializer.ts +0 -133
- package/src/services/profile-sync.ts +0 -158
- package/src/services/symlink-manager.ts +0 -343
|
@@ -1,65 +1,118 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Put a project on a profile: render it, write it, install its skills.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
12
|
-
* live in one function rather than at each call site:
|
|
8
|
+
* Activation, in order:
|
|
13
9
|
*
|
|
14
|
-
* 1.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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 {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
import {
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
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
|
-
*
|
|
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
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
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
|
|
305
|
+
export async function activateProfile(
|
|
82
306
|
manifest: ProfileManifest,
|
|
83
307
|
profileId: string,
|
|
84
308
|
projectPath: string,
|
|
85
|
-
|
|
86
|
-
onDisplace: (artifact: DisplacedArtifact) => void = reportDisplaced(
|
|
87
|
-
projectPath,
|
|
88
|
-
),
|
|
309
|
+
opts: ActivateOptions = {},
|
|
89
310
|
): Promise<ActivationResult> {
|
|
90
|
-
const
|
|
91
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
|
109
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
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
|
|
126
|
-
choice: ProfileChoice,
|
|
127
|
-
manifest: ProfileManifest,
|
|
441
|
+
export async function regenerateLiveFiles(
|
|
128
442
|
projectPath: string,
|
|
129
|
-
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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 };
|