claudeup 4.36.0 → 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.
@@ -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
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * marketplace-catalog.ts — the catalog shape and its parser, transport-agnostic.
3
+ *
4
+ * A marketplace catalog can arrive two ways: over HTTP from
5
+ * raw.githubusercontent.com (`marketplace-fetcher.ts`) or out of a git remote ref
6
+ * (`marketplace-catalog-git.ts`). Both must produce byte-identical results — a
7
+ * catalog that parsed differently depending on how it was fetched would make
8
+ * version comparisons depend on the transport, which is the same class of bug as
9
+ * the one that made a failed fetch read as "up to date".
10
+ *
11
+ * This module exists so the two transports share one parser without importing
12
+ * each other. The fetcher needs the git reader as a fallback and the git reader
13
+ * needs the parser, so putting the parser in either one would make them mutually
14
+ * dependent.
15
+ */
16
+
17
+ import type { PluginRelease } from "../types/index.js";
18
+ import { normalizeReleases } from "./plugin-releases.js";
19
+
20
+ export interface MarketplacePlugin {
21
+ name: string;
22
+ version: string | null;
23
+ description: string;
24
+ category?: string;
25
+ author?: { name: string; email?: string };
26
+ homepage?: string;
27
+ tags?: string[];
28
+ releases?: PluginRelease[];
29
+ }
30
+
31
+ // Session-level cache for each marketplace's declared version (from
32
+ // marketplace.json's top-level `metadata.version`), so the TUI can surface a
33
+ // marketplace's version without re-fetching. Only populated for marketplaces that
34
+ // actually declare one.
35
+ const marketplaceVersionCache = new Map<string, string>();
36
+
37
+ export function clearMarketplaceVersionCache(): void {
38
+ marketplaceVersionCache.clear();
39
+ }
40
+
41
+ /**
42
+ * Returns the version a marketplace declared in its `metadata.version`, if one
43
+ * was seen during a successful read this session. Returns `undefined` for
44
+ * marketplaces that declare no version (or haven't been read yet).
45
+ */
46
+ export function getMarketplaceVersion(
47
+ marketplaceName: string,
48
+ ): string | undefined {
49
+ return marketplaceVersionCache.get(marketplaceName);
50
+ }
51
+
52
+ interface RawPlugin {
53
+ name: string;
54
+ version?: string | null;
55
+ description?: string;
56
+ category?: string;
57
+ author?: { name: string; email?: string };
58
+ homepage?: string;
59
+ tags?: string[];
60
+ releases?: unknown;
61
+ }
62
+
63
+ /** Parse a `marketplace.json` payload into plugin entries. */
64
+ export function normalizeCatalogJson(
65
+ marketplaceName: string,
66
+ raw: unknown,
67
+ ): MarketplacePlugin[] {
68
+ const data = (raw ?? {}) as {
69
+ plugins?: RawPlugin[];
70
+ metadata?: { version?: string };
71
+ };
72
+ const plugins: MarketplacePlugin[] = [];
73
+
74
+ const version = data.metadata?.version;
75
+ if (typeof version === "string" && version.length > 0) {
76
+ marketplaceVersionCache.set(marketplaceName, version);
77
+ }
78
+
79
+ if (data.plugins && Array.isArray(data.plugins)) {
80
+ for (const plugin of data.plugins) {
81
+ plugins.push({
82
+ name: plugin.name,
83
+ version: plugin.version || null,
84
+ description: plugin.description || "",
85
+ category: plugin.category,
86
+ author: plugin.author,
87
+ homepage: plugin.homepage,
88
+ tags: plugin.tags,
89
+ releases: normalizeReleases(plugin.releases),
90
+ });
91
+ }
92
+ }
93
+
94
+ return plugins;
95
+ }