claudeup 4.37.0 → 4.38.1
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/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +722 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1124 -0
- package/src/__tests__/styles-origins.test.ts +416 -0
- package/src/__tests__/styles-screen-state.test.ts +460 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/tabbar-layout.test.ts +62 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/data/community-styles.ts +531 -0
- package/src/main.tsx +15 -0
- package/src/services/catalog-cache-store.ts +101 -7
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/styles-manager.ts +1400 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/TabBar.tsx +43 -9
- package/src/ui/components/primitives/ActionHints.tsx +4 -1
- package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
- package/src/ui/registry.ts +6 -0
- package/src/ui/renderers/styleRenderers.tsx +809 -0
- package/src/ui/screens/StylesScreen.tsx +1089 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +113 -1
- package/src/ui/state/types.ts +60 -2
- package/src/utils/open-file.ts +84 -0
|
@@ -53,13 +53,48 @@ interface StoredCooldown {
|
|
|
53
53
|
strikes: number;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* What the last upstream check learned about one community-styles repository.
|
|
58
|
+
*
|
|
59
|
+
* Lives here rather than in a fourth cache file so it inherits this store's file
|
|
60
|
+
* lock, atomic write and CLAUDE_CONFIG_DIR test isolation — and, more to the
|
|
61
|
+
* point, so it sits beside the rate-limit cooldowns it is budgeted against.
|
|
62
|
+
*
|
|
63
|
+
* `dirHeadSha` is the pivot of the two-phase check: one API call per REPO
|
|
64
|
+
* answers "did anything in its styles directory move", and only a repo that
|
|
65
|
+
* moved is drilled into with free raw fetches. Per-style API calls would spend
|
|
66
|
+
* the machine's whole hourly budget on first use.
|
|
67
|
+
*/
|
|
68
|
+
export interface StoredCommunityCheck {
|
|
69
|
+
/** Wall-clock ms of the last successful phase A. Drives the 24h TTL. */
|
|
70
|
+
checkedAt: number;
|
|
71
|
+
/** Latest commit touching the repo's styles directory, as of that check. */
|
|
72
|
+
dirHeadSha: string;
|
|
73
|
+
/** Per coordinate id: the bytes we hold, and any update sitting in .pending/. */
|
|
74
|
+
styles: Record<
|
|
75
|
+
string,
|
|
76
|
+
{
|
|
77
|
+
sha256: string;
|
|
78
|
+
pendingSha256?: string;
|
|
79
|
+
/** Changed-line count of that pending update, so it survives a restart. */
|
|
80
|
+
pendingLines?: number;
|
|
81
|
+
}
|
|
82
|
+
>;
|
|
83
|
+
}
|
|
84
|
+
|
|
56
85
|
interface StoreShape {
|
|
57
86
|
version: 1;
|
|
58
87
|
catalogs: Record<string, StoredCatalog>;
|
|
59
88
|
cooldowns: Record<string, StoredCooldown>;
|
|
89
|
+
communityStyles: Record<string, StoredCommunityCheck>;
|
|
60
90
|
}
|
|
61
91
|
|
|
62
|
-
const EMPTY: StoreShape = {
|
|
92
|
+
const EMPTY: StoreShape = {
|
|
93
|
+
version: 1,
|
|
94
|
+
catalogs: {},
|
|
95
|
+
cooldowns: {},
|
|
96
|
+
communityStyles: {},
|
|
97
|
+
};
|
|
63
98
|
|
|
64
99
|
/**
|
|
65
100
|
* Resolved per call, honouring CLAUDE_CONFIG_DIR — same reasoning as
|
|
@@ -83,7 +118,8 @@ let memo: { path: string; data: StoreShape } | null = null;
|
|
|
83
118
|
|
|
84
119
|
async function load(): Promise<StoreShape> {
|
|
85
120
|
const file = storePath();
|
|
86
|
-
if (!file)
|
|
121
|
+
if (!file)
|
|
122
|
+
return { version: 1, catalogs: {}, cooldowns: {}, communityStyles: {} };
|
|
87
123
|
if (memo?.path === file) return memo.data;
|
|
88
124
|
try {
|
|
89
125
|
const parsed = JSON.parse(await fs.readFile(file, "utf-8")) as StoreShape;
|
|
@@ -92,11 +128,19 @@ async function load(): Promise<StoreShape> {
|
|
|
92
128
|
const data = parsed?.version === 1 ? parsed : { ...EMPTY };
|
|
93
129
|
data.catalogs ??= {};
|
|
94
130
|
data.cooldowns ??= {};
|
|
131
|
+
// Absent in every file written before community styles existed, so this
|
|
132
|
+
// default is the migration — an upgrade must not read as a corrupt cache.
|
|
133
|
+
data.communityStyles ??= {};
|
|
95
134
|
memo = { path: file, data };
|
|
96
135
|
return data;
|
|
97
136
|
} catch {
|
|
98
137
|
// Absent or corrupt. A cache must never be a failure mode — start empty.
|
|
99
|
-
const data: StoreShape = {
|
|
138
|
+
const data: StoreShape = {
|
|
139
|
+
...EMPTY,
|
|
140
|
+
catalogs: {},
|
|
141
|
+
cooldowns: {},
|
|
142
|
+
communityStyles: {},
|
|
143
|
+
};
|
|
100
144
|
memo = { path: file, data };
|
|
101
145
|
return data;
|
|
102
146
|
}
|
|
@@ -117,13 +161,27 @@ async function mutate(fn: (data: StoreShape) => void): Promise<void> {
|
|
|
117
161
|
await withFileLock(file, async () => {
|
|
118
162
|
let onDisk: StoreShape;
|
|
119
163
|
try {
|
|
120
|
-
const parsed = JSON.parse(
|
|
164
|
+
const parsed = JSON.parse(
|
|
165
|
+
await fs.readFile(file, "utf-8"),
|
|
166
|
+
) as StoreShape;
|
|
167
|
+
// Every section is named explicitly: a key omitted here is silently
|
|
168
|
+
// dropped on the next write of any OTHER section.
|
|
121
169
|
onDisk =
|
|
122
170
|
parsed?.version === 1
|
|
123
|
-
? {
|
|
124
|
-
|
|
171
|
+
? {
|
|
172
|
+
version: 1,
|
|
173
|
+
catalogs: parsed.catalogs ?? {},
|
|
174
|
+
cooldowns: parsed.cooldowns ?? {},
|
|
175
|
+
communityStyles: parsed.communityStyles ?? {},
|
|
176
|
+
}
|
|
177
|
+
: { version: 1, catalogs: {}, cooldowns: {}, communityStyles: {} };
|
|
125
178
|
} catch {
|
|
126
|
-
onDisk = {
|
|
179
|
+
onDisk = {
|
|
180
|
+
version: 1,
|
|
181
|
+
catalogs: {},
|
|
182
|
+
cooldowns: {},
|
|
183
|
+
communityStyles: {},
|
|
184
|
+
};
|
|
127
185
|
}
|
|
128
186
|
fn(onDisk);
|
|
129
187
|
// Temp + rename, so a crash mid-write cannot leave a half-written file
|
|
@@ -212,6 +270,42 @@ export async function clearStoredCooldown(host: string): Promise<void> {
|
|
|
212
270
|
});
|
|
213
271
|
}
|
|
214
272
|
|
|
273
|
+
// ─── Community style checks ──────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Every recorded upstream check, keyed by community source id.
|
|
277
|
+
*
|
|
278
|
+
* Deliberately NOT expired on read, unlike cooldowns. An expired cooldown means
|
|
279
|
+
* "you may call again"; an expired CHECK still carries the shas we compare
|
|
280
|
+
* against, and only its `checkedAt` has gone stale. Dropping it would throw away
|
|
281
|
+
* the one thing that makes the next check cheap, and would make a 25-hour-old
|
|
282
|
+
* result indistinguishable from never having looked. Freshness is judged by the
|
|
283
|
+
* reader, which is what lets an expired check present as `unknown` rather than
|
|
284
|
+
* as up to date.
|
|
285
|
+
*/
|
|
286
|
+
export async function readCommunityChecks(): Promise<
|
|
287
|
+
Record<string, StoredCommunityCheck>
|
|
288
|
+
> {
|
|
289
|
+
return { ...(await load()).communityStyles };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export async function writeCommunityCheck(
|
|
293
|
+
sourceId: string,
|
|
294
|
+
check: StoredCommunityCheck,
|
|
295
|
+
): Promise<void> {
|
|
296
|
+
await mutate((data) => {
|
|
297
|
+
data.communityStyles ??= {};
|
|
298
|
+
data.communityStyles[sourceId] = check;
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Drop every recorded check. Backs an explicit refresh. */
|
|
303
|
+
export async function clearCommunityChecks(): Promise<void> {
|
|
304
|
+
await mutate((data) => {
|
|
305
|
+
data.communityStyles = {};
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
215
309
|
/** Test seam: forget this process's memo so the next read hits disk. */
|
|
216
310
|
export function resetCatalogCacheMemo(): void {
|
|
217
311
|
memo = null;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* community-fetcher.ts — the one thing in the community-styles feature that
|
|
3
|
+
* knows `fetch` exists.
|
|
4
|
+
*
|
|
5
|
+
* ## Why a port at all
|
|
6
|
+
*
|
|
7
|
+
* Exactly one boundary gets an interface: the network. The domain here is thin,
|
|
8
|
+
* and an interface-per-class reflex would be waste — so there is no Strategy
|
|
9
|
+
* registry, no adapter factory, and nothing else in the feature is abstracted.
|
|
10
|
+
* The gain is the one the constraints demand: no test can reach GitHub, because
|
|
11
|
+
* every exported function in `community-styles.ts` takes a `StyleFetcher` as a
|
|
12
|
+
* REQUIRED argument. Omitting it is a type error, not a forgotten mock.
|
|
13
|
+
*
|
|
14
|
+
* ## Why it does not speak `Response`
|
|
15
|
+
*
|
|
16
|
+
* The classic way a port leaks its adapter. A `Response` drags in streaming
|
|
17
|
+
* semantics, a body that can only be read once, and a `fetch`-shaped mental
|
|
18
|
+
* model into every test fake. `{ status, body, etag }` is what the caller
|
|
19
|
+
* actually uses.
|
|
20
|
+
*
|
|
21
|
+
* `headers` is the one deliberate exception. `github-budget.ts` parses
|
|
22
|
+
* `retry-after` / `x-ratelimit-reset` / `x-ratelimit-remaining` itself, and it
|
|
23
|
+
* is the module that knows the two hosts differ in what they report. Re-deriving
|
|
24
|
+
* that here would fork the rate-limit logic, which is precisely what §5.3 of the
|
|
25
|
+
* design forbids. A fake supplies `new Headers({...})`, which is a standard
|
|
26
|
+
* built-in, not a fetch import.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** What the application needs from an HTTP response. Never a `Response`. */
|
|
30
|
+
export interface StyleFetchResponse {
|
|
31
|
+
status: number;
|
|
32
|
+
/** Empty for a 304 and for any status with no body. */
|
|
33
|
+
body: string;
|
|
34
|
+
etag: string | null;
|
|
35
|
+
contentType: string | null;
|
|
36
|
+
/** Handed to `github-budget` unchanged — see the header note. */
|
|
37
|
+
headers: Headers;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type StyleFetcher = (
|
|
41
|
+
url: string,
|
|
42
|
+
opts?: { etag?: string | null; timeoutMs?: number; accept?: string },
|
|
43
|
+
) => Promise<StyleFetchResponse>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 10s, matching `marketplace-fetcher.ts`. A style is a few kilobytes over a CDN
|
|
47
|
+
* — measured at ~340ms — so anything past ten seconds is a broken route rather
|
|
48
|
+
* than a slow one, and the user is waiting on a keypress they just made.
|
|
49
|
+
*/
|
|
50
|
+
export const FETCH_TIMEOUT_MS = 10_000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The real adapter. Wraps global `fetch` and nothing more: no retry, no
|
|
54
|
+
* classification, no budget check. Those are policy and belong in the service,
|
|
55
|
+
* where they can be tested without a network.
|
|
56
|
+
*
|
|
57
|
+
* Sends `GITHUB_TOKEN` / `GITHUB_PERSONAL_ACCESS_TOKEN` to `api.github.com`
|
|
58
|
+
* ONLY — exactly as `skills-manager.ts` already does. The token raises that
|
|
59
|
+
* host's 60/hr unauthenticated budget to 5000/hr and is the documented escape
|
|
60
|
+
* hatch when the advisory says the budget is spent. It is never sent to
|
|
61
|
+
* `raw.githubusercontent.com`, which needs no auth and would receive a
|
|
62
|
+
* credential it has no business seeing.
|
|
63
|
+
*/
|
|
64
|
+
export const githubFetcher: StyleFetcher = async (url, opts = {}) => {
|
|
65
|
+
const headers: Record<string, string> = {
|
|
66
|
+
accept: opts.accept ?? "text/plain, */*",
|
|
67
|
+
"user-agent": "claudeup",
|
|
68
|
+
};
|
|
69
|
+
if (opts.etag) headers["if-none-match"] = opts.etag;
|
|
70
|
+
|
|
71
|
+
if (new URL(url).hostname === "api.github.com") {
|
|
72
|
+
const token =
|
|
73
|
+
process.env.GITHUB_TOKEN || process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
|
|
74
|
+
if (token) headers.authorization = `Bearer ${token}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const response = await fetch(url, {
|
|
78
|
+
headers,
|
|
79
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? FETCH_TIMEOUT_MS),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
status: response.status,
|
|
84
|
+
// 304 carries no body; reading it is still safe and yields "".
|
|
85
|
+
body: response.status === 304 ? "" : await response.text(),
|
|
86
|
+
etag: response.headers.get("etag"),
|
|
87
|
+
contentType: response.headers.get("content-type"),
|
|
88
|
+
headers: response.headers,
|
|
89
|
+
};
|
|
90
|
+
};
|