claudeup 6.2.1 → 6.3.2

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 (150) hide show
  1. package/README.md +22 -0
  2. package/bin/claudeup.js +6 -1
  3. package/package.json +6 -6
  4. package/src/__tests__/alias-adopt.test.ts +331 -329
  5. package/src/__tests__/alias-parser.test.ts +384 -376
  6. package/src/__tests__/alias-shell-writer.test.ts +638 -625
  7. package/src/__tests__/alias-store.test.ts +143 -143
  8. package/src/__tests__/catalog-cache-store.test.ts +265 -265
  9. package/src/__tests__/catalog-notice.test.ts +123 -123
  10. package/src/__tests__/cli-apply-seams.test.ts +12 -8
  11. package/src/__tests__/cli-router.test.ts +132 -3
  12. package/src/__tests__/cli-update-view.test.ts +27 -8
  13. package/src/__tests__/community-staleness.test.ts +0 -2
  14. package/src/__tests__/component-badge.test.ts +4 -4
  15. package/src/__tests__/content-drift.test.ts +5 -1
  16. package/src/__tests__/conventions-integration.test.ts +774 -689
  17. package/src/__tests__/conventions-manager.test.ts +1216 -1152
  18. package/src/__tests__/doctor-bins.test.ts +14 -6
  19. package/src/__tests__/doctor.test.ts +438 -425
  20. package/src/__tests__/dual-write-prevention.test.ts +277 -282
  21. package/src/__tests__/enabled-not-installed.test.ts +71 -63
  22. package/src/__tests__/file-locking.test.ts +196 -196
  23. package/src/__tests__/gap-fill-versions.test.ts +318 -311
  24. package/src/__tests__/github-budget.test.ts +170 -164
  25. package/src/__tests__/gitignore-detector.test.ts +160 -152
  26. package/src/__tests__/gitignore-fixer.test.ts +285 -247
  27. package/src/__tests__/gitignore-prerun.test.ts +57 -57
  28. package/src/__tests__/gitignore-resolver.test.ts +173 -146
  29. package/src/__tests__/gitignore-service.test.ts +121 -119
  30. package/src/__tests__/install-command.test.ts +3 -3
  31. package/src/__tests__/install-plan.test.ts +13 -7
  32. package/src/__tests__/manifest.test.ts +16 -5
  33. package/src/__tests__/markdown-renderer.test.ts +0 -1
  34. package/src/__tests__/marketplace-badge.test.ts +111 -90
  35. package/src/__tests__/marketplace-version.test.ts +64 -61
  36. package/src/__tests__/plugin-manager-fallback.test.ts +249 -239
  37. package/src/__tests__/plugin-requires.test.ts +18 -4
  38. package/src/__tests__/plugin-setup.test.ts +12 -12
  39. package/src/__tests__/profile-command.test.ts +1 -7
  40. package/src/__tests__/profile-materializer.test.ts +31 -8
  41. package/src/__tests__/profile-sync.test.ts +23 -5
  42. package/src/__tests__/registry-version-resolution.test.ts +61 -47
  43. package/src/__tests__/resolve-executable.test.ts +1 -1
  44. package/src/__tests__/resolver.test.ts +40 -15
  45. package/src/__tests__/scope-action.test.ts +52 -50
  46. package/src/__tests__/scope-squares.test.tsx +4 -5
  47. package/src/__tests__/skill-install-files.test.ts +111 -103
  48. package/src/__tests__/symlink-manager.test.ts +27 -10
  49. package/src/__tests__/theme-env.test.ts +281 -0
  50. package/src/__tests__/theme-resolve.test.ts +380 -0
  51. package/src/__tests__/update-apply.test.ts +1 -3
  52. package/src/__tests__/useGitignoreModal.test.ts +69 -61
  53. package/src/__tests__/version-snapshot.test.ts +181 -165
  54. package/src/cli/ansi.ts +23 -15
  55. package/src/cli/doctor.ts +10 -9
  56. package/src/cli/install.ts +3 -3
  57. package/src/cli/profile.ts +5 -4
  58. package/src/cli/prompt.ts +0 -1
  59. package/src/cli/router.ts +69 -2
  60. package/src/cli/update-view.ts +11 -6
  61. package/src/cli/update.ts +9 -4
  62. package/src/cli/upgrade.ts +6 -4
  63. package/src/data/alias-flags.ts +260 -260
  64. package/src/data/gitignore-defaults.ts +20 -20
  65. package/src/data/gitignore-reasons.ts +86 -92
  66. package/src/data/gitignore-templates.ts +14 -14
  67. package/src/data/predefined-profiles.ts +8 -9
  68. package/src/data/skill-repos.ts +43 -35
  69. package/src/main.tsx +29 -6
  70. package/src/opentui.d.ts +1 -3
  71. package/src/prerunner/index.ts +7 -8
  72. package/src/services/alias-settings.ts +20 -23
  73. package/src/services/alias-shell-writer.ts +800 -803
  74. package/src/services/alias-store.ts +77 -77
  75. package/src/services/claude-settings.ts +27 -18
  76. package/src/services/content-drift.ts +13 -3
  77. package/src/services/conventions-manager.ts +692 -617
  78. package/src/services/doctor.ts +464 -403
  79. package/src/services/gitignore-detector.ts +113 -103
  80. package/src/services/gitignore-fixer.ts +204 -189
  81. package/src/services/gitignore-prerun.ts +26 -26
  82. package/src/services/gitignore-resolver.ts +162 -144
  83. package/src/services/gitignore-service.ts +120 -117
  84. package/src/services/install-plan.ts +4 -4
  85. package/src/services/manifest.ts +1 -1
  86. package/src/services/marketplace-catalog-git.ts +5 -2
  87. package/src/services/marketplace-fetcher.ts +241 -235
  88. package/src/services/plugin-manager.ts +17 -20
  89. package/src/services/plugin-mcp-config.ts +2 -2
  90. package/src/services/plugin-requires.ts +9 -6
  91. package/src/services/plugin-setup.ts +7 -9
  92. package/src/services/plugin-version-check.ts +4 -4
  93. package/src/services/profile-materializer.ts +9 -5
  94. package/src/services/profile-sync.ts +12 -4
  95. package/src/services/profiles.ts +3 -3
  96. package/src/services/resolver.ts +10 -6
  97. package/src/services/settings-manager.ts +82 -46
  98. package/src/services/skills-manager.ts +11 -9
  99. package/src/services/symlink-manager.ts +2 -5
  100. package/src/services/toolchain.ts +3 -3
  101. package/src/services/update-cache.ts +1 -1
  102. package/src/services/update-engine.ts +4 -4
  103. package/src/services/update-plan.ts +9 -11
  104. package/src/services/version-snapshot.ts +88 -88
  105. package/src/types/gitignore.ts +28 -28
  106. package/src/ui/App.tsx +36 -36
  107. package/src/ui/adapters/settingsAdapter.ts +2 -2
  108. package/src/ui/components/CategoryHeader.tsx +1 -1
  109. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  110. package/src/ui/components/ScopeIndicator.tsx +3 -1
  111. package/src/ui/components/ScrollableList.tsx +2 -1
  112. package/src/ui/components/StyledText.tsx +1 -1
  113. package/src/ui/components/layout/FooterHints.tsx +5 -3
  114. package/src/ui/components/layout/Panel.tsx +2 -2
  115. package/src/ui/components/layout/ScreenLayout.tsx +5 -4
  116. package/src/ui/components/modals/InputModal.tsx +7 -1
  117. package/src/ui/components/modals/ModalContainer.tsx +3 -3
  118. package/src/ui/components/modals/SelectModal.tsx +1 -1
  119. package/src/ui/components/primitives/DetailSection.tsx +1 -1
  120. package/src/ui/components/primitives/KeyValueLine.tsx +1 -1
  121. package/src/ui/components/primitives/ScopeSquares.tsx +2 -2
  122. package/src/ui/hooks/index.ts +0 -1
  123. package/src/ui/hooks/useGitignoreModal.ts +1 -5
  124. package/src/ui/hooks/useMismatchModal.ts +4 -10
  125. package/src/ui/renderers/cliToolRenderers.tsx +2 -2
  126. package/src/ui/renderers/gitignoreRenderers.tsx +2 -2
  127. package/src/ui/renderers/mcpRenderers.tsx +1 -1
  128. package/src/ui/renderers/pluginRenderers.tsx +3 -9
  129. package/src/ui/renderers/profileRenderers.tsx +9 -8
  130. package/src/ui/renderers/settingsRenderers.tsx +4 -4
  131. package/src/ui/renderers/skillRenderers.tsx +14 -14
  132. package/src/ui/screens/AliasScreen.tsx +31 -34
  133. package/src/ui/screens/CliToolsScreen.tsx +14 -13
  134. package/src/ui/screens/EnvVarsScreen.tsx +8 -8
  135. package/src/ui/screens/GitignoreScreen.tsx +15 -14
  136. package/src/ui/screens/McpRegistryScreen.tsx +10 -7
  137. package/src/ui/screens/McpScreen.tsx +10 -10
  138. package/src/ui/screens/PluginsScreen.tsx +17 -21
  139. package/src/ui/screens/ProfilesScreen.tsx +25 -24
  140. package/src/ui/screens/SkillsScreen.tsx +16 -14
  141. package/src/ui/state/AppContext.tsx +4 -8
  142. package/src/ui/state/DimensionsContext.tsx +3 -2
  143. package/src/ui/theme-mode.ts +20 -38
  144. package/src/ui/theme-resolve.ts +148 -0
  145. package/src/utils/clipboard.ts +5 -5
  146. package/src/utils/command-utils.ts +2 -2
  147. package/src/utils/config-dir.ts +1 -3
  148. package/src/utils/file-locking.ts +88 -86
  149. package/src/ui/hooks/useAsyncData.ts +0 -127
  150. package/src/ui/state/AnimationContext.tsx +0 -76
