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.
- 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/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/adapters/catalogNotice.ts +122 -0
- package/src/ui/components/layout/ScreenLayout.tsx +19 -2
- package/src/ui/renderers/pluginRenderers.tsx +39 -1
- package/src/ui/screens/PluginsScreen.tsx +138 -29
- package/src/ui/state/reducer.ts +11 -2
- package/src/ui/state/types.ts +16 -1
- package/src/utils/config-dir.ts +47 -0
|
@@ -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:
|
|
144
|
-
plugins:
|
|
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
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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}>
|
|
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,
|
package/src/ui/state/reducer.ts
CHANGED
|
@@ -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
|
-
|
|
141
|
-
|
|
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
|
|
package/src/ui/state/types.ts
CHANGED
|
@@ -39,10 +39,25 @@ export type Route =
|
|
|
39
39
|
|
|
40
40
|
export type AsyncData<T> =
|
|
41
41
|
| { status: "idle" }
|
|
42
|
-
|
|
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
|
+
}
|