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
|
@@ -61,6 +61,8 @@ import {
|
|
|
61
61
|
type ActivateOptions,
|
|
62
62
|
activateProfile,
|
|
63
63
|
activationNotes,
|
|
64
|
+
activeWritableSkillFolders,
|
|
65
|
+
writeRefusal,
|
|
64
66
|
} from "./profile-activate.js";
|
|
65
67
|
import {
|
|
66
68
|
type ActiveMarker,
|
|
@@ -169,12 +171,22 @@ function setOrDelete<K extends "plugins" | "settings" | "mcpServers">(
|
|
|
169
171
|
* `otherPluginIds` are the plugins other profiles enable — rendering writes
|
|
170
172
|
* those as `false` in this profile on its own, so a `false` for one of them
|
|
171
173
|
* says nothing new.
|
|
174
|
+
*
|
|
175
|
+
* `derived` are the plugins the render enables because one of the entry's
|
|
176
|
+
* plugins depends on them. A `true` for one equals the render, so it is not
|
|
177
|
+
* an edit and never becomes a member: dependencies are derived, not recorded.
|
|
178
|
+
* Only a `false` the render could not have written — for a dependency no
|
|
179
|
+
* other profile names — is the user turning it off, and is kept as `false`.
|
|
180
|
+
*
|
|
181
|
+
* `updatedAt` is not touched here or anywhere absorption writes: the file is
|
|
182
|
+
* committed, and a timestamp that changes with every absorbed edit is a line
|
|
183
|
+
* in every diff and a conflict between every two teammates.
|
|
172
184
|
*/
|
|
173
185
|
export function computeAbsorb(
|
|
174
186
|
entry: ProfileManifestEntry,
|
|
175
187
|
live: LiveView,
|
|
176
188
|
otherPluginIds: ReadonlySet<string>,
|
|
177
|
-
|
|
189
|
+
derived: ReadonlySet<string> = new Set(),
|
|
178
190
|
): { entry: ProfileManifestEntry; changes: AbsorbChange[] } {
|
|
179
191
|
const changes: AbsorbChange[] = [];
|
|
180
192
|
const next: ProfileManifestEntry = { ...entry };
|
|
@@ -195,6 +207,13 @@ export function computeAbsorb(
|
|
|
195
207
|
const liveValue = enabled[id];
|
|
196
208
|
const on = isOn(resolved.plugins?.[id]);
|
|
197
209
|
const inheritedOn = isOn(inherited.plugins?.[id]);
|
|
210
|
+
if (derived.has(id) && !on) {
|
|
211
|
+
if (liveValue === false && !otherPluginIds.has(id)) {
|
|
212
|
+
own[id] = false;
|
|
213
|
+
changes.push({ kind: "plugin-disabled", plugin: id });
|
|
214
|
+
}
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
198
217
|
if (liveValue === true && !on) {
|
|
199
218
|
// An own `false` over an inherited plugin is what kept it off.
|
|
200
219
|
if (own[id] === false && inheritedOn) delete own[id];
|
|
@@ -259,7 +278,6 @@ export function computeAbsorb(
|
|
|
259
278
|
changes.push({ kind: "models-changed" });
|
|
260
279
|
}
|
|
261
280
|
|
|
262
|
-
if (changes.length > 0) next.updatedAt = now;
|
|
263
281
|
return { entry: next, changes };
|
|
264
282
|
}
|
|
265
283
|
|
|
@@ -539,9 +557,6 @@ export function merge3(
|
|
|
539
557
|
);
|
|
540
558
|
put("skills", kept.length > 0 ? kept : undefined);
|
|
541
559
|
}
|
|
542
|
-
if (!jsonEqual(local, base) && local.updatedAt) {
|
|
543
|
-
out.updatedAt = local.updatedAt;
|
|
544
|
-
}
|
|
545
560
|
return { entry: out, conflicts };
|
|
546
561
|
}
|
|
547
562
|
|
|
@@ -609,17 +624,22 @@ export function refuseOnProblems(result: {
|
|
|
609
624
|
* rendered (`marker.allPluginIds`) that `base` does not enable. The recorded
|
|
610
625
|
* ids matter when a teammate removes a plugin from every profile — the live
|
|
611
626
|
* `false` for it is left over from the old render, not a deliberate disable.
|
|
627
|
+
* A dependency the last render enabled (`renderedTrue`) is never one: it was
|
|
628
|
+
* written `true`, so a `false` for it is the user's, whoever else uses it.
|
|
612
629
|
*/
|
|
613
630
|
function renderedFalseIds(
|
|
614
631
|
others: ReadonlySet<string>,
|
|
615
632
|
recorded: readonly string[] | undefined,
|
|
616
633
|
base: ProfileManifestEntry,
|
|
634
|
+
/** Dependencies the last render wrote `true` (`marker.derived`). */
|
|
635
|
+
renderedTrue: readonly string[] = [],
|
|
617
636
|
): Set<string> {
|
|
618
637
|
const out = new Set(others);
|
|
619
638
|
const enabled = resolveExtends(base).plugins ?? {};
|
|
620
639
|
for (const id of recorded ?? []) {
|
|
621
640
|
if (!isOn(enabled[id])) out.add(id);
|
|
622
641
|
}
|
|
642
|
+
for (const id of renderedTrue) out.delete(id);
|
|
623
643
|
return out;
|
|
624
644
|
}
|
|
625
645
|
|
|
@@ -829,6 +849,7 @@ export async function absorbOrphanedProfile(
|
|
|
829
849
|
? await renderProfile(
|
|
830
850
|
{ ...manifest, profiles: { ...manifest.profiles, [orphan]: base } },
|
|
831
851
|
orphan,
|
|
852
|
+
projectPath,
|
|
832
853
|
)
|
|
833
854
|
: null;
|
|
834
855
|
const live: Record<LiveFileKey, Record<string, unknown> | null> = {
|
|
@@ -872,7 +893,12 @@ export async function absorbOrphanedProfile(
|
|
|
872
893
|
|
|
873
894
|
let merged: ProfileManifestEntry;
|
|
874
895
|
if (base) {
|
|
875
|
-
const local = computeAbsorb(
|
|
896
|
+
const local = computeAbsorb(
|
|
897
|
+
base,
|
|
898
|
+
view,
|
|
899
|
+
union,
|
|
900
|
+
new Set(renderedBase?.dependencies?.derived ?? []),
|
|
901
|
+
).entry;
|
|
876
902
|
const merge = merge3(base, local, targetEntry);
|
|
877
903
|
merged = merge.entry;
|
|
878
904
|
result.conflicts.push(...merge.conflicts);
|
|
@@ -889,7 +915,6 @@ export async function absorbOrphanedProfile(
|
|
|
889
915
|
|
|
890
916
|
const changes = diffEntries(targetEntry, merged);
|
|
891
917
|
if (changes.length > 0) {
|
|
892
|
-
merged.updatedAt = new Date().toISOString();
|
|
893
918
|
await writeManifest(
|
|
894
919
|
{ ...manifest, profiles: { ...manifest.profiles, [target]: merged } },
|
|
895
920
|
projectPath,
|
|
@@ -970,7 +995,7 @@ export async function absorbOlderLayout(
|
|
|
970
995
|
if (!entry) return emptyResult(null);
|
|
971
996
|
const result = emptyResult(into);
|
|
972
997
|
const legacyHashes = await readLegacyMarkerHashes(projectPath);
|
|
973
|
-
const rendered = await renderProfile(manifest, into);
|
|
998
|
+
const rendered = await renderProfile(manifest, into, projectPath);
|
|
974
999
|
const view: Record<LiveFileKey, Record<string, unknown> | null> = {
|
|
975
1000
|
settings: rendered.settings,
|
|
976
1001
|
mcp: rendered.mcp,
|
|
@@ -1019,11 +1044,11 @@ export async function absorbOlderLayout(
|
|
|
1019
1044
|
models: view.models,
|
|
1020
1045
|
},
|
|
1021
1046
|
await olderRenderIds(projectPath, named, rendered.allPluginIds),
|
|
1047
|
+
new Set(rendered.dependencies?.derived ?? []),
|
|
1022
1048
|
);
|
|
1023
1049
|
const next = absorbed.entry;
|
|
1024
1050
|
const changes = diffEntries(entry, next);
|
|
1025
1051
|
if (changes.length > 0) {
|
|
1026
|
-
next.updatedAt = new Date().toISOString();
|
|
1027
1052
|
await writeManifest(
|
|
1028
1053
|
{ ...manifest, profiles: { ...manifest.profiles, [into]: next } },
|
|
1029
1054
|
projectPath,
|
|
@@ -1150,7 +1175,8 @@ function addMissing(
|
|
|
1150
1175
|
* {@link absorbOlderLayout} for why nothing is removed.
|
|
1151
1176
|
*
|
|
1152
1177
|
* plugins a plugin the old file enables and the entry does not name is
|
|
1153
|
-
* added ("latest")
|
|
1178
|
+
* added ("latest") — unless it is in `derived`, a dependency
|
|
1179
|
+
* the render enables anyway; a `false` for a plugin the entry does not
|
|
1154
1180
|
* name is skipped when `renderIds` holds it (the render wrote
|
|
1155
1181
|
* it) and added as `false` when it does not (the user wrote
|
|
1156
1182
|
* it); where the old file and the entry disagree — one
|
|
@@ -1166,6 +1192,7 @@ export function absorbAdditively(
|
|
|
1166
1192
|
entry: ProfileManifestEntry,
|
|
1167
1193
|
old: LiveView,
|
|
1168
1194
|
renderIds: ReadonlySet<string>,
|
|
1195
|
+
derived: ReadonlySet<string> = new Set(),
|
|
1169
1196
|
): { entry: ProfileManifestEntry; conflicts: string[]; keptMore: boolean } {
|
|
1170
1197
|
const conflicts: string[] = [];
|
|
1171
1198
|
const flags = { keptMore: false };
|
|
@@ -1180,7 +1207,9 @@ export function absorbAdditively(
|
|
|
1180
1207
|
for (const [id, value] of Object.entries(enabled)) {
|
|
1181
1208
|
const declared = resolved.plugins?.[id];
|
|
1182
1209
|
if (value === true) {
|
|
1183
|
-
if (isOn(declared))
|
|
1210
|
+
if (isOn(declared) || (derived.has(id) && declared === undefined)) {
|
|
1211
|
+
continue;
|
|
1212
|
+
}
|
|
1184
1213
|
if (declared === false) conflicts.push(`plugins.${id}`);
|
|
1185
1214
|
else own[id] = "latest";
|
|
1186
1215
|
} else if (value === false && isOn(declared)) {
|
|
@@ -1300,7 +1329,8 @@ export async function reconcileActiveProfile(
|
|
|
1300
1329
|
// teammate's change to ANOTHER profile's plugins changes which `false`
|
|
1301
1330
|
// entries this profile writes.
|
|
1302
1331
|
const definitionChanged =
|
|
1303
|
-
(await renderProfile(manifest, name)).definition !==
|
|
1332
|
+
(await renderProfile(manifest, name, projectPath)).definition !==
|
|
1333
|
+
marker.definition;
|
|
1304
1334
|
// Whether THIS entry changed underneath the live files — the case that
|
|
1305
1335
|
// needs merging against `base`, and the one the message names.
|
|
1306
1336
|
const entryChanged =
|
|
@@ -1318,11 +1348,25 @@ export async function reconcileActiveProfile(
|
|
|
1318
1348
|
|
|
1319
1349
|
const result = emptyResult(name);
|
|
1320
1350
|
|
|
1351
|
+
// Every write below lands in a live file, a skill folder the profile
|
|
1352
|
+
// installs (or the manifest, on the way to one). None happens when git
|
|
1353
|
+
// tracks one of them, or cannot say: see `tracked-live-files.ts`.
|
|
1354
|
+
const refusal = await writeRefusal(
|
|
1355
|
+
projectPath,
|
|
1356
|
+
await activeWritableSkillFolders(projectPath, manifest),
|
|
1357
|
+
);
|
|
1358
|
+
if (refusal) {
|
|
1359
|
+
result.problems.push(refusal.message);
|
|
1360
|
+
result.lines.push(refusal.message);
|
|
1361
|
+
return result;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1321
1364
|
// ── a. absorb ────────────────────────────────────────────────────────────
|
|
1322
1365
|
const base = marker.base ?? current;
|
|
1323
1366
|
const renderedBase = await renderProfile(
|
|
1324
1367
|
{ ...manifest, profiles: { ...manifest.profiles, [name]: base } },
|
|
1325
1368
|
name,
|
|
1369
|
+
projectPath,
|
|
1326
1370
|
);
|
|
1327
1371
|
const live: Record<LiveFileKey, Record<string, unknown> | null> = {
|
|
1328
1372
|
settings: renderedBase.settings,
|
|
@@ -1345,7 +1389,11 @@ export async function reconcileActiveProfile(
|
|
|
1345
1389
|
if (key === "settings" && marker.cliBookkeeping) {
|
|
1346
1390
|
value = withoutCliRecords(
|
|
1347
1391
|
value,
|
|
1348
|
-
new Set(
|
|
1392
|
+
new Set(
|
|
1393
|
+
marker.cliInstallable ??
|
|
1394
|
+
marker.allPluginIds ??
|
|
1395
|
+
(await allPluginIdsOf(manifest)),
|
|
1396
|
+
),
|
|
1349
1397
|
base,
|
|
1350
1398
|
renderedBase,
|
|
1351
1399
|
discarded,
|
|
@@ -1390,7 +1438,9 @@ export async function reconcileActiveProfile(
|
|
|
1390
1438
|
await otherProfilesPlugins(manifest, name),
|
|
1391
1439
|
marker.allPluginIds,
|
|
1392
1440
|
base,
|
|
1441
|
+
marker.derived,
|
|
1393
1442
|
),
|
|
1443
|
+
new Set(renderedBase.dependencies?.derived ?? []),
|
|
1394
1444
|
);
|
|
1395
1445
|
local = absorbed.entry;
|
|
1396
1446
|
result.changes.push(...absorbed.changes);
|
|
@@ -1408,7 +1458,7 @@ export async function reconcileActiveProfile(
|
|
|
1408
1458
|
});
|
|
1409
1459
|
}
|
|
1410
1460
|
const kept = refs.filter((r) => !released.has(skillFolder(r)));
|
|
1411
|
-
local = { ...local
|
|
1461
|
+
local = { ...local };
|
|
1412
1462
|
if (kept.length > 0) local.skills = kept;
|
|
1413
1463
|
// biome-ignore lint/performance/noDelete: absent, not undefined, in profiles.json
|
|
1414
1464
|
else delete local.skills;
|
|
@@ -1473,7 +1523,11 @@ export async function reconcileActiveProfile(
|
|
|
1473
1523
|
}
|
|
1474
1524
|
}
|
|
1475
1525
|
} else {
|
|
1476
|
-
const rendered: RenderedProfile = await renderProfile(
|
|
1526
|
+
const rendered: RenderedProfile = await renderProfile(
|
|
1527
|
+
nextManifest,
|
|
1528
|
+
name,
|
|
1529
|
+
projectPath,
|
|
1530
|
+
);
|
|
1477
1531
|
const files: ActiveMarker["files"] = { ...marker.files };
|
|
1478
1532
|
for (const key of LIVE_FILE_KEYS) {
|
|
1479
1533
|
if (missing.includes(key)) {
|
|
@@ -28,9 +28,12 @@
|
|
|
28
28
|
|
|
29
29
|
import { lstat } from "node:fs/promises";
|
|
30
30
|
import path from "node:path";
|
|
31
|
-
import type {
|
|
31
|
+
import type {
|
|
32
|
+
DependencyReport,
|
|
33
|
+
ProfileManifest,
|
|
34
|
+
ProfileSkillRef,
|
|
35
|
+
} from "../types/index.js";
|
|
32
36
|
import { updateManagedGitignoreBlock } from "./conventions-manager.js";
|
|
33
|
-
import { isTrackedByGit } from "./git-worktree.js";
|
|
34
37
|
import { readManifest } from "./manifest.js";
|
|
35
38
|
import {
|
|
36
39
|
type ActiveMarker,
|
|
@@ -48,6 +51,7 @@ import {
|
|
|
48
51
|
hasCurrentMarker,
|
|
49
52
|
liveKind,
|
|
50
53
|
livePath,
|
|
54
|
+
machineLocalSettings,
|
|
51
55
|
markerFor,
|
|
52
56
|
oldLayoutProfile,
|
|
53
57
|
readActiveMarker,
|
|
@@ -56,7 +60,7 @@ import {
|
|
|
56
60
|
writeLiveFile,
|
|
57
61
|
writeRenderedFiles,
|
|
58
62
|
} from "./profile-live.js";
|
|
59
|
-
import { renderProfile } from "./profile-render.js";
|
|
63
|
+
import { describeDependencies, renderProfile } from "./profile-render.js";
|
|
60
64
|
import {
|
|
61
65
|
type LegacySkillConversion,
|
|
62
66
|
SkillClashError,
|
|
@@ -70,6 +74,12 @@ import {
|
|
|
70
74
|
syncSkills,
|
|
71
75
|
updateSkillIgnoreLines,
|
|
72
76
|
} from "./profile-skills.js";
|
|
77
|
+
import {
|
|
78
|
+
LiveFileRefusal,
|
|
79
|
+
TrackedLiveFileError,
|
|
80
|
+
trackedPaths,
|
|
81
|
+
untrackCommand,
|
|
82
|
+
} from "./tracked-live-files.js";
|
|
73
83
|
import type { ApplyReporter } from "./update-engine.js";
|
|
74
84
|
|
|
75
85
|
export interface ActivationResult {
|
|
@@ -87,6 +97,10 @@ export interface ActivationResult {
|
|
|
87
97
|
keptAt: string | null;
|
|
88
98
|
/** Unsaved changes added to a profile before activating, one line each. */
|
|
89
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[];
|
|
90
104
|
}
|
|
91
105
|
|
|
92
106
|
export interface ActivateOptions {
|
|
@@ -109,37 +123,133 @@ export function reportDisplaced(
|
|
|
109
123
|
}
|
|
110
124
|
|
|
111
125
|
/**
|
|
112
|
-
* Generated paths that git tracks
|
|
113
|
-
*
|
|
114
|
-
*
|
|
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.
|
|
115
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. */
|
|
116
171
|
export async function trackedGeneratedFiles(
|
|
117
172
|
projectPath: string,
|
|
118
173
|
): Promise<string[]> {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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);
|
|
134
198
|
}
|
|
135
|
-
if (await isTrackedByGit(projectPath, rel)) out.push(rel);
|
|
136
199
|
}
|
|
137
|
-
|
|
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
|
+
);
|
|
138
223
|
}
|
|
139
224
|
|
|
140
|
-
/**
|
|
141
|
-
|
|
142
|
-
|
|
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);
|
|
143
253
|
}
|
|
144
254
|
|
|
145
255
|
/**
|
|
@@ -147,7 +257,10 @@ export function untrackCommand(rel: string): string {
|
|
|
147
257
|
* both the terminal and the TUI's outcome modal.
|
|
148
258
|
*/
|
|
149
259
|
export function activationNotes(result: ActivationResult): string[] {
|
|
150
|
-
const lines: string[] = [
|
|
260
|
+
const lines: string[] = [
|
|
261
|
+
...(result.absorbed ?? []),
|
|
262
|
+
...describeDependencies(result.dependencies, result.profileId),
|
|
263
|
+
];
|
|
151
264
|
for (const folder of result.conversion.restored) {
|
|
152
265
|
lines.push(
|
|
153
266
|
`Restored custom skill .claude/skills/${folder} (the older profile layout had moved it)`,
|
|
@@ -161,6 +274,11 @@ export function activationNotes(result: ActivationResult): string[] {
|
|
|
161
274
|
for (const f of result.skills.failed) {
|
|
162
275
|
lines.push(`Skill "${f.skill}" could not be installed: ${f.reason}`);
|
|
163
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
|
+
}
|
|
164
282
|
for (const rel of result.tracked) {
|
|
165
283
|
lines.push(
|
|
166
284
|
`${rel} is generated but tracked by git — run \`${untrackCommand(rel)}\`, then commit`,
|
|
@@ -177,8 +295,10 @@ export function activationNotes(result: ActivationResult): string[] {
|
|
|
177
295
|
/**
|
|
178
296
|
* Render `profileId` from `manifest` and make it the active profile.
|
|
179
297
|
*
|
|
180
|
-
* Throws
|
|
181
|
-
*
|
|
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
|
|
182
302
|
* changed nothing but moving an older layout's skills into `.claude/skills/` —
|
|
183
303
|
* when a listed skill's folder is already a custom skill.
|
|
184
304
|
*/
|
|
@@ -189,12 +309,17 @@ export async function activateProfile(
|
|
|
189
309
|
opts: ActivateOptions = {},
|
|
190
310
|
): Promise<ActivationResult> {
|
|
191
311
|
const onDisplace = opts.onDisplace ?? reportDisplaced(projectPath);
|
|
192
|
-
const rendered = await renderProfile(manifest, profileId);
|
|
312
|
+
const rendered = await renderProfile(manifest, profileId, projectPath);
|
|
193
313
|
const skillRefs = rendered.entry.skills ?? [];
|
|
194
314
|
|
|
195
315
|
// Read before converting: the older marker lives in the directory the
|
|
196
316
|
// conversion moves away, and an older layout is recognised by its links.
|
|
197
317
|
const previous = await readActiveMarker(projectPath);
|
|
318
|
+
// Before anything else is touched — the conversion below writes too.
|
|
319
|
+
await assertNoTrackedWriteTargets(
|
|
320
|
+
projectPath,
|
|
321
|
+
await writableSkillFolders(projectPath, skillRefs, previous?.skills),
|
|
322
|
+
);
|
|
198
323
|
const wasOldLayout = (await oldLayoutProfile(projectPath)) !== null;
|
|
199
324
|
// A managed project's live files are the profile's; one that does not
|
|
200
325
|
// parse is somebody mid-edit, and replacing it would lose their work. (At
|
|
@@ -206,7 +331,18 @@ export async function activateProfile(
|
|
|
206
331
|
...conversion.managed,
|
|
207
332
|
};
|
|
208
333
|
|
|
209
|
-
|
|
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
|
+
);
|
|
210
346
|
if (clashes.length > 0) throw new SkillClashError(profileId, clashes);
|
|
211
347
|
|
|
212
348
|
await updateManagedGitignoreBlock(
|
|
@@ -224,6 +360,10 @@ export async function activateProfile(
|
|
|
224
360
|
const backupDir = path.join(backupRoot(projectPath), backupStamp());
|
|
225
361
|
const displaced: DisplacedArtifact[] = [];
|
|
226
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);
|
|
227
367
|
for (const key of LIVE_FILE_KEYS) {
|
|
228
368
|
// First adoption only: nothing marks this project as magus's and it is
|
|
229
369
|
// not an older magus layout, so a real file here is somebody's.
|
|
@@ -238,7 +378,7 @@ export async function activateProfile(
|
|
|
238
378
|
onDisplace(moved);
|
|
239
379
|
}
|
|
240
380
|
}
|
|
241
|
-
const hash = await writeLiveFile(key, rendered[key], projectPath);
|
|
381
|
+
const hash = await writeLiveFile(key, rendered[key], projectPath, carried);
|
|
242
382
|
if (hash) files[key] = hash;
|
|
243
383
|
// Recorded after EACH file, so a throw on the next cannot leave a file
|
|
244
384
|
// magus just wrote looking like an edit to absorb.
|
|
@@ -253,6 +393,7 @@ export async function activateProfile(
|
|
|
253
393
|
managedBefore,
|
|
254
394
|
projectPath,
|
|
255
395
|
opts.installSkill ?? installProfileSkill,
|
|
396
|
+
committedSkills,
|
|
256
397
|
);
|
|
257
398
|
await updateSkillIgnoreLines(
|
|
258
399
|
projectPath,
|
|
@@ -275,6 +416,10 @@ export async function activateProfile(
|
|
|
275
416
|
keptAt: conversion.keptAt
|
|
276
417
|
? path.relative(projectPath, conversion.keptAt)
|
|
277
418
|
: null,
|
|
419
|
+
...(rendered.dependencies ? { dependencies: rendered.dependencies } : {}),
|
|
420
|
+
...(committedSkills.size > 0
|
|
421
|
+
? { committedSkills: [...committedSkills].sort() }
|
|
422
|
+
: {}),
|
|
278
423
|
};
|
|
279
424
|
}
|
|
280
425
|
|
|
@@ -300,8 +445,9 @@ export async function regenerateLiveFiles(
|
|
|
300
445
|
const marker = await readActiveMarker(projectPath);
|
|
301
446
|
const manifest = await readManifest(projectPath);
|
|
302
447
|
if (!marker || !manifest.profiles[marker.profile]) return false;
|
|
448
|
+
await assertNoTrackedWriteTargets(projectPath);
|
|
303
449
|
await assertLiveFilesParse(projectPath);
|
|
304
|
-
const rendered = await renderProfile(manifest, marker.profile);
|
|
450
|
+
const rendered = await renderProfile(manifest, marker.profile, projectPath);
|
|
305
451
|
const files = await writeRenderedFiles(rendered, projectPath);
|
|
306
452
|
await writeActiveMarker(
|
|
307
453
|
markerFor(marker.profile, rendered, files, marker.skills),
|