magus-cli 7.5.2 → 7.5.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/blackbox-convergence.test.ts +1 -2
- package/src/__tests__/blackbox-parity.test.ts +16 -14
- package/src/__tests__/blackbox-plugin-state.test.ts +13 -15
- package/src/__tests__/cli-apply-seams.test.ts +3 -3
- package/src/__tests__/cli-profile-prompts.test.ts +1165 -0
- package/src/__tests__/content-drift.test.ts +0 -1
- package/src/__tests__/files-missing-scope.test.ts +235 -0
- package/src/__tests__/{enabled-not-installed.test.ts → files-missing.test.ts} +32 -26
- package/src/__tests__/format-update-outcome.test.ts +367 -0
- package/src/__tests__/gitignore-check.test.ts +11 -29
- package/src/__tests__/gitignore-profile.test.ts +248 -0
- package/src/__tests__/installed-version-scope.test.tsx +425 -0
- package/src/__tests__/mate-availability.test.ts +11 -13
- package/src/__tests__/models-cli.test.ts +11 -0
- package/src/__tests__/plugin-contents.test.ts +0 -1
- package/src/__tests__/plugin-detail-scopes.test.tsx +270 -0
- package/src/__tests__/plugin-row-text.test.ts +18 -21
- package/src/__tests__/profile-command.test.ts +2 -2
- package/src/__tests__/profile-migration-offer.test.ts +5 -4
- package/src/__tests__/profile-skills.test.ts +17 -22
- package/src/__tests__/profile-startup-nested.test.ts +329 -0
- package/src/__tests__/profile-startup-old-format.test.ts +390 -0
- package/src/__tests__/profile-state.test.ts +152 -164
- package/src/__tests__/scope-action.test.ts +35 -38
- package/src/__tests__/scope-row-lookup.test.ts +126 -0
- package/src/__tests__/select-restore.test.ts +209 -0
- package/src/__tests__/stale-load-repair.test.ts +1 -2
- package/src/__tests__/update-apply.test.ts +18 -2
- package/src/__tests__/update-null-readback.test.ts +300 -0
- package/src/__tests__/update-plan-parity.test.ts +249 -0
- package/src/__tests__/update-plan.test.ts +66 -67
- package/src/__tests__/version-unknown-state.test.ts +38 -39
- package/src/cli/bootstrap.ts +239 -70
- package/src/cli/doctor.ts +7 -20
- package/src/cli/install.ts +83 -27
- package/src/cli/prompt.ts +7 -3
- package/src/cli/router.ts +6 -4
- package/src/cli/select.ts +168 -0
- package/src/cli/update-view.ts +118 -6
- package/src/cli/update.ts +10 -1
- package/src/services/claude-cli.ts +0 -24
- package/src/services/claude-settings.ts +28 -7
- package/src/services/gitignore-check.ts +37 -49
- package/src/services/gitignore-resolver.ts +10 -1
- package/src/services/gitignore-service.ts +67 -0
- package/src/services/manifest.ts +31 -0
- package/src/services/mate-availability.ts +23 -29
- package/src/services/plugin-manager.ts +247 -105
- package/src/services/profile-absorb.ts +41 -24
- package/src/services/profile-activate.ts +102 -127
- package/src/services/profile-startup.ts +160 -4
- package/src/services/profile-state.ts +7 -68
- package/src/services/tracked-live-files.ts +109 -74
- package/src/services/update-engine.ts +28 -10
- package/src/services/update-plan.ts +78 -94
- package/src/ui/App.tsx +41 -35
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/primitives/ScopeSquares.tsx +10 -5
- package/src/ui/hooks/useGitignoreModal.ts +63 -17
- package/src/ui/hooks/useProfileStartupModal.ts +94 -9
- package/src/ui/keymap.ts +1 -1
- package/src/ui/renderers/pluginRenderers.tsx +39 -45
- package/src/ui/renderers/pluginRowText.ts +6 -2
- package/src/ui/renderers/pluginStatusText.ts +4 -3
- package/src/ui/screens/PluginsScreen.tsx +30 -52
- package/src/ui/screens/ProfilesScreen.tsx +3 -10
- package/src/cli/tui-prompt.tsx +0 -143
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
|
@@ -52,9 +52,13 @@ async function ask(question: string): Promise<string> {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
56
|
-
|
|
57
|
-
|
|
55
|
+
/**
|
|
56
|
+
* A yes/no answer to `prompt`, printed as given — the caller styles it (see
|
|
57
|
+
* `question` in update-view.ts). Empty input means yes. Line-based, so a piped
|
|
58
|
+
* stdin (`yes | magus install`) answers it like a person does.
|
|
59
|
+
*/
|
|
60
|
+
export async function askYesNo(prompt: string): Promise<boolean> {
|
|
61
|
+
const answer = (await ask(prompt)).toLowerCase();
|
|
58
62
|
return answer === "" || answer === "y" || answer === "yes";
|
|
59
63
|
}
|
|
60
64
|
|
package/src/cli/router.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import type { ThemeMode } from "@opentui/core";
|
|
10
10
|
import { SettingsParseError } from "../services/settings-file.js";
|
|
11
|
-
import {
|
|
11
|
+
import { GitCheckError } from "../services/tracked-live-files.js";
|
|
12
12
|
import { checkForUpdates } from "../services/version-check.js";
|
|
13
13
|
import { type ThemeEnv, parseThemeWord } from "../ui/theme-resolve.js";
|
|
14
14
|
import { profileGate, reportUnparseable } from "./bootstrap.js";
|
|
@@ -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
|
}
|
|
@@ -224,9 +225,10 @@ export async function route(
|
|
|
224
225
|
reportUnparseable(error);
|
|
225
226
|
return HANDLED(1);
|
|
226
227
|
}
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
228
|
+
// git could not say what it tracks, and converting an older layout needs
|
|
229
|
+
// to know which skill folders the team committed. git's own message, not
|
|
230
|
+
// a stack trace.
|
|
231
|
+
if (error instanceof GitCheckError) {
|
|
230
232
|
console.error(error.message);
|
|
231
233
|
return HANDLED(1);
|
|
232
234
|
}
|
|
@@ -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,126 @@ 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 yes/no question, in the glyph column: `? <question> [Y/n] `.
|
|
218
|
+
* Printed by readline, so the answer is typed on the same line.
|
|
219
|
+
*/
|
|
220
|
+
export function question(text: string): string {
|
|
221
|
+
return `${PAD}${fg(brand.accent, "?")} ${bold(text)} ${dim("[Y/n]")} `;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* An arrow-key choice while it is open: the question, one row per option with
|
|
226
|
+
* `❯` on the current one, then the keys. `select.ts` redraws the whole block in
|
|
227
|
+
* place on every keypress, so its height never changes while it is open.
|
|
228
|
+
*/
|
|
229
|
+
export function selectFrame(
|
|
230
|
+
title: string,
|
|
231
|
+
options: { label: string; meta?: string }[],
|
|
232
|
+
index: number,
|
|
233
|
+
): string[] {
|
|
234
|
+
const labelWidth = Math.max(0, ...options.map((o) => width(o.label)));
|
|
235
|
+
return [
|
|
236
|
+
`${PAD}${fg(brand.accent, "?")} ${bold(title)}`,
|
|
237
|
+
...options.map((o, i) => {
|
|
238
|
+
const label = padEnd(o.label, labelWidth);
|
|
239
|
+
const meta = o.meta ? ` ${dim(o.meta)}` : "";
|
|
240
|
+
return i === index
|
|
241
|
+
? `${PAD} ${fg(brand.accent, "❯")} ${bold(fg(brand.accent, label))}${meta}`
|
|
242
|
+
: `${PAD} ${label}${meta}`;
|
|
243
|
+
}),
|
|
244
|
+
`${PAD} ${dim("↑↓ move · enter select · esc cancel")}`,
|
|
245
|
+
];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The one line an answered choice collapses to: `✓ <title> <answer>`. */
|
|
249
|
+
export function selectDone(title: string, answer: string): string {
|
|
250
|
+
return `${PAD}${fg(brand.success, "✓")} ${title} ${fg(brand.accent, answer)}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
141
253
|
// -- phase checklist ---------------------------------------------------------
|
|
142
254
|
|
|
143
255
|
export type StepState = "pending" | "running" | "done" | "warn" | "fail";
|
|
@@ -346,7 +458,7 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
|
|
|
346
458
|
*
|
|
347
459
|
* Update something an apply would move forward
|
|
348
460
|
* Deprecated no configured marketplace offers it
|
|
349
|
-
* Fix required declared or
|
|
461
|
+
* Fix required declared or installed, with its files missing
|
|
350
462
|
* Unverified the check itself did not complete
|
|
351
463
|
* Current settled — the version was compared, or there was none to compare
|
|
352
464
|
*
|
package/src/cli/update.ts
CHANGED
|
@@ -51,6 +51,7 @@ import {
|
|
|
51
51
|
import {
|
|
52
52
|
recordRefetchedSkills,
|
|
53
53
|
trackFetchedSkills,
|
|
54
|
+
trackedGeneratedNotices,
|
|
54
55
|
updatableSkills,
|
|
55
56
|
} from "../services/profile-activate.js";
|
|
56
57
|
import { activeProfile } from "../services/profile-live.js";
|
|
@@ -83,7 +84,6 @@ import { brand } from "../ui/theme.js";
|
|
|
83
84
|
import { bold, dim, fg, width } from "./ansi.js";
|
|
84
85
|
import { ensureManifest, requireActiveProfile } from "./bootstrap.js";
|
|
85
86
|
import { LiveRegion, withPaused } from "./live.js";
|
|
86
|
-
import { selectDialog } from "./tui-prompt.js";
|
|
87
87
|
import {
|
|
88
88
|
type ApplyState,
|
|
89
89
|
type Step,
|
|
@@ -202,6 +202,13 @@ export async function runUpdateCommand(
|
|
|
202
202
|
return 1;
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
// A generated file git tracks is written like any other; say so once, with
|
|
206
|
+
// the fix. magus never runs git itself.
|
|
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
|
+
}
|
|
211
|
+
|
|
205
212
|
// No active profile, no update: that is where plugin state is read from and
|
|
206
213
|
// where changes are written. Asked before the profile is resolved, so what
|
|
207
214
|
// the user activates here is what this run uses.
|
|
@@ -231,6 +238,8 @@ export async function runUpdateCommand(
|
|
|
231
238
|
? manifest
|
|
232
239
|
: await requireActiveProfile(projectPath, manifest, {
|
|
233
240
|
check: flags.check || flags.dryRun,
|
|
241
|
+
// Said once already, by the check above.
|
|
242
|
+
reportTracked: false,
|
|
234
243
|
});
|
|
235
244
|
// A refused dry run exits 0: it wrote nothing and previewed nothing, which is
|
|
236
245
|
// what it promised. A refused `--check` exits 1, because that is a gate.
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { execFile } from "node:child_process";
|
|
13
|
-
import path from "node:path";
|
|
14
13
|
import { promisify } from "node:util";
|
|
15
14
|
import { defaultMarketplaces } from "../data/marketplaces.js";
|
|
16
15
|
import { which } from "../utils/command-utils.js";
|
|
@@ -20,7 +19,6 @@ import {
|
|
|
20
19
|
removeLocalInstalledPluginVersion,
|
|
21
20
|
} from "./claude-settings.js";
|
|
22
21
|
import { removeInstalledPluginVersion } from "./plugin-manager.js";
|
|
23
|
-
import { refuseTrackedLiveFiles } from "./tracked-live-files.js";
|
|
24
22
|
|
|
25
23
|
const execFileAsync = promisify(execFile);
|
|
26
24
|
|
|
@@ -140,23 +138,6 @@ async function execPluginCommand(
|
|
|
140
138
|
}
|
|
141
139
|
}
|
|
142
140
|
|
|
143
|
-
/**
|
|
144
|
-
* Every `claude plugin … --scope project` command writes the project's
|
|
145
|
-
* `.claude/settings.json` (its enabledPlugins entry). In a project with
|
|
146
|
-
* profiles that file is generated; where git tracks it, refuse — before the
|
|
147
|
-
* CLI runs — rather than let the CLI write it (see `tracked-live-files.ts`).
|
|
148
|
-
* The CLI works on the current directory.
|
|
149
|
-
*/
|
|
150
|
-
async function refuseProjectSettingsWrite(
|
|
151
|
-
scope: PluginScope,
|
|
152
|
-
projectPath: string = process.cwd(),
|
|
153
|
-
): Promise<void> {
|
|
154
|
-
if (scope !== "project") return;
|
|
155
|
-
await refuseTrackedLiveFiles(projectPath, [
|
|
156
|
-
path.join(".claude", "settings.json"),
|
|
157
|
-
]);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
141
|
/**
|
|
161
142
|
* Install a plugin using claude CLI
|
|
162
143
|
* Handles enabling + version tracking + cache copy in one shot.
|
|
@@ -173,7 +154,6 @@ export async function installPlugin(
|
|
|
173
154
|
pluginId: string,
|
|
174
155
|
scope: PluginScope = "user",
|
|
175
156
|
): Promise<void> {
|
|
176
|
-
await refuseProjectSettingsWrite(scope);
|
|
177
157
|
// 60s, matching update and repair. Before the install/update split this path
|
|
178
158
|
// ran through `updatePlugin`'s 60s budget; leaving it on the 30s default
|
|
179
159
|
// would have quietly halved the time a fresh install — the SLOWEST of the
|
|
@@ -192,7 +172,6 @@ export async function uninstallPlugin(
|
|
|
192
172
|
scope: PluginScope = "user",
|
|
193
173
|
projectPath?: string,
|
|
194
174
|
): Promise<void> {
|
|
195
|
-
await refuseProjectSettingsWrite(scope, projectPath);
|
|
196
175
|
try {
|
|
197
176
|
await execClaude(["plugin", "uninstall", pluginId, "--scope", scope]);
|
|
198
177
|
} catch {
|
|
@@ -245,7 +224,6 @@ export async function enablePlugin(
|
|
|
245
224
|
pluginId: string,
|
|
246
225
|
scope: PluginScope = "user",
|
|
247
226
|
): Promise<void> {
|
|
248
|
-
await refuseProjectSettingsWrite(scope);
|
|
249
227
|
await execClaude(["plugin", "enable", pluginId, "--scope", scope]);
|
|
250
228
|
}
|
|
251
229
|
|
|
@@ -256,7 +234,6 @@ export async function disablePlugin(
|
|
|
256
234
|
pluginId: string,
|
|
257
235
|
scope: PluginScope = "user",
|
|
258
236
|
): Promise<void> {
|
|
259
|
-
await refuseProjectSettingsWrite(scope);
|
|
260
237
|
await execClaude(["plugin", "disable", pluginId, "--scope", scope]);
|
|
261
238
|
}
|
|
262
239
|
|
|
@@ -299,7 +276,6 @@ export async function updatePlugin(
|
|
|
299
276
|
pluginId: string,
|
|
300
277
|
scope: PluginScope = "user",
|
|
301
278
|
): Promise<void> {
|
|
302
|
-
await refuseProjectSettingsWrite(scope);
|
|
303
279
|
await execPluginCommand("update", pluginId, scope, 60000);
|
|
304
280
|
}
|
|
305
281
|
|