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.
@@ -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
- update: { glyph: "↑", label: "UPDATE", color: brand.warning },
88
- upgrade: { glyph: "↑", label: "UPGRADE", color: brand.warning },
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, with a duration bar per step.
214
+ * The permanent record: one row per step, its duration, and what it found.
211
215
  *
212
- * The bar is each step's share of the SLOWEST step, not of the total — the
213
- * question a reader has when an update felt slow is "which part was it?", and
214
- * shares of a total flatten to a uniform smear once there are four of them.
215
- * Colour ramps green to red with that share, so the offender is the red one.
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
- const times = steps.map((s) => stepDuration(s, now) ?? 0);
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(times[i]), 6));
225
- return `${PAD}${stepMark(step, 0)} ${label} ${bar} ${time} ${dim(
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
- const note = item.note ? ` ${dim(`(${item.note})`)}` : "";
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
- brand.warning,
264
+ groupStyle("Update").text,
246
265
  "→",
247
- )} ${fg(brand.warning, item.target ?? "?")}${note}`;
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(brand.warning, "→")} ${fg(
276
- brand.warning,
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))} ${bar} ${dim(
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
- ...section(
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
- if (counts.unknownPlugins > 0 || counts.unknownTools > 0) console.log("");
409
- if (counts.unknownPlugins > 0) {
410
- console.log(unknownPluginsNotice(counts.unknownPlugins));
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
- * Set autoUpdate flag for a marketplace in known_marketplaces.json
820
- * This is where Claude Code actually reads the autoUpdate setting
821
- */
822
- export async function setMarketplaceAutoUpdate(
823
- marketplaceName: string,
824
- autoUpdate: boolean,
825
- ): Promise<boolean> {
826
- const known = await readKnownMarketplaces();
827
- if (known[marketplaceName]) {
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 (Claude Code's internal marketplace tracking)
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 readKnownMarketplaces(): Promise<KnownMarketplaces> {
76
+ async function readKnownMarketplacesOrNull(): Promise<KnownMarketplaces | null> {
70
77
  try {
71
- if (await fs.pathExists(KNOWN_MARKETPLACES_FILE)) {
72
- return await fs.readJson(KNOWN_MARKETPLACES_FILE);
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
- // Return empty if can't read
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(