magus-cli 7.0.0 → 7.1.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__/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 +19 -11
- package/src/cli/router.ts +59 -2
- package/src/cli/update-view.ts +236 -45
- package/src/cli/update.ts +9 -22
- package/src/main.tsx +1 -1
- package/src/services/claude-settings.ts +15 -40
- package/src/services/local-marketplace.ts +76 -6
- package/src/services/marketplace-refresh.ts +7 -15
- package/src/services/plugin-manager.ts +0 -31
- package/src/services/resolver.ts +33 -0
- package/src/services/update-plan.ts +23 -66
- package/src/ui/App.tsx +0 -3
- package/src/ui/screens/PluginsScreen.tsx +9 -50
- package/src/ui/theme-mode.ts +88 -0
- package/src/utils/command-utils.ts +19 -14
- package/src/__tests__/moved-marketplace.test.ts +0 -86
package/src/cli/update-view.ts
CHANGED
|
@@ -31,6 +31,7 @@ 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 {
|
|
@@ -84,8 +85,11 @@ type ActionKey =
|
|
|
84
85
|
const ACTIONS: Record<ActionKey, ActionStyle> = {
|
|
85
86
|
current: { glyph: "=", label: "CURRENT", color: brand.muted, chrome: true },
|
|
86
87
|
install: { glyph: "+", label: "INSTALL", color: brand.link },
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
// Green, the tone magus uses for an update everywhere else. Amber stays
|
|
89
|
+
// on `unknown` below, so the two states remain distinguishable: green is
|
|
90
|
+
// "a newer version exists", amber is "we could not tell".
|
|
91
|
+
update: { glyph: "↑", label: "UPDATE", color: brand.success },
|
|
92
|
+
upgrade: { glyph: "↑", label: "UPGRADE", color: brand.success },
|
|
89
93
|
repair: { glyph: "⟳", label: "REPAIR", color: brand.info },
|
|
90
94
|
refresh: { glyph: "⟳", label: "REFRESH", color: brand.info },
|
|
91
95
|
// Warning, not danger: "cannot tell" is not a failure, and red UNKNOWN read
|
|
@@ -207,24 +211,24 @@ export function stepFrame(steps: Step[], tick: number, now: number): string[] {
|
|
|
207
211
|
}
|
|
208
212
|
|
|
209
213
|
/**
|
|
210
|
-
* The permanent record,
|
|
214
|
+
* The permanent record: one row per step, its duration, and what it found.
|
|
211
215
|
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
+
* NO BAR. Each row carried an 18-cell meter of that step's share of the
|
|
217
|
+
* slowest step — which is precisely what the right-aligned millisecond column
|
|
218
|
+
* beside it already says, so the row stated one fact twice. A shape earns its
|
|
219
|
+
* width by making something readable that the number does not; a share of a
|
|
220
|
+
* five-row set, already sorted into a column, is not that.
|
|
221
|
+
*
|
|
222
|
+
* The LIVE frame keeps its bar (`stepFrame` above) and that is not the same
|
|
223
|
+
* widget: there the bar is a progress meter or an indeterminate sweep on the
|
|
224
|
+
* step running right now, and it answers "is this working or hung?" — a
|
|
225
|
+
* question no printed number can answer.
|
|
216
226
|
*/
|
|
217
227
|
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);
|
|
228
|
+
return steps.map((step) => {
|
|
223
229
|
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
|
-
)}`;
|
|
230
|
+
const time = dim(padStart(elapsed(stepDuration(step, now) ?? 0), 6));
|
|
231
|
+
return `${PAD}${stepMark(step, 0)} ${label} ${time} ${dim(step.detail)}`;
|
|
228
232
|
});
|
|
229
233
|
}
|
|
230
234
|
|
|
@@ -236,15 +240,30 @@ function pluginAction(item: PluginUpdateItem): ActionKey {
|
|
|
236
240
|
|
|
237
241
|
/** What the row says on its right: versions, never the action word again. */
|
|
238
242
|
function pluginDetail(item: PluginUpdateItem): string {
|
|
239
|
-
|
|
243
|
+
// A note the row's own badge already states is noise, not information. The
|
|
244
|
+
// DEPRECATED chip and the heading above it both say "no configured
|
|
245
|
+
// marketplace offers this", so printing the sentence again put the longest
|
|
246
|
+
// string in the table on the least interesting rows.
|
|
247
|
+
//
|
|
248
|
+
// The note stays on the item. The TUI has no heading to lean on and renders
|
|
249
|
+
// it as that row's only explanation — this is a rendering decision about a
|
|
250
|
+
// table that carries the fact twice, not a change to what is known.
|
|
251
|
+
const note =
|
|
252
|
+
item.note && item.reason !== "not-offered"
|
|
253
|
+
? ` ${dim(`(${item.note})`)}`
|
|
254
|
+
: "";
|
|
240
255
|
switch (item.action) {
|
|
241
256
|
case "install":
|
|
242
257
|
return `${item.target ?? "latest"}${note}`;
|
|
243
258
|
case "update":
|
|
259
|
+
// The GROUP's page tone, not a literal and not ACTIONS: the arrow and
|
|
260
|
+
// the target sit in the same row as that group's glyph and chip, so
|
|
261
|
+
// they must resolve per page from the same place. A hardcoded tone
|
|
262
|
+
// here is how the row ended up half green and half amber.
|
|
244
263
|
return `${dim(item.installed ?? "—")} ${fg(
|
|
245
|
-
|
|
264
|
+
groupStyle("Update").text,
|
|
246
265
|
"→",
|
|
247
|
-
)} ${fg(
|
|
266
|
+
)} ${fg(groupStyle("Update").text, item.target ?? "?")}${note}`;
|
|
248
267
|
case "repair":
|
|
249
268
|
case "unknown":
|
|
250
269
|
return `${item.installed ?? "—"}${note}`;
|
|
@@ -272,8 +291,11 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
|
|
|
272
291
|
item.version ? ` ${dim(`pinned ${item.version}`)}` : ""
|
|
273
292
|
}`;
|
|
274
293
|
case "upgrade":
|
|
275
|
-
return `${dim(item.installed ?? "—")} ${fg(
|
|
276
|
-
|
|
294
|
+
return `${dim(item.installed ?? "—")} ${fg(
|
|
295
|
+
ACTIONS.upgrade.color,
|
|
296
|
+
"→",
|
|
297
|
+
)} ${fg(
|
|
298
|
+
ACTIONS.upgrade.color,
|
|
277
299
|
item.target ?? "?",
|
|
278
300
|
)}${command ? ` ${command}` : ""}${
|
|
279
301
|
// An upgrade that also changes the tool's owner says so here; a
|
|
@@ -295,6 +317,183 @@ function binDetail(item: BinUpdateItem, plan: UpdatePlan): string {
|
|
|
295
317
|
* The bar is the section's whole story in one line — a reader who takes in
|
|
296
318
|
* nothing else knows whether this section is mostly settled or mostly work.
|
|
297
319
|
*/
|
|
320
|
+
/**
|
|
321
|
+
* Which heading a plugin row sits under.
|
|
322
|
+
*
|
|
323
|
+
* Read off the plan's machine codes — `action` and `reason` — never the human
|
|
324
|
+
* note. Five headings, and no more:
|
|
325
|
+
*
|
|
326
|
+
* Update something an apply would move forward
|
|
327
|
+
* Deprecated no configured marketplace offers it
|
|
328
|
+
* Fix required declared or enabled, with nothing on disk
|
|
329
|
+
* Unverified the check itself did not complete
|
|
330
|
+
* Current settled — the version was compared, or there was none to compare
|
|
331
|
+
*
|
|
332
|
+
* `Current` is last on purpose: it is the biggest group and the least
|
|
333
|
+
* interesting, and leading with it buries the few rows that need a decision.
|
|
334
|
+
*
|
|
335
|
+
* UNVERIFIED IS NOT CURRENT, and the two must not share a heading. A row whose
|
|
336
|
+
* catalog read failed used to land under `Current` carrying a `?` glyph and a
|
|
337
|
+
* note saying the catalog was unreachable — a row that contradicted the heading
|
|
338
|
+
* above it, while the closing notices insisted those rows are NOT reported as up
|
|
339
|
+
* to date. That is a different claim from "nothing could be compared, so we
|
|
340
|
+
* assume the install is the latest", which IS `current` and reaches it in the
|
|
341
|
+
* classifier, not here.
|
|
342
|
+
*
|
|
343
|
+
* "Unverified" is the word `summaryLine` already prints for this count; it is
|
|
344
|
+
* this report's existing vocabulary, not a new one.
|
|
345
|
+
*/
|
|
346
|
+
function pluginGroup(item: PluginUpdateItem): string {
|
|
347
|
+
if (item.action === "update" || item.action === "repair") return "Update";
|
|
348
|
+
if (item.action === "install") return "Fix required";
|
|
349
|
+
if (item.action === "unknown") {
|
|
350
|
+
return item.reason === "not-offered" ? "Deprecated" : "Unverified";
|
|
351
|
+
}
|
|
352
|
+
return "Current";
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const GROUP_ORDER = [
|
|
356
|
+
"Update",
|
|
357
|
+
"Deprecated",
|
|
358
|
+
"Fix required",
|
|
359
|
+
"Unverified",
|
|
360
|
+
"Current",
|
|
361
|
+
];
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Colour and emphasis per GROUP.
|
|
365
|
+
*
|
|
366
|
+
* "Dim the chrome, saturate the signal" is this file's own rule, stated at the
|
|
367
|
+
* top; the signal is the groups an apply would act on. Every colour comes from
|
|
368
|
+
* `brand`, which ui/theme.ts contrast-checks against BOTH a cream and a
|
|
369
|
+
* near-black terminal, so a light theme needs no second palette.
|
|
370
|
+
*/
|
|
371
|
+
const GROUP_KIND: Record<string, ReportBadgeKind> = {
|
|
372
|
+
Update: "update",
|
|
373
|
+
Deprecated: "deprecated",
|
|
374
|
+
"Fix required": "fix",
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
interface GroupStyle {
|
|
378
|
+
/** Heading and glyph, drawn on the page. */
|
|
379
|
+
text: string;
|
|
380
|
+
/** Badge fill and its ink. Absent on a chrome group, which carries no badge. */
|
|
381
|
+
fill?: string;
|
|
382
|
+
ink?: string;
|
|
383
|
+
chrome?: boolean;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* A group's colours, for the page this run resolved.
|
|
388
|
+
*
|
|
389
|
+
* `reportBadge` answers per page — pale fill with deep ink on cream, saturated
|
|
390
|
+
* fill with pale ink on near-black — so this is the one place the report stops
|
|
391
|
+
* using a single set of hues for both. `brand` is still right for everything
|
|
392
|
+
* else here: those tokens are chosen to clear 3:1 as INK on either page, which
|
|
393
|
+
* is a different question from what a filled chip should look like.
|
|
394
|
+
*/
|
|
395
|
+
function groupStyle(group: string): GroupStyle {
|
|
396
|
+
const kind = GROUP_KIND[group];
|
|
397
|
+
if (!kind) return { text: brand.muted, chrome: true };
|
|
398
|
+
const ink = reportBadge(kind);
|
|
399
|
+
return { text: ink.text, fill: ink.fill, ink: ink.ink };
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** The badge's word: the heading, uppercased. Never a second vocabulary. */
|
|
403
|
+
function groupBadge(group: string): string {
|
|
404
|
+
return group.toUpperCase();
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Badge column width, from the labels THIS report shows — a run with nothing
|
|
409
|
+
* deprecated does not pay for the longest word. `width` ignores escape bytes,
|
|
410
|
+
* so measuring an already painted badge is safe.
|
|
411
|
+
*/
|
|
412
|
+
function badgeWidth(items: PluginUpdateItem[]): number {
|
|
413
|
+
return Math.max(
|
|
414
|
+
0,
|
|
415
|
+
...items
|
|
416
|
+
.filter((item) => !groupStyle(pluginGroup(item)).chrome)
|
|
417
|
+
.map((item) => {
|
|
418
|
+
const style = groupStyle(pluginGroup(item));
|
|
419
|
+
return (
|
|
420
|
+
width(
|
|
421
|
+
badge(
|
|
422
|
+
groupBadge(pluginGroup(item)),
|
|
423
|
+
style.fill ?? brand.muted,
|
|
424
|
+
style.ink,
|
|
425
|
+
),
|
|
426
|
+
) + 2
|
|
427
|
+
);
|
|
428
|
+
}),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function pluginRow(
|
|
433
|
+
item: PluginUpdateItem,
|
|
434
|
+
nameWidth: number,
|
|
435
|
+
chipWidth: number,
|
|
436
|
+
): string {
|
|
437
|
+
const group = pluginGroup(item);
|
|
438
|
+
const style = groupStyle(group);
|
|
439
|
+
const name = padEnd(item.pluginId, nameWidth);
|
|
440
|
+
// Only an actionable row is badged; a chrome row gets the same width in
|
|
441
|
+
// spaces, so every version still starts in one column.
|
|
442
|
+
const chip = padEnd(
|
|
443
|
+
style.chrome
|
|
444
|
+
? ""
|
|
445
|
+
: badge(groupBadge(group), style.fill ?? brand.muted, style.ink),
|
|
446
|
+
chipWidth,
|
|
447
|
+
);
|
|
448
|
+
return `${PAD}${fg(style.text, ACTIONS[item.action].glyph)} ${
|
|
449
|
+
style.chrome ? dim(name) : name
|
|
450
|
+
} ${chip}${pluginDetail(item)}`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** The Plugins section: grouped by state, `Current` last. */
|
|
454
|
+
function pluginSection(items: PluginUpdateItem[], nameWidth: number): string[] {
|
|
455
|
+
if (items.length === 0) return [];
|
|
456
|
+
|
|
457
|
+
const byGroup = new Map<string, PluginUpdateItem[]>();
|
|
458
|
+
for (const item of items) {
|
|
459
|
+
const group = pluginGroup(item);
|
|
460
|
+
const list = byGroup.get(group);
|
|
461
|
+
if (list) list.push(item);
|
|
462
|
+
else byGroup.set(group, [item]);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// GROUP_ORDER first, then anything it does not name. A report may never
|
|
466
|
+
// silently drop a row: driving the output purely from a fixed list would
|
|
467
|
+
// render an unexpected group as nothing at all while the header still
|
|
468
|
+
// counted it.
|
|
469
|
+
const ordered = [
|
|
470
|
+
...GROUP_ORDER.filter((g) => byGroup.has(g)),
|
|
471
|
+
...[...byGroup.keys()].filter((g) => !GROUP_ORDER.includes(g)),
|
|
472
|
+
];
|
|
473
|
+
|
|
474
|
+
const chipWidth = badgeWidth(items);
|
|
475
|
+
// No bar: it stacked the mix into one strip above groups that already print
|
|
476
|
+
// their own counts, in a form nobody can read a number off.
|
|
477
|
+
const lines: string[] = [
|
|
478
|
+
"",
|
|
479
|
+
`${PAD}${bold("Plugins")} ${dim(String(items.length))}`,
|
|
480
|
+
];
|
|
481
|
+
|
|
482
|
+
for (const group of ordered) {
|
|
483
|
+
const rows = byGroup.get(group);
|
|
484
|
+
if (!rows || rows.length === 0) continue;
|
|
485
|
+
lines.push(
|
|
486
|
+
"",
|
|
487
|
+
`${PAD}${bold(fg(groupStyle(group).text, group))} ${dim(
|
|
488
|
+
String(rows.length),
|
|
489
|
+
)}`,
|
|
490
|
+
);
|
|
491
|
+
for (const row of rows) lines.push(pluginRow(row, nameWidth, chipWidth));
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
return lines;
|
|
495
|
+
}
|
|
496
|
+
|
|
298
497
|
function section(
|
|
299
498
|
title: string,
|
|
300
499
|
rows: Array<{ action: ActionKey; name: string; detail: string }>,
|
|
@@ -304,13 +503,6 @@ function section(
|
|
|
304
503
|
const counts = new Map<ActionKey, number>();
|
|
305
504
|
for (const row of rows)
|
|
306
505
|
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
506
|
const legend = [...counts]
|
|
315
507
|
.map(([action, n]) =>
|
|
316
508
|
fg(ACTIONS[action].color, `${n} ${ACTIONS[action].label.toLowerCase()}`),
|
|
@@ -319,7 +511,7 @@ function section(
|
|
|
319
511
|
|
|
320
512
|
return [
|
|
321
513
|
"",
|
|
322
|
-
`${PAD}${bold(padEnd(title, 9))} ${
|
|
514
|
+
`${PAD}${bold(padEnd(title, 9))} ${dim(
|
|
323
515
|
padStart(String(rows.length), 3),
|
|
324
516
|
)} ${legend}`,
|
|
325
517
|
"",
|
|
@@ -341,16 +533,22 @@ export function planReport(plan: UpdatePlan): string[] {
|
|
|
341
533
|
...plan.skills.map((s) => width(s.name)),
|
|
342
534
|
);
|
|
343
535
|
|
|
536
|
+
// EVERY planned plugin gets a row. There is no filter here and there must
|
|
537
|
+
// not be one.
|
|
538
|
+
//
|
|
539
|
+
// A previous revision dropped `unknown` + `not-offered` + not-installed rows
|
|
540
|
+
// — a pin naming a plugin no marketplace offers — on the grounds that a
|
|
541
|
+
// notice named them instead. That notice did not exist: the function the
|
|
542
|
+
// comment cited was never written. The row appeared in no table and no
|
|
543
|
+
// notice, while the closing lines still counted it and said "Reasons are
|
|
544
|
+
// shown above", pointing at nothing.
|
|
545
|
+
//
|
|
546
|
+
// A report may never silently drop a row. That is the whole defect this
|
|
547
|
+
// file's grouping was written to end, and a filter here reintroduces it in a
|
|
548
|
+
// form that is harder to see, because the header count agrees with the rows
|
|
549
|
+
// and only the plan knows something is missing.
|
|
344
550
|
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
|
-
),
|
|
551
|
+
...pluginSection(plan.plugins, nameWidth),
|
|
354
552
|
...section(
|
|
355
553
|
"CLI",
|
|
356
554
|
plan.bins.map((item: BinUpdateItem) => ({
|
|
@@ -488,13 +686,6 @@ export function note(text: string): string {
|
|
|
488
686
|
// false for a plugin whose marketplace publishes no version: re-running changes
|
|
489
687
|
// nothing, and the reader went looking for a network fault that did not exist.
|
|
490
688
|
|
|
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
689
|
/** Unverified tools, under the plan. */
|
|
499
690
|
export function unknownToolsNotice(count: number, names: string[]): string {
|
|
500
691
|
return warn(
|
package/src/cli/update.ts
CHANGED
|
@@ -88,7 +88,6 @@ import {
|
|
|
88
88
|
stepRecord,
|
|
89
89
|
summaryLine,
|
|
90
90
|
uncheckedGateNotice,
|
|
91
|
-
unknownPluginsNotice,
|
|
92
91
|
unknownToolsNotice,
|
|
93
92
|
warn as warnLine,
|
|
94
93
|
} from "./update-view.js";
|
|
@@ -293,30 +292,13 @@ export async function runUpdateCommand(
|
|
|
293
292
|
});
|
|
294
293
|
steps.marketplaces.detail = [
|
|
295
294
|
`${refresh.refreshed.length} refreshed`,
|
|
296
|
-
refresh.autoUpdateDisabled.length > 0
|
|
297
|
-
? `${refresh.autoUpdateDisabled.length} auto-update off`
|
|
298
|
-
: "",
|
|
299
295
|
refresh.failed.length > 0 ? `${refresh.failed.length} failed` : "",
|
|
300
296
|
refresh.skipped.length > 0 ? `${refresh.skipped.length} skipped` : "",
|
|
301
297
|
]
|
|
302
298
|
.filter(Boolean)
|
|
303
299
|
.join(" · ");
|
|
304
300
|
if (refresh.failed.length > 0) steps.marketplaces.state = "fail";
|
|
305
|
-
else if (refresh.autoUpdateDisabled.length > 0)
|
|
306
|
-
steps.marketplaces.state = "warn";
|
|
307
301
|
|
|
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
302
|
for (const name of refresh.failed) {
|
|
321
303
|
notices.push(
|
|
322
304
|
failLine(
|
|
@@ -405,11 +387,16 @@ export async function runUpdateCommand(
|
|
|
405
387
|
.map((b) => b.name);
|
|
406
388
|
const toolUnknownLine = () =>
|
|
407
389
|
unknownToolsNotice(counts.unknownTools, unknownToolNames);
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
390
|
+
// No plugin equivalent of `toolUnknownLine`. Every unverified plugin is
|
|
391
|
+
// already a row in the table above, under a heading that names its state,
|
|
392
|
+
// so a trailing "N plugin(s) could not be checked" restated the table in
|
|
393
|
+
// prose and — since the grouping landed — miscounted it: the count is raw
|
|
394
|
+
// `unknown`, which now includes rows filed under Current. A tool has no
|
|
395
|
+
// such row, which is why that notice stays and names them.
|
|
396
|
+
if (counts.unknownTools > 0) {
|
|
397
|
+
console.log("");
|
|
398
|
+
console.log(toolUnknownLine());
|
|
411
399
|
}
|
|
412
|
-
if (counts.unknownTools > 0) console.log(toolUnknownLine());
|
|
413
400
|
|
|
414
401
|
// `--check` gates on planIsBehind, not planHasWork. An already-installed
|
|
415
402
|
// 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
|
}
|
|
@@ -816,24 +816,15 @@ async function writeKnownMarketplaces(
|
|
|
816
816
|
}
|
|
817
817
|
|
|
818
818
|
/**
|
|
819
|
-
*
|
|
820
|
-
*
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
known[marketplaceName].autoUpdate = autoUpdate;
|
|
829
|
-
await writeKnownMarketplaces(known);
|
|
830
|
-
return true;
|
|
831
|
-
}
|
|
832
|
-
return false; // Marketplace not yet installed by Claude Code
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
/**
|
|
836
|
-
* Get autoUpdate status for a marketplace
|
|
819
|
+
* Read a marketplace's `autoUpdate` flag — Claude Code's refresh policy.
|
|
820
|
+
*
|
|
821
|
+
* READ ONLY, and there is deliberately no setter. `autoUpdate` is Claude Code's
|
|
822
|
+
* field (50 occurrences in the 2.1.269 binary, with its own manifest-schema
|
|
823
|
+
* entry and a "Synced autoUpdate= from settings" path), so Claude Code decides
|
|
824
|
+
* its value. magus used to write `true` into every MadAppGang entry on TUI
|
|
825
|
+
* startup, which is a direct write to `known_marketplaces.json` — the file at
|
|
826
|
+
* the top of CLAUDE.md's never-write-directly list — and it fought Claude Code's
|
|
827
|
+
* own sync for ownership of the same byte.
|
|
837
828
|
*/
|
|
838
829
|
export async function getMarketplaceAutoUpdate(
|
|
839
830
|
marketplaceName: string,
|
|
@@ -843,7 +834,6 @@ export async function getMarketplaceAutoUpdate(
|
|
|
843
834
|
}
|
|
844
835
|
|
|
845
836
|
export interface MarketplaceRecoveryResult {
|
|
846
|
-
enabledAutoUpdate: string[];
|
|
847
837
|
removed: string[];
|
|
848
838
|
reregistered: string[];
|
|
849
839
|
}
|
|
@@ -1189,24 +1179,19 @@ export async function migrateMarketplaceRename(
|
|
|
1189
1179
|
return result;
|
|
1190
1180
|
}
|
|
1191
1181
|
|
|
1192
|
-
/**
|
|
1193
|
-
* Check if a marketplace is from Magus (MadAppGang)
|
|
1194
|
-
*/
|
|
1195
|
-
function isMadAppGangMarketplace(entry: KnownMarketplaceEntry): boolean {
|
|
1196
|
-
const repo = entry.source?.repo?.toLowerCase() || "";
|
|
1197
|
-
return repo.includes("madappgang");
|
|
1198
|
-
}
|
|
1199
|
-
|
|
1200
1182
|
/**
|
|
1201
1183
|
* Recover/sync marketplace settings:
|
|
1202
|
-
* - Enable autoUpdate for Magus marketplaces that don't have it set
|
|
1203
1184
|
* - Remove entries for marketplaces whose installLocation no longer exists
|
|
1204
1185
|
* - Fix stale "directory" source for known Magus marketplaces that should be "github"
|
|
1186
|
+
*
|
|
1187
|
+
* It does NOT touch `autoUpdate`. It used to set it to `true` on every
|
|
1188
|
+
* MadAppGang entry whose flag was unset, on every TUI startup — writing a
|
|
1189
|
+
* Claude Code-owned field that Claude Code also syncs from settings. See
|
|
1190
|
+
* {@link getMarketplaceAutoUpdate}.
|
|
1205
1191
|
*/
|
|
1206
1192
|
export async function recoverMarketplaceSettings(): Promise<MarketplaceRecoveryResult> {
|
|
1207
1193
|
const known = await readKnownMarketplaces();
|
|
1208
1194
|
const result: MarketplaceRecoveryResult = {
|
|
1209
|
-
enabledAutoUpdate: [],
|
|
1210
1195
|
removed: [],
|
|
1211
1196
|
reregistered: [],
|
|
1212
1197
|
};
|
|
@@ -1249,21 +1234,11 @@ export async function recoverMarketplaceSettings(): Promise<MarketplaceRecoveryR
|
|
|
1249
1234
|
}
|
|
1250
1235
|
}
|
|
1251
1236
|
|
|
1252
|
-
// Enable autoUpdate if not set - only for Magus (MadAppGang) marketplaces
|
|
1253
|
-
if (entry.autoUpdate === undefined && isMadAppGangMarketplace(entry)) {
|
|
1254
|
-
entry.autoUpdate = true;
|
|
1255
|
-
result.enabledAutoUpdate.push(name);
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
1237
|
updatedKnown[name] = entry;
|
|
1259
1238
|
}
|
|
1260
1239
|
|
|
1261
1240
|
// Write back if any changes were made
|
|
1262
|
-
if (
|
|
1263
|
-
result.enabledAutoUpdate.length > 0 ||
|
|
1264
|
-
result.removed.length > 0 ||
|
|
1265
|
-
result.reregistered.length > 0
|
|
1266
|
-
) {
|
|
1241
|
+
if (result.removed.length > 0 || result.reregistered.length > 0) {
|
|
1267
1242
|
await writeKnownMarketplaces(updatedKnown);
|
|
1268
1243
|
}
|
|
1269
1244
|
|
|
@@ -64,17 +64,73 @@ interface KnownMarketplaceEntry {
|
|
|
64
64
|
type KnownMarketplaces = Record<string, KnownMarketplaceEntry>;
|
|
65
65
|
|
|
66
66
|
/**
|
|
67
|
-
* Read known_marketplaces.json
|
|
67
|
+
* Read known_marketplaces.json, or null when it cannot be read honestly.
|
|
68
|
+
*
|
|
69
|
+
* The null matters. {@link readKnownMarketplaces} collapses "absent",
|
|
70
|
+
* "unparseable" and "genuinely empty" into `{}`, which is fine for a lookup and
|
|
71
|
+
* catastrophic for a filter: intersecting the marketplaces directory against an
|
|
72
|
+
* empty registry deletes every marketplace, and every installed plugin then
|
|
73
|
+
* reports as no longer published. A registry we could not read is UNKNOWN, and
|
|
74
|
+
* unknown must not render as a determinate negative.
|
|
68
75
|
*/
|
|
69
|
-
async function
|
|
76
|
+
async function readKnownMarketplacesOrNull(): Promise<KnownMarketplaces | null> {
|
|
70
77
|
try {
|
|
71
|
-
if (await fs.pathExists(KNOWN_MARKETPLACES_FILE))
|
|
72
|
-
|
|
78
|
+
if (!(await fs.pathExists(KNOWN_MARKETPLACES_FILE))) return null;
|
|
79
|
+
const parsed = await fs.readJson(KNOWN_MARKETPLACES_FILE);
|
|
80
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
81
|
+
return null;
|
|
73
82
|
}
|
|
83
|
+
return parsed as KnownMarketplaces;
|
|
74
84
|
} catch {
|
|
75
|
-
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read known_marketplaces.json (Claude Code's internal marketplace tracking)
|
|
91
|
+
*/
|
|
92
|
+
async function readKnownMarketplaces(): Promise<KnownMarketplaces> {
|
|
93
|
+
return (await readKnownMarketplacesOrNull()) ?? {};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Directory names a parsed `known_marketplaces.json` registers, or null when it
|
|
98
|
+
* says nothing usable.
|
|
99
|
+
*
|
|
100
|
+
* Pure, and exported, because `scanLocalMarketplaces` resolves its paths from
|
|
101
|
+
* `os.homedir()` at module load and cannot be pointed at a temp directory. The
|
|
102
|
+
* decision this encodes is the part worth testing; the IO around it is not.
|
|
103
|
+
*
|
|
104
|
+
* Keyed by BOTH the registry key and the basename of `installLocation`: the two
|
|
105
|
+
* are normally equal and nothing enforces it, and dropping a real marketplace
|
|
106
|
+
* is far worse than keeping a stray directory.
|
|
107
|
+
*
|
|
108
|
+
* An empty object is an ANSWER — nothing is registered — and filters everything
|
|
109
|
+
* out. Only unusable input is null. The distinction lives in
|
|
110
|
+
* {@link readKnownMarketplacesOrNull}, which returns null for an absent file,
|
|
111
|
+
* so a machine that has never added a marketplace stays permissive.
|
|
112
|
+
*/
|
|
113
|
+
export function registeredMarketplaceNames(
|
|
114
|
+
parsed: unknown,
|
|
115
|
+
): Set<string> | null {
|
|
116
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
const names = new Set<string>();
|
|
120
|
+
for (const [name, entry] of Object.entries(
|
|
121
|
+
parsed as Record<string, unknown>,
|
|
122
|
+
)) {
|
|
123
|
+
names.add(name);
|
|
124
|
+
const loc = (entry as { installLocation?: unknown })?.installLocation;
|
|
125
|
+
if (typeof loc === "string" && loc.length > 0) {
|
|
126
|
+
names.add(path.basename(loc));
|
|
127
|
+
}
|
|
76
128
|
}
|
|
77
|
-
return
|
|
129
|
+
return names;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function registeredDirNames(): Promise<Set<string> | null> {
|
|
133
|
+
return registeredMarketplaceNames(await readKnownMarketplacesOrNull());
|
|
78
134
|
}
|
|
79
135
|
|
|
80
136
|
/**
|
|
@@ -321,6 +377,19 @@ export async function scanLocalMarketplaces(): Promise<
|
|
|
321
377
|
> {
|
|
322
378
|
const marketplaces = new Map<string, LocalMarketplace>();
|
|
323
379
|
|
|
380
|
+
// A directory is not a marketplace; a REGISTRY ENTRY is. Null means the
|
|
381
|
+
// registry could not be read, and then every directory is kept — see
|
|
382
|
+
// `registeredDirNames`.
|
|
383
|
+
//
|
|
384
|
+
// Claude Code refreshes a marketplace by delete-then-clone and leaves
|
|
385
|
+
// `<name>.bak` behind. An unfiltered scan treated that backup as a real
|
|
386
|
+
// marketplace, which cost the reporter twice: `magus.bak` shadowed the real
|
|
387
|
+
// `magus` in `getAllMarketplaces()` (deduped by repo, first-come) so the real
|
|
388
|
+
// one vanished from the plugin list entirely, and later it became the target
|
|
389
|
+
// of a nonsense label — `kanban@magus … moved → magus.bak`. `claude plugin
|
|
390
|
+
// marketplace list` never showed it, because Claude Code reads the registry.
|
|
391
|
+
const registered = await registeredDirNames();
|
|
392
|
+
|
|
324
393
|
// 1. Scan marketplaces from the standard directory
|
|
325
394
|
if (await fs.pathExists(CLAUDE_PLUGINS_DIR)) {
|
|
326
395
|
try {
|
|
@@ -330,6 +399,7 @@ export async function scanLocalMarketplaces(): Promise<
|
|
|
330
399
|
|
|
331
400
|
for (const entry of entries) {
|
|
332
401
|
if (!entry.isDirectory()) continue;
|
|
402
|
+
if (registered && !registered.has(entry.name)) continue;
|
|
333
403
|
|
|
334
404
|
const marketplacePath = path.join(CLAUDE_PLUGINS_DIR, entry.name);
|
|
335
405
|
const marketplace = await scanSingleMarketplace(
|