claudeup 4.35.1 → 4.37.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.
Files changed (72) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/catalog-cache-store.test.ts +271 -0
  3. package/src/__tests__/catalog-notice.test.ts +155 -0
  4. package/src/__tests__/github-budget.test.ts +200 -0
  5. package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
  6. package/src/__tests__/scope-squares.test.tsx +165 -0
  7. package/src/__tests__/theme-adaptive-colors.test.ts +307 -0
  8. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  9. package/src/main.tsx +21 -5
  10. package/src/opentui.d.ts +21 -12
  11. package/src/services/catalog-cache-store.ts +218 -0
  12. package/src/services/github-budget.ts +274 -0
  13. package/src/services/marketplace-catalog-git.ts +170 -0
  14. package/src/services/marketplace-catalog.ts +95 -0
  15. package/src/services/marketplace-fetcher.ts +310 -87
  16. package/src/services/plugin-manager.ts +103 -92
  17. package/src/ui/App.tsx +19 -12
  18. package/src/ui/adapters/catalogNotice.ts +122 -0
  19. package/src/ui/adapters/pluginsAdapter.ts +174 -168
  20. package/src/ui/adapters/settingsAdapter.ts +119 -116
  21. package/src/ui/adapters/skillsAdapter.ts +203 -196
  22. package/src/ui/components/CategoryHeader.tsx +9 -8
  23. package/src/ui/components/EmptyFilterState.tsx +10 -5
  24. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  25. package/src/ui/components/ScopeIndicator.tsx +10 -6
  26. package/src/ui/components/ScrollableList.tsx +3 -2
  27. package/src/ui/components/SearchInput.tsx +2 -1
  28. package/src/ui/components/StyledText.tsx +5 -4
  29. package/src/ui/components/TabBar.tsx +4 -3
  30. package/src/ui/components/layout/FooterHints.tsx +37 -30
  31. package/src/ui/components/layout/Panel.tsx +6 -5
  32. package/src/ui/components/layout/ProgressBar.tsx +7 -6
  33. package/src/ui/components/layout/ScopeTabs.tsx +6 -5
  34. package/src/ui/components/layout/ScreenLayout.tsx +46 -26
  35. package/src/ui/components/layout/index.ts +3 -3
  36. package/src/ui/components/modals/ConfirmModal.tsx +12 -11
  37. package/src/ui/components/modals/InputModal.tsx +14 -6
  38. package/src/ui/components/modals/LoadingModal.tsx +6 -5
  39. package/src/ui/components/modals/MessageModal.tsx +9 -8
  40. package/src/ui/components/modals/SelectModal.tsx +11 -7
  41. package/src/ui/components/modals/VersionMismatchModal.tsx +14 -16
  42. package/src/ui/components/primitives/ActionHints.tsx +26 -26
  43. package/src/ui/components/primitives/DetailSection.tsx +13 -12
  44. package/src/ui/components/primitives/KeyValueLine.tsx +9 -8
  45. package/src/ui/components/primitives/ListCategoryRow.tsx +25 -27
  46. package/src/ui/components/primitives/MetaText.tsx +3 -3
  47. package/src/ui/components/primitives/ScopeDetail.tsx +48 -48
  48. package/src/ui/components/primitives/ScopeSquares.tsx +47 -22
  49. package/src/ui/components/primitives/SelectableRow.tsx +22 -16
  50. package/src/ui/hooks/useGitignoreModal.ts +78 -74
  51. package/src/ui/registry.ts +11 -11
  52. package/src/ui/renderers/cliToolRenderers.tsx +260 -203
  53. package/src/ui/renderers/gitignoreRenderers.tsx +43 -42
  54. package/src/ui/renderers/mcpRenderers.tsx +121 -117
  55. package/src/ui/renderers/pluginRenderers.tsx +566 -471
  56. package/src/ui/renderers/profileRenderers.tsx +346 -300
  57. package/src/ui/renderers/settingsRenderers.tsx +183 -176
  58. package/src/ui/renderers/skillRenderers.tsx +410 -326
  59. package/src/ui/screens/AliasScreen.tsx +1336 -1309
  60. package/src/ui/screens/CliToolsScreen.tsx +92 -40
  61. package/src/ui/screens/EnvVarsScreen.tsx +19 -13
  62. package/src/ui/screens/GitignoreScreen.tsx +510 -493
  63. package/src/ui/screens/McpRegistryScreen.tsx +28 -21
  64. package/src/ui/screens/McpScreen.tsx +12 -3
  65. package/src/ui/screens/PluginsScreen.tsx +152 -33
  66. package/src/ui/screens/ProfilesScreen.tsx +39 -23
  67. package/src/ui/screens/SkillsScreen.tsx +832 -688
  68. package/src/ui/state/reducer.ts +11 -2
  69. package/src/ui/state/types.ts +16 -1
  70. package/src/ui/theme-mode.ts +73 -0
  71. package/src/ui/theme.ts +147 -53
  72. package/src/utils/config-dir.ts +47 -0
