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.
Files changed (33) hide show
  1. package/package.json +4 -4
  2. package/scripts/verify-community-registry.ts +272 -0
  3. package/src/__tests__/community-fetch.test.ts +545 -0
  4. package/src/__tests__/community-registry.test.ts +269 -0
  5. package/src/__tests__/community-staleness.test.ts +722 -0
  6. package/src/__tests__/open-file.test.ts +59 -0
  7. package/src/__tests__/style-wrap.test.ts +220 -0
  8. package/src/__tests__/styles-manager.test.ts +1124 -0
  9. package/src/__tests__/styles-origins.test.ts +416 -0
  10. package/src/__tests__/styles-screen-state.test.ts +460 -0
  11. package/src/__tests__/styles-status-line.test.ts +72 -0
  12. package/src/__tests__/styles-sync.test.ts +452 -0
  13. package/src/__tests__/tabbar-layout.test.ts +62 -0
  14. package/src/__tests__/terminology-filler.test.ts +214 -0
  15. package/src/data/community-styles.ts +531 -0
  16. package/src/main.tsx +15 -0
  17. package/src/services/catalog-cache-store.ts +101 -7
  18. package/src/services/community-fetcher.ts +90 -0
  19. package/src/services/community-styles.ts +1194 -0
  20. package/src/services/styles-manager.ts +1400 -0
  21. package/src/services/terminology-filler.ts +266 -0
  22. package/src/ui/App.tsx +15 -3
  23. package/src/ui/adapters/stylesAdapter.ts +403 -0
  24. package/src/ui/components/TabBar.tsx +43 -9
  25. package/src/ui/components/primitives/ActionHints.tsx +4 -1
  26. package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
  27. package/src/ui/registry.ts +6 -0
  28. package/src/ui/renderers/styleRenderers.tsx +809 -0
  29. package/src/ui/screens/StylesScreen.tsx +1089 -0
  30. package/src/ui/screens/index.ts +1 -0
  31. package/src/ui/state/reducer.ts +113 -1
  32. package/src/ui/state/types.ts +60 -2
  33. 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 = { version: 1, catalogs: {}, cooldowns: {} };
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) return { version: 1, catalogs: {}, cooldowns: {} };
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 = { ...EMPTY, catalogs: {}, cooldowns: {} };
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(await fs.readFile(file, "utf-8")) as StoreShape;
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
- ? { version: 1, catalogs: parsed.catalogs ?? {}, cooldowns: parsed.cooldowns ?? {} }
124
- : { version: 1, catalogs: {}, cooldowns: {} };
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 = { version: 1, catalogs: {}, cooldowns: {} };
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
+ };