magus-cli 7.5.1 → 7.5.3

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.
@@ -61,8 +61,7 @@ import {
61
61
  type ActivateOptions,
62
62
  activateProfile,
63
63
  activationNotes,
64
- activeWritableSkillFolders,
65
- writeRefusal,
64
+ releasedCommittedSkillNote,
66
65
  } from "./profile-activate.js";
67
66
  import {
68
67
  type ActiveMarker,
@@ -94,6 +93,7 @@ import {
94
93
  } from "./profile-skills.js";
95
94
  import { resolveExtends, resolveProfile } from "./resolver.js";
96
95
  import { SettingsParseError } from "./settings-file.js";
96
+ import { trackedSkillFolders } from "./tracked-live-files.js";
97
97
 
98
98
  // ─── Changes ─────────────────────────────────────────────────────────────────
99
99
 
@@ -1302,13 +1302,39 @@ export async function reconcileActiveProfile(
1302
1302
  opts: ReconcileOptions = {},
1303
1303
  ): Promise<ReconcileResult> {
1304
1304
  if (!(await hasCurrentMarker(projectPath))) return emptyResult(null);
1305
- const marker = await readActiveMarker(projectPath);
1305
+ let marker = await readActiveMarker(projectPath);
1306
1306
  if (!marker) return emptyResult(null);
1307
1307
  const manifest = await readManifest(projectPath);
1308
1308
  const current = manifest.profiles[marker.profile];
1309
1309
  if (!current) return emptyResult(null);
1310
1310
  const name = marker.profile;
1311
1311
 
1312
+ // A recorded skill folder git tracks is the team's copy: 7.5.0's conversion
1313
+ // recorded committed skills as magus-installed. Handed back — dropped from
1314
+ // the marker, and from magus's ignore lines — before anything below could
1315
+ // treat it as magus's to sync or remove, and said once.
1316
+ const handedBackLines: string[] = [];
1317
+ const handedBack = await trackedSkillFolders(
1318
+ projectPath,
1319
+ Object.keys(marker.skills),
1320
+ );
1321
+ if (handedBack.size > 0) {
1322
+ const skills: Record<string, string> = {};
1323
+ for (const [folder, hash] of Object.entries(marker.skills)) {
1324
+ if (!handedBack.has(folder)) skills[folder] = hash;
1325
+ }
1326
+ await updateSkillIgnoreLines(
1327
+ projectPath,
1328
+ [...handedBack],
1329
+ Object.keys(skills),
1330
+ );
1331
+ marker = { ...marker, skills };
1332
+ await writeActiveMarker(marker, projectPath);
1333
+ handedBackLines.push(
1334
+ ...[...handedBack].sort().map(releasedCommittedSkillNote),
1335
+ );
1336
+ }
1337
+
1312
1338
  const edited: LiveFileKey[] = [];
1313
1339
  const missing: LiveFileKey[] = [];
1314
1340
  for (const key of LIVE_FILE_KEYS) {
@@ -1343,23 +1369,11 @@ export async function reconcileActiveProfile(
1343
1369
  !definitionChanged &&
1344
1370
  !marker.cliBookkeeping
1345
1371
  ) {
1346
- return emptyResult(name);
1372
+ return { ...emptyResult(name), lines: handedBackLines };
1347
1373
  }
1348
1374
 
1349
1375
  const result = emptyResult(name);
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
- }
1376
+ result.lines.push(...handedBackLines);
1363
1377
 
1364
1378
  // ── a. absorb ────────────────────────────────────────────────────────────
1365
1379
  const base = marker.base ?? current;
@@ -75,10 +75,10 @@ import {
75
75
  updateSkillIgnoreLines,
76
76
  } from "./profile-skills.js";
77
77
  import {
78
- LiveFileRefusal,
79
- TrackedLiveFileError,
78
+ GitCheckError,
80
79
  trackedPaths,
81
- untrackCommand,
80
+ trackedSkillFolders,
81
+ trackedWarning,
82
82
  } from "./tracked-live-files.js";
83
83
  import type { ApplyReporter } from "./update-engine.js";
84
84
 
@@ -91,8 +91,12 @@ export interface ActivationResult {
91
91
  skills: SkillSyncResult;
92
92
  /** What converting an older layout did, if there was one. */
93
93
  conversion: LegacySkillConversion;
94
- /** Generated paths git still tracks (see {@link trackedGeneratedFiles}). */
95
- tracked: string[];
94
+ /**
95
+ * Generated paths git still tracks (see {@link trackedGeneratedFiles}) —
96
+ * written all the same, and each warned about. A string instead when git
97
+ * could not say (`GitCheckError`).
98
+ */
99
+ tracked: string[] | string;
96
100
  /** Where `.claude/_profiles/` was kept, project-relative, or null. */
97
101
  keptAt: string | null;
98
102
  /** Unsaved changes added to a profile before activating, one line each. */
@@ -101,6 +105,11 @@ export interface ActivationResult {
101
105
  dependencies?: DependencyReport;
102
106
  /** Listed skills whose folder the team committed: that copy is used. */
103
107
  committedSkills?: string[];
108
+ /**
109
+ * Skill folders the marker recorded as magus-installed that git tracks —
110
+ * handed back to the team (see `releasedCommittedSkillNote`).
111
+ */
112
+ releasedCommitted?: string[];
104
113
  }
105
114
 
106
115
  export interface ActivateOptions {
@@ -123,20 +132,19 @@ export function reportDisplaced(
123
132
  }
124
133
 
125
134
  /**
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.
135
+ * Generated files that git tracks: the three live files, and a committed
136
+ * `.claude/skills` SYMLINK (an older layout committed by mistake). A skill
137
+ * FOLDER git tracks is never one of them — it is the team's copy (see
138
+ * `trackedSkillFolders`). Empty outside a git repository; throws
139
+ * `GitCheckError` when git cannot say. Reported only — nothing refuses to
140
+ * write because of it (see `tracked-live-files.ts`).
131
141
  */
132
- export async function trackedWriteTargets(
142
+ export async function trackedGeneratedFiles(
133
143
  projectPath: string,
134
- skillFolders: readonly string[] = [],
135
144
  ): Promise<string[]> {
136
145
  const rel = (abs: string) => path.relative(projectPath, abs);
137
146
  const live = LIVE_FILE_KEYS.map((key) => rel(livePath(key, projectPath)));
138
147
  const skills = rel(skillsRoot(projectPath));
139
- const folders = skillFolders.map((folder) => path.join(skills, folder));
140
148
  let skillsIsLink = false;
141
149
  try {
142
150
  skillsIsLink = (await lstat(skillsRoot(projectPath))).isSymbolicLink();
@@ -144,112 +152,44 @@ export async function trackedWriteTargets(
144
152
  // No .claude/skills at all.
145
153
  }
146
154
  const files = [...live, ...(skillsIsLink ? [skills] : [])];
147
- const tracked = await trackedPaths(projectPath, files, folders);
148
- return [...files, ...folders].filter((c) => tracked.has(c));
149
- }
150
-
151
- /**
152
- * The folders of `skills` the team committed under `.claude/skills/`. Such a
153
- * folder is the team's copy of the skill: magus neither installs over it nor
154
- * manages it, so it never has cause to suggest untracking it.
155
- */
156
- export async function committedSkillFolders(
157
- projectPath: string,
158
- skills: readonly ProfileSkillRef[],
159
- ): Promise<Set<string>> {
160
- const root = path.relative(projectPath, skillsRoot(projectPath));
161
- const folders = skills.map((ref) => skillFolder(ref));
162
- const tracked = await trackedPaths(
163
- projectPath,
164
- [],
165
- folders.map((folder) => path.join(root, folder)),
166
- );
167
- return new Set(folders.filter((f) => tracked.has(path.join(root, f))));
168
- }
169
-
170
- /** {@link trackedWriteTargets} for the live files alone — what doctor reports. */
171
- export async function trackedGeneratedFiles(
172
- projectPath: string,
173
- ): Promise<string[]> {
174
- return trackedWriteTargets(projectPath);
155
+ const tracked = await trackedPaths(projectPath, files);
156
+ return files.filter((c) => tracked.has(c));
175
157
  }
176
158
 
177
159
  /**
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.
160
+ * {@link trackedGeneratedFiles} as the warning lines the CLI prints: one per
161
+ * tracked path, or one saying git could not be asked.
183
162
  */
184
- export async function writableSkillFolders(
163
+ export async function trackedGeneratedWarnings(
185
164
  projectPath: string,
186
- skills: readonly ProfileSkillRef[],
187
- managed: Readonly<Record<string, string>> = {},
188
165
  ): Promise<string[]> {
189
- const out = new Set(Object.keys(managed));
190
- const missing: ProfileSkillRef[] = [];
191
- for (const ref of skills) {
192
- const folder = skillFolder(ref);
193
- if (out.has(folder)) continue;
194
- try {
195
- await lstat(path.join(skillsRoot(projectPath), folder));
196
- } catch {
197
- missing.push(ref);
198
- }
199
- }
200
- const committed = await committedSkillFolders(projectPath, missing);
201
- for (const ref of missing) {
202
- if (!committed.has(skillFolder(ref))) out.add(skillFolder(ref));
166
+ try {
167
+ return (await trackedGeneratedFiles(projectPath)).map(trackedWarning);
168
+ } catch (error) {
169
+ if (error instanceof GitCheckError) return [error.message];
170
+ throw error;
203
171
  }
204
- return [...out];
205
172
  }
206
173
 
207
174
  /**
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.
175
+ * The folders of `skills` the team committed under `.claude/skills/`. Such a
176
+ * folder is the team's copy of the skill: magus neither installs over it nor
177
+ * manages it, so it never has cause to suggest untracking it. None when git
178
+ * cannot say — each existing folder is then judged as a custom skill.
211
179
  */
212
- export async function activeWritableSkillFolders(
180
+ export async function committedSkillFolders(
213
181
  projectPath: string,
214
- manifest: ProfileManifest,
215
- ): Promise<string[]> {
216
- const marker = await readActiveMarker(projectPath);
217
- if (!marker) return [];
218
- return writableSkillFolders(
182
+ skills: readonly ProfileSkillRef[],
183
+ ): Promise<Set<string>> {
184
+ return trackedSkillFolders(
219
185
  projectPath,
220
- manifest.profiles[marker.profile]?.skills ?? [],
221
- marker.skills,
186
+ skills.map((ref) => skillFolder(ref)),
222
187
  );
223
188
  }
224
189
 
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);
190
+ /** The line for a recorded skill folder handed back to the team. */
191
+ export function releasedCommittedSkillNote(folder: string): string {
192
+ return `Skill ${folder} is committed by the team — magus no longer manages it`;
253
193
  }
254
194
 
255
195
  /**
@@ -274,16 +214,16 @@ export function activationNotes(result: ActivationResult): string[] {
274
214
  for (const f of result.skills.failed) {
275
215
  lines.push(`Skill "${f.skill}" could not be installed: ${f.reason}`);
276
216
  }
217
+ for (const folder of result.releasedCommitted ?? []) {
218
+ lines.push(releasedCommittedSkillNote(folder));
219
+ }
277
220
  for (const folder of result.committedSkills ?? []) {
278
221
  lines.push(
279
222
  `Skill .claude/skills/${folder} is committed to git — the team's copy is used; magus does not install or manage it`,
280
223
  );
281
224
  }
282
- for (const rel of result.tracked) {
283
- lines.push(
284
- `${rel} is generated but tracked by git — run \`${untrackCommand(rel)}\`, then commit`,
285
- );
286
- }
225
+ if (typeof result.tracked === "string") lines.push(result.tracked);
226
+ else lines.push(...result.tracked.map(trackedWarning));
287
227
  if (result.keptAt) {
288
228
  lines.push(
289
229
  `The older layout's .claude/_profiles/ was kept at ${result.keptAt}/`,
@@ -295,9 +235,10 @@ export function activationNotes(result: ActivationResult): string[] {
295
235
  /**
296
236
  * Render `profileId` from `manifest` and make it the active profile.
297
237
  *
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
238
+ * A generated file git tracks is written like any other, and named in
239
+ * `tracked` so the caller can warn about it. Throws `SettingsParseError`,
240
+ * having changed nothing, when a live file of a managed project does not
241
+ * parse. Throws
301
242
  * {@link SkillClashError} — having
302
243
  * changed nothing but moving an older layout's skills into `.claude/skills/` —
303
244
  * when a listed skill's folder is already a custom skill.
@@ -315,21 +256,27 @@ export async function activateProfile(
315
256
  // Read before converting: the older marker lives in the directory the
316
257
  // conversion moves away, and an older layout is recognised by its links.
317
258
  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
- );
323
259
  const wasOldLayout = (await oldLayoutProfile(projectPath)) !== null;
324
260
  // A managed project's live files are the profile's; one that does not
325
261
  // parse is somebody mid-edit, and replacing it would lose their work. (At
326
262
  // first adoption a hand-written file is moved to a backup, not replaced.)
327
263
  if (previous !== null) await assertLiveFilesParse(projectPath);
328
264
  const conversion = await convertLegacySkills(manifest, projectPath);
329
- const managedBefore: Record<string, string> = {
265
+ const recorded: Record<string, string> = {
330
266
  ...(previous?.skills ?? {}),
331
267
  ...conversion.managed,
332
268
  };
269
+ // A recorded folder git tracks is the team's copy — 7.5.0's conversion
270
+ // recorded committed skills as magus-installed. Dropped from the record
271
+ // here, before a sync could remove it, and said once.
272
+ const committedRecorded = await trackedSkillFolders(
273
+ projectPath,
274
+ Object.keys(recorded),
275
+ );
276
+ const managedBefore: Record<string, string> = {};
277
+ for (const [folder, hash] of Object.entries(recorded)) {
278
+ if (!committedRecorded.has(folder)) managedBefore[folder] = hash;
279
+ }
333
280
 
334
281
  // Listed skills the team committed and magus does not manage: that copy
335
282
  // stands, and magus neither installs over it nor reports it as a clash.
@@ -397,7 +344,7 @@ export async function activateProfile(
397
344
  );
398
345
  await updateSkillIgnoreLines(
399
346
  projectPath,
400
- Object.keys(managedBefore),
347
+ Object.keys(recorded),
401
348
  Object.keys(skills.skills),
402
349
  );
403
350
  await writeActiveMarker(
@@ -412,7 +359,12 @@ export async function activateProfile(
412
359
  displaced,
413
360
  skills,
414
361
  conversion,
415
- tracked: await trackedGeneratedFiles(projectPath),
362
+ tracked: await trackedGeneratedFiles(projectPath).catch(
363
+ (error: unknown) => {
364
+ if (error instanceof GitCheckError) return error.message;
365
+ throw error;
366
+ },
367
+ ),
416
368
  keptAt: conversion.keptAt
417
369
  ? path.relative(projectPath, conversion.keptAt)
418
370
  : null,
@@ -420,6 +372,9 @@ export async function activateProfile(
420
372
  ...(committedSkills.size > 0
421
373
  ? { committedSkills: [...committedSkills].sort() }
422
374
  : {}),
375
+ ...(committedRecorded.size > 0
376
+ ? { releasedCommitted: [...committedRecorded].sort() }
377
+ : {}),
423
378
  };
424
379
  }
425
380
 
@@ -445,7 +400,6 @@ export async function regenerateLiveFiles(
445
400
  const marker = await readActiveMarker(projectPath);
446
401
  const manifest = await readManifest(projectPath);
447
402
  if (!marker || !manifest.profiles[marker.profile]) return false;
448
- await assertNoTrackedWriteTargets(projectPath);
449
403
  await assertLiveFilesParse(projectPath);
450
404
  const rendered = await renderProfile(manifest, marker.profile, projectPath);
451
405
  const files = await writeRenderedFiles(rendered, projectPath);
@@ -41,10 +41,6 @@ import {
41
41
  type ProfileChoice,
42
42
  activateProfile,
43
43
  activationNotes,
44
- activeWritableSkillFolders,
45
- assertNoTrackedWriteTargets,
46
- writableSkillFolders,
47
- writeRefusal,
48
44
  } from "./profile-activate.js";
49
45
  import { adoptCurrentProject } from "./profile-adopt.js";
50
46
  import {
@@ -73,7 +69,6 @@ import {
73
69
  skillFolder,
74
70
  } from "./profile-skills.js";
75
71
  import type { SettingsParseError } from "./settings-file.js";
76
- import type { LiveFileRefusal } from "./tracked-live-files.js";
77
72
 
78
73
  export type ActiveProfileCheck =
79
74
  | { state: "no-profiles" }
@@ -304,11 +299,6 @@ export interface ProfileStatePrep {
304
299
  problems: string[];
305
300
  /** The files behind `problems` that could not be parsed. */
306
301
  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
302
  }
313
303
 
314
304
  /**
@@ -322,27 +312,6 @@ export async function prepareProfileState(
322
312
  projectPath: string,
323
313
  opts: Pick<ActivateOptions, "installSkill"> = {},
324
314
  ): 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
315
  const check = await checkActiveProfile(projectPath);
347
316
  const out: ProfileStatePrep = {
348
317
  check,
@@ -524,22 +493,6 @@ export async function switchToProfile(
524
493
  );
525
494
  return result;
526
495
  }
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
496
  if (!take(await absorbBeforeConversion(projectPath, name))) return result;
544
497
  const installSkill = { installSkill: opts.installSkill };
545
498
  if (!take(await reconcileActiveProfile(projectPath, installSkill))) {
@@ -580,20 +533,6 @@ export async function applyProfileChoice(
580
533
  projectPath: string,
581
534
  opts: ActivateOptions = {},
582
535
  ): 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
536
  if (choice.kind === "activate") {
598
537
  const absorbed: string[] = [];
599
538
  let changed = false;
@@ -635,7 +574,6 @@ export async function prepareProfileWrite(
635
574
  projectPath: string,
636
575
  ): Promise<ProfileStatePrep> {
637
576
  const prep = await prepareProfileState(projectPath);
638
- if (prep.refusal) throw prep.refusal;
639
577
  refuseOnProblems(prep);
640
578
  return prep;
641
579
  }