magus-cli 7.11.7 → 7.11.8

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.11.7",
3
+ "version": "7.11.8",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -65,8 +65,8 @@
65
65
  "typescript": "^5.6.3"
66
66
  },
67
67
  "optionalDependencies": {
68
- "magus-cli-darwin-arm64": "7.11.7",
69
- "magus-cli-darwin-x64": "7.11.7",
70
- "magus-cli-linux-x64": "7.11.7"
68
+ "magus-cli-darwin-arm64": "7.11.8",
69
+ "magus-cli-darwin-x64": "7.11.8",
70
+ "magus-cli-linux-x64": "7.11.8"
71
71
  }
72
72
  }
@@ -15,18 +15,36 @@ import path from "node:path";
15
15
  // --- Mock the marketplace registry (the only magus dep of the refresher) ---
16
16
  let configured: Record<string, { source: { source: string; repo?: string } }>;
17
17
  let autoUpdate: Record<string, boolean | undefined>;
18
+ /**
19
+ * USER-scope `enabledPlugins`. `null` means "one USER plugin from every
20
+ * registered marketplace", so the cases written before the needed-only rule
21
+ * keep exercising the pull itself; the needed-only cases set it explicitly.
22
+ */
23
+ let userEnabled: Record<string, boolean> | null;
18
24
 
19
25
  mock.module("../services/claude-settings.js", () => ({
20
26
  getConfiguredMarketplaces: async () => configured,
21
27
  getMarketplaceAutoUpdate: async (name: string) => autoUpdate[name],
28
+ getGlobalEnabledPlugins: async () =>
29
+ userEnabled ??
30
+ Object.fromEntries(Object.keys(configured).map((n) => [`p@${n}`, true])),
22
31
  // The refresher resolves clone paths via this helper; point it at our temp
23
32
  // home so no real ~/.claude marketplace is ever touched. (Bun's os.homedir()
24
33
  // ignores runtime $HOME changes, so mocking the helper is the reliable seam.)
25
34
  getGlobalClaudeDir: () => path.join(tmp, ".claude"),
26
35
  }));
27
36
 
28
- const { refreshRegisteredMarketplaces, fastForwardCloneIfPresent } =
29
- await import("../services/marketplace-refresh.js");
37
+ const {
38
+ refreshRegisteredMarketplaces,
39
+ reportRegisteredMarketplaces,
40
+ fastForwardCloneIfPresent,
41
+ } = await import("../services/marketplace-refresh.js");
42
+ const {
43
+ CATALOG_TTL_MS,
44
+ clearCloneRefreshes,
45
+ readCloneRefreshAge,
46
+ resetCatalogCacheMemo,
47
+ } = await import("../services/catalog-cache-store.js");
30
48
 
