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,5 +1,6 @@
1
- import React, { useEffect, useCallback, useMemo } from "react";
1
+ import React, { useEffect, useCallback, useMemo, useState } from "react";
2
2
  import { useApp, useModal, useProgress } from "../state/AppContext.js";
3
+ import { asyncValue } from "../state/types.js";
3
4
  import { useDimensions } from "../state/DimensionsContext.js";
4
5
  import { useKeyboard } from "../hooks/useKeyboard.js";
5
6
  import { ScreenLayout } from "../components/layout/index.js";
@@ -18,12 +19,14 @@ import {
18
19
  } from "../../services/version-snapshot.js";
19
20
  import {
20
21
  getAvailablePlugins,
22
+ getMarketplaceFetchFailures,
21
23
  refreshAllMarketplaces,
22
24
  clearMarketplaceCache,
23
25
  getLocalMarketplacesInfo,
24
26
  isInstalledInScope,
25
27
  resolveScopeAction,
26
28
  saveInstalledPluginVersion,
29
+ type MarketplaceFetchFailure,
27
30
  type PluginInfo,
28
31
  type ScopeStatus,
29
32
  } from "../../services/plugin-manager.js";
@@ -66,8 +69,18 @@ import {
66
69
  renderPluginRow,
67
70
  renderPluginDetail,
68
71
  } from "../renderers/pluginRenderers.js";
72
+ import { buildCatalogNoticeText } from "../adapters/catalogNotice.js";
69
73
  import { theme } from "../theme.js";
70
74
 
