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.
@@ -1,9 +1,8 @@
1
1
  import path from "node:path";
2
- import os from "node:os";
3
- import { execSync } from "node:child_process";
4
2
  import {
5
3
  getConfiguredMarketplaces,
6
4
  getEnabledPlugins,
5
+ getGlobalClaudeDir,
7
6
  readSettings,
8
7
  writeSettings,
9
8
  getGlobalConfiguredMarketplaces,
@@ -37,13 +36,22 @@ import {
37
36
  } from "../utils/string-utils.js";
38
37
  import {
39
38
  clearMarketplaceCache as clearFetcherCache,
39
+ clearPersistedCatalogs,
40
40
  fetchMarketplacePlugins,
41
+ getMarketplaceFetchFailures,
42
+ isAuthoritative,
41
43
  resolveMarketplacePlugins,
44
+ type CatalogSource,
45
+ type MarketplaceFetchFailure,
42
46
  type MarketplacePlugin,
43
47
  } from "./marketplace-fetcher.js";
44
48
  export {
45
49
  fetchMarketplacePlugins,
50
+ getMarketplaceFetchFailures,
51
+ isAuthoritative,
46
52
  resolveMarketplacePlugins,
53
+ type CatalogSource,
54
+ type MarketplaceFetchFailure,
47
55
  type MarketplacePlugin,
48
56
  };
49
57
  // Cache for local marketplaces (session-level) - Promise-based to prevent race conditions
@@ -110,6 +118,29 @@ export interface PluginInfo {
110
118
  * Never set on the very first run, when there is no baseline to compare to.
111
119
  */
112
120
  recentlyInstalled?: boolean;
121
+ /**
122
+ * Which catalog supplied `version` — i.e. the number this plugin's installed
123
+ * version was compared against.
124
+ *
125
+ * `remote` (HTTP) and `remote-git` (a `git fetch`ed remote ref) are both
126
+ * authoritative — each is upstream's own catalog file. `local-clone` is the
127
+ * checked-out working tree, which is deliberately pinned by
128
+ * `autoUpdate: false`, so its versions can be arbitrarily old.
129
+ */
130
+ catalogSource?: CatalogSource;
131
+ /**
132
+ * Set when `hasUpdate: false` could not actually be verified — the catalog
133
+ * fetch failed and the comparison ran against the pinned clone.
134
+ *
135
+ * This exists because the absence of an update was indistinguishable from a
136
+ * successful check. A rate-limited fetch returned `[]`, the stale clone filled
137
+ * in, its version compared equal to the installed one, and the UI reported
138
+ * "up to date" — the failure rendered as a clean bill of health. Anything that
139
+ * tells the user they are current must check this flag first.
140
+ */
141
+ updateCheckFailed?: boolean;
142
+ /** Why the check failed, for display. Set with `updateCheckFailed`. */
143
+ updateCheckFailure?: MarketplaceFetchFailure;
113
144
  }
114
145
 
115
146
  /**
@@ -241,16 +272,30 @@ export async function getAvailablePlugins(
241
272
  // Fetch local marketplace caches up front so we can detect stale caches
242
273
  let localMarketplaces = await getLocalMarketplaces();
243
274
 
244
- // Fetch plugins from each configured marketplace
245
- for (const mpName of marketplaceNames) {
246
- const marketplace = defaultMarketplaces.find((m) => m.name === mpName);
247
- if (!marketplace) continue;
275
+ // Resolve every marketplace concurrently. These are independent HTTP calls
276
+ // with a 10s timeout each; awaited in sequence, one unreachable marketplace
277
+ // added its full timeout to the "Loading..." the user stares at. Measured on
278
+ // six marketplaces with four unreachable: 44.9s sequential.
279
+ const resolutions = await Promise.all(
280
+ [...marketplaceNames].map(async (mpName) => {
281
+ const marketplace = defaultMarketplaces.find((m) => m.name === mpName);
282
+ if (!marketplace) return null;
283
+ return {
284
+ mpName,
285
+ marketplace,
286
+ resolution: await resolveMarketplacePlugins(
287
+ mpName,
288
+ marketplace.source.repo,
289
+ localMarketplaces,
290
+ ),
291
+ };
292
+ }),
293
+ );
248
294
 
249
- const marketplacePlugins = await resolveMarketplacePlugins(
250
- mpName,
251
- marketplace.source.repo,
252
- localMarketplaces,
253
- );
295
+ for (const entry of resolutions) {
296
+ if (!entry) continue;
297
+ const { mpName, marketplace, resolution } = entry;
298
+ const marketplacePlugins = resolution.plugins;
254
299
 
255
300
  // Auto-sync local cache if remote has plugins the local cache doesn't
256
301
  localMarketplaces = await autoSyncIfStale(
@@ -259,6 +304,13 @@ export async function getAvailablePlugins(
259
304
  localMarketplaces,
260
305
  );
261
306
 
307
+ // The catalog that answered was not the authoritative one, so every
308
+ // version compare below is a guess, not a check.
309
+ // `remote-git` counts as verified: it is upstream's own catalog file, read
310
+ // through a git remote ref rather than over HTTP. Only the pinned working
311
+ // tree (`local-clone`) is untrustworthy for versions.
312
+ const unverified = !isAuthoritative(resolution.source);
313
+
262
314
  for (const plugin of marketplacePlugins) {
263
315
  const pluginId = `${plugin.name}@${mpName}`;
264
316
  const installedVersion = installedVersions[pluginId];
@@ -279,6 +331,9 @@ export async function getAvailablePlugins(
279
331
  installedVersion && plugin.version
280
332
  ? compareVersions(plugin.version, installedVersion) > 0
281
333
  : false,
334
+ catalogSource: resolution.source,
335
+ updateCheckFailed: unverified,
336
+ updateCheckFailure: resolution.failure,
282
337
  ...scopeStatus,
283
338
  category: plugin.category,
284
339
  author: plugin.author,
@@ -438,16 +493,30 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
438
493
  // Fetch local marketplace caches up front so we can detect stale caches
439
494
  let localMarketplaces = await getLocalMarketplaces();
440
495
 
441
- // Fetch plugins from each configured marketplace
442
- for (const mpName of marketplaceNames) {
443
- const marketplace = defaultMarketplaces.find((m) => m.name === mpName);
444
- if (!marketplace) continue;
496
+ // Resolve every marketplace concurrently. These are independent HTTP calls
497
+ // with a 10s timeout each; awaited in sequence, one unreachable marketplace
498
+ // added its full timeout to the "Loading..." the user stares at. Measured on
499
+ // six marketplaces with four unreachable: 44.9s sequential.
500
+ const resolutions = await Promise.all(
501
+ [...marketplaceNames].map(async (mpName) => {
502
+ const marketplace = defaultMarketplaces.find((m) => m.name === mpName);
503
+ if (!marketplace) return null;
504
+ return {
505
+ mpName,
506
+ marketplace,
507
+ resolution: await resolveMarketplacePlugins(
508
+ mpName,
509
+ marketplace.source.repo,
510
+ localMarketplaces,
511
+ ),
512
+ };
513
+ }),
514
+ );
445
515
 
446
- const marketplacePlugins = await resolveMarketplacePlugins(
447
- mpName,
448
- marketplace.source.repo,
449
- localMarketplaces,
450
- );
516
+ for (const entry of resolutions) {
517
+ if (!entry) continue;
518
+ const { mpName, marketplace, resolution } = entry;
519
+ const marketplacePlugins = resolution.plugins;
451
520
 
452
521
  // Auto-sync local cache if remote has plugins the local cache doesn't
453
522
  localMarketplaces = await autoSyncIfStale(
@@ -456,6 +525,13 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
456
525
  localMarketplaces,
457
526
  );
458
527
 
528
+ // The catalog that answered was not the authoritative one, so every
529
+ // version compare below is a guess, not a check.
530
+ // `remote-git` counts as verified: it is upstream's own catalog file, read
531
+ // through a git remote ref rather than over HTTP. Only the pinned working
532
+ // tree (`local-clone`) is untrustworthy for versions.
533
+ const unverified = !isAuthoritative(resolution.source);
534
+
459
535
  for (const plugin of marketplacePlugins) {
460
536
  const pluginId = `${plugin.name}@${mpName}`;
461
537
  const installedVersion = installedVersions[pluginId];
@@ -476,6 +552,9 @@ export async function getGlobalAvailablePlugins(): Promise<PluginInfo[]> {
476
552
  installedVersion && plugin.version
477
553
  ? compareVersions(plugin.version, installedVersion) > 0
478
554
  : false,
555
+ catalogSource: resolution.source,
556
+ updateCheckFailed: unverified,
557
+ updateCheckFailure: resolution.failure,
479
558
  ...scopeStatus,
480
559
  category: plugin.category,
481
560
  author: plugin.author,
@@ -798,8 +877,11 @@ export async function refreshAllMarketplaces(
798
877
  ): Promise<RefreshAndRepairResult> {
799
878
  onProgress?.({ current: 1, total: 1, name: "Clearing cache..." });
800
879
 
801
- // Clear all caches to force fresh data
880
+ // Clear all caches to force fresh data. The on-disk catalog cache is awaited
881
+ // rather than fired and forgotten, so the refetch that follows cannot read the
882
+ // entries this is discarding.
802
883
  clearMarketplaceCache();
884
+ await clearPersistedCatalogs();
803
885
 
804
886
  // Auto-repair plugin.json files with missing agents/commands/skills
805
887
  const repairResults = await repairAllMarketplaces();
@@ -810,76 +892,5 @@ export async function refreshAllMarketplaces(
810
892
  };
811
893
  }
812
894
 
813
- export interface MarketplaceUpdateInfo {
814
- name: string;
815
- hasUpdate: boolean;
816
- latestCommit?: string;
817
- currentCommit?: string;
818
- }
819
-
820
- /**
821
- * Check if marketplaces have updates available on GitHub
822
- * Compares local git HEAD with remote HEAD
823
- */
824
- export async function checkMarketplaceUpdates(): Promise<
825
- MarketplaceUpdateInfo[]
826
- > {
827
- const results: MarketplaceUpdateInfo[] = [];
828
- const localMarketplaces = await getLocalMarketplaces();
829
-
830
- for (const [name, marketplace] of localMarketplaces) {
831
- if (!marketplace.gitRepo) continue;
832
-
833
- try {
834
- const marketplacePath = path.join(
835
- os.homedir(),
836
- ".claude",
837
- "plugins",
838
- "marketplaces",
839
- name,
840
- );
841
-
842
- // Get current HEAD from local repo
843
- let currentHead: string;
844
- try {
845
- currentHead = execSync("git rev-parse HEAD", {
846
- cwd: marketplacePath,
847
- encoding: "utf-8",
848
- timeout: 5000,
849
- }).trim();
850
- } catch {
851
- continue; // Skip if can't get HEAD
852
- }
853
-
854
- // Fetch latest commit from GitHub API
855
- const apiUrl = `https://api.github.com/repos/${marketplace.gitRepo}/commits/main`;
856
- const response = await fetch(apiUrl, {
857
- signal: AbortSignal.timeout(10000),
858
- headers: {
859
- Accept: "application/vnd.github.v3+json",
860
- },
861
- });
862
-
863
- if (response.ok) {
864
- const data = (await response.json()) as { sha: string };
865
- const latestCommit = data.sha;
866
-
867
- results.push({
868
- name,
869
- hasUpdate: currentHead !== latestCommit,
870
- currentCommit: currentHead.substring(0, 7),
871
- latestCommit: latestCommit.substring(0, 7),
872
- });
873
- } else {
874
- results.push({ name, hasUpdate: false });
875
- }
876
- } catch {
877
- results.push({ name, hasUpdate: false });
878
- }
879
- }
880
-
881
- return results;
882
- }
883
-
884
895
  // Re-export types for consumers
885
896
  export type { ProgressCallback };
@@ -0,0 +1,122 @@
1
+ /**
2
+ * catalogNotice.ts — the text of the Plugins screen's warning line.
3
+ *
4
+ * Extracted from PluginsScreen for the same reason as pluginsAdapter: so it can
5
+ * be tested without importing a React/OpenTUI screen. That is not merely tidier —
6
+ * importing the screen into a test pulls the renderer into the shared test process
7
+ * and broke fifteen unrelated tests.
8
+ *
9
+ * What this does NOT warn about, deliberately
10
+ * -------------------------------------------
11
+ * `autoUpdate: false` and a marketplace clone sitting behind its remote are both
12
+ * NORMAL here. Plugins are not auto-updated by policy; the UI's job is to show
13
+ * that an update exists, not to advance the install source. An earlier version of
14
+ * this banner reported both as problems and suggested
15
+ * `claude plugin marketplace update`, which would have defeated the policy it was
16
+ * complaining about. A warning that fires on intended behaviour trains the user to
17
+ * ignore the line.
18
+ *
19
+ * The one thing worth warning about is an update check that did not happen —
20
+ * because the alternative is claiming a plugin is current when nobody looked.
21
+ */
22
+
23
+ import { formatWait } from "../../services/github-budget.js";
24
+ import type { MarketplaceFetchFailure } from "../../services/marketplace-fetcher.js";
25
+
26
+ export interface CatalogNoticeInput {
27
+ /** Catalogs that could not be read from any authoritative source. */
28
+ failures: MarketplaceFetchFailure[];
29
+ /** Installed plugins whose version could not be verified against upstream. */
30
+ unverifiedPlugins: number;
31
+ /** Terminal columns available for the line. */
32
+ width: number;
33
+ /** For the countdown; injected so tests are not clock-dependent. */
34
+ now?: number;
35
+ }
36
+
37
+ /**
38
+ * One line naming why some versions on screen could not be verified — or `null`
39
+ * when everything was actually checked.
40
+ *
41
+ * Returns `null` when there is nothing wrong: a banner that is always present is
42
+ * a banner nobody reads.
43
+ *
44
+ * Segments are added in priority order and dropped from the end when they do not
45
+ * fit `width`. Listing every failing marketplace overflowed an 80-column pane and
46
+ * took the retry hint with it — a warning truncated mid-sentence is barely better
47
+ * than no warning.
48
+ */
49
+ export function buildCatalogNoticeText({
50
+ failures,
51
+ unverifiedPlugins,
52
+ width,
53
+ now = Date.now(),
54
+ }: CatalogNoticeInput): string | null {
55
+ if (failures.length === 0) return null;
56
+
57
+ const segments: string[] = [];
58
+
59
+ // Lead with the reason, not the names: "rate limit" tells the user to wait,
60
+ // "timed out" tells them to check the network. That distinction is the whole
61
+ // value of classifying the failure, and it costs few characters.
62
+ const kinds = new Set(failures.map((f) => f.kind));
63
+ const rateLimited = kinds.has("rate-limited");
64
+ const reason = rateLimited
65
+ ? "GitHub rate limit"
66
+ : kinds.has("timeout")
67
+ ? "GitHub timed out"
68
+ : (failures[0]?.detail ?? "fetch failed");
69
+
70
+ const noun = failures.length === 1 ? "catalog" : "catalogs";
71
+ // Naming them only pays off when there are one or two; past that the count
72
+ // carries the same information in a fraction of the width.
73
+ const named =
74
+ failures.length <= 2
75
+ ? `: ${failures.map((f) => f.marketplace).join(", ")}`
76
+ : "";
77
+ segments.push(`${failures.length} ${noun} unchecked (${reason})${named}`);
78
+
79
+ if (unverifiedPlugins > 0) {
80
+ segments.push(
81
+ `${unverifiedPlugins} version${unverifiedPlugins === 1 ? "" : "s"} unverified`,
82
+ );
83
+ }
84
+
85
+ // The countdown replaces the bare retry hint when we know when the window
86
+ // reopens. Answering "when can I try again" is the point: "try later" leaves
87
+ // the user to guess, and guessing wrong spends another request against the
88
+ // limit. Soonest retry across all failures, since that is when anything at all
89
+ // becomes possible again.
90
+ const retryAt = failures
91
+ .map((f) => f.retryAt)
92
+ .filter((t): t is number => typeof t === "number" && t > now)
93
+ .sort((a, b) => a - b)[0];
94
+
95
+ const hint = retryAt
96
+ ? ` · retrying in ${formatWait(retryAt - now)}`
97
+ : " · r to retry";
98
+
99
+ // Column budget. `⚠` renders double-width in most terminals while
100
+ // `String.length` counts it as one, so one column is held back for it.
101
+ const maxColumns = Math.max(1, width - 1);
102
+
103
+ // The retry affordance is reserved out of the budget rather than appended: it
104
+ // is the only action the line offers, so it is the last thing that may be cut.
105
+ // No floor on what remains — a floor is what let a 40-column pane overflow by
106
+ // two, which is the whole failure mode this budget exists to prevent.
107
+ const messageBudget = maxColumns - hint.length;
108
+
109
+ let line = `⚠ ${segments[0]}`;
110
+ for (const segment of segments.slice(1)) {
111
+ const candidate = `${line} · ${segment}`;
112
+ if (candidate.length > messageBudget) break;
113
+ line = candidate;
114
+ }
115
+ if (line.length > messageBudget) {
116
+ line = messageBudget > 1 ? `${line.slice(0, messageBudget - 1)}…` : "";
117
+ }
118
+
119
+ // Absurdly narrow pane: the hint alone does not fit. Truncate it rather than
120
+ // return a line that overflows and corrupts the layout.
121
+ return `${line}${hint}`.slice(0, maxColumns);
122
+ }
@@ -23,6 +23,15 @@ interface ScreenLayoutProps {
23
23
  };
24
24
  /** Status line content (for screens without search) - shown in second row */
25
25
  statusLine?: React.ReactNode;
26
+ /**
27
+ * One-line warning shown under the header, above the content.
28
+ *
29
+ * For conditions that make the data on screen untrustworthy — not for
30
+ * transient progress. It sits in the fixed chrome rather than in a panel so it
31
+ * cannot be scrolled out of view, and it takes a row from the panels only
32
+ * while it is present.
33
+ */
34
+ notice?: React.ReactNode;
26
35
  /** Footer hints (left side). Pass a FooterHint[] to render key badges
27
36
  * (the standard look). A string or ReactNode is still accepted for
28
37
  * free-form footers. */
@@ -41,6 +50,7 @@ export function ScreenLayout({
41
50
  currentScreen,
42
51
  search,
43
52
  statusLine,
53
+ notice,
44
54
  footerHints,
45
55
  listPanel,
46
56
  detailPanel,
@@ -50,8 +60,8 @@ export function ScreenLayout({
50
60
  const hasSearchBar = search && (search.isActive || search.query);
51
61
 
52
62
  // Fixed chrome: top line + tabs + line + header + separator + footer = 6
53
- // Search bar adds 1 when active
54
- const fixedHeight = 6 + (hasSearchBar ? 1 : 0);
63
+ // Search bar adds 1 when active; the notice adds 1 while present.
64
+ const fixedHeight = 6 + (hasSearchBar ? 1 : 0) + (notice ? 1 : 0);
55
65
  const panelHeight = Math.max(5, dimensions.contentHeight - fixedHeight);
56
66
  const lineWidth = Math.max(10, dimensions.terminalWidth - 4);
57
67
 
@@ -108,6 +118,13 @@ export function ScreenLayout({
108
118
  </box>
109
119
  )}
110
120
 
121
+ {/* Warning line — only when the data on screen cannot be trusted */}
122
+ {notice && (
123
+ <box height={1} paddingLeft={1} paddingRight={1}>
124
+ {notice}
125
+ </box>
126
+ )}
127
+
111
128
  {/* Separator below header */}
112
129
  <box height={1} paddingLeft={1} paddingRight={1}>
113
130
  <text fg={theme.colors.border}>{"─".repeat(lineWidth)}</text>
@@ -181,6 +181,12 @@ function pluginRow(
181
181
  // Same version, different files. The version compare says "up to date"
182
182
  // and is wrong — only a reinstall delivers the current content.
183
183
  versionStr += " stale — reinstall";
184
+ } else if (plugin.updateCheckFailed && versionStr) {
185
+ // No update to show — but we never confirmed there wasn't one. Neither
186
+ // upstream source could be read, so the compare ran against the pinned
187
+ // clone, which by policy never advances. A bare version here reads as
188
+ // "current", which is the exact claim we cannot make.
189
+ versionStr += " ? unverified";
184
190
  }
185
191
  }
186
192
 
@@ -247,7 +253,12 @@ function pluginRow(
247
253
  ? "danger"
248
254
  : plugin.hasUpdate
249
255
  ? "warning"
250
- : "muted"
256
+ : plugin.updateCheckFailed
257
+ ? // Dimmer than a real update: nothing is known to be
258
+ // wrong. But not `muted` either, which is the colour
259
+ // of a verified-current plugin.
260
+ "warning"
261
+ : "muted"
251
262
  }
252
263
  />
253
264
  </text>
@@ -376,6 +387,33 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
376
387
  }
377
388
  />
378
389
  ) : null}
390
+ {/*
391
+ * Name the source of the version above whenever it is not authoritative.
392
+ * Without this the panel showed a version and an installed version that
393
+ * happened to match, and the user reasonably read that as "current" —
394
+ * when in fact GitHub was never reached and the number came off a clone
395
+ * that had not moved in days.
396
+ */}
397
+ {plugin.updateCheckFailed ? (
398
+ <>
399
+ <KeyValueLine
400
+ label="Latest"
401
+ value={
402
+ <span fg={theme.colors.warning}>unverified — not checked</span>
403
+ }
404
+ />
405
+ <box>
406
+ <text fg={theme.colors.muted}>
407
+ {plugin.updateCheckFailure
408
+ ? ` ${plugin.updateCheckFailure.detail}. `
409
+ : " "}
410
+ {plugin.catalogSource === "local-clone"
411
+ ? "Version shown is from the pinned marketplace clone, so a newer release may exist. Press r to retry."
412
+ : "Press r to retry."}
413
+ </text>
414
+ </box>
415
+ </>
416
+ ) : null}
379
417
  {plugin.category ? (
380
418
  <KeyValueLine
381
419
  label="Category"