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,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
+ }
@@ -7,73 +7,288 @@
7
7
  * imported via plugin-manager.ts).
8
8
  */
9
9
 
10
- import type { PluginRelease } from "../types/index.js";
11
10
  import { isValidGitHubRepo } from "../utils/string-utils.js";
11
+ import {
12
+ clearCachedCatalogs,
13
+ readCachedCatalog,
14
+ writeCachedCatalog,
15
+ } from "./catalog-cache-store.js";
16
+ import {
17
+ cooldownFor,
18
+ describeCooldown,
19
+ hydrateGitHubBudget,
20
+ recordRateLimit,
21
+ recordSuccess,
22
+ type GitHubHost,
23
+ } from "./github-budget.js";
12
24
  import type { LocalMarketplace } from "./local-marketplace.js";
13
- import { normalizeReleases } from "./plugin-releases.js";
14
-
15
- export interface MarketplacePlugin {
16
- name: string;
17
- version: string | null;
18
- description: string;
19
- category?: string;
20
- author?: { name: string; email?: string };
21
- homepage?: string;
22
- tags?: string[];
23
- releases?: PluginRelease[];
24
- }
25
+ import {
26
+ clearGitCatalogCache,
27
+ readCatalogFromRemote,
28
+ } from "./marketplace-catalog-git.js";
29
+ import {
30
+ clearMarketplaceVersionCache,
31
+ normalizeCatalogJson,
32
+ type MarketplacePlugin,
33
+ } from "./marketplace-catalog.js";
34
+
35
+ // Re-exported: these used to live here, and plugin-manager plus several screens
36
+ // import them from this module.
37
+ export {
38
+ getMarketplaceVersion,
39
+ normalizeCatalogJson,
40
+ type MarketplacePlugin,
41
+ } from "./marketplace-catalog.js";
25
42
 
26
43
  // Session-level cache for fetched marketplace data (no TTL - persists until explicit refresh)
27
44
  const marketplaceCache = new Map<string, MarketplacePlugin[]>();
28
45
 
29
- // Session-level cache for each marketplace's declared version (from
30
- // marketplace.json's top-level `metadata.version`). Mirrors `marketplaceCache`
31
- // so the TUI can surface a marketplace's version without re-fetching. Only
32
- // populated for marketplaces that actually declare a version.
33
- const marketplaceVersionCache = new Map<string, string>();
46
+ /** Why a catalog fetch did not produce an authoritative plugin list. */
47
+ export type MarketplaceFetchFailureKind =
48
+ | "rate-limited"
49
+ | "http"
50
+ | "timeout"
51
+ | "network"
52
+ | "invalid-repo"
53
+ | "invalid-content";
54
+
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;
67
+ }
68
+
69
+ export interface MarketplaceFetchResult {
70
+ plugins: MarketplacePlugin[];
71
+ /** Present exactly when `plugins` is not an authoritative remote answer. */
72
+ failure?: MarketplaceFetchFailure;
73
+ }
74
+
75
+ /**
76
+ * Where a resolved plugin list actually came from.
77
+ *
78
+ * `remote` raw.githubusercontent.com — authoritative.
79
+ * `remote-git` the clone's `origin/<branch>` ref, refreshed by `git fetch` —
80
+ * also authoritative: it IS upstream's file. Reached when HTTP is
81
+ * rate-limited, and it draws on neither GitHub HTTP budget.
82
+ * `local-clone` the checked-out working tree — usable but NOT authoritative. It
83
+ * is deliberately pinned (`autoUpdate: false`), so its versions can
84
+ * be arbitrarily old.
85
+ * `none` nothing could be read.
86
+ */
87
+ export type CatalogSource = "remote" | "remote-git" | "local-clone" | "none";
88
+
89
+ /** Sources whose versions may be reported to the user as current. */
90
+ export function isAuthoritative(source: CatalogSource): boolean {
91
+ return source === "remote" || source === "remote-git";
92
+ }
93
+
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;
102
+ }
103
+
104
+ /**
105
+ * Failures are remembered, not just returned.
106
+ *
107
+ * Two reasons. First, so the TUI can render "4 of 6 catalogs unreachable"
108
+ * without every caller threading a result type through. Second, so a failure is
109
+ * cached like a success: an un-cached failure was re-attempted on every screen
110
+ * mount, and with a 10s timeout per marketplace that alone cost 30-45s of
111
+ * "Loading..." each time the user switched tabs and came back.
112
+ *
113
+ * The TTL is short and an explicit refresh clears it, so a transient blip never
114
+ * freezes the catalog for a whole session — which would be the same
115
+ * silent-staleness trap this module exists to close.
116
+ */
117
+ const failureCache = new Map<string, { failure: MarketplaceFetchFailure; at: number }>();
118
+ const NEGATIVE_TTL_MS = 60_000;
119
+
120
+ /** Monotonic clock, so a system clock change cannot expire the cache early. */
121
+ function now(): number {
122
+ return performance.now();
123
+ }
34
124
 
