magus-cli 7.0.0 → 7.2.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-parity.test.ts +58 -17
- package/src/__tests__/blackbox-plugin-state.test.ts +41 -19
- package/src/__tests__/cli-update-view.test.ts +100 -21
- package/src/__tests__/local-marketplace-scan.test.ts +74 -0
- package/src/__tests__/marketplace-refresh.test.ts +12 -7
- package/src/__tests__/plugin-row-text.test.ts +33 -24
- 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__/theme-env.test.ts +29 -0
- package/src/__tests__/update-plan.test.ts +57 -34
- package/src/__tests__/version-unknown-state.test.ts +29 -14
- package/src/cli/ansi.ts +58 -11
- package/src/cli/bootstrap.ts +169 -3
- package/src/cli/install.ts +5 -1
- package/src/cli/profile.ts +5 -1
- package/src/cli/prompt.ts +44 -12
- package/src/cli/router.ts +59 -2
- package/src/cli/tui-prompt.tsx +143 -0
- package/src/cli/update-view.ts +237 -45
- package/src/cli/update.ts +59 -29
- package/src/main.tsx +11 -2
- package/src/services/claude-settings.ts +15 -40
- package/src/services/local-marketplace.ts +76 -6
- package/src/services/manifest.ts +21 -0
- package/src/services/marketplace-refresh.ts +7 -15
- package/src/services/plugin-manager.ts +0 -31
- 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/resolver.ts +33 -0
- package/src/services/symlink-manager.ts +184 -30
- package/src/services/update-plan.ts +23 -66
- package/src/types/index.ts +44 -23
- package/src/ui/App.tsx +60 -3
- 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/renderers/profileRenderers.tsx +27 -4
- package/src/ui/screens/PluginsScreen.tsx +9 -50
- package/src/ui/screens/ProfilesScreen.tsx +1 -1
- package/src/ui/state/types.ts +18 -0
- package/src/ui/theme-mode.ts +142 -0
- package/src/utils/command-utils.ts +19 -14
- package/src/__tests__/moved-marketplace.test.ts +0 -86
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One OpenTUI dialog, for a command that is otherwise not a TUI.
|
|
3
|
+
*
|
|
4
|
+
* `magus update` prints a report and exits; it is not the interactive app. But
|
|
5
|
+
* a question it must ask deserves the same dialog the TUI asks with, so this
|
|
6
|
+
* mounts the REAL `SelectModal` — the component tab 5 and tab 0 use — for the
|
|
7
|
+
* length of one question, then tears the renderer down and lets the command
|
|
8
|
+
* carry on printing. Nothing here re-draws a list, a border or a highlight: the
|
|
9
|
+
* component and the theme own all of that, and a second implementation is how
|
|
10
|
+
* the two surfaces drift apart.
|
|
11
|
+
*
|
|
12
|
+
* Every import of `@opentui/*` and `ui/` is DYNAMIC. `main.tsx` splits the TUI
|
|
13
|
+
* out precisely so `magus hook …`, which Claude Code runs on every matching
|
|
14
|
+
* tool call, never pays to load the renderer — a static import here would undo
|
|
15
|
+
* that for every command that imports this module's file, whether or not it
|
|
16
|
+
* ever asks a question.
|
|
17
|
+
*
|
|
18
|
+
* `screenMode: "main-screen"` keeps the dialog in the scrollback beside the
|
|
19
|
+
* report. The alternate screen would take the terminal over and then hand back
|
|
20
|
+
* a screen with no trace the question was ever asked.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { SelectOption } from "../ui/state/types.js";
|
|
24
|
+
|
|
25
|
+
export interface SelectDialogInput {
|
|
26
|
+
title: string;
|
|
27
|
+
message: string;
|
|
28
|
+
options: SelectOption[];
|
|
29
|
+
/** Row to start on. Defaults to the first. */
|
|
30
|
+
defaultIndex?: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Ask the user to pick one option. Resolves to the chosen value, or null when
|
|
35
|
+
* they cancel with Esc or Ctrl+C — the caller decides whether that is fatal.
|
|
36
|
+
*
|
|
37
|
+
* Falls back to `null` when stdin or stdout is not a TTY. A renderer needs both:
|
|
38
|
+
* `magus update > file` has a TTY stdin and a pipe for stdout, and mounting
|
|
39
|
+
* there writes escape sequences into the file.
|
|
40
|
+
*/
|
|
41
|
+
export async function selectDialog(
|
|
42
|
+
input: SelectDialogInput,
|
|
43
|
+
): Promise<string | null> {
|
|
44
|
+
if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const [{ createCliRenderer }, { createRoot }, React, modals, dimensions] =
|
|
49
|
+
await Promise.all([
|
|
50
|
+
import("@opentui/core"),
|
|
51
|
+
import("@opentui/react"),
|
|
52
|
+
import("react"),
|
|
53
|
+
import("../ui/components/modals/index.js"),
|
|
54
|
+
import("../ui/state/DimensionsContext.js"),
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const renderer = await createCliRenderer({
|
|
58
|
+
screenMode: "main-screen",
|
|
59
|
+
// BOTH, so the renderer registers no handler of its own and teardown
|
|
60
|
+
// below is the only path. With either left at its default, a Ctrl+C
|
|
61
|
+
// destroys the renderer underneath this promise and it never settles.
|
|
62
|
+
exitSignals: [],
|
|
63
|
+
exitOnCtrlC: false,
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const root = createRoot(renderer);
|
|
67
|
+
|
|
68
|
+
return await new Promise<string | null>((resolve) => {
|
|
69
|
+
// Clamped, not trusted: a caller computing an index from a list it built
|
|
70
|
+
// separately could hand over one that is out of range, and a cursor
|
|
71
|
+
// pointing past the end makes Enter resolve to null.
|
|
72
|
+
let index = Math.min(
|
|
73
|
+
Math.max(0, input.defaultIndex ?? 0),
|
|
74
|
+
Math.max(0, input.options.length - 1),
|
|
75
|
+
);
|
|
76
|
+
let done = false;
|
|
77
|
+
|
|
78
|
+
const finish = (value: string | null): void => {
|
|
79
|
+
if (done) return;
|
|
80
|
+
done = true;
|
|
81
|
+
renderer.keyInput.off("keypress", onKey);
|
|
82
|
+
root.unmount();
|
|
83
|
+
renderer.destroy();
|
|
84
|
+
resolve(value);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function onKey(key: {
|
|
88
|
+
name?: string;
|
|
89
|
+
ctrl?: boolean;
|
|
90
|
+
sequence?: string;
|
|
91
|
+
}): void {
|
|
92
|
+
if ((key.ctrl && key.name === "c") || key.name === "escape") {
|
|
93
|
+
finish(null);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (key.name === "return" || key.name === "enter") {
|
|
97
|
+
finish(input.options[index]?.value ?? null);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (key.name === "up" || key.name === "k") {
|
|
101
|
+
index = (index - 1 + input.options.length) % input.options.length;
|
|
102
|
+
draw();
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (key.name === "down" || key.name === "j") {
|
|
106
|
+
index = (index + 1) % input.options.length;
|
|
107
|
+
draw();
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// The numbers stay live: the list is short, and the plain-text prompt
|
|
111
|
+
// this replaced numbered it, so the habit already exists.
|
|
112
|
+
const digit = Number(key.sequence);
|
|
113
|
+
if (
|
|
114
|
+
Number.isInteger(digit) &&
|
|
115
|
+
digit >= 1 &&
|
|
116
|
+
digit <= input.options.length
|
|
117
|
+
) {
|
|
118
|
+
index = digit - 1;
|
|
119
|
+
finish(input.options[index]?.value ?? null);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const draw = (): void => {
|
|
124
|
+
root.render(
|
|
125
|
+
React.createElement(
|
|
126
|
+
dimensions.DimensionsProvider,
|
|
127
|
+
null,
|
|
128
|
+
React.createElement(modals.SelectModal, {
|
|
129
|
+
title: input.title,
|
|
130
|
+
message: input.message,
|
|
131
|
+
options: input.options,
|
|
132
|
+
defaultIndex: index,
|
|
133
|
+
onSelect: finish,
|
|
134
|
+
onCancel: () => finish(null),
|
|
135
|
+
}),
|
|
136
|
+
),
|
|
137
|
+
);
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
renderer.keyInput.on("keypress", onKey);
|
|
141
|
+
draw();
|
|
142
|
+
});
|
|
143
|
+
}
|
package/src/cli/update-view.ts
CHANGED
|
@@ -31,11 +31,13 @@ import type {
|
|
|
31
31
|
SkillUpdateItem,
|
|
32
32
|
UpdatePlan,
|
|
33
33
|
} from "../services/update-plan.js";
|
|
34
|
+
import { type ReportBadgeKind, reportBadge } from "../ui/theme-mode.js";
|
|
34
35
|
import { brand } from "../ui/theme.js";
|
|
35
36
|
import { formatCommand } from "../utils/command-utils.js";
|
|
36
37
|
import {
|
|
37
38
|
badge,
|
|
38
39
|
bold,
|
|
40
|
+
dialog,
|
|
39
41
|
dim,
|
|
40
42
|
elapsed,
|
|
41
43
|
fg,
|
|
@@ -84,8 +86,11 @@ type ActionKey =
|
|
|
84
86
|
const ACTIONS: Record<ActionKey, ActionStyle> = {
|
|
85
87
|
current: { glyph: "=", label: "CURRENT", color: brand.muted, chrome: true },
|
|
86
88
|
install: { glyph: "+", label: "INSTALL", color: brand.link },
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
// Green, the tone magus uses for an update everywhere else. Amber stays
|
|
90
|
+
// on `unknown` below, so the two states remain distinguishable: green is
|
|
91
|
+
// "a newer version exists", amber is "we could not tell".
|
|
92
|
+
update: { glyph: "↑", label: "UPDATE", color: brand.success },
|
|
93
|
+
upgrade: { glyph: "↑", label: "UPGRADE", color: brand.success },
|
|
89
94
|
repair: { glyph: "⟳", label: "REPAIR", color: brand.info },
|
|
90
95
|
refresh: { glyph: "⟳", label: "REFRESH", color: brand.info },
|
|
91
96
|
// Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
|
|
@@ -207,24 +212,24 @@ export function stepFrame(steps: Step[], tick: number, now: number): string[] {
|
|
|
207
212
|
}
|
|
208
213
|
|
|
209
214
|
/**
|
|
210
|
-
* The permanent record,
|
|
215
|
+
* The permanent record: one row per step, its duration, and what it found.
|
|
211
216
|
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
217
|
+
* NO BAR. Each row carried an 18-cell meter of that step's share of the
|
|
218
|
+
* slowest step — which is precisely what the right-aligned millisecond column
|
|
219
|
+
* beside it already says, so the row stated one fact twice. A shape earns its
|
|
220
|
+
* width by making something readable that the number does not; a share of a
|
|
221
|
+
* five-row set, already sorted into a column, is not that.
|
|
222
|
+
*
|
|
223
|
+
* The LIVE frame keeps its bar (`stepFrame` above) and that is not the same
|
|
224
|
+
* widget: there the bar is a progress meter or an indeterminate sweep on the
|
|
225
|
+
* step running right now, and it answers "is this working or hung?" — a
|
|
226
|
+
* question no printed number can answer.
|
|
216
227
|
*/
|
|
217
228
|
export function stepRecord(steps: Step[], now: number): string[] {
|
|
218
|
-
|
|
219
|
-
const slowest = Math.max(1, ...times);
|
|
220
|
-
return steps.map((step, i) => {
|
|
221
|
-
const share = (times[i] / slowest) * 100;
|
|
222
|
-
const bar = meter(share, BAR, ramps.severity);
|
|
229
|
+
return steps.map((step) => {
|
|
223
230
|
const label = padEnd(step.label, STEP_LABEL_WIDTH);
|
|
224
|
-
const time = dim(padStart(elapsed(
|
|
225
|
-
return `${PAD}${stepMark(step, 0)} ${label} ${
|
|
226
|
-
step.detail,
|
|
227
|
-
)}`;
|
|
231
|
+
const time = dim(padStart(elapsed(stepDuration(step, now) ?? 0), 6));
|
|
232
|
+
return `${PAD}${stepMark(step, 0)} ${label} ${time} ${dim(step.detail)}`;
|
|
228
233
|
});
|
|
229
234
|
}
|
|
230
235
|
|
|
@@ -236,15 +241,30 @@ function pluginAction(item: PluginUpdateItem): ActionKey {
|
|
|
236
241
|
|
|
237
242
|
/** What the row says on its right: versions, never the action word again. */
|
|
238
243
|
function pluginDetail(item: PluginUpdateItem): string {
|
|
239
|
-
|
|
244
|
+
// A note the row's own badge already states is noise, not information. The
|
|
245
|
+
// DEPRECATED chip and the heading above it both say "no configured
|
|
246
|
+
// marketplace offers this", so printing the sentence again put the longest
|
|
247
|
+
// string in the table on the least interesting rows.
|
|
248
|
+
//
|
|
249
|
+
// The note stays on the item. The TUI has no heading to lean on and renders
|
|
250
|
+
// it as that row's only explanation — this is a rendering decision about a
|
|
251
|
+
// table that carries the fact twice, not a change to what is known.
|
|
252
|
+
const note =
|
|
253
|
+
item.note && item.reason !== "not-offered"
|
|
254
|
+
? ` ${dim(`(${item.note})`)}`
|
|
255
|
+
: "";
|
|
240
256
|
switch (item.action) {
|
|
241
257
|
case "install":
|
|
242
258
|
return `${item.target ?? "latest"}${note}`;
|
|
243
259
|
case "update":
|
|
260
|
+
// The GROUP's page tone, not a literal and not ACTIONS: the arrow and
|
|
261
|
+
// the target sit in the same row as that group's glyph and chip, so
|
|
262
|
+
// they must resolve per page from the same place. A hardcoded tone
|
|
263
|
+
// here is how the row ended up half green and half amber.
|
|
244
264
|
return `${dim(item.installed ?? "—")} ${fg(
|
|
245
|
-
|
|
265
|
+
groupStyle("Update").text,
|
|
246
266
|
"→",
|
|
247
|
-
)} ${fg(
|
|
267
|
+
)} ${fg(groupStyle("Update").text, item.target ?? "?")}${note}`;
|
|
248
268
|
case "repair":
|
|
249
269
|
case "unknown":
|
|
250
270
|
return `${item.installed ?? "—"}${note}`;
|
|
@@ -272,8 +292,11 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
|
|
|
272
292
|
item.version ? ` ${dim(`pinned ${item.version}`)}` : ""
|
|
273
293
|
}`;
|
|
274
294
|
case "upgrade":
|
|
275
|
-
return `${dim(item.installed ?? "—")} ${fg(
|
|
276
|
-
|
|
295
|
+
return `${dim(item.installed ?? "—")} ${fg(
|
|
296
|
+
ACTIONS.upgrade.color,
|
|
297
|
+
"→",
|
|
298
|
+
)} ${fg(
|
|
299
|
+
ACTIONS.upgrade.color,
|
|
277
300
|
item.target ?? "?",
|
|
278
301
|
)}${command ? ` ${command}` : ""}${
|
|
279
302
|
// An upgrade that also changes the tool's owner says so here; a
|
|
@@ -295,6 +318,183 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
|
|
|
295
318
|
* The bar is the section's whole story in one line — a reader who takes in
|
|
296
319
|
* nothing else knows whether this section is mostly settled or mostly work.
|
|
297
320
|
*/
|
|
321
|
+
/**
|
|
322
|
+
* Which heading a plugin row sits under.
|
|
323
|
+
*
|
|
324
|
+
* Read off the plan's machine codes — `action` and `reason` — never the human
|
|
325
|
+
* note. Five headings, and no more:
|
|
326
|
+
*
|
|
327
|
+
* Update something an apply would move forward
|
|
328
|
+
* Deprecated no configured marketplace offers it
|
|
329
|
+
* Fix required declared or enabled, with nothing on disk
|
|
330
|
+
* Unverified the check itself did not complete
|
|
331
|
+
* Current settled — the version was compared, or there was none to compare
|
|
332
|
+
*
|
|
333
|
+
* `Current` is last on purpose: it is the biggest group and the least
|
|
334
|
+
* interesting, and leading with it buries the few rows that need a decision.
|
|
335
|
+
*
|
|
336
|
+
* UNVERIFIED IS NOT CURRENT, and the two must not share a heading. A row whose
|
|
337
|
+
* catalog read failed used to land under `Current` carrying a `?` glyph and a
|
|
338
|
+
* note saying the catalog was unreachable — a row that contradicted the heading
|
|
339
|
+
* above it, while the closing notices insisted those rows are NOT reported as up
|
|
340
|
+
* to date. That is a different claim from "nothing could be compared, so we
|
|
341
|
+
* assume the install is the latest", which IS `current` and reaches it in the
|
|
342
|
+
* classifier, not here.
|
|
343
|
+
*
|
|
344
|
+
* "Unverified" is the word `summaryLine` already prints for this count; it is
|
|
345
|
+
* this report's existing vocabulary, not a new one.
|
|
346
|
+
*/
|
|
347
|
+
function pluginGroup(item: PluginUpdateItem): string {
|
|
348
|
+
if (item.action === "update" || item.action === "repair") return "Update";
|
|
349
|
+
if (item.action === "install") return "Fix required";
|
|
350
|
+
if (item.action === "unknown") {
|
|
351
|
+
return item.reason === "not-offered" ? "Deprecated" : "Unverified";
|
|
352
|
+
}
|
|
353
|
+
return "Current";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const GROUP_ORDER = [
|
|
357
|
+
"Update",
|
|
358
|
+
"Deprecated",
|
|
359
|
+
"Fix required",
|
|
360
|
+
"Unverified",
|
|
361
|
+
"Current",
|
|
362
|
+
];
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Colour and emphasis per GROUP.
|
|
366
|
+
*
|
|
367
|
+
* "Dim the chrome, saturate the signal" is this file's own rule, stated at the
|
|
368
|
+
* top; the signal is the groups an apply would act on. Every colour comes from
|
|
369
|
+
* `brand`, which ui/theme.ts contrast-checks against BOTH a cream and a
|
|
370
|
+
* near-black terminal, so a light theme needs no second palette.
|
|
371
|
+
*/
|
|
372
|
+
const GROUP_KIND: Record<string, ReportBadgeKind> = {
|
|
373
|
+
Update: "update",
|
|
374
|
+
Deprecated: "deprecated",
|
|
375
|
+
"Fix required": "fix",
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
interface GroupStyle {
|
|
379
|
+
/** Heading and glyph, drawn on the page. */
|
|
380
|
+
text: string;
|
|
381
|
+
/** Badge fill and its ink. Absent on a chrome group, which carries no badge. */
|
|
382
|
+
fill?: string;
|
|
383
|
+
ink?: string;
|
|
384
|
+
chrome?: boolean;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* A group's colours, for the page this run resolved.
|
|
389
|
+
*
|
|
390
|
+
* `reportBadge` answers per page — pale fill with deep ink on cream, saturated
|
|
391
|
+
* fill with pale ink on near-black — so this is the one place the report stops
|
|
392
|
+
* using a single set of hues for both. `brand` is still right for everything
|
|
393
|
+
* else here: those tokens are chosen to clear 3:1 as INK on either page, which
|
|
394
|
+
* is a different question from what a filled chip should look like.
|
|
395
|
+
*/
|
|
396
|
+
function groupStyle(group: string): GroupStyle {
|
|
397
|
+
const kind = GROUP_KIND[group];
|
|
398
|
+
if (!kind) return { text: brand.muted, chrome: true };
|
|
399
|
+
const ink = reportBadge(kind);
|
|
400
|
+
return { text: ink.text, fill: ink.fill, ink: ink.ink };
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** The badge's word: the heading, uppercased. Never a second vocabulary. */
|
|
404
|
+
function groupBadge(group: string): string {
|
|
405
|
+
return group.toUpperCase();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Badge column width, from the labels THIS report shows — a run with nothing
|
|
410
|
+
* deprecated does not pay for the longest word. `width` ignores escape bytes,
|
|
411
|
+
* so measuring an already painted badge is safe.
|
|
412
|
+
*/
|
|
413
|
+
function badgeWidth(items: PluginUpdateItem[]): number {
|
|
414
|
+
return Math.max(
|
|
415
|
+
0,
|
|
416
|
+
...items
|
|
417
|
+
.filter((item) => !groupStyle(pluginGroup(item)).chrome)
|
|
418
|
+
.map((item) => {
|
|
419
|
+
const style = groupStyle(pluginGroup(item));
|
|
420
|
+
return (
|
|
421
|
+
width(
|
|
422
|
+
badge(
|
|
423
|
+
groupBadge(pluginGroup(item)),
|
|
424
|
+
style.fill ?? brand.muted,
|
|
425
|
+
style.ink,
|
|
426
|
+
),
|
|
427
|
+
) + 2
|
|
428
|
+
);
|
|
429
|
+
}),
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function pluginRow(
|
|
434
|
+
item: PluginUpdateItem,
|
|
435
|
+
nameWidth: number,
|
|
436
|
+
chipWidth: number,
|
|
437
|
+
): string {
|
|
438
|
+
const group = pluginGroup(item);
|
|
439
|
+
const style = groupStyle(group);
|
|
440
|
+
const name = padEnd(item.pluginId, nameWidth);
|
|
441
|
+
// Only an actionable row is badged; a chrome row gets the same width in
|
|
442
|
+
// spaces, so every version still starts in one column.
|
|
443
|
+
const chip = padEnd(
|
|
444
|
+
style.chrome
|
|
445
|
+
? ""
|
|
446
|
+
: badge(groupBadge(group), style.fill ?? brand.muted, style.ink),
|
|
447
|
+
chipWidth,
|
|
448
|
+
);
|
|
449
|
+
return `${PAD}${fg(style.text, ACTIONS[item.action].glyph)} ${
|
|
450
|
+
style.chrome ? dim(name) : name
|
|
451
|
+
} ${chip}${pluginDetail(item)}`;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** The Plugins section: grouped by state, `Current` last. */
|
|
455
|
+
function pluginSection(items: PluginUpdateItem[], nameWidth: number): string[] {
|
|
456
|
+
if (items.length === 0) return [];
|
|
457
|
+
|
|
458
|
+
const byGroup = new Map<string, PluginUpdateItem[]>();
|
|
459
|
+
for (const item of items) {
|
|
460
|
+
const group = pluginGroup(item);
|
|
461
|
+
const list = byGroup.get(group);
|
|
462
|
+
if (list) list.push(item);
|
|
463
|
+
else byGroup.set(group, [item]);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// GROUP_ORDER first, then anything it does not name. A report may never
|
|
467
|
+
// silently drop a row: driving the output purely from a fixed list would
|
|
468
|
+
// render an unexpected group as nothing at all while the header still
|
|
469
|
+
// counted it.
|
|
470
|
+
const ordered = [
|
|
471
|
+
...GROUP_ORDER.filter((g) => byGroup.has(g)),
|
|
472
|
+
...[...byGroup.keys()].filter((g) => !GROUP_ORDER.includes(g)),
|
|
473
|
+
];
|
|
474
|
+
|
|
475
|
+
const chipWidth = badgeWidth(items);
|
|
476
|
+
// No bar: it stacked the mix into one strip above groups that already print
|
|
477
|
+
// their own counts, in a form nobody can read a number off.
|
|
478
|
+
const lines: string[] = [
|
|
479
|
+
"",
|
|
480
|
+
`${PAD}${bold("Plugins")} ${dim(String(items.length))}`,
|
|
481
|
+
];
|
|
482
|
+
|
|
483
|
+
for (const group of ordered) {
|
|
484
|
+
const rows = byGroup.get(group);
|
|
485
|
+
if (!rows || rows.length === 0) continue;
|
|
486
|
+
lines.push(
|
|
487
|
+
"",
|
|
488
|
+
`${PAD}${bold(fg(groupStyle(group).text, group))} ${dim(
|
|
489
|
+
String(rows.length),
|
|
490
|
+
)}`,
|
|
491
|
+
);
|
|
492
|
+
for (const row of rows) lines.push(pluginRow(row, nameWidth, chipWidth));
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return lines;
|
|
496
|
+
}
|
|
497
|
+
|
|
298
498
|
function section(
|
|
299
499
|
title: string,
|
|
300
500
|
rows: Array<{ action: ActionKey; name: string; detail: string }>,
|
|
@@ -304,13 +504,6 @@ function section(
|
|
|
304
504
|
const counts = new Map<ActionKey, number>();
|
|
305
505
|
for (const row of rows)
|
|
306
506
|
counts.set(row.action, (counts.get(row.action) ?? 0) + 1);
|
|
307
|
-
const bar = stackedBar(
|
|
308
|
-
BAR,
|
|
309
|
-
[...counts].map(([action, value]) => ({
|
|
310
|
-
value,
|
|
311
|
-
color: ACTIONS[action].color,
|
|
312
|
-
})),
|
|
313
|
-
);
|
|
314
507
|
const legend = [...counts]
|
|
315
508
|
.map(([action, n]) =>
|
|
316
509
|
fg(ACTIONS[action].color, `${n} ${ACTIONS[action].label.toLowerCase()}`),
|
|
@@ -319,7 +512,7 @@ function section(
|
|
|
319
512
|
|
|
320
513
|
return [
|
|
321
514
|
"",
|
|
322
|
-
`${PAD}${bold(padEnd(title, 9))} ${
|
|
515
|
+
`${PAD}${bold(padEnd(title, 9))} ${dim(
|
|
323
516
|
padStart(String(rows.length), 3),
|
|
324
517
|
)} ${legend}`,
|
|
325
518
|
"",
|
|
@@ -341,16 +534,22 @@ export function planReport(plan: UpdatePlan): string[] {
|
|
|
341
534
|
...plan.skills.map((s) => width(s.name)),
|
|
342
535
|
);
|
|
343
536
|
|
|
537
|
+
// EVERY planned plugin gets a row. There is no filter here and there must
|
|
538
|
+
// not be one.
|
|
539
|
+
//
|
|
540
|
+
// A previous revision dropped `unknown` + `not-offered` + not-installed rows
|
|
541
|
+
// — a pin naming a plugin no marketplace offers — on the grounds that a
|
|
542
|
+
// notice named them instead. That notice did not exist: the function the
|
|
543
|
+
// comment cited was never written. The row appeared in no table and no
|
|
544
|
+
// notice, while the closing lines still counted it and said "Reasons are
|
|
545
|
+
// shown above", pointing at nothing.
|
|
546
|
+
//
|
|
547
|
+
// A report may never silently drop a row. That is the whole defect this
|
|
548
|
+
// file's grouping was written to end, and a filter here reintroduces it in a
|
|
549
|
+
// form that is harder to see, because the header count agrees with the rows
|
|
550
|
+
// and only the plan knows something is missing.
|
|
344
551
|
return [
|
|
345
|
-
...
|
|
346
|
-
"Plugins",
|
|
347
|
-
plan.plugins.map((item) => ({
|
|
348
|
-
action: pluginAction(item),
|
|
349
|
-
name: item.pluginId,
|
|
350
|
-
detail: pluginDetail(item),
|
|
351
|
-
})),
|
|
352
|
-
nameWidth,
|
|
353
|
-
),
|
|
552
|
+
...pluginSection(plan.plugins, nameWidth),
|
|
354
553
|
...section(
|
|
355
554
|
"CLI",
|
|
356
555
|
plan.bins.map((item: BinUpdateItem) => ({
|
|
@@ -488,13 +687,6 @@ export function note(text: string): string {
|
|
|
488
687
|
// false for a plugin whose marketplace publishes no version: re-running changes
|
|
489
688
|
// nothing, and the reader went looking for a network fault that did not exist.
|
|
490
689
|
|
|
491
|
-
/** Unverified plugins, under the plan. Never "up to date" — that is the point. */
|
|
492
|
-
export function unknownPluginsNotice(count: number): string {
|
|
493
|
-
return warn(
|
|
494
|
-
`${bold(String(count))} plugin(s) could not be checked — not applied. They are NOT reported as up to date. Reasons are shown above.`,
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
|
|
498
690
|
/** Unverified tools, under the plan. */
|
|
499
691
|
export function unknownToolsNotice(count: number, names: string[]): string {
|
|
500
692
|
return warn(
|
package/src/cli/update.ts
CHANGED
|
@@ -70,8 +70,9 @@ import {
|
|
|
70
70
|
} from "../services/update-plan.js";
|
|
71
71
|
import { brand } from "../ui/theme.js";
|
|
72
72
|
import { bold, dim, fg, width } from "./ansi.js";
|
|
73
|
-
import { ensureManifest } from "./bootstrap.js";
|
|
73
|
+
import { ensureManifest, requireActiveProfile } from "./bootstrap.js";
|
|
74
74
|
import { LiveRegion, withPaused } from "./live.js";
|
|
75
|
+
import { selectDialog } from "./tui-prompt.js";
|
|
75
76
|
import {
|
|
76
77
|
type ApplyState,
|
|
77
78
|
type Step,
|
|
@@ -88,7 +89,6 @@ import {
|
|
|
88
89
|
stepRecord,
|
|
89
90
|
summaryLine,
|
|
90
91
|
uncheckedGateNotice,
|
|
91
|
-
unknownPluginsNotice,
|
|
92
92
|
unknownToolsNotice,
|
|
93
93
|
warn as warnLine,
|
|
94
94
|
} from "./update-view.js";
|
|
@@ -191,8 +191,42 @@ export async function runUpdateCommand(
|
|
|
191
191
|
return 1;
|
|
192
192
|
}
|
|
193
193
|
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
// No active profile, no update: that is where plugin state is read from and
|
|
195
|
+
// where changes are written. Asked before the profile is resolved, so what
|
|
196
|
+
// the user activates here is what this run uses.
|
|
197
|
+
//
|
|
198
|
+
// BOTH `--check` and `--dry-run` are read-only here. An earlier version let
|
|
199
|
+
// `--dry-run` activate, reasoning that a preview needs a real profile to
|
|
200
|
+
// report against — but activating writes symlinks and edits a TRACKED
|
|
201
|
+
// `.gitignore`, and `--dry-run` promises to write nothing and exit 0. A
|
|
202
|
+
// preview that mutates the repo is not a preview.
|
|
203
|
+
//
|
|
204
|
+
// So a read-only run refuses instead, and the two flags differ only in what
|
|
205
|
+
// that refusal MEANS: `--check` is a gate, so it fails; `--dry-run` is a
|
|
206
|
+
// preview, and "nothing to preview until a profile is active" is a legitimate
|
|
207
|
+
// preview result rather than an error.
|
|
208
|
+
//
|
|
209
|
+
// NAMING A PROFILE SKIPS THE GATE. `magus update lean` says which profile to
|
|
210
|
+
// update, and updating it needs the manifest, not the symlink — installs go
|
|
211
|
+
// to project scope either way. Gating it would refuse a question the user
|
|
212
|
+
// already answered on the command line, and would break `magus update lean`
|
|
213
|
+
// and `--check lean` in CI, both of which worked before this gate existed.
|
|
214
|
+
// ANY positional argument skips it, not just a valid one. A wrong name has to
|
|
215
|
+
// reach the "Profile not found. Available: …" check below, which says what
|
|
216
|
+
// the user got wrong; gating it first answers a different question — "no
|
|
217
|
+
// profile is selected" — which is true, unhelpful, and not the mistake.
|
|
218
|
+
const current =
|
|
219
|
+
flags.profile !== undefined
|
|
220
|
+
? manifest
|
|
221
|
+
: await requireActiveProfile(projectPath, manifest, {
|
|
222
|
+
check: flags.check || flags.dryRun,
|
|
223
|
+
});
|
|
224
|
+
// A refused dry run exits 0: it wrote nothing and previewed nothing, which is
|
|
225
|
+
// what it promised. A refused `--check` exits 1, because that is a gate.
|
|
226
|
+
if (!current) return flags.dryRun ? 0 : 1;
|
|
227
|
+
|
|
228
|
+
const profileIds = Object.keys(current.profiles);
|
|
229
|
+
if (flags.profile && !current.profiles[flags.profile]) {
|
|
196
230
|
console.error(
|
|
197
231
|
failLine(
|
|
198
232
|
`Profile "${flags.profile}" not found. Available: ${profileIds.join(", ")}`,
|
|
@@ -205,19 +239,27 @@ export async function runUpdateCommand(
|
|
|
205
239
|
// use. A single-profile manifest needs no activation to be unambiguous.
|
|
206
240
|
// Chosen through the same function the TUI uses, so both judge a plugin
|
|
207
241
|
// against the same pin.
|
|
208
|
-
const
|
|
242
|
+
const resolvedId = selectProfileId(
|
|
209
243
|
profileIds,
|
|
210
244
|
flags.profile,
|
|
211
245
|
await activeProfile(projectPath),
|
|
212
246
|
);
|
|
213
|
-
|
|
247
|
+
|
|
248
|
+
// `requireActiveProfile` above has already guaranteed one is active, so rule
|
|
249
|
+
// 2 of `selectProfileId` resolves and this is only unreachable-guard. There
|
|
250
|
+
// is deliberately no "pick one for this run" fallback any more: it used to
|
|
251
|
+
// ask here and then update a profile the project was not running, which is
|
|
252
|
+
// the two-worlds behaviour the gate exists to end. An explicit argument is
|
|
253
|
+
// still honoured — naming a profile is naming intent.
|
|
254
|
+
if (!resolvedId) {
|
|
214
255
|
console.error(
|
|
215
256
|
failLine(
|
|
216
|
-
`No active profile.
|
|
257
|
+
`No active profile. Run \`magus profile switch <${profileIds.join("|")}>\`.`,
|
|
217
258
|
),
|
|
218
259
|
);
|
|
219
260
|
return 1;
|
|
220
261
|
}
|
|
262
|
+
const targetId = resolvedId;
|
|
221
263
|
|
|
222
264
|
for (const line of header(targetId)) console.log(line);
|
|
223
265
|
|
|
@@ -244,7 +286,7 @@ export async function runUpdateCommand(
|
|
|
244
286
|
|
|
245
287
|
const closure = await runStep(steps.profile, async (say) => {
|
|
246
288
|
say(`resolving "${targetId}"`);
|
|
247
|
-
const resolved = await resolveProfile(
|
|
289
|
+
const resolved = await resolveProfile(current, targetId);
|
|
248
290
|
steps.profile.detail = [
|
|
249
291
|
`${Object.keys(resolved.plugins).length} plugins`,
|
|
250
292
|
`${resolved.bins.length} tools`,
|
|
@@ -293,30 +335,13 @@ export async function runUpdateCommand(
|
|
|
293
335
|
});
|
|
294
336
|
steps.marketplaces.detail = [
|
|
295
337
|
`${refresh.refreshed.length} refreshed`,
|
|
296
|
-
refresh.autoUpdateDisabled.length > 0
|
|
297
|
-
? `${refresh.autoUpdateDisabled.length} auto-update off`
|
|
298
|
-
: "",
|
|
299
338
|
refresh.failed.length > 0 ? `${refresh.failed.length} failed` : "",
|
|
300
339
|
refresh.skipped.length > 0 ? `${refresh.skipped.length} skipped` : "",
|
|
301
340
|
]
|
|
302
341
|
.filter(Boolean)
|
|
303
342
|
.join(" · ");
|
|
304
343
|
if (refresh.failed.length > 0) steps.marketplaces.state = "fail";
|
|
305
|
-
else if (refresh.autoUpdateDisabled.length > 0)
|
|
306
|
-
steps.marketplaces.state = "warn";
|
|
307
344
|
|
|
308
|
-
for (const name of refresh.autoUpdateDisabled) {
|
|
309
|
-
notices.push(
|
|
310
|
-
warnLine(
|
|
311
|
-
`${bold(name)}: auto-update disabled — its catalog will not refresh, so updates stay hidden.`,
|
|
312
|
-
),
|
|
313
|
-
);
|
|
314
|
-
notices.push(
|
|
315
|
-
noteLine(
|
|
316
|
-
`claude plugin marketplace update ${name} # to refresh it once`,
|
|
317
|
-
),
|
|
318
|
-
);
|
|
319
|
-
}
|
|
320
345
|
for (const name of refresh.failed) {
|
|
321
346
|
notices.push(
|
|
322
347
|
failLine(
|
|
@@ -405,11 +430,16 @@ export async function runUpdateCommand(
|
|
|
405
430
|
.map((b) => b.name);
|
|
406
431
|
const toolUnknownLine = () =>
|
|
407
432
|
unknownToolsNotice(counts.unknownTools, unknownToolNames);
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
433
|
+
// No plugin equivalent of `toolUnknownLine`. Every unverified plugin is
|
|
434
|
+
// already a row in the table above, under a heading that names its state,
|
|
435
|
+
// so a trailing "N plugin(s) could not be checked" restated the table in
|
|
436
|
+
// prose and — since the grouping landed — miscounted it: the count is raw
|
|
437
|
+
// `unknown`, which now includes rows filed under Current. A tool has no
|
|
438
|
+
// such row, which is why that notice stays and names them.
|
|
439
|
+
if (counts.unknownTools > 0) {
|
|
440
|
+
console.log("");
|
|
441
|
+
console.log(toolUnknownLine());
|
|
411
442
|
}
|
|
412
|
-
if (counts.unknownTools > 0) console.log(toolUnknownLine());
|
|
413
443
|
|
|
414
444
|
// `--check` gates on planIsBehind, not planHasWork. An already-installed
|
|
415
445
|
// skill is re-fetched on every apply because its manifest ref carries no
|
package/src/main.tsx
CHANGED
|
@@ -84,7 +84,7 @@ async function main(): Promise<void> {
|
|
|
84
84
|
// top-level flags (--version/--help). A bare invocation falls through to
|
|
85
85
|
// the interactive TUI below.
|
|
86
86
|
const { route } = await import("./cli/router.js");
|
|
87
|
-
const outcome = await route(args, VERSION);
|
|
87
|
+
const outcome = await route(args, VERSION, themeEnv);
|
|
88
88
|
if (outcome.handled) {
|
|
89
89
|
process.exit(outcome.exitCode ?? 0);
|
|
90
90
|
}
|
|
@@ -104,7 +104,16 @@ async function main(): Promise<void> {
|
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
main().catch((error) => {
|
|
107
|
+
main().catch(async (error) => {
|
|
108
|
+
// Ctrl+C at a prompt is an answer — "stop" — not a failure. 130 is the
|
|
109
|
+
// shell's own convention for SIGINT (128 + 2), so `magus update && next`
|
|
110
|
+
// stops rather than running `next`. The newline lands the shell prompt on
|
|
111
|
+
// its own line instead of against the half-finished question.
|
|
112
|
+
const { isPromptAborted } = await import("./cli/prompt.js");
|
|
113
|
+
if (isPromptAborted(error)) {
|
|
114
|
+
process.stderr.write("\n");
|
|
115
|
+
process.exit(130);
|
|
116
|
+
}
|
|
108
117
|
console.error("Error starting magus:", error);
|
|
109
118
|
process.exit(1);
|
|
110
119
|
});
|