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.
- package/package.json +4 -4
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/catalog-cache-store.test.ts +271 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +722 -0
- package/src/__tests__/github-budget.test.ts +200 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1124 -0
- package/src/__tests__/styles-origins.test.ts +416 -0
- package/src/__tests__/styles-screen-state.test.ts +460 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/tabbar-layout.test.ts +62 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/data/community-styles.ts +521 -0
- package/src/main.tsx +15 -0
- package/src/services/catalog-cache-store.ts +312 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/marketplace-catalog-git.ts +170 -0
- package/src/services/marketplace-catalog.ts +95 -0
- package/src/services/marketplace-fetcher.ts +310 -87
- package/src/services/plugin-manager.ts +103 -92
- package/src/services/styles-manager.ts +1400 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/TabBar.tsx +43 -9
- package/src/ui/components/layout/ScreenLayout.tsx +19 -2
- package/src/ui/components/primitives/ActionHints.tsx +4 -1
- package/src/ui/components/primitives/ListCategoryRow.tsx +10 -1
- package/src/ui/registry.ts +6 -0
- package/src/ui/renderers/pluginRenderers.tsx +39 -1
- package/src/ui/renderers/styleRenderers.tsx +809 -0
- package/src/ui/screens/PluginsScreen.tsx +138 -29
- package/src/ui/screens/StylesScreen.tsx +1089 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +124 -3
- package/src/ui/state/types.ts +76 -3
- package/src/utils/config-dir.ts +47 -0
- package/src/utils/open-file.ts +84 -0
|
@@ -0,0 +1,1124 @@
|
|
|
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
|
+
INTEGRITY_BLOCK,
|
|
8
|
+
type ImportedStyle,
|
|
9
|
+
type StylePreset,
|
|
10
|
+
type StyleSource,
|
|
11
|
+
applyStyles,
|
|
12
|
+
clearStyle,
|
|
13
|
+
composeStyleFile,
|
|
14
|
+
generatedStyleName,
|
|
15
|
+
orderSources,
|
|
16
|
+
readApplied,
|
|
17
|
+
splitFrontmatter,
|
|
18
|
+
validateSelection,
|
|
19
|
+
} from "../services/styles-manager.js";
|
|
20
|
+
import {
|
|
21
|
+
buildStyleBrowserItems,
|
|
22
|
+
firstSelectableIndex,
|
|
23
|
+
} from "../ui/adapters/stylesAdapter.js";
|
|
24
|
+
|
|
25
|
+
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
function preset(overrides: Partial<StylePreset> = {}): StylePreset {
|
|
28
|
+
const name = overrides.name ?? "direct";
|
|
29
|
+
return {
|
|
30
|
+
kind: "preset",
|
|
31
|
+
name,
|
|
32
|
+
displayName: name,
|
|
33
|
+
axis: "verbosity",
|
|
34
|
+
summary: "Answer first.",
|
|
35
|
+
conflicts: [],
|
|
36
|
+
template: false,
|
|
37
|
+
body: "### Direct\n\n- Lead with the answer.",
|
|
38
|
+
path: `/styles/${name}.md`,
|
|
39
|
+
...overrides,
|
|
40
|
+
// Last, so a fixture that only sets `name` still gets a matching id.
|
|
41
|
+
id: overrides.id ?? name,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function imported(overrides: Partial<ImportedStyle> = {}): ImportedStyle {
|
|
46
|
+
const name = overrides.name ?? "my-voice";
|
|
47
|
+
const scope = overrides.scope ?? "user";
|
|
48
|
+
return {
|
|
49
|
+
kind: "imported",
|
|
50
|
+
name,
|
|
51
|
+
displayName: name,
|
|
52
|
+
scope,
|
|
53
|
+
origin: scope === "project" ? "team" : "personal",
|
|
54
|
+
managed: false,
|
|
55
|
+
description: "A personal voice.",
|
|
56
|
+
updatedAt: "2026-08-18",
|
|
57
|
+
capturedFrom: null,
|
|
58
|
+
community: null,
|
|
59
|
+
body: "Be brief.",
|
|
60
|
+
path: `/home/.claude/output-styles/${name}.md`,
|
|
61
|
+
...overrides,
|
|
62
|
+
// After the spread so a fixture that only sets `name`/`scope` still gets
|
|
63
|
+
// a matching id.
|
|
64
|
+
id: overrides.id ?? `${scope}:${name}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let dir: string;
|
|
69
|
+
|
|
70
|
+
beforeEach(async () => {
|
|
71
|
+
dir = await mkdtemp(join(tmpdir(), "claudeup-styles-"));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(async () => {
|
|
75
|
+
await rm(dir, { recursive: true, force: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ─── Frontmatter ──────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
describe("splitFrontmatter", () => {
|
|
81
|
+
test("parses the shape the style plugin actually ships", () => {
|
|
82
|
+
const { frontmatter, body } = splitFrontmatter(
|
|
83
|
+
[
|
|
84
|
+
"---",
|
|
85
|
+
"name: direct",
|
|
86
|
+
"axis: verbosity",
|
|
87
|
+
"summary: Answer first, no preamble.",
|
|
88
|
+
"conflicts: explanatory, terse",
|
|
89
|
+
"---",
|
|
90
|
+
"",
|
|
91
|
+
"### Direct",
|
|
92
|
+
"",
|
|
93
|
+
"- Lead with the answer.",
|
|
94
|
+
].join("\n"),
|
|
95
|
+
);
|
|
96
|
+
expect(frontmatter.name).toBe("direct");
|
|
97
|
+
expect(frontmatter.axis).toBe("verbosity");
|
|
98
|
+
expect(frontmatter.conflicts).toBe("explanatory, terse");
|
|
99
|
+
expect(body.startsWith("### Direct")).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("unquotes and unescapes a quoted description", () => {
|
|
103
|
+
const { frontmatter } = splitFrontmatter(
|
|
104
|
+
["---", 'description: "Composed: \\"x\\", y"', "---", "body"].join("\n"),
|
|
105
|
+
);
|
|
106
|
+
expect(frontmatter.description).toBe('Composed: "x", y');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a file with no frontmatter is all body", () => {
|
|
110
|
+
const { frontmatter, body } = splitFrontmatter("just text");
|
|
111
|
+
expect(frontmatter).toEqual({});
|
|
112
|
+
expect(body).toBe("just text");
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("an unterminated fence is not treated as frontmatter", () => {
|
|
116
|
+
const { frontmatter, body } = splitFrontmatter("---\nname: x\nno close");
|
|
117
|
+
expect(frontmatter).toEqual({});
|
|
118
|
+
expect(body).toContain("name: x");
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// ─── Validation ───────────────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
describe("validateSelection", () => {
|
|
125
|
+
test("accepts one verbosity plus any number of modifiers", () => {
|
|
126
|
+
expect(
|
|
127
|
+
validateSelection([
|
|
128
|
+
preset({ name: "direct", axis: "verbosity" }),
|
|
129
|
+
preset({ name: "no-slop", axis: "modifier" }),
|
|
130
|
+
preset({ name: "evidence-first", axis: "modifier" }),
|
|
131
|
+
]),
|
|
132
|
+
).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("rejects two verbosity presets — they cancel out", () => {
|
|
136
|
+
const errors = validateSelection([
|
|
137
|
+
preset({ name: "direct", axis: "verbosity" }),
|
|
138
|
+
preset({ name: "terse", axis: "verbosity" }),
|
|
139
|
+
]);
|
|
140
|
+
expect(errors).toHaveLength(1);
|
|
141
|
+
expect(errors[0]).toContain("exactly one verbosity");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("reports a conflicting pair once, not once from each side", () => {
|
|
145
|
+
const errors = validateSelection([
|
|
146
|
+
preset({ name: "a", axis: "modifier", conflicts: ["b"] }),
|
|
147
|
+
preset({ name: "b", axis: "modifier", conflicts: ["a"] }),
|
|
148
|
+
]);
|
|
149
|
+
expect(errors).toHaveLength(1);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("rejects a template preset with an actionable message", () => {
|
|
153
|
+
const errors = validateSelection([
|
|
154
|
+
preset({ name: "terminology", axis: "modifier", template: true }),
|
|
155
|
+
]);
|
|
156
|
+
expect(errors).toHaveLength(1);
|
|
157
|
+
expect(errors[0]).toContain("template");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("an empty selection has no validation errors of its own", () => {
|
|
161
|
+
expect(validateSelection([])).toEqual([]);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ─── Ordering ─────────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
describe("orderSources", () => {
|
|
168
|
+
test("imports first, then verbosity, then modifiers", () => {
|
|
169
|
+
const ordered = orderSources(
|
|
170
|
+
[
|
|
171
|
+
preset({ name: "no-slop", axis: "modifier" }),
|
|
172
|
+
preset({ name: "direct", axis: "verbosity" }),
|
|
173
|
+
],
|
|
174
|
+
[imported({ name: "mine" })],
|
|
175
|
+
);
|
|
176
|
+
expect(ordered.map((s) => s.name)).toEqual(["mine", "direct", "no-slop"]);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("keeps only the first verbosity preset", () => {
|
|
180
|
+
const ordered = orderSources(
|
|
181
|
+
[
|
|
182
|
+
preset({ name: "direct", axis: "verbosity" }),
|
|
183
|
+
preset({ name: "terse", axis: "verbosity" }),
|
|
184
|
+
],
|
|
185
|
+
[],
|
|
186
|
+
);
|
|
187
|
+
expect(ordered.map((s) => s.name)).toEqual(["direct"]);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// ─── Composition ──────────────────────────────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
describe("composeStyleFile", () => {
|
|
194
|
+
test("always keeps coding instructions on", () => {
|
|
195
|
+
// A style about how to COMMUNICATE must not switch off how to write code.
|
|
196
|
+
// Without this key Claude Code drops its coding-discipline block entirely.
|
|
197
|
+
const file = composeStyleFile("composed", [preset()]);
|
|
198
|
+
expect(file).toContain("keep-coding-instructions: true");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test("records provenance in frontmatter, not the body", () => {
|
|
202
|
+
const file = composeStyleFile("composed", [
|
|
203
|
+
preset({ name: "direct" }),
|
|
204
|
+
imported({ name: "mine", scope: "user" }),
|
|
205
|
+
]);
|
|
206
|
+
const { frontmatter, body } = splitFrontmatter(file);
|
|
207
|
+
expect(frontmatter["style-presets"]).toBe("direct");
|
|
208
|
+
expect(frontmatter["style-imports"]).toBe("user:mine");
|
|
209
|
+
// The body is the prompt and is paid for on every request — provenance
|
|
210
|
+
// must not leak into it.
|
|
211
|
+
expect(body).not.toContain("style-presets");
|
|
212
|
+
expect(body).not.toContain("generated-by");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
test("records 'none' when a category is empty", () => {
|
|
216
|
+
const { frontmatter } = splitFrontmatter(
|
|
217
|
+
composeStyleFile("composed", [preset()]),
|
|
218
|
+
);
|
|
219
|
+
expect(frontmatter["style-imports"]).toBe("none");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("quotes the description so a colon cannot break the YAML", () => {
|
|
223
|
+
const file = composeStyleFile("composed", [preset({ name: "direct" })]);
|
|
224
|
+
const { frontmatter } = splitFrontmatter(file);
|
|
225
|
+
// Round-trips through the parser rather than appearing as a nested map.
|
|
226
|
+
expect(frontmatter.description).toContain("Composed communication style");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test("imported bodies come before preset bodies", () => {
|
|
230
|
+
const file = composeStyleFile("composed", [
|
|
231
|
+
imported({ name: "mine", body: "IMPORTED-MARKER" }),
|
|
232
|
+
preset({ name: "direct", body: "PRESET-MARKER" }),
|
|
233
|
+
]);
|
|
234
|
+
expect(file.indexOf("IMPORTED-MARKER")).toBeLessThan(
|
|
235
|
+
file.indexOf("PRESET-MARKER"),
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("appends the integrity block to EVERY composition", () => {
|
|
240
|
+
// "Every" is the claim worth testing. The block is the semantic backstop
|
|
241
|
+
// for text we did not write — a third-party import cannot opt out of it,
|
|
242
|
+
// and neither can a selection that happens to contain no imports.
|
|
243
|
+
const selections: Array<{ label: string; sources: StyleSource[] }> = [
|
|
244
|
+
{ label: "nothing selected", sources: [] },
|
|
245
|
+
{ label: "one preset", sources: [preset()] },
|
|
246
|
+
{ label: "one import", sources: [imported({ name: "mine" })] },
|
|
247
|
+
{
|
|
248
|
+
label: "both",
|
|
249
|
+
sources: [imported({ name: "mine" }), preset({ name: "direct" })],
|
|
250
|
+
},
|
|
251
|
+
];
|
|
252
|
+
for (const { label, sources } of selections) {
|
|
253
|
+
const { body } = splitFrontmatter(composeStyleFile("composed", sources));
|
|
254
|
+
expect(body, `${label} carries the integrity block`).toContain(
|
|
255
|
+
INTEGRITY_BLOCK,
|
|
256
|
+
);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("puts the integrity block LAST, so it refines what came before", () => {
|
|
261
|
+
// Same reasoning as orderSources: specific-after-broad. A limit stated
|
|
262
|
+
// before the rule it limits reads as something the rule may override.
|
|
263
|
+
const { body } = splitFrontmatter(
|
|
264
|
+
composeStyleFile("composed", [
|
|
265
|
+
imported({ name: "mine", body: "IMPORTED-MARKER" }),
|
|
266
|
+
preset({ name: "direct", body: "PRESET-MARKER" }),
|
|
267
|
+
]),
|
|
268
|
+
);
|
|
269
|
+
expect(body.trimEnd().endsWith(INTEGRITY_BLOCK)).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("the integrity block reaches the model, not just the file", () => {
|
|
273
|
+
// Frontmatter is split off before the prompt is built, so a block written
|
|
274
|
+
// there would be invisible to the model — the exact failure this test
|
|
275
|
+
// exists to catch.
|
|
276
|
+
const file = composeStyleFile("composed", [preset()]);
|
|
277
|
+
const { frontmatter, body } = splitFrontmatter(file);
|
|
278
|
+
expect(body).toContain("## Style limits");
|
|
279
|
+
expect(JSON.stringify(frontmatter)).not.toContain("Style limits");
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("the block states the rules it is there to state", () => {
|
|
283
|
+
// Checkable content, not just presence: these are the four commitments
|
|
284
|
+
// the block exists for, and a rewrite that drops one should fail here.
|
|
285
|
+
expect(INTEGRITY_BLOCK).toContain("error");
|
|
286
|
+
expect(INTEGRITY_BLOCK).toContain("security warning");
|
|
287
|
+
expect(INTEGRITY_BLOCK).toContain("destructive or irreversible action");
|
|
288
|
+
expect(INTEGRITY_BLOCK).toMatch(/never cut a flag from a command/);
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
describe("readApplied", () => {
|
|
293
|
+
test("round-trips the selection a composed file records", async () => {
|
|
294
|
+
const path = join(dir, "composed.md");
|
|
295
|
+
await fs.writeFile(
|
|
296
|
+
path,
|
|
297
|
+
composeStyleFile("composed", [
|
|
298
|
+
preset({ name: "direct" }),
|
|
299
|
+
preset({ name: "no-slop", axis: "modifier" }),
|
|
300
|
+
imported({ name: "mine", scope: "project" }),
|
|
301
|
+
]),
|
|
302
|
+
);
|
|
303
|
+
const applied = await readApplied(path);
|
|
304
|
+
expect(applied?.presets).toEqual(["direct", "no-slop"]);
|
|
305
|
+
expect(applied?.imports).toEqual(["project:mine"]);
|
|
306
|
+
// composeStyleFile now records a fingerprint of the rules it was built
|
|
307
|
+
// from, so drift can be detected later.
|
|
308
|
+
expect(applied?.hash).toMatch(/^sha256:[0-9a-f]{32}$/);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test("returns null when nothing has been applied", async () => {
|
|
312
|
+
expect(await readApplied(join(dir, "missing.md"))).toBeNull();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ─── Naming ───────────────────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
describe("generatedStyleName", () => {
|
|
319
|
+
test("is plain when no profile is active", () => {
|
|
320
|
+
expect(generatedStyleName(null)).toBe("composed");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("is per-profile so two profiles cannot clobber each other's file", () => {
|
|
324
|
+
expect(generatedStyleName("frontend")).toBe("composed-frontend");
|
|
325
|
+
expect(generatedStyleName("Team Alpha")).toBe("composed-team-alpha");
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
test("falls back to the plain name when a profile slugifies to nothing", () => {
|
|
329
|
+
expect(generatedStyleName("///")).toBe("composed");
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ─── Apply ────────────────────────────────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
describe("applyStyles", () => {
|
|
336
|
+
test("writes the style file and activates it", async () => {
|
|
337
|
+
const result = await applyStyles({
|
|
338
|
+
projectPath: dir,
|
|
339
|
+
presets: [preset({ name: "direct" })],
|
|
340
|
+
imports: [],
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
expect(result.styleName).toBe("composed");
|
|
344
|
+
expect(await fs.pathExists(result.stylePath)).toBe(true);
|
|
345
|
+
const settings = await fs.readJson(result.settingsPath);
|
|
346
|
+
expect(settings.outputStyle).toBe("composed");
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
test("preserves unrelated settings keys", async () => {
|
|
350
|
+
const settingsPath = join(dir, ".claude", "settings.json");
|
|
351
|
+
await fs.outputJson(settingsPath, {
|
|
352
|
+
enabledPlugins: { "dev@magus": true },
|
|
353
|
+
someOtherKey: 42,
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
await applyStyles({
|
|
357
|
+
projectPath: dir,
|
|
358
|
+
presets: [preset({ name: "direct" })],
|
|
359
|
+
imports: [],
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
const settings = await fs.readJson(settingsPath);
|
|
363
|
+
expect(settings.enabledPlugins).toEqual({ "dev@magus": true });
|
|
364
|
+
expect(settings.someOtherKey).toBe(42);
|
|
365
|
+
expect(settings.outputStyle).toBe("composed");
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("refuses to overwrite settings.json that is not valid JSON", async () => {
|
|
369
|
+
const settingsPath = join(dir, ".claude", "settings.json");
|
|
370
|
+
await fs.outputFile(settingsPath, "{ not json");
|
|
371
|
+
|
|
372
|
+
await expect(
|
|
373
|
+
applyStyles({
|
|
374
|
+
projectPath: dir,
|
|
375
|
+
presets: [preset({ name: "direct" })],
|
|
376
|
+
imports: [],
|
|
377
|
+
}),
|
|
378
|
+
).rejects.toThrow(/not valid JSON/);
|
|
379
|
+
|
|
380
|
+
// The user's file is still there, untouched.
|
|
381
|
+
expect(await fs.readFile(settingsPath, "utf8")).toBe("{ not json");
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test("rejects an invalid selection instead of writing a contradictory style", async () => {
|
|
385
|
+
await expect(
|
|
386
|
+
applyStyles({
|
|
387
|
+
projectPath: dir,
|
|
388
|
+
presets: [
|
|
389
|
+
preset({ name: "direct", axis: "verbosity" }),
|
|
390
|
+
preset({ name: "terse", axis: "verbosity" }),
|
|
391
|
+
],
|
|
392
|
+
imports: [],
|
|
393
|
+
}),
|
|
394
|
+
).rejects.toThrow(/exactly one verbosity/);
|
|
395
|
+
expect(await fs.pathExists(join(dir, ".claude", "output-styles"))).toBe(
|
|
396
|
+
false,
|
|
397
|
+
);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
test("rejects an empty selection", async () => {
|
|
401
|
+
await expect(
|
|
402
|
+
applyStyles({ projectPath: dir, presets: [], imports: [] }),
|
|
403
|
+
).rejects.toThrow(/Nothing selected/);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
test("writes THROUGH an active profile's settings symlink without breaking it", async () => {
|
|
407
|
+
// The regression this guards: removing-then-recreating settings.json
|
|
408
|
+
// silently detaches the project from its profile, and every later profile
|
|
409
|
+
// switch then writes to a file nothing reads.
|
|
410
|
+
const profileSettings = join(
|
|
411
|
+
dir,
|
|
412
|
+
".claude",
|
|
413
|
+
"_profiles",
|
|
414
|
+
"dev",
|
|
415
|
+
"settings.json",
|
|
416
|
+
);
|
|
417
|
+
await fs.outputJson(profileSettings, { enabledPlugins: {} });
|
|
418
|
+
const link = join(dir, ".claude", "settings.json");
|
|
419
|
+
await symlink("_profiles/dev/settings.json", link);
|
|
420
|
+
|
|
421
|
+
const result = await applyStyles({
|
|
422
|
+
projectPath: dir,
|
|
423
|
+
presets: [preset({ name: "direct" })],
|
|
424
|
+
imports: [],
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
expect(result.profile).toBe("dev");
|
|
428
|
+
expect(result.styleName).toBe("composed-dev");
|
|
429
|
+
expect((await fs.lstat(link)).isSymbolicLink()).toBe(true);
|
|
430
|
+
// The write landed in the profile, which is what "apply to the current
|
|
431
|
+
// profile" means.
|
|
432
|
+
expect((await fs.readJson(profileSettings)).outputStyle).toBe(
|
|
433
|
+
"composed-dev",
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("records the style in profiles.json so re-materializing cannot drop it", async () => {
|
|
438
|
+
// materializeProfile rewrites settings.json from the manifest, so a live
|
|
439
|
+
// write alone regresses on the next `claudeup install`.
|
|
440
|
+
const profileSettings = join(
|
|
441
|
+
dir,
|
|
442
|
+
".claude",
|
|
443
|
+
"_profiles",
|
|
444
|
+
"dev",
|
|
445
|
+
"settings.json",
|
|
446
|
+
);
|
|
447
|
+
await fs.outputJson(profileSettings, { enabledPlugins: {} });
|
|
448
|
+
await symlink(
|
|
449
|
+
"_profiles/dev/settings.json",
|
|
450
|
+
join(dir, ".claude", "settings.json"),
|
|
451
|
+
);
|
|
452
|
+
await fs.outputJson(join(dir, ".claude", "profiles.json"), {
|
|
453
|
+
version: 2,
|
|
454
|
+
profiles: { dev: { name: "dev", plugins: {} } },
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
const result = await applyStyles({
|
|
458
|
+
projectPath: dir,
|
|
459
|
+
presets: [preset({ name: "direct" })],
|
|
460
|
+
imports: [],
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
expect(result.recordedInManifest).toBe(true);
|
|
464
|
+
const manifest = await fs.readJson(join(dir, ".claude", "profiles.json"));
|
|
465
|
+
expect(manifest.profiles.dev.settings.outputStyle).toBe("composed-dev");
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("reports honestly when there is a profile but no manifest entry", async () => {
|
|
469
|
+
const profileSettings = join(
|
|
470
|
+
dir,
|
|
471
|
+
".claude",
|
|
472
|
+
"_profiles",
|
|
473
|
+
"dev",
|
|
474
|
+
"settings.json",
|
|
475
|
+
);
|
|
476
|
+
await fs.outputJson(profileSettings, {});
|
|
477
|
+
await symlink(
|
|
478
|
+
"_profiles/dev/settings.json",
|
|
479
|
+
join(dir, ".claude", "settings.json"),
|
|
480
|
+
);
|
|
481
|
+
|
|
482
|
+
const result = await applyStyles({
|
|
483
|
+
projectPath: dir,
|
|
484
|
+
presets: [preset({ name: "direct" })],
|
|
485
|
+
imports: [],
|
|
486
|
+
});
|
|
487
|
+
expect(result.profile).toBe("dev");
|
|
488
|
+
expect(result.recordedInManifest).toBe(false);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
describe("clearStyle", () => {
|
|
493
|
+
test("removes the key rather than setting it undefined", async () => {
|
|
494
|
+
await applyStyles({
|
|
495
|
+
projectPath: dir,
|
|
496
|
+
presets: [preset({ name: "direct" })],
|
|
497
|
+
imports: [],
|
|
498
|
+
});
|
|
499
|
+
await clearStyle(dir);
|
|
500
|
+
|
|
501
|
+
const raw = await fs.readFile(
|
|
502
|
+
join(dir, ".claude", "settings.json"),
|
|
503
|
+
"utf8",
|
|
504
|
+
);
|
|
505
|
+
expect(raw).not.toContain("outputStyle");
|
|
506
|
+
expect(JSON.parse(raw)).toEqual({});
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test("leaves the generated file on disk so a re-apply is one keypress away", async () => {
|
|
510
|
+
const result = await applyStyles({
|
|
511
|
+
projectPath: dir,
|
|
512
|
+
presets: [preset({ name: "direct" })],
|
|
513
|
+
imports: [],
|
|
514
|
+
});
|
|
515
|
+
await clearStyle(dir);
|
|
516
|
+
expect(await fs.pathExists(result.stylePath)).toBe(true);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("is a no-op on a project that never had a style", async () => {
|
|
520
|
+
await clearStyle(dir);
|
|
521
|
+
expect(await fs.pathExists(join(dir, ".claude", "settings.json"))).toBe(
|
|
522
|
+
false,
|
|
523
|
+
);
|
|
524
|
+
});
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// ─── Parity with the shipped plugin ───────────────────────────────────────────
|
|
528
|
+
|
|
529
|
+
describe("parity with plugins/style/styles", () => {
|
|
530
|
+
// styles-manager re-implements the plugin's compose-style.ts rather than
|
|
531
|
+
// shelling out to it, so the one thing that must not drift is the file
|
|
532
|
+
// format. These read the REAL shipped presets — if the plugin changes its
|
|
533
|
+
// frontmatter vocabulary, this is what notices.
|
|
534
|
+
const stylesDir = join(
|
|
535
|
+
import.meta.dir,
|
|
536
|
+
"..",
|
|
537
|
+
"..",
|
|
538
|
+
"..",
|
|
539
|
+
"..",
|
|
540
|
+
"plugins",
|
|
541
|
+
"style",
|
|
542
|
+
"styles",
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
test("the shipped presets are where this test expects them", async () => {
|
|
546
|
+
expect(await fs.pathExists(stylesDir)).toBe(true);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("every shipped preset parses into a usable preset", async () => {
|
|
550
|
+
const files = (await fs.readdir(stylesDir)).filter((f) =>
|
|
551
|
+
f.endsWith(".md"),
|
|
552
|
+
);
|
|
553
|
+
expect(files.length).toBeGreaterThan(0);
|
|
554
|
+
|
|
555
|
+
for (const file of files) {
|
|
556
|
+
const raw = await fs.readFile(join(stylesDir, file), "utf8");
|
|
557
|
+
const { frontmatter, body } = splitFrontmatter(raw);
|
|
558
|
+
|
|
559
|
+
expect(frontmatter.name, `${file} declares a name`).toBeTruthy();
|
|
560
|
+
expect(
|
|
561
|
+
["verbosity", "modifier"],
|
|
562
|
+
`${file} declares a known axis`,
|
|
563
|
+
).toContain(frontmatter.axis);
|
|
564
|
+
expect(frontmatter.summary, `${file} declares a summary`).toBeTruthy();
|
|
565
|
+
// The summary is the list-row text; an empty one renders a blank row.
|
|
566
|
+
expect(body.length, `${file} has a non-empty body`).toBeGreaterThan(0);
|
|
567
|
+
}
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test("exactly one verbosity preset can be chosen from what ships", async () => {
|
|
571
|
+
const files = (await fs.readdir(stylesDir)).filter((f) =>
|
|
572
|
+
f.endsWith(".md"),
|
|
573
|
+
);
|
|
574
|
+
const axes = await Promise.all(
|
|
575
|
+
files.map(async (file) => {
|
|
576
|
+
const { frontmatter } = splitFrontmatter(
|
|
577
|
+
await fs.readFile(join(stylesDir, file), "utf8"),
|
|
578
|
+
);
|
|
579
|
+
return frontmatter.axis;
|
|
580
|
+
}),
|
|
581
|
+
);
|
|
582
|
+
// Both groups must be non-empty or the screen renders a category with no
|
|
583
|
+
// rows, which reads as "the plugin is broken".
|
|
584
|
+
expect(axes.filter((a) => a === "verbosity").length).toBeGreaterThan(0);
|
|
585
|
+
expect(axes.filter((a) => a === "modifier").length).toBeGreaterThan(0);
|
|
586
|
+
});
|
|
587
|
+
|
|
588
|
+
test("the plugin's composer carries the identical integrity block", async () => {
|
|
589
|
+
// The two composers are separate implementations of one format (see the
|
|
590
|
+
// header of styles-manager.ts). Everything else about them can differ
|
|
591
|
+
// harmlessly; this block cannot, because it is the security backstop and a
|
|
592
|
+
// user who composes with /style:apply must get the same one as a user who
|
|
593
|
+
// composes with claudeup.
|
|
594
|
+
//
|
|
595
|
+
// Read as TEXT rather than imported: claudeup's tsconfig roots at src/,
|
|
596
|
+
// and a cross-package import would make the check depend on the plugin
|
|
597
|
+
// being resolvable rather than on the text agreeing.
|
|
598
|
+
const composer = join(
|
|
599
|
+
import.meta.dir,
|
|
600
|
+
"..",
|
|
601
|
+
"..",
|
|
602
|
+
"..",
|
|
603
|
+
"..",
|
|
604
|
+
"plugins",
|
|
605
|
+
"style",
|
|
606
|
+
"scripts",
|
|
607
|
+
"compose-style.ts",
|
|
608
|
+
);
|
|
609
|
+
expect(await fs.pathExists(composer)).toBe(true);
|
|
610
|
+
const text = await fs.readFile(composer, "utf8");
|
|
611
|
+
expect(text).toContain(INTEGRITY_BLOCK);
|
|
612
|
+
// And it is actually appended there, not merely declared.
|
|
613
|
+
expect(text).toContain("lines.push(INTEGRITY_BLOCK,");
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
test("declared conflicts all name a preset that actually ships", async () => {
|
|
617
|
+
const files = (await fs.readdir(stylesDir)).filter((f) =>
|
|
618
|
+
f.endsWith(".md"),
|
|
619
|
+
);
|
|
620
|
+
const parsed = await Promise.all(
|
|
621
|
+
files.map(async (file) =>
|
|
622
|
+
splitFrontmatter(await fs.readFile(join(stylesDir, file), "utf8")),
|
|
623
|
+
),
|
|
624
|
+
);
|
|
625
|
+
const names = new Set(parsed.map((p) => p.frontmatter.name));
|
|
626
|
+
for (const { frontmatter } of parsed) {
|
|
627
|
+
for (const conflict of (frontmatter.conflicts ?? "")
|
|
628
|
+
.split(",")
|
|
629
|
+
.map((c) => c.trim())
|
|
630
|
+
.filter(Boolean)) {
|
|
631
|
+
// A conflict naming a preset that no longer exists is dead config:
|
|
632
|
+
// it silently stops protecting the pair it was written for.
|
|
633
|
+
expect(
|
|
634
|
+
names,
|
|
635
|
+
`${frontmatter.name} conflicts with a real preset`,
|
|
636
|
+
).toContain(conflict);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
// ─── Adapter ──────────────────────────────────────────────────────────────────
|
|
643
|
+
|
|
644
|
+
describe("buildStyleBrowserItems", () => {
|
|
645
|
+
const presets = [
|
|
646
|
+
preset({ name: "direct", axis: "verbosity", conflicts: ["terse"] }),
|
|
647
|
+
preset({ name: "terse", axis: "verbosity" }),
|
|
648
|
+
preset({ name: "no-slop", axis: "modifier", conflicts: ["chatty"] }),
|
|
649
|
+
preset({ name: "chatty", axis: "modifier" }),
|
|
650
|
+
];
|
|
651
|
+
|
|
652
|
+
test("leads with Anthropic official, then verbosity, modifiers, team, personal", () => {
|
|
653
|
+
const items = buildStyleBrowserItems({
|
|
654
|
+
presets,
|
|
655
|
+
imports: [
|
|
656
|
+
imported({ name: "mine", scope: "user", origin: "personal" }),
|
|
657
|
+
imported({
|
|
658
|
+
name: "builtin-explanatory",
|
|
659
|
+
displayName: "Explanatory",
|
|
660
|
+
scope: "user",
|
|
661
|
+
origin: "anthropic",
|
|
662
|
+
}),
|
|
663
|
+
imported({ name: "house", scope: "project", origin: "team" }),
|
|
664
|
+
],
|
|
665
|
+
selected: new Set(),
|
|
666
|
+
applied: null,
|
|
667
|
+
query: "",
|
|
668
|
+
// This test is about the ORDER of the offline-authored sections, so the
|
|
669
|
+
// registry is emptied to keep it about that. Community's own placement
|
|
670
|
+
// is asserted separately, against the real registry.
|
|
671
|
+
registry: [],
|
|
672
|
+
});
|
|
673
|
+
const categories = items
|
|
674
|
+
.filter((i) => i.kind === "category")
|
|
675
|
+
.map((i) => (i.kind === "category" ? i.categoryKey : ""));
|
|
676
|
+
expect(categories).toEqual([
|
|
677
|
+
"anthropic",
|
|
678
|
+
"verbosity",
|
|
679
|
+
"modifier",
|
|
680
|
+
"team",
|
|
681
|
+
"personal",
|
|
682
|
+
]);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test("shows Anthropic's own name, not the builtin- slug", () => {
|
|
686
|
+
const items = buildStyleBrowserItems({
|
|
687
|
+
presets: [],
|
|
688
|
+
imports: [
|
|
689
|
+
imported({
|
|
690
|
+
name: "builtin-explanatory",
|
|
691
|
+
displayName: "Explanatory",
|
|
692
|
+
origin: "anthropic",
|
|
693
|
+
}),
|
|
694
|
+
],
|
|
695
|
+
selected: new Set(),
|
|
696
|
+
applied: null,
|
|
697
|
+
query: "",
|
|
698
|
+
});
|
|
699
|
+
const entry = items.find((i) => i.kind === "style");
|
|
700
|
+
expect(entry?.label).toBe("Explanatory");
|
|
701
|
+
// The id still carries the real on-disk name — that is what round-trips
|
|
702
|
+
// through the composed file's `style-imports`.
|
|
703
|
+
expect(entry?.id).toBe("user:builtin-explanatory");
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
test("filters on the displayed name as well as the file name", () => {
|
|
707
|
+
// Typing what you can see has to find it.
|
|
708
|
+
const items = buildStyleBrowserItems({
|
|
709
|
+
presets: [],
|
|
710
|
+
imports: [
|
|
711
|
+
imported({
|
|
712
|
+
name: "builtin-proactive",
|
|
713
|
+
displayName: "Proactive",
|
|
714
|
+
origin: "anthropic",
|
|
715
|
+
}),
|
|
716
|
+
],
|
|
717
|
+
selected: new Set(),
|
|
718
|
+
applied: null,
|
|
719
|
+
query: "proact",
|
|
720
|
+
});
|
|
721
|
+
expect(items.filter((i) => i.kind === "style")).toHaveLength(1);
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
test("omits a section with no members rather than showing a zero count", () => {
|
|
725
|
+
const items = buildStyleBrowserItems({
|
|
726
|
+
presets,
|
|
727
|
+
imports: [],
|
|
728
|
+
selected: new Set(),
|
|
729
|
+
applied: null,
|
|
730
|
+
query: "",
|
|
731
|
+
// Community is omitted the same way, and needs an empty registry to be:
|
|
732
|
+
// with the shipped one it is never empty, which is the point of offers.
|
|
733
|
+
registry: [],
|
|
734
|
+
});
|
|
735
|
+
const categories = items
|
|
736
|
+
.filter((i) => i.kind === "category")
|
|
737
|
+
.map((i) => (i.kind === "category" ? i.categoryKey : ""));
|
|
738
|
+
expect(categories).toEqual(["verbosity", "modifier"]);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("omits a category entirely when nothing in it matches the filter", () => {
|
|
742
|
+
const items = buildStyleBrowserItems({
|
|
743
|
+
presets,
|
|
744
|
+
imports: [],
|
|
745
|
+
selected: new Set(),
|
|
746
|
+
applied: null,
|
|
747
|
+
query: "no-slop",
|
|
748
|
+
});
|
|
749
|
+
expect(items.filter((i) => i.kind === "category")).toHaveLength(1);
|
|
750
|
+
expect(items.filter((i) => i.kind === "style")).toHaveLength(1);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("filters on the summary as well as the name", () => {
|
|
754
|
+
const items = buildStyleBrowserItems({
|
|
755
|
+
presets: [preset({ name: "zzz", summary: "findable phrase" })],
|
|
756
|
+
imports: [],
|
|
757
|
+
selected: new Set(),
|
|
758
|
+
applied: null,
|
|
759
|
+
query: "findable",
|
|
760
|
+
});
|
|
761
|
+
expect(items.filter((i) => i.kind === "style")).toHaveLength(1);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
test("distinguishes pending selection from what is live", () => {
|
|
765
|
+
const items = buildStyleBrowserItems({
|
|
766
|
+
presets,
|
|
767
|
+
imports: [],
|
|
768
|
+
selected: new Set(["no-slop"]),
|
|
769
|
+
applied: { presets: ["chatty"], imports: [] },
|
|
770
|
+
query: "",
|
|
771
|
+
});
|
|
772
|
+
const byName = new Map(
|
|
773
|
+
items
|
|
774
|
+
.filter((i) => i.kind === "style")
|
|
775
|
+
.map((i) => [i.label, i as { checked: boolean; applied: boolean }]),
|
|
776
|
+
);
|
|
777
|
+
expect(byName.get("no-slop")?.checked).toBe(true);
|
|
778
|
+
expect(byName.get("no-slop")?.applied).toBe(false);
|
|
779
|
+
expect(byName.get("chatty")?.checked).toBe(false);
|
|
780
|
+
expect(byName.get("chatty")?.applied).toBe(true);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
test("flags a conflict from both directions, not only the declared one", () => {
|
|
784
|
+
// `conflicts` is declared on one side in the shipped presets, but the
|
|
785
|
+
// relationship is symmetric — otherwise the warning would depend on which
|
|
786
|
+
// of the pair the user happened to tick first.
|
|
787
|
+
const withChattySelected = buildStyleBrowserItems({
|
|
788
|
+
presets,
|
|
789
|
+
imports: [],
|
|
790
|
+
selected: new Set(["chatty"]),
|
|
791
|
+
applied: null,
|
|
792
|
+
query: "",
|
|
793
|
+
});
|
|
794
|
+
const noSlop = withChattySelected.find((i) => i.label === "no-slop");
|
|
795
|
+
expect(noSlop?.kind === "style" && noSlop.conflictsWith).toBe("chatty");
|
|
796
|
+
|
|
797
|
+
const withNoSlopSelected = buildStyleBrowserItems({
|
|
798
|
+
presets,
|
|
799
|
+
imports: [],
|
|
800
|
+
selected: new Set(["no-slop"]),
|
|
801
|
+
applied: null,
|
|
802
|
+
query: "",
|
|
803
|
+
});
|
|
804
|
+
const chatty = withNoSlopSelected.find((i) => i.label === "chatty");
|
|
805
|
+
expect(chatty?.kind === "style" && chatty.conflictsWith).toBe("no-slop");
|
|
806
|
+
});
|
|
807
|
+
|
|
808
|
+
test("never flags a conflict between verbosity presets — they are a radio group", () => {
|
|
809
|
+
const items = buildStyleBrowserItems({
|
|
810
|
+
presets,
|
|
811
|
+
imports: [],
|
|
812
|
+
selected: new Set(["direct"]),
|
|
813
|
+
applied: null,
|
|
814
|
+
query: "",
|
|
815
|
+
});
|
|
816
|
+
const terse = items.find((i) => i.label === "terse");
|
|
817
|
+
expect(terse?.kind === "style" && terse.conflictsWith).toBeNull();
|
|
818
|
+
});
|
|
819
|
+
|
|
820
|
+
test("flags a modifier that conflicts with the chosen verbosity preset", () => {
|
|
821
|
+
// This is the conflict that actually occurs in the shipped set:
|
|
822
|
+
// plain-language (modifier) declares a conflict with terse (verbosity).
|
|
823
|
+
// A rule that only compared modifiers against modifiers would never fire.
|
|
824
|
+
const cross = [
|
|
825
|
+
preset({ name: "terse", axis: "verbosity" }),
|
|
826
|
+
preset({
|
|
827
|
+
name: "plain-language",
|
|
828
|
+
axis: "modifier",
|
|
829
|
+
conflicts: ["terse"],
|
|
830
|
+
}),
|
|
831
|
+
];
|
|
832
|
+
const items = buildStyleBrowserItems({
|
|
833
|
+
presets: cross,
|
|
834
|
+
imports: [],
|
|
835
|
+
selected: new Set(["terse"]),
|
|
836
|
+
applied: null,
|
|
837
|
+
query: "",
|
|
838
|
+
});
|
|
839
|
+
const modifier = items.find((i) => i.label === "plain-language");
|
|
840
|
+
expect(modifier?.kind === "style" && modifier.conflictsWith).toBe("terse");
|
|
841
|
+
expect(validateSelection(cross)).toHaveLength(1);
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
test("marks template presets disabled", () => {
|
|
845
|
+
const items = buildStyleBrowserItems({
|
|
846
|
+
presets: [preset({ name: "terminology", template: true })],
|
|
847
|
+
imports: [],
|
|
848
|
+
selected: new Set(),
|
|
849
|
+
applied: null,
|
|
850
|
+
query: "",
|
|
851
|
+
});
|
|
852
|
+
const entry = items.find((i) => i.kind === "style");
|
|
853
|
+
expect(entry?.kind === "style" && entry.disabled).toBe(true);
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// ─── Community section ────────────────────────────────────────────────────────
|
|
858
|
+
|
|
859
|
+
describe("the Community section", () => {
|
|
860
|
+
const offer = (id: string, sourceId: string, name: string) => ({
|
|
861
|
+
id,
|
|
862
|
+
sourceId,
|
|
863
|
+
path: `output-styles/${id.split("--")[1]}.md`,
|
|
864
|
+
displayName: name,
|
|
865
|
+
summary: `Summary for ${name}.`,
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
const spartan = (name = "attention-span--spartan", displayName = "Spartan") =>
|
|
869
|
+
imported({
|
|
870
|
+
name,
|
|
871
|
+
displayName,
|
|
872
|
+
scope: "community",
|
|
873
|
+
origin: "community",
|
|
874
|
+
managed: true,
|
|
875
|
+
community: {
|
|
876
|
+
source: "alexgreensh/attention-span",
|
|
877
|
+
path: "output-styles/spartan.md",
|
|
878
|
+
ref: "HEAD",
|
|
879
|
+
commit: "b860c9f8f3c7",
|
|
880
|
+
sha256: "sha256:abc",
|
|
881
|
+
fetched: "2026-08-18",
|
|
882
|
+
licence: "AGPL-3.0",
|
|
883
|
+
author: "alexgreensh",
|
|
884
|
+
},
|
|
885
|
+
});
|
|
886
|
+
|
|
887
|
+
test("sorts last, after the sections nobody fetched from the network", () => {
|
|
888
|
+
const items = buildStyleBrowserItems({
|
|
889
|
+
presets: [preset({ name: "direct", axis: "verbosity" })],
|
|
890
|
+
imports: [
|
|
891
|
+
imported({ name: "mine", scope: "user", origin: "personal" }),
|
|
892
|
+
imported({ name: "house", scope: "project", origin: "team" }),
|
|
893
|
+
],
|
|
894
|
+
selected: new Set(),
|
|
895
|
+
applied: null,
|
|
896
|
+
query: "",
|
|
897
|
+
});
|
|
898
|
+
const categories = items
|
|
899
|
+
.filter((i) => i.kind === "category")
|
|
900
|
+
.map((i) => (i.kind === "category" ? i.categoryKey : ""));
|
|
901
|
+
expect(categories.at(-1)).toBe("community");
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("lists the registry as offers even when nothing has been fetched", () => {
|
|
905
|
+
// The mitigation for placing the section last: it is non-empty from first
|
|
906
|
+
// launch, so it is discoverable by scrolling rather than only by already
|
|
907
|
+
// knowing it exists.
|
|
908
|
+
const items = buildStyleBrowserItems({
|
|
909
|
+
presets: [],
|
|
910
|
+
imports: [],
|
|
911
|
+
selected: new Set(),
|
|
912
|
+
applied: null,
|
|
913
|
+
query: "",
|
|
914
|
+
registry: [offer("attention-span--spartan", "attention-span", "Spartan")],
|
|
915
|
+
});
|
|
916
|
+
const offers = items.filter((i) => i.kind === "offer");
|
|
917
|
+
expect(offers).toHaveLength(1);
|
|
918
|
+
expect(offers[0]?.label).toBe("Spartan");
|
|
919
|
+
expect(offers[0]?.id).toBe("offer:attention-span--spartan");
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
test("is omitted entirely when the registry is empty and nothing is cached", () => {
|
|
923
|
+
const items = buildStyleBrowserItems({
|
|
924
|
+
presets: [],
|
|
925
|
+
imports: [],
|
|
926
|
+
selected: new Set(),
|
|
927
|
+
applied: null,
|
|
928
|
+
query: "",
|
|
929
|
+
registry: [],
|
|
930
|
+
});
|
|
931
|
+
expect(items).toHaveLength(0);
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
test("a fetched style REPLACES its offer rather than appearing twice", () => {
|
|
935
|
+
const items = buildStyleBrowserItems({
|
|
936
|
+
presets: [],
|
|
937
|
+
imports: [spartan()],
|
|
938
|
+
selected: new Set(),
|
|
939
|
+
applied: null,
|
|
940
|
+
query: "",
|
|
941
|
+
registry: [offer("attention-span--spartan", "attention-span", "Spartan")],
|
|
942
|
+
});
|
|
943
|
+
expect(items.filter((i) => i.kind === "offer")).toHaveLength(0);
|
|
944
|
+
const style = items.find((i) => i.kind === "style");
|
|
945
|
+
expect(style?.label).toBe("Spartan");
|
|
946
|
+
expect(style?.id).toBe("community:attention-span--spartan");
|
|
947
|
+
});
|
|
948
|
+
|
|
949
|
+
test("fetching a style does NOT move it — the row keeps its slot", () => {
|
|
950
|
+
// The cursor tracks an index. Grouping fetched styles above offers meant
|
|
951
|
+
// that fetching one moved it up, shifted everything below by a row, and
|
|
952
|
+
// left the cursor pointing at a different style than the one just acted
|
|
953
|
+
// on. Position is registry order now; state is the checkbox's job.
|
|
954
|
+
const reg = [
|
|
955
|
+
offer(
|
|
956
|
+
"attention-span--attention-kind",
|
|
957
|
+
"attention-span",
|
|
958
|
+
"Attention-kind",
|
|
959
|
+
),
|
|
960
|
+
offer("attention-span--rundown", "attention-span", "Rundown"),
|
|
961
|
+
offer("attention-span--spartan", "attention-span", "Spartan"),
|
|
962
|
+
];
|
|
963
|
+
|
|
964
|
+
const before = buildStyleBrowserItems({
|
|
965
|
+
presets: [],
|
|
966
|
+
imports: [],
|
|
967
|
+
selected: new Set(),
|
|
968
|
+
applied: null,
|
|
969
|
+
query: "",
|
|
970
|
+
registry: reg,
|
|
971
|
+
});
|
|
972
|
+
|
|
973
|
+
// Same registry, but the middle entry has now been fetched.
|
|
974
|
+
const after = buildStyleBrowserItems({
|
|
975
|
+
presets: [],
|
|
976
|
+
imports: [spartan("attention-span--rundown", "Rundown")],
|
|
977
|
+
selected: new Set(),
|
|
978
|
+
applied: null,
|
|
979
|
+
query: "",
|
|
980
|
+
registry: reg,
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
expect(before.map((i) => i.label)).toEqual(after.map((i) => i.label));
|
|
984
|
+
// Only the row's KIND changed, and only for the one that was fetched.
|
|
985
|
+
expect(before.map((i) => i.kind)).toEqual([
|
|
986
|
+
"category",
|
|
987
|
+
"offer",
|
|
988
|
+
"offer",
|
|
989
|
+
"offer",
|
|
990
|
+
]);
|
|
991
|
+
expect(after.map((i) => i.kind)).toEqual([
|
|
992
|
+
"category",
|
|
993
|
+
"offer",
|
|
994
|
+
"style",
|
|
995
|
+
"offer",
|
|
996
|
+
]);
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
test("offers are landable so the cursor can reach them", () => {
|
|
1000
|
+
// `firstSelectableIndex` used to look for `kind === "style"`, which would
|
|
1001
|
+
// make the whole section unreachable for a user who has fetched nothing —
|
|
1002
|
+
// i.e. everyone, on their first launch.
|
|
1003
|
+
const items = buildStyleBrowserItems({
|
|
1004
|
+
presets: [],
|
|
1005
|
+
imports: [],
|
|
1006
|
+
selected: new Set(),
|
|
1007
|
+
applied: null,
|
|
1008
|
+
query: "",
|
|
1009
|
+
registry: [offer("attention-span--spartan", "attention-span", "Spartan")],
|
|
1010
|
+
});
|
|
1011
|
+
expect(firstSelectableIndex(items)).toBe(1);
|
|
1012
|
+
expect(items[1]?.kind).toBe("offer");
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
test("an offer is not tickable — it has no `checked` at all", () => {
|
|
1016
|
+
const items = buildStyleBrowserItems({
|
|
1017
|
+
presets: [],
|
|
1018
|
+
imports: [],
|
|
1019
|
+
selected: new Set(["offer:attention-span--spartan"]),
|
|
1020
|
+
applied: null,
|
|
1021
|
+
query: "",
|
|
1022
|
+
registry: [offer("attention-span--spartan", "attention-span", "Spartan")],
|
|
1023
|
+
});
|
|
1024
|
+
const item = items.find((i) => i.kind === "offer");
|
|
1025
|
+
expect(item).toBeDefined();
|
|
1026
|
+
expect("checked" in (item as object)).toBe(false);
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
test("offers match the filter on name and summary", () => {
|
|
1030
|
+
const items = buildStyleBrowserItems({
|
|
1031
|
+
presets: [],
|
|
1032
|
+
imports: [],
|
|
1033
|
+
selected: new Set(),
|
|
1034
|
+
applied: null,
|
|
1035
|
+
query: "rundown",
|
|
1036
|
+
registry: [
|
|
1037
|
+
offer("attention-span--spartan", "attention-span", "Spartan"),
|
|
1038
|
+
offer("attention-span--rundown", "attention-span", "Rundown"),
|
|
1039
|
+
],
|
|
1040
|
+
});
|
|
1041
|
+
const offers = items.filter((i) => i.kind === "offer");
|
|
1042
|
+
expect(offers.map((o) => o.label)).toEqual(["Rundown"]);
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
test("a retired entry is no longer offered", () => {
|
|
1046
|
+
// Retired means "do not recommend this any more". It must still RESOLVE
|
|
1047
|
+
// for a committed declaration, which is asserted in styles-sync.test.ts —
|
|
1048
|
+
// deleting the entry is what breaks a teammate's checkout.
|
|
1049
|
+
const items = buildStyleBrowserItems({
|
|
1050
|
+
presets: [],
|
|
1051
|
+
imports: [],
|
|
1052
|
+
selected: new Set(),
|
|
1053
|
+
applied: null,
|
|
1054
|
+
query: "",
|
|
1055
|
+
registry: [
|
|
1056
|
+
{
|
|
1057
|
+
...offer("attention-span--spartan", "attention-span", "Spartan"),
|
|
1058
|
+
retired: true as const,
|
|
1059
|
+
},
|
|
1060
|
+
],
|
|
1061
|
+
});
|
|
1062
|
+
expect(items).toHaveLength(0);
|
|
1063
|
+
});
|
|
1064
|
+
|
|
1065
|
+
test("an offer the project's declaration asks for is flagged as needed", () => {
|
|
1066
|
+
const items = buildStyleBrowserItems({
|
|
1067
|
+
presets: [],
|
|
1068
|
+
imports: [],
|
|
1069
|
+
selected: new Set(),
|
|
1070
|
+
applied: null,
|
|
1071
|
+
query: "",
|
|
1072
|
+
registry: [
|
|
1073
|
+
offer("attention-span--spartan", "attention-span", "Spartan"),
|
|
1074
|
+
offer("attention-span--rundown", "attention-span", "Rundown"),
|
|
1075
|
+
],
|
|
1076
|
+
fetchable: ["community:attention-span--spartan"],
|
|
1077
|
+
});
|
|
1078
|
+
const byLabel = new Map(
|
|
1079
|
+
items.filter((i) => i.kind === "offer").map((i) => [i.label, i]),
|
|
1080
|
+
);
|
|
1081
|
+
expect(byLabel.get("Spartan")?.declared).toBe(true);
|
|
1082
|
+
expect(byLabel.get("Rundown")?.declared).toBe(false);
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
test("carries the upstream check state onto the row, keyed by coordinate", () => {
|
|
1086
|
+
const items = buildStyleBrowserItems({
|
|
1087
|
+
presets: [],
|
|
1088
|
+
imports: [spartan()],
|
|
1089
|
+
selected: new Set(),
|
|
1090
|
+
applied: null,
|
|
1091
|
+
query: "",
|
|
1092
|
+
registry: [],
|
|
1093
|
+
upstream: {
|
|
1094
|
+
"attention-span--spartan": {
|
|
1095
|
+
state: "update-available",
|
|
1096
|
+
detail: "newer version available — 12 lines changed",
|
|
1097
|
+
checkedAt: 1,
|
|
1098
|
+
changedLines: 12,
|
|
1099
|
+
},
|
|
1100
|
+
},
|
|
1101
|
+
});
|
|
1102
|
+
const style = items.find((i) => i.kind === "style");
|
|
1103
|
+
expect(style?.kind === "style" && style.upstream?.state).toBe(
|
|
1104
|
+
"update-available",
|
|
1105
|
+
);
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
test("wears the same tone as Anthropic captures, never the danger tone", () => {
|
|
1109
|
+
// Both sections are "text captured from somewhere else onto this disk".
|
|
1110
|
+
// Red is the only unused tone and it means danger here — a normal, working
|
|
1111
|
+
// section must not wear it.
|
|
1112
|
+
const items = buildStyleBrowserItems({
|
|
1113
|
+
presets: [],
|
|
1114
|
+
imports: [],
|
|
1115
|
+
selected: new Set(),
|
|
1116
|
+
applied: null,
|
|
1117
|
+
query: "",
|
|
1118
|
+
registry: [offer("attention-span--spartan", "attention-span", "Spartan")],
|
|
1119
|
+
});
|
|
1120
|
+
const category = items.find((i) => i.kind === "category");
|
|
1121
|
+
expect(category?.kind === "category" && category.tone).toBe("yellow");
|
|
1122
|
+
expect(category?.kind === "category" && category.badge).toBe("from GitHub");
|
|
1123
|
+
});
|
|
1124
|
+
});
|