@@ -0,0 +1,218 @@
1
+ /**
2
+ * catalog-cache-store.ts — the catalog cache and the rate-limit cooldown, on disk.
3
+ *
4
+ * Why on disk
5
+ * -----------
6
+ * Every cache in this area used to be a module-level `Map`, which for a TUI means
7
+ * "cleared on every launch". Measured 2026-08-17, two consecutive processes:
8
+ *
9
+ * [process-1] 6 HTTP requests in 13.32s
10
+ * [process-2] 6 HTTP requests in 13.01s
11
+ *
12
+ * So each launch re-fetched all six catalogs, and the second process had no idea
13
+ * the first had just been rate-limited — it spent six more requests rediscovering
14
+ * that. On some endpoints a rejected request extends the penalty, so an in-memory
15
+ * cooldown is not merely useless across launches, it is counterproductive.
16
+ *
17
+ * Persisting the cooldown is the more important half. A stale catalog costs the
18
+ * user a version number that is an hour old; re-firing six doomed requests on
19
+ * every launch costs them the ability to check at all.
20
+ *
21
+ * Trade-off, stated
22
+ * -----------------
23
+ * A cached catalog means a release published inside the TTL is not visible yet.
24
+ * That is why the TTL is short, why `r` forces a real refetch, and why the age is
25
+ * available for display. A cache the user cannot see through or override would be
26
+ * the same trap as a frozen clone.
27
+ */
28
+
29
+ import { promises as fs } from "node:fs";
30
+ import path from "node:path";
31
+ import { claudeConfigDirOrNull } from "../utils/config-dir.js";
32
+ import { withFileLock } from "../utils/file-locking.js";
33
+ import type { MarketplacePlugin } from "./marketplace-catalog.js";
34
+
35
+ /**
36
+ * How long a cached catalog is served before we ask again.
37
+ *
38
+ * Matches the hour that `update-cache.ts` already uses for its own check, so the
39
+ * tool has one refresh rhythm rather than two.
40
+ */
41
+ export const CATALOG_TTL_MS = 60 * 60 * 1000;
42
+
43
+ interface StoredCatalog {
44
+ fetchedAt: number;
45
+ /** Which transport answered — carried so the UI can still say "unverified". */
46
+ source: string;
47
+ plugins: MarketplacePlugin[];
48
+ }
49
+
50
+ interface StoredCooldown {
51
+ until: number;
52
+ exact: boolean;
53
+ strikes: number;
54
+ }
55
+
56
+ interface StoreShape {
57
+ version: 1;
58
+ catalogs: Record<string, StoredCatalog>;
59
+ cooldowns: Record<string, StoredCooldown>;
60
+ }
61
+
62
+ const EMPTY: StoreShape = { version: 1, catalogs: {}, cooldowns: {} };
63
+
64
+ /**
65
+ * Resolved per call, honouring CLAUDE_CONFIG_DIR — same reasoning as
66
+ * content-drift.ts and marketplace-catalog-git.ts.
67
+ *
68
+ * Null when a test has not chosen a config dir, which makes this whole store inert
69
+ * rather than letting an unisolated test read the operator's live cooldown and
70
+ * decide its own outcome from it. See utils/config-dir.ts.
71
+ */
72
+ function storePath(): string | null {
73
+ const configDir = claudeConfigDirOrNull();
74
+ if (!configDir) return null;
75
+ return path.join(configDir, "claudeup-catalog-cache.json");
76
+ }
77
+
78
+ /**
79
+ * Read-through memo for this process. The disk file is the cross-launch cache;
80
+ * this just avoids re-reading and re-parsing it on every lookup within one run.
81
+ */
82
+ let memo: { path: string; data: StoreShape } | null = null;
83
+
84
+ async function load(): Promise<StoreShape> {
85
+ const file = storePath();
86
+ if (!file) return { version: 1, catalogs: {}, cooldowns: {} };
87
+ if (memo?.path === file) return memo.data;
88
+ try {
89
+ const parsed = JSON.parse(await fs.readFile(file, "utf-8")) as StoreShape;
90
+ // A cache from a future/unknown layout is discarded rather than migrated:
91
+ // it is regenerable by definition, so guessing at it buys nothing.
92
+ const data = parsed?.version === 1 ? parsed : { ...EMPTY };
93
+ data.catalogs ??= {};
94
+ data.cooldowns ??= {};
95
+ memo = { path: file, data };
96
+ return data;
97
+ } catch {
98
+ // Absent or corrupt. A cache must never be a failure mode — start empty.
99
+ const data: StoreShape = { ...EMPTY, catalogs: {}, cooldowns: {} };
100
+ memo = { path: file, data };
101
+ return data;
102
+ }
103
+ }
104
+
105
+ /**
106
+ * Apply a mutation and persist it.
107
+ *
108
+ * Locked and re-read inside the lock, because two claudeup processes can be open
109
+ * at once and a blind read-modify-write would drop the other's cooldown — exactly
110
+ * the record we most need to keep.
111
+ */
112
+ async function mutate(fn: (data: StoreShape) => void): Promise<void> {
113
+ const file = storePath();
114
+ if (!file) return;
115
+ try {
116
+ await fs.mkdir(path.dirname(file), { recursive: true });
117
+ await withFileLock(file, async () => {
118
+ let onDisk: StoreShape;
119
+ try {
120
+ const parsed = JSON.parse(await fs.readFile(file, "utf-8")) as StoreShape;
121
+ onDisk =
122
+ parsed?.version === 1
123
+ ? { version: 1, catalogs: parsed.catalogs ?? {}, cooldowns: parsed.cooldowns ?? {} }
124
+ : { version: 1, catalogs: {}, cooldowns: {} };
125
+ } catch {
126
+ onDisk = { version: 1, catalogs: {}, cooldowns: {} };
127
+ }
128
+ fn(onDisk);
129
+ // Temp + rename, so a crash mid-write cannot leave a half-written file
130
+ // that the next launch has to treat as corrupt.
131
+ const tmp = `${file}.${process.pid}.tmp`;
132
+ await fs.writeFile(tmp, JSON.stringify(onDisk), "utf-8");
133
+ await fs.rename(tmp, file);
134
+ memo = { path: file, data: onDisk };
135
+ });
136
+ } catch {
137
+ // Never let a cache write break the run. Worst case we re-fetch next time.
138
+ }
139
+ }
140
+
141
+ // ─── Catalogs ────────────────────────────────────────────────────────────────
142
+
143
+ export interface CachedCatalog {
144
+ plugins: MarketplacePlugin[];
145
+ source: string;
146
+ /** Milliseconds since it was fetched — for display, and for the TTL test. */
147
+ ageMs: number;
148
+ }
149
+
150
+ /** A cached catalog for this marketplace, if one is present and still fresh. */
151
+ export async function readCachedCatalog(
152
+ marketplace: string,
153
+ now: number = Date.now(),
154
+ ): Promise<CachedCatalog | null> {
155
+ const data = await load();
156
+ const entry = data.catalogs[marketplace];
157
+ if (!entry) return null;
158
+ const ageMs = now - entry.fetchedAt;
159
+ if (ageMs < 0 || ageMs > CATALOG_TTL_MS) return null;
160
+ return { plugins: entry.plugins, source: entry.source, ageMs };
161
+ }
162
+
163
+ export async function writeCachedCatalog(
164
+ marketplace: string,
165
+ plugins: MarketplacePlugin[],
166
+ source: string,
167
+ now: number = Date.now(),
168
+ ): Promise<void> {
169
+ // An empty catalog is never worth caching: it is indistinguishable from a
170
+ // failure we did not classify, and serving it would resurrect the original bug.
171
+ if (plugins.length === 0) return;
172
+ await mutate((data) => {
173
+ data.catalogs[marketplace] = { fetchedAt: now, source, plugins };
174
+ });
175
+ }
176
+
177
+ /** Drop every cached catalog. Backs the explicit refresh (`r`). */
178
+ export async function clearCachedCatalogs(): Promise<void> {
179
+ await mutate((data) => {
180
+ data.catalogs = {};
181
+ });
182
+ }
183
+
184
+ // ─── Cooldowns ───────────────────────────────────────────────────────────────
185
+
186
+ export async function readStoredCooldowns(
187
+ now: number = Date.now(),
188
+ ): Promise<Record<string, StoredCooldown>> {
189
+ const data = await load();
190
+ const live: Record<string, StoredCooldown> = {};
191
+ for (const [host, cooldown] of Object.entries(data.cooldowns)) {
192
+ // Expired entries are dropped on read rather than persisted forever. The
193
+ // strike count goes with them: a cooldown that lapsed a day ago should not
194
+ // make today's first failure back off for ten minutes.
195
+ if (cooldown.until > now) live[host] = cooldown;
196
+ }
197
+ return live;
198
+ }
199
+
200
+ export async function writeStoredCooldown(
201
+ host: string,
202
+ cooldown: StoredCooldown,
203
+ ): Promise<void> {
204
+ await mutate((data) => {
205
+ data.cooldowns[host] = cooldown;
206
+ });
207
+ }
208
+
209
+ export async function clearStoredCooldown(host: string): Promise<void> {
210
+ await mutate((data) => {
211
+ delete data.cooldowns[host];
212
+ });
213
+ }
214
+
215
+ /** Test seam: forget this process's memo so the next read hits disk. */
216
+ export function resetCatalogCacheMemo(): void {
217
+ memo = null;
218
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * github-budget.ts — one place that knows when we are allowed to call GitHub.
3
+ *
4
+ * claudeup talks to two GitHub hosts from several services, and they enforce
5
+ * separate budgets:
6
+ *
7
+ * api.github.com 60 req/hr unauthenticated, and it TELLS you:
8
+ * x-ratelimit-remaining / x-ratelimit-reset / retry-after
9
+ * raw.githubusercontent.com rate-limited too, and it tells you NOTHING —
10
+ * measured 2026-08-17: a 429 carried no retry-after
11
+ * and no x-ratelimit-* header, only a request id.
12
+ *
13
+ * That asymmetry is the whole design problem. For one host we can state exactly
14
+ * when the window reopens; for the other any specific time would be invented. So
15
+ * a cooldown records WHERE its timing came from, and the UI says "resets in 4m"
16
+ * only when GitHub actually said so, and "next attempt in 30s" — our own
17
+ * schedule, honestly labelled — when it did not.
18
+ *
19
+ * Two jobs:
20
+ * 1. Stop sending requests that are already known to fail. Every service shares
21
+ * one bucket per host, so the skills screen's raw fetches and the plugin
22
+ * catalog fetches must share one gate — otherwise each rediscovers the limit
23
+ * by burning more attempts against it.
24
+ * 2. Report when the next attempt is due, so the UI can show a countdown and
25
+ * retry itself instead of asking the user to guess.
26
+ */
27
+
28
+ import {
29
+ clearStoredCooldown,
30
+ readStoredCooldowns,
31
+ writeStoredCooldown,
32
+ } from "./catalog-cache-store.js";
33
+
34
+ /** Hosts we gate. Anything else is not budgeted. */
35
+ export type GitHubHost = "api.github.com" | "raw.githubusercontent.com";
36
+
37
+ export interface Cooldown {
38
+ host: GitHubHost;
39
+ /** Wall-clock ms when the next attempt is allowed. */
40
+ until: number;
41
+ /**
42
+ * True when `until` came from GitHub's own `retry-after` /
43
+ * `x-ratelimit-reset`. False when we derived it from backoff because the
44
+ * response carried no timing — the distinction the UI must not blur.
45
+ */
46
+ exact: boolean;
47
+ /** Consecutive rate-limited responses, driving the backoff. */
48
+ strikes: number;
49
+ }
50
+
51
+ const cooldowns = new Map<GitHubHost, Cooldown>();
52
+
53
+ /**
54
+ * Load cooldowns recorded by a previous launch.
55
+ *
56
+ * Without this the gate is per-process, which for a TUI means per-launch, which
57
+ * means useless: two consecutive processes each spent six requests rediscovering
58
+ * the same rate limit. Memoised, so callers may await it unconditionally.
59
+ */
60
+ let hydrated: Promise<void> | null = null;
61
+
62
+ export function hydrateGitHubBudget(): Promise<void> {
63
+ hydrated ??= (async () => {
64
+ try {
65
+ const stored = await readStoredCooldowns();
66
+ for (const [host, cooldown] of Object.entries(stored)) {
67
+ if (host !== "api.github.com" && host !== "raw.githubusercontent.com") {
68
+ continue;
69
+ }
70
+ // A cooldown already known in this process wins: it is newer.
71
+ if (cooldowns.has(host)) continue;
72
+ cooldowns.set(host, { host, ...cooldown });
73
+ }
74
+ } catch {
75
+ // A cache that cannot be read must not stop us making requests.
76
+ }
77
+ })();
78
+ return hydrated;
79
+ }
80
+
81
+ /**
82
+ * Backoff for hosts that give no retry timing. Deliberately coarse: a rate limit
83
+ * measured in minutes is not escaped by retrying in one second, and each wasted
84
+ * attempt can extend the penalty.
85
+ */
86
+ const BACKOFF_MS = [30_000, 60_000, 120_000, 300_000, 600_000];
87
+
88
+ /** Cap on a server-provided window we will honour, so a bad header cannot wedge the UI. */
89
+ const MAX_EXACT_WAIT_MS = 60 * 60_000;
90
+
91
+ export function hostOf(url: string): GitHubHost | null {
92
+ try {
93
+ const { hostname } = new URL(url);
94
+ if (hostname === "api.github.com") return "api.github.com";
95
+ if (hostname === "raw.githubusercontent.com")
96
+ return "raw.githubusercontent.com";
97
+ return null;
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Parse GitHub's retry timing, if the response carries any.
105
+ *
106
+ * `retry-after` is delta-seconds or an HTTP date (RFC 9110). `x-ratelimit-reset`
107
+ * is epoch SECONDS, not ms — treating it as ms yields a date in 1970 and a
108
+ * cooldown that expires instantly, which reads as "not rate limited".
109
+ */
110
+ function parseRetryTiming(headers: Headers, now: number): number | null {
111
+ const retryAfter = headers.get("retry-after");
112
+ if (retryAfter) {
113
+ const seconds = Number(retryAfter);
114
+ if (Number.isFinite(seconds) && seconds >= 0) {
115
+ return now + seconds * 1000;
116
+ }
117
+ const asDate = Date.parse(retryAfter);
118
+ if (Number.isFinite(asDate)) return asDate;
119
+ }
120
+
121
+ const reset = headers.get("x-ratelimit-reset");
122
+ if (reset) {
123
+ const epochSeconds = Number(reset);
124
+ if (Number.isFinite(epochSeconds) && epochSeconds > 0) {
125
+ return epochSeconds * 1000;
126
+ }
127
+ }
128
+
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * Record a rate-limited response and return the resulting cooldown.
134
+ *
135
+ * Call this for 429 and for 403, which is how GitHub reports an exhausted
136
+ * unauthenticated budget on the REST API.
137
+ */
138
+ export function recordRateLimit(
139
+ host: GitHubHost,
140
+ headers?: Headers,
141
+ now: number = Date.now(),
142
+ ): Cooldown {
143
+ const strikes = (cooldowns.get(host)?.strikes ?? 0) + 1;
144
+
145
+ const fromHeader = headers ? parseRetryTiming(headers, now) : null;
146
+ if (fromHeader !== null && fromHeader > now) {
147
+ const cooldown: Cooldown = {
148
+ host,
149
+ until: Math.min(fromHeader, now + MAX_EXACT_WAIT_MS),
150
+ exact: true,
151
+ strikes,
152
+ };
153
+ cooldowns.set(host, cooldown);
154
+ persist(cooldown);
155
+ return cooldown;
156
+ }
157
+
158
+ const step = BACKOFF_MS[Math.min(strikes - 1, BACKOFF_MS.length - 1)];
159
+ const cooldown: Cooldown = {
160
+ host,
161
+ // No jitter: this is a single-user desktop tool, not a fleet stampeding
162
+ // one endpoint. A predictable countdown is worth more here than spread.
163
+ until: now + (step ?? 30_000),
164
+ exact: false,
165
+ strikes,
166
+ };
167
+ cooldowns.set(host, cooldown);
168
+ persist(cooldown);
169
+ return cooldown;
170
+ }
171
+
172
+ /**
173
+ * Write-behind, so `recordRateLimit` stays synchronous for its many call sites.
174
+ *
175
+ * Fire-and-forget is acceptable here and only here: losing one cooldown write
176
+ * degrades to the old behaviour (one wasted request next launch), whereas making
177
+ * every caller async to guarantee it would spread `await` through the fetch path
178
+ * for no proportional gain.
179
+ */
180
+ function persist(cooldown: Cooldown): void {
181
+ void writeStoredCooldown(cooldown.host, {
182
+ until: cooldown.until,
183
+ exact: cooldown.exact,
184
+ strikes: cooldown.strikes,
185
+ });
186
+ }
187
+
188
+ /**
189
+ * A successful response clears the host's cooldown and its accumulated strikes.
190
+ *
191
+ * Resetting strikes matters: without it, one bad afternoon leaves the backoff
192
+ * pinned at ten minutes for the rest of the session even though requests are
193
+ * succeeding again.
194
+ */
195
+ export function recordSuccess(host: GitHubHost): void {
196
+ cooldowns.delete(host);
197
+ void clearStoredCooldown(host);
198
+ }
199
+
200
+ /**
201
+ * Also worth recording: the budget is nearly spent. Lets a caller stop before it
202
+ * trips the limit rather than after. Only api.github.com reports this.
203
+ */
204
+ export function remainingFromHeaders(headers: Headers): number | null {
205
+ const remaining = headers.get("x-ratelimit-remaining");
206
+ if (!remaining) return null;
207
+ const n = Number(remaining);
208
+ return Number.isFinite(n) ? n : null;
209
+ }
210
+
211
+ /** The active cooldown for a host, or null when calls are allowed. */
212
+ export function cooldownFor(
213
+ host: GitHubHost,
214
+ now: number = Date.now(),
215
+ ): Cooldown | null {
216
+ const cooldown = cooldowns.get(host);
217
+ if (!cooldown) return null;
218
+ if (cooldown.until <= now) {
219
+ // Expired. Keep the strike count — it is what makes repeated failures back
220
+ // off further — but stop blocking.
221
+ cooldowns.set(host, { ...cooldown, until: now });
222
+ return null;
223
+ }
224
+ return cooldown;
225
+ }
226
+
227
+ /** Milliseconds until the next attempt is allowed. 0 when allowed now. */
228
+ export function waitMs(host: GitHubHost, now: number = Date.now()): number {
229
+ const cooldown = cooldownFor(host, now);
230
+ return cooldown ? cooldown.until - now : 0;
231
+ }
232
+
233
+ /** Every host currently in cooldown, soonest first. */
234
+ export function activeCooldowns(now: number = Date.now()): Cooldown[] {
235
+ const active: Cooldown[] = [];
236
+ for (const host of cooldowns.keys()) {
237
+ const cooldown = cooldownFor(host, now);
238
+ if (cooldown) active.push(cooldown);
239
+ }
240
+ return active.sort((a, b) => a.until - b.until);
241
+ }
242
+
243
+ /** Human countdown: "45s", "4m", "1h 12m". */
244
+ export function formatWait(ms: number): string {
245
+ const totalSeconds = Math.max(0, Math.ceil(ms / 1000));
246
+ if (totalSeconds < 60) return `${totalSeconds}s`;
247
+ const minutes = Math.ceil(totalSeconds / 60);
248
+ if (minutes < 60) return `${minutes}m`;
249
+ const hours = Math.floor(minutes / 60);
250
+ return `${hours}h ${minutes % 60}m`;
251
+ }
252
+
253
+ /**
254
+ * How to describe a cooldown to the user.
255
+ *
256
+ * The two phrasings are not interchangeable. "resets in 4m" is a claim about
257
+ * GitHub's state and is only sayable when GitHub sent the timing;
258
+ * "next attempt in 30s" is a claim about our own schedule and is always sayable.
259
+ */
260
+ export function describeCooldown(
261
+ cooldown: Cooldown,
262
+ now: number = Date.now(),
263
+ ): string {
264
+ const wait = formatWait(cooldown.until - now);
265
+ return cooldown.exact
266
+ ? `GitHub rate limit resets in ${wait}`
267
+ : `GitHub rate limit — next attempt in ${wait}`;
268
+ }
269
+
270
+ /** Test seam only. Clears in-memory state and the hydration memo, not the file. */
271
+ export function resetGitHubBudget(): void {
272
+ cooldowns.clear();
273
+ hydrated = null;
274
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * marketplace-catalog-git.ts — read a marketplace's CURRENT catalog over git,
3
+ * without touching the checked-out clone.
4
+ *
5
+ * Why this exists
6
+ * ---------------
7
+ * `autoUpdate: false` is deliberate policy here: plugins are not updated
8
+ * automatically, the UI merely shows that an update is available. That freezes the
9
+ * clone at `~/.claude/plugins/marketplaces/<name>`, which is correct — the clone
10
+ * is the INSTALL SOURCE, and pinning it is the point.
11
+ *
12
+ * The mistake was using that same frozen clone as the CATALOG — the answer to
13
+ * "what version exists upstream". Those are two different jobs riding one
14
+ * artifact, and pinning the first silently blinded the second: the clone said
15
+ * terminal 4.1.4, the installed copy was 4.1.4, so the screen reported up to date
16
+ * while 4.2.0 had shipped.
17
+ *
18
+ * `git fetch` updates `refs/remotes/origin/*` and nothing else. The working tree
19
+ * does not move, HEAD does not move, so installs stay pinned exactly as the policy
20
+ * intends — and `git show origin/<branch>:.claude-plugin/marketplace.json` then
21
+ * reads the real upstream catalog. Verified 2026-08-17 against the magus clone:
22
+ * catalog read as 9.0.3 / terminal 4.2.0 while HEAD stayed at c45ddee.
23
+ *
24
+ * It also sidesteps the reason the HTTP path was failing. `raw.githubusercontent.com`
25
+ * was returning 429; git talks the smart-transport protocol over SSH or HTTPS and
26
+ * draws on neither that bucket nor the REST budget. So the version check keeps
27
+ * working precisely when the HTTP catalog cannot.
28
+ */
29
+
30
+ import { spawn } from "node:child_process";
31
+ import fs from "node:fs";
32
+ import path from "node:path";
33
+ import { claudeConfigDirOrNull } from "../utils/config-dir.js";
34
+ import {
35
+ normalizeCatalogJson,
36
+ type MarketplacePlugin,
37
+ } from "./marketplace-catalog.js";
38
+
39
+ /** Network reach; a wedged fetch must never hold up the screen. */
40
+ const FETCH_TIMEOUT_MS = 15_000;
41
+ /** Local object reads are fast or broken. */
42
+ const LOCAL_TIMEOUT_MS = 5_000;
43
+
44
+ /**
45
+ * Resolved per call, honouring CLAUDE_CONFIG_DIR — the same override Claude Code
46
+ * uses, and the same approach content-drift.ts takes for the same directory.
47
+ *
48
+ * Returns null when a test has not chosen a config dir, so an unisolated test
49
+ * cannot make this function perform a live `git fetch` against the operator's real
50
+ * marketplace clones. See utils/config-dir.ts.
51
+ */
52
+ function cloneDir(name: string): string | null {
53
+ const configDir = claudeConfigDirOrNull();
54
+ if (!configDir) return null;
55
+ return path.join(configDir, "plugins", "marketplaces", name);
56
+ }
57
+
58
+ function git(
59
+ cwd: string,
60
+ args: string[],
61
+ timeoutMs: number,
62
+ ): Promise<{ ok: boolean; out: string }> {
63
+ return new Promise((resolve) => {
64
+ const child = spawn("git", args, {
65
+ cwd,
66
+ stdio: ["ignore", "pipe", "ignore"],
67
+ timeout: timeoutMs,
68
+ killSignal: "SIGKILL",
69
+ env: {
70
+ ...process.env,
71
+ // Never let git stop for credentials: this runs inside a TUI with no
72
+ // way to answer a prompt, and a private marketplace we cannot read
73
+ // should degrade to "unknown", not hang the screen forever.
74
+ GIT_TERMINAL_PROMPT: "0",
75
+ GIT_ASKPASS: "echo",
76
+ SSH_ASKPASS: "echo",
77
+ },
78
+ });
79
+ let out = "";
80
+ child.stdout.on("data", (d: Buffer | string) => {
81
+ out += String(d);
82
+ });
83
+ child.on("error", () => resolve({ ok: false, out: "" }));
84
+ child.on("close", (code: number | null) =>
85
+ resolve({ ok: code === 0, out }),
86
+ );
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Session cache. A catalog cannot change under us mid-session unless the user
92
+ * explicitly refreshes, and each miss costs a network round trip per marketplace.
93
+ */
94
+ const catalogCache = new Map<string, MarketplacePlugin[] | null>();
95
+
96
+ export function clearGitCatalogCache(): void {
97
+ catalogCache.clear();
98
+ }
99
+
100
+ /** The branch `origin/HEAD` points at, falling back to `main`. */
101
+ async function defaultBranch(dir: string): Promise<string> {
102
+ const ref = await git(
103
+ dir,
104
+ ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
105
+ LOCAL_TIMEOUT_MS,
106
+ );
107
+ const name = ref.out.trim();
108
+ // "origin/main" → "main"
109
+ if (ref.ok && name.startsWith("origin/")) return name.slice("origin/".length);
110
+ return "main";
111
+ }
112
+
113
+ /**
114
+ * The marketplace's upstream catalog, or null when it cannot be read honestly.
115
+ *
116
+ * Null covers: no clone on disk, not a git repo, no reachable remote, fetch
117
+ * refused, or a catalog that does not parse. Every one of those must stay
118
+ * distinguishable from "an empty catalog" — collapsing them is the bug this
119
+ * module was written to help fix.
120
+ */
121
+ export async function readCatalogFromRemote(
122
+ marketplaceName: string,
123
+ ): Promise<MarketplacePlugin[] | null> {
124
+ if (catalogCache.has(marketplaceName)) {
125
+ return catalogCache.get(marketplaceName) ?? null;
126
+ }
127
+
128
+ const dir = cloneDir(marketplaceName);
129
+ if (!dir || !fs.existsSync(path.join(dir, ".git"))) {
130
+ catalogCache.set(marketplaceName, null);
131
+ return null;
132
+ }
133
+
134
+ const branch = await defaultBranch(dir);
135
+
136
+ // Refs only. No merge, no checkout, no working-tree change — this is what
137
+ // makes the read compatible with `autoUpdate: false`.
138
+ const fetched = await git(
139
+ dir,
140
+ ["fetch", "--quiet", "origin", branch],
141
+ FETCH_TIMEOUT_MS,
142
+ );
143
+ if (!fetched.ok) {
144
+ // A failed fetch may still leave a usable-but-older origin ref from a
145
+ // previous fetch. That is strictly better than the working tree, but it is
146
+ // not current, so it is not offered here — the caller's clone fallback
147
+ // already covers "possibly stale", and labels it as such.
148
+ catalogCache.set(marketplaceName, null);
149
+ return null;
150
+ }
151
+
152
+ const shown = await git(
153
+ dir,
154
+ ["show", `origin/${branch}:.claude-plugin/marketplace.json`],
155
+ LOCAL_TIMEOUT_MS,
156
+ );
157
+ if (!shown.ok) {
158
+ catalogCache.set(marketplaceName, null);
159
+ return null;
160
+ }
161
+
162
+ try {
163
+ const plugins = normalizeCatalogJson(marketplaceName, JSON.parse(shown.out));
164
+ catalogCache.set(marketplaceName, plugins);
165
+ return plugins;
166
+ } catch {
167
+ catalogCache.set(marketplaceName, null);
168
+ return null;
169
+ }
170
+ }