35
125
  export function clearMarketplaceCache(): void {
36
126
  marketplaceCache.clear();
37
- marketplaceVersionCache.clear();
127
+ clearMarketplaceVersionCache();
128
+ clearGitCatalogCache();
129
+ failureCache.clear();
38
130
  }
39
131
 
40
132
  /**
41
- * Returns the version a marketplace declared in its `metadata.version`, if one
42
- * was seen during a successful fetch this session. Returns `undefined` for
43
- * marketplaces that declare no version (or haven't been fetched yet).
133
+ * Drop the on-disk catalog cache as well.
134
+ *
135
+ * Separate and async because it must be AWAITED before the refetch that follows
136
+ * it: fire-and-forget would race the next `resolveMarketplacePlugins`, which would
137
+ * then read the cache it was told to discard and make `r` a no-op.
138
+ *
139
+ * Deliberately does NOT clear the host cooldown. That records GitHub's state, not
140
+ * ours, and retrying inside a known window cannot succeed — the screen counts down
141
+ * and refetches by itself when the window opens.
44
142
  */
45
- export function getMarketplaceVersion(
46
- marketplaceName: string,
47
- ): string | undefined {
48
- return marketplaceVersionCache.get(marketplaceName);
143
+ export async function clearPersistedCatalogs(): Promise<void> {
144
+ await clearCachedCatalogs();
145
+ }
146
+
147
+ /**
148
+ * Every marketplace whose catalog could not be fetched, freshest answer first.
149
+ *
150
+ * A caller that renders plugin versions MUST consult this. An empty list is the
151
+ * only proof that the versions on screen were actually checked against GitHub.
152
+ */
153
+ 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;
159
+ }
160
+
161
+ /** Record a failure and return it as the result, so callers cannot ignore it. */
162
+ function fail(
163
+ marketplace: string,
164
+ kind: MarketplaceFetchFailureKind,
165
+ detail: string,
166
+ httpStatus?: number,
167
+ retryAt?: number,
168
+ ): 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 };
178
+ }
179
+
180
+ /**
181
+ * Turn a thrown fetch error into a classified failure.
182
+ *
183
+ * A timeout is distinguished from an outright network error because they mean
184
+ * different things to the user: the first is "GitHub is slow or unreachable
185
+ * right now", the second is usually "this machine has no route at all".
186
+ */
187
+ function classifyThrown(
188
+ marketplace: string,
189
+ error: unknown,
190
+ ): 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);
49
201
  }
50
202
 
203
+ const FETCH_TIMEOUT_MS = 10_000;
204
+
205
+ /**
206
+ * Fetch a marketplace catalog from GitHub.
207
+ *
208
+ * Returns a result, never a bare list, because "the fetch failed" and "this
209
+ * marketplace has no plugins" used to be the same value (`[]`) and the caller
210
+ * could not tell them apart. That single conflation is what let a rate-limited
211
+ * request render as "you are up to date": the empty list fell through to the
212
+ * on-disk clone, and a stale clone's version compared equal to the installed
213
+ * one. A failure must be a value the caller has to look at.
214
+ */
51
215
  export async function fetchMarketplacePlugins(
52
216
  marketplaceName: string,
53
217
  repo: string,
54
- ): Promise<MarketplacePlugin[]> {
218
+ ): Promise<MarketplaceFetchResult> {
55
219
  // Check cache first - session-level, no TTL
56
220
  const cached = marketplaceCache.get(marketplaceName);
57
221
  if (cached) {
58
- return 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 };
59
229
  }
60
230
 
61
231
  // Validate repo format to prevent SSRF
62
232
  if (!isValidGitHubRepo(repo)) {
63
- console.error(`Invalid GitHub repo format: ${repo}`);
64
- return [];
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
+ );
65
259
  }
66
260
 
