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
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which profile is active — strictly — and getting the project into a state
|
|
3
|
+
* magus can act on, before any command does.
|
|
4
|
+
*
|
|
5
|
+
* {@link checkActiveProfile} answers the question without guessing:
|
|
6
|
+
*
|
|
7
|
+
* active the marker names a profile profiles.json defines
|
|
8
|
+
* unknown the marker names a profile profiles.json does NOT define —
|
|
9
|
+
* magus must not act on profile state; the fix is
|
|
10
|
+
* `magus profile switch <name>`
|
|
11
|
+
* old-layout a defined profile is named, and something of an older layout
|
|
12
|
+
* remains (links into `.claude/_profiles/`, the directory
|
|
13
|
+
* itself, the older marker); converted by
|
|
14
|
+
* {@link prepareProfileState} after what Claude Code wrote
|
|
15
|
+
* through the old links is added to the profile
|
|
16
|
+
* unmarked no marker. If the live config equals the rendered output of
|
|
17
|
+
* exactly one profile, the marker is restored for it and the
|
|
18
|
+
* state is `active`; otherwise the TUI asks once and a
|
|
19
|
+
* non-interactive CLI stops with the instruction.
|
|
20
|
+
* no-profiles profiles.json defines none (or does not exist)
|
|
21
|
+
*
|
|
22
|
+
* {@link prepareProfileState} is what entry points call: the check, then —
|
|
23
|
+
* for an active profile — absorbing Claude Code's edits and regenerating from
|
|
24
|
+
* a changed profiles.json (`profile-absorb.ts`). {@link inspectProfileState}
|
|
25
|
+
* is its read-only twin, for `magus doctor` without `--fix`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import path from "node:path";
|
|
29
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
30
|
+
import { readManifest, writeManifest } from "./manifest.js";
|
|
31
|
+
import {
|
|
32
|
+
type ReconcileResult,
|
|
33
|
+
absorbOlderLayout,
|
|
34
|
+
absorbOrphanedProfile,
|
|
35
|
+
reconcileActiveProfile,
|
|
36
|
+
refuseOnProblems,
|
|
37
|
+
} from "./profile-absorb.js";
|
|
38
|
+
import {
|
|
39
|
+
type ActivateOptions,
|
|
40
|
+
type ActivationResult,
|
|
41
|
+
type ProfileChoice,
|
|
42
|
+
activateProfile,
|
|
43
|
+
activationNotes,
|
|
44
|
+
activeWritableSkillFolders,
|
|
45
|
+
assertNoTrackedWriteTargets,
|
|
46
|
+
writableSkillFolders,
|
|
47
|
+
writeRefusal,
|
|
48
|
+
} from "./profile-activate.js";
|
|
49
|
+
import { adoptCurrentProject } from "./profile-adopt.js";
|
|
50
|
+
import {
|
|
51
|
+
type ActiveMarker,
|
|
52
|
+
LIVE_FILE_KEYS,
|
|
53
|
+
MACHINE_LOCAL_SETTINGS_KEYS,
|
|
54
|
+
folderHash,
|
|
55
|
+
hasCurrentMarker,
|
|
56
|
+
hasOlderLayout,
|
|
57
|
+
jsonEqual,
|
|
58
|
+
liveHash,
|
|
59
|
+
liveKind,
|
|
60
|
+
livePath,
|
|
61
|
+
markerFor,
|
|
62
|
+
namedActiveProfile,
|
|
63
|
+
readActiveMarker,
|
|
64
|
+
readLiveJsonSafely,
|
|
65
|
+
skillsRoot,
|
|
66
|
+
unreadableLiveFiles,
|
|
67
|
+
writeActiveMarker,
|
|
68
|
+
} from "./profile-live.js";
|
|
69
|
+
import { renderProfile } from "./profile-render.js";
|
|
70
|
+
import {
|
|
71
|
+
SkillClashError,
|
|
72
|
+
inspectManagedSkills,
|
|
73
|
+
skillFolder,
|
|
74
|
+
} from "./profile-skills.js";
|
|
75
|
+
import type { SettingsParseError } from "./settings-file.js";
|
|
76
|
+
import type { LiveFileRefusal } from "./tracked-live-files.js";
|
|
77
|
+
|
|
78
|
+
export type ActiveProfileCheck =
|
|
79
|
+
| { state: "no-profiles" }
|
|
80
|
+
| { state: "active"; profile: string; restored: boolean }
|
|
81
|
+
| { state: "old-layout"; profile: string }
|
|
82
|
+
| {
|
|
83
|
+
state: "unknown";
|
|
84
|
+
profile: string;
|
|
85
|
+
available: string[];
|
|
86
|
+
/** The profile to suggest switching to, or null to say `<name>`. */
|
|
87
|
+
suggested: string | null;
|
|
88
|
+
}
|
|
89
|
+
| {
|
|
90
|
+
state: "unmarked";
|
|
91
|
+
available: string[];
|
|
92
|
+
liveFiles: boolean;
|
|
93
|
+
suggested: string | null;
|
|
94
|
+
/**
|
|
95
|
+
* The one profile the live config matches exactly, when the check
|
|
96
|
+
* was told not to restore the marker for it (`restore: false`).
|
|
97
|
+
*/
|
|
98
|
+
match: string | null;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The profile a "run `magus profile switch …`" hint should name: the one the
|
|
103
|
+
* manifest declares as its default, or the only one there is. Null when
|
|
104
|
+
* neither says, and the hint names `<name>` with the choices listed.
|
|
105
|
+
*/
|
|
106
|
+
export function suggestedProfile(manifest: ProfileManifest): string | null {
|
|
107
|
+
const ids = Object.keys(manifest.profiles);
|
|
108
|
+
if (manifest.default !== undefined && ids.includes(manifest.default)) {
|
|
109
|
+
return manifest.default;
|
|
110
|
+
}
|
|
111
|
+
return ids.length === 1 ? (ids[0] as string) : null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The switch command to print: a real profile name when there is one to
|
|
116
|
+
* suggest, never literal angle brackets around one.
|
|
117
|
+
*/
|
|
118
|
+
export function switchCommand(suggested: string | null): string {
|
|
119
|
+
return suggested !== null
|
|
120
|
+
? `magus profile switch ${suggested}`
|
|
121
|
+
: "magus profile switch <name>";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** The hint sentence: the command, plus the choices when it says `<name>`. */
|
|
125
|
+
export function switchHint(
|
|
126
|
+
available: readonly string[],
|
|
127
|
+
suggested: string | null,
|
|
128
|
+
): string {
|
|
129
|
+
const command = `\`${switchCommand(suggested)}\``;
|
|
130
|
+
return suggested !== null
|
|
131
|
+
? command
|
|
132
|
+
: `${command} (profiles: ${available.join(", ")})`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function withoutMachineKeys(
|
|
136
|
+
settings: Record<string, unknown>,
|
|
137
|
+
): Record<string, unknown> {
|
|
138
|
+
const out = { ...settings };
|
|
139
|
+
for (const key of MACHINE_LOCAL_SETTINGS_KEYS) delete out[key];
|
|
140
|
+
return out;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Profiles whose rendered output equals the live config exactly. */
|
|
144
|
+
async function matchingProfiles(
|
|
145
|
+
manifest: ProfileManifest,
|
|
146
|
+
projectPath: string,
|
|
147
|
+
): Promise<string[]> {
|
|
148
|
+
const settings = await readLiveJsonSafely("settings", projectPath);
|
|
149
|
+
if (!settings) return [];
|
|
150
|
+
const mcp = (await readLiveJsonSafely("mcp", projectPath)) ?? {
|
|
151
|
+
mcpServers: {},
|
|
152
|
+
};
|
|
153
|
+
const models = await readLiveJsonSafely("models", projectPath);
|
|
154
|
+
const matches: string[] = [];
|
|
155
|
+
for (const id of Object.keys(manifest.profiles)) {
|
|
156
|
+
const rendered = await renderProfile(manifest, id, projectPath);
|
|
157
|
+
if (
|
|
158
|
+
jsonEqual(withoutMachineKeys(settings), rendered.settings) &&
|
|
159
|
+
jsonEqual(mcp, rendered.mcp) &&
|
|
160
|
+
jsonEqual(models, rendered.models)
|
|
161
|
+
) {
|
|
162
|
+
matches.push(id);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return matches;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Write a marker for `profile` from the live files as they stand. Its skills
|
|
170
|
+
* whose folders exist are recorded as magus-installed: the live config is
|
|
171
|
+
* exactly this profile's, so they are its own.
|
|
172
|
+
*/
|
|
173
|
+
async function restoreMarker(
|
|
174
|
+
manifest: ProfileManifest,
|
|
175
|
+
profile: string,
|
|
176
|
+
projectPath: string,
|
|
177
|
+
): Promise<void> {
|
|
178
|
+
const entry = manifest.profiles[profile];
|
|
179
|
+
if (!entry) return;
|
|
180
|
+
const files: ActiveMarker["files"] = {};
|
|
181
|
+
for (const key of LIVE_FILE_KEYS) {
|
|
182
|
+
const hash = await liveHash(key, projectPath);
|
|
183
|
+
if (hash) files[key] = hash;
|
|
184
|
+
}
|
|
185
|
+
const skills: Record<string, string> = {};
|
|
186
|
+
for (const ref of entry.skills ?? []) {
|
|
187
|
+
const folder = skillFolder(ref);
|
|
188
|
+
const hash = await folderHash(path.join(skillsRoot(projectPath), folder));
|
|
189
|
+
if (hash) skills[folder] = hash;
|
|
190
|
+
}
|
|
191
|
+
await writeActiveMarker(
|
|
192
|
+
markerFor(
|
|
193
|
+
profile,
|
|
194
|
+
await renderProfile(manifest, profile, projectPath),
|
|
195
|
+
files,
|
|
196
|
+
skills,
|
|
197
|
+
),
|
|
198
|
+
projectPath,
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface CheckOptions {
|
|
203
|
+
/**
|
|
204
|
+
* Restore a lost marker when the live config matches exactly one profile —
|
|
205
|
+
* the check's only write. `magus doctor` without `--fix` turns it off.
|
|
206
|
+
*/
|
|
207
|
+
restore?: boolean;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The strict active-profile check. Restoring a lost marker for a unique match
|
|
212
|
+
* is its only write, and only unless `restore: false`. Throws
|
|
213
|
+
* `SettingsParseError` when profiles.json does not parse.
|
|
214
|
+
*/
|
|
215
|
+
export async function checkActiveProfile(
|
|
216
|
+
projectPath: string,
|
|
217
|
+
opts: CheckOptions = {},
|
|
218
|
+
): Promise<ActiveProfileCheck> {
|
|
219
|
+
const manifest = await readManifest(projectPath);
|
|
220
|
+
const available = Object.keys(manifest.profiles);
|
|
221
|
+
if (available.length === 0) return { state: "no-profiles" };
|
|
222
|
+
|
|
223
|
+
const named = await namedActiveProfile(projectPath);
|
|
224
|
+
if (named) {
|
|
225
|
+
if (!available.includes(named.profile)) {
|
|
226
|
+
return {
|
|
227
|
+
state: "unknown",
|
|
228
|
+
profile: named.profile,
|
|
229
|
+
available,
|
|
230
|
+
suggested: suggestedProfile(manifest),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
return (await hasOlderLayout(projectPath))
|
|
234
|
+
? { state: "old-layout", profile: named.profile }
|
|
235
|
+
: { state: "active", profile: named.profile, restored: false };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const matches = await matchingProfiles(manifest, projectPath);
|
|
239
|
+
if (matches.length === 1 && opts.restore !== false) {
|
|
240
|
+
const [profile] = matches as [string];
|
|
241
|
+
await restoreMarker(manifest, profile, projectPath);
|
|
242
|
+
return { state: "active", profile, restored: true };
|
|
243
|
+
}
|
|
244
|
+
let liveFiles = false;
|
|
245
|
+
for (const key of LIVE_FILE_KEYS) {
|
|
246
|
+
if ((await liveKind(key, projectPath)) !== "missing") liveFiles = true;
|
|
247
|
+
}
|
|
248
|
+
const match = matches.length === 1 ? (matches[0] as string) : null;
|
|
249
|
+
return {
|
|
250
|
+
state: "unmarked",
|
|
251
|
+
available,
|
|
252
|
+
liveFiles,
|
|
253
|
+
suggested: match ?? suggestedProfile(manifest),
|
|
254
|
+
match,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** The message for a marker naming a profile profiles.json does not define. */
|
|
259
|
+
export function describeUnknownProfile(
|
|
260
|
+
check: Extract<ActiveProfileCheck, { state: "unknown" }>,
|
|
261
|
+
): string {
|
|
262
|
+
return `The active profile is recorded as "${check.profile}", but .claude/profiles.json defines no such profile. Run ${switchHint(check.available, check.suggested)}.`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The message for a project with profiles and no marker to say which is live. */
|
|
266
|
+
export function describeUnmarked(
|
|
267
|
+
check: Extract<ActiveProfileCheck, { state: "unmarked" }>,
|
|
268
|
+
): string {
|
|
269
|
+
return `No profile is active in this project, and its live config matches no single profile. Run ${switchHint(check.available, check.suggested)}.`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Before an older layout is converted to activate `target`: add what Claude
|
|
274
|
+
* Code wrote through its links to the profile it belongs to — the one the
|
|
275
|
+
* layout names, or `target` when profiles.json no longer defines that one.
|
|
276
|
+
*
|
|
277
|
+
* Null when there is nothing of the kind to do: a current marker exists (its
|
|
278
|
+
* live files are reconciled instead), there is no older layout, or the layout
|
|
279
|
+
* names no profile (nothing was written through a link; a real file there is
|
|
280
|
+
* a first adoption's to back up).
|
|
281
|
+
*/
|
|
282
|
+
export async function absorbBeforeConversion(
|
|
283
|
+
projectPath: string,
|
|
284
|
+
target: string,
|
|
285
|
+
): Promise<ReconcileResult | null> {
|
|
286
|
+
if (await hasCurrentMarker(projectPath)) return null;
|
|
287
|
+
if (!(await hasOlderLayout(projectPath))) return null;
|
|
288
|
+
const named = (await namedActiveProfile(projectPath))?.profile ?? null;
|
|
289
|
+
if (named === null) return null;
|
|
290
|
+
const manifest = await readManifest(projectPath);
|
|
291
|
+
const into = manifest.profiles[named] ? named : target;
|
|
292
|
+
return absorbOlderLayout(projectPath, into, named);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface ProfileStatePrep {
|
|
296
|
+
check: ActiveProfileCheck;
|
|
297
|
+
reconcile: ReconcileResult | null;
|
|
298
|
+
/** Everything worth printing, in order. */
|
|
299
|
+
lines: string[];
|
|
300
|
+
/**
|
|
301
|
+
* Things that stop a command which would write profile state. When
|
|
302
|
+
* non-empty, nothing was written.
|
|
303
|
+
*/
|
|
304
|
+
problems: string[];
|
|
305
|
+
/** The files behind `problems` that could not be parsed. */
|
|
306
|
+
unreadable: SettingsParseError[];
|
|
307
|
+
/**
|
|
308
|
+
* Set when a file magus would write is tracked by git, or git could not
|
|
309
|
+
* say: nothing ran, and `problems` holds its message.
|
|
310
|
+
*/
|
|
311
|
+
refusal?: LiveFileRefusal;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The entry-point gate: check which profile is active, restore a lost marker,
|
|
316
|
+
* absorb/regenerate (see `profile-absorb.ts`), and convert an older layout —
|
|
317
|
+
* after adding what Claude Code wrote through its links to the profile.
|
|
318
|
+
* Idempotent and cheap when nothing changed. Writes nothing when a live file
|
|
319
|
+
* cannot be read (`problems`).
|
|
320
|
+
*/
|
|
321
|
+
export async function prepareProfileState(
|
|
322
|
+
projectPath: string,
|
|
323
|
+
opts: Pick<ActivateOptions, "installSkill"> = {},
|
|
324
|
+
): Promise<ProfileStatePrep> {
|
|
325
|
+
// A project with profiles whose live files — or the active profile's
|
|
326
|
+
// magus-installed skill folders — git tracks: every path below would write
|
|
327
|
+
// one of them (or the marker, or the manifest on the way), so nothing runs,
|
|
328
|
+
// not even the marker a unique match would restore.
|
|
329
|
+
const manifest = await readManifest(projectPath);
|
|
330
|
+
if (Object.keys(manifest.profiles).length > 0) {
|
|
331
|
+
const refusal = await writeRefusal(
|
|
332
|
+
projectPath,
|
|
333
|
+
await activeWritableSkillFolders(projectPath, manifest),
|
|
334
|
+
);
|
|
335
|
+
if (refusal) {
|
|
336
|
+
return {
|
|
337
|
+
check: await checkActiveProfile(projectPath, { restore: false }),
|
|
338
|
+
reconcile: null,
|
|
339
|
+
lines: [],
|
|
340
|
+
problems: [refusal.message],
|
|
341
|
+
unreadable: [],
|
|
342
|
+
refusal,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
const check = await checkActiveProfile(projectPath);
|
|
347
|
+
const out: ProfileStatePrep = {
|
|
348
|
+
check,
|
|
349
|
+
reconcile: null,
|
|
350
|
+
lines: [],
|
|
351
|
+
problems: [],
|
|
352
|
+
unreadable: [],
|
|
353
|
+
};
|
|
354
|
+
const take = (r: ReconcileResult) => {
|
|
355
|
+
out.lines.push(...r.lines.filter((l) => !r.problems.includes(l)));
|
|
356
|
+
out.problems.push(...r.problems);
|
|
357
|
+
out.unreadable.push(...r.unreadable);
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
if (check.state === "active" && check.restored) {
|
|
361
|
+
out.lines.push(
|
|
362
|
+
`No active-profile marker, but the live config matches profile "${check.profile}" exactly — restored it`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (check.state === "active" || check.state === "old-layout") {
|
|
367
|
+
const profile = check.profile;
|
|
368
|
+
if (await hasCurrentMarker(projectPath)) {
|
|
369
|
+
out.reconcile = await reconcileActiveProfile(projectPath, opts);
|
|
370
|
+
take(out.reconcile);
|
|
371
|
+
if (out.problems.length > 0) return out;
|
|
372
|
+
}
|
|
373
|
+
if (await hasOlderLayout(projectPath)) {
|
|
374
|
+
const absorbed = await absorbBeforeConversion(projectPath, profile);
|
|
375
|
+
if (absorbed) {
|
|
376
|
+
take(absorbed);
|
|
377
|
+
if (out.problems.length > 0) return out;
|
|
378
|
+
}
|
|
379
|
+
const result = await activateProfile(
|
|
380
|
+
await readManifest(projectPath),
|
|
381
|
+
profile,
|
|
382
|
+
projectPath,
|
|
383
|
+
{ installSkill: opts.installSkill },
|
|
384
|
+
);
|
|
385
|
+
if (check.state === "old-layout") {
|
|
386
|
+
out.lines.push(
|
|
387
|
+
`Converted profile "${profile}" from the older layout to real files`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
out.lines.push(...activationNotes(result));
|
|
391
|
+
}
|
|
392
|
+
out.check = { state: "active", profile, restored: false };
|
|
393
|
+
}
|
|
394
|
+
return out;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export interface ProfileStateReport {
|
|
398
|
+
/** The strict check, made without restoring anything. */
|
|
399
|
+
check: ActiveProfileCheck;
|
|
400
|
+
/** What the next magus command (or `magus doctor --fix`) would do. */
|
|
401
|
+
pending: string[];
|
|
402
|
+
/** Live files that do not parse; magus writes none of them. */
|
|
403
|
+
unreadable: SettingsParseError[];
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* What {@link prepareProfileState} would do, without doing any of it — for
|
|
408
|
+
* `magus doctor` without `--fix`, which must not convert or write anything.
|
|
409
|
+
*/
|
|
410
|
+
export async function inspectProfileState(
|
|
411
|
+
projectPath: string,
|
|
412
|
+
): Promise<ProfileStateReport> {
|
|
413
|
+
const check = await checkActiveProfile(projectPath, { restore: false });
|
|
414
|
+
const pending: string[] = [];
|
|
415
|
+
const unreadable = await unreadableLiveFiles(projectPath);
|
|
416
|
+
const rel = (p: string) => path.relative(projectPath, p);
|
|
417
|
+
|
|
418
|
+
if (check.state === "old-layout") {
|
|
419
|
+
pending.push(
|
|
420
|
+
`Profile "${check.profile}" is on an older magus layout (links into .claude/_profiles/). The next magus command, or \`magus doctor --fix\`, adds any changes Claude Code made under it to profiles.json, converts it to real files, and keeps .claude/_profiles/ in .claude/.magus-backups/.`,
|
|
421
|
+
);
|
|
422
|
+
} else if (check.state === "unmarked" && check.match !== null) {
|
|
423
|
+
pending.push(
|
|
424
|
+
`No active-profile marker, but the live config matches profile "${check.match}" exactly — the next magus command, or \`magus doctor --fix\`, restores it.`,
|
|
425
|
+
);
|
|
426
|
+
} else if (
|
|
427
|
+
check.state === "active" &&
|
|
428
|
+
(await hasCurrentMarker(projectPath))
|
|
429
|
+
) {
|
|
430
|
+
const marker = await readActiveMarker(projectPath);
|
|
431
|
+
if (marker) {
|
|
432
|
+
const p = check.profile;
|
|
433
|
+
for (const key of LIVE_FILE_KEYS) {
|
|
434
|
+
const kind = await liveKind(key, projectPath);
|
|
435
|
+
const file = rel(livePath(key, projectPath));
|
|
436
|
+
if (kind === "file") {
|
|
437
|
+
if ((await liveHash(key, projectPath)) !== marker.files[key]) {
|
|
438
|
+
pending.push(
|
|
439
|
+
`${file} has changes not yet recorded in profile "${p}" — the next magus command records them.`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
} else if (kind === "missing" && marker.files[key] !== undefined) {
|
|
443
|
+
pending.push(
|
|
444
|
+
`${file} is missing — the next magus command writes it again from profile "${p}".`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (marker.cliBookkeeping) {
|
|
449
|
+
pending.push(
|
|
450
|
+
"An interrupted magus install left the Claude CLI's plugin records in .claude/settings.json — the next magus command replaces them.",
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
const manifest = await readManifest(projectPath);
|
|
454
|
+
if (
|
|
455
|
+
(await renderProfile(manifest, p, projectPath)).definition !==
|
|
456
|
+
marker.definition
|
|
457
|
+
) {
|
|
458
|
+
pending.push(
|
|
459
|
+
"profiles.json changed since the live config was generated — the next magus command regenerates it.",
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
const { edited } = await inspectManagedSkills(marker.skills, projectPath);
|
|
463
|
+
for (const folder of edited) {
|
|
464
|
+
pending.push(
|
|
465
|
+
`Skill .claude/skills/${folder} was edited — the next magus command makes it a custom skill.`,
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
return { check, pending, unreadable };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export interface SwitchResult {
|
|
474
|
+
/** What was recorded on the way: absorbed edits, an orphaned marker's changes. */
|
|
475
|
+
lines: string[];
|
|
476
|
+
/** Why nothing was activated. Empty when the switch happened. */
|
|
477
|
+
problems: string[];
|
|
478
|
+
/** The files behind `problems` that could not be parsed. */
|
|
479
|
+
unreadable: SettingsParseError[];
|
|
480
|
+
/** The activation, when there was one. */
|
|
481
|
+
activation: ActivationResult | null;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Make `name` the active profile — the ONE switch path, for `magus profile
|
|
486
|
+
* switch`, `magus install` and the TUI's Profiles tab alike.
|
|
487
|
+
*
|
|
488
|
+
* 1. On an older layout, what Claude Code wrote through its links is added
|
|
489
|
+
* to the profile it belongs to (`absorbBeforeConversion`).
|
|
490
|
+
* 2. Whatever Claude Code wrote into the live config belongs to the profile
|
|
491
|
+
* being LEFT, so it is absorbed there (`reconcileActiveProfile`).
|
|
492
|
+
* 3. If that profile no longer exists in profiles.json, its unsaved changes
|
|
493
|
+
* have nowhere to go but `name` (`absorbOrphanedProfile`).
|
|
494
|
+
* 4. `name` is activated: its live files rendered, its skills installed.
|
|
495
|
+
*
|
|
496
|
+
* Stops — having written nothing — when `name` is not defined or a live file
|
|
497
|
+
* cannot be read, and before activating when one of `name`'s skills would
|
|
498
|
+
* land on a custom skill folder.
|
|
499
|
+
*/
|
|
500
|
+
export async function switchToProfile(
|
|
501
|
+
projectPath: string,
|
|
502
|
+
name: string,
|
|
503
|
+
opts: ActivateOptions = {},
|
|
504
|
+
): Promise<SwitchResult> {
|
|
505
|
+
const result: SwitchResult = {
|
|
506
|
+
lines: [],
|
|
507
|
+
problems: [],
|
|
508
|
+
unreadable: [],
|
|
509
|
+
activation: null,
|
|
510
|
+
};
|
|
511
|
+
const take = (r: ReconcileResult | null): boolean => {
|
|
512
|
+
if (!r) return true;
|
|
513
|
+
result.lines.push(...r.lines.filter((l) => !r.problems.includes(l)));
|
|
514
|
+
result.problems.push(...r.problems);
|
|
515
|
+
result.unreadable.push(...r.unreadable);
|
|
516
|
+
return r.problems.length === 0;
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
const before = await readManifest(projectPath);
|
|
520
|
+
const target = before.profiles[name];
|
|
521
|
+
if (!target) {
|
|
522
|
+
result.problems.push(
|
|
523
|
+
`Profile "${name}" not found. Available: ${Object.keys(before.profiles).join(", ")}`,
|
|
524
|
+
);
|
|
525
|
+
return result;
|
|
526
|
+
}
|
|
527
|
+
// Before the absorbs below write profiles.json: nothing at all is written
|
|
528
|
+
// when a live file this switch would write is tracked by git.
|
|
529
|
+
const marker = await readActiveMarker(projectPath);
|
|
530
|
+
const refusal = await writeRefusal(projectPath, [
|
|
531
|
+
...(await writableSkillFolders(
|
|
532
|
+
projectPath,
|
|
533
|
+
target.skills ?? [],
|
|
534
|
+
marker?.skills,
|
|
535
|
+
)),
|
|
536
|
+
...(await activeWritableSkillFolders(projectPath, before)),
|
|
537
|
+
]);
|
|
538
|
+
if (refusal) {
|
|
539
|
+
result.problems.push(refusal.message);
|
|
540
|
+
return result;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (!take(await absorbBeforeConversion(projectPath, name))) return result;
|
|
544
|
+
const installSkill = { installSkill: opts.installSkill };
|
|
545
|
+
if (!take(await reconcileActiveProfile(projectPath, installSkill))) {
|
|
546
|
+
return result;
|
|
547
|
+
}
|
|
548
|
+
if (!take(await absorbOrphanedProfile(projectPath, name))) return result;
|
|
549
|
+
|
|
550
|
+
try {
|
|
551
|
+
result.activation = await activateProfile(
|
|
552
|
+
await readManifest(projectPath),
|
|
553
|
+
name,
|
|
554
|
+
projectPath,
|
|
555
|
+
opts,
|
|
556
|
+
);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
if (!(error instanceof SkillClashError)) throw error;
|
|
559
|
+
result.problems.push(error.message);
|
|
560
|
+
}
|
|
561
|
+
return result;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Carry out the user's choice from the "which profile?" question, whichever
|
|
566
|
+
* surface asked.
|
|
567
|
+
*
|
|
568
|
+
* Activating first adds anything unsaved to a profile — what Claude Code wrote
|
|
569
|
+
* under an older layout, or under a recorded profile profiles.json no longer
|
|
570
|
+
* defines — so the activation renders over nothing unrecorded; those lines are
|
|
571
|
+
* returned in `absorbed`. Throws, having written nothing, when a live file
|
|
572
|
+
* cannot be read.
|
|
573
|
+
*
|
|
574
|
+
* Adopting is two writes that must not come apart — write the manifest, then
|
|
575
|
+
* activate it.
|
|
576
|
+
*/
|
|
577
|
+
export async function applyProfileChoice(
|
|
578
|
+
choice: ProfileChoice,
|
|
579
|
+
manifest: ProfileManifest,
|
|
580
|
+
projectPath: string,
|
|
581
|
+
opts: ActivateOptions = {},
|
|
582
|
+
): Promise<ActivationResult> {
|
|
583
|
+
// Nothing is written — not the adopted manifest, not an absorbed edit —
|
|
584
|
+
// when a live file this choice would write is tracked by git.
|
|
585
|
+
const skills =
|
|
586
|
+
choice.kind === "activate"
|
|
587
|
+
? (manifest.profiles[choice.id]?.skills ?? [])
|
|
588
|
+
: [];
|
|
589
|
+
await assertNoTrackedWriteTargets(
|
|
590
|
+
projectPath,
|
|
591
|
+
await writableSkillFolders(
|
|
592
|
+
projectPath,
|
|
593
|
+
skills,
|
|
594
|
+
(await readActiveMarker(projectPath))?.skills,
|
|
595
|
+
),
|
|
596
|
+
);
|
|
597
|
+
if (choice.kind === "activate") {
|
|
598
|
+
const absorbed: string[] = [];
|
|
599
|
+
let changed = false;
|
|
600
|
+
for (const step of [
|
|
601
|
+
await absorbBeforeConversion(projectPath, choice.id),
|
|
602
|
+
await absorbOrphanedProfile(projectPath, choice.id),
|
|
603
|
+
]) {
|
|
604
|
+
if (!step) continue;
|
|
605
|
+
refuseOnProblems(step);
|
|
606
|
+
absorbed.push(...step.lines);
|
|
607
|
+
if (step.changes.length > 0) changed = true;
|
|
608
|
+
}
|
|
609
|
+
const result = await activateProfile(
|
|
610
|
+
changed ? await readManifest(projectPath) : manifest,
|
|
611
|
+
choice.id,
|
|
612
|
+
projectPath,
|
|
613
|
+
opts,
|
|
614
|
+
);
|
|
615
|
+
return absorbed.length > 0 ? { ...result, absorbed } : result;
|
|
616
|
+
}
|
|
617
|
+
const result = await adoptCurrentProject(
|
|
618
|
+
projectPath,
|
|
619
|
+
undefined,
|
|
620
|
+
manifest,
|
|
621
|
+
choice.id,
|
|
622
|
+
);
|
|
623
|
+
await writeManifest(result.manifest, projectPath);
|
|
624
|
+
return activateProfile(result.manifest, choice.id, projectPath, opts);
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* The gate, for a service about to write profile state (`models use`/`off`,
|
|
629
|
+
* a style set or cleared — from the CLI or the TUI): absorb and regenerate
|
|
630
|
+
* first, and throw — having written nothing — when that could not be done
|
|
631
|
+
* safely. The first unreadable file's `SettingsParseError` is thrown as is,
|
|
632
|
+
* so every surface reports it the way it reports any damaged settings file.
|
|
633
|
+
*/
|
|
634
|
+
export async function prepareProfileWrite(
|
|
635
|
+
projectPath: string,
|
|
636
|
+
): Promise<ProfileStatePrep> {
|
|
637
|
+
const prep = await prepareProfileState(projectPath);
|
|
638
|
+
if (prep.refusal) throw prep.refusal;
|
|
639
|
+
refuseOnProblems(prep);
|
|
640
|
+
return prep;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export interface CliBookkeepingOptions {
|
|
644
|
+
/**
|
|
645
|
+
* The plugin ids `work` installs. Only a `true` for one of these is taken
|
|
646
|
+
* for the CLI's record; every other id in settings.json is the user's.
|
|
647
|
+
* Recorded in the marker, so a killed run's next command applies the same
|
|
648
|
+
* set. Omitted, every profile's plugins count.
|
|
649
|
+
*/
|
|
650
|
+
installable?: readonly string[];
|
|
651
|
+
/**
|
|
652
|
+
* Runs after `work`, before the reconcile — for `magus update`, which must
|
|
653
|
+
* record the skill folders it re-fetched before they are judged edited.
|
|
654
|
+
*/
|
|
655
|
+
beforeReconcile?: () => Promise<unknown>;
|
|
656
|
+
/** Receives each line the reconcile prints (absorbed edits, problems). */
|
|
657
|
+
report?: (line: string) => void;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
* Run the Claude CLI's plugin installs while a profile is active, so their
|
|
662
|
+
* writes to the live settings.json are never taken for the user's edits — and
|
|
663
|
+
* the user's own edits in the same window are never lost to them.
|
|
664
|
+
*
|
|
665
|
+
* `claude plugin install --scope project` enables each plugin it installs in
|
|
666
|
+
* `.claude/settings.json`, for every profile's plugins when `magus install`
|
|
667
|
+
* runs without an argument. Before `work` starts the marker is flagged
|
|
668
|
+
* (`cliBookkeeping`). In `finally`, while the flag is still set, the live
|
|
669
|
+
* config is reconciled: anything Claude Code (or the user) wrote meanwhile —
|
|
670
|
+
* an "always allow" rule, a plugin of their own — is absorbed into the
|
|
671
|
+
* profile, and only the install's own records (a `true` for a plugin some
|
|
672
|
+
* profile names that the active one does not enable) are discarded. The
|
|
673
|
+
* reconcile renders the file and writes a fresh marker, which clears the flag.
|
|
674
|
+
*
|
|
675
|
+
* A run killed outright (Ctrl-C, a crash) leaves the flag set; the next
|
|
676
|
+
* command's reconcile does the same then, and says the run was interrupted.
|
|
677
|
+
* If the reconcile cannot read a live file, nothing is written and the flag
|
|
678
|
+
* stays for the next command. Without an active profile `work` just runs.
|
|
679
|
+
*/
|
|
680
|
+
export async function withCliBookkeeping<T>(
|
|
681
|
+
projectPath: string,
|
|
682
|
+
work: () => Promise<T>,
|
|
683
|
+
opts: CliBookkeepingOptions = {},
|
|
684
|
+
): Promise<T> {
|
|
685
|
+
const marker = (await hasCurrentMarker(projectPath))
|
|
686
|
+
? await readActiveMarker(projectPath)
|
|
687
|
+
: null;
|
|
688
|
+
if (!marker) return work();
|
|
689
|
+
await writeActiveMarker(
|
|
690
|
+
{
|
|
691
|
+
...marker,
|
|
692
|
+
cliBookkeeping: true,
|
|
693
|
+
...(opts.installable ? { cliInstallable: [...opts.installable] } : {}),
|
|
694
|
+
},
|
|
695
|
+
projectPath,
|
|
696
|
+
);
|
|
697
|
+
try {
|
|
698
|
+
return await work();
|
|
699
|
+
} finally {
|
|
700
|
+
await opts.beforeReconcile?.();
|
|
701
|
+
const reconciled = await reconcileActiveProfile(projectPath, {
|
|
702
|
+
cliRunFinished: true,
|
|
703
|
+
});
|
|
704
|
+
for (const line of reconciled.lines) opts.report?.(line);
|
|
705
|
+
}
|
|
706
|
+
}
|