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.
- package/package.json +4 -4
- package/scripts/verify-community-registry.ts +272 -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__/open-file.test.ts +59 -0
- 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 +101 -7
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- 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/stylesAdapter.ts +403 -0
- package/src/ui/components/TabBar.tsx +43 -9
- 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/styleRenderers.tsx +809 -0
- package/src/ui/screens/StylesScreen.tsx +1089 -0
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +113 -1
- package/src/ui/state/types.ts +60 -2
- package/src/utils/open-file.ts +84 -0
|
@@ -0,0 +1,416 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, utimes } from "node:fs/promises";
|
|
3
|
+
import { homedir, tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import fs from "fs-extra";
|
|
6
|
+
import {
|
|
7
|
+
communityCacheDirOrNull,
|
|
8
|
+
createTeamStyle,
|
|
9
|
+
loadStyles,
|
|
10
|
+
splitFrontmatter,
|
|
11
|
+
stripBuiltinPrefix,
|
|
12
|
+
styleSlug,
|
|
13
|
+
} from "../services/styles-manager.js";
|
|
14
|
+
|
|
15
|
+
let dir: string;
|
|
16
|
+
let home: string;
|
|
17
|
+
let previousConfigDir: string | undefined;
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
dir = await mkdtemp(join(tmpdir(), "claudeup-origins-"));
|
|
21
|
+
home = join(dir, "home");
|
|
22
|
+
// The community cache resolves through CLAUDE_CONFIG_DIR when it is set, and
|
|
23
|
+
// another suite may have left one behind. Clearing it here makes the `home`
|
|
24
|
+
// override the only thing deciding where these tests look.
|
|
25
|
+
previousConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
|
26
|
+
// Must be ABSENT, not assigned undefined: `process.env.X = undefined` stores
|
|
27
|
+
// the STRING "undefined", which is truthy, so the resolver would happily
|
|
28
|
+
// build a path under a directory literally called "undefined".
|
|
29
|
+
// biome-ignore lint/performance/noDelete: absence is the intent, not a shortcut
|
|
30
|
+
delete process.env.CLAUDE_CONFIG_DIR;
|
|
31
|
+
});
|
|
32
|
+
afterEach(async () => {
|
|
33
|
+
await rm(dir, { recursive: true, force: true });
|
|
34
|
+
// biome-ignore lint/performance/noDelete: same reason as above
|
|
35
|
+
if (previousConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
|
|
36
|
+
else process.env.CLAUDE_CONFIG_DIR = previousConfigDir;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** Write a project-scoped output style with the given frontmatter lines. */
|
|
40
|
+
async function writeProjectStyle(
|
|
41
|
+
name: string,
|
|
42
|
+
frontmatter: string[],
|
|
43
|
+
): Promise<string> {
|
|
44
|
+
const file = join(dir, ".claude", "output-styles", `${name}.md`);
|
|
45
|
+
await fs.outputFile(
|
|
46
|
+
file,
|
|
47
|
+
["---", ...frontmatter, "---", "", "Some rules."].join("\n"),
|
|
48
|
+
);
|
|
49
|
+
return file;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Write a user-scoped output style under the temporary home. */
|
|
53
|
+
async function writeUserStyle(
|
|
54
|
+
name: string,
|
|
55
|
+
frontmatter: string[],
|
|
56
|
+
): Promise<string> {
|
|
57
|
+
const file = join(home, ".claude", "output-styles", `${name}.md`);
|
|
58
|
+
await fs.outputFile(
|
|
59
|
+
file,
|
|
60
|
+
["---", ...frontmatter, "---", "", "Some rules."].join("\n"),
|
|
61
|
+
);
|
|
62
|
+
return file;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Write a style into the claudeup community cache under the temporary home. */
|
|
66
|
+
async function writeCachedCommunityStyle(
|
|
67
|
+
name: string,
|
|
68
|
+
frontmatter: string[],
|
|
69
|
+
): Promise<string> {
|
|
70
|
+
const cacheDir = communityCacheDirOrNull(home);
|
|
71
|
+
if (!cacheDir)
|
|
72
|
+
throw new Error("community cache dir did not resolve under test");
|
|
73
|
+
const file = join(cacheDir, `${name}.md`);
|
|
74
|
+
await fs.outputFile(
|
|
75
|
+
file,
|
|
76
|
+
["---", ...frontmatter, "---", "", "Some rules."].join("\n"),
|
|
77
|
+
);
|
|
78
|
+
return file;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
describe("stripBuiltinPrefix", () => {
|
|
82
|
+
test("turns the slug into something readable", () => {
|
|
83
|
+
expect(stripBuiltinPrefix("builtin-explanatory")).toBe("Explanatory");
|
|
84
|
+
expect(stripBuiltinPrefix("builtin-plain-text")).toBe("Plain text");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("leaves a name that has no prefix alone apart from casing", () => {
|
|
88
|
+
expect(stripBuiltinPrefix("house-style")).toBe("House style");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("styleSlug", () => {
|
|
93
|
+
test("makes a filename-safe name", () => {
|
|
94
|
+
expect(styleSlug(" House Style ")).toBe("house-style");
|
|
95
|
+
expect(styleSlug("Team/Voice")).toBe("team-voice");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("strips path traversal rather than preserving it", () => {
|
|
99
|
+
// The slug becomes a filename under .claude/output-styles/, so anything
|
|
100
|
+
// that could climb out of that directory must not survive.
|
|
101
|
+
expect(styleSlug("../../etc/passwd")).toBe("etc-passwd");
|
|
102
|
+
expect(styleSlug("..")).toBe("");
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
describe("origin classification", () => {
|
|
107
|
+
test("a captured built-in is Anthropic official, named by its captured-style", () => {
|
|
108
|
+
// capture-builtin.ts writes `captured-style` with Anthropic's own name.
|
|
109
|
+
return (async () => {
|
|
110
|
+
await writeProjectStyle("builtin-explanatory", [
|
|
111
|
+
"name: builtin-explanatory",
|
|
112
|
+
'description: "Captured built-in output style: Explanatory"',
|
|
113
|
+
"captured-from: 2.1.233",
|
|
114
|
+
"captured-style: Explanatory",
|
|
115
|
+
]);
|
|
116
|
+
const snapshot = await loadStyles(dir, { home });
|
|
117
|
+
const style = snapshot.imports.find(
|
|
118
|
+
(s) => s.name === "builtin-explanatory",
|
|
119
|
+
);
|
|
120
|
+
expect(style?.origin).toBe("anthropic");
|
|
121
|
+
expect(style?.displayName).toBe("Explanatory");
|
|
122
|
+
expect(style?.capturedFrom).toBe("2.1.233");
|
|
123
|
+
})();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("the builtin- name alone is enough when captured-style is absent", async () => {
|
|
127
|
+
await writeProjectStyle("builtin-learning", ["name: builtin-learning"]);
|
|
128
|
+
const snapshot = await loadStyles(dir, { home });
|
|
129
|
+
const style = snapshot.imports.find((s) => s.name === "builtin-learning");
|
|
130
|
+
expect(style?.origin).toBe("anthropic");
|
|
131
|
+
expect(style?.displayName).toBe("Learning");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("a project style the user wrote is a team style", async () => {
|
|
135
|
+
await writeProjectStyle("house", ["name: house"]);
|
|
136
|
+
const snapshot = await loadStyles(dir, { home });
|
|
137
|
+
const style = snapshot.imports.find((s) => s.name === "house");
|
|
138
|
+
expect(style?.origin).toBe("team");
|
|
139
|
+
// Display name is the real name — nothing to strip.
|
|
140
|
+
expect(style?.displayName).toBe("house");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("the on-disk name survives for round-tripping, whatever is displayed", async () => {
|
|
144
|
+
await writeProjectStyle("builtin-proactive", [
|
|
145
|
+
"name: builtin-proactive",
|
|
146
|
+
"captured-style: Proactive",
|
|
147
|
+
]);
|
|
148
|
+
const snapshot = await loadStyles(dir, { home });
|
|
149
|
+
const style = snapshot.imports.find((s) => s.name === "builtin-proactive");
|
|
150
|
+
// `style-imports` records the id; a display-only rename that leaked into
|
|
151
|
+
// the id would break every already-applied composition.
|
|
152
|
+
expect(style?.id).toBe("project:builtin-proactive");
|
|
153
|
+
expect(style?.name).toBe("builtin-proactive");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("reports the date the file was last written", async () => {
|
|
157
|
+
const file = await writeProjectStyle("dated", ["name: dated"]);
|
|
158
|
+
const when = new Date("2026-03-04T12:00:00Z");
|
|
159
|
+
await utimes(file, when, when);
|
|
160
|
+
|
|
161
|
+
const snapshot = await loadStyles(dir, { home });
|
|
162
|
+
const style = snapshot.imports.find((s) => s.name === "dated");
|
|
163
|
+
expect(style?.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
|
164
|
+
// Local date, so allow the day either side of the UTC instant.
|
|
165
|
+
expect(["2026-03-03", "2026-03-04", "2026-03-05"]).toContain(
|
|
166
|
+
style?.updatedAt,
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe("the community cache directory", () => {
|
|
172
|
+
test("is NOT one of Claude Code's output-styles directories", () => {
|
|
173
|
+
// A fetched style is a copy claudeup may overwrite or delete without
|
|
174
|
+
// asking. Putting it in the user's own styles directory would make
|
|
175
|
+
// "delete this cached copy" and "delete my work" the same keypress.
|
|
176
|
+
const resolved = communityCacheDirOrNull(home);
|
|
177
|
+
expect(resolved).toBe(
|
|
178
|
+
join(home, ".claude", "claudeup", "community-styles"),
|
|
179
|
+
);
|
|
180
|
+
expect(resolved).not.toContain("output-styles");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("honours CLAUDE_CONFIG_DIR, like every other claudeup cache", () => {
|
|
184
|
+
process.env.CLAUDE_CONFIG_DIR = join(dir, "elsewhere");
|
|
185
|
+
expect(communityCacheDirOrNull(home)).toBe(
|
|
186
|
+
join(dir, "elsewhere", "claudeup", "community-styles"),
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("returns null for a test that never isolated its home", () => {
|
|
191
|
+
// The guard that stops an unisolated test reading — and later writing —
|
|
192
|
+
// the operator's real cache. It gets an empty section instead.
|
|
193
|
+
expect(process.env.NODE_ENV).toBe("test");
|
|
194
|
+
expect(communityCacheDirOrNull(homedir())).toBeNull();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("community classification", () => {
|
|
199
|
+
const spartan = [
|
|
200
|
+
"name: attention-span--spartan",
|
|
201
|
+
'description: "Blunt Spartan mode."',
|
|
202
|
+
"keep-coding-instructions: true",
|
|
203
|
+
"community-source: alexgreensh/attention-span",
|
|
204
|
+
"community-path: output-styles/spartan.md",
|
|
205
|
+
"community-commit: b860c9f8f3c7",
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
test("a cached style is community, managed, and namespaced by scope", async () => {
|
|
209
|
+
await writeCachedCommunityStyle("attention-span--spartan", spartan);
|
|
210
|
+
const snapshot = await loadStyles(dir, { home });
|
|
211
|
+
const style = snapshot.imports.find(
|
|
212
|
+
(s) => s.name === "attention-span--spartan",
|
|
213
|
+
);
|
|
214
|
+
expect(style?.origin).toBe("community");
|
|
215
|
+
expect(style?.scope).toBe("community");
|
|
216
|
+
expect(style?.managed).toBe(true);
|
|
217
|
+
expect(style?.id).toBe("community:attention-span--spartan");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("it shows upstream's own name, from the registry, not the coordinate", async () => {
|
|
221
|
+
// `name` is machinery (`attention-span--spartan`); "Spartan" is what a
|
|
222
|
+
// human wrote. Same id/displayName split the `builtin-` handling uses.
|
|
223
|
+
await writeCachedCommunityStyle("attention-span--spartan", spartan);
|
|
224
|
+
const snapshot = await loadStyles(dir, { home });
|
|
225
|
+
expect(
|
|
226
|
+
snapshot.imports.find((s) => s.name === "attention-span--spartan")
|
|
227
|
+
?.displayName,
|
|
228
|
+
).toBe("Spartan");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("an id the registry does not know still displays, using its own name", async () => {
|
|
232
|
+
// A style fetched before an entry was retired must not render blank.
|
|
233
|
+
await writeCachedCommunityStyle("retired-repo--gone", [
|
|
234
|
+
"name: retired-repo--gone",
|
|
235
|
+
"community-source: someone/retired-repo",
|
|
236
|
+
]);
|
|
237
|
+
const snapshot = await loadStyles(dir, { home });
|
|
238
|
+
const style = snapshot.imports.find((s) => s.name === "retired-repo--gone");
|
|
239
|
+
expect(style?.origin).toBe("community");
|
|
240
|
+
expect(style?.displayName).toBe("retired-repo--gone");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("the MARKER decides origin, the DIRECTORY decides ownership", async () => {
|
|
244
|
+
// A copy the user moved into their own output-styles directory still says
|
|
245
|
+
// where the words came from — hiding that would be exactly the
|
|
246
|
+
// directory-guessing this design rejects — but it is no longer ours to
|
|
247
|
+
// update or delete.
|
|
248
|
+
await writeUserStyle("my-copy-of-spartan", [
|
|
249
|
+
"name: my-copy-of-spartan",
|
|
250
|
+
"community-source: alexgreensh/attention-span",
|
|
251
|
+
]);
|
|
252
|
+
const snapshot = await loadStyles(dir, { home });
|
|
253
|
+
const style = snapshot.imports.find((s) => s.name === "my-copy-of-spartan");
|
|
254
|
+
expect(style?.origin).toBe("community");
|
|
255
|
+
expect(style?.scope).toBe("user");
|
|
256
|
+
expect(style?.managed).toBe(false);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("captured-style keeps precedence over community-source", async () => {
|
|
260
|
+
// A captured built-in is an Anthropic style wherever it sits, and however
|
|
261
|
+
// it got there.
|
|
262
|
+
await writeCachedCommunityStyle("builtin-explanatory", [
|
|
263
|
+
"name: builtin-explanatory",
|
|
264
|
+
"captured-style: Explanatory",
|
|
265
|
+
"community-source: someone/repo",
|
|
266
|
+
]);
|
|
267
|
+
const snapshot = await loadStyles(dir, { home });
|
|
268
|
+
const style = snapshot.imports.find(
|
|
269
|
+
(s) => s.name === "builtin-explanatory",
|
|
270
|
+
);
|
|
271
|
+
expect(style?.origin).toBe("anthropic");
|
|
272
|
+
expect(style?.displayName).toBe("Explanatory");
|
|
273
|
+
// Not ours to manage either — it was captured, not fetched.
|
|
274
|
+
expect(style?.managed).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("a hand-written file in the cache directory is not ours to manage", async () => {
|
|
278
|
+
await writeCachedCommunityStyle("stray", ["name: stray"]);
|
|
279
|
+
const snapshot = await loadStyles(dir, { home });
|
|
280
|
+
const style = snapshot.imports.find((s) => s.name === "stray");
|
|
281
|
+
expect(style?.managed).toBe(false);
|
|
282
|
+
expect(style?.origin).not.toBe("community");
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("a team or personal style is untouched by any of this", async () => {
|
|
286
|
+
await writeProjectStyle("house", ["name: house"]);
|
|
287
|
+
await writeUserStyle("mine", ["name: mine"]);
|
|
288
|
+
const snapshot = await loadStyles(dir, { home });
|
|
289
|
+
expect(snapshot.imports.find((s) => s.name === "house")?.origin).toBe(
|
|
290
|
+
"team",
|
|
291
|
+
);
|
|
292
|
+
expect(snapshot.imports.find((s) => s.name === "mine")?.origin).toBe(
|
|
293
|
+
"personal",
|
|
294
|
+
);
|
|
295
|
+
for (const style of snapshot.imports) expect(style.managed).toBe(false);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("loadStyles works with no community cache on disk at all", async () => {
|
|
299
|
+
await writeProjectStyle("house", ["name: house"]);
|
|
300
|
+
const snapshot = await loadStyles(dir, { home });
|
|
301
|
+
expect(snapshot.imports.map((s) => s.name)).toEqual(["house"]);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
describe("a comma in the declared name", () => {
|
|
306
|
+
// LATENT BUG, live today for any hand-written style: `composeStyleFile`
|
|
307
|
+
// serialises `style-imports` comma-separated and `readApplied` splits it back
|
|
308
|
+
// on commas, so an id containing one returns as two bogus ids and
|
|
309
|
+
// `computeStyleStatus` reports `stale` forever with no way to converge. None
|
|
310
|
+
// of the 57 community files observed trips it and `createTeamStyle` slugifies,
|
|
311
|
+
// so ours cannot — but a hand-edited file can.
|
|
312
|
+
|
|
313
|
+
test("falls back to the basename for the id", async () => {
|
|
314
|
+
await writeProjectStyle("blunt-fast", ['name: "Blunt, fast"']);
|
|
315
|
+
const snapshot = await loadStyles(dir, { home });
|
|
316
|
+
const style = snapshot.imports.find((s) => s.id === "project:blunt-fast");
|
|
317
|
+
expect(style).toBeDefined();
|
|
318
|
+
expect(style?.name).toBe("blunt-fast");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("keeps the declared name for display, so the user still sees what they wrote", async () => {
|
|
322
|
+
await writeProjectStyle("blunt-fast", ['name: "Blunt, fast"']);
|
|
323
|
+
const snapshot = await loadStyles(dir, { home });
|
|
324
|
+
expect(
|
|
325
|
+
snapshot.imports.find((s) => s.id === "project:blunt-fast")?.displayName,
|
|
326
|
+
).toBe("Blunt, fast");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("no id anywhere in the snapshot contains a comma", async () => {
|
|
330
|
+
// The actual invariant. An id with a comma cannot round-trip through
|
|
331
|
+
// `style-imports`, whatever produced it.
|
|
332
|
+
await writeProjectStyle("blunt-fast", ['name: "Blunt, fast"']);
|
|
333
|
+
await writeUserStyle("many-commas", ['name: "a, b, c"']);
|
|
334
|
+
const snapshot = await loadStyles(dir, { home });
|
|
335
|
+
expect(snapshot.imports.length).toBe(2);
|
|
336
|
+
for (const style of snapshot.imports) {
|
|
337
|
+
expect(style.id, style.id).not.toContain(",");
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("a name without a comma is left exactly as declared", async () => {
|
|
342
|
+
// The guard must not slugify names that were always fine — that would
|
|
343
|
+
// change every existing user's ids and break their committed declarations.
|
|
344
|
+
await writeProjectStyle("file-name", ["name: Declared Name"]);
|
|
345
|
+
const snapshot = await loadStyles(dir, { home });
|
|
346
|
+
const style = snapshot.imports.find(
|
|
347
|
+
(s) => s.displayName === "Declared Name",
|
|
348
|
+
);
|
|
349
|
+
expect(style?.name).toBe("Declared Name");
|
|
350
|
+
expect(style?.id).toBe("project:Declared Name");
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
describe("createTeamStyle", () => {
|
|
355
|
+
test("writes into the project so the style commits with the repo", async () => {
|
|
356
|
+
const created = await createTeamStyle(dir, "House Style");
|
|
357
|
+
expect(created.name).toBe("house-style");
|
|
358
|
+
expect(created.existed).toBe(false);
|
|
359
|
+
expect(created.path).toBe(
|
|
360
|
+
join(dir, ".claude", "output-styles", "house-style.md"),
|
|
361
|
+
);
|
|
362
|
+
expect(await fs.pathExists(created.path)).toBe(true);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("the scaffold keeps coding instructions on", async () => {
|
|
366
|
+
const created = await createTeamStyle(dir, "house");
|
|
367
|
+
const { frontmatter } = splitFrontmatter(
|
|
368
|
+
await fs.readFile(created.path, "utf8"),
|
|
369
|
+
);
|
|
370
|
+
expect(frontmatter["keep-coding-instructions"]).toBe("true");
|
|
371
|
+
expect(frontmatter.name).toBe("house");
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("the new style shows up as a team style straight away", async () => {
|
|
375
|
+
await createTeamStyle(dir, "house");
|
|
376
|
+
const snapshot = await loadStyles(dir, { home });
|
|
377
|
+
const style = snapshot.imports.find((s) => s.name === "house");
|
|
378
|
+
expect(style?.origin).toBe("team");
|
|
379
|
+
expect(style?.scope).toBe("project");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("never overwrites an existing file", async () => {
|
|
383
|
+
const first = await createTeamStyle(dir, "house");
|
|
384
|
+
await fs.writeFile(first.path, "HAND WRITTEN", "utf8");
|
|
385
|
+
|
|
386
|
+
const second = await createTeamStyle(dir, "house");
|
|
387
|
+
expect(second.existed).toBe(true);
|
|
388
|
+
// Destroying work the team already committed is not an acceptable
|
|
389
|
+
// outcome of pressing a key by mistake.
|
|
390
|
+
expect(await fs.readFile(second.path, "utf8")).toBe("HAND WRITTEN");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
test("refuses a name that would collide with the generated style", async () => {
|
|
394
|
+
// composed.md is rewritten on every apply, so a team style there would
|
|
395
|
+
// be silently destroyed.
|
|
396
|
+
await expect(createTeamStyle(dir, "composed")).rejects.toThrow(
|
|
397
|
+
/claudeup generates/,
|
|
398
|
+
);
|
|
399
|
+
await expect(createTeamStyle(dir, "composed-dev")).rejects.toThrow(
|
|
400
|
+
/claudeup generates/,
|
|
401
|
+
);
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
test("refuses a name with nothing usable in it", async () => {
|
|
405
|
+
await expect(createTeamStyle(dir, " ")).rejects.toThrow(/no letters/);
|
|
406
|
+
await expect(createTeamStyle(dir, "!!!")).rejects.toThrow(/no letters/);
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("a traversing name cannot escape the styles directory", async () => {
|
|
410
|
+
const created = await createTeamStyle(dir, "../../escaped");
|
|
411
|
+
expect(created.path.startsWith(join(dir, ".claude", "output-styles"))).toBe(
|
|
412
|
+
true,
|
|
413
|
+
);
|
|
414
|
+
expect(await fs.pathExists(join(dir, "..", "escaped.md"))).toBe(false);
|
|
415
|
+
});
|
|
416
|
+
});
|