magus-cli 7.5.3 → 7.6.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-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__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/key-focus-guard.test.tsx +260 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- 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-name-on-save.test.ts +443 -0
- package/src/__tests__/profile-skills.test.ts +3 -3
- 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-startup.test.ts +1 -1
- package/src/__tests__/profile-state.test.ts +27 -22
- package/src/__tests__/prompt-eof.test.ts +245 -0
- 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 +271 -66
- package/src/cli/doctor.ts +4 -3
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +19 -5
- package/src/cli/router.ts +1 -0
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +120 -6
- package/src/cli/update.ts +6 -4
- package/src/services/claude-settings.ts +28 -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 +10 -7
- package/src/services/profile-activate.ts +41 -19
- package/src/services/profile-adopt.ts +9 -2
- package/src/services/profile-startup.ts +206 -5
- package/src/services/profile-state.ts +8 -7
- package/src/services/tracked-live-files.ts +24 -3
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +96 -56
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/modals/ModalContainer.tsx +3 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +21 -3
- package/src/ui/hooks/useProfileStartupModal.ts +214 -15
- package/src/ui/hooks/useStaleLoadModal.ts +5 -1
- package/src/ui/input/KeyRouter.tsx +43 -2
- 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 +28 -41
- package/src/cli/tui-prompt.tsx +0 -143
package/src/cli/install.ts
CHANGED
|
@@ -48,9 +48,20 @@ import { bringLoadForward } from "../services/update-engine.js";
|
|
|
48
48
|
import type { ResolvedBin, ResolvedClosure } from "../types/index.js";
|
|
49
49
|
import { formatCommand, resolveExecutable } from "../utils/command-utils.js";
|
|
50
50
|
import { runCommand, runShellScript } from "../utils/run.js";
|
|
51
|
+
import { bold } from "./ansi.js";
|
|
51
52
|
import { ensureManifest, printActivationNotes } from "./bootstrap.js";
|
|
52
|
-
import {
|
|
53
|
-
import {
|
|
53
|
+
import { askYesNo, promptValue } from "./prompt.js";
|
|
54
|
+
import { selectOption } from "./select.js";
|
|
55
|
+
import {
|
|
56
|
+
commandHeader,
|
|
57
|
+
fail as failLine,
|
|
58
|
+
field,
|
|
59
|
+
info as infoLine,
|
|
60
|
+
note as noteLine,
|
|
61
|
+
ok as okLine,
|
|
62
|
+
question,
|
|
63
|
+
warn as warnLine,
|
|
64
|
+
} from "./update-view.js";
|
|
54
65
|
|
|
55
66
|
interface InstallFlags {
|
|
56
67
|
check: boolean;
|
|
@@ -119,8 +130,10 @@ async function ensureToolchains(
|
|
|
119
130
|
}
|
|
120
131
|
const ok =
|
|
121
132
|
yes ||
|
|
122
|
-
(await
|
|
123
|
-
|
|
133
|
+
(await askYesNo(
|
|
134
|
+
question(
|
|
135
|
+
`${tc.name} is missing — ${tc.requiredBy.join(", ")} need${tc.requiredBy.length === 1 ? "s" : ""} it. Install it?`,
|
|
136
|
+
),
|
|
124
137
|
));
|
|
125
138
|
if (ok) {
|
|
126
139
|
console.log(`Installing ${tc.name}…`);
|
|
@@ -149,15 +162,18 @@ async function ensureToolchains(
|
|
|
149
162
|
* EVERY profile's plugins — so the caller runs this inside
|
|
150
163
|
* `withCliBookkeeping`, which renders the live files over it afterwards.
|
|
151
164
|
*/
|
|
152
|
-
async function installPlugins(closure: ResolvedClosure): Promise<
|
|
165
|
+
async function installPlugins(closure: ResolvedClosure): Promise<string[]> {
|
|
166
|
+
const installed: string[] = [];
|
|
153
167
|
for (const pluginId of Object.keys(closure.plugins)) {
|
|
154
168
|
try {
|
|
155
169
|
console.log(`+ plugin ${pluginId}`);
|
|
156
170
|
await installPlugin(pluginId, "project");
|
|
171
|
+
installed.push(pluginId);
|
|
157
172
|
} catch (e) {
|
|
158
173
|
console.warn(`⚠ plugin ${pluginId}: ${(e as Error).message}`);
|
|
159
174
|
}
|
|
160
175
|
}
|
|
176
|
+
return installed;
|
|
161
177
|
}
|
|
162
178
|
|
|
163
179
|
/**
|
|
@@ -176,7 +192,15 @@ async function bringLoadsForward(
|
|
|
176
192
|
"project",
|
|
177
193
|
projectPath,
|
|
178
194
|
);
|
|
179
|
-
if (!here)
|
|
195
|
+
if (!here) {
|
|
196
|
+
// Skipped rather than checked against another scope's record: that
|
|
197
|
+
// would repair toward a version this project does not have. Said, so a
|
|
198
|
+
// plugin that installed but left nothing to check is not silent.
|
|
199
|
+
console.warn(
|
|
200
|
+
`⚠ ${pluginId}: installed, but Claude Code recorded no version for this project, so magus cannot check which version loads here`,
|
|
201
|
+
);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
180
204
|
const forward = await bringLoadForward(pluginId, here, projectPath);
|
|
181
205
|
if (!forward) continue;
|
|
182
206
|
if (forward.ok) {
|
|
@@ -314,18 +338,32 @@ export async function runInstallCommand(
|
|
|
314
338
|
}
|
|
315
339
|
|
|
316
340
|
// Plan
|
|
317
|
-
|
|
318
|
-
|
|
341
|
+
// In the report style `update` uses: a header, then the plan as aligned
|
|
342
|
+
// rows. `summarizeClosure` pads each line as "label values"; split at the
|
|
343
|
+
// padding so the label takes the dim label column.
|
|
344
|
+
for (const line of commandHeader(
|
|
345
|
+
"install",
|
|
346
|
+
flags.profile
|
|
347
|
+
? { label: "profile", value: flags.profile }
|
|
348
|
+
: { label: "profiles", value: "all" },
|
|
349
|
+
)) {
|
|
350
|
+
console.log(line);
|
|
351
|
+
}
|
|
352
|
+
for (const line of summarizeClosure(closure)) {
|
|
353
|
+
const [label, ...rest] = line.split(/ {2,}/);
|
|
354
|
+
console.log(field(label ?? "", rest.join(" ")));
|
|
355
|
+
}
|
|
356
|
+
console.log("");
|
|
319
357
|
if (closure.conflicts?.length) {
|
|
320
358
|
console.log(
|
|
321
|
-
"
|
|
359
|
+
warnLine("Profiles pin different versions — the higher pin wins"),
|
|
322
360
|
);
|
|
323
|
-
for (const c of closure.conflicts) console.log(
|
|
361
|
+
for (const c of closure.conflicts) console.log(noteLine(c));
|
|
362
|
+
console.log("");
|
|
324
363
|
}
|
|
325
|
-
console.log();
|
|
326
364
|
|
|
327
|
-
if (!flags.yes && !(await
|
|
328
|
-
console.log("
|
|
365
|
+
if (!flags.yes && !(await askYesNo(question("Install this plan?")))) {
|
|
366
|
+
console.log(infoLine("Nothing installed."));
|
|
329
367
|
return 0;
|
|
330
368
|
}
|
|
331
369
|
|
|
@@ -363,6 +401,10 @@ export async function runInstallCommand(
|
|
|
363
401
|
flags.profile ??
|
|
364
402
|
current ??
|
|
365
403
|
(await pickActiveProfile(profileIds, flags.yes));
|
|
404
|
+
if (activeId === null) {
|
|
405
|
+
console.error(failLine("No profile chosen — stopping."));
|
|
406
|
+
return 1;
|
|
407
|
+
}
|
|
366
408
|
if (activeId) {
|
|
367
409
|
// The one switch path: what Claude Code wrote into the live config — or
|
|
368
410
|
// through an older layout's links — goes into the profile it was written
|
|
@@ -375,10 +417,10 @@ export async function runInstallCommand(
|
|
|
375
417
|
});
|
|
376
418
|
for (const line of switched.lines) console.log(noteLine(line));
|
|
377
419
|
if (!switched.activation) {
|
|
378
|
-
for (const problem of switched.problems) console.error(problem);
|
|
420
|
+
for (const problem of switched.problems) console.error(failLine(problem));
|
|
379
421
|
return 1;
|
|
380
422
|
}
|
|
381
|
-
console.log(
|
|
423
|
+
console.log(okLine(`Active profile ${bold(activeId)}`));
|
|
382
424
|
printActivationNotes(switched.activation);
|
|
383
425
|
}
|
|
384
426
|
|
|
@@ -387,11 +429,16 @@ export async function runInstallCommand(
|
|
|
387
429
|
// while they run and reconciles in `finally`: what the user changed in the
|
|
388
430
|
// meantime is kept, only the install's own records are discarded, and a
|
|
389
431
|
// Ctrl-C leaves the flag for the next command to do the same.
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
432
|
+
const installed = await withCliBookkeeping(
|
|
433
|
+
projectPath,
|
|
434
|
+
() => installPlugins(closure),
|
|
435
|
+
{
|
|
436
|
+
installable: Object.keys(closure.plugins),
|
|
437
|
+
report: (line) => console.log(noteLine(line)),
|
|
438
|
+
},
|
|
439
|
+
);
|
|
440
|
+
// Only what installed: a failed install already printed its own warning.
|
|
441
|
+
await bringLoadsForward(installed, projectPath);
|
|
395
442
|
await installBins(closure.bins);
|
|
396
443
|
|
|
397
444
|
if (activeId) {
|
|
@@ -406,16 +453,25 @@ export async function runInstallCommand(
|
|
|
406
453
|
return 0;
|
|
407
454
|
}
|
|
408
455
|
|
|
456
|
+
/**
|
|
457
|
+
* Which profile to make active when none is: the only one, the first under
|
|
458
|
+
* `--yes` or with nobody at the terminal, else the user's pick from an
|
|
459
|
+
* arrow-key list. Null when the user cancels the list — the install stops then,
|
|
460
|
+
* since magus does not run a project without a profile.
|
|
461
|
+
*/
|
|
409
462
|
async function pickActiveProfile(
|
|
410
463
|
profileIds: string[],
|
|
411
464
|
yes: boolean,
|
|
412
|
-
): Promise<string | undefined> {
|
|
465
|
+
): Promise<string | undefined | null> {
|
|
413
466
|
if (profileIds.length === 0) return undefined;
|
|
414
467
|
if (profileIds.length === 1) return profileIds[0];
|
|
415
|
-
if (yes)
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
const
|
|
420
|
-
|
|
468
|
+
if (yes || !process.stdin.isTTY || !process.stdout.isTTY) {
|
|
469
|
+
return profileIds[0];
|
|
470
|
+
}
|
|
471
|
+
console.log("");
|
|
472
|
+
const picked = await selectOption(
|
|
473
|
+
"Which profile should be active?",
|
|
474
|
+
profileIds.map((id) => ({ label: id })),
|
|
475
|
+
);
|
|
476
|
+
return picked === null ? null : profileIds[picked];
|
|
421
477
|
}
|
package/src/cli/prompt.ts
CHANGED
|
@@ -39,22 +39,36 @@ export function isPromptAborted(error: unknown): boolean {
|
|
|
39
39
|
);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/** One readline question, with Ctrl+C normalised. */
|
|
42
|
+
/** One readline question, with Ctrl+C and end of input normalised. */
|
|
43
43
|
async function ask(question: string): Promise<string> {
|
|
44
44
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
45
|
+
// End of input (Ctrl+D, or a pipe that ran dry) closes the interface and
|
|
46
|
+
// never settles `question()` — MEASURED under Bun, it waits forever. Input
|
|
47
|
+
// that ends before an answer is a cancellation, the same as Ctrl+C.
|
|
48
|
+
let settled = false;
|
|
49
|
+
const closed = new Promise<never>((_, reject) => {
|
|
50
|
+
rl.once("close", () => {
|
|
51
|
+
if (!settled) reject(new PromptAborted());
|
|
52
|
+
});
|
|
53
|
+
});
|
|
45
54
|
try {
|
|
46
|
-
return (await rl.question(question)).trim();
|
|
55
|
+
return (await Promise.race([rl.question(question), closed])).trim();
|
|
47
56
|
} catch (error) {
|
|
48
57
|
if (isPromptAborted(error)) throw new PromptAborted();
|
|
49
58
|
throw error;
|
|
50
59
|
} finally {
|
|
60
|
+
settled = true;
|
|
51
61
|
rl.close();
|
|
52
62
|
}
|
|
53
63
|
}
|
|
54
64
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
65
|
+
/**
|
|
66
|
+
* A yes/no answer to `prompt`, printed as given — the caller styles it (see
|
|
67
|
+
* `question` in update-view.ts). Empty input means yes. Line-based, so a piped
|
|
68
|
+
* stdin (`yes | magus install`) answers it like a person does.
|
|
69
|
+
*/
|
|
70
|
+
export async function askYesNo(prompt: string): Promise<boolean> {
|
|
71
|
+
const answer = (await ask(prompt)).toLowerCase();
|
|
58
72
|
return answer === "" || answer === "y" || answer === "yes";
|
|
59
73
|
}
|
|
60
74
|
|
package/src/cli/router.ts
CHANGED
|
@@ -192,6 +192,7 @@ export async function route(
|
|
|
192
192
|
// Both of these fix a marker naming a missing profile by activating
|
|
193
193
|
// one that exists.
|
|
194
194
|
allowUnknown: command === "install" || command === "profile",
|
|
195
|
+
yes: rest.includes("--yes") || rest.includes("-y"),
|
|
195
196
|
});
|
|
196
197
|
if (!ok) return HANDLED(1);
|
|
197
198
|
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An arrow-key choice in the terminal — interactive, but not a TUI.
|
|
3
|
+
*
|
|
4
|
+
* No renderer and no alternate screen: the rows of `selectFrame` are written
|
|
5
|
+
* under the report, redrawn in place on each keypress, and collapse to one
|
|
6
|
+
* `selectDone` line once answered. The owner asked for this in place of typing
|
|
7
|
+
* a number ("not this ugly thing … not tui, interactive cli").
|
|
8
|
+
*
|
|
9
|
+
* The key handling is `selectKey`, pure, so it is testable without a terminal;
|
|
10
|
+
* `selectOption` is only the terminal around it.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { emitKeypressEvents } from "node:readline";
|
|
14
|
+
import { selectDone, selectFrame } from "./update-view.js";
|
|
15
|
+
|
|
16
|
+
export interface SelectItem {
|
|
17
|
+
label: string;
|
|
18
|
+
meta?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The fields of a readline keypress that the choice reads. */
|
|
22
|
+
export interface SelectKeypress {
|
|
23
|
+
name?: string;
|
|
24
|
+
ctrl?: boolean;
|
|
25
|
+
sequence?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type SelectStep =
|
|
29
|
+
| { index: number }
|
|
30
|
+
| { chosen: number }
|
|
31
|
+
| { cancelled: true };
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* What one keypress does: ↑/↓ (and k/j) move with wrap-around, Enter picks the
|
|
35
|
+
* current row, a digit picks that row at once, Esc and Ctrl+C cancel. Any other
|
|
36
|
+
* key leaves the cursor where it is.
|
|
37
|
+
*/
|
|
38
|
+
export function selectKey(
|
|
39
|
+
index: number,
|
|
40
|
+
key: SelectKeypress,
|
|
41
|
+
count: number,
|
|
42
|
+
): SelectStep {
|
|
43
|
+
if ((key.ctrl && key.name === "c") || key.name === "escape") {
|
|
44
|
+
return { cancelled: true };
|
|
45
|
+
}
|
|
46
|
+
if (key.name === "return" || key.name === "enter") return { chosen: index };
|
|
47
|
+
if (key.name === "up" || key.name === "k") {
|
|
48
|
+
return { index: (index - 1 + count) % count };
|
|
49
|
+
}
|
|
50
|
+
if (key.name === "down" || key.name === "j") {
|
|
51
|
+
return { index: (index + 1) % count };
|
|
52
|
+
}
|
|
53
|
+
const digit = Number(key.sequence);
|
|
54
|
+
if (Number.isInteger(digit) && digit >= 1 && digit <= count) {
|
|
55
|
+
return { chosen: digit - 1 };
|
|
56
|
+
}
|
|
57
|
+
return { index };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** 128 + signum, the shell convention `cli/live.ts` exits with too. */
|
|
61
|
+
const SIGNAL_EXIT = { SIGINT: 130, SIGTERM: 143, SIGHUP: 129 } as const;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Ask. Resolves to the chosen index, or null when the user cancels — and at
|
|
65
|
+
* once when there is no terminal on either end, since nobody can answer and a
|
|
66
|
+
* redraw would write cursor codes into a pipe. Callers decide what null means.
|
|
67
|
+
*
|
|
68
|
+
* Whatever ends it — an answer, a throw, a signal, or the process exiting —
|
|
69
|
+
* puts the terminal back: raw mode off, the keypress listener gone, the cursor
|
|
70
|
+
* shown. Only Enter, Esc and Ctrl+C used to, so anything else left the shell
|
|
71
|
+
* typing blind until `reset`. Ctrl+C typed at the list arrives as a keypress
|
|
72
|
+
* in raw mode; the SIGINT handler is for one sent from outside (`kill -INT`).
|
|
73
|
+
*/
|
|
74
|
+
export async function selectOption(
|
|
75
|
+
title: string,
|
|
76
|
+
items: SelectItem[],
|
|
77
|
+
defaultIndex = 0,
|
|
78
|
+
): Promise<number | null> {
|
|
79
|
+
const { stdin, stdout } = process;
|
|
80
|
+
if (!stdin.isTTY || !stdout.isTTY || items.length === 0) return null;
|
|
81
|
+
|
|
82
|
+
let index = Math.min(Math.max(0, defaultIndex), items.length - 1);
|
|
83
|
+
let height = 0;
|
|
84
|
+
const draw = (): void => {
|
|
85
|
+
const lines = selectFrame(title, items, index);
|
|
86
|
+
// Back to the block's first row, then rewrite every row in full.
|
|
87
|
+
if (height > 0) stdout.write(`\x1b[${height}F`);
|
|
88
|
+
stdout.write(`${lines.map((l) => `\x1b[2K${l}`).join("\n")}\n`);
|
|
89
|
+
height = lines.length;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
emitKeypressEvents(stdin);
|
|
93
|
+
const wasRaw = stdin.isRaw;
|
|
94
|
+
let onKey: ((_: string, key: SelectKeypress | undefined) => void) | null =
|
|
95
|
+
null;
|
|
96
|
+
|
|
97
|
+
// Once only: `finish`, a throw's `finally` and a signal can all reach it.
|
|
98
|
+
let released = false;
|
|
99
|
+
const release = (): void => {
|
|
100
|
+
if (released) return;
|
|
101
|
+
released = true;
|
|
102
|
+
process.off("exit", release);
|
|
103
|
+
process.off("SIGINT", onSigint);
|
|
104
|
+
process.off("SIGTERM", onSigterm);
|
|
105
|
+
process.off("SIGHUP", onSighup);
|
|
106
|
+
if (onKey) stdin.off("keypress", onKey);
|
|
107
|
+
stdin.setRawMode(wasRaw);
|
|
108
|
+
stdin.pause();
|
|
109
|
+
stdout.write("\x1b[?25h");
|
|
110
|
+
};
|
|
111
|
+
// A listener for a signal replaces its default action, so each one exits
|
|
112
|
+
// itself once the terminal is back.
|
|
113
|
+
const onSigint = (): void => {
|
|
114
|
+
release();
|
|
115
|
+
process.exit(SIGNAL_EXIT.SIGINT);
|
|
116
|
+
};
|
|
117
|
+
const onSigterm = (): void => {
|
|
118
|
+
release();
|
|
119
|
+
process.exit(SIGNAL_EXIT.SIGTERM);
|
|
120
|
+
};
|
|
121
|
+
const onSighup = (): void => {
|
|
122
|
+
release();
|
|
123
|
+
process.exit(SIGNAL_EXIT.SIGHUP);
|
|
124
|
+
};
|
|
125
|
+
process.once("exit", release);
|
|
126
|
+
process.once("SIGINT", onSigint);
|
|
127
|
+
process.once("SIGTERM", onSigterm);
|
|
128
|
+
process.once("SIGHUP", onSighup);
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
stdin.setRawMode(true);
|
|
132
|
+
stdin.resume();
|
|
133
|
+
stdout.write("\x1b[?25l"); // hide the cursor while the list is open
|
|
134
|
+
|
|
135
|
+
return await new Promise<number | null>((resolve, reject) => {
|
|
136
|
+
const finish = (chosen: number | null): void => {
|
|
137
|
+
// Clear the open block; an answer leaves one line behind it.
|
|
138
|
+
stdout.write(`\x1b[${height}F\x1b[0J`);
|
|
139
|
+
if (chosen !== null) {
|
|
140
|
+
stdout.write(`${selectDone(title, items[chosen]?.label ?? "")}\n`);
|
|
141
|
+
}
|
|
142
|
+
// Before resolving, not in `finally`: one read can carry several
|
|
143
|
+
// keys, and the listener must be gone before the next is emitted.
|
|
144
|
+
release();
|
|
145
|
+
resolve(chosen);
|
|
146
|
+
};
|
|
147
|
+
onKey = (_, key) => {
|
|
148
|
+
try {
|
|
149
|
+
const step = selectKey(index, key ?? {}, items.length);
|
|
150
|
+
if ("cancelled" in step) finish(null);
|
|
151
|
+
else if ("chosen" in step) finish(step.chosen);
|
|
152
|
+
else {
|
|
153
|
+
index = step.index;
|
|
154
|
+
draw();
|
|
155
|
+
}
|
|
156
|
+
} catch (error) {
|
|
157
|
+
// Thrown inside an event listener it would reach no caller.
|
|
158
|
+
release();
|
|
159
|
+
reject(error);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
stdin.on("keypress", onKey);
|
|
163
|
+
draw();
|
|
164
|
+
});
|
|
165
|
+
} finally {
|
|
166
|
+
release();
|
|
167
|
+
}
|
|
168
|
+
}
|
package/src/cli/update-view.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* update-view.ts — every string `magus update` prints,
|
|
2
|
+
* update-view.ts — every string `magus update` prints, plus the pieces the
|
|
3
|
+
* other CLI commands reuse so they look the same (`commandHeader`, `field`,
|
|
4
|
+
* the `ok`/`warn`/`fail`/`info`/`note` rows, and `question`).
|
|
3
5
|
*
|
|
4
6
|
* Pure functions: plan in, lines out. No IO, no timers, no terminal. That is
|
|
5
7
|
* what makes the look testable — the old report was `console.log` calls braided
|
|
@@ -128,16 +130,128 @@ function rowText(key: ActionKey, text: string): string {
|
|
|
128
130
|
|
|
129
131
|
// -- header ------------------------------------------------------------------
|
|
130
132
|
|
|
131
|
-
|
|
133
|
+
/**
|
|
134
|
+
* `◆ magus <command> <label> <value>` — the line a CLI report opens with.
|
|
135
|
+
*
|
|
136
|
+
* Shared so `install` and the adoption question open the way `update` does,
|
|
137
|
+
* rather than each command printing its own plain "Plan for …:" heading.
|
|
138
|
+
*/
|
|
139
|
+
export function commandHeader(
|
|
140
|
+
command: string,
|
|
141
|
+
context?: { label: string; value: string },
|
|
142
|
+
): string[] {
|
|
143
|
+
const tail = context
|
|
144
|
+
? ` ${dim(context.label)} ${fg(brand.accent, context.value)}`
|
|
145
|
+
: "";
|
|
132
146
|
return [
|
|
133
147
|
"",
|
|
134
|
-
`${PAD}${fg(brand.accent, "◆")} ${bold(
|
|
135
|
-
"profile",
|
|
136
|
-
)} ${fg(brand.accent, profileId)}`,
|
|
148
|
+
`${PAD}${fg(brand.accent, "◆")} ${bold(`magus ${command}`)}${tail}`,
|
|
137
149
|
"",
|
|
138
150
|
];
|
|
139
151
|
}
|
|
140
152
|
|
|
153
|
+
export function header(profileId: string): string[] {
|
|
154
|
+
return commandHeader("update", { label: "profile", value: profileId });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Width of the label column in `field` rows. */
|
|
158
|
+
const FIELD_WIDTH = 14;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* `label value`, indented to the text column of the glyph rows (`ok`, `warn`,
|
|
162
|
+
* …) so a block of facts lines up under the notice it belongs to.
|
|
163
|
+
*/
|
|
164
|
+
export function field(label: string, value: string): string {
|
|
165
|
+
return `${PAD} ${dim(padEnd(label, FIELD_WIDTH))}${value}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* A multi-paragraph note, word-wrapped to the terminal in the `note` column.
|
|
170
|
+
* Paragraphs are separated by "\n"; an empty paragraph stays an empty line. A
|
|
171
|
+
* note printed as one line per paragraph wrapped mid-word at the terminal edge.
|
|
172
|
+
*/
|
|
173
|
+
export function noteBlock(text: string, columns = 80): string[] {
|
|
174
|
+
const room = Math.max(20, Math.min(columns, 100) - PAD.length - 4);
|
|
175
|
+
const out: string[] = [];
|
|
176
|
+
for (const paragraph of text.split("\n")) {
|
|
177
|
+
if (paragraph === "") {
|
|
178
|
+
out.push("");
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const indent = /^ */.exec(paragraph)?.[0] ?? "";
|
|
182
|
+
let line = indent;
|
|
183
|
+
for (const word of paragraph.trimStart().split(" ")) {
|
|
184
|
+
if (line.trim() !== "" && width(line) + 1 + width(word) > room) {
|
|
185
|
+
out.push(note(line));
|
|
186
|
+
line = word;
|
|
187
|
+
} else {
|
|
188
|
+
line = line.trim() === "" ? `${line}${word}` : `${line} ${word}`;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (line.trim() !== "") out.push(note(line));
|
|
192
|
+
}
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** One entry of a list under a `field`, indented to the field's value column. */
|
|
197
|
+
export function listItem(text: string): string {
|
|
198
|
+
return `${PAD} ${" ".repeat(FIELD_WIDTH)}${text}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The scopes a user deals in. Where each is stored is magus's business. */
|
|
202
|
+
export type ScopeName = "user" | "project" | "local";
|
|
203
|
+
|
|
204
|
+
const SCOPE_BADGE: Record<ScopeName, [string, string]> = {
|
|
205
|
+
user: ["USER", brand.accent],
|
|
206
|
+
project: ["PROJECT", brand.info],
|
|
207
|
+
local: ["LOCAL", brand.warning],
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
/** A scope as a badge — the word a user knows it by, never a file name. */
|
|
211
|
+
export function scopeBadge(scope: ScopeName): string {
|
|
212
|
+
const [label, color] = SCOPE_BADGE[scope];
|
|
213
|
+
return badge(label, color);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* The prompt for a question, in the glyph column: `? <question> [Y/n] `.
|
|
218
|
+
* Printed by readline, so the answer is typed on the same line. `hint` is what
|
|
219
|
+
* an empty answer means — yes by default, or the suggested value for a
|
|
220
|
+
* free-text question.
|
|
221
|
+
*/
|
|
222
|
+
export function question(text: string, hint = "[Y/n]"): string {
|
|
223
|
+
return `${PAD}${fg(brand.accent, "?")} ${bold(text)} ${dim(hint)} `;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* An arrow-key choice while it is open: the question, one row per option with
|
|
228
|
+
* `❯` on the current one, then the keys. `select.ts` redraws the whole block in
|
|
229
|
+
* place on every keypress, so its height never changes while it is open.
|
|
230
|
+
*/
|
|
231
|
+
export function selectFrame(
|
|
232
|
+
title: string,
|
|
233
|
+
options: { label: string; meta?: string }[],
|
|
234
|
+
index: number,
|
|
235
|
+
): string[] {
|
|
236
|
+
const labelWidth = Math.max(0, ...options.map((o) => width(o.label)));
|
|
237
|
+
return [
|
|
238
|
+
`${PAD}${fg(brand.accent, "?")} ${bold(title)}`,
|
|
239
|
+
...options.map((o, i) => {
|
|
240
|
+
const label = padEnd(o.label, labelWidth);
|
|
241
|
+
const meta = o.meta ? ` ${dim(o.meta)}` : "";
|
|
242
|
+
return i === index
|
|
243
|
+
? `${PAD} ${fg(brand.accent, "❯")} ${bold(fg(brand.accent, label))}${meta}`
|
|
244
|
+
: `${PAD} ${label}${meta}`;
|
|
245
|
+
}),
|
|
246
|
+
`${PAD} ${dim("↑↓ move · enter select · esc cancel")}`,
|
|
247
|
+
];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The one line an answered choice collapses to: `✓ <title> <answer>`. */
|
|
251
|
+
export function selectDone(title: string, answer: string): string {
|
|
252
|
+
return `${PAD}${fg(brand.success, "✓")} ${title} ${fg(brand.accent, answer)}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
141
255
|
// -- phase checklist ---------------------------------------------------------
|
|
142
256
|
|
|
143
257
|
export type StepState = "pending" | "running" | "done" | "warn" | "fail";
|
|
@@ -346,7 +460,7 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
|
|
|
346
460
|
*
|
|
347
461
|
* Update something an apply would move forward
|
|
348
462
|
* Deprecated no configured marketplace offers it
|
|
349
|
-
* Fix required declared or
|
|
463
|
+
* Fix required declared or installed, with its files missing
|
|
350
464
|
* Unverified the check itself did not complete
|
|
351
465
|
* Current settled — the version was compared, or there was none to compare
|
|
352
466
|
*
|
package/src/cli/update.ts
CHANGED
|
@@ -51,7 +51,7 @@ import {
|
|
|
51
51
|
import {
|
|
52
52
|
recordRefetchedSkills,
|
|
53
53
|
trackFetchedSkills,
|
|
54
|
-
|
|
54
|
+
trackedGeneratedNotices,
|
|
55
55
|
updatableSkills,
|
|
56
56
|
} from "../services/profile-activate.js";
|
|
57
57
|
import { activeProfile } from "../services/profile-live.js";
|
|
@@ -84,7 +84,6 @@ import { brand } from "../ui/theme.js";
|
|
|
84
84
|
import { bold, dim, fg, width } from "./ansi.js";
|
|
85
85
|
import { ensureManifest, requireActiveProfile } from "./bootstrap.js";
|
|
86
86
|
import { LiveRegion, withPaused } from "./live.js";
|
|
87
|
-
import { selectDialog } from "./tui-prompt.js";
|
|
88
87
|
import {
|
|
89
88
|
type ApplyState,
|
|
90
89
|
type Step,
|
|
@@ -205,8 +204,9 @@ export async function runUpdateCommand(
|
|
|
205
204
|
|
|
206
205
|
// A generated file git tracks is written like any other; say so once, with
|
|
207
206
|
// the fix. magus never runs git itself.
|
|
208
|
-
for (const
|
|
209
|
-
console.log(warnLine(
|
|
207
|
+
for (const n of await trackedGeneratedNotices(projectPath)) {
|
|
208
|
+
console.log(warnLine(n.problem));
|
|
209
|
+
for (const line of n.fix) console.log(noteLine(line));
|
|
210
210
|
}
|
|
211
211
|
|
|
212
212
|
// No active profile, no update: that is where plugin state is read from and
|
|
@@ -238,6 +238,8 @@ export async function runUpdateCommand(
|
|
|
238
238
|
? manifest
|
|
239
239
|
: await requireActiveProfile(projectPath, manifest, {
|
|
240
240
|
check: flags.check || flags.dryRun,
|
|
241
|
+
// Said once already, by the check above.
|
|
242
|
+
reportTracked: false,
|
|
241
243
|
});
|
|
242
244
|
// A refused dry run exits 0: it wrote nothing and previewed nothing, which is
|
|
243
245
|
// what it promised. A refused `--check` exits 1, because that is a gate.
|
|
@@ -406,6 +406,34 @@ export function pickRegistryEntry(
|
|
|
406
406
|
return entries.find((e) => e.scope === "user");
|
|
407
407
|
}
|
|
408
408
|
|
|
409
|
+
/**
|
|
410
|
+
* The row a `claude plugin … --scope <scope>` run from `projectPath` owns: the
|
|
411
|
+
* user row for user scope; for project or local scope, the row of exactly that
|
|
412
|
+
* scope pinned to exactly this path.
|
|
413
|
+
*
|
|
414
|
+
* NOT {@link pickRegistryEntry}, which answers "what version applies here" and
|
|
415
|
+
* so falls back to inherited rows and then to the user row. That fallback asked
|
|
416
|
+
* the apply engine's "does this project hold a row?" guard made a plugin
|
|
417
|
+
* installed only at user scope, and turned on in project settings, look like it
|
|
418
|
+
* held a project row — so `claude plugin update --scope project` ran from a
|
|
419
|
+
* folder with none, and Claude Code updated some other project's row instead.
|
|
420
|
+
*/
|
|
421
|
+
export function ownRegistryEntry(
|
|
422
|
+
entries: InstalledPluginEntry[] | undefined,
|
|
423
|
+
scope: "user" | "project" | "local",
|
|
424
|
+
projectPath: string,
|
|
425
|
+
): InstalledPluginEntry | undefined {
|
|
426
|
+
if (!Array.isArray(entries)) return undefined;
|
|
427
|
+
if (scope === "user") return entries.find((e) => e.scope === "user");
|
|
428
|
+
const target = path.resolve(projectPath);
|
|
429
|
+
return entries.find(
|
|
430
|
+
(e) =>
|
|
431
|
+
e.scope === scope &&
|
|
432
|
+
e.projectPath !== undefined &&
|
|
433
|
+
path.resolve(e.projectPath) === target,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
|
|
409
437
|
async function overlayRegistryVersions(
|
|
410
438
|
base: Record<string, string>,
|
|
411
439
|
scope: "user" | "project" | "local",
|
package/src/services/manifest.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* structural subset of a v2 manifest entry, so migration is a version bump.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
+
import os from "node:os";
|
|
10
11
|
import path from "node:path";
|
|
11
12
|
import fs from "fs-extra";
|
|
12
13
|
import {
|
|
@@ -41,6 +42,36 @@ export function getManifestPath(projectPath?: string): string {
|
|
|
41
42
|
return path.join(base, MANIFEST_FILE);
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The nearest ANCESTOR directory holding a `.claude/profiles.json`, searched no
|
|
47
|
+
* higher than the repository root — so magus can say so when it runs in a
|
|
48
|
+
* subdirectory.
|
|
49
|
+
*
|
|
50
|
+
* magus manages the directory it runs in because that is the project Claude Code
|
|
51
|
+
* reads: launched in `tools/magus`, it watches `tools/magus/.claude/settings.json`
|
|
52
|
+
* and never the root's (MEASURED with `--debug-file`, Claude Code 2.1.281). A
|
|
53
|
+
* root profiles.json is therefore not this directory's — but someone who meant
|
|
54
|
+
* the root has to hear that it exists before creating a second one here.
|
|
55
|
+
*
|
|
56
|
+
* A directory holding `.git` (file or folder) is a repository root, a worktree's
|
|
57
|
+
* included: nothing above it is searched, or a worktree under
|
|
58
|
+
* `<repo>/.claude/worktrees/` would find the main checkout's file. The home
|
|
59
|
+
* directory is never checked — its `.claude/` is Claude Code's user config.
|
|
60
|
+
*/
|
|
61
|
+
export async function findEnclosingManifest(
|
|
62
|
+
projectPath: string,
|
|
63
|
+
): Promise<string | null> {
|
|
64
|
+
const home = os.homedir();
|
|
65
|
+
let dir = path.resolve(projectPath);
|
|
66
|
+
for (;;) {
|
|
67
|
+
if (await fs.pathExists(path.join(dir, ".git"))) return null;
|
|
68
|
+
const parent = path.dirname(dir);
|
|
69
|
+
if (parent === dir || parent === home) return null;
|
|
70
|
+
dir = parent;
|
|
71
|
+
if (await fs.pathExists(path.join(dir, MANIFEST_FILE))) return dir;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
44
75
|
/** An empty, valid v2 manifest. */
|
|
45
76
|
export function emptyManifest(): ProfileManifest {
|
|
46
77
|
return { version: 2, profiles: {} };
|