claudeup 4.36.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 (47) hide show
  1. package/package.json +4 -4
  2. package/scripts/verify-community-registry.ts +272 -0
  3. package/src/__tests__/catalog-cache-store.test.ts +271 -0
  4. package/src/__tests__/catalog-notice.test.ts +155 -0
  5. package/src/__tests__/community-fetch.test.ts +545 -0
  6. package/src/__tests__/community-registry.test.ts +269 -0
  7. package/src/__tests__/community-staleness.test.ts +722 -0
  8. package/src/__tests__/github-budget.test.ts +200 -0
  9. package/src/__tests__/open-file.test.ts +59 -0
  10. package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
  11. package/src/__tests__/style-wrap.test.ts +220 -0
  12. package/src/__tests__/styles-manager.test.ts +1124 -0
  13. package/src/__tests__/styles-origins.test.ts +416 -0
  14. package/src/__tests__/styles-screen-state.test.ts +460 -0
  15. package/src/__tests__/styles-status-line.test.ts +72 -0
  16. package/src/__tests__/styles-sync.test.ts +452 -0
  17. package/src/__tests__/tabbar-layout.test.ts +62 -0
  18. package/src/__tests__/terminology-filler.test.ts +214 -0
  19. package/src/data/community-styles.ts +521 -0
  20. package/src/main.tsx +15 -0
  21. package/src/services/catalog-cache-store.ts +312 -0
  22. package/src/services/community-fetcher.ts +90 -0
  23. package/src/services/community-styles.ts +1194 -0
  24. package/src/services/github-budget.ts +274 -0
  25. package/src/services/marketplace-catalog-git.ts +170 -0
  26. package/src/services/marketplace-catalog.ts +95 -0
  27. package/src/services/marketplace-fetcher.ts +310 -87
  28. package/src/services/plugin-manager.ts +103 -92
  29. package/src/services/styles-manager.ts +1400 -0
  30. package/src/services/terminology-filler.ts +266 -0
  31. package/src/ui/App.tsx +15 -3
  32. package/src/ui/adapters/catalogNotice.ts +122 -0
  33. package/src/ui/adapters/stylesAdapter.ts +403 -0
  34. package/src/ui/components/TabBar.tsx +43 -9
  35. package/src/ui/components/layout/ScreenLayout.tsx +19 -2
  36. package/src/ui/components/primitives/ActionHints.tsx +4 -1
  37. package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
  38. package/src/ui/registry.ts +6 -0
  39. package/src/ui/renderers/pluginRenderers.tsx +39 -1
  40. package/src/ui/renderers/styleRenderers.tsx +809 -0
  41. package/src/ui/screens/PluginsScreen.tsx +138 -29
  42. package/src/ui/screens/StylesScreen.tsx +1089 -0
  43. package/src/ui/screens/index.ts +1 -0
  44. package/src/ui/state/reducer.ts +124 -3
  45. package/src/ui/state/types.ts +76 -3
  46. package/src/utils/config-dir.ts +47 -0
  47. package/src/utils/open-file.ts +84 -0
