magus-cli 7.4.0 → 7.5.0
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 +4 -4
- package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
- 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 +1138 -0
- package/src/__tests__/profile-command.test.ts +90 -32
- 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 +425 -0
- package/src/__tests__/profile-startup.test.ts +44 -14
- package/src/__tests__/profile-state.test.ts +795 -0
- package/src/__tests__/settings-file.test.ts +5 -7
- package/src/__tests__/stale-load-repair.test.ts +5 -4
- package/src/__tests__/styles-manager.test.ts +47 -74
- package/src/__tests__/styles-screen-state.test.ts +11 -10
- package/src/cli/bootstrap.ts +87 -27
- package/src/cli/doctor.ts +152 -44
- package/src/cli/install.ts +92 -127
- package/src/cli/models.ts +21 -1
- package/src/cli/profile.ts +48 -77
- package/src/cli/router.ts +32 -6
- package/src/cli/update.ts +43 -6
- package/src/data/gitignore-defaults.ts +3 -3
- package/src/services/active-closure.ts +3 -3
- package/src/services/claude-settings.ts +283 -125
- package/src/services/cli-tool-commands.ts +30 -0
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +21 -0
- package/src/services/gitignore-service.ts +39 -22
- package/src/services/manifest.ts +4 -5
- package/src/services/models-manager.ts +36 -73
- package/src/services/profile-absorb.ts +1532 -0
- package/src/services/profile-activate.ts +348 -96
- package/src/services/profile-live.ts +825 -0
- package/src/services/profile-render.ts +115 -0
- package/src/services/profile-skills.ts +462 -0
- package/src/services/profile-startup.ts +88 -16
- package/src/services/profile-state.ts +622 -0
- package/src/services/profiles.ts +20 -32
- package/src/services/resolver.ts +6 -1
- package/src/services/settings-file.ts +14 -8
- package/src/services/skills-manager.ts +14 -8
- package/src/services/styles-manager.ts +45 -28
- package/src/services/tool-probe.ts +48 -3
- package/src/types/index.ts +15 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/components/modals/ModalContainer.tsx +9 -1
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/input/KeyRouter.tsx +147 -23
- package/src/ui/input/key-dispatch.ts +48 -0
- package/src/ui/keymap.ts +8 -1
- package/src/ui/renderers/cliToolRenderers.tsx +65 -27
- package/src/ui/screens/CliToolsScreen.tsx +74 -13
- package/src/ui/screens/PluginsScreen.tsx +6 -2
- package/src/ui/screens/ProfilesScreen.tsx +75 -27
- 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,104 @@
|
|
|
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 { ProfileManifest } from "../types/index.js";
|
|
26
|
-
import {
|
|
27
|
-
import {
|
|
28
|
-
import {
|
|
29
|
-
import { materializeProfile } from "./profile-materializer.js";
|
|
30
|
-
import { resolveAllProfiles, resolveProfile } from "./resolver.js";
|
|
31
|
+
import type { ProfileManifest, ProfileSkillRef } from "../types/index.js";
|
|
32
|
+
import { updateManagedGitignoreBlock } from "./conventions-manager.js";
|
|
33
|
+
import { isTrackedByGit } from "./git-worktree.js";
|
|
34
|
+
import { readManifest } from "./manifest.js";
|
|
31
35
|
import {
|
|
36
|
+
type ActiveMarker,
|
|
32
37
|
type DisplacedArtifact,
|
|
38
|
+
LIVE_FILE_KEYS,
|
|
39
|
+
OBSOLETE_GITIGNORE_ENTRIES,
|
|
40
|
+
PROFILE_GITIGNORE_COMMENT,
|
|
33
41
|
PROFILE_GITIGNORE_ENTRIES,
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
assertLiveFilesParse,
|
|
43
|
+
backupRoot,
|
|
44
|
+
backupStamp,
|
|
45
|
+
displace,
|
|
46
|
+
folderHash,
|
|
47
|
+
generatedPaths,
|
|
48
|
+
hasCurrentMarker,
|
|
49
|
+
liveKind,
|
|
50
|
+
livePath,
|
|
51
|
+
markerFor,
|
|
52
|
+
oldLayoutProfile,
|
|
53
|
+
readActiveMarker,
|
|
54
|
+
skillsRoot,
|
|
55
|
+
writeActiveMarker,
|
|
56
|
+
writeLiveFile,
|
|
57
|
+
writeRenderedFiles,
|
|
58
|
+
} from "./profile-live.js";
|
|
59
|
+
import { renderProfile } from "./profile-render.js";
|
|
60
|
+
import {
|
|
61
|
+
type LegacySkillConversion,
|
|
62
|
+
SkillClashError,
|
|
63
|
+
type SkillInstaller,
|
|
64
|
+
type SkillSyncResult,
|
|
65
|
+
archiveOlderLayout,
|
|
66
|
+
convertLegacySkills,
|
|
67
|
+
findSkillClashes,
|
|
68
|
+
installProfileSkill,
|
|
69
|
+
skillFolder,
|
|
70
|
+
syncSkills,
|
|
71
|
+
updateSkillIgnoreLines,
|
|
72
|
+
} from "./profile-skills.js";
|
|
73
|
+
import type { ApplyReporter } from "./update-engine.js";
|
|
44
74
|
|
|
45
75
|
export interface ActivationResult {
|
|
46
76
|
/** The profile now live. */
|
|
47
77
|
profileId: string;
|
|
48
|
-
/** Hand-written
|
|
78
|
+
/** Hand-written files moved aside at first adoption. */
|
|
49
79
|
displaced: DisplacedArtifact[];
|
|
80
|
+
/** What happened to the profile's skill folders. */
|
|
81
|
+
skills: SkillSyncResult;
|
|
82
|
+
/** What converting an older layout did, if there was one. */
|
|
83
|
+
conversion: LegacySkillConversion;
|
|
84
|
+
/** Generated paths git still tracks (see {@link trackedGeneratedFiles}). */
|
|
85
|
+
tracked: string[];
|
|
86
|
+
/** Where `.claude/_profiles/` was kept, project-relative, or null. */
|
|
87
|
+
keptAt: string | null;
|
|
88
|
+
/** Unsaved changes added to a profile before activating, one line each. */
|
|
89
|
+
absorbed?: string[];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface ActivateOptions {
|
|
93
|
+
/** Called as each hand-written file moves; defaults to printing it. */
|
|
94
|
+
onDisplace?: (artifact: DisplacedArtifact) => void;
|
|
95
|
+
/** Skill installer; the real one fetches from GitHub. */
|
|
96
|
+
installSkill?: SkillInstaller;
|
|
50
97
|
}
|
|
51
98
|
|
|
52
99
|
/**
|
|
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.
|
|
100
|
+
* Announce one displaced artifact, in project-relative terms, the instant it
|
|
101
|
+
* moves — so a throw partway through still says where the first one went.
|
|
63
102
|
*/
|
|
64
103
|
export function reportDisplaced(
|
|
65
104
|
projectPath: string,
|
|
@@ -70,78 +109,291 @@ export function reportDisplaced(
|
|
|
70
109
|
}
|
|
71
110
|
|
|
72
111
|
/**
|
|
73
|
-
*
|
|
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.
|
|
115
|
+
*/
|
|
116
|
+
export async function trackedGeneratedFiles(
|
|
117
|
+
projectPath: string,
|
|
118
|
+
): 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;
|
|
134
|
+
}
|
|
135
|
+
if (await isTrackedByGit(projectPath, rel)) out.push(rel);
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** How to untrack one generated path. */
|
|
141
|
+
export function untrackCommand(rel: string): string {
|
|
142
|
+
return `git rm --cached ${rel}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Lines describing what activation did beyond the switch itself, phrased for
|
|
147
|
+
* both the terminal and the TUI's outcome modal.
|
|
148
|
+
*/
|
|
149
|
+
export function activationNotes(result: ActivationResult): string[] {
|
|
150
|
+
const lines: string[] = [...(result.absorbed ?? [])];
|
|
151
|
+
for (const folder of result.conversion.restored) {
|
|
152
|
+
lines.push(
|
|
153
|
+
`Restored custom skill .claude/skills/${folder} (the older profile layout had moved it)`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
for (const c of result.conversion.conflicts) {
|
|
157
|
+
lines.push(
|
|
158
|
+
`Skill "${c.folder}" exists in two versions; kept ${c.kept}, the other is at ${c.other}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
for (const f of result.skills.failed) {
|
|
162
|
+
lines.push(`Skill "${f.skill}" could not be installed: ${f.reason}`);
|
|
163
|
+
}
|
|
164
|
+
for (const rel of result.tracked) {
|
|
165
|
+
lines.push(
|
|
166
|
+
`${rel} is generated but tracked by git — run \`${untrackCommand(rel)}\`, then commit`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
if (result.keptAt) {
|
|
170
|
+
lines.push(
|
|
171
|
+
`The older layout's .claude/_profiles/ was kept at ${result.keptAt}/`,
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
return lines;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Render `profileId` from `manifest` and make it the active profile.
|
|
74
179
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
* project — the exact drift adoption was run to end.
|
|
180
|
+
* Throws `SettingsParseError`, having changed nothing, when a live file of a
|
|
181
|
+
* managed project does not parse. Throws {@link SkillClashError} — having
|
|
182
|
+
* changed nothing but moving an older layout's skills into `.claude/skills/` —
|
|
183
|
+
* when a listed skill's folder is already a custom skill.
|
|
80
184
|
*/
|
|
81
|
-
export async function
|
|
185
|
+
export async function activateProfile(
|
|
82
186
|
manifest: ProfileManifest,
|
|
83
187
|
profileId: string,
|
|
84
188
|
projectPath: string,
|
|
85
|
-
|
|
86
|
-
onDisplace: (artifact: DisplacedArtifact) => void = reportDisplaced(
|
|
87
|
-
projectPath,
|
|
88
|
-
),
|
|
189
|
+
opts: ActivateOptions = {},
|
|
89
190
|
): Promise<ActivationResult> {
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
const union = await resolveAllProfiles(manifest, { binResolver: noBins });
|
|
191
|
+
const onDisplace = opts.onDisplace ?? reportDisplaced(projectPath);
|
|
192
|
+
const rendered = await renderProfile(manifest, profileId);
|
|
193
|
+
const skillRefs = rendered.entry.skills ?? [];
|
|
94
194
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
195
|
+
// Read before converting: the older marker lives in the directory the
|
|
196
|
+
// conversion moves away, and an older layout is recognised by its links.
|
|
197
|
+
const previous = await readActiveMarker(projectPath);
|
|
198
|
+
const wasOldLayout = (await oldLayoutProfile(projectPath)) !== null;
|
|
199
|
+
// A managed project's live files are the profile's; one that does not
|
|
200
|
+
// parse is somebody mid-edit, and replacing it would lose their work. (At
|
|
201
|
+
// first adoption a hand-written file is moved to a backup, not replaced.)
|
|
202
|
+
if (previous !== null) await assertLiveFilesParse(projectPath);
|
|
203
|
+
const conversion = await convertLegacySkills(manifest, projectPath);
|
|
204
|
+
const managedBefore: Record<string, string> = {
|
|
205
|
+
...(previous?.skills ?? {}),
|
|
206
|
+
...conversion.managed,
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const clashes = await findSkillClashes(skillRefs, managedBefore, projectPath);
|
|
210
|
+
if (clashes.length > 0) throw new SkillClashError(profileId, clashes);
|
|
101
211
|
|
|
102
|
-
await
|
|
212
|
+
await updateManagedGitignoreBlock(
|
|
103
213
|
path.join(projectPath, ".gitignore"),
|
|
104
|
-
|
|
105
|
-
|
|
214
|
+
PROFILE_GITIGNORE_COMMENT,
|
|
215
|
+
{
|
|
216
|
+
add: PROFILE_GITIGNORE_ENTRIES,
|
|
217
|
+
// Only while converting an older layout, and only from magus's own
|
|
218
|
+
// block: the blanket `.claude/skills` line it wrote hid committed
|
|
219
|
+
// custom skills. The same line a person wrote elsewhere is theirs.
|
|
220
|
+
remove: conversion.converted ? OBSOLETE_GITIGNORE_ENTRIES : [],
|
|
221
|
+
},
|
|
106
222
|
);
|
|
107
223
|
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
}
|
|
224
|
+
const backupDir = path.join(backupRoot(projectPath), backupStamp());
|
|
225
|
+
const displaced: DisplacedArtifact[] = [];
|
|
226
|
+
const files: ActiveMarker["files"] = {};
|
|
227
|
+
for (const key of LIVE_FILE_KEYS) {
|
|
228
|
+
// First adoption only: nothing marks this project as magus's and it is
|
|
229
|
+
// not an older magus layout, so a real file here is somebody's.
|
|
230
|
+
if (
|
|
231
|
+
previous === null &&
|
|
232
|
+
!wasOldLayout &&
|
|
233
|
+
(await liveKind(key, projectPath)) !== "missing"
|
|
234
|
+
) {
|
|
235
|
+
const moved = await displace(livePath(key, projectPath), backupDir);
|
|
236
|
+
if (moved) {
|
|
237
|
+
displaced.push(moved);
|
|
238
|
+
onDisplace(moved);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
const hash = await writeLiveFile(key, rendered[key], projectPath);
|
|
242
|
+
if (hash) files[key] = hash;
|
|
243
|
+
// Recorded after EACH file, so a throw on the next cannot leave a file
|
|
244
|
+
// magus just wrote looking like an edit to absorb.
|
|
245
|
+
await writeActiveMarker(
|
|
246
|
+
markerFor(profileId, rendered, files, managedBefore),
|
|
247
|
+
projectPath,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
111
250
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
251
|
+
const skills = await syncSkills(
|
|
252
|
+
skillRefs,
|
|
253
|
+
managedBefore,
|
|
254
|
+
projectPath,
|
|
255
|
+
opts.installSkill ?? installProfileSkill,
|
|
256
|
+
);
|
|
257
|
+
await updateSkillIgnoreLines(
|
|
258
|
+
projectPath,
|
|
259
|
+
Object.keys(managedBefore),
|
|
260
|
+
Object.keys(skills.skills),
|
|
261
|
+
);
|
|
262
|
+
await writeActiveMarker(
|
|
263
|
+
markerFor(profileId, rendered, files, skills.skills),
|
|
264
|
+
projectPath,
|
|
265
|
+
);
|
|
266
|
+
// Last, so a failure above leaves the older layout readable.
|
|
267
|
+
await archiveOlderLayout(projectPath, conversion.keptAt);
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
profileId,
|
|
271
|
+
displaced,
|
|
272
|
+
skills,
|
|
273
|
+
conversion,
|
|
274
|
+
tracked: await trackedGeneratedFiles(projectPath),
|
|
275
|
+
keptAt: conversion.keptAt
|
|
276
|
+
? path.relative(projectPath, conversion.keptAt)
|
|
277
|
+
: null,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
116
280
|
|
|
117
281
|
/**
|
|
118
|
-
*
|
|
282
|
+
* Re-render the ACTIVE profile's settings.json, .mcp.json and models.json from
|
|
283
|
+
* the manifest and record them, leaving skills alone.
|
|
119
284
|
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
285
|
+
* For a command that has just changed the manifest (`models use`, a style) or
|
|
286
|
+
* let the Claude CLI write its own bookkeeping into settings.json (`magus
|
|
287
|
+
* install`, `magus update`). Anything Claude Code wrote was absorbed before the
|
|
288
|
+
* command acted, so replacing the files loses nothing.
|
|
289
|
+
*
|
|
290
|
+
* Returns false, writing nothing, when there is no current-format marker
|
|
291
|
+
* naming a defined profile. An older layout is NOT converted here: conversion
|
|
292
|
+
* must first record what Claude Code wrote through the old links, which is the
|
|
293
|
+
* entry-point gate's job (`prepareProfileState`). Throws `SettingsParseError`,
|
|
294
|
+
* writing nothing, when a live file does not parse.
|
|
124
295
|
*/
|
|
125
|
-
export async function
|
|
126
|
-
choice: ProfileChoice,
|
|
127
|
-
manifest: ProfileManifest,
|
|
296
|
+
export async function regenerateLiveFiles(
|
|
128
297
|
projectPath: string,
|
|
129
|
-
|
|
130
|
-
)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
298
|
+
): Promise<boolean> {
|
|
299
|
+
if (!(await hasCurrentMarker(projectPath))) return false;
|
|
300
|
+
const marker = await readActiveMarker(projectPath);
|
|
301
|
+
const manifest = await readManifest(projectPath);
|
|
302
|
+
if (!marker || !manifest.profiles[marker.profile]) return false;
|
|
303
|
+
await assertLiveFilesParse(projectPath);
|
|
304
|
+
const rendered = await renderProfile(manifest, marker.profile);
|
|
305
|
+
const files = await writeRenderedFiles(rendered, projectPath);
|
|
306
|
+
await writeActiveMarker(
|
|
307
|
+
markerFor(marker.profile, rendered, files, marker.skills),
|
|
135
308
|
projectPath,
|
|
136
|
-
undefined,
|
|
137
|
-
manifest,
|
|
138
|
-
choice.id,
|
|
139
309
|
);
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* The profile skills `magus update` may fetch into `.claude/skills/`: only for
|
|
315
|
+
* the ACTIVE profile (another profile's skills would land as folders nothing
|
|
316
|
+
* records, which read as custom skills), and only into a folder that is
|
|
317
|
+
* magus-installed or absent — never over a custom skill.
|
|
318
|
+
*/
|
|
319
|
+
export async function updatableSkills(
|
|
320
|
+
projectPath: string,
|
|
321
|
+
profileId: string,
|
|
322
|
+
refs: readonly ProfileSkillRef[],
|
|
323
|
+
): Promise<ProfileSkillRef[]> {
|
|
324
|
+
const marker = await readActiveMarker(projectPath);
|
|
325
|
+
if (!marker || marker.profile !== profileId) return [];
|
|
326
|
+
const out: ProfileSkillRef[] = [];
|
|
327
|
+
for (const ref of refs) {
|
|
328
|
+
const folder = skillFolder(ref);
|
|
329
|
+
if (folder in marker.skills) {
|
|
330
|
+
out.push(ref);
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
await lstat(path.join(skillsRoot(projectPath), folder));
|
|
335
|
+
} catch {
|
|
336
|
+
out.push(ref);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return out;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Which skill folders an apply actually fetched, read off its reporter: a
|
|
344
|
+
* skill counts once its item finishes successfully. Pass the returned
|
|
345
|
+
* `report` to the apply and `fetched()` to {@link recordRefetchedSkills} — a
|
|
346
|
+
* skill that was not fetched keeps its old hash, so an edit someone made to it
|
|
347
|
+
* during the run is still seen as an edit, not recorded as magus's own.
|
|
348
|
+
*/
|
|
349
|
+
export function trackFetchedSkills(
|
|
350
|
+
refs: readonly ProfileSkillRef[],
|
|
351
|
+
report: ApplyReporter,
|
|
352
|
+
): { report: ApplyReporter; fetched: () => string[] } {
|
|
353
|
+
const folders = new Map(refs.map((ref) => [ref.name, skillFolder(ref)]));
|
|
354
|
+
const fetched = new Set<string>();
|
|
355
|
+
return {
|
|
356
|
+
report: {
|
|
357
|
+
begin: (name) => report.begin(name),
|
|
358
|
+
finish: (ok, name, detail, ms) => {
|
|
359
|
+
const folder = folders.get(name);
|
|
360
|
+
if (ok && folder) fetched.add(folder);
|
|
361
|
+
report.finish(ok, name, detail, ms);
|
|
362
|
+
},
|
|
363
|
+
detach: (work) => report.detach(work),
|
|
364
|
+
},
|
|
365
|
+
fetched: () => [...fetched],
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* After `magus update` re-fetched skills in place: record those folders as
|
|
371
|
+
* magus-installed with their new contents, so they do not read as edited.
|
|
372
|
+
* Only the folders it fetched (see {@link trackFetchedSkills}).
|
|
373
|
+
* Keeps every other part of the marker — including a `cliBookkeeping` flag,
|
|
374
|
+
* which the reconcile that follows (`withCliBookkeeping`) clears.
|
|
375
|
+
*/
|
|
376
|
+
export async function recordRefetchedSkills(
|
|
377
|
+
projectPath: string,
|
|
378
|
+
skillFolders: readonly string[],
|
|
379
|
+
): Promise<void> {
|
|
380
|
+
if (!(await hasCurrentMarker(projectPath))) return;
|
|
381
|
+
const marker = await readActiveMarker(projectPath);
|
|
382
|
+
if (!marker) return;
|
|
383
|
+
const skills = { ...marker.skills };
|
|
384
|
+
for (const folder of skillFolders) {
|
|
385
|
+
const hash = await folderHash(path.join(skillsRoot(projectPath), folder));
|
|
386
|
+
if (hash) skills[folder] = hash;
|
|
387
|
+
}
|
|
388
|
+
await writeActiveMarker({ ...marker, skills }, projectPath);
|
|
389
|
+
await updateSkillIgnoreLines(
|
|
144
390
|
projectPath,
|
|
145
|
-
|
|
391
|
+
Object.keys(marker.skills),
|
|
392
|
+
Object.keys(skills),
|
|
146
393
|
);
|
|
147
394
|
}
|
|
395
|
+
|
|
396
|
+
/** The two ways a project can be put onto a profile. */
|
|
397
|
+
export type ProfileChoice =
|
|
398
|
+
| { kind: "adopt"; id: string }
|
|
399
|
+
| { kind: "activate"; id: string };
|