magus-cli 7.5.0 → 7.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -4
- package/src/__tests__/profile-absorb.test.ts +72 -0
- package/src/__tests__/profile-dependencies.test.ts +442 -0
- package/src/__tests__/profile-manifest-write.test.ts +231 -0
- package/src/__tests__/profile-skills.test.ts +99 -0
- package/src/__tests__/profile-state.test.ts +328 -13
- package/src/__tests__/stale-load-repair.test.ts +265 -0
- package/src/__tests__/support/preload.ts +14 -0
- package/src/__tests__/update-apply.test.ts +342 -1
- package/src/cli/bootstrap.ts +4 -0
- package/src/cli/doctor.ts +39 -6
- package/src/cli/install.ts +68 -4
- package/src/cli/router.ts +7 -0
- package/src/cli/update.ts +3 -0
- package/src/services/claude-cli.ts +24 -0
- package/src/services/claude-settings.ts +45 -0
- package/src/services/git-worktree.ts +92 -9
- package/src/services/manifest.ts +220 -3
- package/src/services/plugin-dependencies.ts +48 -0
- package/src/services/plugin-requires.ts +137 -14
- package/src/services/profile-absorb.ts +69 -15
- package/src/services/profile-activate.ts +179 -33
- package/src/services/profile-live.ts +66 -7
- package/src/services/profile-render.ts +73 -6
- package/src/services/profile-skills.ts +32 -7
- package/src/services/profile-state.ts +89 -5
- package/src/services/profiles.ts +6 -5
- package/src/services/resolver.ts +96 -3
- package/src/services/tracked-live-files.ts +125 -0
- package/src/services/update-engine.ts +266 -8
- package/src/types/index.ts +19 -0
- package/src/ui/screens/PluginsScreen.tsx +11 -2
- package/src/ui/screens/ProfilesScreen.tsx +10 -3
|
@@ -49,7 +49,7 @@ import { createHash } from "node:crypto";
|
|
|
49
49
|
import type { Stats } from "node:fs";
|
|
50
50
|
import path from "node:path";
|
|
51
51
|
import fs from "fs-extra";
|
|
52
|
-
import type { ProfileManifestEntry } from "../types/index.js";
|
|
52
|
+
import type { DependencyReport, ProfileManifestEntry } from "../types/index.js";
|
|
53
53
|
import { readManifest } from "./manifest.js";
|
|
54
54
|
import { SettingsParseError } from "./settings-file.js";
|
|
55
55
|
|
|
@@ -174,6 +174,12 @@ export interface RenderedProfile {
|
|
|
174
174
|
* profile does not enable was written as `false` by the render itself.
|
|
175
175
|
*/
|
|
176
176
|
allPluginIds: string[];
|
|
177
|
+
/**
|
|
178
|
+
* What the profile's plugins depend on — derived ids (already enabled in
|
|
179
|
+
* `settings`), unreadable plugin.json files, and dependencies the profile
|
|
180
|
+
* turns off. Absent when there is nothing to say.
|
|
181
|
+
*/
|
|
182
|
+
dependencies?: DependencyReport;
|
|
177
183
|
}
|
|
178
184
|
|
|
179
185
|
/** The exact bytes magus writes for a rendered file. */
|
|
@@ -245,6 +251,20 @@ export interface ActiveMarker {
|
|
|
245
251
|
* enabledPlugins as that bookkeeping rather than as the user's edits.
|
|
246
252
|
*/
|
|
247
253
|
cliBookkeeping?: true;
|
|
254
|
+
/**
|
|
255
|
+
* The plugin ids the flagged run installs — only a `true` for one of these
|
|
256
|
+
* can be the CLI's record. `magus update` installs the active profile's
|
|
257
|
+
* plugins alone, so a plugin of another profile's that the user enabled
|
|
258
|
+
* during the run is theirs. Absent (an older flag), every profile's
|
|
259
|
+
* recorded ids (`allPluginIds`) stand in.
|
|
260
|
+
*/
|
|
261
|
+
cliInstallable?: string[];
|
|
262
|
+
/**
|
|
263
|
+
* The dependencies the last render enabled (`true`) though no profile entry
|
|
264
|
+
* names them. A live `false` for one of these is the user's own disable —
|
|
265
|
+
* the render never wrote it. Absent in a marker an older release wrote.
|
|
266
|
+
*/
|
|
267
|
+
derived?: string[];
|
|
248
268
|
/** sha256 of each live file exactly as magus last wrote or absorbed it. */
|
|
249
269
|
files: Partial<Record<LiveFileKey, string>>;
|
|
250
270
|
/** Magus-installed skill folders under `.claude/skills/`, with content hash. */
|
|
@@ -276,6 +296,8 @@ function parseMarker(raw: unknown): ActiveMarker | null {
|
|
|
276
296
|
skills,
|
|
277
297
|
allPluginIds,
|
|
278
298
|
cliBookkeeping,
|
|
299
|
+
cliInstallable,
|
|
300
|
+
derived,
|
|
279
301
|
} = raw;
|
|
280
302
|
if (!isSegment(profile)) return null;
|
|
281
303
|
const hashes: ActiveMarker["files"] = {};
|
|
@@ -308,6 +330,15 @@ function parseMarker(raw: unknown): ActiveMarker | null {
|
|
|
308
330
|
marker.allPluginIds = allPluginIds as string[];
|
|
309
331
|
}
|
|
310
332
|
if (cliBookkeeping === true) marker.cliBookkeeping = true;
|
|
333
|
+
if (
|
|
334
|
+
Array.isArray(cliInstallable) &&
|
|
335
|
+
cliInstallable.every((id) => typeof id === "string")
|
|
336
|
+
) {
|
|
337
|
+
marker.cliInstallable = cliInstallable as string[];
|
|
338
|
+
}
|
|
339
|
+
if (Array.isArray(derived) && derived.every((id) => typeof id === "string")) {
|
|
340
|
+
marker.derived = derived as string[];
|
|
341
|
+
}
|
|
311
342
|
return marker;
|
|
312
343
|
}
|
|
313
344
|
|
|
@@ -381,16 +412,21 @@ export async function writeActiveMarker(
|
|
|
381
412
|
*/
|
|
382
413
|
export function markerFor(
|
|
383
414
|
profile: string,
|
|
384
|
-
rendered: Pick<
|
|
415
|
+
rendered: Pick<
|
|
416
|
+
RenderedProfile,
|
|
417
|
+
"entry" | "definition" | "allPluginIds" | "dependencies"
|
|
418
|
+
>,
|
|
385
419
|
files: ActiveMarker["files"],
|
|
386
420
|
skills: Record<string, string>,
|
|
387
421
|
): ActiveMarker {
|
|
422
|
+
const derived = rendered.dependencies?.derived ?? [];
|
|
388
423
|
return {
|
|
389
424
|
version: 1,
|
|
390
425
|
profile,
|
|
391
426
|
definition: rendered.definition,
|
|
392
427
|
base: rendered.entry,
|
|
393
428
|
allPluginIds: rendered.allPluginIds,
|
|
429
|
+
...(derived.length > 0 ? { derived } : {}),
|
|
394
430
|
files,
|
|
395
431
|
skills,
|
|
396
432
|
};
|
|
@@ -557,6 +593,13 @@ export async function writeLiveFile(
|
|
|
557
593
|
key: LiveFileKey,
|
|
558
594
|
content: Record<string, unknown> | null,
|
|
559
595
|
projectPath?: string,
|
|
596
|
+
/**
|
|
597
|
+
* The machine-local settings keys to carry, when the caller read them
|
|
598
|
+
* before the live file moved — first adoption displaces it to a backup
|
|
599
|
+
* before this runs, and reading it here then would find nothing. Omitted,
|
|
600
|
+
* they are read from the live file as it is now.
|
|
601
|
+
*/
|
|
602
|
+
carry?: Record<string, unknown>,
|
|
560
603
|
): Promise<string | null> {
|
|
561
604
|
const file = livePath(key, projectPath);
|
|
562
605
|
// Backstop for the callers' own checks: never replace a regular file that
|
|
@@ -571,12 +614,11 @@ export async function writeLiveFile(
|
|
|
571
614
|
}
|
|
572
615
|
let body = content;
|
|
573
616
|
if (key === "settings") {
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
if (live) {
|
|
617
|
+
const keep = carry ?? (await machineLocalSettings(projectPath));
|
|
618
|
+
if (Object.keys(keep).length > 0) {
|
|
577
619
|
body = { ...content };
|
|
578
|
-
for (const k of
|
|
579
|
-
if (
|
|
620
|
+
for (const [k, value] of Object.entries(keep)) {
|
|
621
|
+
if (!(k in body)) body[k] = value;
|
|
580
622
|
}
|
|
581
623
|
}
|
|
582
624
|
}
|
|
@@ -585,6 +627,23 @@ export async function writeLiveFile(
|
|
|
585
627
|
return sha256(bytes);
|
|
586
628
|
}
|
|
587
629
|
|
|
630
|
+
/**
|
|
631
|
+
* The machine-local keys (see {@link MACHINE_LOCAL_SETTINGS_KEYS}) the live
|
|
632
|
+
* settings.json holds now — read through an older layout's link too. Empty
|
|
633
|
+
* when there is no readable file.
|
|
634
|
+
*/
|
|
635
|
+
export async function machineLocalSettings(
|
|
636
|
+
projectPath?: string,
|
|
637
|
+
): Promise<Record<string, unknown>> {
|
|
638
|
+
const live = (await readLiveThrough("settings", projectPath)).value;
|
|
639
|
+
const out: Record<string, unknown> = {};
|
|
640
|
+
if (!live) return out;
|
|
641
|
+
for (const k of MACHINE_LOCAL_SETTINGS_KEYS) {
|
|
642
|
+
if (k in live) out[k] = live[k];
|
|
643
|
+
}
|
|
644
|
+
return out;
|
|
645
|
+
}
|
|
646
|
+
|
|
588
647
|
/** Write all three live files of `rendered`; returns their hashes. */
|
|
589
648
|
export async function writeRenderedFiles(
|
|
590
649
|
rendered: RenderedProfile,
|
|
@@ -9,7 +9,15 @@
|
|
|
9
9
|
* No installs and no global state, so it is testable with a temp dir.
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
-
import type {
|
|
12
|
+
import type {
|
|
13
|
+
DependencyReport,
|
|
14
|
+
ProfileManifest,
|
|
15
|
+
ResolvedClosure,
|
|
16
|
+
} from "../types/index.js";
|
|
17
|
+
import {
|
|
18
|
+
type PluginDependencyReader,
|
|
19
|
+
readPluginDependencies,
|
|
20
|
+
} from "./plugin-dependencies.js";
|
|
13
21
|
import { type RenderedProfile, canonicalJson, sha256 } from "./profile-live.js";
|
|
14
22
|
import { resolveAllProfiles, resolveProfile } from "./resolver.js";
|
|
15
23
|
|
|
@@ -27,6 +35,12 @@ import { resolveAllProfiles, resolveProfile } from "./resolver.js";
|
|
|
27
35
|
* the id — an id a scope omits falls through to the next one, so a non-member
|
|
28
36
|
* enabled at user scope would otherwise stay enabled here. A plugin the
|
|
29
37
|
* profile itself declares `false` is written as `false` for the same reason.
|
|
38
|
+
*
|
|
39
|
+
* `closure.plugins` already holds every plugin an enabled one depends on, so
|
|
40
|
+
* a dependency is written `true` even when another profile lists it — Claude
|
|
41
|
+
* Code will not load a plugin whose dependency is disabled. The one exception
|
|
42
|
+
* is a dependency the profile itself turns off: its `false` stands, and
|
|
43
|
+
* {@link describeDependencies} says which plugin will not load.
|
|
30
44
|
*/
|
|
31
45
|
export function buildProfileSettings(
|
|
32
46
|
closure: ResolvedClosure,
|
|
@@ -54,11 +68,53 @@ export function buildProfileMcp(closure: ResolvedClosure): {
|
|
|
54
68
|
|
|
55
69
|
/**
|
|
56
70
|
* Rendering needs no binary requirements — nothing it writes reads
|
|
57
|
-
* `closure.bins` —
|
|
58
|
-
* the marketplace cache entirely.
|
|
71
|
+
* `closure.bins` — so no plugin's `requires.bin` is read.
|
|
59
72
|
*/
|
|
60
73
|
const noBins = async () => [];
|
|
61
74
|
|
|
75
|
+
/**
|
|
76
|
+
* One read per plugin per render: the render resolves this profile and then
|
|
77
|
+
* every profile, and most plugins appear in both.
|
|
78
|
+
*/
|
|
79
|
+
function oncePerPlugin(read: PluginDependencyReader): PluginDependencyReader {
|
|
80
|
+
const seen = new Map<string, Promise<string[] | null>>();
|
|
81
|
+
return (pluginId) => {
|
|
82
|
+
let pending = seen.get(pluginId);
|
|
83
|
+
if (!pending) {
|
|
84
|
+
pending = read(pluginId);
|
|
85
|
+
seen.set(pluginId, pending);
|
|
86
|
+
}
|
|
87
|
+
return pending;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What a profile's dependencies mean for the person running it, one line
|
|
93
|
+
* each: a plugin that will not load because the profile turns off something
|
|
94
|
+
* it depends on, and plugins whose plugin.json could not be read here.
|
|
95
|
+
*/
|
|
96
|
+
export function describeDependencies(
|
|
97
|
+
report: DependencyReport | undefined,
|
|
98
|
+
profile: string,
|
|
99
|
+
): string[] {
|
|
100
|
+
const lines: string[] = [];
|
|
101
|
+
for (const { plugin, dependency } of report?.blocked ?? []) {
|
|
102
|
+
lines.push(
|
|
103
|
+
`${plugin} will not load: it depends on ${dependency}, which profile "${profile}" turns off — remove "${dependency}": false from the profile to let it load`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const unreadable = report?.unreadable ?? [];
|
|
107
|
+
if (unreadable.length > 0) {
|
|
108
|
+
const one = unreadable.length === 1;
|
|
109
|
+
lines.push(
|
|
110
|
+
`Could not read the plugin.json of ${unreadable.join(", ")} on this machine, so nothing ${
|
|
111
|
+
one ? "it depends" : "they depend"
|
|
112
|
+
} on was enabled — run \`magus install\` to fetch ${one ? "it" : "them"}`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return lines;
|
|
116
|
+
}
|
|
117
|
+
|
|
62
118
|
/**
|
|
63
119
|
* The hash of everything a profile's live config is rendered from.
|
|
64
120
|
*
|
|
@@ -68,7 +124,9 @@ const noBins = async () => [];
|
|
|
68
124
|
* teammate adding a plugin to another profile changes this profile's
|
|
69
125
|
* settings.json. Hashing only the entry would leave that change unrendered
|
|
70
126
|
* until the next switch. Install-only fields (`bins`, `env`, cross-profile
|
|
71
|
-
* version conflicts) are left out: they change no live file.
|
|
127
|
+
* version conflicts) are left out: they change no live file. Dependencies are
|
|
128
|
+
* in: they are part of `closure.plugins`, so a plugin.json that starts or
|
|
129
|
+
* stops declaring one changes the definition and the live config regenerates.
|
|
72
130
|
*/
|
|
73
131
|
function renderDefinition(
|
|
74
132
|
closure: ResolvedClosure,
|
|
@@ -96,11 +154,19 @@ function renderDefinition(
|
|
|
96
154
|
export async function renderProfile(
|
|
97
155
|
manifest: ProfileManifest,
|
|
98
156
|
name: string,
|
|
157
|
+
/** Whose installed plugins' dependencies count (default: the current directory). */
|
|
158
|
+
projectPath?: string,
|
|
99
159
|
): Promise<RenderedProfile> {
|
|
100
160
|
const entry = manifest.profiles[name];
|
|
101
161
|
if (!entry) throw new Error(`Profile "${name}" not found in manifest`);
|
|
102
|
-
const
|
|
103
|
-
|
|
162
|
+
const opts = {
|
|
163
|
+
binResolver: noBins,
|
|
164
|
+
dependencyResolver: oncePerPlugin((id) =>
|
|
165
|
+
readPluginDependencies(id, projectPath),
|
|
166
|
+
),
|
|
167
|
+
};
|
|
168
|
+
const closure = await resolveProfile(manifest, name, opts);
|
|
169
|
+
const union = await resolveAllProfiles(manifest, opts);
|
|
104
170
|
const allPluginIds = Object.keys(union.plugins);
|
|
105
171
|
return {
|
|
106
172
|
settings: buildProfileSettings(closure, allPluginIds),
|
|
@@ -111,5 +177,6 @@ export async function renderProfile(
|
|
|
111
177
|
entry,
|
|
112
178
|
definition: renderDefinition(closure, allPluginIds),
|
|
113
179
|
allPluginIds: [...allPluginIds].sort(),
|
|
180
|
+
...(closure.dependencies ? { dependencies: closure.dependencies } : {}),
|
|
114
181
|
};
|
|
115
182
|
}
|
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
skillsRoot,
|
|
39
39
|
} from "./profile-live.js";
|
|
40
40
|
import { installSkill, skillFolderName } from "./skills-manager.js";
|
|
41
|
+
import { trackedPaths } from "./tracked-live-files.js";
|
|
41
42
|
|
|
42
43
|
/** Installs one profile skill into `.claude/skills/<folder>/`. */
|
|
43
44
|
export type SkillInstaller = (
|
|
@@ -124,11 +125,13 @@ export async function findSkillClashes(
|
|
|
124
125
|
skills: readonly ProfileSkillRef[],
|
|
125
126
|
managed: Readonly<Record<string, string>>,
|
|
126
127
|
projectPath: string,
|
|
128
|
+
/** Folders the team committed: the listed skill IS that copy, not a clash. */
|
|
129
|
+
committed: ReadonlySet<string> = new Set(),
|
|
127
130
|
): Promise<SkillClash[]> {
|
|
128
131
|
const clashes: SkillClash[] = [];
|
|
129
132
|
for (const ref of skills) {
|
|
130
133
|
const folder = skillFolder(ref);
|
|
131
|
-
if (folder in managed) continue;
|
|
134
|
+
if (folder in managed || committed.has(folder)) continue;
|
|
132
135
|
if (await exists(path.join(skillsRoot(projectPath), folder))) {
|
|
133
136
|
clashes.push({ skill: ref.name, folder });
|
|
134
137
|
}
|
|
@@ -162,6 +165,8 @@ export async function syncSkills(
|
|
|
162
165
|
managed: Readonly<Record<string, string>>,
|
|
163
166
|
projectPath: string,
|
|
164
167
|
install: SkillInstaller = installProfileSkill,
|
|
168
|
+
/** Folders the team committed: never installed into, even when missing. */
|
|
169
|
+
committed: ReadonlySet<string> = new Set(),
|
|
165
170
|
): Promise<SkillSyncResult> {
|
|
166
171
|
const root = skillsRoot(projectPath);
|
|
167
172
|
const wanted = new Map<string, ProfileSkillRef>();
|
|
@@ -197,6 +202,8 @@ export async function syncSkills(
|
|
|
197
202
|
// Present but not ours (or ours and since edited): never overwritten.
|
|
198
203
|
// Clashes are refused before this runs; this is the backstop.
|
|
199
204
|
if (await exists(dir)) continue;
|
|
205
|
+
// The team's committed copy, deleted in this checkout: git restores it.
|
|
206
|
+
if (recorded === undefined && committed.has(folder)) continue;
|
|
200
207
|
try {
|
|
201
208
|
await fs.ensureDir(root);
|
|
202
209
|
await install(ref, projectPath);
|
|
@@ -367,6 +374,24 @@ export async function convertLegacySkills(
|
|
|
367
374
|
);
|
|
368
375
|
}
|
|
369
376
|
|
|
377
|
+
const activeFolders =
|
|
378
|
+
linkedProfile !== null
|
|
379
|
+
? manifestSkillFolders(manifest, linkedProfile)
|
|
380
|
+
: new Set<string>();
|
|
381
|
+
// A folder the team committed under .claude/skills/ is the team's copy
|
|
382
|
+
// even when the profile lists it: it is restored as a custom skill, never
|
|
383
|
+
// recorded as magus-installed — which would make magus gitignore it, remove
|
|
384
|
+
// it on a switch, and tell the user to untrack a skill they committed.
|
|
385
|
+
// Asked before anything moves, so a git failure leaves the layout intact.
|
|
386
|
+
const skillsRel = path.relative(projectPath, root);
|
|
387
|
+
const committedDirs = await trackedPaths(
|
|
388
|
+
projectPath,
|
|
389
|
+
[],
|
|
390
|
+
[...activeFolders].map((folder) => path.join(skillsRel, folder)),
|
|
391
|
+
);
|
|
392
|
+
const committed = (folder: string) =>
|
|
393
|
+
committedDirs.has(path.join(skillsRel, folder));
|
|
394
|
+
|
|
370
395
|
// Where the link pointed: the skills the project was actually running.
|
|
371
396
|
let linkedDir: string | null = null;
|
|
372
397
|
if (linkedProfile !== null) {
|
|
@@ -416,14 +441,14 @@ export async function convertLegacySkills(
|
|
|
416
441
|
result.conflicts.push({ folder, kept: dest, other: inKeptLayout(src) });
|
|
417
442
|
};
|
|
418
443
|
|
|
419
|
-
const activeFolders =
|
|
420
|
-
linkedProfile !== null
|
|
421
|
-
? manifestSkillFolders(manifest, linkedProfile)
|
|
422
|
-
: new Set<string>();
|
|
423
|
-
|
|
424
444
|
if (linkedDir !== null) {
|
|
425
445
|
for (const folder of await subfolders(linkedDir)) {
|
|
426
|
-
await place(
|
|
446
|
+
await place(
|
|
447
|
+
linkedDir,
|
|
448
|
+
folder,
|
|
449
|
+
"move",
|
|
450
|
+
activeFolders.has(folder) && !committed(folder),
|
|
451
|
+
);
|
|
427
452
|
}
|
|
428
453
|
}
|
|
429
454
|
for (const dir of await backupSkillDirs(projectPath)) {
|
|
@@ -41,6 +41,10 @@ import {
|
|
|
41
41
|
type ProfileChoice,
|
|
42
42
|
activateProfile,
|
|
43
43
|
activationNotes,
|
|
44
|
+
activeWritableSkillFolders,
|
|
45
|
+
assertNoTrackedWriteTargets,
|
|
46
|
+
writableSkillFolders,
|
|
47
|
+
writeRefusal,
|
|
44
48
|
} from "./profile-activate.js";
|
|
45
49
|
import { adoptCurrentProject } from "./profile-adopt.js";
|
|
46
50
|
import {
|
|
@@ -69,6 +73,7 @@ import {
|
|
|
69
73
|
skillFolder,
|
|
70
74
|
} from "./profile-skills.js";
|
|
71
75
|
import type { SettingsParseError } from "./settings-file.js";
|
|
76
|
+
import type { LiveFileRefusal } from "./tracked-live-files.js";
|
|
72
77
|
|
|
73
78
|
export type ActiveProfileCheck =
|
|
74
79
|
| { state: "no-profiles" }
|
|
@@ -148,7 +153,7 @@ async function matchingProfiles(
|
|
|
148
153
|
const models = await readLiveJsonSafely("models", projectPath);
|
|
149
154
|
const matches: string[] = [];
|
|
150
155
|
for (const id of Object.keys(manifest.profiles)) {
|
|
151
|
-
const rendered = await renderProfile(manifest, id);
|
|
156
|
+
const rendered = await renderProfile(manifest, id, projectPath);
|
|
152
157
|
if (
|
|
153
158
|
jsonEqual(withoutMachineKeys(settings), rendered.settings) &&
|
|
154
159
|
jsonEqual(mcp, rendered.mcp) &&
|
|
@@ -184,7 +189,12 @@ async function restoreMarker(
|
|
|
184
189
|
if (hash) skills[folder] = hash;
|
|
185
190
|
}
|
|
186
191
|
await writeActiveMarker(
|
|
187
|
-
markerFor(
|
|
192
|
+
markerFor(
|
|
193
|
+
profile,
|
|
194
|
+
await renderProfile(manifest, profile, projectPath),
|
|
195
|
+
files,
|
|
196
|
+
skills,
|
|
197
|
+
),
|
|
188
198
|
projectPath,
|
|
189
199
|
);
|
|
190
200
|
}
|
|
@@ -294,6 +304,11 @@ export interface ProfileStatePrep {
|
|
|
294
304
|
problems: string[];
|
|
295
305
|
/** The files behind `problems` that could not be parsed. */
|
|
296
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;
|
|
297
312
|
}
|
|
298
313
|
|
|
299
314
|
/**
|
|
@@ -307,6 +322,27 @@ export async function prepareProfileState(
|
|
|
307
322
|
projectPath: string,
|
|
308
323
|
opts: Pick<ActivateOptions, "installSkill"> = {},
|
|
309
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
|
+
}
|
|
310
346
|
const check = await checkActiveProfile(projectPath);
|
|
311
347
|
const out: ProfileStatePrep = {
|
|
312
348
|
check,
|
|
@@ -415,7 +451,10 @@ export async function inspectProfileState(
|
|
|
415
451
|
);
|
|
416
452
|
}
|
|
417
453
|
const manifest = await readManifest(projectPath);
|
|
418
|
-
if (
|
|
454
|
+
if (
|
|
455
|
+
(await renderProfile(manifest, p, projectPath)).definition !==
|
|
456
|
+
marker.definition
|
|
457
|
+
) {
|
|
419
458
|
pending.push(
|
|
420
459
|
"profiles.json changed since the live config was generated — the next magus command regenerates it.",
|
|
421
460
|
);
|
|
@@ -478,12 +517,28 @@ export async function switchToProfile(
|
|
|
478
517
|
};
|
|
479
518
|
|
|
480
519
|
const before = await readManifest(projectPath);
|
|
481
|
-
|
|
520
|
+
const target = before.profiles[name];
|
|
521
|
+
if (!target) {
|
|
482
522
|
result.problems.push(
|
|
483
523
|
`Profile "${name}" not found. Available: ${Object.keys(before.profiles).join(", ")}`,
|
|
484
524
|
);
|
|
485
525
|
return result;
|
|
486
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
|
+
}
|
|
487
542
|
|
|
488
543
|
if (!take(await absorbBeforeConversion(projectPath, name))) return result;
|
|
489
544
|
const installSkill = { installSkill: opts.installSkill };
|
|
@@ -525,6 +580,20 @@ export async function applyProfileChoice(
|
|
|
525
580
|
projectPath: string,
|
|
526
581
|
opts: ActivateOptions = {},
|
|
527
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
|
+
);
|
|
528
597
|
if (choice.kind === "activate") {
|
|
529
598
|
const absorbed: string[] = [];
|
|
530
599
|
let changed = false;
|
|
@@ -566,11 +635,19 @@ export async function prepareProfileWrite(
|
|
|
566
635
|
projectPath: string,
|
|
567
636
|
): Promise<ProfileStatePrep> {
|
|
568
637
|
const prep = await prepareProfileState(projectPath);
|
|
638
|
+
if (prep.refusal) throw prep.refusal;
|
|
569
639
|
refuseOnProblems(prep);
|
|
570
640
|
return prep;
|
|
571
641
|
}
|
|
572
642
|
|
|
573
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[];
|
|
574
651
|
/**
|
|
575
652
|
* Runs after `work`, before the reconcile — for `magus update`, which must
|
|
576
653
|
* record the skill folders it re-fetched before they are judged edited.
|
|
@@ -609,7 +686,14 @@ export async function withCliBookkeeping<T>(
|
|
|
609
686
|
? await readActiveMarker(projectPath)
|
|
610
687
|
: null;
|
|
611
688
|
if (!marker) return work();
|
|
612
|
-
await writeActiveMarker(
|
|
689
|
+
await writeActiveMarker(
|
|
690
|
+
{
|
|
691
|
+
...marker,
|
|
692
|
+
cliBookkeeping: true,
|
|
693
|
+
...(opts.installable ? { cliInstallable: [...opts.installable] } : {}),
|
|
694
|
+
},
|
|
695
|
+
projectPath,
|
|
696
|
+
);
|
|
613
697
|
try {
|
|
614
698
|
return await work();
|
|
615
699
|
} finally {
|
package/src/services/profiles.ts
CHANGED
|
@@ -2,7 +2,7 @@ import os from "node:os";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import fs from "fs-extra";
|
|
4
4
|
import type { ProfileEntry, ProfileManifest } from "../types/index.js";
|
|
5
|
-
import { readProfilesFile } from "./manifest.js";
|
|
5
|
+
import { readProfilesFile, writeJsonMinimal } from "./manifest.js";
|
|
6
6
|
import { activeProfile } from "./profile-live.js";
|
|
7
7
|
|
|
8
8
|
const PROFILES_FILE = "profiles.json";
|
|
@@ -64,10 +64,11 @@ async function writeProfiles(
|
|
|
64
64
|
projectPath?: string,
|
|
65
65
|
): Promise<void> {
|
|
66
66
|
const filePath = profilesPath(scope, projectPath);
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
await
|
|
67
|
+
// The refusal, and the file as read: only what the caller changed is
|
|
68
|
+
// written, every other byte kept. Diffed against the file's own `version`,
|
|
69
|
+
// so a v1 file is recorded as v2.
|
|
70
|
+
const current = await readProfilesFile(filePath);
|
|
71
|
+
await writeJsonMinimal(filePath, data, current);
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
// ─── CRUD ──────────────────────────────────────────────────────────────────────
|