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,460 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm, symlink } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import fs from "fs-extra";
6
+ import {
7
+ applyStyles,
8
+ clearStyle,
9
+ communityCacheDirOrNull,
10
+ loadStyles,
11
+ } from "../services/styles-manager.js";
12
+ import { appReducer, initialState } from "../ui/state/reducer.js";
13
+ import type { AppAction, AppState } from "../ui/state/types.js";
14
+
15
+ function run(actions: AppAction[], from: AppState = initialState): AppState {
16
+ return actions.reduce(appReducer, from);
17
+ }
18
+
19
+ describe("styles search state", () => {
20
+ test("appending builds up the query one character at a time", () => {
21
+ // The regression this guards: computing `searchQuery + char` in the screen
22
+ // reads a value captured at render time. Keystrokes arriving faster than
23
+ // React re-renders then all start from the same stale string, so typing
24
+ // "slop" left just "p" in the filter.
25
+ const state = run([
26
+ { type: "STYLES_SEARCH_APPEND", char: "s" },
27
+ { type: "STYLES_SEARCH_APPEND", char: "l" },
28
+ { type: "STYLES_SEARCH_APPEND", char: "o" },
29
+ { type: "STYLES_SEARCH_APPEND", char: "p" },
30
+ ]);
31
+ expect(state.styles.searchQuery).toBe("slop");
32
+ });
33
+
34
+ test("appending resets the cursor so it cannot sit past the filtered list", () => {
35
+ const state = run([
36
+ { type: "STYLES_SELECT", index: 9 },
37
+ { type: "STYLES_SEARCH_APPEND", char: "a" },
38
+ ]);
39
+ expect(state.styles.selectedIndex).toBe(0);
40
+ });
41
+
42
+ test("backspace removes one character at a time", () => {
43
+ const typed = run([
44
+ { type: "STYLES_SEARCH_APPEND", char: "a" },
45
+ { type: "STYLES_SEARCH_APPEND", char: "b" },
46
+ { type: "STYLES_SEARCH_APPEND", char: "c" },
47
+ ]);
48
+ expect(
49
+ run([{ type: "STYLES_SEARCH_BACKSPACE" }], typed).styles.searchQuery,
50
+ ).toBe("ab");
51
+ });
52
+
53
+ test("backspace on an empty query is harmless", () => {
54
+ expect(run([{ type: "STYLES_SEARCH_BACKSPACE" }]).styles.searchQuery).toBe(
55
+ "",
56
+ );
57
+ });
58
+ });
59
+
60
+ describe("template fill state", () => {
61
+ test("survives the screen unmounting, because it lives in app state", () => {
62
+ // A fill runs Claude Code over the whole project for minutes, so switching
63
+ // tabs mid-run is the normal thing to do. Held in the screen's own
64
+ // useState it was lost on unmount, and the returning user — seeing no sign
65
+ // one was running — could launch a second subprocess on top of the first.
66
+ const filling = run([{ type: "STYLES_FILL_START" }]);
67
+ expect(filling.styles.isFilling).toBe(true);
68
+
69
+ // Navigating away and back is what unmounts the screen. App state is
70
+ // untouched by it.
71
+ const navigated = run(
72
+ [
73
+ { type: "NAVIGATE", route: { screen: "plugins" } },
74
+ { type: "NAVIGATE", route: { screen: "styles" } },
75
+ ],
76
+ filling,
77
+ );
78
+ expect(navigated.styles.isFilling).toBe(true);
79
+ });
80
+
81
+ test("clears when the fill finishes", () => {
82
+ const done = run([
83
+ { type: "STYLES_FILL_START" },
84
+ { type: "STYLES_FILL_END" },
85
+ ]);
86
+ expect(done.styles.isFilling).toBe(false);
87
+ });
88
+
89
+ test("starts false", () => {
90
+ expect(initialState.styles.isFilling).toBe(false);
91
+ });
92
+ });
93
+
94
+ describe("styles selection state", () => {
95
+ test("toggle adds then removes", () => {
96
+ const on = run([{ type: "STYLES_TOGGLE", id: "direct" }]);
97
+ expect([...on.styles.selected]).toEqual(["direct"]);
98
+ const off = run([{ type: "STYLES_TOGGLE", id: "direct" }], on);
99
+ expect([...off.styles.selected]).toEqual([]);
100
+ });
101
+
102
+ test("the committed declaration wins over the stale local artifact", () => {
103
+ // The screen tells the user to "press a to re-apply" when the project's
104
+ // style has moved on. Seeding from the local artifact would make `a`
105
+ // re-apply the OLD set and leave the message permanently true.
106
+ const state = run([
107
+ {
108
+ type: "STYLES_DATA_SUCCESS",
109
+ snapshot: {
110
+ presets: [],
111
+ imports: [],
112
+ applied: { presets: ["old"], imports: [], hash: "sha256:old" },
113
+ declaration: {
114
+ version: 1,
115
+ presets: ["pulled"],
116
+ imports: [],
117
+ hash: "sha256:new",
118
+ updatedAt: "",
119
+ },
120
+ status: {
121
+ state: "stale",
122
+ detail: "project style changed",
123
+ missing: [],
124
+ fetchable: [],
125
+ },
126
+ stylePath: "/x/composed.md",
127
+ settingsPath: "/x/settings.json",
128
+ styleName: "composed",
129
+ presetsRoot: "/styles",
130
+ profile: null,
131
+ currentOutputStyle: "composed",
132
+ },
133
+ },
134
+ ]);
135
+ expect([...state.styles.selected]).toEqual(["pulled"]);
136
+ });
137
+
138
+ test("loading a snapshot seeds the selection from what is live", () => {
139
+ // Opening the screen on an empty set would read as "nothing is applied"
140
+ // even when a style is active.
141
+ const state = run([
142
+ {
143
+ type: "STYLES_DATA_SUCCESS",
144
+ snapshot: {
145
+ presets: [],
146
+ imports: [],
147
+ applied: {
148
+ presets: ["direct"],
149
+ imports: ["user:mine"],
150
+ hash: "sha256:x",
151
+ },
152
+ declaration: null,
153
+ status: {
154
+ state: "undeclared",
155
+ detail: "",
156
+ missing: [],
157
+ fetchable: [],
158
+ },
159
+ stylePath: "/x/composed.md",
160
+ settingsPath: "/x/settings.json",
161
+ styleName: "composed",
162
+ presetsRoot: "/styles",
163
+ profile: null,
164
+ currentOutputStyle: "composed",
165
+ },
166
+ },
167
+ ]);
168
+ expect([...state.styles.selected].sort()).toEqual(["direct", "user:mine"]);
169
+ });
170
+
171
+ test("a snapshot with nothing live seeds an empty selection", () => {
172
+ const state = run([
173
+ { type: "STYLES_TOGGLE", id: "stale" },
174
+ {
175
+ type: "STYLES_DATA_SUCCESS",
176
+ snapshot: {
177
+ presets: [],
178
+ imports: [],
179
+ applied: null,
180
+ declaration: null,
181
+ status: {
182
+ state: "undeclared",
183
+ detail: "",
184
+ missing: [],
185
+ fetchable: [],
186
+ },
187
+ stylePath: "/x/composed.md",
188
+ settingsPath: "/x/settings.json",
189
+ styleName: "composed",
190
+ presetsRoot: null,
191
+ profile: null,
192
+ currentOutputStyle: null,
193
+ },
194
+ },
195
+ ]);
196
+ expect([...state.styles.selected]).toEqual([]);
197
+ });
198
+ });
199
+
200
+ describe("loadStyles reports what is actually in force", () => {
201
+ let dir: string;
202
+
203
+ beforeEach(async () => {
204
+ dir = await mkdtemp(join(tmpdir(), "claudeup-styles-live-"));
205
+ });
206
+ afterEach(async () => {
207
+ await rm(dir, { recursive: true, force: true });
208
+ });
209
+
210
+ test("reports the applied selection while the style is active", async () => {
211
+ await fs.outputFile(
212
+ join(dir, ".claude", "output-styles", "composed.md"),
213
+ [
214
+ "---",
215
+ "name: composed",
216
+ "keep-coding-instructions: true",
217
+ "style-presets: direct",
218
+ "style-imports: none",
219
+ "---",
220
+ "",
221
+ "body",
222
+ ].join("\n"),
223
+ );
224
+ await fs.outputJson(join(dir, ".claude", "settings.json"), {
225
+ outputStyle: "composed",
226
+ });
227
+
228
+ const snapshot = await loadStyles(dir);
229
+ expect(snapshot.applied).toEqual({
230
+ presets: ["direct"],
231
+ imports: [],
232
+ hash: null,
233
+ });
234
+ });
235
+
236
+ test("reports nothing applied once the style is cleared", async () => {
237
+ // clearStyle removes the pointer and deliberately keeps the file. Reading
238
+ // the file alone would keep reporting the rules as live.
239
+ await applyStyles({
240
+ projectPath: dir,
241
+ presets: [
242
+ {
243
+ kind: "preset",
244
+ id: "direct",
245
+ name: "direct",
246
+ axis: "verbosity",
247
+ summary: "",
248
+ conflicts: [],
249
+ template: false,
250
+ body: "rules",
251
+ path: "/styles/direct.md",
252
+ },
253
+ ],
254
+ imports: [],
255
+ });
256
+ expect((await loadStyles(dir)).applied).not.toBeNull();
257
+
258
+ await clearStyle(dir);
259
+
260
+ const snapshot = await loadStyles(dir);
261
+ expect(snapshot.currentOutputStyle).toBeNull();
262
+ expect(snapshot.applied).toBeNull();
263
+ // The file is still there — clearing is reversible.
264
+ expect(await fs.pathExists(snapshot.stylePath)).toBe(true);
265
+ });
266
+
267
+ test("reports nothing applied when another style is active", async () => {
268
+ await fs.outputFile(
269
+ join(dir, ".claude", "output-styles", "composed.md"),
270
+ "---\nname: composed\nstyle-presets: direct\n---\n\nbody",
271
+ );
272
+ await fs.outputJson(join(dir, ".claude", "settings.json"), {
273
+ outputStyle: "Explanatory",
274
+ });
275
+
276
+ const snapshot = await loadStyles(dir);
277
+ expect(snapshot.currentOutputStyle).toBe("Explanatory");
278
+ expect(snapshot.applied).toBeNull();
279
+ });
280
+
281
+ test("makes no network call, in any phase", async () => {
282
+ // The constraint the whole feature is built around: `loadStyles` runs on
283
+ // every render and every reload. Fetching and update-checking are explicit
284
+ // user actions, never a consequence of drawing a panel.
285
+ const src = await fs.readFile(
286
+ join(import.meta.dir, "..", "services", "styles-manager.ts"),
287
+ "utf8",
288
+ );
289
+ expect(src).not.toMatch(/\bfetch\s*\(/);
290
+ expect(src).not.toMatch(/community-fetcher/);
291
+ });
292
+
293
+ test("looks for the per-profile style when a profile is active", async () => {
294
+ await fs.outputJson(
295
+ join(dir, ".claude", "_profiles", "dev", "settings.json"),
296
+ {
297
+ outputStyle: "composed-dev",
298
+ },
299
+ );
300
+ await symlink(
301
+ "_profiles/dev/settings.json",
302
+ join(dir, ".claude", "settings.json"),
303
+ );
304
+ await fs.outputFile(
305
+ join(dir, ".claude", "output-styles", "composed-dev.md"),
306
+ "---\nname: composed-dev\nstyle-presets: terse\nstyle-imports: none\n---\n\nbody",
307
+ );
308
+
309
+ const snapshot = await loadStyles(dir);
310
+ expect(snapshot.profile).toBe("dev");
311
+ expect(snapshot.styleName).toBe("composed-dev");
312
+ expect(snapshot.applied).toEqual({
313
+ presets: ["terse"],
314
+ imports: [],
315
+ hash: null,
316
+ });
317
+ });
318
+ });
319
+
320
+ // ─── Community styles, end to end through loadStyles ──────────────────────────
321
+
322
+ describe("a project that committed a community style", () => {
323
+ let dir: string;
324
+ let home: string;
325
+ let previousConfigDir: string | undefined;
326
+
327
+ beforeEach(async () => {
328
+ dir = await mkdtemp(join(tmpdir(), "claudeup-styles-community-"));
329
+ home = join(dir, "home");
330
+ previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
331
+ // Must be ABSENT, not assigned undefined: `process.env.X = undefined`
332
+ // stores the STRING "undefined", which is truthy.
333
+ // biome-ignore lint/performance/noDelete: absence is the intent
334
+ delete process.env.CLAUDE_CONFIG_DIR;
335
+ });
336
+ afterEach(async () => {
337
+ await rm(dir, { recursive: true, force: true });
338
+ // biome-ignore lint/performance/noDelete: same reason as above
339
+ if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
340
+ else process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
341
+ });
342
+
343
+ const declare = () =>
344
+ fs.outputJson(join(dir, ".claude", "style.json"), {
345
+ version: 1,
346
+ presets: [],
347
+ imports: ["community:attention-span--spartan"],
348
+ hash: "sha256:whatever",
349
+ updatedAt: "2026-08-18T00:00:00.000Z",
350
+ });
351
+
352
+ test("reports it as fetchable, with an action that actually converges", async () => {
353
+ // The fresh-clone case, which is the whole point of coordinate ids: this
354
+ // machine has fetched nothing, so the id has to resolve against the
355
+ // registry before any file exists locally.
356
+ await declare();
357
+ const snapshot = await loadStyles(dir, { home });
358
+ expect(snapshot.status.fetchable).toEqual([
359
+ "community:attention-span--spartan",
360
+ ]);
361
+ expect(snapshot.status.missing).toEqual([]);
362
+ expect(snapshot.status.detail).toBe(
363
+ "project style needs 1 community style — press f",
364
+ );
365
+ });
366
+
367
+ test("stops saying so once the style is in the cache", async () => {
368
+ await declare();
369
+ const cacheDir = communityCacheDirOrNull(home);
370
+ if (!cacheDir) throw new Error("community cache dir did not resolve");
371
+ await fs.outputFile(
372
+ join(cacheDir, "attention-span--spartan.md"),
373
+ [
374
+ "---",
375
+ "name: attention-span--spartan",
376
+ 'description: "Blunt Spartan mode."',
377
+ "keep-coding-instructions: true",
378
+ "community-source: alexgreensh/attention-span",
379
+ "community-path: output-styles/spartan.md",
380
+ "community-ref: HEAD",
381
+ "community-commit: b860c9f8f3c7",
382
+ "community-sha256: sha256:abc",
383
+ "community-fetched: 2026-08-18",
384
+ "community-licence: AGPL-3.0",
385
+ "community-author: alexgreensh",
386
+ "---",
387
+ "",
388
+ "- Answer first.",
389
+ ].join("\n"),
390
+ );
391
+
392
+ const snapshot = await loadStyles(dir, { home });
393
+ expect(snapshot.status.fetchable).toEqual([]);
394
+ const style = snapshot.imports.find(
395
+ (s) => s.id === "community:attention-span--spartan",
396
+ );
397
+ // The provenance the detail panel renders, parsed from the file itself —
398
+ // no network call, on a code path that runs on every render.
399
+ expect(style?.community?.source).toBe("alexgreensh/attention-span");
400
+ expect(style?.community?.commit).toBe("b860c9f8f3c7");
401
+ expect(style?.community?.licence).toBe("AGPL-3.0");
402
+ });
403
+
404
+ test("an unresolved pin and an unlicensed repo read as ABSENT, not as values", async () => {
405
+ // The fetcher stamps `unknown` and `unlicensed` because a blank value in
406
+ // the file would read as a truncated write. Neither may survive into the
407
+ // panel, where "unlicensed" in the licence slot looks like an SPDX id.
408
+ const cacheDir = communityCacheDirOrNull(home);
409
+ if (!cacheDir) throw new Error("community cache dir did not resolve");
410
+ await fs.outputFile(
411
+ join(cacheDir, "attention-span--rundown.md"),
412
+ [
413
+ "---",
414
+ "name: attention-span--rundown",
415
+ "community-source: alexgreensh/attention-span",
416
+ "community-commit: unknown",
417
+ "community-licence: unlicensed",
418
+ "---",
419
+ "",
420
+ "- Brief.",
421
+ ].join("\n"),
422
+ );
423
+ const snapshot = await loadStyles(dir, { home });
424
+ const style = snapshot.imports.find(
425
+ (s) => s.id === "community:attention-span--rundown",
426
+ );
427
+ expect(style?.community?.commit).toBeNull();
428
+ expect(style?.community?.licence).toBeNull();
429
+ });
430
+ });
431
+
432
+ // ─── Keybindings ──────────────────────────────────────────────────────────────
433
+
434
+ describe("StylesScreen keybindings", () => {
435
+ test("Shift+U is tested BEFORE plain u, or the plain branch swallows it", async () => {
436
+ // OpenTUI reports Shift+U as `{name: "u", shift: true}`, so a lowercase
437
+ // branch placed first matches it too. That exact shape shipped once in
438
+ // PluginsScreen and made "update" install at user scope instead.
439
+ const src = await fs.readFile(
440
+ join(import.meta.dir, "..", "ui", "screens", "StylesScreen.tsx"),
441
+ "utf8",
442
+ );
443
+ const shifted = src.indexOf('event.name === "u" && event.shift');
444
+ const plain = src.indexOf('event.name === "u") {');
445
+ expect(shifted).toBeGreaterThan(-1);
446
+ expect(plain).toBeGreaterThan(-1);
447
+ expect(shifted).toBeLessThan(plain);
448
+ });
449
+
450
+ test("f is bound, and it is what the sync status tells the user to press", async () => {
451
+ const src = await fs.readFile(
452
+ join(import.meta.dir, "..", "ui", "screens", "StylesScreen.tsx"),
453
+ "utf8",
454
+ );
455
+ expect(src).toContain('event.name === "f"');
456
+ // The falls-through-to-the-declaration branch. Without it, "press f" is a
457
+ // lie whenever the cursor is not parked on a Community row.
458
+ expect(src).toContain("status.fetchable");
459
+ });
460
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { appReducer, initialState } from "../ui/state/reducer.js";
3
+ import type { AppAction, AppState } from "../ui/state/types.js";
4
+
5
+ function run(actions: AppAction[], from: AppState = initialState): AppState {
6
+ return actions.reduce(appReducer, from);
7
+ }
8
+
9
+ /**
10
+ * The status line lives in app state, not in the screen.
11
+ *
12
+ * Two bugs drove this, and both were invisible to every test we had:
13
+ *
14
+ * 1. It was cleared on a 5-second wall clock, so the message describing what
15
+ * you just did was usually gone before you looked at it — and unobservable
16
+ * to anything sampling the screen a moment later.
17
+ * 2. It was component-local, and `Router` swaps the component type on a tab
18
+ * change, so `9 → 1 → 9` destroyed it.
19
+ */
20
+ describe("status line state", () => {
21
+ test("survives leaving the tab and coming back", () => {
22
+ const shown = run([
23
+ {
24
+ type: "STYLES_STATUS_SET",
25
+ status: { text: "Selection reset to the live style", tone: "success" },
26
+ },
27
+ ]);
28
+ expect(shown.styles.status?.text).toBe("Selection reset to the live style");
29
+
30
+ // Navigating away and back is what unmounts the screen.
31
+ const roundTrip = run(
32
+ [
33
+ { type: "NAVIGATE", route: { screen: "plugins" } },
34
+ { type: "NAVIGATE", route: { screen: "styles" } },
35
+ ],
36
+ shown,
37
+ );
38
+ expect(roundTrip.styles.status?.text).toBe(
39
+ "Selection reset to the live style",
40
+ );
41
+ });
42
+
43
+ test("a new message replaces the previous one", () => {
44
+ const state = run([
45
+ { type: "STYLES_STATUS_SET", status: { text: "first", tone: "success" } },
46
+ { type: "STYLES_STATUS_SET", status: { text: "second", tone: "error" } },
47
+ ]);
48
+ expect(state.styles.status).toEqual({ text: "second", tone: "error" });
49
+ });
50
+
51
+ test("clearing returns the line to its default", () => {
52
+ const state = run([
53
+ { type: "STYLES_STATUS_SET", status: { text: "done", tone: "success" } },
54
+ { type: "STYLES_STATUS_CLEAR" },
55
+ ]);
56
+ expect(state.styles.status).toBeNull();
57
+ });
58
+
59
+ test("starts empty", () => {
60
+ expect(initialState.styles.status).toBeNull();
61
+ });
62
+
63
+ test("keeps the tone, so an error cannot render as a success", () => {
64
+ const state = run([
65
+ {
66
+ type: "STYLES_STATUS_SET",
67
+ status: { text: "Already talking to GitHub", tone: "error" },
68
+ },
69
+ ]);
70
+ expect(state.styles.status?.tone).toBe("error");
71
+ });
72
+ });