magus-cli 7.4.1 → 7.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -8
- package/package.json +4 -4
- package/src/__tests__/install-command.test.ts +4 -3
- package/src/__tests__/models-cli.test.ts +4 -7
- package/src/__tests__/models-manager.test.ts +52 -40
- package/src/__tests__/profile-absorb.test.ts +1138 -0
- package/src/__tests__/profile-command.test.ts +90 -32
- package/src/__tests__/profile-migration-offer.test.ts +18 -15
- package/src/__tests__/profile-migration.test.ts +246 -117
- package/src/__tests__/profile-render.test.ts +141 -0
- package/src/__tests__/profile-skills.test.ts +425 -0
- package/src/__tests__/profile-startup.test.ts +44 -14
- package/src/__tests__/profile-state.test.ts +795 -0
- package/src/__tests__/settings-file.test.ts +5 -7
- package/src/__tests__/styles-manager.test.ts +47 -74
- package/src/__tests__/styles-screen-state.test.ts +11 -10
- package/src/cli/bootstrap.ts +87 -27
- package/src/cli/doctor.ts +132 -38
- package/src/cli/install.ts +58 -118
- package/src/cli/models.ts +21 -1
- package/src/cli/profile.ts +48 -77
- package/src/cli/router.ts +32 -6
- package/src/cli/update.ts +43 -6
- package/src/data/gitignore-defaults.ts +3 -3
- package/src/services/active-closure.ts +3 -3
- package/src/services/conventions-manager.ts +88 -0
- package/src/services/git-worktree.ts +21 -0
- package/src/services/manifest.ts +4 -5
- package/src/services/models-manager.ts +36 -73
- package/src/services/profile-absorb.ts +1532 -0
- package/src/services/profile-activate.ts +348 -96
- package/src/services/profile-live.ts +825 -0
- package/src/services/profile-render.ts +115 -0
- package/src/services/profile-skills.ts +462 -0
- package/src/services/profile-startup.ts +88 -16
- package/src/services/profile-state.ts +622 -0
- package/src/services/profiles.ts +20 -32
- package/src/services/resolver.ts +6 -1
- package/src/services/settings-file.ts +5 -5
- package/src/services/skills-manager.ts +14 -8
- package/src/services/styles-manager.ts +45 -28
- package/src/types/index.ts +15 -9
- package/src/ui/App.tsx +25 -2
- package/src/ui/hooks/useProfileStartupModal.ts +20 -15
- package/src/ui/screens/ProfilesScreen.tsx +75 -27
- package/src/ui/screens/StylesScreen.tsx +2 -1
- package/src/ui/state/types.ts +1 -1
- package/src/__tests__/profile-materializer.test.ts +0 -238
- package/src/__tests__/profile-sync.test.ts +0 -154
- package/src/__tests__/symlink-manager.test.ts +0 -180
- package/src/services/profile-materializer.ts +0 -133
- package/src/services/profile-sync.ts +0 -158
- package/src/services/symlink-manager.ts +0 -343
|
@@ -5,11 +5,9 @@
|
|
|
5
5
|
* their configuration:
|
|
6
6
|
*
|
|
7
7
|
* - an unparseable file must be REFUSED, and left byte-for-byte alone;
|
|
8
|
-
* - a symlinked settings.json must be written THROUGH the link
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* replace the link with a plain file and detach the project from its
|
|
12
|
-
* profile, which nothing downstream would notice.
|
|
8
|
+
* - a symlinked settings.json must be written THROUGH the link: a symlink a
|
|
9
|
+
* person set up deliberately is theirs. Remove-then-create would replace
|
|
10
|
+
* the link with a plain file, which nothing downstream would notice.
|
|
13
11
|
*/
|
|
14
12
|
|
|
15
13
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
@@ -154,7 +152,7 @@ describe("updateSettingsFile", () => {
|
|
|
154
152
|
expect(readFileSync(file, "utf8")).toBe(original);
|
|
155
153
|
});
|
|
156
154
|
|
|
157
|
-
test("S-6: a symlinked settings.json is written THROUGH the link
|
|
155
|
+
test("S-6: a symlinked settings.json is written THROUGH the link", async () => {
|
|
158
156
|
const dir = tempDir();
|
|
159
157
|
const target = path.join(dir, "profile-settings.json");
|
|
160
158
|
const link = path.join(dir, "settings.json");
|
|
@@ -168,7 +166,7 @@ describe("updateSettingsFile", () => {
|
|
|
168
166
|
settings.outputStyle = "composed";
|
|
169
167
|
});
|
|
170
168
|
|
|
171
|
-
// The
|
|
169
|
+
// The link target received the write …
|
|
172
170
|
expect(JSON.parse(readFileSync(target, "utf8"))).toEqual({
|
|
173
171
|
enabledPlugins: { "dev@magus": true },
|
|
174
172
|
outputStyle: "composed",
|
|
@@ -4,6 +4,13 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import fs from "fs-extra";
|
|
6
6
|
import { EMBEDDED_PRESETS } from "../data/styles/index.js";
|
|
7
|
+
import { writeManifest } from "../services/manifest.js";
|
|
8
|
+
import { activateProfile } from "../services/profile-activate.js";
|
|
9
|
+
import {
|
|
10
|
+
liveHash,
|
|
11
|
+
readActiveMarker,
|
|
12
|
+
writeActiveMarker,
|
|
13
|
+
} from "../services/profile-live.js";
|
|
7
14
|
import {
|
|
8
15
|
INTEGRITY_BLOCK,
|
|
9
16
|
type ImportedStyle,
|
|
@@ -19,6 +26,7 @@ import {
|
|
|
19
26
|
splitFrontmatter,
|
|
20
27
|
validateSelection,
|
|
21
28
|
} from "../services/styles-manager.js";
|
|
29
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
22
30
|
import {
|
|
23
31
|
buildStyleBrowserItems,
|
|
24
32
|
firstSelectableIndex,
|
|
@@ -405,20 +413,26 @@ describe("applyStyles", () => {
|
|
|
405
413
|
).rejects.toThrow(/Nothing selected/);
|
|
406
414
|
});
|
|
407
415
|
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
416
|
+
const DEV: ProfileManifest = {
|
|
417
|
+
version: 2,
|
|
418
|
+
profiles: { dev: { name: "dev", plugins: {} } },
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
test("an older settings symlink is converted to a real file, not written through", async () => {
|
|
422
|
+
// Claude Code refuses to write through a symlink, so the style must not
|
|
423
|
+
// be applied by writing through one either: the file is rendered from
|
|
424
|
+
// the manifest in place of the link.
|
|
425
|
+
const oldSettings = join(
|
|
413
426
|
dir,
|
|
414
427
|
".claude",
|
|
415
428
|
"_profiles",
|
|
416
429
|
"dev",
|
|
417
430
|
"settings.json",
|
|
418
431
|
);
|
|
419
|
-
await fs.outputJson(
|
|
432
|
+
await fs.outputJson(oldSettings, { enabledPlugins: {} });
|
|
420
433
|
const link = join(dir, ".claude", "settings.json");
|
|
421
434
|
await symlink("_profiles/dev/settings.json", link);
|
|
435
|
+
await writeManifest(DEV, dir);
|
|
422
436
|
|
|
423
437
|
const result = await applyStyles({
|
|
424
438
|
projectPath: dir,
|
|
@@ -428,33 +442,13 @@ describe("applyStyles", () => {
|
|
|
428
442
|
|
|
429
443
|
expect(result.profile).toBe("dev");
|
|
430
444
|
expect(result.styleName).toBe("composed-dev");
|
|
431
|
-
expect((await fs.lstat(link)).isSymbolicLink()).toBe(
|
|
432
|
-
|
|
433
|
-
// profile" means.
|
|
434
|
-
expect((await fs.readJson(profileSettings)).outputStyle).toBe(
|
|
435
|
-
"composed-dev",
|
|
436
|
-
);
|
|
445
|
+
expect((await fs.lstat(link)).isSymbolicLink()).toBe(false);
|
|
446
|
+
expect((await fs.readJson(link)).outputStyle).toBe("composed-dev");
|
|
437
447
|
});
|
|
438
448
|
|
|
439
|
-
test("
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
const profileSettings = join(
|
|
443
|
-
dir,
|
|
444
|
-
".claude",
|
|
445
|
-
"_profiles",
|
|
446
|
-
"dev",
|
|
447
|
-
"settings.json",
|
|
448
|
-
);
|
|
449
|
-
await fs.outputJson(profileSettings, { enabledPlugins: {} });
|
|
450
|
-
await symlink(
|
|
451
|
-
"_profiles/dev/settings.json",
|
|
452
|
-
join(dir, ".claude", "settings.json"),
|
|
453
|
-
);
|
|
454
|
-
await fs.outputJson(join(dir, ".claude", "profiles.json"), {
|
|
455
|
-
version: 2,
|
|
456
|
-
profiles: { dev: { name: "dev", plugins: {} } },
|
|
457
|
-
});
|
|
449
|
+
test("with an activated profile, the live file stays real and is recorded", async () => {
|
|
450
|
+
await writeManifest(DEV, dir);
|
|
451
|
+
await activateProfile(DEV, "dev", dir, { onDisplace: () => {} });
|
|
458
452
|
|
|
459
453
|
const result = await applyStyles({
|
|
460
454
|
projectPath: dir,
|
|
@@ -462,67 +456,46 @@ describe("applyStyles", () => {
|
|
|
462
456
|
imports: [],
|
|
463
457
|
});
|
|
464
458
|
|
|
459
|
+
const live = join(dir, ".claude", "settings.json");
|
|
465
460
|
expect(result.recordedInManifest).toBe(true);
|
|
466
|
-
|
|
467
|
-
expect(
|
|
461
|
+
expect((await fs.lstat(live)).isFile()).toBe(true);
|
|
462
|
+
expect((await fs.readJson(live)).outputStyle).toBe("composed-dev");
|
|
463
|
+
// Rendered from the manifest and recorded, so it reads as unchanged.
|
|
464
|
+
expect((await readActiveMarker(dir))?.files.settings).toBe(
|
|
465
|
+
(await liveHash("settings", dir)) ?? "",
|
|
466
|
+
);
|
|
468
467
|
});
|
|
469
468
|
|
|
470
|
-
test("
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
".claude",
|
|
474
|
-
"_profiles",
|
|
475
|
-
"dev",
|
|
476
|
-
"settings.json",
|
|
477
|
-
);
|
|
478
|
-
await fs.outputJson(profileSettings, {});
|
|
479
|
-
await symlink(
|
|
480
|
-
"_profiles/dev/settings.json",
|
|
481
|
-
join(dir, ".claude", "settings.json"),
|
|
482
|
-
);
|
|
469
|
+
test("records the style in profiles.json, where rendering reads it", async () => {
|
|
470
|
+
await writeManifest(DEV, dir);
|
|
471
|
+
await activateProfile(DEV, "dev", dir, { onDisplace: () => {} });
|
|
483
472
|
|
|
484
473
|
const result = await applyStyles({
|
|
485
474
|
projectPath: dir,
|
|
486
475
|
presets: [preset({ name: "direct" })],
|
|
487
476
|
imports: [],
|
|
488
477
|
});
|
|
489
|
-
expect(result.profile).toBe("dev");
|
|
490
|
-
expect(result.recordedInManifest).toBe(false);
|
|
491
|
-
});
|
|
492
|
-
});
|
|
493
478
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
presets: [preset({ name: "direct" })],
|
|
499
|
-
imports: [],
|
|
500
|
-
});
|
|
501
|
-
await clearStyle(dir);
|
|
479
|
+
expect(result.recordedInManifest).toBe(true);
|
|
480
|
+
const manifest = await fs.readJson(join(dir, ".claude", "profiles.json"));
|
|
481
|
+
expect(manifest.profiles.dev.settings.outputStyle).toBe("composed-dev");
|
|
482
|
+
});
|
|
502
483
|
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
484
|
+
test("a marker naming a profile profiles.json lacks is no active profile", async () => {
|
|
485
|
+
await writeManifest(DEV, dir);
|
|
486
|
+
await writeActiveMarker(
|
|
487
|
+
{ version: 1, profile: "gone", files: {}, skills: {} },
|
|
488
|
+
dir,
|
|
506
489
|
);
|
|
507
|
-
|
|
508
|
-
expect(JSON.parse(raw)).toEqual({});
|
|
509
|
-
});
|
|
490
|
+
await fs.outputJson(join(dir, ".claude", "settings.json"), {});
|
|
510
491
|
|
|
511
|
-
test("leaves the generated file on disk so a re-apply is one keypress away", async () => {
|
|
512
492
|
const result = await applyStyles({
|
|
513
493
|
projectPath: dir,
|
|
514
494
|
presets: [preset({ name: "direct" })],
|
|
515
495
|
imports: [],
|
|
516
496
|
});
|
|
517
|
-
|
|
518
|
-
expect(
|
|
519
|
-
});
|
|
520
|
-
|
|
521
|
-
test("is a no-op on a project that never had a style", async () => {
|
|
522
|
-
await clearStyle(dir);
|
|
523
|
-
expect(await fs.pathExists(join(dir, ".claude", "settings.json"))).toBe(
|
|
524
|
-
false,
|
|
525
|
-
);
|
|
497
|
+
expect(result.profile).toBeNull();
|
|
498
|
+
expect(result.recordedInManifest).toBe(false);
|
|
526
499
|
});
|
|
527
500
|
});
|
|
528
501
|
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import { mkdtemp, rm
|
|
2
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { parseKeypress } from "@opentui/core";
|
|
6
6
|
import fs from "fs-extra";
|
|
7
|
+
import { writeManifest } from "../services/manifest.js";
|
|
8
|
+
import { activateProfile } from "../services/profile-activate.js";
|
|
7
9
|
import {
|
|
8
10
|
applyStyles,
|
|
9
11
|
clearStyle,
|
|
10
12
|
communityCacheDirOrNull,
|
|
11
13
|
loadStyles,
|
|
12
14
|
} from "../services/styles-manager.js";
|
|
15
|
+
import type { ProfileManifest } from "../types/index.js";
|
|
13
16
|
import type { KeyPress } from "../ui/input/key-dispatch.js";
|
|
14
17
|
import { SCREEN_BINDINGS, matchBinding } from "../ui/keymap.js";
|
|
15
18
|
import { appReducer, initialState } from "../ui/state/reducer.js";
|
|
@@ -291,16 +294,14 @@ describe("loadStyles reports what is actually in force", () => {
|
|
|
291
294
|
});
|
|
292
295
|
|
|
293
296
|
test("looks for the per-profile style when a profile is active", async () => {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
{
|
|
297
|
-
outputStyle: "composed-dev",
|
|
297
|
+
const manifest: ProfileManifest = {
|
|
298
|
+
version: 2,
|
|
299
|
+
profiles: {
|
|
300
|
+
dev: { name: "dev", settings: { outputStyle: "composed-dev" } },
|
|
298
301
|
},
|
|
299
|
-
|
|
300
|
-
await
|
|
301
|
-
|
|
302
|
-
join(dir, ".claude", "settings.json"),
|
|
303
|
-
);
|
|
302
|
+
};
|
|
303
|
+
await writeManifest(manifest, dir);
|
|
304
|
+
await activateProfile(manifest, "dev", dir, { onDisplace: () => {} });
|
|
304
305
|
await fs.outputFile(
|
|
305
306
|
join(dir, ".claude", "output-styles", "composed-dev.md"),
|
|
306
307
|
"---\nname: composed-dev\nstyle-presets: terse\nstyle-imports: none\n---\n\nbody",
|
package/src/cli/bootstrap.ts
CHANGED
|
@@ -15,8 +15,9 @@ import {
|
|
|
15
15
|
writeManifest,
|
|
16
16
|
} from "../services/manifest.js";
|
|
17
17
|
import {
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
type ActivationResult,
|
|
19
|
+
activateProfile,
|
|
20
|
+
activationNotes,
|
|
20
21
|
reportDisplaced,
|
|
21
22
|
} from "../services/profile-activate.js";
|
|
22
23
|
import {
|
|
@@ -31,6 +32,13 @@ import {
|
|
|
31
32
|
needsNothing,
|
|
32
33
|
parseProfileChoice,
|
|
33
34
|
} from "../services/profile-startup.js";
|
|
35
|
+
import {
|
|
36
|
+
applyProfileChoice,
|
|
37
|
+
describeUnknownProfile,
|
|
38
|
+
prepareProfileState,
|
|
39
|
+
suggestedProfile,
|
|
40
|
+
switchHint,
|
|
41
|
+
} from "../services/profile-state.js";
|
|
34
42
|
import { SettingsParseError } from "../services/settings-file.js";
|
|
35
43
|
import type { ProfileManifest } from "../types/index.js";
|
|
36
44
|
import { dim } from "./ansi.js";
|
|
@@ -57,8 +65,8 @@ export function reportUnparseable(error: SettingsParseError): void {
|
|
|
57
65
|
* Write the adopted manifest, then put the project ON the profile it describes.
|
|
58
66
|
*
|
|
59
67
|
* Both halves or neither. Writing alone is what made migrating feel like a
|
|
60
|
-
* no-op: `activeProfile()`
|
|
61
|
-
*
|
|
68
|
+
* no-op: `activeProfile()` reads the marker activation writes, so a manifest
|
|
69
|
+
* nothing activated changes no behaviour, and the run that just migrated still has to
|
|
62
70
|
* ask which profile to use.
|
|
63
71
|
*
|
|
64
72
|
* Activation is reported in full, including anything it moved aside, because it
|
|
@@ -79,20 +87,73 @@ async function writeAndActivate(
|
|
|
79
87
|
console.log(`✓ Wrote ${rel(getManifestPath(projectPath))}`);
|
|
80
88
|
|
|
81
89
|
// Each move is printed as it happens, not from the returned list afterwards.
|
|
82
|
-
// Activation
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
await materializeAndActivate(
|
|
90
|
+
// Activation writes three files in sequence, so a throw on the third would
|
|
91
|
+
// discard the record of the first two — and the first of those is the
|
|
92
|
+
// user's `.claude/settings.json`, now absent from where they left it.
|
|
93
|
+
const activation = await activateProfile(
|
|
87
94
|
result.manifest,
|
|
88
95
|
result.profileId,
|
|
89
96
|
projectPath,
|
|
90
|
-
reportDisplaced(projectPath),
|
|
97
|
+
{ onDisplace: reportDisplaced(projectPath) },
|
|
91
98
|
);
|
|
92
99
|
console.log(`✓ Active profile: ${result.profileId}`);
|
|
100
|
+
printActivationNotes(activation);
|
|
93
101
|
console.log();
|
|
94
102
|
}
|
|
95
103
|
|
|
104
|
+
/** Print what activation did beyond the switch itself, one line each. */
|
|
105
|
+
export function printActivationNotes(result: ActivationResult): void {
|
|
106
|
+
for (const line of activationNotes(result)) console.log(noteLine(line));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ProfileGateOptions {
|
|
110
|
+
/**
|
|
111
|
+
* Carry on when the marker names a profile profiles.json does not define.
|
|
112
|
+
* Only for commands that fix exactly that by activating a profile that
|
|
113
|
+
* exists — `profile switch`, `install`.
|
|
114
|
+
*/
|
|
115
|
+
allowUnknown?: boolean;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The gate every CLI command that reads or writes profile state passes first.
|
|
120
|
+
*
|
|
121
|
+
* Restores a lost marker when the live config matches exactly one profile,
|
|
122
|
+
* converts an older layout, absorbs what Claude Code wrote to the live config
|
|
123
|
+
* into the active profile, and regenerates the live config when profiles.json
|
|
124
|
+
* changed — printing one line for each. Returns false, having said why, when
|
|
125
|
+
* the command must not act: a marker naming a profile that does not exist, or
|
|
126
|
+
* a live file that cannot be read (it would be overwritten).
|
|
127
|
+
*/
|
|
128
|
+
export async function profileGate(
|
|
129
|
+
projectPath: string,
|
|
130
|
+
opts: ProfileGateOptions = {},
|
|
131
|
+
): Promise<boolean> {
|
|
132
|
+
let prep: Awaited<ReturnType<typeof prepareProfileState>>;
|
|
133
|
+
try {
|
|
134
|
+
prep = await prepareProfileState(projectPath);
|
|
135
|
+
} catch (error) {
|
|
136
|
+
if (error instanceof SettingsParseError) {
|
|
137
|
+
reportUnparseable(error);
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
console.error(
|
|
141
|
+
failLine(error instanceof Error ? error.message : String(error)),
|
|
142
|
+
);
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
for (const line of prep.lines) console.log(noteLine(line));
|
|
146
|
+
if (prep.problems.length > 0) {
|
|
147
|
+
for (const problem of prep.problems) console.error(failLine(problem));
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
if (prep.check.state === "unknown" && !opts.allowUnknown) {
|
|
151
|
+
console.error(failLine(describeUnknownProfile(prep.check)));
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
96
157
|
/** Print what adoption would capture, so the user can judge it before writing. */
|
|
97
158
|
function printAdoptionPlan(result: AdoptResult): void {
|
|
98
159
|
console.log(
|
|
@@ -141,12 +202,11 @@ function printAdoptionPlan(result: AdoptResult): void {
|
|
|
141
202
|
// onto the profile, which replaces two files that are in the working tree
|
|
142
203
|
// right now. The prompt below is the only place to decline.
|
|
143
204
|
console.log(
|
|
144
|
-
"The project then runs this profile: .claude/settings.json
|
|
205
|
+
"The project then runs this profile: .claude/settings.json and .mcp.json are",
|
|
145
206
|
);
|
|
146
207
|
console.log(
|
|
147
|
-
"
|
|
208
|
+
"generated from it, and your current copies are kept where the run reports.",
|
|
148
209
|
);
|
|
149
|
-
console.log("the run reports.");
|
|
150
210
|
}
|
|
151
211
|
|
|
152
212
|
export interface EnsureManifestOptions {
|
|
@@ -231,7 +291,7 @@ export async function ensureManifest(
|
|
|
231
291
|
* state is read from and where updates are written. With none there is nothing
|
|
232
292
|
* to read and nowhere to write, so there is no useful work to do — and the
|
|
233
293
|
* previous version of this offered exactly that anyway. It asked only when the
|
|
234
|
-
* manifest
|
|
294
|
+
* manifest disagreed with `enabledPlugins`, let the user decline, and `update` then carried on with
|
|
235
295
|
* a profile "for this run only", resolving against a manifest the project was
|
|
236
296
|
* not running. Two worlds live at once, which is the confusion being removed.
|
|
237
297
|
*
|
|
@@ -262,15 +322,16 @@ export async function requireActiveProfile(
|
|
|
262
322
|
// `--check` and with no TTY: this is the committed answer being applied, not
|
|
263
323
|
// a decision being made, and it is exactly the case CI hits.
|
|
264
324
|
if (need.autoActivate && need.declaredDefault !== null && !opts.check) {
|
|
265
|
-
const
|
|
325
|
+
const result = await applyProfileChoice(
|
|
266
326
|
{ kind: "activate", id: need.declaredDefault },
|
|
267
327
|
need.manifest,
|
|
268
328
|
projectPath,
|
|
269
|
-
reportDisplaced(projectPath),
|
|
329
|
+
{ onDisplace: reportDisplaced(projectPath) },
|
|
270
330
|
);
|
|
271
331
|
console.log(
|
|
272
|
-
`✓ Active profile: ${profileId} ${dim("(declared by this project)")}`,
|
|
332
|
+
`✓ Active profile: ${result.profileId} ${dim("(declared by this project)")}`,
|
|
273
333
|
);
|
|
334
|
+
printActivationNotes(result);
|
|
274
335
|
console.log("");
|
|
275
336
|
return readManifest(projectPath);
|
|
276
337
|
}
|
|
@@ -292,9 +353,10 @@ export async function requireActiveProfile(
|
|
|
292
353
|
noteLine(
|
|
293
354
|
need.state === "unmigrated"
|
|
294
355
|
? "Run `magus profile init` to create one deliberately."
|
|
295
|
-
: `Run
|
|
296
|
-
.map((p) => p.id)
|
|
297
|
-
.
|
|
356
|
+
: `Run ${switchHint(
|
|
357
|
+
need.activate.map((p) => p.id),
|
|
358
|
+
need.declaredDefault ?? suggestedProfile(need.manifest),
|
|
359
|
+
)} first.`,
|
|
298
360
|
),
|
|
299
361
|
);
|
|
300
362
|
return null;
|
|
@@ -316,13 +378,11 @@ export async function requireActiveProfile(
|
|
|
316
378
|
return null;
|
|
317
379
|
}
|
|
318
380
|
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
);
|
|
325
|
-
console.log(`✓ Active profile: ${profileId}`);
|
|
381
|
+
const result = await applyProfileChoice(choice, need.manifest, projectPath, {
|
|
382
|
+
onDisplace: reportDisplaced(projectPath),
|
|
383
|
+
});
|
|
384
|
+
console.log(`✓ Active profile: ${result.profileId}`);
|
|
385
|
+
printActivationNotes(result);
|
|
326
386
|
console.log("");
|
|
327
387
|
|
|
328
388
|
// Re-read rather than return `need.manifest`: adopting wrote a new profile
|
package/src/cli/doctor.ts
CHANGED
|
@@ -5,7 +5,11 @@
|
|
|
5
5
|
* 0. Global settings — `~/.claude/settings.json` parses.
|
|
6
6
|
* 1. Binary dependencies — every requires.bin / cliTools binary on PATH.
|
|
7
7
|
* This is the check that would have caught a dead `tmux-mcp`.
|
|
8
|
-
* 2. Profile
|
|
8
|
+
* 2. Profile state — which profile is active (strictly), what the next magus
|
|
9
|
+
* command would record or regenerate (with `--fix`, it does it now and
|
|
10
|
+
* says what it did), generated files git still tracks, and
|
|
11
|
+
* magus-installed skills that are missing. Without `--fix` this section
|
|
12
|
+
* writes nothing: no absorbing, no converting an older layout.
|
|
9
13
|
* 3. Convention compliance — .gitignore + CLAUDE.md blocks (ported engine).
|
|
10
14
|
* 4. Model routing — config validity, hook registration, and drift.
|
|
11
15
|
* 5. Plugin loads — what Claude Code will load here vs what is installed.
|
|
@@ -42,8 +46,26 @@ import {
|
|
|
42
46
|
type RepoRootCache,
|
|
43
47
|
pickLoadedEntry,
|
|
44
48
|
} from "../services/plugin-resolution.js";
|
|
49
|
+
import {
|
|
50
|
+
trackedGeneratedFiles,
|
|
51
|
+
untrackCommand,
|
|
52
|
+
} from "../services/profile-activate.js";
|
|
53
|
+
import {
|
|
54
|
+
LIVE_FILE_KEYS,
|
|
55
|
+
liveKind,
|
|
56
|
+
livePath,
|
|
57
|
+
readActiveMarker,
|
|
58
|
+
} from "../services/profile-live.js";
|
|
59
|
+
import { inspectManagedSkills } from "../services/profile-skills.js";
|
|
60
|
+
import {
|
|
61
|
+
type ActiveProfileCheck,
|
|
62
|
+
describeUnknownProfile,
|
|
63
|
+
describeUnmarked,
|
|
64
|
+
inspectProfileState,
|
|
65
|
+
prepareProfileState,
|
|
66
|
+
switchHint,
|
|
67
|
+
} from "../services/profile-state.js";
|
|
45
68
|
import { resolveAllProfiles } from "../services/resolver.js";
|
|
46
|
-
import { activeProfile, profileDir } from "../services/symlink-manager.js";
|
|
47
69
|
import type {
|
|
48
70
|
ClaudeSettings,
|
|
49
71
|
InstalledPluginsRegistry,
|
|
@@ -98,18 +120,11 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
|
|
|
98
120
|
}
|
|
99
121
|
}
|
|
100
122
|
|
|
101
|
-
// 2. Profile
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
console.log(`Profile "${active}" has broken symlinks:`);
|
|
107
|
-
for (const l of dangling) console.log(` ✗ ${l}`);
|
|
108
|
-
console.log(` Fix: magus install ${active}`);
|
|
109
|
-
problems += dangling.length;
|
|
110
|
-
} else {
|
|
111
|
-
console.log(`✓ Active profile "${active}" symlinks resolve.`);
|
|
112
|
-
}
|
|
123
|
+
// 2. Profile state.
|
|
124
|
+
if (manifest) {
|
|
125
|
+
const profile = await reportProfileState(projectPath, fix);
|
|
126
|
+
problems += profile.fixable;
|
|
127
|
+
unfixable += profile.unfixable;
|
|
113
128
|
}
|
|
114
129
|
|
|
115
130
|
// 3. Convention compliance — scoped to THIS project only (the ported engine
|
|
@@ -418,32 +433,111 @@ async function checkModels(
|
|
|
418
433
|
return { fixable: problems, unfixable };
|
|
419
434
|
}
|
|
420
435
|
|
|
421
|
-
/**
|
|
422
|
-
|
|
423
|
-
|
|
436
|
+
/**
|
|
437
|
+
* Section 2: which profile is active, and whether its live config is whole.
|
|
438
|
+
*
|
|
439
|
+
* Runs the same gate as every other command first — restoring a lost marker,
|
|
440
|
+
* converting an older layout, absorbing what Claude Code wrote into the live
|
|
441
|
+
* config and regenerating from a changed profiles.json — and reports what it
|
|
442
|
+
* did. What is left is reported plainly, each with its fix.
|
|
443
|
+
*/
|
|
444
|
+
export async function reportProfileState(
|
|
424
445
|
projectPath: string,
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
{
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
446
|
+
fix: boolean,
|
|
447
|
+
): Promise<{ fixable: number; unfixable: number }> {
|
|
448
|
+
const rel = (p: string) => path.relative(projectPath, p);
|
|
449
|
+
let fixable = 0;
|
|
450
|
+
let unfixable = 0;
|
|
451
|
+
let check: ActiveProfileCheck;
|
|
452
|
+
try {
|
|
453
|
+
if (fix) {
|
|
454
|
+
// `--fix`: the same gate every other command runs, reported.
|
|
455
|
+
const prep = await prepareProfileState(projectPath);
|
|
456
|
+
check = prep.check;
|
|
457
|
+
if (check.state === "no-profiles") return { fixable, unfixable };
|
|
458
|
+
for (const line of prep.lines) console.log(`• ${line}`);
|
|
459
|
+
for (const problem of prep.problems) {
|
|
460
|
+
console.log(`✗ ${problem}`);
|
|
461
|
+
unfixable++;
|
|
462
|
+
}
|
|
463
|
+
} else {
|
|
464
|
+
// Report only. Nothing here writes — not an absorb, not a marker, not
|
|
465
|
+
// a conversion: a diagnostic that changes the project is not one.
|
|
466
|
+
const report = await inspectProfileState(projectPath);
|
|
467
|
+
check = report.check;
|
|
468
|
+
if (check.state === "no-profiles") return { fixable, unfixable };
|
|
469
|
+
for (const error of report.unreadable) {
|
|
470
|
+
console.log(`✗ ${error.message}`);
|
|
471
|
+
console.log(
|
|
472
|
+
" Fix the JSON by hand; magus leaves the file as it is.",
|
|
473
|
+
);
|
|
474
|
+
unfixable++;
|
|
475
|
+
}
|
|
476
|
+
// What the next magus command will do on its own — record an edit,
|
|
477
|
+
// regenerate, convert, restore a lost marker — is information, not
|
|
478
|
+
// a fault: none of it makes doctor exit non-zero.
|
|
479
|
+
for (const line of report.pending) console.log(`• ${line}`);
|
|
480
|
+
}
|
|
481
|
+
} catch (error) {
|
|
482
|
+
console.log("\nProfile:");
|
|
483
|
+
console.log(
|
|
484
|
+
` ✗ ${error instanceof Error ? error.message : String(error)}`,
|
|
485
|
+
);
|
|
486
|
+
return { fixable: 0, unfixable: 1 };
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
if (check.state === "unknown") {
|
|
490
|
+
console.log(`✗ ${describeUnknownProfile(check)}`);
|
|
491
|
+
unfixable++;
|
|
492
|
+
} else if (check.state === "unmarked" && check.match !== null) {
|
|
493
|
+
// The live config matches exactly one profile: the pending line above
|
|
494
|
+
// already says the next command restores the marker. Not a fault.
|
|
495
|
+
} else if (check.state === "unmarked") {
|
|
496
|
+
console.log(
|
|
497
|
+
check.liveFiles
|
|
498
|
+
? `✗ ${describeUnmarked(check)}`
|
|
499
|
+
: `✗ No profile is active yet. Run \`magus install\` or ${switchHint(check.available, check.suggested)}.`,
|
|
500
|
+
);
|
|
501
|
+
unfixable++;
|
|
502
|
+
} else if (check.state === "active" || check.state === "old-layout") {
|
|
503
|
+
const broken: string[] = [];
|
|
504
|
+
for (const key of LIVE_FILE_KEYS) {
|
|
505
|
+
const kind = await liveKind(key, projectPath);
|
|
506
|
+
// An older layout's links are reported as pending conversion above.
|
|
507
|
+
if (
|
|
508
|
+
kind === "other" ||
|
|
509
|
+
(kind === "symlink" && check.state === "active")
|
|
510
|
+
) {
|
|
511
|
+
broken.push(`${rel(livePath(key, projectPath))} is not a regular file`);
|
|
443
512
|
}
|
|
444
|
-
} catch {
|
|
445
|
-
// link absent — not "broken", the profile may not provide it.
|
|
446
513
|
}
|
|
514
|
+
const marker = await readActiveMarker(projectPath);
|
|
515
|
+
if (marker) {
|
|
516
|
+
const { missing } = await inspectManagedSkills(
|
|
517
|
+
marker.skills,
|
|
518
|
+
projectPath,
|
|
519
|
+
);
|
|
520
|
+
for (const folder of missing) {
|
|
521
|
+
broken.push(`skill .claude/skills/${folder} is missing`);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (broken.length > 0) {
|
|
525
|
+
console.log(`Profile "${check.profile}" is not fully in place:`);
|
|
526
|
+
for (const line of broken) console.log(` ✗ ${line}`);
|
|
527
|
+
console.log(` Fix: magus profile switch ${check.profile}`);
|
|
528
|
+
fixable += broken.length;
|
|
529
|
+
} else if (check.state === "active") {
|
|
530
|
+
console.log(`✓ Active profile "${check.profile}" is in place.`);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// A .gitignore entry does not untrack a file committed before it. Report
|
|
535
|
+
// only: magus never runs git rm.
|
|
536
|
+
for (const tracked of await trackedGeneratedFiles(projectPath)) {
|
|
537
|
+
console.log(
|
|
538
|
+
`✗ ${tracked} is generated from profiles.json but tracked by git. Fix: \`${untrackCommand(tracked)}\`, then commit.`,
|
|
539
|
+
);
|
|
540
|
+
unfixable++;
|
|
447
541
|
}
|
|
448
|
-
return
|
|
542
|
+
return { fixable, unfixable };
|
|
449
543
|
}
|