@@ -0,0 +1,269 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ COMMUNITY_ID_SEPARATOR,
4
+ COMMUNITY_SOURCES,
5
+ COMMUNITY_STYLES,
6
+ DENIED_REPOS,
7
+ LICENCE_DEFERRED_REPOS,
8
+ coordinateProblem,
9
+ findCommunitySource,
10
+ findCommunityStyle,
11
+ rawUrlFor,
12
+ resolveCommunityStyle,
13
+ slugOf,
14
+ stylesForSource,
15
+ } from "../data/community-styles.js";
16
+ import { isValidGitHubRepo } from "../utils/string-utils.js";
17
+
18
+ // Pure data invariants. No I/O, no network — the registry is a constant, and
19
+ // every defect it can carry is visible without leaving the process. Fetching to
20
+ // prove a path exists is the job of `scripts/verify-community-registry.ts`,
21
+ // which a human runs while curating and which must never run in CI.
22
+
23
+ describe("sources", () => {
24
+ test("every repo is a syntactically valid GitHub coordinate", () => {
25
+ // Not decoration: the repo goes straight into a URL, so a malformed one is
26
+ // an SSRF vector before it is a 404.
27
+ for (const source of COMMUNITY_SOURCES) {
28
+ expect(isValidGitHubRepo(source.repo), source.repo).toBe(true);
29
+ }
30
+ });
31
+
32
+ test("source ids are unique and filename-safe", () => {
33
+ // The id is the first half of every cache filename this source produces.
34
+ const ids = COMMUNITY_SOURCES.map((source) => source.id);
35
+ expect(new Set(ids).size).toBe(ids.length);
36
+ for (const id of ids) expect(id, id).toMatch(/^[a-z0-9-]+$/);
37
+ });
38
+
39
+ test("every shipped source declares a licence", () => {
40
+ // `licence: null` is representable on purpose — the type carries it and the
41
+ // UI renders it in warning colour — but nothing with one ships in v1. See
42
+ // LICENCE_DEFERRED_REPOS.
43
+ for (const source of COMMUNITY_SOURCES) {
44
+ expect(
45
+ source.licence,
46
+ `${source.repo} declares a licence`,
47
+ ).not.toBeNull();
48
+ expect(
49
+ source.licenceUrl,
50
+ `${source.repo} links its licence`,
51
+ ).toBeTruthy();
52
+ }
53
+ });
54
+
55
+ test("the styles directory is per-source, never assumed", () => {
56
+ // research.md finding 1: four of five use `output-styles/` and one uses
57
+ // `.claude/output-styles/`. A test that only saw the majority would let a
58
+ // future globbing "simplification" through.
59
+ const dirs = new Set(COMMUNITY_SOURCES.map((source) => source.dir));
60
+ expect(dirs.size).toBeGreaterThan(1);
61
+ expect(dirs).toContain(".claude/output-styles");
62
+ });
63
+
64
+ test("every source is attributed and reachable by a human", () => {
65
+ for (const source of COMMUNITY_SOURCES) {
66
+ expect(source.author, source.repo).toBeTruthy();
67
+ expect(source.homepage, source.repo).toContain(source.repo);
68
+ }
69
+ });
70
+ });
71
+
72
+ describe("styles", () => {
73
+ test("ids are unique", () => {
74
+ const ids = COMMUNITY_STYLES.map((style) => style.id);
75
+ expect(new Set(ids).size).toBe(ids.length);
76
+ });
77
+
78
+ test("every entry resolves to a source, and names it in its id", () => {
79
+ for (const style of COMMUNITY_STYLES) {
80
+ const source = findCommunitySource(style.sourceId);
81
+ expect(source, `${style.id} resolves its source`).not.toBeNull();
82
+ expect(
83
+ style.id.startsWith(`${style.sourceId}${COMMUNITY_ID_SEPARATOR}`),
84
+ ).toBe(true);
85
+ }
86
+ });
87
+
88
+ test("every coordinate passes the validator the fetcher runs", () => {
89
+ // The fetcher THROWS on a bad coordinate, because it is our data and a
90
+ // failure is a bug in a commit rather than a user error. This is the test
91
+ // that catches it in the commit instead of at a keypress.
92
+ for (const style of COMMUNITY_STYLES) {
93
+ const source = findCommunitySource(style.sourceId);
94
+ expect(source).not.toBeNull();
95
+ if (!source) continue;
96
+ expect(coordinateProblem(style, source), style.id).toBeNull();
97
+ }
98
+ });
99
+
100
+ test("no path escapes its repo or its styles directory", () => {
101
+ for (const style of COMMUNITY_STYLES) {
102
+ const source = findCommunitySource(style.sourceId);
103
+ if (!source) continue;
104
+ expect(style.path.endsWith(".md"), style.id).toBe(true);
105
+ expect(style.path.includes(".."), style.id).toBe(false);
106
+ expect(style.path.startsWith("/"), style.id).toBe(false);
107
+ expect(style.path.startsWith(`${source.dir}/`), style.id).toBe(true);
108
+ }
109
+ });
110
+
111
+ test("every entry carries a display name and a summary we wrote", () => {
112
+ for (const style of COMMUNITY_STYLES) {
113
+ expect(style.displayName, style.id).toBeTruthy();
114
+ expect(style.summary, style.id).toBeTruthy();
115
+ // The summary is the row text. Longer than this and it truncates in the
116
+ // list, which reads as a bug rather than as a long description.
117
+ expect(
118
+ style.summary.length,
119
+ `${style.id} summary length`,
120
+ ).toBeLessThanOrEqual(100);
121
+ }
122
+ });
123
+
124
+ test("no display name or summary carries a comma-driven round-trip hazard", () => {
125
+ // `style-imports` is comma-separated, so only the ID must be comma-free.
126
+ // Asserting it here as well as in the id regex, because this is the field a
127
+ // future editor is most likely to paste an upstream string into.
128
+ for (const style of COMMUNITY_STYLES) {
129
+ expect(style.id.includes(","), style.id).toBe(false);
130
+ }
131
+ });
132
+
133
+ test("the raw URL is built from the coordinate, with nothing inferred", () => {
134
+ const resolved = resolveCommunityStyle("attention-span--spartan");
135
+ expect(resolved).not.toBeNull();
136
+ if (!resolved) return;
137
+ expect(rawUrlFor(resolved.style, resolved.source)).toBe(
138
+ "https://raw.githubusercontent.com/alexgreensh/attention-span/HEAD/output-styles/spartan.md",
139
+ );
140
+ });
141
+
142
+ test("the one source with a non-standard directory is actually shipped", () => {
143
+ // hesreallyhim keeps its styles in `.claude/output-styles/`. Shipping it is
144
+ // what keeps the per-entry path exercised in production rather than merely
145
+ // designed for.
146
+ const paths = stylesForSource("hesreallyhim").map((style) => style.path);
147
+ expect(paths.length).toBeGreaterThan(0);
148
+ for (const path of paths) {
149
+ expect(path.startsWith(".claude/output-styles/")).toBe(true);
150
+ }
151
+ });
152
+ });
153
+
154
+ describe("lookup", () => {
155
+ test("finds an entry by its coordinate id", () => {
156
+ expect(findCommunityStyle("attention-span--spartan")?.displayName).toBe(
157
+ "Spartan",
158
+ );
159
+ });
160
+
161
+ test("returns null rather than guessing at an unknown id", () => {
162
+ expect(findCommunityStyle("attention-span--nope")).toBeNull();
163
+ expect(findCommunitySource("nope")).toBeNull();
164
+ expect(resolveCommunityStyle("nope--nope")).toBeNull();
165
+ });
166
+
167
+ test("splits a coordinate on the FIRST separator, so hyphenated slugs survive", () => {
168
+ expect(slugOf("lej-output-fixer--walk-me-through-it")).toBe(
169
+ "walk-me-through-it",
170
+ );
171
+ expect(slugOf("attention-span--spartan")).toBe("spartan");
172
+ });
173
+ });
174
+
175
+ describe("exclusions", () => {
176
+ test("no entry names a repo that ships zero style files", () => {
177
+ // THE POINT OF THIS TEST, so a future editor learns it instead of deleting
178
+ // it: `JuliusBrussee/caveman` (98,781 stars) and `blader/humanizer`
179
+ // (36,229) are the two highest-starred names in this space and they ship
180
+ // NO output styles at all — they are methodologies packaged as skills. An
181
+ // entry for either would 404 on first fetch. Anyone re-adding one from a
182
+ // popularity list will land here first.
183
+ const shipped = new Set(COMMUNITY_SOURCES.map((source) => source.repo));
184
+ for (const denied of DENIED_REPOS) {
185
+ expect(shipped.has(denied), `${denied} must not be a source`).toBe(false);
186
+ }
187
+ });
188
+
189
+ test("the deny-list still names the two that motivate it", () => {
190
+ // A deny-list that quietly lost its most important entries would pass the
191
+ // test above while protecting nothing.
192
+ expect(DENIED_REPOS).toContain("JuliusBrussee/caveman");
193
+ expect(DENIED_REPOS).toContain("blader/humanizer");
194
+ });
195
+
196
+ test("repos with no redistribution licence are deferred, not shipped", () => {
197
+ // smixs is NOASSERTION and SimpleClaude has no licence file. Excluded from
198
+ // v1 deliberately: the cost of being wrong this way is a style the user can
199
+ // still install by hand; the other way it is a legal problem on other
200
+ // people's machines.
201
+ const shipped = new Set(COMMUNITY_SOURCES.map((source) => source.repo));
202
+ for (const deferred of LICENCE_DEFERRED_REPOS) {
203
+ expect(shipped.has(deferred), `${deferred} is deferred`).toBe(false);
204
+ }
205
+ });
206
+ });
207
+
208
+ describe("coordinateProblem", () => {
209
+ const source = COMMUNITY_SOURCES[0];
210
+
211
+ const entry = (over: Partial<(typeof COMMUNITY_STYLES)[number]> = {}) => ({
212
+ id: "attention-span--spartan",
213
+ sourceId: "attention-span",
214
+ path: "output-styles/spartan.md",
215
+ displayName: "Spartan",
216
+ summary: "s",
217
+ ...over,
218
+ });
219
+
220
+ test("accepts a well-formed entry", () => {
221
+ expect(coordinateProblem(entry(), source)).toBeNull();
222
+ });
223
+
224
+ test("rejects a traversing path", () => {
225
+ expect(
226
+ coordinateProblem(
227
+ entry({ path: "output-styles/../../etc/passwd.md" }),
228
+ source,
229
+ ),
230
+ ).toBeTruthy();
231
+ });
232
+
233
+ test("rejects a path outside the source's directory", () => {
234
+ expect(coordinateProblem(entry({ path: "README.md" }), source)).toContain(
235
+ "is not under",
236
+ );
237
+ });
238
+
239
+ test("rejects a path that is not markdown", () => {
240
+ expect(
241
+ coordinateProblem(entry({ path: "output-styles/spartan.sh" }), source),
242
+ ).toBeTruthy();
243
+ });
244
+
245
+ test("rejects an id that is not a slug pair", () => {
246
+ expect(coordinateProblem(entry({ id: "Zen Master" }), source)).toBeTruthy();
247
+ expect(
248
+ coordinateProblem(entry({ id: "attention-span--Blunt, fast" }), source),
249
+ ).toBeTruthy();
250
+ });
251
+
252
+ test("rejects an id whose source half does not match", () => {
253
+ expect(
254
+ coordinateProblem(entry({ id: "other-repo--spartan" }), source),
255
+ ).toBeTruthy();
256
+ });
257
+
258
+ test("rejects a malformed repo", () => {
259
+ expect(
260
+ coordinateProblem(entry(), { ...source, repo: "http://evil/x" }),
261
+ ).toBeTruthy();
262
+ });
263
+
264
+ test("rejects a ref that could smuggle a query string", () => {
265
+ expect(
266
+ coordinateProblem(entry(), { ...source, ref: "HEAD?x=1" }),
267
+ ).toBeTruthy();
268
+ });
269
+ });