magus-cli 7.5.2 → 7.5.4
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 +4 -4
- package/src/__tests__/blackbox-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/gitignore-check.test.ts +11 -29
- package/src/__tests__/gitignore-profile.test.ts +248 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/models-cli.test.ts +11 -0
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-skills.test.ts +17 -22
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-state.test.ts +152 -164
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +239 -70
- package/src/cli/doctor.ts +7 -20
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +7 -3
- package/src/cli/router.ts +6 -4
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +118 -6
- package/src/cli/update.ts +10 -1
- package/src/services/claude-cli.ts +0 -24
- package/src/services/claude-settings.ts +28 -7
- package/src/services/gitignore-check.ts +37 -49
- package/src/services/gitignore-resolver.ts +10 -1
- package/src/services/gitignore-service.ts +67 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +41 -24
- package/src/services/profile-activate.ts +102 -127
- package/src/services/profile-startup.ts +160 -4
- package/src/services/profile-state.ts +7 -68
- package/src/services/tracked-live-files.ts +109 -74
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +41 -35
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +63 -17
- package/src/ui/hooks/useProfileStartupModal.ts +94 -9
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +30 -52
- package/src/ui/screens/ProfilesScreen.tsx +3 -10
- package/src/cli/tui-prompt.tsx +0 -143
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
*
|
|
27
27
|
* ok a profile is active; the manifest is in effect; ask nothing
|
|
28
28
|
* unmigrated no manifest — the project is not using profiles at all
|
|
29
|
+
* old-format the project is on an older magus profile format; asked about
|
|
30
|
+
* before it is updated, in the CLI and the TUI alike
|
|
31
|
+
* nested no manifest here, but this folder is inside a project that
|
|
32
|
+
* has one — Claude Code started here would read this folder's
|
|
33
|
+
* settings, not that project's, so nothing is offered: the way
|
|
34
|
+
* on is running magus in that project, or `magus profile init`
|
|
29
35
|
* inactive a manifest with profiles, none of them activated
|
|
30
36
|
* unknown the active-profile marker names a profile the manifest does
|
|
31
37
|
* not define; the only way on is activating one that exists
|
|
@@ -50,18 +56,25 @@
|
|
|
50
56
|
* detects.
|
|
51
57
|
*/
|
|
52
58
|
|
|
59
|
+
import path from "node:path";
|
|
53
60
|
import type { ProfileManifest } from "../types/index.js";
|
|
54
61
|
// Type-only, so it is erased: this module stays free of the UI and of React,
|
|
55
62
|
// which is what lets `magus hook` and the CLI import it. The shape of a
|
|
56
63
|
// selectable option is presentation-neutral data, and both surfaces render the
|
|
57
64
|
// same list from these builders rather than each composing its own.
|
|
58
65
|
import type { SelectOption } from "../ui/state/types.js";
|
|
59
|
-
import {
|
|
66
|
+
import {
|
|
67
|
+
MANIFEST_FILE,
|
|
68
|
+
emptyManifest,
|
|
69
|
+
findEnclosingManifest,
|
|
70
|
+
readManifest,
|
|
71
|
+
} from "./manifest.js";
|
|
60
72
|
import type { ProfileChoice } from "./profile-activate.js";
|
|
61
73
|
import { freeProfileId } from "./profile-adopt.js";
|
|
62
74
|
import {
|
|
63
75
|
activeProfile,
|
|
64
76
|
displaceableArtifacts,
|
|
77
|
+
hasOlderLayout,
|
|
65
78
|
namedActiveProfile,
|
|
66
79
|
} from "./profile-live.js";
|
|
67
80
|
import { prepareProfileState } from "./profile-state.js";
|
|
@@ -71,6 +84,8 @@ import { SettingsParseError } from "./settings-file.js";
|
|
|
71
84
|
export type ProfileStartupState =
|
|
72
85
|
| "ok"
|
|
73
86
|
| "unmigrated"
|
|
87
|
+
| "old-format"
|
|
88
|
+
| "nested"
|
|
74
89
|
| "inactive"
|
|
75
90
|
| "unknown"
|
|
76
91
|
| "unparseable";
|
|
@@ -102,6 +117,11 @@ export interface ProfileStartupNeed {
|
|
|
102
117
|
activate: ActivatableProfile[];
|
|
103
118
|
/** For `unknown`: the profile the marker names. */
|
|
104
119
|
unknownProfile?: string;
|
|
120
|
+
/**
|
|
121
|
+
* For `nested`: the enclosing project — its path relative to this folder
|
|
122
|
+
* and its folder name, which is how the user knows it.
|
|
123
|
+
*/
|
|
124
|
+
enclosing?: { rel: string; name: string };
|
|
105
125
|
/**
|
|
106
126
|
* The profile this project declares it wants, if the manifest names one that
|
|
107
127
|
* exists. Used to preselect the row so Enter does the intended thing.
|
|
@@ -167,6 +187,24 @@ export async function detectProfileStartupNeed(
|
|
|
167
187
|
const ids = Object.keys(manifest.profiles);
|
|
168
188
|
|
|
169
189
|
if (ids.length === 0) {
|
|
190
|
+
// One decision for the CLI and the TUI alike. It lived in the CLI alone,
|
|
191
|
+
// so `magus update` in a subfolder explained and stopped while the TUI in
|
|
192
|
+
// the same folder offered to save a second, separate profile.
|
|
193
|
+
const enclosing = await findEnclosingManifest(projectPath);
|
|
194
|
+
if (enclosing !== null) {
|
|
195
|
+
return {
|
|
196
|
+
state: "nested",
|
|
197
|
+
adopt: null,
|
|
198
|
+
activate: [],
|
|
199
|
+
declaredDefault: null,
|
|
200
|
+
autoActivate: false,
|
|
201
|
+
manifest,
|
|
202
|
+
enclosing: {
|
|
203
|
+
rel: path.relative(projectPath, enclosing),
|
|
204
|
+
name: path.basename(enclosing),
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
170
208
|
return {
|
|
171
209
|
state: "unmigrated",
|
|
172
210
|
adopt: freeProfileId(manifest),
|
|
@@ -242,11 +280,45 @@ export async function detectProfileStartupNeed(
|
|
|
242
280
|
};
|
|
243
281
|
}
|
|
244
282
|
|
|
283
|
+
/**
|
|
284
|
+
* The `old-format` question, or null when the project is not on an older
|
|
285
|
+
* magus profile format. One source for the CLI's gate and the TUI's startup,
|
|
286
|
+
* so both ask the same thing — the TUI used to convert without asking while
|
|
287
|
+
* the CLI asked.
|
|
288
|
+
*/
|
|
289
|
+
export async function oldFormatNeed(
|
|
290
|
+
projectPath: string,
|
|
291
|
+
): Promise<ProfileStartupNeed | null> {
|
|
292
|
+
if (!(await hasOlderLayout(projectPath))) return null;
|
|
293
|
+
let manifest: ProfileManifest;
|
|
294
|
+
try {
|
|
295
|
+
manifest = await readManifest(projectPath);
|
|
296
|
+
} catch (error) {
|
|
297
|
+
if (!(error instanceof SettingsParseError)) throw error;
|
|
298
|
+
return unparseableNeed(error);
|
|
299
|
+
}
|
|
300
|
+
return {
|
|
301
|
+
state: "old-format",
|
|
302
|
+
adopt: null,
|
|
303
|
+
activate: [],
|
|
304
|
+
declaredDefault: null,
|
|
305
|
+
autoActivate: false,
|
|
306
|
+
manifest,
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
|
|
245
310
|
/** What the TUI shows at startup, beyond the question (if any). */
|
|
246
311
|
export interface ProfileStartup {
|
|
247
312
|
need: ProfileStartupNeed;
|
|
248
313
|
/** One line per thing the gate did: absorbed edits, regeneration, repairs. */
|
|
249
314
|
notices: string[];
|
|
315
|
+
/**
|
|
316
|
+
* One line per thing the gate REFUSED — the lines the CLI stops on. Kept
|
|
317
|
+
* apart from `notices` because a refusal can leave detection reading `ok`:
|
|
318
|
+
* an old layout whose settings file does not parse is not converted, yet
|
|
319
|
+
* still names its profile, so only this says the update did not happen.
|
|
320
|
+
*/
|
|
321
|
+
problems: string[];
|
|
250
322
|
}
|
|
251
323
|
|
|
252
324
|
/**
|
|
@@ -257,17 +329,66 @@ export interface ProfileStartup {
|
|
|
257
329
|
*/
|
|
258
330
|
export async function prepareProfileStartup(
|
|
259
331
|
projectPath: string,
|
|
332
|
+
/**
|
|
333
|
+
* Update an older profile format. Without it, a project on one is answered
|
|
334
|
+
* with the `old-format` question and NOTHING is converted — the TUI asks
|
|
335
|
+
* first, as the CLI does; the modal's "Update it now" calls back with this.
|
|
336
|
+
*/
|
|
337
|
+
opts: { convert?: boolean } = {},
|
|
260
338
|
): Promise<ProfileStartup> {
|
|
339
|
+
if (!opts.convert) {
|
|
340
|
+
const format = await oldFormatNeed(projectPath);
|
|
341
|
+
if (format !== null) return { need: format, notices: [], problems: [] };
|
|
342
|
+
}
|
|
261
343
|
const notices: string[] = [];
|
|
344
|
+
const problems: string[] = [];
|
|
262
345
|
try {
|
|
263
346
|
const prep = await prepareProfileState(projectPath);
|
|
264
|
-
notices.push(...prep.lines
|
|
347
|
+
notices.push(...prep.lines);
|
|
348
|
+
problems.push(...prep.problems);
|
|
265
349
|
} catch (error) {
|
|
266
350
|
if (!(error instanceof SettingsParseError)) {
|
|
267
|
-
|
|
351
|
+
problems.push(error instanceof Error ? error.message : String(error));
|
|
268
352
|
}
|
|
269
353
|
}
|
|
270
|
-
return {
|
|
354
|
+
return {
|
|
355
|
+
need: await detectProfileStartupNeed(projectPath),
|
|
356
|
+
notices,
|
|
357
|
+
problems,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** What the TUI shows once "Update it now" has run. */
|
|
362
|
+
export type FormatUpdateOutcome =
|
|
363
|
+
| { kind: "ask"; need: ProfileStartupNeed }
|
|
364
|
+
| { kind: "updated"; title: string; message: string }
|
|
365
|
+
| { kind: "failed"; title: string; message: string };
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* The verdict on an old-format update, from {@link prepareProfileStartup}'s
|
|
369
|
+
* result. A refusal is a failure even when detection reads `ok` — the CLI
|
|
370
|
+
* stops on the same lines, and the TUI used to show "Profile format updated"
|
|
371
|
+
* over a message saying magus had not updated it.
|
|
372
|
+
*/
|
|
373
|
+
export function formatUpdateOutcome(
|
|
374
|
+
result: ProfileStartup,
|
|
375
|
+
): FormatUpdateOutcome {
|
|
376
|
+
if (result.problems.length > 0) {
|
|
377
|
+
return {
|
|
378
|
+
kind: "failed",
|
|
379
|
+
title: "Could not update the profile format",
|
|
380
|
+
message: [...result.problems, "", "Your profiles are kept."].join("\n"),
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
if (!needsNothing(result.need)) return { kind: "ask", need: result.need };
|
|
384
|
+
return {
|
|
385
|
+
kind: "updated",
|
|
386
|
+
title: "Profile format updated",
|
|
387
|
+
message:
|
|
388
|
+
result.notices.length > 0
|
|
389
|
+
? result.notices.join("\n")
|
|
390
|
+
: "This project now uses the current profile format.",
|
|
391
|
+
};
|
|
271
392
|
}
|
|
272
393
|
|
|
273
394
|
// ─── What to offer, as data both surfaces render ─────────────────────────────
|
|
@@ -276,6 +397,8 @@ export async function prepareProfileStartup(
|
|
|
276
397
|
export const ADOPT_VALUE = "adopt";
|
|
277
398
|
export const QUIT_VALUE = "quit";
|
|
278
399
|
export const ACTIVATE_PREFIX = "activate:";
|
|
400
|
+
/** The `old-format` question's "Update it now" row. Not a profile choice. */
|
|
401
|
+
export const UPDATE_FORMAT_VALUE = "update-format";
|
|
279
402
|
|
|
280
403
|
/**
|
|
281
404
|
* Title and message for the state we are in.
|
|
@@ -349,6 +472,35 @@ export function describeNeed(need: ProfileStartupNeed): {
|
|
|
349
472
|
};
|
|
350
473
|
}
|
|
351
474
|
|
|
475
|
+
if (need.state === "old-format") {
|
|
476
|
+
return {
|
|
477
|
+
title: "This project uses an old magus profile format",
|
|
478
|
+
message:
|
|
479
|
+
"magus has to update it before it can manage this project.\n\nYour profiles, plugins and custom skills are kept.",
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (need.state === "nested") {
|
|
484
|
+
// The one other place a path appears, and for the same reason as
|
|
485
|
+
// `unparseable`: the folder IS the message. It sits on a line of its own,
|
|
486
|
+
// short, so the modal never wraps it at a `.`. The reason is stated as
|
|
487
|
+
// Claude Code's behaviour, which it is — magus follows it.
|
|
488
|
+
const at = need.enclosing;
|
|
489
|
+
return {
|
|
490
|
+
title: "Inside another Claude Code project",
|
|
491
|
+
message: [
|
|
492
|
+
"This folder is inside a Claude Code project that already has profiles:",
|
|
493
|
+
"",
|
|
494
|
+
` ${at?.rel ?? ".."} (${at?.name ?? "?"})`,
|
|
495
|
+
"",
|
|
496
|
+
"Claude Code treats each folder you start it in as a separate project, with its own plugins and settings, and magus follows it.",
|
|
497
|
+
"",
|
|
498
|
+
"To manage plugins for the whole project, run magus there.",
|
|
499
|
+
"To give this folder a setup of its own, run magus profile init here.",
|
|
500
|
+
].join("\n"),
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
|
|
352
504
|
if (need.state === "unknown") {
|
|
353
505
|
return {
|
|
354
506
|
title: "Active profile no longer exists",
|
|
@@ -390,6 +542,10 @@ export function describeNeed(need: ProfileStartupNeed): {
|
|
|
390
542
|
export function buildProfileOptions(need: ProfileStartupNeed): SelectOption[] {
|
|
391
543
|
const options: SelectOption[] = [];
|
|
392
544
|
|
|
545
|
+
if (need.state === "old-format") {
|
|
546
|
+
options.push({ label: "Update it now", value: UPDATE_FORMAT_VALUE });
|
|
547
|
+
}
|
|
548
|
+
|
|
393
549
|
if (need.adopt !== null) {
|
|
394
550
|
options.push({
|
|
395
551
|
// Says what it PRESERVES, not what it creates. "Create <id> from this
|
|
@@ -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" }
|
|
@@ -266,7 +261,7 @@ export function describeUnknownProfile(
|
|
|
266
261
|
export function describeUnmarked(
|
|
267
262
|
check: Extract<ActiveProfileCheck, { state: "unmarked" }>,
|
|
268
263
|
): string {
|
|
269
|
-
return `No profile is active in this project, and its
|
|
264
|
+
return `No profile is active in this project, and its plugins match none of the profiles. Run ${switchHint(check.available, check.suggested)}.`;
|
|
270
265
|
}
|
|
271
266
|
|
|
272
267
|
/**
|
|
@@ -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,
|
|
@@ -359,7 +328,7 @@ export async function prepareProfileState(
|
|
|
359
328
|
|
|
360
329
|
if (check.state === "active" && check.restored) {
|
|
361
330
|
out.lines.push(
|
|
362
|
-
`
|
|
331
|
+
`Using profile "${check.profile}" — this project's plugins match it`,
|
|
363
332
|
);
|
|
364
333
|
}
|
|
365
334
|
|
|
@@ -384,10 +353,11 @@ export async function prepareProfileState(
|
|
|
384
353
|
);
|
|
385
354
|
if (check.state === "old-layout") {
|
|
386
355
|
out.lines.push(
|
|
387
|
-
`
|
|
356
|
+
`Updated profile "${profile}" to the current profile format`,
|
|
388
357
|
);
|
|
389
358
|
}
|
|
390
|
-
|
|
359
|
+
// Tracked files are the command's to report, once (see profile-absorb).
|
|
360
|
+
out.lines.push(...activationNotes(result, { tracked: false }));
|
|
391
361
|
}
|
|
392
362
|
out.check = { state: "active", profile, restored: false };
|
|
393
363
|
}
|
|
@@ -417,11 +387,11 @@ export async function inspectProfileState(
|
|
|
417
387
|
|
|
418
388
|
if (check.state === "old-layout") {
|
|
419
389
|
pending.push(
|
|
420
|
-
`Profile "${check.profile}"
|
|
390
|
+
`Profile "${check.profile}" uses an old magus profile format. The next magus command, or \`magus doctor --fix\`, updates it — keeping any changes Claude Code made, and a backup of the old format.`,
|
|
421
391
|
);
|
|
422
392
|
} else if (check.state === "unmarked" && check.match !== null) {
|
|
423
393
|
pending.push(
|
|
424
|
-
`
|
|
394
|
+
`This project's plugins match profile "${check.match}" — the next magus command, or \`magus doctor --fix\`, switches to it.`,
|
|
425
395
|
);
|
|
426
396
|
} else if (
|
|
427
397
|
check.state === "active" &&
|
|
@@ -524,22 +494,6 @@ export async function switchToProfile(
|
|
|
524
494
|
);
|
|
525
495
|
return result;
|
|
526
496
|
}
|
|
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
497
|
if (!take(await absorbBeforeConversion(projectPath, name))) return result;
|
|
544
498
|
const installSkill = { installSkill: opts.installSkill };
|
|
545
499
|
if (!take(await reconcileActiveProfile(projectPath, installSkill))) {
|
|
@@ -580,20 +534,6 @@ export async function applyProfileChoice(
|
|
|
580
534
|
projectPath: string,
|
|
581
535
|
opts: ActivateOptions = {},
|
|
582
536
|
): 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
537
|
if (choice.kind === "activate") {
|
|
598
538
|
const absorbed: string[] = [];
|
|
599
539
|
let changed = false;
|
|
@@ -635,7 +575,6 @@ export async function prepareProfileWrite(
|
|
|
635
575
|
projectPath: string,
|
|
636
576
|
): Promise<ProfileStatePrep> {
|
|
637
577
|
const prep = await prepareProfileState(projectPath);
|
|
638
|
-
if (prep.refusal) throw prep.refusal;
|
|
639
578
|
refuseOnProblems(prep);
|
|
640
579
|
return prep;
|
|
641
580
|
}
|
|
@@ -1,76 +1,75 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Generated files that git tracks: reported, never refused.
|
|
3
3
|
*
|
|
4
|
-
* In a project with profiles, `.claude/settings.json`, `.mcp.json
|
|
5
|
-
* `.claude/models.json`
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* everyone. `.gitignore` cannot fix that — it does not untrack a file already
|
|
10
|
-
* committed. So every writer refuses, having written nothing, and says how to
|
|
11
|
-
* stop tracking it; the user decides.
|
|
4
|
+
* In a project with profiles, `.claude/settings.json`, `.mcp.json`,
|
|
5
|
+
* `.claude/models.json` and the magus-installed skill folders are generated
|
|
6
|
+
* per developer from `.claude/profiles.json`, and are expected to be
|
|
7
|
+
* gitignored. A repository that committed one before adopting profiles keeps
|
|
8
|
+
* tracking it — `.gitignore` does not untrack a committed file.
|
|
12
9
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
10
|
+
* magus writes those files anyway: every command that writes them proceeds
|
|
11
|
+
* exactly as for an untracked file. What changes is what it says. The CLI
|
|
12
|
+
* prints one warning per tracked file with the fix (`git rm --cached <path>`,
|
|
13
|
+
* then commit) and never runs git itself; `magus doctor` reports it as a note;
|
|
14
|
+
* the TUI asks on every open and, when told to, untracks them in place
|
|
15
|
+
* (`gitignore-check.ts`, `profileGitignoreManifest`).
|
|
17
16
|
*/
|
|
18
17
|
|
|
19
18
|
import path from "node:path";
|
|
19
|
+
import type { GitignoreManifest } from "../types/gitignore.js";
|
|
20
20
|
import { GitQueryError, trackedByGit } from "./git-worktree.js";
|
|
21
21
|
import { getManifestPath, readProfilesFile } from "./manifest.js";
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
constructor(readonly paths: readonly string[]) {
|
|
29
|
-
const one = paths.length === 1;
|
|
30
|
-
super(
|
|
31
|
-
[
|
|
32
|
-
`magus will not write ${one ? "a file" : "files"} git tracks in this project — ${
|
|
33
|
-
one ? "it is" : "they are"
|
|
34
|
-
} generated from .claude/profiles.json and ${one ? "differs" : "differ"} per developer:`,
|
|
35
|
-
...paths.map((p) => ` ${p}`),
|
|
36
|
-
`Stop tracking ${one ? "it" : "them"}, commit, then run the command again:`,
|
|
37
|
-
...paths.map((p) => ` ${untrackCommand(p)}`),
|
|
38
|
-
' git commit -m "Stop tracking generated Claude Code config"',
|
|
39
|
-
"Nothing was changed.",
|
|
40
|
-
].join("\n"),
|
|
41
|
-
);
|
|
42
|
-
this.name = "TrackedLiveFileError";
|
|
43
|
-
}
|
|
44
|
-
}
|
|
22
|
+
import {
|
|
23
|
+
LIVE_FILE_KEYS,
|
|
24
|
+
livePath,
|
|
25
|
+
readActiveMarker,
|
|
26
|
+
skillsRoot,
|
|
27
|
+
} from "./profile-live.js";
|
|
45
28
|
|
|
46
29
|
/**
|
|
47
30
|
* The project is a git work tree, but git could not say what it tracks — it
|
|
48
31
|
* timed out, is missing, or refused the repository (`detected dubious
|
|
49
|
-
* ownership`).
|
|
50
|
-
*
|
|
32
|
+
* ownership`). Reported with git's own words wherever the check is made; it
|
|
33
|
+
* never stops a write.
|
|
51
34
|
*/
|
|
52
|
-
export class GitCheckError extends
|
|
35
|
+
export class GitCheckError extends Error {
|
|
53
36
|
constructor(readonly detail: string) {
|
|
54
37
|
super(
|
|
55
|
-
|
|
56
|
-
"magus could not check which files git tracks in this project, so it wrote nothing. git said:",
|
|
57
|
-
...detail
|
|
58
|
-
.trim()
|
|
59
|
-
.split("\n")
|
|
60
|
-
.map((line) => ` ${line}`),
|
|
61
|
-
"Fix that, then run the command again. Nothing was changed.",
|
|
62
|
-
].join("\n"),
|
|
38
|
+
`magus could not check which files git tracks in this project. git said: ${detail.trim()}`,
|
|
63
39
|
);
|
|
64
40
|
this.name = "GitCheckError";
|
|
65
41
|
}
|
|
66
42
|
}
|
|
67
43
|
|
|
68
|
-
/** How to untrack one generated path
|
|
44
|
+
/** How to untrack one generated path. */
|
|
69
45
|
export function untrackCommand(rel: string): string {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
46
|
+
return `git rm --cached ${rel}`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** A tracked generated path, as reported: the problem, then the fix beneath. */
|
|
50
|
+
export interface TrackedNotice {
|
|
51
|
+
problem: string;
|
|
52
|
+
/** Lines under the problem. Empty when git itself could not be asked. */
|
|
53
|
+
fix: string[];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The notice for one tracked generated path.
|
|
58
|
+
*
|
|
59
|
+
* Short lines, not one long one. The single line this replaced ran the
|
|
60
|
+
* problem, the cause and the command together and wrapped mid-command at 80
|
|
61
|
+
* columns; the owner read it as confusing. Now: what is wrong, what the user
|
|
62
|
+
* will see because of it, and the command alone on its line to be copied —
|
|
63
|
+
* each under 80 columns with the report's indent.
|
|
64
|
+
*/
|
|
65
|
+
export function trackedNotice(rel: string): TrackedNotice {
|
|
66
|
+
return {
|
|
67
|
+
problem: `git tracks ${rel}, but magus rewrites it`,
|
|
68
|
+
fix: [
|
|
69
|
+
"It will keep showing as changed. Untrack it — the file stays on disk:",
|
|
70
|
+
`${untrackCommand(rel)}, then commit`,
|
|
71
|
+
],
|
|
72
|
+
};
|
|
74
73
|
}
|
|
75
74
|
|
|
76
75
|
/**
|
|
@@ -92,34 +91,70 @@ export async function trackedPaths(
|
|
|
92
91
|
}
|
|
93
92
|
|
|
94
93
|
/**
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
94
|
+
* Of `folders` under `.claude/skills/`, the ones git tracks — the team's own
|
|
95
|
+
* copies. A skill folder git tracks is the team's, never magus's: magus does
|
|
96
|
+
* not install over it, manage it, flag it or untrack it. Empty when git cannot
|
|
97
|
+
* say, so a failed git call never turns a team skill into one magus removes —
|
|
98
|
+
* it only leaves a folder magus recorded as its own until git answers.
|
|
98
99
|
*/
|
|
99
|
-
async function
|
|
100
|
+
export async function trackedSkillFolders(
|
|
101
|
+
projectPath: string,
|
|
102
|
+
folders: readonly string[],
|
|
103
|
+
): Promise<Set<string>> {
|
|
104
|
+
if (folders.length === 0) return new Set();
|
|
105
|
+
const root = path.relative(projectPath, skillsRoot(projectPath));
|
|
106
|
+
let tracked: Set<string>;
|
|
100
107
|
try {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
108
|
+
tracked = await trackedPaths(
|
|
109
|
+
projectPath,
|
|
110
|
+
[],
|
|
111
|
+
folders.map((folder) => path.join(root, folder)),
|
|
112
|
+
);
|
|
113
|
+
} catch (error) {
|
|
114
|
+
if (error instanceof GitCheckError) return new Set();
|
|
115
|
+
throw error;
|
|
105
116
|
}
|
|
117
|
+
return new Set(folders.filter((f) => tracked.has(path.join(root, f))));
|
|
106
118
|
}
|
|
107
119
|
|
|
108
120
|
/**
|
|
109
|
-
*
|
|
110
|
-
* `
|
|
111
|
-
*
|
|
112
|
-
*
|
|
121
|
+
* The gitignore rules the profile model implies, for the gitignore manager's
|
|
122
|
+
* `profile` tier — or null in a project without profiles.
|
|
123
|
+
*
|
|
124
|
+
* In a project with profiles — profiles.json defines at least one — the three
|
|
125
|
+
* live files and the magus-installed skill folders are generated per
|
|
126
|
+
* developer, so they must be ignored. The built-in defaults say to TRACK
|
|
127
|
+
* `.claude/settings.json` and `.mcp.json`, which is right for a project that
|
|
128
|
+
* writes them by hand; this tier overrides that for a profiled one, so a
|
|
129
|
+
* committed copy is reported as "tracked but should be ignored". A left-over
|
|
130
|
+
* marker in a repo that dropped profiles.json does not make it profiled.
|
|
131
|
+
*
|
|
132
|
+
* A skill folder git tracks is left out even when the marker records it:
|
|
133
|
+
* markers written by 7.5.0's conversion recorded committed skills as
|
|
134
|
+
* magus-installed, and the team's copy must never be flagged or untracked.
|
|
113
135
|
*/
|
|
114
|
-
export async function
|
|
136
|
+
export async function profileGitignoreManifest(
|
|
115
137
|
projectPath: string,
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
const
|
|
124
|
-
|
|
138
|
+
): Promise<GitignoreManifest | null> {
|
|
139
|
+
try {
|
|
140
|
+
const file = await readProfilesFile(getManifestPath(projectPath));
|
|
141
|
+
if (Object.keys(file.profiles).length === 0) return null;
|
|
142
|
+
} catch {
|
|
143
|
+
return null; // An unreadable profiles.json is reported by every profile command.
|
|
144
|
+
}
|
|
145
|
+
const marker = await readActiveMarker(projectPath);
|
|
146
|
+
const managed = Object.keys(marker?.skills ?? {});
|
|
147
|
+
const committed = await trackedSkillFolders(projectPath, managed);
|
|
148
|
+
const posix = (abs: string) =>
|
|
149
|
+
path.relative(projectPath, abs).split(path.sep).join("/");
|
|
150
|
+
const skills = posix(skillsRoot(projectPath));
|
|
151
|
+
return {
|
|
152
|
+
ignore: [
|
|
153
|
+
...LIVE_FILE_KEYS.map((key) => posix(livePath(key, projectPath))),
|
|
154
|
+
...managed
|
|
155
|
+
.filter((folder) => !committed.has(folder))
|
|
156
|
+
.map((folder) => `${skills}/${folder}/`),
|
|
157
|
+
],
|
|
158
|
+
track: [],
|
|
159
|
+
};
|
|
125
160
|
}
|