@@ -32,8 +32,8 @@ import fs from "node:fs";
32
32
  import path from "node:path";
33
33
  import { claudeConfigDirOrNull } from "../utils/config-dir.js";
34
34
  import {
35
- normalizeCatalogJson,
36
35
  type MarketplacePlugin,
36
+ normalizeCatalogJson,
37
37
  } from "./marketplace-catalog.js";
38
38
 
39
39
  /** Network reach; a wedged fetch must never hold up the screen. */
@@ -160,7 +160,10 @@ export async function readCatalogFromRemote(
160
160
  }
161
161
 
162
162
  try {
163
- const plugins = normalizeCatalogJson(marketplaceName, JSON.parse(shown.out));
163
+ const plugins = normalizeCatalogJson(
164
+ marketplaceName,
165
+ JSON.parse(shown.out),
166
+ );
164
167
  catalogCache.set(marketplaceName, plugins);
165
168
  return plugins;
166
169
  } catch {
@@ -9,35 +9,35 @@
9
9
 
10
10
  import { isValidGitHubRepo } from "../utils/string-utils.js";
11
11
  import {
12
- clearCachedCatalogs,
13
- readCachedCatalog,
14
- writeCachedCatalog,
12
+ clearCachedCatalogs,
13
+ readCachedCatalog,
14
+ writeCachedCatalog,
15
15
  } from "./catalog-cache-store.js";
16
16
  import {
17
- cooldownFor,
18
- describeCooldown,
19
- hydrateGitHubBudget,
20
- recordRateLimit,
21
- recordSuccess,
22
- type GitHubHost,
17
+ type GitHubHost,
18
+ cooldownFor,
19
+ describeCooldown,
20
+ hydrateGitHubBudget,
21
+ recordRateLimit,
22
+ recordSuccess,
23
23
  } from "./github-budget.js";
24
24
  import type { LocalMarketplace } from "./local-marketplace.js";
25
25
  import {
26
- clearGitCatalogCache,
27
- readCatalogFromRemote,
26
+ clearGitCatalogCache,
27
+ readCatalogFromRemote,
28
28
  } from "./marketplace-catalog-git.js";
29
29
  import {
30
- clearMarketplaceVersionCache,
31
- normalizeCatalogJson,
32
- type MarketplacePlugin,
30
+ type MarketplacePlugin,
31
+ clearMarketplaceVersionCache,
32
+ normalizeCatalogJson,
33
33
  } from "./marketplace-catalog.js";
34
34
 
35
35
  // Re-exported: these used to live here, and plugin-manager plus several screens
36
36
  // import them from this module.
37
37
  export {
38
- getMarketplaceVersion,
39
- normalizeCatalogJson,
40
- type MarketplacePlugin,
38
+ getMarketplaceVersion,
39
+ normalizeCatalogJson,
40
+ type MarketplacePlugin,
41
41
  } from "./marketplace-catalog.js";
42
42
 
43
43
  // Session-level cache for fetched marketplace data (no TTL - persists until explicit refresh)
@@ -45,31 +45,31 @@ const marketplaceCache = new Map<string, MarketplacePlugin[]>();
45
45
 
46
46
  /** Why a catalog fetch did not produce an authoritative plugin list. */
47
47
  export type MarketplaceFetchFailureKind =
48
- | "rate-limited"
49
- | "http"
50
- | "timeout"
51
- | "network"
52
- | "invalid-repo"
53
- | "invalid-content";
48
+ | "rate-limited"
49
+ | "http"
50
+ | "timeout"
51
+ | "network"
52
+ | "invalid-repo"
53
+ | "invalid-content";
54
54
 
55
55
  export interface MarketplaceFetchFailure {
56
- marketplace: string;
57
- kind: MarketplaceFetchFailureKind;
58
- httpStatus?: number;
59
- /** Short, user-facing reason — rendered verbatim in the TUI. */
60
- detail: string;
61
- /**
62
- * Wall-clock ms when a retry is due, for rate limits. Lets the UI count down
63
- * and retry itself instead of telling the user to try again "later" and leaving
64
- * them to guess how much later.
65
- */
66
- retryAt?: number;
56
+ marketplace: string;
57
+ kind: MarketplaceFetchFailureKind;
58
+ httpStatus?: number;
59
+ /** Short, user-facing reason — rendered verbatim in the TUI. */
60
+ detail: string;
61
+ /**
62
+ * Wall-clock ms when a retry is due, for rate limits. Lets the UI count down
63
+ * and retry itself instead of telling the user to try again "later" and leaving
64
+ * them to guess how much later.
65
+ */
66
+ retryAt?: number;
67
67
  }
68
68
 
69
69
  export interface MarketplaceFetchResult {
70
- plugins: MarketplacePlugin[];
71
- /** Present exactly when `plugins` is not an authoritative remote answer. */
72
- failure?: MarketplaceFetchFailure;
70
+ plugins: MarketplacePlugin[];
71
+ /** Present exactly when `plugins` is not an authoritative remote answer. */
72
+ failure?: MarketplaceFetchFailure;
73
73
  }
74
74
 
75
75
  /**
@@ -88,17 +88,17 @@ export type CatalogSource = "remote" | "remote-git" | "local-clone" | "none";
88
88
 
89
89
  /** Sources whose versions may be reported to the user as current. */
90
90
  export function isAuthoritative(source: CatalogSource): boolean {
91
- return source === "remote" || source === "remote-git";
91
+ return source === "remote" || source === "remote-git";
92
92
  }
93
93
 
94
94
  export interface MarketplaceResolution {
95
- plugins: MarketplacePlugin[];
96
- source: CatalogSource;
97
- /**
98
- * The remote failure, if any. Set even when `source` is `local-clone` — the
99
- * fallback succeeded, but the versions in it are not known to be current.
100
- */
101
- failure?: MarketplaceFetchFailure;
95
+ plugins: MarketplacePlugin[];
96
+ source: CatalogSource;
97
+ /**
98
+ * The remote failure, if any. Set even when `source` is `local-clone` — the
99
+ * fallback succeeded, but the versions in it are not known to be current.
100
+ */
101
+ failure?: MarketplaceFetchFailure;
102
102
  }
103
103
 
104
104
  /**
@@ -114,19 +114,22 @@ export interface MarketplaceResolution {
114
114
  * freezes the catalog for a whole session — which would be the same
115
115
  * silent-staleness trap this module exists to close.
116
116
  */
117
- const failureCache = new Map<string, { failure: MarketplaceFetchFailure; at: number }>();
117
+ const failureCache = new Map<
118
+ string,
119
+ { failure: MarketplaceFetchFailure; at: number }
120
+ >();
118
121
  const NEGATIVE_TTL_MS = 60_000;
119
122
 
120
123
  /** Monotonic clock, so a system clock change cannot expire the cache early. */
121
124
  function now(): number {
122
- return performance.now();
125
+ return performance.now();
123
126
  }
124
127
 
125
128
  export function clearMarketplaceCache(): void {
126
- marketplaceCache.clear();
127
- clearMarketplaceVersionCache();
128
- clearGitCatalogCache();
129
- failureCache.clear();
129
+ marketplaceCache.clear();
130
+ clearMarketplaceVersionCache();
131
+ clearGitCatalogCache();
132
+ failureCache.clear();
130
133
  }
131
134
 
132
135
  /**
@@ -141,7 +144,7 @@ export function clearMarketplaceCache(): void {
141
144
  * and refetches by itself when the window opens.
142
145
  */
143
146
  export async function clearPersistedCatalogs(): Promise<void> {
144
- await clearCachedCatalogs();
147
+ await clearCachedCatalogs();
145
148
  }
146
149
 
147
150
  /**
@@ -151,30 +154,30 @@ export async function clearPersistedCatalogs(): Promise<void> {
151
154
  * only proof that the versions on screen were actually checked against GitHub.
152
155
  */
153
156
  export function getMarketplaceFetchFailures(): MarketplaceFetchFailure[] {
154
- const out: MarketplaceFetchFailure[] = [];
155
- for (const [, entry] of failureCache) {
156
- if (now() - entry.at < NEGATIVE_TTL_MS) out.push(entry.failure);
157
- }
158
- return out;
157
+ const out: MarketplaceFetchFailure[] = [];
158
+ for (const [, entry] of failureCache) {
159
+ if (now() - entry.at < NEGATIVE_TTL_MS) out.push(entry.failure);
160
+ }
161
+ return out;
159
162
  }
160
163
 
161
164
  /** Record a failure and return it as the result, so callers cannot ignore it. */
162
165
  function fail(
163
- marketplace: string,
164
- kind: MarketplaceFetchFailureKind,
165
- detail: string,
166
- httpStatus?: number,
167
- retryAt?: number,
166
+ marketplace: string,
167
+ kind: MarketplaceFetchFailureKind,
168
+ detail: string,
169
+ httpStatus?: number,
170
+ retryAt?: number,
168
171
  ): MarketplaceFetchResult {
169
- const failure: MarketplaceFetchFailure = {
170
- marketplace,
171
- kind,
172
- detail,
173
- httpStatus,
174
- retryAt,
175
- };
176
- failureCache.set(marketplace, { failure, at: now() });
177
- return { plugins: [], failure };
172
+ const failure: MarketplaceFetchFailure = {
173
+ marketplace,
174
+ kind,
175
+ detail,
176
+ httpStatus,
177
+ retryAt,
178
+ };
179
+ failureCache.set(marketplace, { failure, at: now() });
180
+ return { plugins: [], failure };
178
181
  }
179
182
 
180
183
  /**
@@ -185,19 +188,19 @@ function fail(
185
188
  * right now", the second is usually "this machine has no route at all".
186
189
  */
187
190
  function classifyThrown(
188
- marketplace: string,
189
- error: unknown,
191
+ marketplace: string,
192
+ error: unknown,
190
193
  ): MarketplaceFetchResult {
191
- const name = (error as { name?: string } | null)?.name;
192
- if (name === "TimeoutError" || name === "AbortError") {
193
- return fail(
194
- marketplace,
195
- "timeout",
196
- `no response in ${FETCH_TIMEOUT_MS / 1000}s`,
197
- );
198
- }
199
- const message = error instanceof Error ? error.message : String(error);
200
- return fail(marketplace, "network", message);
194
+ const name = (error as { name?: string } | null)?.name;
195
+ if (name === "TimeoutError" || name === "AbortError") {
196
+ return fail(
197
+ marketplace,
198
+ "timeout",
199
+ `no response in ${FETCH_TIMEOUT_MS / 1000}s`,
200
+ );
201
+ }
202
+ const message = error instanceof Error ? error.message : String(error);
203
+ return fail(marketplace, "network", message);
201
204
  }
202
205
 
203
206
  const FETCH_TIMEOUT_MS = 10_000;
@@ -213,110 +216,113 @@ const FETCH_TIMEOUT_MS = 10_000;
213
216
  * one. A failure must be a value the caller has to look at.
214
217
  */
215
218
  export async function fetchMarketplacePlugins(
216
- marketplaceName: string,
217
- repo: string,
219
+ marketplaceName: string,
220
+ repo: string,
218
221
  ): Promise<MarketplaceFetchResult> {
219
- // Check cache first - session-level, no TTL
220
- const cached = marketplaceCache.get(marketplaceName);
221
- if (cached) {
222
- return { plugins: cached };
223
- }
224
-
225
- // A fresh failure is reused rather than re-attempted: see `failureCache`.
226
- const priorFailure = failureCache.get(marketplaceName);
227
- if (priorFailure && now() - priorFailure.at < NEGATIVE_TTL_MS) {
228
- return { plugins: [], failure: priorFailure.failure };
229
- }
230
-
231
- // Validate repo format to prevent SSRF
232
- if (!isValidGitHubRepo(repo)) {
233
- return fail(
234
- marketplaceName,
235
- "invalid-repo",
236
- `not a valid GitHub repo: "${repo}"`,
237
- );
238
- }
239
-
240
- const host: GitHubHost = "raw.githubusercontent.com";
241
-
242
- // Cooldowns recorded by a PREVIOUS launch count too. Without this the gate is
243
- // per-process, and a TUI's process lives for one screen session.
244
- await hydrateGitHubBudget();
245
-
246
- // Already known to be rate-limited: do not spend an attempt to rediscover it.
247
- // The budget is per-host and shared with every other service, so six
248
- // marketplaces used to trip the same limit six times over — and on some
249
- // endpoints a rejected request extends the penalty.
250
- const cooling = cooldownFor(host);
251
- if (cooling) {
252
- return fail(
253
- marketplaceName,
254
- "rate-limited",
255
- describeCooldown(cooling),
256
- 429,
257
- cooling.until,
258
- );
259
- }
260
-
261
- try {
262
- // Fetch marketplace.json from GitHub
263
- const url = `https://raw.githubusercontent.com/${repo}/main/.claude-plugin/marketplace.json`;
264
- const response = await fetch(url, {
265
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
266
- });
267
-
268
- if (!response.ok) {
269
- // 429/403 are the rate-limit shapes GitHub returns for unauthenticated
270
- // traffic. Called out separately because the fix is "wait, or
271
- // authenticate", not "check your network".
272
- const rateLimited = response.status === 429 || response.status === 403;
273
- if (rateLimited) {
274
- // Hand the response headers over: api.github.com states when the window
275
- // reopens, raw.githubusercontent.com states nothing, and the budget
276
- // module is what knows the difference.
277
- const cooldown = recordRateLimit(host, response.headers);
278
- return fail(
279
- marketplaceName,
280
- "rate-limited",
281
- describeCooldown(cooldown),
282
- response.status,
283
- cooldown.until,
284
- );
285
- }
286
- return fail(
287
- marketplaceName,
288
- "http",
289
- `HTTP ${response.status} ${response.statusText}`.trim(),
290
- response.status,
291
- );
292
- }
293
-
294
- // Validate content-type
295
- const contentType = response.headers.get("content-type");
296
- if (
297
- contentType &&
298
- !contentType.includes("application/json") &&
299
- !contentType.includes("text/plain")
300
- ) {
301
- return fail(
302
- marketplaceName,
303
- "invalid-content",
304
- `unexpected content-type "${contentType}"`,
305
- );
306
- }
307
-
308
- const plugins = normalizeCatalogJson(marketplaceName, await response.json());
309
-
310
- // Cache the result (session-level)
311
- marketplaceCache.set(marketplaceName, plugins);
312
- // A success retires any recorded failure, so the banner clears itself.
313
- failureCache.delete(marketplaceName);
314
- recordSuccess(host);
315
-
316
- return { plugins };
317
- } catch (error) {
318
- return classifyThrown(marketplaceName, error);
319
- }
222
+ // Check cache first - session-level, no TTL
223
+ const cached = marketplaceCache.get(marketplaceName);
224
+ if (cached) {
225
+ return { plugins: cached };
226
+ }
227
+
228
+ // A fresh failure is reused rather than re-attempted: see `failureCache`.
229
+ const priorFailure = failureCache.get(marketplaceName);
230
+ if (priorFailure && now() - priorFailure.at < NEGATIVE_TTL_MS) {
231
+ return { plugins: [], failure: priorFailure.failure };
232
+ }
233
+
234
+ // Validate repo format to prevent SSRF
235
+ if (!isValidGitHubRepo(repo)) {
236
+ return fail(
237
+ marketplaceName,
238
+ "invalid-repo",
239
+ `not a valid GitHub repo: "${repo}"`,
240
+ );
241
+ }
242
+
243
+ const host: GitHubHost = "raw.githubusercontent.com";
244
+
245
+ // Cooldowns recorded by a PREVIOUS launch count too. Without this the gate is
246
+ // per-process, and a TUI's process lives for one screen session.
247
+ await hydrateGitHubBudget();
248
+
249
+ // Already known to be rate-limited: do not spend an attempt to rediscover it.
250
+ // The budget is per-host and shared with every other service, so six
251
+ // marketplaces used to trip the same limit six times over — and on some
252
+ // endpoints a rejected request extends the penalty.
253
+ const cooling = cooldownFor(host);
254
+ if (cooling) {
255
+ return fail(
256
+ marketplaceName,
257
+ "rate-limited",
258
+ describeCooldown(cooling),
259
+ 429,
260
+ cooling.until,
261
+ );
262
+ }
263
+
264
+ try {
265
+ // Fetch marketplace.json from GitHub
266
+ const url = `https://raw.githubusercontent.com/${repo}/main/.claude-plugin/marketplace.json`;
267
+ const response = await fetch(url, {
268
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
269
+ });
270
+
271
+ if (!response.ok) {
272
+ // 429/403 are the rate-limit shapes GitHub returns for unauthenticated
273
+ // traffic. Called out separately because the fix is "wait, or
274
+ // authenticate", not "check your network".
275
+ const rateLimited = response.status === 429 || response.status === 403;
276
+ if (rateLimited) {
277
+ // Hand the response headers over: api.github.com states when the window
278
+ // reopens, raw.githubusercontent.com states nothing, and the budget
279
+ // module is what knows the difference.
280
+ const cooldown = recordRateLimit(host, response.headers);
281
+ return fail(
282
+ marketplaceName,
283
+ "rate-limited",
284
+ describeCooldown(cooldown),
285
+ response.status,
286
+ cooldown.until,
287
+ );
288
+ }
289
+ return fail(
290
+ marketplaceName,
291
+ "http",
292
+ `HTTP ${response.status} ${response.statusText}`.trim(),
293
+ response.status,
294
+ );
295
+ }
296
+
297
+ // Validate content-type
298
+ const contentType = response.headers.get("content-type");
299
+ if (
300
+ contentType &&
301
+ !contentType.includes("application/json") &&
302
+ !contentType.includes("text/plain")
303
+ ) {
304
+ return fail(
305
+ marketplaceName,
306
+ "invalid-content",
307
+ `unexpected content-type "${contentType}"`,
308
+ );
309
+ }
310
+
311
+ const plugins = normalizeCatalogJson(
312
+ marketplaceName,
313
+ await response.json(),
314
+ );
315
+
316
+ // Cache the result (session-level)
317
+ marketplaceCache.set(marketplaceName, plugins);
318
+ // A success retires any recorded failure, so the banner clears itself.
319
+ failureCache.delete(marketplaceName);
320
+ recordSuccess(host);
321
+
322
+ return { plugins };
323
+ } catch (error) {
324
+ return classifyThrown(marketplaceName, error);
325
+ }
320
326
  }
321
327
 
322
328
  /**
@@ -341,54 +347,54 @@ export async function fetchMarketplacePlugins(
341
347
  * reporting a guess as a fact.
342
348
  */
343
349
  export async function resolveMarketplacePlugins(
344
- mpName: string,
345
- repo: string,
346
- localMarketplaces: Map<string, LocalMarketplace>,
350
+ mpName: string,
351
+ repo: string,
352
+ localMarketplaces: Map<string, LocalMarketplace>,
347
353
  ): Promise<MarketplaceResolution> {
348
- // Disk cache first, so a relaunch costs no network at all. Measured before this
349
- // existed: every process spent six requests and ~13s re-fetching catalogs it had
350
- // already read minutes earlier.
351
- const cached = await readCachedCatalog(mpName);
352
- if (cached) {
353
- return {
354
- plugins: cached.plugins,
355
- source: cached.source as CatalogSource,
356
- };
357
- }
358
-
359
- const remote = await fetchMarketplacePlugins(mpName, repo);
360
- if (remote.plugins.length > 0) {
361
- void writeCachedCatalog(mpName, remote.plugins, "remote");
362
- return { plugins: remote.plugins, source: "remote" };
363
- }
364
-
365
- // HTTP failed — most often rate-limited. Before falling back to the pinned
366
- // working tree, ask git for upstream's catalog. `git fetch` moves only remote
367
- // refs, so this respects `autoUpdate: false` (the install source stays pinned)
368
- // while still answering "what version exists upstream". It is also on a
369
- // different transport, so a raw.githubusercontent.com rate limit does not
370
- // affect it.
371
- const viaGit = await readCatalogFromRemote(mpName);
372
- if (viaGit && viaGit.length > 0) {
373
- void writeCachedCatalog(mpName, viaGit, "remote-git");
374
- return { plugins: viaGit, source: "remote-git", failure: remote.failure };
375
- }
376
-
377
- const local = localMarketplaces.get(mpName);
378
- if (!local || local.plugins.length === 0) {
379
- return { plugins: [], source: "none", failure: remote.failure };
380
- }
381
-
382
- return {
383
- plugins: local.plugins.map((p) => ({
384
- name: p.name,
385
- version: p.version,
386
- description: p.description,
387
- category: p.category,
388
- author: p.author,
389
- releases: p.releases,
390
- })),
391
- source: "local-clone",
392
- failure: remote.failure,
393
- };
354
+ // Disk cache first, so a relaunch costs no network at all. Measured before this
355
+ // existed: every process spent six requests and ~13s re-fetching catalogs it had
356
+ // already read minutes earlier.
357
+ const cached = await readCachedCatalog(mpName);
358
+ if (cached) {
359
+ return {
360
+ plugins: cached.plugins,
361
+ source: cached.source as CatalogSource,
362
+ };
363
+ }
364
+
365
+ const remote = await fetchMarketplacePlugins(mpName, repo);
366
+ if (remote.plugins.length > 0) {
367
+ void writeCachedCatalog(mpName, remote.plugins, "remote");
368
+ return { plugins: remote.plugins, source: "remote" };
369
+ }
370
+
371
+ // HTTP failed — most often rate-limited. Before falling back to the pinned
372
+ // working tree, ask git for upstream's catalog. `git fetch` moves only remote
373
+ // refs, so this respects `autoUpdate: false` (the install source stays pinned)
374
+ // while still answering "what version exists upstream". It is also on a
375
+ // different transport, so a raw.githubusercontent.com rate limit does not
376
+ // affect it.
377
+ const viaGit = await readCatalogFromRemote(mpName);
378
+ if (viaGit && viaGit.length > 0) {
379
+ void writeCachedCatalog(mpName, viaGit, "remote-git");
380
+ return { plugins: viaGit, source: "remote-git", failure: remote.failure };
381
+ }
382
+
383
+ const local = localMarketplaces.get(mpName);
384
+ if (!local || local.plugins.length === 0) {
385
+ return { plugins: [], source: "none", failure: remote.failure };
386
+ }
387
+
388
+ return {
389
+ plugins: local.plugins.map((p) => ({
390
+ name: p.name,
391
+ version: p.version,
392
+ description: p.description,
393
+ category: p.category,
394
+ author: p.author,
395
+ releases: p.releases,
396
+ })),
397
+ source: "local-clone",
398
+ failure: remote.failure,
399
+ };
394
400
  }