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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "magus-cli",
3
- "version": "7.0.0",
3
+ "version": "7.1.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^5.6.3"
65
65
  },
66
66
  "optionalDependencies": {
67
- "magus-cli-darwin-arm64": "7.0.0",
68
- "magus-cli-darwin-x64": "7.0.0",
69
- "magus-cli-linux-x64": "7.0.0"
67
+ "magus-cli-darwin-arm64": "7.1.0",
68
+ "magus-cli-darwin-x64": "7.1.0",
69
+ "magus-cli-linux-x64": "7.1.0"
70
70
  }
71
71
  }
@@ -132,7 +132,6 @@ function makeCatalog(): PluginInfo[] {
132
132
  // _synthetic orphans
133
133
  row("seo@magus", null, "1.0.0", {
134
134
  isOrphaned: true,
135
- movedTo: "magus-marketing",
136
135
  }),
137
136
  row("legacy-tool@magus", null, "0.5.0", { isOrphaned: true }),
138
137
  row("seo@magus-marketing", "1.1.0", null),
@@ -442,7 +441,12 @@ describe("R7 parity: one fixture through the CLI path and the TUI path", () => {
442
441
  }
443
442
  });
444
443
 
445
- test("the 5 reported official plugins are UNKNOWN with the same no-published-version reason on both paths", async () => {
444
+ // These 5 are CURRENT now, not unknown: their marketplace publishes no
445
+ // version, so there is nothing newer to be behind. R7 is unchanged and is
446
+ // still what this asserts — the two front ends must give the same answer,
447
+ // whatever that answer is. The reason code survives so the WHY is still
448
+ // recorded; the note does not, because nothing here is actionable.
449
+ test("the 5 official plugins get the same verdict on both paths", async () => {
446
450
  const p = await runBothPaths();
447
451
 
448
452
  for (const id of Object.keys(REAL_OFFICIAL)) {
@@ -450,14 +454,15 @@ describe("R7 parity: one fixture through the CLI path and the TUI path", () => {
450
454
  const tui = p.tuiRows.get(id)?.status;
451
455
  expect({ id, action: cli?.action, reason: cli?.reason }).toEqual({
452
456
  id,
453
- action: "unknown",
457
+ action: "current",
454
458
  reason: "no-published-version",
455
459
  });
456
- expect(cli?.note ?? "").toContain(NO_PUBLISHED_VERSION_NOTE);
460
+ // Parity, the point of the file: same note (none), same rendered row.
457
461
  expect(tui?.note).toBe(cli?.note);
462
+ expect(cli?.note).toBeUndefined();
458
463
  const row = p.tuiRows.get(id);
459
464
  if (!row) throw new Error(`no TUI row for ${id}`);
460
- expect(pluginRowText(row).text).toContain(NO_PUBLISHED_VERSION_NOTE);
465
+ expect(pluginRowText(row).text).not.toContain(NO_PUBLISHED_VERSION_NOTE);
461
466
  }
462
467
  });
463
468
 
@@ -467,7 +472,12 @@ describe("R7 parity: one fixture through the CLI path and the TUI path", () => {
467
472
  string,
468
473
  { action: string; reason?: string; target?: string | null }
469
474
  > = {
470
- [`zero-version@${OFFICIAL}`]: { action: "unknown" },
475
+ // No published version to be behind → current. The reason still records
476
+ // that the verdict came from an absent catalog version, not a match.
477
+ [`zero-version@${OFFICIAL}`]: {
478
+ action: "current",
479
+ reason: "no-published-version",
480
+ },
471
481
  [`typescript-lsp@${OFFICIAL}`]: {
472
482
  action: "current",
473
483
  reason: "up-to-date",
@@ -482,7 +492,10 @@ describe("R7 parity: one fixture through the CLI path and the TUI path", () => {
482
492
  reason: "catalog-unverified",
483
493
  target: null,
484
494
  },
485
- "seo@magus": { action: "unknown", reason: "moved", target: null },
495
+ // Was `moved`, which guessed a destination from a bare name match and
496
+ // could not tell a move from a dual publication. An orphan is simply
497
+ // not offered.
498
+ "seo@magus": { action: "unknown", reason: "not-offered", target: null },
486
499
  "legacy-tool@magus": {
487
500
  action: "unknown",
488
501
  reason: "not-offered",
@@ -493,11 +506,10 @@ describe("R7 parity: one fixture through the CLI path and the TUI path", () => {
493
506
  reason: "behind",
494
507
  target: "2.1.0",
495
508
  },
496
- "pinned-unorderable@magus": {
497
- action: "unknown",
498
- reason: "unorderable",
499
- target: null,
500
- },
509
+ // `unorderable` is gone: when two versions cannot be ordered we assume
510
+ // the install is the latest rather than raising a warning no action
511
+ // can clear.
512
+ "pinned-unorderable@magus": { action: "current" },
501
513
  };
502
514
 
503
515
  for (const [id, want] of Object.entries(expected)) {
@@ -563,8 +575,16 @@ describe("R7 parity: one fixture through the CLI path and the TUI path", () => {
563
575
  expect(countPluginStates(tuiItems)).toEqual(cliCounts);
564
576
  expect(cliCounts.install).toBe(1); // bunjs
565
577
  expect(cliCounts.repair).toBe(1); // madbench
566
- // 5 official + zero-version + claudish (unverified) + 2 orphans + unorderable
567
- expect(cliCounts.unknown).toBe(10);
578
+ // Was 10. Seven of those rows are `current` now, and each for the same
579
+ // reason: nothing could be compared, so nothing is known to be behind.
580
+ // − 5 official (marketplace publishes no version)
581
+ // − zero-version (installed "0.0.0", no published version)
582
+ // − pinned-unorderable (sha install against a semver pin)
583
+ // What is left is unknown because something is genuinely wrong or unread:
584
+ // claudish catalog-unverified — the check itself failed
585
+ // seo@magus not-offered — an orphan
586
+ // legacy-tool not-offered — an orphan
587
+ expect(cliCounts.unknown).toBe(3);
568
588
  expect(cliCounts.update).toBeGreaterThanOrEqual(2); // designer, pinned-behind
569
589
  });
570
590
 
@@ -671,14 +691,35 @@ describe("fixture discriminates the pre-change logic", () => {
671
691
  }
672
692
  });
673
693
 
674
- test("flag-derived TUI state calls the unversioned rows healthy; the shared status says unknown", async () => {
694
+ // The fixture must still tell the two designs apart, or every test above it
695
+ // could pass against the old flag-derived logic and prove nothing.
696
+ //
697
+ // It no longer discriminates on the UNVERSIONED rows: both call them
698
+ // healthy/current. That is a real narrowing and it is stated rather than
699
+ // hidden — the surviving difference there is that the classifier records WHY
700
+ // (`no-published-version`) while the flags carry no reason at all.
701
+ test("flag-derived state cannot reach the states the classifier does", async () => {
675
702
  const p = await runBothPaths();
676
- const ids = [...Object.keys(REAL_OFFICIAL), `zero-version@${OFFICIAL}`];
677
703
 
678
- for (const id of ids) {
704
+ // Same verdict, different information: the flags have no reason field.
705
+ for (const id of [
706
+ ...Object.keys(REAL_OFFICIAL),
707
+ `zero-version@${OFFICIAL}`,
708
+ ]) {
709
+ const info = p.catalog.find((c) => c.id === id) as PluginInfo;
710
+ expect(legacyFlagState(info)).toBe("healthy");
711
+ expect(p.tuiRows.get(id)?.status?.action).toBe("current");
712
+ expect(p.tuiRows.get(id)?.status?.reason).toBe("no-published-version");
713
+ }
714
+
715
+ // Where they still disagree outright: an orphan carries neither
716
+ // `hasUpdate` nor `updateCheckFailed`, so the flags call it healthy while
717
+ // the classifier says no marketplace offers it at all.
718
+ for (const id of ["seo@magus", "legacy-tool@magus"]) {
679
719
  const info = p.catalog.find((c) => c.id === id) as PluginInfo;
680
720
  expect(legacyFlagState(info)).toBe("healthy");
681
721
  expect(p.tuiRows.get(id)?.status?.action).toBe("unknown");
722
+ expect(p.tuiRows.get(id)?.status?.reason).toBe("not-offered");
682
723
  }
683
724
  });
684
725
  });
@@ -170,7 +170,12 @@ describe("classifyPlugin — one state plus a human reason", () => {
170
170
  });
171
171
  });
172
172
 
173
- test("unversioned catalog with a commit-sha install is unknown, never current (report #1)", () => {
173
+ // Report #1 asked for the opposite of this, and that decision was REVERSED:
174
+ // a catalog that publishes no version leaves the install `current`, because
175
+ // there is no newer version to be behind. The reason code still records WHY
176
+ // the verdict was reached, so the state is not lost — it just stops being
177
+ // reported as a problem the reader must act on.
178
+ test("unversioned catalog with a commit-sha install is current, with the reason kept", () => {
174
179
  const idx = indexCatalog([
175
180
  info("code-review@claude-plugins-official", null, "3ea32df27be7"),
176
181
  ]);
@@ -181,16 +186,12 @@ describe("classifyPlugin — one state plus a human reason", () => {
181
186
  idx,
182
187
  );
183
188
 
184
- expect({
185
- action: got.action,
186
- reason: got.reason,
187
- target: got.target,
188
- }).toEqual({
189
- action: "unknown",
189
+ expect({ action: got.action, reason: got.reason }).toEqual({
190
+ action: "current",
190
191
  reason: "no-published-version",
191
- target: null,
192
192
  });
193
- expect(got.note).toBeTruthy();
193
+ // No note: nothing here is actionable, so there is nothing to tell anyone.
194
+ expect(got.note).toBeUndefined();
194
195
  });
195
196
 
196
197
  test("a failed catalog check on a latest pin is unknown / catalog-unverified, never current", () => {
@@ -214,13 +215,11 @@ describe("classifyPlugin — one state plus a human reason", () => {
214
215
  info("e@nowhere", null, "0.3.0", { isOrphaned: true }),
215
216
  ]);
216
217
 
217
- for (const id of [
218
- "a@magus",
219
- "b@magus",
220
- "c@magus",
221
- "d@magus",
222
- "e@nowhere",
223
- ]) {
218
+ // `a@magus` is deliberately NOT in this list: a catalog that publishes no
219
+ // version is `current` now, not unknown. It is the negative control
220
+ // below — without it this loop would pass even if every state collapsed
221
+ // into one.
222
+ for (const id of ["b@magus", "c@magus", "d@magus", "e@nowhere"]) {
224
223
  const got = classifyPlugin(id, "latest", idx);
225
224
  expect({ id, action: got.action }).toEqual({ id, action: "unknown" });
226
225
  expect({ id, target: got.target, hasNote: Boolean(got.note) }).toEqual({
@@ -229,6 +228,12 @@ describe("classifyPlugin — one state plus a human reason", () => {
229
228
  hasNote: true,
230
229
  });
231
230
  }
231
+
232
+ // The control: same catalog, same call, the opposite verdict — and
233
+ // crucially no note, because nothing about it is actionable.
234
+ const a = classifyPlugin("a@magus", "latest", idx);
235
+ expect(a.action).toBe("current");
236
+ expect(a.note).toBeUndefined();
232
237
  });
233
238
  });
234
239
 
@@ -486,14 +491,31 @@ describe("pluginRowText", () => {
486
491
  };
487
492
  }
488
493
 
489
- test("an unknown status on a 0.0.0 install still renders the note (no version gate)", () => {
490
- const plugin = info("zero-version@claude-plugins-official", null, "0.0.0");
491
- const status = classifyPlugin(plugin.id, "latest", indexCatalog([plugin]));
494
+ // The gate this guards: every status clause used to be conditional on the
495
+ // installed version being displayable, so a plugin recorded as "0.0.0"
496
+ // printed nothing after its name and read as healthy.
497
+ //
498
+ // The fixture is an ORPHAN at 0.0.0, not a plain 0.0.0 install. A plain one
499
+ // is `current` with no note now, which would leave nothing for this test to
500
+ // find and let the gate come back unnoticed.
501
+ test("a note renders on a 0.0.0 install — no version gate", () => {
502
+ const plugin = info("zero-version@magus", null, "0.0.0", {
503
+ isOrphaned: true,
504
+ });
505
+ const sibling = info("dev@magus", "1.0.0", "1.0.0");
506
+ const status = classifyPlugin(
507
+ plugin.id,
508
+ "latest",
509
+ indexCatalog([plugin, sibling]),
510
+ );
492
511
 
493
512
  const out = pluginRowText(rowItem(plugin, status));
494
513
 
495
514
  expect(status.action).toBe("unknown");
515
+ expect(status.note).toBeTruthy();
496
516
  expect(out.text).toContain(status.note ?? "<missing note>");
517
+ // The undisplayable version itself is still never printed.
518
+ expect(out.text).not.toContain("0.0.0");
497
519
  });
498
520
 
499
521
  test("text is exactly the segments joined, and contains the status suffix", () => {
@@ -25,7 +25,6 @@ import {
25
25
  stepRecord,
26
26
  summaryLine,
27
27
  uncheckedGateNotice,
28
- unknownPluginsNotice,
29
28
  unknownToolsNotice,
30
29
  } from "../cli/update-view.js";
31
30
  import type { UpdatePlan } from "../services/update-plan.js";
@@ -167,7 +166,10 @@ describe("alignment", () => {
167
166
  const rows = planReport(p).filter((l) => l.includes("@"));
168
167
  const cols = rows.map((r) => r.search(/(1\.7\.0|4\.6\.1|latest|1\.8\.0)/));
169
168
  expect(new Set(cols).size).toBe(1);
170
- expect(rows[0]).toContain(long);
169
+ // Not `rows[0]`: grouping means row order follows state, not plan order,
170
+ // and this fixture's long id is `current` — the LAST group. The claim is
171
+ // that the id is printed in full somewhere, not where it lands.
172
+ expect(rows.some((r) => r.includes(long))).toBe(true);
171
173
  });
172
174
  });
173
175
 
@@ -195,13 +197,30 @@ describe("NO_COLOR", () => {
195
197
  test("every action stays distinguishable without colour", () => {
196
198
  setColorEnabled(false);
197
199
  const out = planReport(plan()).join("\n");
198
- // Colour never carries meaning alone: each action has its own word AND
199
- // its own glyph, so the report survives a pipe and colour blindness.
200
- for (const label of ["CURRENT", "UPDATE", "INSTALL", "UNKNOWN", "UPGRADE"])
200
+ // Colour never carries meaning alone: the report has to survive a pipe
201
+ // and colour blindness. Plugins say their state as a GROUP HEADING and a
202
+ // glyph; the tool and skill sections still badge each row with its own
203
+ // action word.
204
+ for (const heading of ["Update", "Fix required", "Current"])
205
+ expect(out).toContain(heading);
206
+ for (const label of ["CURRENT", "UPGRADE", "INSTALL"])
201
207
  expect(out).toContain(label);
202
208
  for (const glyph of ["=", "↑", "+", "?"]) expect(out).toContain(glyph);
203
209
  });
204
210
 
211
+ // Two states that an apply treats very differently, and which a reader must
212
+ // never have to tell apart by hue. `unknown` is grouped under Current and
213
+ // wears `?`; an update wears `↑` under its own heading.
214
+ test("an unverified row is not confusable with an up-to-date one", () => {
215
+ setColorEnabled(false);
216
+ const rows = planReport(plan());
217
+ const unknown = rows.find((l) => l.includes("designer@magus"))!;
218
+ const current = rows.find((l) => l.includes("agentdev@magus"))!;
219
+ expect(unknown).toContain("?");
220
+ expect(current).toContain("=");
221
+ expect(unknown).toContain("catalog unreachable");
222
+ });
223
+
205
224
  test("a note rides along on the row it explains", () => {
206
225
  setColorEnabled(false);
207
226
  const row = planReport(plan()).find((l) => l.includes("designer@magus"))!;
@@ -240,7 +259,10 @@ describe("bars", () => {
240
259
  expect(frame[2]).not.toMatch(/[█░]/);
241
260
  });
242
261
 
243
- test("the record scales each step against the slowest, not the total", () => {
262
+ // The record carries NO bar: a share-of-the-slowest meter restated the
263
+ // millisecond column next to it. The durations themselves still have to be
264
+ // right, and still have to rank the steps.
265
+ test("the record reports each step's duration and draws no bar", () => {
244
266
  setColorEnabled(false);
245
267
  const record = stepRecord(
246
268
  [
@@ -261,18 +283,19 @@ describe("bars", () => {
261
283
  ],
262
284
  1000,
263
285
  );
264
- const bars = record.map((r) => r.match(/[█░]+/)![0]);
265
- expect(bars[1]!.split("█").length - 1).toBe(18);
266
- expect(bars[0]!.split("█").length - 1).toBe(2);
286
+ for (const row of record) expect(row).not.toMatch(/[█░]/);
287
+ expect(record[0]).toContain("100ms");
288
+ expect(record[1]).toContain("1.0s");
267
289
  });
268
290
 
269
- test("a step that never ran does not divide by zero", () => {
291
+ test("a step that never ran reports no elapsed time, and does not throw", () => {
270
292
  setColorEnabled(false);
271
293
  const record = stepRecord(
272
294
  [{ label: "x", state: "pending", detail: "" }],
273
295
  0,
274
296
  );
275
- expect(record[0]).toContain("░");
297
+ expect(record[0]).toContain("x");
298
+ expect(record[0]).not.toMatch(/[█░]/);
276
299
  });
277
300
 
278
301
  test("the apply meter is full exactly when the work is done", () => {
@@ -317,13 +340,69 @@ describe("sections", () => {
317
340
  expect(out).not.toContain("Skills");
318
341
  });
319
342
 
320
- test("a section legend counts every action it contains", () => {
343
+ // The Plugins section is grouped by state, so its counts live on the group
344
+ // headings, not on one legend line. The invariant that matters is the one
345
+ // that broke once already: the counts printed must add up to the rows
346
+ // printed. A fixed heading order used to render an unnamed group as nothing
347
+ // while the total above still counted it — "Plugins 4" over three rows.
348
+ test("every plugin row sits under a heading, and the headings add up", () => {
321
349
  setColorEnabled(false);
322
- const line = planReport(plan()).find((l) => l.includes("Plugins"))!;
323
- expect(line).toContain("1 current");
324
- expect(line).toContain("1 update");
325
- expect(line).toContain("1 install");
326
- expect(line).toContain("1 unknown");
350
+ const out = planReport(plan());
351
+ const total = out.find((l) => /^\s+Plugins\s+\d+$/.test(l))!;
352
+ expect(total).toContain("4");
353
+
354
+ // Matches ANY "<words> <count>" heading, not a fixed alternation of the
355
+ // group names. A named list silently stops counting a group that is added
356
+ // later, which is the same blind spot as the fixed order above — the sum
357
+ // then holds against a subset and the test passes while rows go unheaded.
358
+ const headings = out.flatMap((l) => {
359
+ const m = l.match(/^\s+([A-Z][A-Za-z ]+?)\s\s+(\d+)$/);
360
+ return m && m[1] !== "Plugins"
361
+ ? [{ group: m[1], count: Number(m[2]) }]
362
+ : [];
363
+ });
364
+ // This fixture has one of each except Deprecated, whose row would need a
365
+ // `not-offered` reason. `designer@magus` is unknown with a note, so it is
366
+ // Unverified — NOT Current, which would claim a failed check succeeded.
367
+ expect(headings.map((h) => h.group)).toEqual([
368
+ "Update",
369
+ "Fix required",
370
+ "Unverified",
371
+ "Current",
372
+ ]);
373
+ // Every counted row is a row on screen: 1 + 1 + 1 + 1 === the 4 above.
374
+ const rows = out.filter((l) => l.includes("@"));
375
+ expect(headings.reduce((n, h) => n + h.count, 0)).toBe(rows.length);
376
+ expect(rows.length).toBe(4);
377
+ });
378
+
379
+ // The regression the reviewer caught before release: a pin naming a plugin no
380
+ // marketplace offers, with nothing installed, was filtered out of the table
381
+ // on the grounds that a notice printed it instead. That notice never existed.
382
+ // The row appeared nowhere while the closing lines still counted it.
383
+ test("a pin nothing offers still gets a row", () => {
384
+ setColorEnabled(false);
385
+ const p = plan();
386
+ p.plugins = [
387
+ {
388
+ pluginId: "agentdev@magus",
389
+ pinned: "latest",
390
+ installed: null,
391
+ available: null,
392
+ target: null,
393
+ action: "unknown",
394
+ reason: "not-offered",
395
+ scopes: [],
396
+ },
397
+ ...p.plugins,
398
+ ] as UpdatePlan["plugins"];
399
+
400
+ const out = planReport(p);
401
+ expect(out.join("\n")).toContain("agentdev@magus");
402
+ // And it is counted where it is shown, not somewhere else.
403
+ const total = out.find((l) => /^\s+Plugins\s+\d+$/.test(l))!;
404
+ expect(total).toContain("5");
405
+ expect(out.filter((l) => l.includes("@")).length).toBe(5);
327
406
  });
328
407
 
329
408
  test("a CLI upgrade shows the command that will run", () => {
@@ -398,7 +477,6 @@ describe("unverified notices", () => {
398
477
  // once the catalog is reachable", which is false advice for a plugin whose
399
478
  // marketplace publishes no version — the case a real dry run showed five of.
400
479
  const notices = () => [
401
- unknownPluginsNotice(5),
402
480
  nothingToApplyNotice(5),
403
481
  unknownToolsNotice(1, ["crush"]),
404
482
  uncheckedGateNotice(6),
@@ -413,11 +491,12 @@ describe("unverified notices", () => {
413
491
  }
414
492
  });
415
493
 
494
+ // There is no per-plugin notice under the plan any more: every unverified
495
+ // plugin is a row in the table, under a heading naming its state, so the
496
+ // line restated the table and miscounted it. `nothingToApplyNotice` is a
497
+ // different case — it fires when there IS no table worth reading.
416
498
  test("an unverified plugin is never reported as up to date", () => {
417
499
  setColorEnabled(false);
418
- const under = unknownPluginsNotice(5);
419
- expect(under).toContain("5 plugin(s) could not be checked");
420
- expect(under).toContain("They are NOT reported as up to date.");
421
500
  const nothing = nothingToApplyNotice(5);
422
501
  expect(nothing.trimStart()).toStartWith("⚠ Nothing to apply.");
423
502
  expect(nothing).toContain("5 plugin(s) could not be checked");
@@ -3,6 +3,7 @@ import os from "node:os";
3
3
  import path from "node:path";
4
4
  import fs from "fs-extra";
5
5
  import {
6
+ registeredMarketplaceNames,
6
7
  scanMcpServers,
7
8
  scanSkillDirs,
8
9
  } from "../services/local-marketplace.js";
@@ -172,3 +173,76 @@ describe("clone walkers", () => {
172
173
  expect(await walkMarkdown(path.join(root, "nope"))).toEqual([]);
173
174
  });
174
175
  });
176
+
177
+ /**
178
+ * REGRESSION: a directory under `marketplaces/` was treated as a marketplace.
179
+ *
180
+ * `scanLocalMarketplaces` enumerated the directory and accepted anything with a
181
+ * parseable manifest — never asking `known_marketplaces.json` whether it was
182
+ * registered. Claude Code refreshes a marketplace by delete-then-clone and
183
+ * leaves `<name>.bak` behind, so the reporter's machine carried a 13M
184
+ * `magus.bak` holding an older magus manifest. It cost them twice:
185
+ *
186
+ * - `getAllMarketplaces()` dedupes by repo, first-come, and `magus.bak`
187
+ * reported the same repo — so the REAL magus was dropped from the plugin
188
+ * list entirely. That is the "nothing installed and magus is not first"
189
+ * screenshot.
190
+ * - `findPluginInOtherMarketplace` then answered from it: `kanban@magus`
191
+ * rendered `moved → magus.bak`, pointing the user at a backup folder.
192
+ *
193
+ * `claude plugin marketplace list` never showed it — six entries, never seven —
194
+ * because Claude Code reads the registry. This is magus agreeing with it.
195
+ */
196
+ describe("registeredMarketplaceNames", () => {
197
+ it("accepts a registry key", () => {
198
+ const names = registeredMarketplaceNames({
199
+ magus: { installLocation: "/h/.claude/plugins/marketplaces/magus" },
200
+ });
201
+ expect(names?.has("magus")).toBe(true);
202
+ });
203
+
204
+ it("rejects a directory with no registry entry — the magus.bak case", () => {
205
+ const names = registeredMarketplaceNames({
206
+ magus: { installLocation: "/h/.claude/plugins/marketplaces/magus" },
207
+ });
208
+ expect(names?.has("magus.bak")).toBe(false);
209
+ });
210
+
211
+ it("also accepts the basename of installLocation when it differs from the key", () => {
212
+ // Nothing enforces key === directory name. Dropping a real marketplace is
213
+ // far worse than keeping a stray directory, so both spellings count.
214
+ const names = registeredMarketplaceNames({
215
+ magus: {
216
+ installLocation: "/h/.claude/plugins/marketplaces/magus-pinned",
217
+ },
218
+ });
219
+ expect(names?.has("magus")).toBe(true);
220
+ expect(names?.has("magus-pinned")).toBe(true);
221
+ });
222
+
223
+ it("returns null for input it cannot use, so nothing is filtered", () => {
224
+ // THE failure mode to avoid. Null means "no opinion" and the caller keeps
225
+ // every directory. Returning an empty set here instead would delete every
226
+ // marketplace on a corrupt registry, and every installed plugin would
227
+ // report as withdrawn — the exact "unknown rendered as a determinate
228
+ // negative" this whole change removes, rebuilt one level up.
229
+ expect(registeredMarketplaceNames(null)).toBeNull();
230
+ expect(registeredMarketplaceNames(undefined)).toBeNull();
231
+ expect(registeredMarketplaceNames("not an object")).toBeNull();
232
+ expect(registeredMarketplaceNames(["magus"])).toBeNull();
233
+ });
234
+
235
+ it("treats an empty registry as an answer, not as missing data", () => {
236
+ // A file that exists and parses to {} says nothing is registered. An
237
+ // ABSENT file is what returns null, upstream in readKnownMarketplacesOrNull.
238
+ const names = registeredMarketplaceNames({});
239
+ expect(names).not.toBeNull();
240
+ expect(names?.size).toBe(0);
241
+ });
242
+
243
+ it("survives an entry with no installLocation", () => {
244
+ const names = registeredMarketplaceNames({ magus: {} });
245
+ expect(names?.has("magus")).toBe(true);
246
+ expect(names?.size).toBe(1);
247
+ });
248
+ });
@@ -214,19 +214,24 @@ describe("refreshRegisteredMarketplaces — selection & skips", () => {
214
214
  expect(r.skipped).toEqual(["local"]);
215
215
  });
216
216
 
217
- it("reports a marketplace with autoUpdate === false separately from ordinary skips", async () => {
218
- // Not lumped into `skipped`: opting out here freezes the catalog, so the
219
- // marketplace's plugins read as up to date forever. The caller has to be
220
- // able to tell the user, which it cannot do if this looks like "no clone
221
- // on disk". A real magus clone sat 11 days behind exactly this way.
217
+ // `autoUpdateDisabled` is GONE, and so is the warning built on it. The
218
+ // premise was that opting out freezes the catalog, so the marketplace's
219
+ // plugins read as up to date forever. MEASURED against a real clone, that is
220
+ // false: `readCatalogFromRemote` does its own `git fetch` plus
221
+ // `git show origin/<branch>`, so a clone pinned at dev 7.4.0 with autoUpdate
222
+ // off still reported 7.4.1 available. The flag does not hide updates, and the
223
+ // warning told users to fix something that was not broken.
224
+ //
225
+ // It is an ordinary skip now: nothing was pulled, and that is all we claim.
226
+ it("an autoUpdate === false marketplace is an ordinary skip", async () => {
222
227
  installClone("magus");
223
228
  publishV2();
224
229
  configured = { magus: gh() };
225
230
  autoUpdate = { magus: false };
226
231
  const r = await refreshRegisteredMarketplaces();
227
232
  expect(r.refreshed).toEqual([]);
228
- expect(r.autoUpdateDisabled).toEqual(["magus"]);
229
- expect(r.skipped).toEqual([]);
233
+ expect(r.skipped).toEqual(["magus"]);
234
+ expect(r).not.toHaveProperty("autoUpdateDisabled");
230
235
  });
231
236
 
232
237
  it("honors the skip set (already handled this run)", async () => {