67
261
  try {
68
262
  // Fetch marketplace.json from GitHub
69
263
  const url = `https://raw.githubusercontent.com/${repo}/main/.claude-plugin/marketplace.json`;
70
264
  const response = await fetch(url, {
71
- signal: AbortSignal.timeout(10000), // 10s timeout
265
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
72
266
  });
73
267
 
74
268
  if (!response.ok) {
75
- console.error(`Failed to fetch marketplace: ${response.status}`);
76
- return [];
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
+ );
77
292
  }
78
293
 
79
294
  // Validate content-type
@@ -83,55 +298,24 @@ export async function fetchMarketplacePlugins(
83
298
  !contentType.includes("application/json") &&
84
299
  !contentType.includes("text/plain")
85
300
  ) {
86
- console.error(`Invalid content-type for marketplace: ${contentType}`);
87
- return [];
301
+ return fail(
302
+ marketplaceName,
303
+ "invalid-content",
304
+ `unexpected content-type "${contentType}"`,
305
+ );
88
306
  }
89
307
 
90
- interface RawPlugin {
91
- name: string;
92
- version?: string | null;
93
- description?: string;
94
- category?: string;
95
- author?: { name: string; email?: string };
96
- homepage?: string;
97
- tags?: string[];
98
- releases?: unknown;
99
- }
100
- const data = (await response.json()) as {
101
- plugins?: RawPlugin[];
102
- metadata?: { version?: string };
103
- };
104
- const plugins: MarketplacePlugin[] = [];
105
-
106
- // Capture the marketplace's declared version (if any) so the TUI can
107
- // display it. Absent/blank versions are simply not cached.
108
- const version = data.metadata?.version;
109
- if (typeof version === "string" && version.length > 0) {
110
- marketplaceVersionCache.set(marketplaceName, version);
111
- }
112
-
113
- if (data.plugins && Array.isArray(data.plugins)) {
114
- for (const plugin of data.plugins) {
115
- plugins.push({
116
- name: plugin.name,
117
- version: plugin.version || null,
118
- description: plugin.description || "",
119
- category: plugin.category,
120
- author: plugin.author,
121
- homepage: plugin.homepage,
122
- tags: plugin.tags,
123
- releases: normalizeReleases(plugin.releases),
124
- });
125
- }
126
- }
308
+ const plugins = normalizeCatalogJson(marketplaceName, await response.json());
127
309
 
128
310
  // Cache the result (session-level)
129
311
  marketplaceCache.set(marketplaceName, plugins);
312
+ // A success retires any recorded failure, so the banner clears itself.
313
+ failureCache.delete(marketplaceName);
314
+ recordSuccess(host);
130
315
 
131
- return plugins;
316
+ return { plugins };
132
317
  } catch (error) {
133
- console.error(`Error fetching marketplace ${marketplaceName}:`, error);
134
- return [];
318
+ return classifyThrown(marketplaceName, error);
135
319
  }
136
320
  }
137
321
 
@@ -148,24 +332,63 @@ export async function fetchMarketplacePlugins(
148
332
  * The two shapes are nearly identical; we drop a couple of LocalMarketplacePlugin
149
333
  * fields (lspServers, agents, etc.) that MarketplacePlugin doesn't carry
150
334
  * since the caller doesn't read them at this layer.
335
+ *
336
+ * The fallback stays — being offline should not make every plugin read as
337
+ * deprecated — but the return value now says WHICH source answered. The clone is
338
+ * a usable catalog, not an authoritative one: nothing refreshes it when a
339
+ * marketplace carries `autoUpdate: false`, so its versions can be arbitrarily
340
+ * old. A caller that reports "up to date" off a `local-clone` answer is
341
+ * reporting a guess as a fact.
151
342
  */
152
343
  export async function resolveMarketplacePlugins(
153
344
  mpName: string,
154
345
  repo: string,
155
346
  localMarketplaces: Map<string, LocalMarketplace>,
156
- ): Promise<MarketplacePlugin[]> {
347
+ ): 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
+
157
359
  const remote = await fetchMarketplacePlugins(mpName, repo);
158
- if (remote.length > 0) return remote;
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
+ }
159
376
 
160
377
  const local = localMarketplaces.get(mpName);
161
- if (!local || local.plugins.length === 0) return remote;
162
-
163
- return local.plugins.map((p) => ({
164
- name: p.name,
165
- version: p.version,
166
- description: p.description,
167
- category: p.category,
168
- author: p.author,
169
- releases: p.releases,
170
- }));
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
+ };
171
394
  }