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.
- package/package.json +4 -4
- package/src/__tests__/catalog-cache-store.test.ts +271 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/github-budget.test.ts +200 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
- package/src/__tests__/scope-squares.test.tsx +165 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +307 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/main.tsx +21 -5
- package/src/opentui.d.ts +21 -12
- package/src/services/catalog-cache-store.ts +218 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/marketplace-catalog-git.ts +170 -0
- package/src/services/marketplace-catalog.ts +95 -0
- package/src/services/marketplace-fetcher.ts +310 -87
- package/src/services/plugin-manager.ts +103 -92
- package/src/ui/App.tsx +19 -12
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/pluginsAdapter.ts +174 -168
- package/src/ui/adapters/settingsAdapter.ts +119 -116
- package/src/ui/adapters/skillsAdapter.ts +203 -196
- package/src/ui/components/CategoryHeader.tsx +9 -8
- package/src/ui/components/EmptyFilterState.tsx +10 -5
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +10 -6
- package/src/ui/components/ScrollableList.tsx +3 -2
- package/src/ui/components/SearchInput.tsx +2 -1
- package/src/ui/components/StyledText.tsx +5 -4
- package/src/ui/components/TabBar.tsx +4 -3
- package/src/ui/components/layout/FooterHints.tsx +37 -30
- package/src/ui/components/layout/Panel.tsx +6 -5
- package/src/ui/components/layout/ProgressBar.tsx +7 -6
- package/src/ui/components/layout/ScopeTabs.tsx +6 -5
- package/src/ui/components/layout/ScreenLayout.tsx +46 -26
- package/src/ui/components/layout/index.ts +3 -3
- package/src/ui/components/modals/ConfirmModal.tsx +12 -11
- package/src/ui/components/modals/InputModal.tsx +14 -6
- package/src/ui/components/modals/LoadingModal.tsx +6 -5
- package/src/ui/components/modals/MessageModal.tsx +9 -8
- package/src/ui/components/modals/SelectModal.tsx +11 -7
- package/src/ui/components/modals/VersionMismatchModal.tsx +14 -16
- package/src/ui/components/primitives/ActionHints.tsx +26 -26
- package/src/ui/components/primitives/DetailSection.tsx +13 -12
- package/src/ui/components/primitives/KeyValueLine.tsx +9 -8
- package/src/ui/components/primitives/ListCategoryRow.tsx +25 -27
- package/src/ui/components/primitives/MetaText.tsx +3 -3
- package/src/ui/components/primitives/ScopeDetail.tsx +48 -48
- package/src/ui/components/primitives/ScopeSquares.tsx +47 -22
- package/src/ui/components/primitives/SelectableRow.tsx +22 -16
- package/src/ui/hooks/useGitignoreModal.ts +78 -74
- package/src/ui/registry.ts +11 -11
- package/src/ui/renderers/cliToolRenderers.tsx +260 -203
- package/src/ui/renderers/gitignoreRenderers.tsx +43 -42
- package/src/ui/renderers/mcpRenderers.tsx +121 -117
- package/src/ui/renderers/pluginRenderers.tsx +566 -471
- package/src/ui/renderers/profileRenderers.tsx +346 -300
- package/src/ui/renderers/settingsRenderers.tsx +183 -176
- package/src/ui/renderers/skillRenderers.tsx +410 -326
- package/src/ui/screens/AliasScreen.tsx +1336 -1309
- package/src/ui/screens/CliToolsScreen.tsx +92 -40
- package/src/ui/screens/EnvVarsScreen.tsx +19 -13
- package/src/ui/screens/GitignoreScreen.tsx +510 -493
- package/src/ui/screens/McpRegistryScreen.tsx +28 -21
- package/src/ui/screens/McpScreen.tsx +12 -3
- package/src/ui/screens/PluginsScreen.tsx +152 -33
- package/src/ui/screens/ProfilesScreen.tsx +39 -23
- package/src/ui/screens/SkillsScreen.tsx +832 -688
- package/src/ui/state/reducer.ts +11 -2
- package/src/ui/state/types.ts +16 -1
- package/src/ui/theme-mode.ts +73 -0
- package/src/ui/theme.ts +147 -53
- 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
|
+
}
|