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,521 @@
1
+ /**
2
+ * community-styles.ts — the curated registry of third-party output styles.
3
+ *
4
+ * COORDINATES ONLY. This file ships `(repo, path, ref, licence, attribution)`
5
+ * and never a byte of anyone else's style text. The text is fetched from the
6
+ * source repository on an explicit user action and cached outside
7
+ * `~/.claude/output-styles/`; nothing here redistributes it.
8
+ *
9
+ * ## What earns an entry
10
+ *
11
+ * A repo qualifies **iff** walking its git tree finds at least one `*.md` under
12
+ * a styles directory. `SKILL.md` count is irrelevant. That rule is not
13
+ * pedantry: the two highest-starred names in this space ship ZERO style files
14
+ * (see `DENIED_REPOS`), so a registry built from a popularity list would 404 on
15
+ * its most prominent entries.
16
+ *
17
+ * Paths are VERIFIED, never inferred. `scripts/verify-community-registry.ts`
18
+ * lists the real `.md` blobs in a repo (`--discover`) and re-fetches every
19
+ * committed entry (`--check`). A wrong path is a 404 the user sees, and the
20
+ * error message blames us for it, correctly.
21
+ *
22
+ * ## What this file must never become
23
+ *
24
+ * An arbitrary-URL fetcher. The set of fetchable things being a constant in a
25
+ * reviewed commit is the single strongest control on a feature whose output
26
+ * becomes system-prompt text.
27
+ */
28
+
29
+ import { isValidGitHubRepo } from "../utils/string-utils.js";
30
+
31
+ /** SPDX id, or null when the repo grants no redistribution licence. */
32
+ export type StyleLicence = "MIT" | "AGPL-3.0" | "Apache-2.0" | null;
33
+
34
+ /** One upstream repository. Shared by every style it ships. */
35
+ export interface CommunityStyleSource {
36
+ /** Registry slug, and the first half of every id from this repo. Never changes. */
37
+ id: string;
38
+ /** `owner/name`, as GitHub spells it. */
39
+ repo: string;
40
+ /**
41
+ * Directory the styles live in. NOT a convention — four of these use
42
+ * `output-styles/` and one uses `.claude/output-styles/`, so nothing here may
43
+ * glob a fixed name.
44
+ */
45
+ dir: string;
46
+ /** Git ref to fetch content from. `HEAD` means "whatever is current". */
47
+ ref: string;
48
+ licence: StyleLicence;
49
+ licenceUrl: string | null;
50
+ /** Display attribution. */
51
+ author: string;
52
+ homepage: string;
53
+ /** Curation-time snapshot of the last push, for context only. */
54
+ pushedAt: string;
55
+ /**
56
+ * Retired sources are no longer offered but still resolve, so a committed
57
+ * `.claude/style.json` naming one keeps working. Entries are never deleted.
58
+ */
59
+ retired?: true;
60
+ }
61
+
62
+ export interface CommunityStyle {
63
+ /** `<sourceId>--<slug>`. The stable coordinate id — see the note below. */
64
+ id: string;
65
+ sourceId: string;
66
+ /** Path within the repo, INCLUDING the source's `dir`. Verified, never inferred. */
67
+ path: string;
68
+ /** What the list shows: upstream's own `name`, recorded at curation time. */
69
+ displayName: string;
70
+ /** One line, ours, kept short — upstream descriptions vary wildly in length. */
71
+ summary: string;
72
+ /** Retired styles still resolve for an old declaration; see `retired` above. */
73
+ retired?: true;
74
+ }
75
+
76
+ /**
77
+ * Why ids are coordinates rather than the file's own `frontmatter.name`.
78
+ *
79
+ * Three reasons, the third decisive:
80
+ * 1. It survives upstream renaming the style.
81
+ * 2. Upstream names are arbitrary human strings ("Zen Master", "ADHD") and a
82
+ * comma in one corrupts the comma-separated `style-imports` round-trip.
83
+ * 3. A teammate who clones a repo whose `.claude/style.json` names
84
+ * `community:attention-span--spartan` has fetched nothing. To offer "press f
85
+ * to fetch this" rather than a bare "missing", the id must resolve against
86
+ * this registry BEFORE any file exists locally. An id derived from the
87
+ * file's own frontmatter is unknowable until after the fetch.
88
+ */
89
+ export const COMMUNITY_ID_SEPARATOR = "--";
90
+
91
+ // ─── Sources ─────────────────────────────────────────────────────────────────
92
+
93
+ export const COMMUNITY_SOURCES: CommunityStyleSource[] = [
94
+ {
95
+ id: "attention-span",
96
+ repo: "alexgreensh/attention-span",
97
+ dir: "output-styles",
98
+ ref: "HEAD",
99
+ licence: "AGPL-3.0",
100
+ licenceUrl:
101
+ "https://github.com/alexgreensh/attention-span/blob/HEAD/LICENSE",
102
+ author: "alexgreensh",
103
+ homepage: "https://github.com/alexgreensh/attention-span",
104
+ pushedAt: "2026-08-15",
105
+ },
106
+ {
107
+ id: "claude-mods",
108
+ repo: "0xDarkMatter/claude-mods",
109
+ dir: "output-styles",
110
+ ref: "HEAD",
111
+ licence: "MIT",
112
+ licenceUrl: "https://github.com/0xDarkMatter/claude-mods/blob/HEAD/LICENSE",
113
+ author: "0xDarkMatter",
114
+ homepage: "https://github.com/0xDarkMatter/claude-mods",
115
+ pushedAt: "2026-08-15",
116
+ },
117
+ {
118
+ id: "lej-output-fixer",
119
+ repo: "justfinethanku/LEJ-output-fixer",
120
+ dir: "output-styles",
121
+ ref: "HEAD",
122
+ licence: "MIT",
123
+ licenceUrl:
124
+ "https://github.com/justfinethanku/LEJ-output-fixer/blob/HEAD/LICENSE",
125
+ author: "justfinethanku",
126
+ homepage: "https://github.com/justfinethanku/LEJ-output-fixer",
127
+ pushedAt: "2026-08-09",
128
+ },
129
+ {
130
+ // The only source whose styles are NOT in `output-styles/`. Shipping it is
131
+ // what keeps the per-entry `path` exercised in production rather than
132
+ // merely designed for.
133
+ id: "hesreallyhim",
134
+ repo: "hesreallyhim/awesome-claude-code-output-styles-that-i-really-like",
135
+ dir: ".claude/output-styles",
136
+ ref: "HEAD",
137
+ licence: "MIT",
138
+ licenceUrl:
139
+ "https://github.com/hesreallyhim/awesome-claude-code-output-styles-that-i-really-like/blob/HEAD/LICENSE",
140
+ author: "hesreallyhim",
141
+ homepage:
142
+ "https://github.com/hesreallyhim/awesome-claude-code-output-styles-that-i-really-like",
143
+ pushedAt: "2025-11-21",
144
+ },
145
+ {
146
+ id: "nattergabriel",
147
+ repo: "nattergabriel/claude-code-output-styles",
148
+ dir: "output-styles",
149
+ ref: "HEAD",
150
+ licence: "MIT",
151
+ licenceUrl:
152
+ "https://github.com/nattergabriel/claude-code-output-styles/blob/HEAD/LICENSE",
153
+ author: "nattergabriel",
154
+ homepage: "https://github.com/nattergabriel/claude-code-output-styles",
155
+ pushedAt: "2026-04-03",
156
+ },
157
+ {
158
+ id: "simple-english",
159
+ repo: "AminBlg/SimpleEnglish",
160
+ dir: "output-styles",
161
+ ref: "HEAD",
162
+ licence: "MIT",
163
+ licenceUrl: "https://github.com/AminBlg/SimpleEnglish/blob/HEAD/LICENSE",
164
+ author: "AminBlg",
165
+ homepage: "https://github.com/AminBlg/SimpleEnglish",
166
+ pushedAt: "2026-08-17",
167
+ },
168
+ ];
169
+
170
+ // ─── Styles ──────────────────────────────────────────────────────────────────
171
+
172
+ /**
173
+ * 22 entries across the 5 sources above.
174
+ *
175
+ * Every `path` and every `displayName` here was produced by
176
+ * `scripts/verify-community-registry.ts --discover`, which lists the repo's
177
+ * real `.md` blobs and reads each file's own `name`. None was typed from a
178
+ * README. `--check` was clean before this was committed.
179
+ *
180
+ * `displayName` is upstream's `name` verbatim, warts included — `claude-mods`
181
+ * writes lowercase slugs, `hesreallyhim` writes title case, and
182
+ * `existentialist-poet.md` calls itself "Existential Poet". Normalising any of
183
+ * that would put a name in the list that appears nowhere upstream.
184
+ *
185
+ * `summary` is OURS: one line, under 100 characters, in our register. Upstream
186
+ * descriptions run from 40 to 200+ characters and several are marketing.
187
+ *
188
+ * Not everything importable is listed. `claude-mods` and `nattergabriel` ship
189
+ * 13 each and 5 of each are here — the rest are unreviewed, and adding one is a
190
+ * one-line diff, so deferring costs nothing. Entries are never DELETED, only
191
+ * marked `retired`: a deleted id breaks a committed `.claude/style.json` on
192
+ * every teammate's machine.
193
+ */
194
+ export const COMMUNITY_STYLES: CommunityStyle[] = [
195
+ // ── attention-span ───────────────────────────────────────────────────────
196
+ {
197
+ id: "attention-span--attention-kind",
198
+ sourceId: "attention-span",
199
+ path: "output-styles/attention-kind.md",
200
+ displayName: "Attention-kind",
201
+ summary:
202
+ "ADHD-friendly. Plain English, answers front-loaded, expands only where it matters.",
203
+ },
204
+ {
205
+ id: "attention-span--rundown",
206
+ sourceId: "attention-span",
207
+ path: "output-styles/rundown.md",
208
+ displayName: "Rundown",
209
+ summary:
210
+ "Briefing format. TL;DR first, then state as checkboxes and tagged choices.",
211
+ },
212
+ {
213
+ id: "attention-span--spartan",
214
+ sourceId: "attention-span",
215
+ path: "output-styles/spartan.md",
216
+ displayName: "Spartan",
217
+ summary: "Blunt and answer-first. Arrow points, no warmth, no filler.",
218
+ },
219
+
220
+ // ── claude-mods (5 of 13) ────────────────────────────────────────────────
221
+ {
222
+ id: "claude-mods--executive",
223
+ sourceId: "claude-mods",
224
+ path: "output-styles/executive.md",
225
+ displayName: "executive",
226
+ summary:
227
+ "Stakeholder brief. Decisions, impact and timelines, with the engineering left out.",
228
+ },
229
+ {
230
+ id: "claude-mods--mentor",
231
+ sourceId: "claude-mods",
232
+ path: "output-styles/mentor.md",
233
+ displayName: "mentor",
234
+ summary:
235
+ "Teaching voice. Explains why before what, and builds understanding as it goes.",
236
+ },
237
+ {
238
+ id: "claude-mods--noir",
239
+ sourceId: "claude-mods",
240
+ path: "output-styles/noir.md",
241
+ displayName: "noir",
242
+ summary:
243
+ "Hard-boiled detective narrating your codebase. Pure persona, no workflow changes.",
244
+ },
245
+ {
246
+ id: "claude-mods--pair",
247
+ sourceId: "claude-mods",
248
+ path: "output-styles/pair.md",
249
+ displayName: "pair",
250
+ summary:
251
+ "Pairing voice. Thinks out loud and shares the driver's seat rather than dictating.",
252
+ },
253
+ {
254
+ id: "claude-mods--roast",
255
+ sourceId: "claude-mods",
256
+ path: "output-styles/roast.md",
257
+ displayName: "roast",
258
+ summary:
259
+ "Adversarial review. Brutally honest about your code, then helps you fix it.",
260
+ },
261
+
262
+ // ── lej-output-fixer (3 of 3) ────────────────────────────────────────────
263
+ {
264
+ id: "lej-output-fixer--bottom-line-first",
265
+ sourceId: "lej-output-fixer",
266
+ path: "output-styles/bottom-line-first.md",
267
+ displayName: "Bottom Line First",
268
+ summary:
269
+ "Verdict up top. Details appear only when they would change a decision.",
270
+ },
271
+ {
272
+ id: "lej-output-fixer--scannable",
273
+ sourceId: "lej-output-fixer",
274
+ path: "output-styles/scannable.md",
275
+ displayName: "Scannable",
276
+ summary:
277
+ "Short paragraphs, plain reading level, answer first. The antidote to a wall of text.",
278
+ },
279
+ {
280
+ id: "lej-output-fixer--walk-me-through-it",
281
+ sourceId: "lej-output-fixer",
282
+ path: "output-styles/walk-me-through-it.md",
283
+ displayName: "Walk Me Through It",
284
+ summary:
285
+ "Explains as it goes and assumes no prior context. The inverse of a terse preset.",
286
+ },
287
+
288
+ // ── hesreallyhim (6 of 6) ────────────────────────────────────────────────
289
+ {
290
+ id: "hesreallyhim--door-to-door-vim-salesman",
291
+ sourceId: "hesreallyhim",
292
+ path: ".claude/output-styles/door-to-door-vim-salesman.md",
293
+ displayName: "Door-to-Door Vim Salesman",
294
+ summary: "Redirects every topic toward the merits of Vim, relentlessly.",
295
+ },
296
+ {
297
+ id: "hesreallyhim--existentialist-poet",
298
+ sourceId: "hesreallyhim",
299
+ path: ".claude/output-styles/existentialist-poet.md",
300
+ // Upstream's own `name` is "Existential Poet" while the file is
301
+ // existentialist-poet.md. Recorded as upstream writes it.
302
+ displayName: "Existential Poet",
303
+ summary:
304
+ "Melancholic engineer-philosopher. Debugging as a metaphor for the human condition.",
305
+ },
306
+ {
307
+ id: "hesreallyhim--haiku-helper",
308
+ sourceId: "hesreallyhim",
309
+ path: ".claude/output-styles/haiku-helper.md",
310
+ displayName: "Haiku Helper",
311
+ summary:
312
+ "Replies only in 5-7-5 haiku. A hard constraint on length, played straight.",
313
+ },
314
+ {
315
+ id: "hesreallyhim--tabloid-journalist",
316
+ sourceId: "hesreallyhim",
317
+ path: ".claude/output-styles/tabloid-journalist.md",
318
+ displayName: "Tabloid Journalist",
319
+ summary:
320
+ "Covers your code as a scandal. Sensationalist persona, technically accurate.",
321
+ },
322
+ {
323
+ id: "hesreallyhim--technical-evangelist",
324
+ sourceId: "hesreallyhim",
325
+ path: ".claude/output-styles/technical-evangelist.md",
326
+ displayName: "Claude Code Technical Evangelist",
327
+ summary: "Enthusiastic Claude Code power user with deep product knowledge.",
328
+ },
329
+ {
330
+ id: "hesreallyhim--zen-master",
331
+ sourceId: "hesreallyhim",
332
+ path: ".claude/output-styles/zen-master.md",
333
+ displayName: "Zen Master",
334
+ summary: "Guides through koans and metaphor instead of answering directly.",
335
+ },
336
+
337
+ // ── nattergabriel (5 of 13) ──────────────────────────────────────────────
338
+ {
339
+ id: "nattergabriel--challenger",
340
+ sourceId: "nattergabriel",
341
+ path: "output-styles/challenger.md",
342
+ displayName: "Challenger",
343
+ summary:
344
+ "Challenges each decision and stress-tests a design before you commit to it.",
345
+ },
346
+ {
347
+ id: "nattergabriel--paranoid",
348
+ sourceId: "nattergabriel",
349
+ path: "output-styles/paranoid.md",
350
+ displayName: "Paranoid",
351
+ summary:
352
+ "Security-obsessed. Every input is hostile and every dependency is suspect.",
353
+ },
354
+ {
355
+ id: "nattergabriel--socratic",
356
+ sourceId: "nattergabriel",
357
+ path: "output-styles/socratic.md",
358
+ displayName: "Socratic",
359
+ summary: "Guides you to the answer with questions rather than giving it.",
360
+ },
361
+ {
362
+ id: "nattergabriel--tdd",
363
+ sourceId: "nattergabriel",
364
+ path: "output-styles/tdd.md",
365
+ displayName: "TDD",
366
+ summary:
367
+ "Test-driven. Writes the failing test before the implementation, every time.",
368
+ },
369
+ {
370
+ id: "nattergabriel--think-aloud",
371
+ sourceId: "nattergabriel",
372
+ path: "output-styles/think-aloud.md",
373
+ displayName: "Think Aloud",
374
+ summary:
375
+ "Reflects your reasoning back so you debug the problem by talking it through.",
376
+ },
377
+ // ── simple-english ───────────────────────────────────────────────────────
378
+ {
379
+ id: "simple-english--simple-english",
380
+ sourceId: "simple-english",
381
+ path: "output-styles/simple-english.md",
382
+ displayName: "Simple English",
383
+ summary:
384
+ "ASD-STE100 Simplified Technical English. One word one meaning, short sentences, no filler.",
385
+ },
386
+ ];
387
+
388
+ // ─── Exclusions, recorded so they are not silently reversed ──────────────────
389
+
390
+ /**
391
+ * Repos that ship ZERO output style files. An entry naming one would 404 on
392
+ * first fetch.
393
+ *
394
+ * They are here because two of them are the most-starred names in the field —
395
+ * `JuliusBrussee/caveman` (98,781 stars) and `blader/humanizer` (36,229) — and
396
+ * their absence reads as an oversight to anyone scanning a star column. They
397
+ * are METHODOLOGIES SHIPPED AS SKILLS. There is nothing to import. A test
398
+ * asserts no registry entry names any of them, so this decision cannot be
399
+ * quietly undone six months from now by someone sorting by popularity.
400
+ */
401
+ export const DENIED_REPOS: readonly string[] = [
402
+ "JuliusBrussee/caveman",
403
+ "blader/humanizer",
404
+ "ayghri/i-have-adhd",
405
+ "hardikpandya/stop-slop",
406
+ "petergyang/no-ai-slop",
407
+ "conorbronsdon/avoid-ai-writing",
408
+ "mattpocock/skills",
409
+ ];
410
+
411
+ /**
412
+ * Repos that DO ship importable styles but grant no redistribution licence.
413
+ *
414
+ * Deferred, not denied. `smixs` is otherwise the highest-value repo in the
415
+ * field; it is excluded only because GitHub reports NOASSERTION, and
416
+ * `SimpleClaude` carries no licence file at all. Fetching a copy to the user's
417
+ * own machine is arguably fine, but claudeup would be curating, recommending
418
+ * and automating it, and the cost of being wrong in that direction is a legal
419
+ * problem on other people's machines. The cost of being wrong in this direction
420
+ * is a style a user can still install by hand.
421
+ *
422
+ * Revisit the moment a licence appears upstream. The mechanism for shipping one
423
+ * already exists: `licence: null` renders in warning colour.
424
+ */
425
+ export const LICENCE_DEFERRED_REPOS: readonly string[] = [
426
+ "smixs/awesome-claude-output-styles",
427
+ "kylesnowschwartz/SimpleClaude",
428
+ ];
429
+
430
+ // ─── Lookup ──────────────────────────────────────────────────────────────────
431
+
432
+ /** The source a style belongs to, or null when the id names no known source. */
433
+ export function findCommunitySource(
434
+ sourceId: string,
435
+ ): CommunityStyleSource | null {
436
+ return COMMUNITY_SOURCES.find((source) => source.id === sourceId) ?? null;
437
+ }
438
+
439
+ /** A registry entry by its coordinate id (`attention-span--spartan`). */
440
+ export function findCommunityStyle(id: string): CommunityStyle | null {
441
+ return COMMUNITY_STYLES.find((style) => style.id === id) ?? null;
442
+ }
443
+
444
+ /** An entry with its source resolved, or null when either half is unknown. */
445
+ export function resolveCommunityStyle(
446
+ id: string,
447
+ ): { style: CommunityStyle; source: CommunityStyleSource } | null {
448
+ const style = findCommunityStyle(id);
449
+ if (!style) return null;
450
+ const source = findCommunitySource(style.sourceId);
451
+ if (!source) return null;
452
+ return { style, source };
453
+ }
454
+
455
+ /** Every style a source ships, in display order. */
456
+ export function stylesForSource(sourceId: string): CommunityStyle[] {
457
+ return COMMUNITY_STYLES.filter((style) => style.sourceId === sourceId).sort(
458
+ (a, b) => a.displayName.localeCompare(b.displayName),
459
+ );
460
+ }
461
+
462
+ /**
463
+ * The raw.githubusercontent.com URL for an entry.
464
+ *
465
+ * Kept here rather than in the fetcher so the one place that knows a
466
+ * coordinate's shape is the one place that knows the registry's shape.
467
+ */
468
+ export function rawUrlFor(
469
+ style: CommunityStyle,
470
+ source: CommunityStyleSource,
471
+ ): string {
472
+ return `https://raw.githubusercontent.com/${source.repo}/${source.ref}/${style.path}`;
473
+ }
474
+
475
+ /**
476
+ * Why a coordinate is unusable, or null when it is fine.
477
+ *
478
+ * These are OUR data, so a failure is a bug in a commit rather than a user
479
+ * error — callers throw on it and the registry test catches it before it ships.
480
+ * Validating anyway, because a `..` that reached the URL builder would be both
481
+ * an SSRF and a path traversal in the destination filename.
482
+ */
483
+ export function coordinateProblem(
484
+ style: CommunityStyle,
485
+ source: CommunityStyleSource,
486
+ ): string | null {
487
+ if (!isValidGitHubRepo(source.repo)) {
488
+ return `not a valid GitHub repo: "${source.repo}"`;
489
+ }
490
+ if (!/^[A-Za-z0-9._/-]+$/.test(source.ref)) {
491
+ return `not a valid git ref: "${source.ref}"`;
492
+ }
493
+ if (!/^[A-Za-z0-9._/-]+\.md$/.test(style.path)) {
494
+ return `not a valid style path: "${style.path}"`;
495
+ }
496
+ if (style.path.includes("..") || style.path.startsWith("/")) {
497
+ return `style path escapes the repo: "${style.path}"`;
498
+ }
499
+ if (!style.path.startsWith(`${source.dir}/`)) {
500
+ return `"${style.path}" is not under "${source.dir}/"`;
501
+ }
502
+ // The id is also the cache filename, so anything that is not a plain slug
503
+ // pair would escape the cache directory or collide with another entry.
504
+ if (
505
+ !new RegExp(`^[a-z0-9-]+${COMMUNITY_ID_SEPARATOR}[a-z0-9-]+$`).test(
506
+ style.id,
507
+ )
508
+ ) {
509
+ return `id "${style.id}" is not a <source>${COMMUNITY_ID_SEPARATOR}<style> slug pair`;
510
+ }
511
+ if (style.id !== `${source.id}${COMMUNITY_ID_SEPARATOR}${slugOf(style.id)}`) {
512
+ return `id "${style.id}" does not start with "${source.id}${COMMUNITY_ID_SEPARATOR}"`;
513
+ }
514
+ return null;
515
+ }
516
+
517
+ /** The style half of a coordinate id. `attention-span--spartan` -> `spartan`. */
518
+ export function slugOf(id: string): string {
519
+ const at = id.indexOf(COMMUNITY_ID_SEPARATOR);
520
+ return at === -1 ? id : id.slice(at + COMMUNITY_ID_SEPARATOR.length);
521
+ }
package/src/main.tsx CHANGED
@@ -33,6 +33,21 @@ async function main(): Promise<void> {
33
33
  // element now names an adaptive colour explicitly — see src/ui/theme.ts.
34
34
  const renderer = await createCliRenderer({
35
35
  backgroundColor: RGBA.defaultBackground(),
36
+ // claudeup is keyboard-only — no component sets onMouseDown/Up/Move, and
37
+ // every action has a key. OpenTUI nevertheless defaults BOTH of these to
38
+ // true (`config.useMouse ?? true`, `config.enableMouseMovement ?? true`),
39
+ // which turns on button reporting and mode-1003 "report all motion".
40
+ //
41
+ // Inside tmux that is actively harmful: once a pane's application asks for
42
+ // mouse events, tmux hands them to that application instead of using them
43
+ // to select a pane or scroll history. Clicking another pane then does
44
+ // nothing, keyboard focus stays where it was, and what you type lands in
45
+ // the pane you thought you had just left.
46
+ //
47
+ // Turning it off costs nothing here and gives clicking and scrolling back
48
+ // to the terminal.
49
+ useMouse: false,
50
+ enableMouseMovement: false,
36
51
  });
37
52
 
38
53
  // Ask the terminal whether it is light or dark, once. Only the disabled-row
@@ -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;