31
49
  const gh = (repo = "MadAppGang/magus") => ({
32
50
  source: { source: "github", repo },
@@ -110,6 +128,7 @@ beforeEach(() => {
110
128
 
111
129
  configured = {};
112
130
  autoUpdate = {};
131
+ userEnabled = null;
113
132
  });
114
133
 
115
134
  afterEach(() => {
@@ -457,3 +476,193 @@ describe("fastForwardCloneIfPresent — per-clone deadline", () => {
457
476
  expect(await goneWithin(readPid(pullPid), 1_000)).toBe(true);
458
477
  }, 10_000);
459
478
  });
479
+
480
+ /** The clone's HEAD and origin's main, to tell "pulled" from "left behind". */
481
+ function headOf(dir: string): string {
482
+ return git(dir, "rev-parse", "HEAD").trim();
483
+ }
484
+ function originHead(): string {
485
+ return git(origin, "rev-parse", "main").trim();
486
+ }
487
+
488
+ describe("refreshRegisteredMarketplaces — needed only (MTL-367)", () => {
489
+ it("does not pull a registered marketplace no installed plugin comes from, and reports it skipped", async () => {
490
+ const used = installClone("magus");
491
+ const unused = installClone("unused");
492
+ publishV2();
493
+ configured = { magus: gh(), unused: gh("x/unused") };
494
+ userEnabled = {};
495
+
496
+ const r = await refreshRegisteredMarketplaces(
497
+ [],
498
+ {},
499
+ { pluginIds: ["dev@magus"] },
500
+ );
501
+
502
+ expect(r.refreshed).toEqual(["magus"]);
503
+ expect(r.skipped).toEqual(["unused"]);
504
+ expect(headOf(used)).toBe(originHead());
505
+ expect(headOf(unused)).not.toBe(originHead()); // left behind
506
+ });
507
+
508
+ it("pulls a marketplace used only by a USER-scope plugin", async () => {
509
+ const dir = installClone("userland");
510
+ installClone("unused");
511
+ publishV2();
512
+ configured = { userland: gh("x/userland"), unused: gh("x/unused") };
513
+ // `false` is not installed: only `true` counts.
514
+ userEnabled = { "tool@userland": true, "off@unused": false };
515
+
516
+ const r = await refreshRegisteredMarketplaces([], {}, { pluginIds: [] });
517
+
518
+ expect(r.refreshed).toEqual(["userland"]);
519
+ expect(r.skipped).toEqual(["unused"]);
520
+ expect(headOf(dir)).toBe(originHead());
521
+ });
522
+
523
+ it("report-only applies the same selection: an unused marketplace is skipped, a used one pending", async () => {
524
+ installClone("magus");
525
+ installClone("unused");
526
+ configured = { magus: gh(), unused: gh("x/unused") };
527
+ userEnabled = {};
528
+
529
+ const r = await reportRegisteredMarketplaces([], {
530
+ pluginIds: ["dev@magus"],
531
+ });
532
+
533
+ expect(r.refreshed).toEqual([]);
534
+ expect(r.pending).toEqual(["magus"]);
535
+ expect(r.skipped).toEqual(["unused"]);
536
+ });
537
+ });
538
+
539
+ describe("refreshRegisteredMarketplaces — at most once an hour, machine-wide (MTL-367)", () => {
540
+ let prevConfigDir: string | undefined;
541
+ const T0 = 1_790_000_000_000;
542
+
543
+ beforeEach(() => {
544
+ prevConfigDir = process.env.CLAUDE_CONFIG_DIR;
545
+ // The store lives under the temp home, never the operator's.
546
+ process.env.CLAUDE_CONFIG_DIR = path.join(tmp, ".claude");
547
+ resetCatalogCacheMemo();
548
+ });
549
+ afterEach(() => {
550
+ // biome-ignore lint/performance/noDelete: assigning undefined stores the string "undefined"
551
+ if (prevConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
552
+ else process.env.CLAUDE_CONFIG_DIR = prevConfigDir;
553
+ resetCatalogCacheMemo();
554
+ });
555
+
556
+ it("does not pull again within the hour, and reports it fresh", async () => {
557
+ const dir = installClone("magus");
558
+ configured = { magus: gh() };
559
+
560
+ const first = await refreshRegisteredMarketplaces([], {}, { now: T0 });
561
+ expect(first.refreshed).toEqual(["magus"]);
562
+
563
+ publishV2(); // origin advances after the first pull
564
+ resetCatalogCacheMemo(); // a second process, same machine
565
+ const before = headOf(dir);
566
+ const second = await refreshRegisteredMarketplaces(
567
+ [],
568
+ {},
569
+ { now: T0 + 10 * 60 * 1000 },
570
+ );
571
+
572
+ expect(second.refreshed).toEqual([]);
573
+ expect(second.fresh).toEqual(["magus"]);
574
+ expect(second.skipped).toEqual([]);
575
+ expect(headOf(dir)).toBe(before);
576
+ expect(headOf(dir)).not.toBe(originHead());
577
+ });
578
+
579
+ it("pulls again once the TTL has passed", async () => {
580
+ const dir = installClone("magus");
581
+ configured = { magus: gh() };
582
+ await refreshRegisteredMarketplaces([], {}, { now: T0 });
583
+
584
+ publishV2();
585
+ const r = await refreshRegisteredMarketplaces(
586
+ [],
587
+ {},
588
+ { now: T0 + CATALOG_TTL_MS + 1 },
589
+ );
590
+
591
+ expect(r.refreshed).toEqual(["magus"]);
592
+ expect(r.fresh).toEqual([]);
593
+ expect(headOf(dir)).toBe(originHead());
594
+ });
595
+
596
+ it("a failed pull records no time, so the next call pulls again", async () => {
597
+ const dir = installClone("magus");
598
+ git(dir, "remote", "set-url", "origin", path.join(tmp, "gone.git"));
599
+ configured = { magus: gh() };
600
+
601
+ const failed = await refreshRegisteredMarketplaces([], {}, { now: T0 });
602
+ expect(failed.failed).toEqual(["magus"]);
603
+ expect(await readCloneRefreshAge("magus", T0 + 1)).toBeNull();
604
+
605
+ git(dir, "remote", "set-url", "origin", origin);
606
+ publishV2();
607
+ const retry = await refreshRegisteredMarketplaces(
608
+ [],
609
+ {},
610
+ { now: T0 + 60_000 },
611
+ );
612
+ expect(retry.refreshed).toEqual(["magus"]);
613
+ expect(headOf(dir)).toBe(originHead());
614
+ });
615
+
616
+ it("an explicit refresh (the TUI's `r`) clears the record, so the next call pulls", async () => {
617
+ const dir = installClone("magus");
618
+ configured = { magus: gh() };
619
+ await refreshRegisteredMarketplaces([], {}, { now: T0 });
620
+
621
+ // `r` -> refreshAllMarketplaces -> clearPersistedCatalogs.
622
+ const { clearPersistedCatalogs } = await import(
623
+ "../services/marketplace-fetcher.js"
624
+ );
625
+ await clearPersistedCatalogs();
626
+
627
+ publishV2();
628
+ resetCatalogCacheMemo();
629
+ const r = await refreshRegisteredMarketplaces([], {}, { now: T0 + 60_000 });
630
+ expect(r.refreshed).toEqual(["magus"]);
631
+ expect(headOf(dir)).toBe(originHead());
632
+ });
633
+
634
+ it("clearCloneRefreshes drops the record", async () => {
635
+ installClone("magus");
636
+ configured = { magus: gh() };
637
+ await refreshRegisteredMarketplaces([], {}, { now: T0 });
638
+ expect(await readCloneRefreshAge("magus", T0 + 1)).toBe(1);
639
+
640
+ await clearCloneRefreshes();
641
+ expect(await readCloneRefreshAge("magus", T0 + 1)).toBeNull();
642
+ });
643
+
644
+ it("report-only writes no time, and the next writing run pulls", async () => {
645
+ const dir = installClone("magus");
646
+ configured = { magus: gh() };
647
+ publishV2();
648
+
649
+ const report = await reportRegisteredMarketplaces([], { now: T0 });
650
+ expect(report.pending).toEqual(["magus"]);
651
+ resetCatalogCacheMemo();
652
+ expect(await readCloneRefreshAge("magus", T0 + 1)).toBeNull();
653
+ expect(headOf(dir)).not.toBe(originHead());
654
+
655
+ const r = await refreshRegisteredMarketplaces([], {}, { now: T0 + 1 });
656
+ expect(r.refreshed).toEqual(["magus"]);
657
+ });
658
+
659
+ it("report-only reports a clone refreshed within the hour as fresh", async () => {
660
+ installClone("magus");
661
+ configured = { magus: gh() };
662
+ await refreshRegisteredMarketplaces([], {}, { now: T0 });
663
+
664
+ const report = await reportRegisteredMarketplaces([], { now: T0 + 1000 });
665
+ expect(report.fresh).toEqual(["magus"]);
666
+ expect(report.pending).toEqual([]);
667
+ });
668
+ });
package/src/cli/update.ts CHANGED
@@ -364,36 +364,53 @@ async function updateProfile(
364
364
  // own output, which is where this was caught. A read-only run plans against
365
365
  // the clone as it stands; the notice below says so, so an unrefreshed
366
366
  // catalog is never mistaken for a current one.
367
+ //
368
+ // Only marketplaces some installed plugin comes from are pulled: this
369
+ // profile's plugins, plus whatever is installed at USER scope (the refresher
370
+ // reads that itself). And each clone at most once an hour, machine-wide.
371
+ const readOnly = flags.dryRun || flags.check;
372
+ const pluginIds = Object.keys(closure.plugins);
367
373
  const refresh = await runStep(steps.marketplaces, async (say) => {
368
- if (flags.dryRun || flags.check) {
374
+ if (readOnly) {
369
375
  say("read-only — selecting, not pulling");
370
- // Report-only, NOT a skip: the autoUpdate-disabled verdict is a pure
371
- // read and is the one diagnostic a read-only run must not lose.
372
- const report = await reportRegisteredMarketplaces();
373
- steps.marketplaces.detail = `not refreshed (read-only) · ${report.skipped.length} eligible`;
374
- return report;
376
+ // Report-only, NOT a skip: the selection is a pure read, so a read-only
377
+ // run can still say exactly what a real run would pull.
378
+ return reportRegisteredMarketplaces([], { pluginIds });
375
379
  }
376
380
  let settled = 0;
377
- return refreshRegisteredMarketplaces([], {
378
- onStart: (names) => {
379
- // Only now is there a denominator; until `onStart` fires the step
380
- // shows a sweep rather than a meter that would have to guess one.
381
- steps.marketplaces.progress = { done: 0, total: names.length };
382
- say(
383
- names.length === 0 ? "none eligible" : `pulling ${names.join(", ")}`,
384
- );
385
- },
386
- onSettled: (name, status) => {
387
- settled++;
388
- if (steps.marketplaces.progress)
389
- steps.marketplaces.progress.done = settled;
390
- say(`${name} ${status}`);
381
+ return refreshRegisteredMarketplaces(
382
+ [],
383
+ {
384
+ onStart: (names) => {
385
+ // Only now is there a denominator; until `onStart` fires the step
386
+ // shows a sweep rather than a meter that would have to guess one.
387
+ steps.marketplaces.progress = { done: 0, total: names.length };
388
+ say(
389
+ names.length === 0
390
+ ? "none eligible"
391
+ : `pulling ${names.join(", ")}`,
392
+ );
393
+ },
394
+ onSettled: (name, status) => {
395
+ settled++;
396
+ if (steps.marketplaces.progress)
397
+ steps.marketplaces.progress.done = settled;
398
+ say(`${name} ${status}`);
399
+ },
391
400
  },
392
- });
401
+ { pluginIds },
402
+ );
393
403
  });
394
404
  steps.marketplaces.detail = [
395
- `${refresh.refreshed.length} refreshed`,
405
+ readOnly
406
+ ? `not refreshed (read-only) · ${refresh.pending.length} would refresh`
407
+ : `${refresh.refreshed.length} refreshed`,
396
408
  refresh.failed.length > 0 ? `${refresh.failed.length} failed` : "",
409
+ // Not "up to date": the clone was pulled within the hour, and upstream
410
+ // may have moved since. Say what is known.
411
+ refresh.fresh.length > 0
412
+ ? `${refresh.fresh.length} refreshed in the last hour`
413
+ : "",
397
414
  refresh.skipped.length > 0 ? `${refresh.skipped.length} skipped` : "",
398
415
  ]
399
416
  .filter(Boolean)
@@ -37,10 +37,11 @@ import type { MarketplacePlugin } from "./marketplace-catalog.js";
37
37
  *
38
38
  * This is the ONLY refresh rhythm magus has, and it applies to both surfaces:
39
39
  * `magus update` and the TUI read a catalog written here within the hour
40
- * before they go to the network. `update` pulls the marketplace clones and
41
- * drops its in-process memo before planning, but it does NOT clear this file,
42
- * so a release published inside the TTL is not seen by `update` either. Only
43
- * an explicit refresh (`r` in the TUI) clears it. Anything that reports "no
40
+ * before they go to the network. `update` pulls the marketplace clones (each
41
+ * at most once per TTL, machine-wide — see `cloneRefreshes`) and drops its
42
+ * in-process memo before planning, but it does NOT clear this file, so a
43
+ * release published inside the TTL is not seen by `update` either. Only an
44
+ * explicit refresh (`r` in the TUI) clears it, clone refresh times included. Anything that reports "no
44
45
  * update" — or decides a plugin is no longer published — off a cached catalog
45
46
  * is working from an answer up to an hour old, by design.
46
47
  */
@@ -93,14 +94,24 @@ interface StoreShape {
93
94
  catalogs: Record<string, StoredCatalog>;
94
95
  cooldowns: Record<string, StoredCooldown>;
95
96
  communityStyles: Record<string, StoredCommunityCheck>;
97
+ /**
98
+ * Per marketplace: wall-clock ms of the last SUCCESSFUL fast-forward of its
99
+ * on-disk clone. Machine-wide, so one project's `magus update` spares every
100
+ * other project's the same pull within CATALOG_TTL_MS.
101
+ */
102
+ cloneRefreshes: Record<string, number>;
96
103
  }
97
104
 
98
- const EMPTY: StoreShape = {
99
- version: 1,
100
- catalogs: {},
101
- cooldowns: {},
102
- communityStyles: {},
103
- };
105
+ /** A fresh empty store. A function, so no caller can share and mutate one object. */
106
+ function emptyStore(): StoreShape {
107
+ return {
108
+ version: 1,
109
+ catalogs: {},
110
+ cooldowns: {},
111
+ communityStyles: {},
112
+ cloneRefreshes: {},
113
+ };
114
+ }
104
115
 
105
116
  /**
106
117
  * Resolved per call, honouring CLAUDE_CONFIG_DIR — same reasoning as
@@ -124,29 +135,24 @@ let memo: { path: string; data: StoreShape } | null = null;
124
135
 
125
136
  async function load(): Promise<StoreShape> {
126
137
  const file = storePath();
127
- if (!file)
128
- return { version: 1, catalogs: {}, cooldowns: {}, communityStyles: {} };
138
+ if (!file) return emptyStore();
129
139
  if (memo?.path === file) return memo.data;
130
140
  try {
131
141
  const parsed = JSON.parse(await fs.readFile(file, "utf-8")) as StoreShape;
132
142
  // A cache from a future/unknown layout is discarded rather than migrated:
133
143
  // it is regenerable by definition, so guessing at it buys nothing.
134
- const data = parsed?.version === 1 ? parsed : { ...EMPTY };
144
+ const data = parsed?.version === 1 ? parsed : emptyStore();
135
145
  data.catalogs ??= {};
136
146
  data.cooldowns ??= {};
137
- // Absent in every file written before community styles existed, so this
147
+ // Absent in every file written before these sections existed, so the
138
148
  // default is the migration — an upgrade must not read as a corrupt cache.
139
149
  data.communityStyles ??= {};
150
+ data.cloneRefreshes ??= {};
140
151
  memo = { path: file, data };
141
152
  return data;
142
153
  } catch {
143
154
  // Absent or corrupt. A cache must never be a failure mode — start empty.
144
- const data: StoreShape = {
145
- ...EMPTY,
146
- catalogs: {},
147
- cooldowns: {},
148
- communityStyles: {},
149
- };
155
+ const data = emptyStore();
150
156
  memo = { path: file, data };
151
157
  return data;
152
158
  }
@@ -179,15 +185,11 @@ async function mutate(fn: (data: StoreShape) => void): Promise<void> {
179
185
  catalogs: parsed.catalogs ?? {},
180
186
  cooldowns: parsed.cooldowns ?? {},
181
187
  communityStyles: parsed.communityStyles ?? {},
188
+ cloneRefreshes: parsed.cloneRefreshes ?? {},
182
189
  }
183
- : { version: 1, catalogs: {}, cooldowns: {}, communityStyles: {} };
190
+ : emptyStore();
184
191
  } catch {
185
- onDisk = {
186
- version: 1,
187
- catalogs: {},
188
- cooldowns: {},
189
- communityStyles: {},
190
- };
192
+ onDisk = emptyStore();
191
193
  }
192
194
  fn(onDisk);
193
195
  // Temp + rename, so a crash mid-write cannot leave a half-written file
@@ -312,6 +314,44 @@ export async function clearCommunityChecks(): Promise<void> {
312
314
  });
313
315
  }
314
316
 
317
+ // ─── Marketplace clone refreshes ─────────────────────────────────────────────
318
+
319
+ /**
320
+ * Milliseconds since this marketplace's clone was last fast-forwarded
321
+ * successfully, or null when that was never, longer ago than CATALOG_TTL_MS, or
322
+ * in the future (a clock that moved back must not freeze the clone).
323
+ *
324
+ * Same TTL as the catalogs, on purpose: the clone and the catalog answer the
325
+ * same question, so they go stale on one rhythm and `r` clears both.
326
+ */
327
+ export async function readCloneRefreshAge(
328
+ marketplace: string,
329
+ now: number = Date.now(),
330
+ ): Promise<number | null> {
331
+ const at = (await load()).cloneRefreshes[marketplace];
332
+ if (typeof at !== "number") return null;
333
+ const ageMs = now - at;
334
+ if (ageMs < 0 || ageMs > CATALOG_TTL_MS) return null;
335
+ return ageMs;
336
+ }
337
+
338
+ /** Record a successful fast-forward. Never called for a failed or skipped pull. */
339
+ export async function writeCloneRefreshed(
340
+ marketplace: string,
341
+ now: number = Date.now(),
342
+ ): Promise<void> {
343
+ await mutate((data) => {
344
+ data.cloneRefreshes[marketplace] = now;
345
+ });
346
+ }
347
+
348
+ /** Drop every clone refresh time. Backs the explicit refresh (`r`). */
349
+ export async function clearCloneRefreshes(): Promise<void> {
350
+ await mutate((data) => {
351
+ data.cloneRefreshes = {};
352
+ });
353
+ }
354
+
315
355
  /** Test seam: forget this process's memo so the next read hits disk. */
316
356
  export function resetCatalogCacheMemo(): void {
317
357
  memo = null;
@@ -10,6 +10,7 @@
10
10
  import { isValidGitHubRepo } from "../utils/string-utils.js";
11
11
  import {
12
12
  clearCachedCatalogs,
13
+ clearCloneRefreshes,
13
14
  readCachedCatalog,
14
15
  writeCachedCatalog,
15
16
  } from "./catalog-cache-store.js";
@@ -145,6 +146,9 @@ export function clearMarketplaceCache(): void {
145
146
  */
146
147
  export async function clearPersistedCatalogs(): Promise<void> {
147
148
  await clearCachedCatalogs();
149
+ // The clone refresh times too: an explicit refresh means "ask again now", so
150
+ // the next `magus update` pulls every needed clone instead of trusting the hour.
151
+ await clearCloneRefreshes();
148
152
  }
149
153
 
150
154
  /**
@@ -20,9 +20,15 @@
20
20
  import fs from "node:fs";
21
21
  import path from "node:path";
22
22
  import { runBounded } from "../utils/spawn-bounded.js";
23
+ import { parsePluginId } from "../utils/string-utils.js";
24
+ import {
25
+ readCloneRefreshAge,
26
+ writeCloneRefreshed,
27
+ } from "./catalog-cache-store.js";
23
28
  import {
24
29
  getConfiguredMarketplaces,
25
30
  getGlobalClaudeDir,
31
+ getGlobalEnabledPlugins,
26
32
  getMarketplaceAutoUpdate,
27
33
  } from "./claude-settings.js";
28
34
 
@@ -37,10 +43,54 @@ export interface RefreshResult {
37
43
  refreshed: string[];
38
44
  /** Pull errored (network, diverged, timeout) — clone left intact and stale. */
39
45
  failed: string[];
40
- /** Nothing to do: no clone on disk, not a git repo, or dirty tree. */
46
+ /**
47
+ * Not pulled because this machine already refreshed the clone successfully
48
+ * within CATALOG_TTL_MS — by this project's run or any other.
49
+ */
50
+ fresh: string[];
51
+ /**
52
+ * Report-only: selected, and a writing run would pull it. Always empty when
53
+ * the refresh actually ran.
54
+ */
55
+ pending: string[];
56
+ /**
57
+ * Not pulled, and not a failure: no installed plugin comes from it, not a
58
+ * github source, autoUpdate off, no clone on disk, or dirty tree.
59
+ */
41
60
  skipped: string[];
42
61
  }
43
62
 
63
+ export interface RefreshOptions {
64
+ /**
65
+ * Plugin ids (`name@marketplace`) this run installs — the profile's
66
+ * PROJECT/LOCAL set. Unioned with every plugin installed at USER scope;
67
+ * a marketplace none of them comes from is not pulled.
68
+ */
69
+ pluginIds?: Iterable<string>;
70
+ /** Clock seam for the once-an-hour rule. */
71
+ now?: number;
72
+ }
73
+
74
+ /**
75
+ * The marketplaces some installed plugin comes from: the ones passed in
76
+ * (PROJECT/LOCAL, from the profile) plus every plugin turned on at USER scope.
77
+ * A USER entry set to `false` is not installed and does not count.
78
+ */
79
+ async function neededMarketplaces(
80
+ pluginIds: Iterable<string>,
81
+ ): Promise<Set<string>> {
82
+ const ids = [...pluginIds];
83
+ for (const [id, on] of Object.entries(await getGlobalEnabledPlugins())) {
84
+ if (on === true) ids.push(id);
85
+ }
86
+ const needed = new Set<string>();
87
+ for (const id of ids) {
88
+ const parsed = parsePluginId(id);
89
+ if (parsed) needed.add(parsed.marketplace);
90
+ }
91
+ return needed;
92
+ }
93
+
44
94
  function cloneDir(name: string): string {
45
95
  return path.join(getGlobalClaudeDir(), "plugins", "marketplaces", name);
46
96
  }
@@ -170,46 +220,62 @@ function tap(fn: (() => void) | undefined): void {
170
220
  }
171
221
  }
172
222
 
173
- /**
174
- * Non-destructively fast-forward the on-disk clone of every registered,
175
- * auto-update-enabled, github-sourced marketplace.
176
- *
177
- * @param skip marketplaces already refreshed this run (e.g. re-registered ones),
178
- * so they aren't pulled twice.
179
- * @param progress optional taps for a live progress display.
180
- * @returns which marketplaces were refreshed, which failed (clone left intact),
181
- * and which were skipped. Failures and skips are collected rather than thrown —
182
- * one unreachable marketplace must not block launching Claude. Pulls run
183
- * concurrently, so a slow one doesn't serialize the rest onto the launch path.
184
- */
185
223
  /**
186
224
  * Select and report, writing nothing.
187
225
  *
188
- * Everything above the pull loop is a read — the marketplace registry and each
189
- * entry's `autoUpdate` flag — so a read-only caller can have the full selection
190
- * verdict for free. `--dry-run` and `--check` promise to write nothing and
191
- * therefore cannot pull, but they must still be able to say "your magus clone
192
- * has autoUpdate off, so its catalog will never advance". Gating the whole call
193
- * off silently dropped that warning, which is the same "no signal reads as no
194
- * problem" failure the flag exists to prevent.
226
+ * Everything above the pull loop is a read — the marketplace registry, each
227
+ * entry's `autoUpdate` flag, which marketplaces installed plugins come from,
228
+ * and when each clone was last refreshed — so a read-only caller can have the
229
+ * full selection verdict for free. `--dry-run` and `--check` promise to write
230
+ * nothing and therefore cannot pull (nor record a refresh time), but their
231
+ * report must name the same set a writing run would pull: that set is
232
+ * `pending`.
195
233
  */
196
234
  export async function reportRegisteredMarketplaces(
197
235
  skip: Iterable<string> = [],
236
+ options: RefreshOptions = {},
198
237
  ): Promise<RefreshResult> {
199
- return refreshRegisteredMarketplaces(skip, {}, { reportOnly: true });
238
+ return selectAndRefresh(skip, {}, { ...options, reportOnly: true });
200
239
  }
201
240
 
241
+ /**
242
+ * Non-destructively fast-forward the on-disk clone of every registered,
243
+ * auto-update-enabled, github-sourced marketplace that some installed plugin
244
+ * comes from (USER scope, or `options.pluginIds`), unless this machine already
245
+ * refreshed it within CATALOG_TTL_MS. A successful pull records its time; a
246
+ * failed one does not, so the next run retries.
247
+ *
248
+ * @param skip marketplaces already refreshed this run (e.g. re-registered ones),
249
+ * so they aren't pulled twice.
250
+ * @param progress optional taps for a live progress display.
251
+ * @returns which marketplaces were refreshed, which failed (clone left intact),
252
+ * which were fresh, and which were skipped. Failures and skips are collected
253
+ * rather than thrown — one unreachable marketplace must not block launching
254
+ * Claude. Pulls run concurrently, so a slow one doesn't serialize the rest
255
+ * onto the launch path.
256
+ */
202
257
  export async function refreshRegisteredMarketplaces(
203
258
  skip: Iterable<string> = [],
204
259
  progress: RefreshProgress = {},
205
- options: { reportOnly?: boolean } = {},
260
+ options: RefreshOptions = {},
261
+ ): Promise<RefreshResult> {
262
+ return selectAndRefresh(skip, progress, { ...options, reportOnly: false });
263
+ }
264
+
265
+ async function selectAndRefresh(
266
+ skip: Iterable<string>,
267
+ progress: RefreshProgress,
268
+ options: RefreshOptions & { reportOnly: boolean },
206
269
  ): Promise<RefreshResult> {
207
270
  const refreshed: string[] = [];
208
271
  const failed: string[] = [];
272
+ const fresh: string[] = [];
209
273
  const skipped: string[] = [];
274
+ const now = options.now ?? Date.now();
210
275
 
211
276
  const configured = await getConfiguredMarketplaces();
212
277
  const skipSet = new Set(skip);
278
+ const needed = await neededMarketplaces(options.pluginIds ?? []);
213
279
 
214
280
  // Select eligible marketplaces first so the pulls can run concurrently.
215
281
  const eligible: string[] = [];
@@ -228,18 +294,25 @@ export async function refreshRegisteredMarketplaces(
228
294
  skipped.push(name);
229
295
  continue;
230
296
  }
297
+ // Nothing installed at any scope comes from it, so a pull buys nothing.
298
+ if (!needed.has(name)) {
299
+ skipped.push(name);
300
+ continue;
301
+ }
302
+ // Refreshed on this machine within the hour, by any project's run.
303
+ if ((await readCloneRefreshAge(name, now)) !== null) {
304
+ fresh.push(name);
305
+ continue;
306
+ }
231
307
  eligible.push(name);
232
308
  }
233
309
 
234
310
  // A read-only caller stops here, with the selection verdict intact and not
235
- // one clone touched. `eligible` becomes `skipped`: nothing was refreshed, so
236
- // claiming otherwise would be the false "all current" this module guards.
311
+ // one clone touched. `eligible` becomes `pending`, never `refreshed`: nothing
312
+ // was pulled, so claiming otherwise would be the false "all current" this
313
+ // module guards.
237
314
  if (options.reportOnly) {
238
- return {
239
- refreshed: [],
240
- failed: [],
241
- skipped: [...skipped, ...eligible],
242
- };
315
+ return { refreshed: [], failed: [], fresh, pending: eligible, skipped };
243
316
  }
244
317
 
245
318
  // Fast-forward every eligible clone in parallel so a slow one doesn't
@@ -261,5 +334,9 @@ export async function refreshRegisteredMarketplaces(
261
334
  else skipped.push(name); // absent | skipped
262
335
  });
263
336
 
264
- return { refreshed, failed, skipped };
337
+ // Only a successful pull earns the hour. A failed, absent or dirty clone
338
+ // records nothing, so the next run — from any project — tries again.
339
+ for (const name of refreshed) await writeCloneRefreshed(name, now);
340
+
341
+ return { refreshed, failed, fresh, pending: [], skipped };
265
342
  }