75
+ /** Wraps the notice text in the warning-coloured row ScreenLayout renders. */
76
+ function buildCatalogNotice(
77
+ input: Parameters<typeof buildCatalogNoticeText>[0],
78
+ ): React.ReactNode {
79
+ const text = buildCatalogNoticeText(input);
80
+ if (!text) return null;
81
+ return <text fg={theme.colors.warning}>{text}</text>;
82
+ }
83
+
71
84
  export function PluginsScreen() {
72
85
  const { state, dispatch } = useApp();
73
86
  const { plugins: pluginsState } = state;
@@ -81,6 +94,20 @@ export function PluginsScreen() {
81
94
  state.currentRoute.screen === "plugins" &&
82
95
  !state.modal;
83
96
 
97
+ /**
98
+ * Catalogs that could not be read from any authoritative source.
99
+ *
100
+ * Held in state rather than derived at render time because it is an answer
101
+ * from the last fetch and must survive until the next one — a failure the user
102
+ * cannot see is the bug this screen shipped with.
103
+ */
104
+ const [catalogFailures, setCatalogFailures] = useState<
105
+ MarketplaceFetchFailure[]
106
+ >([]);
107
+
108
+ /** Re-render tick so the retry countdown in the notice actually counts down. */
109
+ const [nowTick, setNowTick] = useState(() => Date.now());
110
+
84
111
  // Fetch data (always fetches all scopes)
85
112
  const fetchData = useCallback(async () => {
86
113
  dispatch({ type: "PLUGINS_DATA_LOADING" });
@@ -89,6 +116,24 @@ export function PluginsScreen() {
89
116
  const allMarketplaces = getAllMarketplaces(localMarketplaces);
90
117
  const pluginData = await getAvailablePlugins(state.projectPath);
91
118
 
119
+ // Which catalogs answered, and which only appeared to. Read straight
120
+ // after the fetch, while the record still describes this attempt.
121
+ //
122
+ // Only failures that survived every authoritative source are worth
123
+ // reporting: a rate-limited HTTP fetch that the git remote ref then
124
+ // answered is a non-event for the user, and reporting it would fire a
125
+ // warning about intended, working behaviour.
126
+ const unresolved = new Set(
127
+ pluginData
128
+ .filter((p) => p.updateCheckFailed)
129
+ .map((p) => p.marketplace),
130
+ );
131
+ setCatalogFailures(
132
+ getMarketplaceFetchFailures().filter((f) =>
133
+ unresolved.has(f.marketplace),
134
+ ),
135
+ );
136
+
92
137
  // Surface version changes made outside claudeup (Claude Code's own
93
138
  // plugin update, the prerunner, a manual CLI call). Diffing against
94
139
  // what we last rendered catches all of them; logging only what
@@ -131,24 +176,60 @@ export function PluginsScreen() {
131
176
  fetchData();
132
177
  }, [fetchData, state.dataRefreshVersion]);
133
178
 
179
+ /**
180
+ * Tick the countdown, and refetch by itself once the rate-limit window reopens.
181
+ *
182
+ * The user asked "when can I try again" — so the screen answers it and then
183
+ * acts on it. Leaving them to press `r` and guess is what makes a rate limit
184
+ * feel like a dead end, and a mistimed guess spends another request against the
185
+ * limit for nothing.
186
+ *
187
+ * Only armed while a retry is actually pending, so an untroubled screen runs no
188
+ * timer at all.
189
+ */
190
+ const nextRetryAt = useMemo(() => {
191
+ const times = catalogFailures
192
+ .map((f) => f.retryAt)
193
+ .filter((t): t is number => typeof t === "number");
194
+ return times.length > 0 ? Math.min(...times) : undefined;
195
+ }, [catalogFailures]);
196
+
197
+ useEffect(() => {
198
+ if (nextRetryAt === undefined) return;
199
+
200
+ const tick = setInterval(() => {
201
+ const now = Date.now();
202
+ setNowTick(now);
203
+ if (now >= nextRetryAt) {
204
+ clearInterval(tick);
205
+ // The cooldown has expired, so the fetcher will really try again
206
+ // rather than short-circuit on its own negative cache.
207
+ clearMarketplaceCache();
208
+ fetchData();
209
+ }
210
+ }, 1000);
211
+
212
+ return () => clearInterval(tick);
213
+ }, [nextRetryAt, fetchData]);
214
+
215
+ // The data to render: fresh when loaded, else the previous list while a
216
+ // reload is in flight. Reading `status === "success"` alone is what made a
217
+ // tab switch blank the screen — the router remounts this component, which
218
+ // refetches, and every intermediate frame had nothing to show.
219
+ const shownMarketplaces = asyncValue(pluginsState.marketplaces);
220
+ const shownPlugins = asyncValue(pluginsState.plugins);
221
+ const isReloading =
222
+ pluginsState.plugins.status === "loading" && shownPlugins !== undefined;
223
+
134
224
  // Build list items (categories + plugins)
135
225
  const allItems = useMemo((): PluginBrowserItem[] => {
136
- if (
137
- pluginsState.marketplaces.status !== "success" ||
138
- pluginsState.plugins.status !== "success"
139
- ) {
140
- return [];
141
- }
226
+ if (!shownMarketplaces || !shownPlugins) return [];
142
227
  return buildPluginBrowserItems({
143
- marketplaces: pluginsState.marketplaces.data,
144
- plugins: pluginsState.plugins.data,
228
+ marketplaces: shownMarketplaces,
229
+ plugins: shownPlugins,
145
230
  collapsedMarketplaces: pluginsState.collapsedMarketplaces,
146
231
  });
147
- }, [
148
- pluginsState.marketplaces,
149
- pluginsState.plugins,
150
- pluginsState.collapsedMarketplaces,
151
- ]);
232
+ }, [shownMarketplaces, shownPlugins, pluginsState.collapsedMarketplaces]);
152
233
 
153
234
  // Filter items by search query
154
235
  const filteredItems = useMemo(() => {
@@ -1061,30 +1142,47 @@ export function PluginsScreen() {
1061
1142
 
1062
1143
  // ── Render ─────────────────────────────────────────────────────────────────
1063
1144
 
1064
- if (
1065
- pluginsState.marketplaces.status === "loading" ||
1066
- pluginsState.plugins.status === "loading"
1067
- ) {
1068
- return (
1069
- <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1070
- <text fg={theme.colors.accent}>
1071
- <strong>claudeup Plugins</strong>
1072
- </text>
1073
- <text fg={theme.colors.muted}>Loading...</text>
1074
- </box>
1075
- );
1145
+ // Only a genuine first load takes over the screen. A reload keeps the list on
1146
+ // screen and says so in the notice line instead.
1147
+ if (!shownMarketplaces || !shownPlugins) {
1148
+ if (
1149
+ pluginsState.marketplaces.status === "loading" ||
1150
+ pluginsState.plugins.status === "loading"
1151
+ ) {
1152
+ return (
1153
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1154
+ <text fg={theme.colors.accent}>
1155
+ <strong>claudeup Plugins</strong>
1156
+ </text>
1157
+ <text fg={theme.colors.muted}>
1158
+ Loading plugin catalogs from GitHub…
1159
+ </text>
1160
+ </box>
1161
+ );
1162
+ }
1076
1163
  }
1077
1164
 
1078
1165
  if (
1079
1166
  pluginsState.marketplaces.status === "error" ||
1080
1167
  pluginsState.plugins.status === "error"
1081
1168
  ) {
1169
+ // Name the error. "Error loading data" told the user nothing and sent them
1170
+ // to the debug pane to find out what claudeup already knew.
1171
+ const failure =
1172
+ pluginsState.plugins.status === "error"
1173
+ ? pluginsState.plugins.error
1174
+ : pluginsState.marketplaces.status === "error"
1175
+ ? pluginsState.marketplaces.error
1176
+ : undefined;
1082
1177
  return (
1083
1178
  <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1084
1179
  <text fg={theme.colors.accent}>
1085
1180
  <strong>claudeup Plugins</strong>
1086
1181
  </text>
1087
- <text fg={theme.colors.danger}>Error loading data</text>
1182
+ <text fg={theme.colors.danger}>
1183
+ Could not load plugins: {failure?.message ?? "unknown error"}
1184
+ </text>
1185
+ <text fg={theme.colors.muted}>Press r to retry.</text>
1088
1186
  </box>
1089
1187
  );
1090
1188
  }
@@ -1107,8 +1205,7 @@ export function PluginsScreen() {
1107
1205
  ];
1108
1206
 
1109
1207
  const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
1110
- const plugins: PluginInfo[] =
1111
- pluginsState.plugins.status === "success" ? pluginsState.plugins.data : [];
1208
+ const plugins: PluginInfo[] = shownPlugins ?? [];
1112
1209
  const installedCount = plugins.filter((p) => p.enabled).length;
1113
1210
  const updateCount = plugins.filter(
1114
1211
  (p) =>
@@ -1120,10 +1217,22 @@ export function PluginsScreen() {
1120
1217
  const subtitle = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} updates` : ""}`;
1121
1218
  const searchPlaceholder = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} ⬆` : ""} │ / to search`;
1122
1219
 
1220
+ const notice = isReloading ? (
1221
+ <text fg={theme.colors.muted}>⟳ Refreshing catalogs from GitHub…</text>
1222
+ ) : (
1223
+ buildCatalogNotice({
1224
+ failures: catalogFailures,
1225
+ unverifiedPlugins: plugins.filter((p) => p.updateCheckFailed).length,
1226
+ width: dimensions.terminalWidth,
1227
+ now: nowTick,
1228
+ })
1229
+ );
1230
+
1123
1231
  return (
1124
1232
  <ScreenLayout
1125
1233
  title="claudeup Plugins"
1126
1234
  subtitle={subtitle}
1235
+ notice={notice}
1127
1236
  currentScreen="plugins"
1128
1237
  search={{
1129
1238
  isActive: isSearchActive,
@@ -1,4 +1,5 @@
1
1
  import type { AppState, AppAction } from "./types.js";
2
+ import { asyncValue } from "./types.js";
2
3
 
3
4
  export const initialState: AppState = {
4
5
  // Navigation - start on plugins screen
@@ -137,8 +138,16 @@ export function appReducer(state: AppState, action: AppAction): AppState {
137
138
  ...state,
138
139
  plugins: {
139
140
  ...state.plugins,
140
- marketplaces: { status: "loading" },
141
- plugins: { status: "loading" },
141
+ // Hand the previous list forward so a reload refreshes in place
142
+ // instead of replacing the screen with "Loading...".
143
+ marketplaces: {
144
+ status: "loading",
145
+ previous: asyncValue(state.plugins.marketplaces),
146
+ },
147
+ plugins: {
148
+ status: "loading",
149
+ previous: asyncValue(state.plugins.plugins),
150
+ },
142
151
  },
143
152
  };
144
153
 
@@ -39,10 +39,25 @@ export type Route =
39
39
 
40
40
  export type AsyncData<T> =
41
41
  | { status: "idle" }
42
- | { status: "loading" }
42
+ /**
43
+ * `previous` carries the last successful value through a reload.
44
+ *
45
+ * Without it a refetch is indistinguishable from a first load, so a screen
46
+ * that already had data blanked itself to "Loading..." every time it
47
+ * remounted — and the router unmounts screens on every tab switch. Optional so
48
+ * that a genuine first load is still just `{ status: "loading" }`.
49
+ */
50
+ | { status: "loading"; previous?: T }
43
51
  | { status: "success"; data: T }
44
52
  | { status: "error"; error: Error };
45
53
 
54
+ /** The value to render: fresh if loaded, else the last good one during a reload. */
55
+ export function asyncValue<T>(d: AsyncData<T>): T | undefined {
56
+ if (d.status === "success") return d.data;
57
+ if (d.status === "loading") return d.previous;
58
+ return undefined;
59
+ }
60
+
46
61
  // ============================================================================
47
62
  // Modal Types
48
63
  // ============================================================================
@@ -0,0 +1,47 @@
1
+ /**
2
+ * config-dir.ts — where Claude Code's config lives, with a test guard.
3
+ *
4
+ * `CLAUDE_CONFIG_DIR` is Claude Code's own override and must be honoured. It is
5
+ * resolved per call rather than captured at import time, because a module-level
6
+ * `os.homedir()` bakes in the operator's real directory and cannot be overridden
7
+ * afterwards.
8
+ *
9
+ * The test guard
10
+ * --------------
11
+ * Under `bun test`, a missing `CLAUDE_CONFIG_DIR` returns null instead of
12
+ * `~/.claude`. Callers treat null as "no cache, no clone" — an empty, inert state.
13
+ *
14
+ * This is not defensive decoration. Once the catalog cache and the rate-limit
15
+ * cooldown moved to disk, and the catalog resolver gained a `git fetch` fallback,
16
+ * any test that had not isolated its config dir began reading the operator's real
17
+ * cooldown file and fetching from their real marketplace clones. The suite went
18
+ * from 6s to 59s and started failing intermittently, because results depended on
19
+ * whether this particular machine happened to be rate-limited at that moment.
20
+ *
21
+ * A test that forgets to isolate should get nothing, not the developer's live
22
+ * state. Production is unaffected: NODE_ENV is not "test" there.
23
+ */
24
+
25
+ import os from "node:os";
26
+ import path from "node:path";
27
+
28
+ /**
29
+ * The Claude config directory, or null when a test has not chosen one.
30
+ *
31
+ * Callers that must always have a path (writing real user config) should use
32
+ * `requireClaudeConfigDir`. Callers holding regenerable state — caches — should
33
+ * treat null as "cache unavailable" and carry on.
34
+ */
35
+ export function claudeConfigDirOrNull(): string | null {
36
+ const explicit = process.env.CLAUDE_CONFIG_DIR;
37
+ if (explicit) return explicit;
38
+ if (process.env.NODE_ENV === "test") return null;
39
+ return path.join(os.homedir(), ".claude");
40
+ }
41
+
42
+ /** The Claude config directory, falling back to `~/.claude` even under test. */
43
+ export function requireClaudeConfigDir(): string {
44
+ return (
45
+ process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude")
46
+ );
47
+ }