claudeup 4.37.0 → 4.38.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.
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 +521 -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
@@ -0,0 +1,1194 @@
1
+ /**
2
+ * community-styles.ts — fetching a curated third-party output style.
3
+ *
4
+ * ## The shape of the thing
5
+ *
6
+ * The registry (`src/data/community-styles.ts`) holds coordinates. This module
7
+ * turns a coordinate into a file in `~/.claude/claudeup/community-styles/`,
8
+ * stamped with where it came from. Nothing here runs without an explicit user
9
+ * action, and `loadStyles()` never calls into it — that function runs on every
10
+ * screen render and makes zero network calls in every phase of this feature.
11
+ *
12
+ * ## Two hosts, two budgets
13
+ *
14
+ * `raw.githubusercontent.com` serves the style text, needs no auth, and costs
15
+ * nothing against the API rate limit. `api.github.com` resolves the commit pin
16
+ * and is the scarce host: 60 requests an hour, unauthenticated, shared by every
17
+ * process on the machine — `gh`, the skills screen, the marketplace catalogue.
18
+ * So the pin is resolved ONCE PER REPO, not once per style, and is abandoned
19
+ * (stamped `unknown`) rather than allowed to exhaust a budget claudeup does not
20
+ * own. A missing pin never blocks a fetch: the raw content is free and the user
21
+ * asked for the style.
22
+ *
23
+ * Rate-limit state is `github-budget.ts`, unchanged and unforked. It already
24
+ * gates both hosts, persists cooldowns across launches, and knows that one host
25
+ * states when the window reopens and the other states nothing.
26
+ *
27
+ * ## What is validated, and what is not
28
+ *
29
+ * Validated: the coordinate (ours, so a failure throws — it is a bug in a
30
+ * commit, not a user error), the content type, a 64 KiB ceiling, a non-empty
31
+ * frontmatter-plus-body parse, and the frontmatter itself, which is REWRITTEN
32
+ * rather than passed through.
33
+ *
34
+ * Not validated: the body. It is arbitrary third-party instruction text that
35
+ * becomes part of the system prompt when the user applies it. We cannot detect
36
+ * prompt injection in it and will not pretend to. What bounds it is stated
37
+ * plainly elsewhere: an explicit action to fetch, a visible diff before any
38
+ * update is accepted, a pin so a repo that turns malicious later cannot change
39
+ * what is already on disk, and the integrity block that `composeStyleFile`
40
+ * appends to every composition.
41
+ */
42
+
43
+ import { createHash } from "node:crypto";
44
+ import path from "node:path";
45
+ import fs from "fs-extra";
46
+ import {
47
+ COMMUNITY_SOURCES,
48
+ type CommunityStyle,
49
+ type CommunityStyleSource,
50
+ coordinateProblem,
51
+ rawUrlFor,
52
+ resolveCommunityStyle,
53
+ } from "../data/community-styles.js";
54
+ import {
55
+ type StoredCommunityCheck,
56
+ readCommunityChecks,
57
+ writeCommunityCheck,
58
+ } from "./catalog-cache-store.js";
59
+ import type { StyleFetcher } from "./community-fetcher.js";
60
+ import {
61
+ type Cooldown,
62
+ cooldownFor,
63
+ describeCooldown,
64
+ hydrateGitHubBudget,
65
+ recordRateLimit,
66
+ recordSuccess,
67
+ remainingFromHeaders,
68
+ } from "./github-budget.js";
69
+ import { splitFrontmatter } from "./styles-manager.js";
70
+
71
+ // ─── Limits ──────────────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * 64 KiB. Every byte of a style body is re-sent on every single request for as
75
+ * long as it is applied, so a 5 MB "style" would not be a broken download — it
76
+ * would be a silent, permanent tax on the user's context window. The rejection
77
+ * message reports the actual size, because "too large" without a number tells
78
+ * the user nothing about whether it is marginal or absurd.
79
+ */
80
+ export const MAX_STYLE_BYTES = 64 * 1024;
81
+
82
+ /**
83
+ * Stop spending `api.github.com` budget with fewer than this many requests
84
+ * left. claudeup must not be the tool that exhausts a machine-wide limit and
85
+ * breaks `gh` for the next hour to save the user from reading `unknown` in one
86
+ * field.
87
+ */
88
+ export const API_RESERVE = 10;
89
+
90
+ // ─── Errors ──────────────────────────────────────────────────────────────────
91
+
92
+ /**
93
+ * Kinds mirror `marketplace-fetcher.ts`'s so the two read alike, plus the two
94
+ * this feature needs: `too-large` and `dns`.
95
+ */
96
+ export type CommunityFetchErrorKind =
97
+ | "rate-limited"
98
+ | "not-found"
99
+ | "dns"
100
+ | "offline"
101
+ | "timeout"
102
+ | "too-large"
103
+ | "invalid"
104
+ | "http"
105
+ /** Residual: reached the network stack, failed in a way we did not classify. */
106
+ | "network";
107
+
108
+ export interface CommunityFetchFailure {
109
+ kind: CommunityFetchErrorKind;
110
+ /** Short, user-facing reason. Rendered verbatim. */
111
+ detail: string;
112
+ httpStatus?: number;
113
+ /** Wall-clock ms when a retry is due, for rate limits. */
114
+ retryAt?: number;
115
+ }
116
+
117
+ /**
118
+ * A 404 is reported as OUR bug, deliberately.
119
+ *
120
+ * The set of fetchable coordinates is a constant in a reviewed commit, so a
121
+ * missing file means the registry entry is stale — the file was renamed or
122
+ * moved upstream. Telling the user "not found" would invite them to go looking
123
+ * for something they cannot fix.
124
+ */
125
+ const NOT_FOUND_DETAIL =
126
+ "claudeup's registry entry for this style is out of date — the file moved or was renamed upstream. Please report it.";
127
+
128
+ /**
129
+ * Called out by name because it has cost real time on this machine before.
130
+ *
131
+ * Tailscale MagicDNS can hijack resolution of `raw.githubusercontent.com`,
132
+ * producing a failure that looks exactly like "GitHub is down" and is not. A
133
+ * generic "network error" here sends the user to check GitHub's status page.
134
+ */
135
+ const DNS_DETAIL = (host: string) =>
136
+ `Could not resolve ${host}. A VPN or split-DNS resolver can hijack this hostname — Tailscale MagicDNS is a known cause. Check with \`dig ${host}\`; quitting Tailscale or adding an /etc/hosts override clears it.`;
137
+
138
+ /** Resolution failures. Distinguished from "no route" — see DNS_DETAIL. */
139
+ const DNS_CODES = new Set([
140
+ "ENOTFOUND",
141
+ "EAI_AGAIN",
142
+ "EAI_NODATA",
143
+ "EAI_NONAME",
144
+ "ERR_TLS_CERT_ALTNAME_INVALID",
145
+ ]);
146
+
147
+ /** This machine has no route at all, as opposed to GitHub misbehaving. */
148
+ const OFFLINE_CODES = new Set([
149
+ "ENETUNREACH",
150
+ "ENETDOWN",
151
+ "EHOSTUNREACH",
152
+ "ECONNREFUSED",
153
+ ]);
154
+
155
+ /**
156
+ * Classify on the error's `code`, walking the `cause` chain, never on message
157
+ * text. `fetch` wraps the real error in a bare `TypeError: fetch failed`, and
158
+ * matching on that string breaks the moment a runtime rewords it.
159
+ */
160
+ function errorCode(error: unknown): string | null {
161
+ let current: unknown = error;
162
+ for (let depth = 0; depth < 6 && current; depth++) {
163
+ const code = (current as { code?: unknown }).code;
164
+ if (typeof code === "string") return code;
165
+ current = (current as { cause?: unknown }).cause;
166
+ }
167
+ return null;
168
+ }
169
+
170
+ function classifyThrown(error: unknown, host: string): CommunityFetchFailure {
171
+ const name = (error as { name?: string } | null)?.name;
172
+ if (name === "TimeoutError" || name === "AbortError") {
173
+ return { kind: "timeout", detail: `${host} did not respond in 10s` };
174
+ }
175
+ const code = errorCode(error);
176
+ if (code && DNS_CODES.has(code)) {
177
+ return { kind: "dns", detail: DNS_DETAIL(host) };
178
+ }
179
+ if (code && OFFLINE_CODES.has(code)) {
180
+ return {
181
+ kind: "offline",
182
+ detail:
183
+ "no network — the styles you already fetched still work, and still compose",
184
+ };
185
+ }
186
+ const message = error instanceof Error ? error.message : String(error);
187
+ return { kind: "network", detail: `${host}: ${message}` };
188
+ }
189
+
190
+ function rateLimited(
191
+ cooldown: Cooldown,
192
+ status?: number,
193
+ ): CommunityFetchFailure {
194
+ return {
195
+ kind: "rate-limited",
196
+ detail: describeCooldown(cooldown),
197
+ httpStatus: status,
198
+ retryAt: cooldown.until,
199
+ };
200
+ }
201
+
202
+ // ─── The API budget, as seen by this feature ─────────────────────────────────
203
+
204
+ /**
205
+ * Last `x-ratelimit-remaining` we were told, or null if we have not asked.
206
+ *
207
+ * Module state rather than a parameter because the reserve is a property of the
208
+ * MACHINE's shared budget, not of one batch: a five-repo sweep must stop after
209
+ * the call that takes it under the line, not rediscover the limit five times.
210
+ */
211
+ let lastKnownApiRemaining: number | null = null;
212
+
213
+ /** Test seam, and what an explicit refresh should call. */
214
+ export function resetCommunityApiHeadroom(): void {
215
+ lastKnownApiRemaining = null;
216
+ }
217
+
218
+ /** What we last heard about the shared `api.github.com` budget. */
219
+ export function apiRemaining(): number | null {
220
+ return lastKnownApiRemaining;
221
+ }
222
+
223
+ export interface CommitPin {
224
+ /** Short sha, or null when we could not resolve one. Never blocks a fetch. */
225
+ commit: string | null;
226
+ /** Why it is null, for the detail panel. Absent on success. */
227
+ reason?: string;
228
+ }
229
+
230
+ /**
231
+ * Resolve a repo's styles directory to the latest commit that touched it.
232
+ *
233
+ * ONE API call for a whole batch. The content of any file in that directory at
234
+ * branch head is identical to its content at that commit, so this is a correct
235
+ * and reproducible pin for every style in the repo — verified in the design
236
+ * spike, where a sha-addressed fetch was byte-identical to a branch fetch.
237
+ */
238
+ export async function resolveCommitPin(args: {
239
+ source: CommunityStyleSource;
240
+ fetcher: StyleFetcher;
241
+ now?: number;
242
+ }): Promise<CommitPin> {
243
+ const { source, fetcher } = args;
244
+ const host = "api.github.com" as const;
245
+
246
+ if (lastKnownApiRemaining !== null && lastKnownApiRemaining < API_RESERVE) {
247
+ return {
248
+ commit: null,
249
+ reason: `GitHub API budget nearly spent (${lastKnownApiRemaining} left) — holding the rest back for other tools`,
250
+ };
251
+ }
252
+
253
+ await hydrateGitHubBudget();
254
+ const cooling = cooldownFor(host, args.now);
255
+ if (cooling)
256
+ return { commit: null, reason: describeCooldown(cooling, args.now) };
257
+
258
+ const query = new URLSearchParams({ per_page: "1", path: source.dir });
259
+ const url = `https://${host}/repos/${source.repo}/commits?${query}`;
260
+
261
+ try {
262
+ const response = await fetcher(url, {
263
+ accept: "application/vnd.github+json",
264
+ });
265
+
266
+ const remaining = remainingFromHeaders(response.headers);
267
+ if (remaining !== null) lastKnownApiRemaining = remaining;
268
+
269
+ if (response.status === 429 || response.status === 403) {
270
+ const cooldown = recordRateLimit(host, response.headers, args.now);
271
+ return { commit: null, reason: describeCooldown(cooldown, args.now) };
272
+ }
273
+ if (response.status !== 200) {
274
+ return {
275
+ commit: null,
276
+ reason: `HTTP ${response.status} resolving the commit pin`,
277
+ };
278
+ }
279
+
280
+ recordSuccess(host);
281
+ const parsed = JSON.parse(response.body) as Array<{ sha?: unknown }>;
282
+ const sha = Array.isArray(parsed) ? parsed[0]?.sha : undefined;
283
+ if (typeof sha !== "string" || !/^[0-9a-f]{7,40}$/.test(sha)) {
284
+ return {
285
+ commit: null,
286
+ reason: "GitHub returned no commit for this path",
287
+ };
288
+ }
289
+ return { commit: sha.slice(0, 12) };
290
+ } catch (error) {
291
+ return { commit: null, reason: classifyThrown(error, host).detail };
292
+ }
293
+ }
294
+
295
+ // ─── Fetching a style ────────────────────────────────────────────────────────
296
+
297
+ export interface FetchedCommunityStyleFile {
298
+ /** Coordinate id, e.g. `attention-span--spartan`. */
299
+ id: string;
300
+ /** Where it was written. */
301
+ path: string;
302
+ /** `sha256:<64 hex>` over the RAW UPSTREAM BYTES — see the note in `write`. */
303
+ sha256: string;
304
+ /** Short commit sha, or null when the pin could not be resolved. */
305
+ commit: string | null;
306
+ /** Upstream's own description, kept verbatim. */
307
+ description: string;
308
+ bytes: number;
309
+ }
310
+
311
+ export type CommunityFetchResult =
312
+ | { outcome: "written"; file: FetchedCommunityStyleFile }
313
+ /** 304: upstream says our copy is current. Nothing was rewritten. */
314
+ | { outcome: "unchanged"; path: string }
315
+ | { outcome: "failed"; failure: CommunityFetchFailure };
316
+
317
+ export interface FetchCommunityStyleArgs {
318
+ style: CommunityStyle;
319
+ source: CommunityStyleSource;
320
+ /** Required, never defaulted — that is what stops a test reaching GitHub. */
321
+ fetcher: StyleFetcher;
322
+ /** Resolved cache directory. Callers get it from `communityCacheDirOrNull`. */
323
+ cacheDir: string;
324
+ /** Resolved once per batch by `resolveCommitPin`. */
325
+ commit?: string | null;
326
+ /** Conditional GET, for the staleness path. */
327
+ etag?: string | null;
328
+ now?: number;
329
+ }
330
+
331
+ /**
332
+ * Fetch one style and write it into the cache directory.
333
+ *
334
+ * Throws only for a bad coordinate, which is our data and therefore our bug.
335
+ * Everything a user or a network can cause comes back as a classified failure,
336
+ * because a thrown error at a keypress is a stack trace and a classified one is
337
+ * a sentence.
338
+ */
339
+ export async function fetchCommunityStyle(
340
+ args: FetchCommunityStyleArgs,
341
+ ): Promise<CommunityFetchResult> {
342
+ const { style, source, fetcher, cacheDir } = args;
343
+
344
+ // Before ANY I/O. A `..` reaching the URL builder would be both an SSRF and
345
+ // a traversal in the destination filename.
346
+ const problem = coordinateProblem(style, source);
347
+ if (problem) {
348
+ throw new Error(
349
+ `community registry entry "${style.id}" is invalid: ${problem}`,
350
+ );
351
+ }
352
+
353
+ const host = "raw.githubusercontent.com" as const;
354
+ await hydrateGitHubBudget();
355
+ const cooling = cooldownFor(host, args.now);
356
+ if (cooling) return { outcome: "failed", failure: rateLimited(cooling) };
357
+
358
+ const url = rawUrlFor(style, source);
359
+ let response: Awaited<ReturnType<StyleFetcher>>;
360
+ try {
361
+ response = await fetcher(url, { etag: args.etag });
362
+ } catch (error) {
363
+ return { outcome: "failed", failure: classifyThrown(error, host) };
364
+ }
365
+
366
+ if (response.status === 304) {
367
+ return { outcome: "unchanged", path: cachePathFor(cacheDir, style.id) };
368
+ }
369
+ if (response.status === 429 || response.status === 403) {
370
+ const cooldown = recordRateLimit(host, response.headers, args.now);
371
+ return {
372
+ outcome: "failed",
373
+ failure: rateLimited(cooldown, response.status),
374
+ };
375
+ }
376
+ if (response.status === 404) {
377
+ return {
378
+ outcome: "failed",
379
+ failure: { kind: "not-found", detail: NOT_FOUND_DETAIL, httpStatus: 404 },
380
+ };
381
+ }
382
+ if (response.status < 200 || response.status >= 300) {
383
+ return {
384
+ outcome: "failed",
385
+ failure: {
386
+ kind: "http",
387
+ detail: `HTTP ${response.status} from ${host}`,
388
+ httpStatus: response.status,
389
+ },
390
+ };
391
+ }
392
+
393
+ // Absent content-type is not an error — a fake omits it and raw.github always
394
+ // sends one. Present-and-wrong is: it means we asked for the wrong thing.
395
+ if (response.contentType && !response.contentType.includes("text/plain")) {
396
+ return {
397
+ outcome: "failed",
398
+ failure: {
399
+ kind: "invalid",
400
+ detail: `unexpected content-type "${response.contentType}" — expected text/plain`,
401
+ },
402
+ };
403
+ }
404
+
405
+ const bytes = Buffer.byteLength(response.body, "utf8");
406
+ if (bytes > MAX_STYLE_BYTES) {
407
+ return {
408
+ outcome: "failed",
409
+ failure: {
410
+ kind: "too-large",
411
+ detail: `${describeBytes(bytes)} exceeds the ${describeBytes(MAX_STYLE_BYTES)} limit — a style this size would be re-sent on every request`,
412
+ },
413
+ };
414
+ }
415
+
416
+ const { frontmatter, body } = splitFrontmatter(response.body);
417
+ if (Object.keys(frontmatter).length === 0 || body.trim().length === 0) {
418
+ return {
419
+ outcome: "failed",
420
+ failure: {
421
+ kind: "invalid",
422
+ detail:
423
+ "upstream file is not a valid output style — it has no frontmatter, or no body",
424
+ },
425
+ };
426
+ }
427
+
428
+ recordSuccess(host);
429
+
430
+ const file = await writeCachedStyle({
431
+ cacheDir,
432
+ style,
433
+ source,
434
+ upstream: response.body,
435
+ description: frontmatter.description ?? "",
436
+ body,
437
+ commit: args.commit ?? null,
438
+ now: args.now,
439
+ });
440
+ return { outcome: "written", file };
441
+ }
442
+
443
+ export interface FetchSourceResult {
444
+ source: CommunityStyleSource;
445
+ pin: CommitPin;
446
+ results: Array<{ style: CommunityStyle; result: CommunityFetchResult }>;
447
+ }
448
+
449
+ /**
450
+ * Fetch one or more styles from a single repo.
451
+ *
452
+ * The batch exists for the pin: one `api.github.com` call serves every style in
453
+ * the repo, which is the difference between ~8% of the machine's hourly budget
454
+ * and all of it.
455
+ */
456
+ export async function fetchSource(args: {
457
+ source: CommunityStyleSource;
458
+ styles: CommunityStyle[];
459
+ fetcher: StyleFetcher;
460
+ cacheDir: string;
461
+ now?: number;
462
+ }): Promise<FetchSourceResult> {
463
+ const pin = await resolveCommitPin({
464
+ source: args.source,
465
+ fetcher: args.fetcher,
466
+ now: args.now,
467
+ });
468
+
469
+ const results: FetchSourceResult["results"] = [];
470
+ for (const style of args.styles) {
471
+ results.push({
472
+ style,
473
+ result: await fetchCommunityStyle({
474
+ style,
475
+ source: args.source,
476
+ fetcher: args.fetcher,
477
+ cacheDir: args.cacheDir,
478
+ commit: pin.commit,
479
+ now: args.now,
480
+ }),
481
+ });
482
+ }
483
+ return { source: args.source, pin, results };
484
+ }
485
+
486
+ // ─── Writing ─────────────────────────────────────────────────────────────────
487
+
488
+ /** `<sourceId>--<slug>.md`, flat. The id is already filename-safe; see §2.1. */
489
+ export function cachePathFor(cacheDir: string, id: string): string {
490
+ return path.join(cacheDir, `${id}.md`);
491
+ }
492
+
493
+ function describeBytes(bytes: number): string {
494
+ return bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} KiB`;
495
+ }
496
+
497
+ /**
498
+ * Emit a value that survives `splitFrontmatter`'s round trip.
499
+ *
500
+ * Always quoted: an upstream description routinely carries `: `, which YAML
501
+ * reads as a nested mapping, and the frontmatter then fails to parse. Newlines
502
+ * are collapsed because the parser is line-based — a multi-line value would
503
+ * silently truncate at the first newline and leave the rest as a stray key.
504
+ */
505
+ function yamlScalar(value: string): string {
506
+ const flat = value.replace(/\s+/g, " ").trim();
507
+ return `"${flat.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
508
+ }
509
+
510
+ /** Local date, matching `fileDate()` — the user reads this against their own calendar. */
511
+ function localDate(now: number): string {
512
+ const date = new Date(now);
513
+ const pad = (n: number) => String(n).padStart(2, "0");
514
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
515
+ }
516
+
517
+ async function writeCachedStyle(args: {
518
+ cacheDir: string;
519
+ style: CommunityStyle;
520
+ source: CommunityStyleSource;
521
+ /** The bytes as served, for the content hash. */
522
+ upstream: string;
523
+ description: string;
524
+ body: string;
525
+ commit: string | null;
526
+ now?: number;
527
+ }): Promise<FetchedCommunityStyleFile> {
528
+ const { cacheDir, style, source, body } = args;
529
+ const now = args.now ?? Date.now();
530
+
531
+ // Hashed over the RAW UPSTREAM BYTES, not over what we write. The staleness
532
+ // check compares this against a fresh raw fetch; hashing our rewritten copy
533
+ // would mean it never matched and every style read as changed forever.
534
+ const sha256 = `sha256:${createHash("sha256").update(args.upstream).digest("hex")}`;
535
+
536
+ // Upstream frontmatter is REWRITTEN, not passed through. We keep exactly one
537
+ // upstream value — `description` — and force `keep-coding-instructions`.
538
+ // A style that set it false would strip Claude Code's coding-discipline
539
+ // block; `composeStyleFile` already forces the key on the composed file, so
540
+ // this is defence in depth, and it also drops any harness-recognised key we
541
+ // have not heard of yet.
542
+ const lines = [
543
+ "---",
544
+ `name: ${style.id}`,
545
+ `description: ${yamlScalar(args.description)}`,
546
+ "keep-coding-instructions: true",
547
+ // THE marker. Classification is by this key, never by directory — a copy
548
+ // the user moves into ~/.claude/output-styles/ still says where the words
549
+ // came from.
550
+ `community-source: ${source.repo}`,
551
+ `community-path: ${style.path}`,
552
+ `community-ref: ${source.ref}`,
553
+ `community-commit: ${args.commit ?? "unknown"}`,
554
+ `community-sha256: ${sha256}`,
555
+ `community-fetched: ${localDate(now)}`,
556
+ `community-licence: ${source.licence ?? "unlicensed"}`,
557
+ `community-author: ${source.author}`,
558
+ "---",
559
+ "",
560
+ body,
561
+ "",
562
+ ];
563
+ const contents = `${lines
564
+ .join("\n")
565
+ .replace(/\n{3,}/g, "\n\n")
566
+ .trimEnd()}\n`;
567
+
568
+ const file = cachePathFor(cacheDir, style.id);
569
+ await fs.ensureDir(cacheDir);
570
+ // Temp + rename, same as catalog-cache-store: a crash mid-write must not
571
+ // leave a half-written style that the next load reads as a valid one.
572
+ const tmp = `${file}.${process.pid}.tmp`;
573
+ await fs.writeFile(tmp, contents, "utf8");
574
+ await fs.rename(tmp, file);
575
+
576
+ return {
577
+ id: style.id,
578
+ path: file,
579
+ sha256,
580
+ commit: args.commit ?? null,
581
+ description: args.description,
582
+ bytes: Buffer.byteLength(contents, "utf8"),
583
+ };
584
+ }
585
+
586
+ // ─── Fetching a set of styles ────────────────────────────────────────────────
587
+
588
+ export interface FetchManyResult {
589
+ written: FetchedCommunityStyleFile[];
590
+ unchanged: string[];
591
+ failed: Array<{ id: string; label: string; failure: CommunityFetchFailure }>;
592
+ /** Coordinate ids that name no registry entry. Ours to fix, so they are named. */
593
+ unknown: string[];
594
+ }
595
+
596
+ /**
597
+ * Fetch every named coordinate, grouped so each repo costs ONE api.github.com
598
+ * call.
599
+ *
600
+ * The grouping is the whole point and is easy to lose: fetching twenty-two
601
+ * styles one at a time would resolve twenty-two pins and spend a third of the
602
+ * machine's hourly budget, where five batched pins spend 8%.
603
+ */
604
+ export async function fetchCommunityStyles(args: {
605
+ /** Coordinate ids WITHOUT the `community:` prefix. */
606
+ ids: string[];
607
+ fetcher: StyleFetcher;
608
+ cacheDir: string;
609
+ now?: number;
610
+ }): Promise<FetchManyResult> {
611
+ const out: FetchManyResult = {
612
+ written: [],
613
+ unchanged: [],
614
+ failed: [],
615
+ unknown: [],
616
+ };
617
+
618
+ const bySource = new Map<
619
+ string,
620
+ { source: CommunityStyleSource; styles: CommunityStyle[] }
621
+ >();
622
+ for (const id of args.ids) {
623
+ const resolved = resolveCommunityStyle(id);
624
+ if (!resolved) {
625
+ out.unknown.push(id);
626
+ continue;
627
+ }
628
+ const group = bySource.get(resolved.source.id) ?? {
629
+ source: resolved.source,
630
+ styles: [],
631
+ };
632
+ group.styles.push(resolved.style);
633
+ bySource.set(resolved.source.id, group);
634
+ }
635
+
636
+ for (const { source, styles } of bySource.values()) {
637
+ const batch = await fetchSource({
638
+ source,
639
+ styles,
640
+ fetcher: args.fetcher,
641
+ cacheDir: args.cacheDir,
642
+ now: args.now,
643
+ });
644
+ for (const { style, result } of batch.results) {
645
+ if (result.outcome === "written") {
646
+ out.written.push(result.file);
647
+ // A re-fetch supersedes any update that was waiting to be accepted:
648
+ // the live file now holds the current bytes. Leaving the pending
649
+ // file and its store entry behind would keep the row advertising
650
+ // "(new)" for an update the user has already taken.
651
+ await discardPending(args.cacheDir, style.id, result.file.sha256);
652
+ } else if (result.outcome === "unchanged") out.unchanged.push(style.id);
653
+ else {
654
+ out.failed.push({
655
+ id: style.id,
656
+ label: style.displayName,
657
+ failure: result.failure,
658
+ });
659
+ }
660
+ }
661
+ }
662
+
663
+ return out;
664
+ }
665
+
666
+ /**
667
+ * Drop a waiting update and record the bytes now installed.
668
+ *
669
+ * Called after a direct fetch, which is the case the store cannot infer: the
670
+ * file on disk changed without a check running, so the recorded sha and any
671
+ * pending marker are both out of date the instant the write lands.
672
+ */
673
+ async function discardPending(
674
+ cacheDir: string,
675
+ id: string,
676
+ sha256: string,
677
+ ): Promise<void> {
678
+ await fs.remove(pendingPathFor(cacheDir, id));
679
+ const resolved = resolveCommunityStyle(id);
680
+ if (!resolved) return;
681
+ const check = (await readCommunityChecks())[resolved.source.id];
682
+ if (!check) return;
683
+ check.styles[id] = { sha256 };
684
+ await writeCommunityCheck(resolved.source.id, check);
685
+ }
686
+
687
+ /**
688
+ * The one line to show a user when a fetch failed.
689
+ *
690
+ * Per KIND, not one generic wrapper, because the kinds differ in who has to do
691
+ * something about it: a 404 is our registry being wrong, a rate limit has a
692
+ * documented escape hatch, a DNS failure is usually a VPN on this machine, and
693
+ * `too-large` / `invalid` are refusals rather than failures. Collapsing them to
694
+ * "could not fetch X" would throw all of that away at the exact moment the user
695
+ * needs it.
696
+ */
697
+ export function describeCommunityFailure(
698
+ failure: CommunityFetchFailure,
699
+ label: string,
700
+ ): string {
701
+ switch (failure.kind) {
702
+ case "rate-limited":
703
+ return `${label} not fetched — ${failure.detail}. Set GITHUB_TOKEN to raise the limit.`;
704
+ case "not-found":
705
+ // Reads as our bug because it is one: the coordinate set is a constant in
706
+ // a reviewed commit, so a 404 means the registry entry went stale.
707
+ return `${label}: ${failure.detail}`;
708
+ case "dns":
709
+ // Stands alone: the detail is three sentences of diagnosis, and a label
710
+ // in front of it would push the useful half off a one-row status line.
711
+ return failure.detail;
712
+ case "offline":
713
+ return `${label} not fetched — ${failure.detail}`;
714
+ case "timeout":
715
+ return `${label} not fetched — ${failure.detail}`;
716
+ case "too-large":
717
+ return `${label} refused — ${failure.detail}`;
718
+ case "invalid":
719
+ return `${label} refused — ${failure.detail}`;
720
+ case "http":
721
+ return `${label} not fetched — ${failure.detail}`;
722
+ case "network":
723
+ return `${label} not fetched — ${failure.detail}`;
724
+ }
725
+ }
726
+
727
+ // ─── Staleness: advisory, two-phase, never automatic ─────────────────────────
728
+
729
+ /**
730
+ * 24 hours, matching image-generate's staleness precedent in the root CLAUDE.md.
731
+ *
732
+ * The TTL is not only a "when to re-check" rule — it is also a "how long may we
733
+ * keep SAYING up-to-date" rule. A check from three days ago is evidence about
734
+ * three days ago, so past this window a recorded pass degrades to `unknown`
735
+ * rather than continuing to assert currency. See `statusFromStore`.
736
+ */
737
+ export const COMMUNITY_CHECK_TTL_MS = 24 * 60 * 60 * 1000;
738
+
739
+ export type CommunityUpstreamState =
740
+ /** Verified current: phase A sha matched, or phase B bytes were identical. */
741
+ | "up-to-date"
742
+ /** Phase B found different bytes. They are in `.pending/`, not installed. */
743
+ | "update-available"
744
+ /** Never checked, TTL expired, rate-limited, offline, or any error. */
745
+ | "unknown";
746
+
747
+ export interface CommunityUpstreamStatus {
748
+ state: CommunityUpstreamState;
749
+ /** One line, rendered verbatim. For `unknown` this is the REASON. */
750
+ detail: string;
751
+ /** Wall-clock ms of the check this came from, or null if there was none. */
752
+ checkedAt: number | null;
753
+ /** Lines that differ between the installed copy and the pending one. */
754
+ changedLines?: number;
755
+ }
756
+
757
+ /** Downloaded-but-not-accepted updates. A subdirectory, so discovery skips it. */
758
+ export function pendingDirFor(cacheDir: string): string {
759
+ return path.join(cacheDir, ".pending");
760
+ }
761
+
762
+ export function pendingPathFor(cacheDir: string, id: string): string {
763
+ return cachePathFor(pendingDirFor(cacheDir), id);
764
+ }
765
+
766
+ /**
767
+ * Insertions plus deletions between two texts, by line.
768
+ *
769
+ * A real LCS rather than a length difference, because the number is shown to the
770
+ * user as the reason to accept or refuse an update, and "12 lines changed" for a
771
+ * reordering that changed nothing would be an invitation to stop trusting it.
772
+ * Bounded: past a million cell pairs the DP is abandoned for the cheap estimate,
773
+ * which cannot happen for a file capped at 64 KiB but keeps the function safe if
774
+ * that cap ever moves.
775
+ */
776
+ export function countChangedLines(before: string, after: string): number {
777
+ const a = before.split("\n");
778
+ const b = after.split("\n");
779
+ if (a.length * b.length > 1_000_000) {
780
+ return Math.abs(a.length - b.length) + Math.min(a.length, b.length);
781
+ }
782
+
783
+ // Trim the common head and tail first: edits cluster, and this usually leaves
784
+ // the DP a few lines instead of a few hundred.
785
+ let head = 0;
786
+ while (head < a.length && head < b.length && a[head] === b[head]) head++;
787
+ let tail = 0;
788
+ while (
789
+ tail < a.length - head &&
790
+ tail < b.length - head &&
791
+ a[a.length - 1 - tail] === b[b.length - 1 - tail]
792
+ ) {
793
+ tail++;
794
+ }
795
+ const left = a.slice(head, a.length - tail);
796
+ const right = b.slice(head, b.length - tail);
797
+ if (left.length === 0 || right.length === 0) {
798
+ return left.length + right.length;
799
+ }
800
+
801
+ const table: number[][] = Array.from({ length: left.length + 1 }, () =>
802
+ new Array<number>(right.length + 1).fill(0),
803
+ );
804
+ for (let i = 1; i <= left.length; i++) {
805
+ for (let j = 1; j <= right.length; j++) {
806
+ table[i][j] =
807
+ left[i - 1] === right[j - 1]
808
+ ? table[i - 1][j - 1] + 1
809
+ : Math.max(table[i - 1][j], table[i][j - 1]);
810
+ }
811
+ }
812
+ const common = table[left.length][right.length];
813
+ return left.length - common + (right.length - common);
814
+ }
815
+
816
+ /** A cached style as the checker needs it: an id and the bytes we hold. */
817
+ export interface CachedCommunityStyle {
818
+ /** Coordinate id, `attention-span--spartan`. */
819
+ id: string;
820
+ /** `sha256:…` from the file's own frontmatter, or null if it carries none. */
821
+ sha256: string | null;
822
+ }
823
+
824
+ /**
825
+ * What the store alone can say about one style, with no network call.
826
+ *
827
+ * `up-to-date` is the only state with an expiry. A pending update is a FILE on
828
+ * disk — a fact, not an inference — so it keeps reporting `update-available`
829
+ * however old the check was. Currency is the claim that decays, and letting a
830
+ * three-day-old pass keep asserting it is exactly the fail-closed clause this
831
+ * design exists to honour.
832
+ */
833
+ function statusFromStore(
834
+ check: StoredCommunityCheck | undefined,
835
+ id: string,
836
+ now: number,
837
+ ): CommunityUpstreamStatus {
838
+ const entry = check?.styles[id];
839
+ if (!check || !entry) {
840
+ return { state: "unknown", detail: "not checked", checkedAt: null };
841
+ }
842
+ if (entry.pendingSha256) {
843
+ return {
844
+ state: "update-available",
845
+ detail: `newer version available — ${entry.pendingLines ?? 0} lines changed, press f to accept`,
846
+ checkedAt: check.checkedAt,
847
+ changedLines: entry.pendingLines ?? 0,
848
+ };
849
+ }
850
+ if (now - check.checkedAt > COMMUNITY_CHECK_TTL_MS) {
851
+ return {
852
+ state: "unknown",
853
+ detail: "not checked in the last 24h — press u",
854
+ checkedAt: check.checkedAt,
855
+ };
856
+ }
857
+ return {
858
+ state: "up-to-date",
859
+ detail: "up to date",
860
+ checkedAt: check.checkedAt,
861
+ };
862
+ }
863
+
864
+ /**
865
+ * Every recorded status, keyed by coordinate id. Reads the store, nothing else.
866
+ *
867
+ * This is what the screen renders. It makes no network call in any phase, which
868
+ * is what keeps the update advisory an advisory: nothing is checked because a
869
+ * panel was drawn.
870
+ */
871
+ export async function readCommunityStatuses(
872
+ now: number = Date.now(),
873
+ ): Promise<Record<string, CommunityUpstreamStatus>> {
874
+ const checks = await readCommunityChecks();
875
+ const out: Record<string, CommunityUpstreamStatus> = {};
876
+ for (const check of Object.values(checks)) {
877
+ for (const id of Object.keys(check.styles)) {
878
+ out[id] = statusFromStore(check, id, now);
879
+ }
880
+ }
881
+ return out;
882
+ }
883
+
884
+ export interface CheckSourceResult {
885
+ source: CommunityStyleSource;
886
+ statuses: Record<string, CommunityUpstreamStatus>;
887
+ /** api.github.com calls this check actually made: 1, or 0 when served by TTL. */
888
+ apiCalls: number;
889
+ }
890
+
891
+ /**
892
+ * Ask whether a repo's cached styles have moved. Advisory, never automatic.
893
+ *
894
+ * Two phases, priced deliberately:
895
+ *
896
+ * A one `GET /repos/{repo}/commits?path={dir}` for the WHOLE repo — did
897
+ * anything in its styles directory move since we last looked?
898
+ * B only for a repo that moved: a free raw GET per cached style, sha256
899
+ * compared against the bytes we hold.
900
+ *
901
+ * Fifty-seven per-style API calls would exhaust the machine's shared hourly
902
+ * budget on first use and break `gh` for everything else on the box. Five
903
+ * per-repo calls plus free raw drilldown costs about 8% of it.
904
+ *
905
+ * Phase B DOWNLOADS into `.pending/` and stops. Installing it silently would put
906
+ * new third-party words into the system prompt of a style that is already
907
+ * applied — which is why "never silently re-fetch" is a security property here
908
+ * and not a UX preference.
909
+ */
910
+ export async function checkSourceForUpdates(args: {
911
+ source: CommunityStyleSource;
912
+ cached: CachedCommunityStyle[];
913
+ fetcher: StyleFetcher;
914
+ cacheDir: string;
915
+ now?: number;
916
+ /** Skip the TTL. `u` and `U` are explicit requests, so they always force. */
917
+ force?: boolean;
918
+ }): Promise<CheckSourceResult> {
919
+ const { source, cached, fetcher, cacheDir } = args;
920
+ const now = args.now ?? Date.now();
921
+ const statuses: Record<string, CommunityUpstreamStatus> = {};
922
+
923
+ if (cached.length === 0) return { source, statuses, apiCalls: 0 };
924
+
925
+ const stored = (await readCommunityChecks())[source.id];
926
+
927
+ if (
928
+ !args.force &&
929
+ stored &&
930
+ now - stored.checkedAt <= COMMUNITY_CHECK_TTL_MS
931
+ ) {
932
+ for (const style of cached) {
933
+ statuses[style.id] = statusFromStore(stored, style.id, now);
934
+ }
935
+ return { source, statuses, apiCalls: 0 };
936
+ }
937
+
938
+ // ── Phase A ──────────────────────────────────────────────────────────────
939
+ const pin = await resolveCommitPin({ source, fetcher, now });
940
+ if (!pin.commit) {
941
+ // FAIL CLOSED. A rate limit, an offline box or a budget reserve produces
942
+ // `unknown` with the reason — never a quiet "up to date", which is the one
943
+ // answer we are not entitled to give when we could not look.
944
+ for (const style of cached) {
945
+ statuses[style.id] = {
946
+ state: "unknown",
947
+ detail: pin.reason ?? "could not reach GitHub",
948
+ checkedAt: stored?.checkedAt ?? null,
949
+ };
950
+ }
951
+ return { source, statuses, apiCalls: 1 };
952
+ }
953
+
954
+ if (stored && stored.dirHeadSha === pin.commit) {
955
+ // Nothing in the directory moved, so nothing in it can have changed. This
956
+ // is the branch that makes the whole design affordable — the common case
957
+ // costs one API call for the repo and no raw fetches at all.
958
+ const next: StoredCommunityCheck = {
959
+ checkedAt: now,
960
+ dirHeadSha: pin.commit,
961
+ styles: { ...stored.styles },
962
+ };
963
+ await writeCommunityCheck(source.id, next);
964
+ for (const style of cached) {
965
+ statuses[style.id] = statusFromStore(next, style.id, now);
966
+ }
967
+ return { source, statuses, apiCalls: 1 };
968
+ }
969
+
970
+ // ── Phase B ──────────────────────────────────────────────────────────────
971
+ const pendingDir = pendingDirFor(cacheDir);
972
+ const nextStyles: StoredCommunityCheck["styles"] = {};
973
+ /**
974
+ * False once any style in this batch could not be verified.
975
+ *
976
+ * An incomplete check must not be RECORDED as a complete one. If it were, the
977
+ * new `dirHeadSha` would be stored, the next phase A would short-circuit on
978
+ * "nothing moved", and a style whose bytes were never compared would start
979
+ * reading as up to date on the strength of a check that failed.
980
+ */
981
+ let complete = true;
982
+
983
+ for (const entry of cached) {
984
+ const resolved = resolveCommunityStyle(entry.id);
985
+ if (!resolved) {
986
+ // Fetched before the entry was retired, or from a registry we no longer
987
+ // ship. There is no coordinate to re-fetch, so there is nothing to say.
988
+ statuses[entry.id] = {
989
+ state: "unknown",
990
+ detail: "no longer in claudeup's registry — this copy still works",
991
+ checkedAt: stored?.checkedAt ?? null,
992
+ };
993
+ // Not a failure of the check: there is no coordinate, so there is
994
+ // nothing this check could ever have told us about it.
995
+ continue;
996
+ }
997
+ if (!entry.sha256) {
998
+ statuses[entry.id] = {
999
+ state: "unknown",
1000
+ detail: "this copy records no content hash — press f to re-fetch it",
1001
+ checkedAt: stored?.checkedAt ?? null,
1002
+ };
1003
+ continue;
1004
+ }
1005
+
1006
+ // Deliberately the same call the fetch path uses, writing into `.pending/`
1007
+ // rather than over the live file. Reimplementing the download here would
1008
+ // fork the validation, the frontmatter rewriting and the error taxonomy —
1009
+ // three things that must not differ between "fetch" and "check for a
1010
+ // newer fetch".
1011
+ const result = await fetchCommunityStyle({
1012
+ style: resolved.style,
1013
+ source,
1014
+ fetcher,
1015
+ cacheDir: pendingDir,
1016
+ commit: pin.commit,
1017
+ now,
1018
+ });
1019
+
1020
+ if (result.outcome === "failed") {
1021
+ statuses[entry.id] = {
1022
+ state: "unknown",
1023
+ detail: describeCommunityFailure(
1024
+ result.failure,
1025
+ resolved.style.displayName,
1026
+ ),
1027
+ checkedAt: stored?.checkedAt ?? null,
1028
+ };
1029
+ // Left OUT of the recorded styles, so the next check cannot infer a pass
1030
+ // for it from the directory sha alone.
1031
+ complete = false;
1032
+ continue;
1033
+ }
1034
+ if (result.outcome === "unchanged") {
1035
+ statuses[entry.id] = {
1036
+ state: "up-to-date",
1037
+ detail: "up to date",
1038
+ checkedAt: now,
1039
+ };
1040
+ nextStyles[entry.id] = { sha256: entry.sha256 };
1041
+ continue;
1042
+ }
1043
+
1044
+ if (result.file.sha256 === entry.sha256) {
1045
+ // The directory moved but this file did not — a commit touching one of
1046
+ // the repo's other styles. Remove the download; keeping it would leave a
1047
+ // pending update that is byte-identical to what is installed.
1048
+ await fs.remove(result.file.path);
1049
+ statuses[entry.id] = {
1050
+ state: "up-to-date",
1051
+ detail: "up to date",
1052
+ checkedAt: now,
1053
+ };
1054
+ nextStyles[entry.id] = { sha256: entry.sha256 };
1055
+ continue;
1056
+ }
1057
+
1058
+ const changedLines = await diffAgainstInstalled(
1059
+ cachePathFor(cacheDir, entry.id),
1060
+ result.file.path,
1061
+ );
1062
+ nextStyles[entry.id] = {
1063
+ sha256: entry.sha256,
1064
+ pendingSha256: result.file.sha256,
1065
+ pendingLines: changedLines,
1066
+ };
1067
+ statuses[entry.id] = {
1068
+ state: "update-available",
1069
+ detail: `newer version available — ${changedLines} lines changed, press f to accept`,
1070
+ checkedAt: now,
1071
+ changedLines,
1072
+ };
1073
+ }
1074
+
1075
+ await writeCommunityCheck(source.id, {
1076
+ checkedAt: now,
1077
+ // An incomplete pass keeps the OLD directory sha — or none, if there was
1078
+ // none — so the next check drills in again instead of short-circuiting on
1079
+ // a head we never finished verifying against.
1080
+ dirHeadSha: complete ? pin.commit : (stored?.dirHeadSha ?? ""),
1081
+ styles: nextStyles,
1082
+ });
1083
+
1084
+ return { source, statuses, apiCalls: 1 };
1085
+ }
1086
+
1087
+ /** Lines differing between the installed copy and a pending one. 0 if unreadable. */
1088
+ async function diffAgainstInstalled(
1089
+ installed: string,
1090
+ pending: string,
1091
+ ): Promise<number> {
1092
+ try {
1093
+ const [before, after] = await Promise.all([
1094
+ fs.readFile(installed, "utf8"),
1095
+ fs.readFile(pending, "utf8"),
1096
+ ]);
1097
+ // Bodies only. The frontmatter differs on every fetch — `community-fetched`
1098
+ // and `community-commit` change whether or not a single word did — so
1099
+ // counting it would report "3 lines changed" for an identical style.
1100
+ return countChangedLines(
1101
+ splitFrontmatter(before).body,
1102
+ splitFrontmatter(after).body,
1103
+ );
1104
+ } catch {
1105
+ return 0;
1106
+ }
1107
+ }
1108
+
1109
+ export interface CheckAllResult {
1110
+ statuses: Record<string, CommunityUpstreamStatus>;
1111
+ /** Total api.github.com calls. At most one per repo with a cached style. */
1112
+ apiCalls: number;
1113
+ }
1114
+
1115
+ /**
1116
+ * Check every repo that has at least one cached style.
1117
+ *
1118
+ * At most five API calls today, and at most one per repo however many styles the
1119
+ * registry grows to — the cost is a function of REPOS, which is the property the
1120
+ * whole two-phase design is protecting.
1121
+ */
1122
+ export async function checkAllSources(args: {
1123
+ cached: CachedCommunityStyle[];
1124
+ fetcher: StyleFetcher;
1125
+ cacheDir: string;
1126
+ now?: number;
1127
+ force?: boolean;
1128
+ }): Promise<CheckAllResult> {
1129
+ const bySource = new Map<string, CachedCommunityStyle[]>();
1130
+ for (const entry of args.cached) {
1131
+ const resolved = resolveCommunityStyle(entry.id);
1132
+ if (!resolved) continue;
1133
+ const group = bySource.get(resolved.source.id) ?? [];
1134
+ group.push(entry);
1135
+ bySource.set(resolved.source.id, group);
1136
+ }
1137
+
1138
+ const statuses: Record<string, CommunityUpstreamStatus> = {};
1139
+ let apiCalls = 0;
1140
+ for (const [sourceId, cached] of bySource) {
1141
+ const source = COMMUNITY_SOURCES.find((s) => s.id === sourceId);
1142
+ if (!source) continue;
1143
+ const result = await checkSourceForUpdates({
1144
+ source,
1145
+ cached,
1146
+ fetcher: args.fetcher,
1147
+ cacheDir: args.cacheDir,
1148
+ now: args.now,
1149
+ force: args.force,
1150
+ });
1151
+ Object.assign(statuses, result.statuses);
1152
+ apiCalls += result.apiCalls;
1153
+ }
1154
+ return { statuses, apiCalls };
1155
+ }
1156
+
1157
+ export type AcceptPendingResult =
1158
+ | { outcome: "accepted"; path: string }
1159
+ | { outcome: "none" };
1160
+
1161
+ /**
1162
+ * Install a downloaded update over the copy in use.
1163
+ *
1164
+ * The one place in this feature that changes the bytes of a style that may be
1165
+ * applied right now, and it happens only on a keypress with a changed-line count
1166
+ * already on screen. A rename, not a re-fetch: the user accepts the text they
1167
+ * were shown the diff for, not whatever upstream serves a second later.
1168
+ */
1169
+ export async function acceptPendingUpdate(args: {
1170
+ cacheDir: string;
1171
+ /** Coordinate id, without the `community:` prefix. */
1172
+ id: string;
1173
+ }): Promise<AcceptPendingResult> {
1174
+ const pending = pendingPathFor(args.cacheDir, args.id);
1175
+ if (!(await fs.pathExists(pending))) return { outcome: "none" };
1176
+
1177
+ const live = cachePathFor(args.cacheDir, args.id);
1178
+ const { frontmatter } = splitFrontmatter(await fs.readFile(pending, "utf8"));
1179
+ await fs.move(pending, live, { overwrite: true });
1180
+
1181
+ const resolved = resolveCommunityStyle(args.id);
1182
+ if (resolved) {
1183
+ const checks = await readCommunityChecks();
1184
+ const check = checks[resolved.source.id];
1185
+ if (check) {
1186
+ check.styles[args.id] = {
1187
+ sha256: frontmatter["community-sha256"] ?? "",
1188
+ };
1189
+ await writeCommunityCheck(resolved.source.id, check);
1190
+ }
1191
+ }
1192
+
1193
+ return { outcome: "accepted", path: live };
1194
+ }