magus-cli 7.1.0 → 7.3.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/package.json +4 -4
- package/src/__tests__/blackbox-plugin-state.test.ts +3 -0
- package/src/__tests__/doctor.test.ts +31 -0
- package/src/__tests__/plugin-resolution.test.ts +431 -0
- package/src/__tests__/plugin-row-text.test.ts +66 -0
- package/src/__tests__/profile-migration-offer.test.ts +211 -0
- package/src/__tests__/profile-migration.test.ts +242 -0
- package/src/__tests__/profile-startup.test.ts +458 -0
- package/src/__tests__/stale-load-repair.test.ts +1076 -0
- package/src/__tests__/update-apply.test.ts +146 -1
- package/src/__tests__/update-plan.test.ts +338 -0
- package/src/__tests__/useStaleLoadModal.test.ts +177 -0
- package/src/cli/ansi.ts +39 -0
- package/src/cli/bootstrap.ts +169 -3
- package/src/cli/doctor.ts +128 -2
- package/src/cli/install.ts +5 -1
- package/src/cli/profile.ts +5 -1
- package/src/cli/prompt.ts +44 -12
- package/src/cli/tui-prompt.tsx +143 -0
- package/src/cli/update-view.ts +32 -1
- package/src/cli/update.ts +50 -7
- package/src/main.tsx +10 -1
- package/src/services/active-closure.ts +23 -0
- package/src/services/claude-settings.ts +645 -34
- package/src/services/manifest.ts +21 -0
- package/src/services/plugin-manager.ts +61 -0
- package/src/services/plugin-resolution.ts +280 -0
- package/src/services/profile-activate.ts +147 -0
- package/src/services/profile-adopt.ts +50 -4
- package/src/services/profile-startup.ts +311 -0
- package/src/services/profiles.ts +49 -11
- package/src/services/symlink-manager.ts +184 -30
- package/src/services/update-engine.ts +104 -2
- package/src/services/update-plan.ts +188 -1
- package/src/types/index.ts +44 -23
- package/src/ui/App.tsx +123 -0
- package/src/ui/components/layout/FooterHints.tsx +21 -9
- package/src/ui/components/modals/ModalContainer.tsx +1 -0
- package/src/ui/components/modals/SelectModal.tsx +142 -21
- package/src/ui/hooks/useProfileStartupModal.ts +260 -0
- package/src/ui/hooks/useStaleLoadModal.ts +157 -0
- package/src/ui/renderers/pluginRenderers.tsx +29 -0
- package/src/ui/renderers/pluginStatusText.ts +14 -0
- package/src/ui/renderers/profileRenderers.tsx +27 -4
- package/src/ui/screens/PluginsScreen.tsx +46 -13
- package/src/ui/screens/ProfilesScreen.tsx +1 -1
- package/src/ui/state/types.ts +18 -0
- package/src/ui/theme-mode.ts +54 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
buildStaleLoadModal,
|
|
4
|
+
staleLoadRepairSummary,
|
|
5
|
+
} from "../ui/hooks/useStaleLoadModal";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The startup popup for a broken plugin.
|
|
9
|
+
*
|
|
10
|
+
* Same shape as buildGitignoreModal beside it, with one difference that is the
|
|
11
|
+
* whole point of the popup: FIXING is the default. A plugin the loader resolves
|
|
12
|
+
* at the wrong version means every session in this directory runs code nobody
|
|
13
|
+
* installed, so Enter must repair it rather than dismiss.
|
|
14
|
+
*/
|
|
15
|
+
describe("buildStaleLoadModal", () => {
|
|
16
|
+
const args = (
|
|
17
|
+
over: Partial<Parameters<typeof buildStaleLoadModal>[0]> = {},
|
|
18
|
+
) => ({
|
|
19
|
+
brokenCount: 2,
|
|
20
|
+
onFix: () => {},
|
|
21
|
+
onOpenTab: () => {},
|
|
22
|
+
onDismiss: () => {},
|
|
23
|
+
...over,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("offers fix, review and dismiss, with FIX first", () => {
|
|
27
|
+
const modal = buildStaleLoadModal(args());
|
|
28
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
29
|
+
|
|
30
|
+
expect(modal.options.map((o) => o.value)).toEqual([
|
|
31
|
+
"fix",
|
|
32
|
+
"open-tab",
|
|
33
|
+
"dismiss",
|
|
34
|
+
]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("defaults to FIX explicitly, not by accident of ordering", () => {
|
|
38
|
+
// Stated in the data so a future reordering cannot quietly make "leave it
|
|
39
|
+
// broken" the thing Enter does.
|
|
40
|
+
const modal = buildStaleLoadModal(args());
|
|
41
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
42
|
+
|
|
43
|
+
expect(modal.defaultIndex).toBe(0);
|
|
44
|
+
expect(modal.options[modal.defaultIndex ?? -1]?.value).toBe("fix");
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("blames the Claude Code bug, not magus's own state", () => {
|
|
48
|
+
// The title is the whole message for anyone who reads one line. It has to
|
|
49
|
+
// say what is wrong — a defect in Claude Code's loader — rather than
|
|
50
|
+
// report that two of magus's stores disagree, which is not actionable and
|
|
51
|
+
// reads as though the user misconfigured something.
|
|
52
|
+
const modal = buildStaleLoadModal(args({ brokenCount: 3 }));
|
|
53
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
54
|
+
|
|
55
|
+
expect(modal.title).toContain("Claude Code");
|
|
56
|
+
expect(modal.message).toContain("stale cached version");
|
|
57
|
+
expect(modal.options[0].label).toBe("Fix it (3 plugins)");
|
|
58
|
+
expect(modal.message).toContain("3 plugins are on an older version");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("says the reach is every worktree, not just this directory", () => {
|
|
62
|
+
// Relevance is decided by repository root, so every worktree and the main
|
|
63
|
+
// checkout resolve identically and break together. A reader told only
|
|
64
|
+
// about "this directory" would fix one worktree and expect the others to
|
|
65
|
+
// differ — they cannot.
|
|
66
|
+
const modal = buildStaleLoadModal(args({ brokenCount: 3 }));
|
|
67
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
68
|
+
|
|
69
|
+
expect(modal.message).toContain("every worktree");
|
|
70
|
+
expect(modal.message).toContain("main checkout");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("states the effect plainly, without overstating it", () => {
|
|
74
|
+
// A draft claimed "your sessions run code you did not install". Nothing
|
|
75
|
+
// happens to the user's code; an old cached plugin version is used. The
|
|
76
|
+
// overstatement is pinned here so it cannot come back on the next edit.
|
|
77
|
+
const modal = buildStaleLoadModal(args({ brokenCount: 3 }));
|
|
78
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
79
|
+
|
|
80
|
+
expect(modal.message).not.toContain("run code");
|
|
81
|
+
expect(modal.message).not.toContain("did not install");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("reads correctly for a single plugin", () => {
|
|
85
|
+
const modal = buildStaleLoadModal(args({ brokenCount: 1 }));
|
|
86
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
87
|
+
|
|
88
|
+
expect(modal.options[0].label).toBe("Fix it (1 plugin)");
|
|
89
|
+
expect(modal.message).toContain("1 plugin is on an older version");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("promises the backup and promises not to delete or downgrade", () => {
|
|
93
|
+
// The popup is asking permission to edit Claude Code-owned state. What it
|
|
94
|
+
// will and will not do has to be on the screen, not only in a code comment.
|
|
95
|
+
const modal = buildStaleLoadModal(args());
|
|
96
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
97
|
+
|
|
98
|
+
expect(modal.message).toContain(
|
|
99
|
+
"installed_plugins.json is backed up first",
|
|
100
|
+
);
|
|
101
|
+
// Case-insensitive: the promise has to be on screen, but whether it opens a
|
|
102
|
+
// sentence or trails one is a wording choice, and pinning the capital made
|
|
103
|
+
// an ordinary copy edit fail for nothing.
|
|
104
|
+
expect(modal.message.toLowerCase()).toContain(
|
|
105
|
+
"nothing is deleted or downgraded",
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("routes each choice to its callback, and cancel to dismiss", () => {
|
|
110
|
+
let fixed = 0;
|
|
111
|
+
let opened = 0;
|
|
112
|
+
let dismissed = 0;
|
|
113
|
+
const modal = buildStaleLoadModal(
|
|
114
|
+
args({
|
|
115
|
+
onFix: () => {
|
|
116
|
+
fixed++;
|
|
117
|
+
},
|
|
118
|
+
onOpenTab: () => {
|
|
119
|
+
opened++;
|
|
120
|
+
},
|
|
121
|
+
onDismiss: () => {
|
|
122
|
+
dismissed++;
|
|
123
|
+
},
|
|
124
|
+
}),
|
|
125
|
+
);
|
|
126
|
+
if (modal.type !== "select") throw new Error("type guard");
|
|
127
|
+
|
|
128
|
+
modal.onSelect("fix");
|
|
129
|
+
modal.onSelect("open-tab");
|
|
130
|
+
modal.onSelect("dismiss");
|
|
131
|
+
modal.onCancel();
|
|
132
|
+
|
|
133
|
+
expect({ fixed, opened, dismissed }).toEqual({
|
|
134
|
+
fixed: 1,
|
|
135
|
+
opened: 1,
|
|
136
|
+
dismissed: 2,
|
|
137
|
+
});
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("staleLoadRepairSummary", () => {
|
|
142
|
+
it("says what loads NOW for each repaired plugin", () => {
|
|
143
|
+
expect(
|
|
144
|
+
staleLoadRepairSummary([
|
|
145
|
+
{ pluginId: "madbench@magus", target: "0.5.0", ok: true },
|
|
146
|
+
{ pluginId: "dev@magus", target: "7.4.0", ok: true },
|
|
147
|
+
]),
|
|
148
|
+
).toBe("✓ madbench@magus now loads v0.5.0\n✓ dev@magus now loads v7.4.0");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("reports a failed repair with its own reason, never as a success", () => {
|
|
152
|
+
expect(
|
|
153
|
+
staleLoadRepairSummary([
|
|
154
|
+
{
|
|
155
|
+
pluginId: "madbench@magus",
|
|
156
|
+
target: "0.5.0",
|
|
157
|
+
ok: false,
|
|
158
|
+
error: "the loader still resolves v0.3.0 here, not v0.5.0",
|
|
159
|
+
},
|
|
160
|
+
]),
|
|
161
|
+
).toBe(
|
|
162
|
+
"✗ madbench@magus — the loader still resolves v0.3.0 here, not v0.5.0",
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("falls back to a plain reason rather than an empty line", () => {
|
|
167
|
+
expect(
|
|
168
|
+
staleLoadRepairSummary([
|
|
169
|
+
{ pluginId: "a@magus", target: "1.0.0", ok: false },
|
|
170
|
+
]),
|
|
171
|
+
).toBe("✗ a@magus — repair did not take effect");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("says so when there was nothing to repair", () => {
|
|
175
|
+
expect(staleLoadRepairSummary([])).toBe("Nothing to repair.");
|
|
176
|
+
});
|
|
177
|
+
});
|
package/src/cli/ansi.ts
CHANGED
|
@@ -513,6 +513,45 @@ export function badge(label: string, hex: string, ink?: string): string {
|
|
|
513
513
|
*/
|
|
514
514
|
const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] as const;
|
|
515
515
|
|
|
516
|
+
/**
|
|
517
|
+
* A bordered dialog: title in the top rule, body inside, one blank row of
|
|
518
|
+
* breathing space top and bottom.
|
|
519
|
+
*
|
|
520
|
+
* Built on `padEnd`, which measures visible cells, so a body line carrying
|
|
521
|
+
* colour still aligns — padding a coloured string by `String.length` counts the
|
|
522
|
+
* escape bytes and shears the right border by however many bytes the colour
|
|
523
|
+
* cost.
|
|
524
|
+
*
|
|
525
|
+
* The border and the rule are dim and the body is not: a frame that competes
|
|
526
|
+
* with its own contents is why a boxed message can read as louder but say less.
|
|
527
|
+
*/
|
|
528
|
+
export function dialog(
|
|
529
|
+
title: string,
|
|
530
|
+
lines: string[],
|
|
531
|
+
opts: { width?: number; color?: string } = {},
|
|
532
|
+
): string[] {
|
|
533
|
+
const inner = Math.max(
|
|
534
|
+
opts.width ?? 0,
|
|
535
|
+
...lines.map((l) => width(l)),
|
|
536
|
+
width(title) + 4,
|
|
537
|
+
);
|
|
538
|
+
const accent = opts.color;
|
|
539
|
+
const paint = (s: string) => (accent ? fg(accent, s) : s);
|
|
540
|
+
|
|
541
|
+
// Every row must be `inner + 4` cells: corner, space, body, space, corner.
|
|
542
|
+
// The head spends 2 on "╭─" and `width(title) + 2` on the titled gap, so the
|
|
543
|
+
// rule takes the rest. Getting this off by one is invisible in source and
|
|
544
|
+
// obvious on screen — the right border jags one column in at the title row.
|
|
545
|
+
const head = `${paint("╭─")}${paint(` ${title} `)}${dim(
|
|
546
|
+
"─".repeat(Math.max(0, inner - width(title) - 1)),
|
|
547
|
+
)}${dim("╮")}`;
|
|
548
|
+
const foot = dim(`╰${"─".repeat(inner + 2)}╯`);
|
|
549
|
+
const row = (text: string) =>
|
|
550
|
+
`${dim("│")} ${padEnd(text, inner)} ${dim("│")}`;
|
|
551
|
+
|
|
552
|
+
return [head, row(""), ...lines.map(row), row(""), foot];
|
|
553
|
+
}
|
|
554
|
+
|
|
516
555
|
export function spinner(tick: number): string {
|
|
517
556
|
return SPINNER[Math.abs(Math.trunc(tick)) % SPINNER.length];
|
|
518
557
|
}
|
package/src/cli/bootstrap.ts
CHANGED
|
@@ -8,17 +8,74 @@
|
|
|
8
8
|
* stands, then the original command proceeds against it normally.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import path from "node:path";
|
|
11
12
|
import {
|
|
12
13
|
getManifestPath,
|
|
13
14
|
readManifest,
|
|
14
15
|
writeManifest,
|
|
15
16
|
} from "../services/manifest.js";
|
|
17
|
+
import {
|
|
18
|
+
applyProfileChoice,
|
|
19
|
+
materializeAndActivate,
|
|
20
|
+
reportDisplaced,
|
|
21
|
+
} from "../services/profile-activate.js";
|
|
16
22
|
import {
|
|
17
23
|
type AdoptResult,
|
|
18
24
|
adoptCurrentProject,
|
|
19
25
|
} from "../services/profile-adopt.js";
|
|
26
|
+
import {
|
|
27
|
+
buildProfileOptions,
|
|
28
|
+
defaultOptionIndex,
|
|
29
|
+
describeNeed,
|
|
30
|
+
detectProfileStartupNeed,
|
|
31
|
+
needsNothing,
|
|
32
|
+
parseProfileChoice,
|
|
33
|
+
} from "../services/profile-startup.js";
|
|
20
34
|
import type { ProfileManifest } from "../types/index.js";
|
|
35
|
+
import { dim } from "./ansi.js";
|
|
21
36
|
import { confirm } from "./prompt.js";
|
|
37
|
+
import { selectDialog } from "./tui-prompt.js";
|
|
38
|
+
import { fail as failLine, note as noteLine } from "./update-view.js";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Write the adopted manifest, then put the project ON the profile it describes.
|
|
42
|
+
*
|
|
43
|
+
* Both halves or neither. Writing alone is what made migrating feel like a
|
|
44
|
+
* no-op: `activeProfile()` is derived from the symlink, so a manifest nothing
|
|
45
|
+
* links to changes no behaviour, and the run that just migrated still has to
|
|
46
|
+
* ask which profile to use.
|
|
47
|
+
*
|
|
48
|
+
* Activation is reported in full, including anything it moved aside, because it
|
|
49
|
+
* replaces files the user wrote by hand. A migration is allowed to change the
|
|
50
|
+
* working tree; it is not allowed to do it quietly.
|
|
51
|
+
*/
|
|
52
|
+
async function writeAndActivate(
|
|
53
|
+
result: AdoptResult,
|
|
54
|
+
projectPath: string,
|
|
55
|
+
): Promise<void> {
|
|
56
|
+
// Relative to the project throughout. These are all project paths, and an
|
|
57
|
+
// absolute one wraps over two terminal lines — several wrapped lines in a row
|
|
58
|
+
// is where a reader stops reading, and this notice is the one that says where
|
|
59
|
+
// their hand-written files went.
|
|
60
|
+
const rel = (p: string) => path.relative(projectPath, p);
|
|
61
|
+
|
|
62
|
+
await writeManifest(result.manifest, projectPath);
|
|
63
|
+
console.log(`✓ Wrote ${rel(getManifestPath(projectPath))}`);
|
|
64
|
+
|
|
65
|
+
// Each move is printed as it happens, not from the returned list afterwards.
|
|
66
|
+
// Activation touches up to four artifacts in sequence, so a throw on the
|
|
67
|
+
// third would discard the record of the first two — and the first of those
|
|
68
|
+
// is the user's `.claude/settings.json`, now absent from where they left it.
|
|
69
|
+
// Printing first means a failed activation still says where everything went.
|
|
70
|
+
await materializeAndActivate(
|
|
71
|
+
result.manifest,
|
|
72
|
+
result.profileId,
|
|
73
|
+
projectPath,
|
|
74
|
+
reportDisplaced(projectPath),
|
|
75
|
+
);
|
|
76
|
+
console.log(`✓ Active profile: ${result.profileId}`);
|
|
77
|
+
console.log();
|
|
78
|
+
}
|
|
22
79
|
|
|
23
80
|
/** Print what adoption would capture, so the user can judge it before writing. */
|
|
24
81
|
function printAdoptionPlan(result: AdoptResult): void {
|
|
@@ -64,6 +121,16 @@ function printAdoptionPlan(result: AdoptResult): void {
|
|
|
64
121
|
console.log(
|
|
65
122
|
"\nCommit the file to share this setup with the team; edit it to pin versions.",
|
|
66
123
|
);
|
|
124
|
+
// Said before the question, not after it: adopting also SWITCHES the project
|
|
125
|
+
// onto the profile, which replaces two files that are in the working tree
|
|
126
|
+
// right now. The prompt below is the only place to decline.
|
|
127
|
+
console.log(
|
|
128
|
+
"The project then runs this profile: .claude/settings.json, .mcp.json and",
|
|
129
|
+
);
|
|
130
|
+
console.log(
|
|
131
|
+
".claude/skills become links into it, and your current copies are kept where",
|
|
132
|
+
);
|
|
133
|
+
console.log("the run reports.");
|
|
67
134
|
}
|
|
68
135
|
|
|
69
136
|
export interface EnsureManifestOptions {
|
|
@@ -123,12 +190,111 @@ export async function ensureManifest(
|
|
|
123
190
|
"\nRefusing to create .claude/profiles.json without a human: run `magus profile init` to create it deliberately.",
|
|
124
191
|
);
|
|
125
192
|
return null;
|
|
126
|
-
} else if (!(await confirm("\nCreate
|
|
193
|
+
} else if (!(await confirm("\nCreate it and switch to it?"))) {
|
|
127
194
|
console.log("Aborted — nothing written.");
|
|
128
195
|
return null;
|
|
129
196
|
}
|
|
130
197
|
|
|
131
|
-
await
|
|
132
|
-
console.log(`✓ Wrote ${getManifestPath(projectPath)}\n`);
|
|
198
|
+
await writeAndActivate(result, projectPath);
|
|
133
199
|
return result.manifest;
|
|
134
200
|
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Require an active profile, or stop the command.
|
|
204
|
+
*
|
|
205
|
+
* magus manages a project THROUGH its active profile: that is where plugin
|
|
206
|
+
* state is read from and where updates are written. With none there is nothing
|
|
207
|
+
* to read and nowhere to write, so there is no useful work to do — and the
|
|
208
|
+
* previous version of this offered exactly that anyway. It asked only when the
|
|
209
|
+
* manifest had drifted, let the user decline, and `update` then carried on with
|
|
210
|
+
* a profile "for this run only", resolving against a manifest the project was
|
|
211
|
+
* not running. Two worlds live at once, which is the confusion being removed.
|
|
212
|
+
*
|
|
213
|
+
* So this asks whenever no profile is active, and the only answers are migrate,
|
|
214
|
+
* activate, or stop. Returns the manifest to carry on with, or null when the
|
|
215
|
+
* command must exit non-zero — by which point it has printed why.
|
|
216
|
+
*
|
|
217
|
+
* Nothing is compared. An earlier version weighed the manifest against
|
|
218
|
+
* `enabledPlugins`; see the header of `services/profile-startup.ts` for why
|
|
219
|
+
* that reads a profiled project's own generated output and cannot be acted on.
|
|
220
|
+
*/
|
|
221
|
+
export async function requireActiveProfile(
|
|
222
|
+
projectPath: string,
|
|
223
|
+
manifest: ProfileManifest,
|
|
224
|
+
opts: EnsureManifestOptions = {},
|
|
225
|
+
): Promise<ProfileManifest | null> {
|
|
226
|
+
const need = await detectProfileStartupNeed(projectPath);
|
|
227
|
+
if (needsNothing(need)) return manifest;
|
|
228
|
+
|
|
229
|
+
// The repo declares which profile it wants and there is nothing of the
|
|
230
|
+
// user's to replace — a fresh clone. Activate it and carry on, even under
|
|
231
|
+
// `--check` and with no TTY: this is the committed answer being applied, not
|
|
232
|
+
// a decision being made, and it is exactly the case CI hits.
|
|
233
|
+
if (need.autoActivate && need.declaredDefault !== null && !opts.check) {
|
|
234
|
+
const { profileId } = await applyProfileChoice(
|
|
235
|
+
{ kind: "activate", id: need.declaredDefault },
|
|
236
|
+
need.manifest,
|
|
237
|
+
projectPath,
|
|
238
|
+
reportDisplaced(projectPath),
|
|
239
|
+
);
|
|
240
|
+
console.log(
|
|
241
|
+
`✓ Active profile: ${profileId} ${dim("(declared by this project)")}`,
|
|
242
|
+
);
|
|
243
|
+
console.log("");
|
|
244
|
+
return readManifest(projectPath);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const { title } = describeNeed(need);
|
|
248
|
+
|
|
249
|
+
// `--check` is a read-only CI gate, and "this project is not on a profile"
|
|
250
|
+
// is a legitimate thing for it to fail on rather than quietly repair.
|
|
251
|
+
if (opts.check) {
|
|
252
|
+
console.error(failLine(title));
|
|
253
|
+
console.error(noteLine("Run `magus update` at a terminal to resolve it."));
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// CI has nobody to ask. Exiting beats hanging on an answer that cannot come.
|
|
258
|
+
if (!process.stdin.isTTY) {
|
|
259
|
+
console.error(failLine(title));
|
|
260
|
+
console.error(
|
|
261
|
+
noteLine(
|
|
262
|
+
need.state === "unmigrated"
|
|
263
|
+
? "Run `magus profile init` to create one deliberately."
|
|
264
|
+
: `Run \`magus profile switch <${need.activate
|
|
265
|
+
.map((p) => p.id)
|
|
266
|
+
.join("|")}>\` first.`,
|
|
267
|
+
),
|
|
268
|
+
);
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
console.log("");
|
|
273
|
+
const chosen = await selectDialog({
|
|
274
|
+
title,
|
|
275
|
+
message: describeNeed(need).message,
|
|
276
|
+
options: buildProfileOptions(need),
|
|
277
|
+
defaultIndex: defaultOptionIndex(need),
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
// Esc, Ctrl+C and the "Quit magus" row all land here, and there is no third
|
|
281
|
+
// outcome: the command stops rather than proceed without a profile.
|
|
282
|
+
const choice = chosen === null ? null : parseProfileChoice(chosen, need);
|
|
283
|
+
if (!choice) {
|
|
284
|
+
console.error(failLine("No active profile — stopping."));
|
|
285
|
+
return null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const { profileId } = await applyProfileChoice(
|
|
289
|
+
choice,
|
|
290
|
+
need.manifest,
|
|
291
|
+
projectPath,
|
|
292
|
+
reportDisplaced(projectPath),
|
|
293
|
+
);
|
|
294
|
+
console.log(`✓ Active profile: ${profileId}`);
|
|
295
|
+
console.log("");
|
|
296
|
+
|
|
297
|
+
// Re-read rather than return `need.manifest`: adopting wrote a new profile
|
|
298
|
+
// into the file, and the caller resolves against whatever comes back.
|
|
299
|
+
return readManifest(projectPath);
|
|
300
|
+
}
|
package/src/cli/doctor.ts
CHANGED
|
@@ -7,10 +7,16 @@
|
|
|
7
7
|
* 2. Profile symlink integrity — the active profile's links resolve.
|
|
8
8
|
* 3. Convention compliance — .gitignore + CLAUDE.md blocks (ported engine).
|
|
9
9
|
* 4. Model routing — config validity, hook registration, and drift.
|
|
10
|
+
* 5. Plugin loads — what Claude Code will load here vs what is installed.
|
|
10
11
|
*/
|
|
11
12
|
|
|
12
13
|
import path from "node:path";
|
|
13
14
|
import fs from "fs-extra";
|
|
15
|
+
import {
|
|
16
|
+
getProjectInstalledPluginVersions,
|
|
17
|
+
readInstalledPluginsRegistry,
|
|
18
|
+
readSettings,
|
|
19
|
+
} from "../services/claude-settings.js";
|
|
14
20
|
import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
|
|
15
21
|
import { checkProject, fixDoctorIssues } from "../services/doctor.js";
|
|
16
22
|
import {
|
|
@@ -22,6 +28,11 @@ import {
|
|
|
22
28
|
readModelsConfig,
|
|
23
29
|
readModelsStatus,
|
|
24
30
|
} from "../services/models-manager.js";
|
|
31
|
+
import { isBehind } from "../services/plugin-manager.js";
|
|
32
|
+
import {
|
|
33
|
+
type RepoRootCache,
|
|
34
|
+
pickLoadedEntry,
|
|
35
|
+
} from "../services/plugin-resolution.js";
|
|
25
36
|
import { resolveAllProfiles } from "../services/resolver.js";
|
|
26
37
|
import { activeProfile, profileDir } from "../services/symlink-manager.js";
|
|
27
38
|
import { resolveExecutable } from "../utils/command-utils.js";
|
|
@@ -111,8 +122,123 @@ export async function runDoctorCommand(args: string[]): Promise<number> {
|
|
|
111
122
|
// 4. Model routing.
|
|
112
123
|
problems += await checkModels(projectPath, fix);
|
|
113
124
|
|
|
114
|
-
|
|
115
|
-
|
|
125
|
+
// 5. Stale plugin loads. Counted SEPARATELY, because `--fix` does not repair
|
|
126
|
+
// them — see doctorExitCode.
|
|
127
|
+
const staleLoads = await checkPluginLoads(projectPath);
|
|
128
|
+
|
|
129
|
+
if (problems === 0 && staleLoads === 0) console.log("\n✓ No problems found.");
|
|
130
|
+
return doctorExitCode({ fixable: problems, unfixable: staleLoads, fix });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Doctor's exit code.
|
|
135
|
+
*
|
|
136
|
+
* `--fix` suppresses the exit code for what it repaired — that is the point of
|
|
137
|
+
* the flag, and a caller that asked for repairs does not want a red exit over
|
|
138
|
+
* problems that are now gone. It must NOT suppress a class it never touches.
|
|
139
|
+
*
|
|
140
|
+
* Stale plugin loads are exactly that class: `checkPluginLoads` prints them and
|
|
141
|
+
* says so, and the repair lives in `repairStaleLoad`, reached by `magus update`
|
|
142
|
+
* and the Plugins screen. Folding them into the same counter meant `magus doctor
|
|
143
|
+
* --fix` printed red lines and exited 0 — so a CI or pre-flight step went green
|
|
144
|
+
* over a plugin whose sessions run a version nobody installed, which is the same
|
|
145
|
+
* deception the stale-load work exists to end.
|
|
146
|
+
*
|
|
147
|
+
* Pure and exported so the decision is pinned by a test rather than inferred from
|
|
148
|
+
* a command that needs a real `~/.claude` to run.
|
|
149
|
+
*/
|
|
150
|
+
export function doctorExitCode(args: {
|
|
151
|
+
/** Problems `--fix` can act on. */
|
|
152
|
+
fixable: number;
|
|
153
|
+
/** Problems it cannot — reported either way, and never silenced by `--fix`. */
|
|
154
|
+
unfixable: number;
|
|
155
|
+
fix: boolean;
|
|
156
|
+
}): number {
|
|
157
|
+
if (args.unfixable > 0) return 1;
|
|
158
|
+
return args.fixable > 0 && !args.fix ? 1 : 0;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Plugins whose installed version is NOT the version that will load here.
|
|
163
|
+
*
|
|
164
|
+
* Reported, never repaired — `--fix` deliberately does nothing about it, and
|
|
165
|
+
* that is a division of labour rather than a dead end. A reinstall does not
|
|
166
|
+
* clear this (measured): it writes another `installed_plugins.json` row for THIS
|
|
167
|
+
* project, while the row the loader picks belongs to a different one — usually a
|
|
168
|
+
* worktree that has since been deleted, whose entry nothing prunes. Moving those
|
|
169
|
+
* rows forward is what fixes it, and that lives in `repairStaleLoad`, reached by
|
|
170
|
+
* `magus update` and by the Plugins screen's `a` key. Doctor's job is to name
|
|
171
|
+
* the winner, which is the one thing neither of those prints.
|
|
172
|
+
*
|
|
173
|
+
* Both halves come from the registry rather than the catalog, so this section
|
|
174
|
+
* makes no network call and holds even when GitHub is unreachable:
|
|
175
|
+
* installed — magus's own resolution, `pickRegistryEntry` via settings overlay
|
|
176
|
+
* loads — the loader's, `pickLoadedEntry`
|
|
177
|
+
*/
|
|
178
|
+
async function checkPluginLoads(projectPath: string): Promise<number> {
|
|
179
|
+
const registry = await readInstalledPluginsRegistry();
|
|
180
|
+
const settings = await readSettings(projectPath);
|
|
181
|
+
const installed = await getProjectInstalledPluginVersions(
|
|
182
|
+
settings.installedPluginVersions || {},
|
|
183
|
+
projectPath,
|
|
184
|
+
);
|
|
185
|
+
const ids = Object.keys(installed);
|
|
186
|
+
if (ids.length === 0) return 0;
|
|
187
|
+
|
|
188
|
+
// One cache across every plugin: the same handful of project paths repeat in
|
|
189
|
+
// hundreds of rows, and each miss walks the tree to the filesystem root.
|
|
190
|
+
const cache: RepoRootCache = new Map();
|
|
191
|
+
const stale: Array<{
|
|
192
|
+
pluginId: string;
|
|
193
|
+
loaded: string;
|
|
194
|
+
recorded: string;
|
|
195
|
+
winner: string;
|
|
196
|
+
}> = [];
|
|
197
|
+
|
|
198
|
+
for (const pluginId of ids.sort()) {
|
|
199
|
+
const recorded = installed[pluginId];
|
|
200
|
+
if (!recorded) continue;
|
|
201
|
+
const pick = pickLoadedEntry(
|
|
202
|
+
registry.plugins?.[pluginId],
|
|
203
|
+
projectPath,
|
|
204
|
+
cache,
|
|
205
|
+
);
|
|
206
|
+
if (!pick?.version) continue;
|
|
207
|
+
// The same predicate the classifier uses, so doctor and the plugin screen
|
|
208
|
+
// cannot disagree about which pairs count. Unorderable pairs answer null
|
|
209
|
+
// and are left alone — "cannot tell" is not a finding.
|
|
210
|
+
if (isBehind(recorded, pick.version) !== true) continue;
|
|
211
|
+
stale.push({
|
|
212
|
+
pluginId,
|
|
213
|
+
loaded: pick.version,
|
|
214
|
+
recorded,
|
|
215
|
+
winner:
|
|
216
|
+
pick.projectPath ??
|
|
217
|
+
(pick.scope === "user" ? "user scope (no project)" : "unknown project"),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log("\nPlugin loads:");
|
|
222
|
+
if (stale.length === 0) {
|
|
223
|
+
console.log(
|
|
224
|
+
` ✓ All ${ids.length} installed plugins load at their installed version.`,
|
|
225
|
+
);
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const row of stale) {
|
|
230
|
+
console.log(
|
|
231
|
+
` ✗ ${row.pluginId} — loads v${row.loaded}, installed v${row.recorded}`,
|
|
232
|
+
);
|
|
233
|
+
console.log(` the entry that wins belongs to ${row.winner}`);
|
|
234
|
+
}
|
|
235
|
+
console.log(
|
|
236
|
+
" Reinstalling does not fix this. Run `magus update` — it moves those",
|
|
237
|
+
);
|
|
238
|
+
console.log(
|
|
239
|
+
" install records forward, backing up installed_plugins.json first.",
|
|
240
|
+
);
|
|
241
|
+
return stale.length;
|
|
116
242
|
}
|
|
117
243
|
|
|
118
244
|
/**
|
package/src/cli/install.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
} from "../services/install-plan.js";
|
|
28
28
|
import { validateManifest } from "../services/manifest.js";
|
|
29
29
|
import { registerClosureMarketplaces } from "../services/marketplace-sync.js";
|
|
30
|
+
import { reportDisplaced } from "../services/profile-activate.js";
|
|
30
31
|
import { materializeProfile } from "../services/profile-materializer.js";
|
|
31
32
|
import { detectProfileDrift } from "../services/profile-sync.js";
|
|
32
33
|
import { resolveAllProfiles, resolveProfile } from "../services/resolver.js";
|
|
@@ -355,7 +356,10 @@ export async function runInstallCommand(
|
|
|
355
356
|
const activeId =
|
|
356
357
|
flags.profile ?? (await pickActiveProfile(profileIds, flags.yes));
|
|
357
358
|
if (activeId) {
|
|
358
|
-
|
|
359
|
+
// The reporter is not optional here. The first `install` in a project that
|
|
360
|
+
// had a hand-written .claude/settings.json moves it aside, and without
|
|
361
|
+
// this the move is invisible — the file is simply not where it was.
|
|
362
|
+
await activateProfile(activeId, projectPath, reportDisplaced(projectPath));
|
|
359
363
|
console.log(`\n✓ Active profile: ${activeId}`);
|
|
360
364
|
// Skills route through the now-active .claude/skills symlink.
|
|
361
365
|
const activeClosure = await resolveProfile(manifest, activeId);
|
package/src/cli/profile.ts
CHANGED
|
@@ -13,6 +13,7 @@ import { ensureGitignoreEntries } from "../services/conventions-manager.js";
|
|
|
13
13
|
import { checkBinaries, missingBinaries } from "../services/doctor-bins.js";
|
|
14
14
|
import { summarizeClosure } from "../services/install-plan.js";
|
|
15
15
|
import { getManifestPath, readManifest } from "../services/manifest.js";
|
|
16
|
+
import { reportDisplaced } from "../services/profile-activate.js";
|
|
16
17
|
import { syncProfile } from "../services/profile-sync.js";
|
|
17
18
|
import { resolveProfile } from "../services/resolver.js";
|
|
18
19
|
import {
|
|
@@ -147,7 +148,10 @@ export async function switchProfile(
|
|
|
147
148
|
"# magus: generated profile build output + active-profile symlinks",
|
|
148
149
|
);
|
|
149
150
|
|
|
150
|
-
|
|
151
|
+
// Switching between two already-active profiles displaces nothing — the paths
|
|
152
|
+
// are links magus owns. The FIRST switch in an unmanaged project displaces
|
|
153
|
+
// real files, and that is the run that must say so.
|
|
154
|
+
await activateProfile(name, projectPath, reportDisplaced(projectPath));
|
|
151
155
|
console.log(`✓ Switched to profile: ${name}`);
|
|
152
156
|
|
|
153
157
|
// Verify the profile's binary deps are actually usable — the switch itself
|