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,1400 @@
1
+ /**
2
+ * Styles manager — discovery and composition of communication style presets.
3
+ *
4
+ * ## What a "style" is
5
+ *
6
+ * Claude Code activates exactly ONE output style at a time. The `style@magus`
7
+ * plugin's model is compositional instead — one verbosity preset plus any
8
+ * number of modifiers — so the combination has to be flattened into a single
9
+ * generated style file before the harness ever sees it. That flattening is
10
+ * what `composeStyleFile` does here.
11
+ *
12
+ * ## Why this re-implements the plugin's compose-style.ts
13
+ *
14
+ * The plugin ships `scripts/compose-style.ts`, run via `bun`. claudeup cannot
15
+ * call it: claudeup ships as a `bun --compile` binary, so `bun` may not be on
16
+ * PATH, and `scripts/` only exists when style@magus is installed. What IS
17
+ * stable is the *file format* — `styles/*.md` with frontmatter carrying
18
+ * `name`, `axis`, `summary`, `conflicts`, `template`. That format is the
19
+ * contract, and reading it needs no runtime at all.
20
+ *
21
+ * Parity with the plugin is asserted in `__tests__/styles-manager.test.ts`
22
+ * against the real shipped preset files.
23
+ *
24
+ * ## Profile awareness
25
+ *
26
+ * `.claude/settings.json` is a symlink into `.claude/_profiles/<name>/` when a
27
+ * profile is active, so writing `outputStyle` through it lands in that
28
+ * profile. But `materializeProfile` REWRITES that file from the manifest on
29
+ * every `claudeup install`, so the live write alone silently regresses. The
30
+ * selection is therefore also recorded in `.claude/profiles.json` under
31
+ * `profiles.<name>.settings.outputStyle`, which is the durable source.
32
+ *
33
+ * The generated style file is named per-profile (`composed-<name>`) so two
34
+ * profiles can hold different styles without clobbering each other's file
35
+ * while both point at the same name.
36
+ */
37
+
38
+ import { createHash } from "node:crypto";
39
+ import os from "node:os";
40
+ import path from "node:path";
41
+ import fs from "fs-extra";
42
+ import { findCommunityStyle } from "../data/community-styles.js";
43
+ import { getManifestPath, readManifest, writeManifest } from "./manifest.js";
44
+ import { activeProfile } from "./symlink-manager.js";
45
+
46
+ // ─── Types ────────────────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * The id namespace fetched styles live in. `community:<sourceId>--<slug>`.
50
+ *
51
+ * Exported because `.claude/style.json` carries these ids verbatim and both the
52
+ * adapter and the screen need to split one back into a registry coordinate.
53
+ */
54
+ export const COMMUNITY_ID_PREFIX = "community:";
55
+
56
+ export type StyleAxis = "verbosity" | "modifier";
57
+
58
+ /** A preset shipped by the style@magus plugin. */
59
+ export interface StylePreset {
60
+ kind: "preset";
61
+ /** Selection key. Equals `name` — presets share one namespace. */
62
+ id: string;
63
+ name: string;
64
+ /**
65
+ * What lists show. From the preset's `title:` frontmatter, else the slug.
66
+ * Display only — the CLI, declarations, and hashes all use `name`, the
67
+ * same split the `builtin-` captures already have.
68
+ */
69
+ displayName: string;
70
+ axis: StyleAxis;
71
+ summary: string;
72
+ /** Preset names that cancel this one out when both are selected. */
73
+ conflicts: string[];
74
+ /** Template presets ship an empty table and cannot be applied directly. */
75
+ template: boolean;
76
+ body: string;
77
+ path: string;
78
+ }
79
+
80
+ /**
81
+ * Where an importable style came from, which is what decides how it is grouped
82
+ * and whether it travels with the repository.
83
+ *
84
+ * - `anthropic` — a Claude Code built-in captured by the style plugin's
85
+ * `capture-builtin.ts`. Its text ships inside the binary, so the file is a
86
+ * snapshot that goes stale on the next upgrade.
87
+ * - `team` — project-scoped, so it lives in the repo and commits with it. This
88
+ * is the one everyone on the project gets.
89
+ * - `personal` — user-scoped, so it exists only on this machine.
90
+ * - `community` — someone else's style, fetched from their GitHub repo on an
91
+ * explicit user action and cached outside `~/.claude/output-styles/`. We
92
+ * store no copy of the text until the user asks for one, and the file carries
93
+ * the coordinates it came from.
94
+ */
95
+ export type StyleOrigin = "anthropic" | "team" | "personal" | "community";
96
+
97
+ /**
98
+ * Where a community style's words came from, as its own frontmatter records it.
99
+ *
100
+ * Read from text already on disk, so populating it costs no network call and
101
+ * `loadStyles` stays offline on every render. `source` is THE marker — its
102
+ * presence is what classifies the file as `community` in the first place.
103
+ *
104
+ * The two sentinels the fetcher writes are normalised back to null here, so the
105
+ * detail panel never has to know the words "unknown" and "unlicensed": a pin we
106
+ * could not resolve and a repo that grants no licence are both ABSENCES, and
107
+ * rendering them as values would let "unlicensed" read as an SPDX id.
108
+ */
109
+ export interface CommunityProvenance {
110
+ /** `owner/name` of the upstream repository. */
111
+ source: string;
112
+ /** Path within that repository, including its styles directory. */
113
+ path: string | null;
114
+ /** The git ref we asked for — usually `HEAD`. */
115
+ ref: string | null;
116
+ /** Short commit sha this copy is pinned at, or null if none was resolved. */
117
+ commit: string | null;
118
+ /** `sha256:<64 hex>` over the RAW upstream bytes at fetch time. */
119
+ sha256: string | null;
120
+ /** Local date of the fetch, YYYY-MM-DD. */
121
+ fetched: string | null;
122
+ /** SPDX id, or null when upstream grants no redistribution licence. */
123
+ licence: string | null;
124
+ author: string | null;
125
+ }
126
+
127
+ /** An output style already on this machine, importable into a composition. */
128
+ export interface ImportedStyle {
129
+ kind: "imported";
130
+ /** Selection key, `"user:name"` or `"project:name"` — scopes may collide. */
131
+ id: string;
132
+ /** The on-disk name. This is what `style-imports` records — never display-only. */
133
+ name: string;
134
+ /**
135
+ * What to show in the list. For a captured built-in this is Anthropic's own
136
+ * name ("Explanatory"), not the `builtin-explanatory` slug the file carries.
137
+ * Display only — composing still round-trips through `id`/`name`.
138
+ */
139
+ displayName: string;
140
+ /**
141
+ * Which directory it was read from. `community` is the claudeup cache, not a
142
+ * Claude Code styles directory — see `communityCacheDirOrNull`.
143
+ */
144
+ scope: "user" | "project" | "community";
145
+ origin: StyleOrigin;
146
+ /**
147
+ * True when claudeup owns this copy: it lives in the community cache, so it
148
+ * is re-fetchable and safe to delete. False for everything the user wrote or
149
+ * placed themselves, INCLUDING a community-marked file they copied into
150
+ * `~/.claude/output-styles/` — the marker says where the words came from, the
151
+ * directory says whose copy it is, and those are different questions.
152
+ */
153
+ managed: boolean;
154
+ description: string;
155
+ /** File mtime as YYYY-MM-DD — when this copy was last written. */
156
+ updatedAt: string | null;
157
+ /** For a captured built-in: the Claude Code version it was taken from. */
158
+ capturedFrom: string | null;
159
+ /** For a community style: where its words came from. Null for everything else. */
160
+ community: CommunityProvenance | null;
161
+ body: string;
162
+ path: string;
163
+ }
164
+
165
+ export type StyleSource = StylePreset | ImportedStyle;
166
+
167
+ /** Everything the Styles screen needs for one project, in one read. */
168
+ export interface StylesSnapshot {
169
+ presets: StylePreset[];
170
+ imports: ImportedStyle[];
171
+ /** Selection recorded in the generated style file; null if never applied. */
172
+ applied: { presets: string[]; imports: string[]; hash: string | null } | null;
173
+ /** The project's committed choice, `.claude/style.json`. */
174
+ declaration: StyleDeclaration | null;
175
+ /** Whether this machine's active style matches that declaration. */
176
+ status: StyleSyncStatus;
177
+ /** Where the generated style file is (or would be) written. */
178
+ stylePath: string;
179
+ /** The settings.json that carries `outputStyle`. */
180
+ settingsPath: string;
181
+ /** Name of the generated style, e.g. "composed" or "composed-dev". */
182
+ styleName: string;
183
+ /** Directory presets were read from; null when style@magus isn't installed. */
184
+ presetsRoot: string | null;
185
+ /** Active profile name, when .claude/settings.json is a profile symlink. */
186
+ profile: string | null;
187
+ /** The value currently in settings.json, whatever set it. */
188
+ currentOutputStyle: string | null;
189
+ }
190
+
191
+ /**
192
+ * The project's committed style choice: `.claude/style.json`.
193
+ *
194
+ * The generated `composed.md` is an ARTIFACT — it is what Claude Code reads.
195
+ * This is the DECLARATION: the short, reviewable statement of what the project
196
+ * wants, which is the thing worth committing and arguing about in a pull
197
+ * request. A teammate who clones the repo gets the declaration, and claudeup
198
+ * can tell them their local artifact does not match it yet.
199
+ */
200
+ export interface StyleDeclaration {
201
+ version: 1;
202
+ presets: string[];
203
+ imports: string[];
204
+ /**
205
+ * Hash of the composition this declaration produced when it was written.
206
+ *
207
+ * Over the source BODIES, not just their names: the same preset names
208
+ * compose to different text after a plugin update, and "you have the right
209
+ * names" is not the same claim as "you have the right rules".
210
+ */
211
+ hash: string;
212
+ updatedAt: string;
213
+ }
214
+
215
+ /** How the local generated style relates to the committed declaration. */
216
+ export type StyleSyncState =
217
+ /** No `.claude/style.json` — the project has not declared a style. */
218
+ | "undeclared"
219
+ /** Declared, but this machine has not applied it (or applied something else). */
220
+ | "not-applied"
221
+ /** Applied, but the composition would differ now — usually a pulled change. */
222
+ | "stale"
223
+ /** The active style matches the declaration exactly. */
224
+ | "in-sync";
225
+
226
+ export interface StyleSyncStatus {
227
+ state: StyleSyncState;
228
+ /** One line explaining the state, safe to show in the status bar. */
229
+ detail: string;
230
+ /** Names the declaration asks for that no longer resolve, and cannot be got. */
231
+ missing: string[];
232
+ /**
233
+ * Missing `community:` ids the registry still knows how to fetch.
234
+ *
235
+ * DISJOINT from `missing`, because they are different situations wearing the
236
+ * same shape: a name in `missing` is a dead end the user can do nothing
237
+ * about, and one in `fetchable` is a single keypress from resolved. Reporting
238
+ * both as "missing" is what would turn this feature's best case — a team
239
+ * commits a style choice including third-party text and everyone gets it —
240
+ * into an unactionable error.
241
+ */
242
+ fetchable: string[];
243
+ }
244
+
245
+ export interface ApplyResult {
246
+ stylePath: string;
247
+ settingsPath: string;
248
+ styleName: string;
249
+ profile: string | null;
250
+ /** True when the selection was also recorded in .claude/profiles.json. */
251
+ recordedInManifest: boolean;
252
+ /** The committed declaration that was written — commit this. */
253
+ declarationPath: string;
254
+ hash: string;
255
+ presets: string[];
256
+ imports: string[];
257
+ bytes: number;
258
+ }
259
+
260
+ // ─── Frontmatter ──────────────────────────────────────────────────────────────
261
+
262
+ type Frontmatter = Record<string, string>;
263
+
264
+ /**
265
+ * Split `---` frontmatter from a markdown body.
266
+ *
267
+ * Deliberately not a YAML parser: the keys we read are flat scalars, and
268
+ * pulling in a YAML dependency to read five strings would be the tail wagging
269
+ * the dog. Mirrors the plugin's parser exactly, quote-unescaping included.
270
+ */
271
+ export function splitFrontmatter(text: string): {
272
+ frontmatter: Frontmatter;
273
+ body: string;
274
+ } {
275
+ const lines = text.replace(/^\uFEFF/, "").split("\n");
276
+ if (lines[0]?.trim() !== "---") {
277
+ return { frontmatter: {}, body: text.trim() };
278
+ }
279
+
280
+ let close = -1;
281
+ for (let i = 1; i < lines.length; i++) {
282
+ if (lines[i].trim() === "---") {
283
+ close = i;
284
+ break;
285
+ }
286
+ }
287
+ if (close === -1) return { frontmatter: {}, body: text.trim() };
288
+
289
+ const frontmatter: Frontmatter = {};
290
+ for (const line of lines.slice(1, close)) {
291
+ const match = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line);
292
+ if (!match) continue;
293
+ let value = match[2].trim();
294
+ const quoted =
295
+ (value.startsWith('"') && value.endsWith('"')) ||
296
+ (value.startsWith("'") && value.endsWith("'"));
297
+ if (quoted && value.length >= 2) {
298
+ value = value.slice(1, -1).replace(/\\(["\\])/g, "$1");
299
+ }
300
+ frontmatter[match[1].toLowerCase()] = value;
301
+ }
302
+
303
+ return {
304
+ frontmatter,
305
+ body: lines
306
+ .slice(close + 1)
307
+ .join("\n")
308
+ .trim(),
309
+ };
310
+ }
311
+
312
+ function asBool(value: string | undefined): boolean | null {
313
+ if (value === undefined) return null;
314
+ const normalized = value.trim().toLowerCase();
315
+ if (normalized === "true" || normalized === "yes") return true;
316
+ if (normalized === "false" || normalized === "no") return false;
317
+ return null;
318
+ }
319
+
320
+ function splitList(value: string | undefined): string[] {
321
+ if (!value) return [];
322
+ return value
323
+ .split(",")
324
+ .map((entry) => entry.trim())
325
+ .filter(Boolean);
326
+ }
327
+
328
+ async function markdownFiles(dir: string): Promise<string[]> {
329
+ try {
330
+ if (!(await fs.pathExists(dir))) return [];
331
+ if (!(await fs.stat(dir)).isDirectory()) return [];
332
+ const entries = await fs.readdir(dir);
333
+ const files: string[] = [];
334
+ for (const entry of entries) {
335
+ if (!entry.endsWith(".md")) continue;
336
+ const full = path.join(dir, entry);
337
+ try {
338
+ if ((await fs.stat(full)).isFile()) files.push(full);
339
+ } catch {
340
+ /* unreadable entry — skip */
341
+ }
342
+ }
343
+ return files.sort();
344
+ } catch {
345
+ return [];
346
+ }
347
+ }
348
+
349
+ // ─── Preset discovery ─────────────────────────────────────────────────────────
350
+
351
+ /** Newest-first semver-ish comparison. Non-numeric segments sort last. */
352
+ function compareVersionsDesc(a: string, b: string): number {
353
+ const parse = (v: string) =>
354
+ v.split(".").map((part) => {
355
+ const n = Number.parseInt(part, 10);
356
+ return Number.isNaN(n) ? -1 : n;
357
+ });
358
+ const av = parse(a);
359
+ const bv = parse(b);
360
+ for (let i = 0; i < Math.max(av.length, bv.length); i++) {
361
+ const diff = (bv[i] ?? 0) - (av[i] ?? 0);
362
+ if (diff !== 0) return diff;
363
+ }
364
+ return 0;
365
+ }
366
+
367
+ /**
368
+ * Locate the style plugin's `styles/` directory.
369
+ *
370
+ * Order matters. The plugin CACHE is authoritative — that is what Claude Code
371
+ * actually loads (see CLAUDE.md, "Marketplace directory deletion bug"), and it
372
+ * survives a marketplace clone being deleted. The marketplace clone is the
373
+ * fallback, and a repo-relative path covers running claudeup from source.
374
+ *
375
+ * Returns null when style@magus isn't installed; the screen renders an empty
376
+ * state rather than pretending there are no styles.
377
+ */
378
+ export async function discoverPresetsRoot(): Promise<string | null> {
379
+ const home = os.homedir();
380
+ const cacheRoot = path.join(home, ".claude", "plugins", "cache");
381
+
382
+ // 1. Installed plugin cache: cache/<marketplace>/style/<version>/styles
383
+ try {
384
+ const marketplaces = await fs.readdir(cacheRoot);
385
+ const candidates: Array<{ version: string; dir: string }> = [];
386
+ for (const marketplace of marketplaces) {
387
+ const styleDir = path.join(cacheRoot, marketplace, "style");
388
+ if (!(await fs.pathExists(styleDir))) continue;
389
+ for (const version of await fs.readdir(styleDir)) {
390
+ const dir = path.join(styleDir, version, "styles");
391
+ if (await fs.pathExists(dir)) candidates.push({ version, dir });
392
+ }
393
+ }
394
+ if (candidates.length > 0) {
395
+ candidates.sort((a, b) => compareVersionsDesc(a.version, b.version));
396
+ return candidates[0].dir;
397
+ }
398
+ } catch {
399
+ /* no cache dir — fall through */
400
+ }
401
+
402
+ // 2. Marketplace clone
403
+ const marketplacesRoot = path.join(
404
+ home,
405
+ ".claude",
406
+ "plugins",
407
+ "marketplaces",
408
+ );
409
+ try {
410
+ for (const marketplace of await fs.readdir(marketplacesRoot)) {
411
+ const dir = path.join(
412
+ marketplacesRoot,
413
+ marketplace,
414
+ "plugins",
415
+ "style",
416
+ "styles",
417
+ );
418
+ if (await fs.pathExists(dir)) return dir;
419
+ }
420
+ } catch {
421
+ /* no marketplaces dir — fall through */
422
+ }
423
+
424
+ // 3. Running from source: walk up looking for plugins/style/styles
425
+ try {
426
+ let dir = path.dirname(new URL(import.meta.url).pathname);
427
+ for (let depth = 0; depth < 8; depth++) {
428
+ const candidate = path.join(dir, "plugins", "style", "styles");
429
+ if (await fs.pathExists(candidate)) return candidate;
430
+ const parent = path.dirname(dir);
431
+ if (parent === dir) break;
432
+ dir = parent;
433
+ }
434
+ } catch {
435
+ /* not resolvable in a compiled binary — fine */
436
+ }
437
+
438
+ return null;
439
+ }
440
+
441
+ async function readPresets(root: string | null): Promise<StylePreset[]> {
442
+ if (!root) return [];
443
+ const presets: StylePreset[] = [];
444
+ for (const file of await markdownFiles(root)) {
445
+ const { frontmatter, body } = splitFrontmatter(
446
+ await fs.readFile(file, "utf8"),
447
+ );
448
+ const name = frontmatter.name || path.basename(file, ".md");
449
+ presets.push({
450
+ kind: "preset",
451
+ id: name,
452
+ name,
453
+ displayName: frontmatter.title || name,
454
+ axis: frontmatter.axis === "verbosity" ? "verbosity" : "modifier",
455
+ summary: frontmatter.summary || "",
456
+ conflicts: splitList(frontmatter.conflicts),
457
+ template: asBool(frontmatter.template) === true,
458
+ body,
459
+ path: file,
460
+ });
461
+ }
462
+ return presets;
463
+ }
464
+
465
+ /**
466
+ * Where fetched community styles are cached, or null when a test has not
467
+ * isolated itself.
468
+ *
469
+ * Deliberately NOT `~/.claude/output-styles/`. A fetched style is a copy of
470
+ * someone else's text that claudeup can overwrite or delete without asking; the
471
+ * output-styles directories are the user's own, and putting a re-fetchable copy
472
+ * in them makes "delete this" and "delete my work" the same keypress.
473
+ *
474
+ * The null case mirrors `catalog-cache-store.ts`. Under `bun test`, a `home`
475
+ * that is still the operator's real one means the test never isolated itself —
476
+ * it gets an empty Community section rather than reading, and later possibly
477
+ * writing, the operator's live cache. A test that DOES pass a temporary home
478
+ * gets a working directory, which is what makes the classification tests
479
+ * possible at all.
480
+ */
481
+ export function communityCacheDirOrNull(home: string): string | null {
482
+ const configDir = process.env.CLAUDE_CONFIG_DIR;
483
+ if (configDir) return path.join(configDir, "claudeup", "community-styles");
484
+ if (process.env.NODE_ENV === "test" && home === os.homedir()) return null;
485
+ return path.join(home, ".claude", "claudeup", "community-styles");
486
+ }
487
+
488
+ async function readImportable(
489
+ projectPath: string,
490
+ generatedNames: string[],
491
+ home: string,
492
+ ): Promise<ImportedStyle[]> {
493
+ const communityDir = communityCacheDirOrNull(home);
494
+ const scopes: Array<{ scope: ImportedStyle["scope"]; dir: string }> = [
495
+ { scope: "user", dir: path.join(home, ".claude", "output-styles") },
496
+ {
497
+ scope: "project",
498
+ dir: path.join(projectPath, ".claude", "output-styles"),
499
+ },
500
+ ];
501
+ // A third entry in the SAME array, which is why the cache is flat: it reuses
502
+ // the non-recursive `markdownFiles` verbatim, and `.pending/` (downloaded but
503
+ // not accepted updates) is invisible to discovery by construction rather than
504
+ // by a filter someone could drop.
505
+ if (communityDir) scopes.push({ scope: "community", dir: communityDir });
506
+
507
+ const found: ImportedStyle[] = [];
508
+ for (const { scope, dir } of scopes) {
509
+ for (const file of await markdownFiles(dir)) {
510
+ const { frontmatter, body } = splitFrontmatter(
511
+ await fs.readFile(file, "utf8"),
512
+ );
513
+ const basename = path.basename(file, ".md");
514
+ const declared = frontmatter.name || basename;
515
+
516
+ // A comma in `name` corrupts the declaration round trip: `composeStyleFile`
517
+ // serialises `style-imports` comma-separated and `readApplied` splits it
518
+ // back on commas, so an id containing one returns as two bogus ids and
519
+ // `computeStyleStatus` reports `stale` forever with no way to converge.
520
+ // The basename cannot carry one, and keeping the declared value as
521
+ // `displayName` means the user still sees the name they wrote. Fixing the
522
+ // SERIALISATION instead would change the format every already-written
523
+ // composed.md carries, so every user's next load would report a spurious
524
+ // drift — a worse trade for a bug that is latent rather than live.
525
+ const hasComma = declared.includes(",");
526
+ const name = hasComma ? basename : declared;
527
+
528
+ // Never import a file we generate — it would compound on every apply.
529
+ if (generatedNames.includes(name)) continue;
530
+
531
+ // Classification is by MARKER, never by directory, and in this order.
532
+ // `captured-style` keeps precedence: capture-builtin.ts writes it with
533
+ // Anthropic's own name for the style, and a captured built-in is an
534
+ // Anthropic style wherever it sits. The `builtin-` name prefix is only a
535
+ // fallback, because a file the user happened to call `builtin-…` is
536
+ // theirs, not Anthropic's.
537
+ const capturedStyle = frontmatter["captured-style"];
538
+ const isAnthropic = Boolean(capturedStyle) || name.startsWith("builtin-");
539
+ // `community-source` names the repo the words came from. It travels with
540
+ // the file, so a copy the user moves into their own output-styles
541
+ // directory still reports its origin honestly — it is simply no longer
542
+ // `managed`, so nothing offers to update or delete it.
543
+ const isCommunity =
544
+ !isAnthropic && Boolean(frontmatter["community-source"]);
545
+
546
+ found.push({
547
+ kind: "imported",
548
+ id: `${scope}:${name}`,
549
+ name,
550
+ // Display only. The registry carries upstream's own name ("Spartan",
551
+ // "Zen Master") because the fetched file's `name` is the coordinate id,
552
+ // which is machinery. Same split as the `builtin-` handling above.
553
+ displayName: isAnthropic
554
+ ? capturedStyle || stripBuiltinPrefix(name)
555
+ : hasComma
556
+ ? declared
557
+ : isCommunity
558
+ ? (findCommunityStyle(name)?.displayName ?? name)
559
+ : name,
560
+ scope,
561
+ origin: isAnthropic
562
+ ? "anthropic"
563
+ : isCommunity
564
+ ? "community"
565
+ : scope === "project"
566
+ ? "team"
567
+ : "personal",
568
+ // Only what claudeup put in its own cache. A stray hand-written file
569
+ // in that directory is still not ours to overwrite.
570
+ managed: scope === "community" && isCommunity,
571
+ description: frontmatter.description || "",
572
+ updatedAt: await fileDate(file),
573
+ capturedFrom: frontmatter["captured-from"] || null,
574
+ community: isCommunity ? readProvenance(frontmatter) : null,
575
+ body,
576
+ path: file,
577
+ });
578
+ }
579
+ }
580
+ return found;
581
+ }
582
+
583
+ /**
584
+ * Lift the `community-*` keys out of a fetched file's frontmatter.
585
+ *
586
+ * The two sentinels the fetcher stamps — `unknown` for an unresolved pin and
587
+ * `unlicensed` for a repo that grants no licence — come back as null. They exist
588
+ * in the FILE because a blank value there would read as a truncated write; they
589
+ * must not survive into the model, where "unlicensed" would render in the
590
+ * licence slot as though it were an SPDX id.
591
+ */
592
+ function readProvenance(
593
+ frontmatter: Record<string, string>,
594
+ ): CommunityProvenance {
595
+ const value = (key: string, sentinel?: string): string | null => {
596
+ const raw = frontmatter[key]?.trim();
597
+ if (!raw || raw === sentinel) return null;
598
+ return raw;
599
+ };
600
+ return {
601
+ source: frontmatter["community-source"] ?? "",
602
+ path: value("community-path"),
603
+ ref: value("community-ref"),
604
+ commit: value("community-commit", "unknown"),
605
+ sha256: value("community-sha256"),
606
+ fetched: value("community-fetched"),
607
+ licence: value("community-licence", "unlicensed"),
608
+ author: value("community-author"),
609
+ };
610
+ }
611
+
612
+ /** `builtin-plain-text` -> `Plain text`. Only a fallback; see readImportable. */
613
+ export function stripBuiltinPrefix(name: string): string {
614
+ const bare = name.replace(/^builtin-/, "").replace(/-/g, " ");
615
+ return bare ? bare.charAt(0).toUpperCase() + bare.slice(1) : name;
616
+ }
617
+
618
+ /** Last-written date as YYYY-MM-DD, or null if the file cannot be stat'd. */
619
+ async function fileDate(file: string): Promise<string | null> {
620
+ try {
621
+ const { mtime } = await fs.stat(file);
622
+ // Local date, not toISOString(): the user reads this against their own
623
+ // calendar, and UTC would show yesterday for anything written after 5pm
624
+ // in a western timezone.
625
+ const pad = (n: number) => String(n).padStart(2, "0");
626
+ return `${mtime.getFullYear()}-${pad(mtime.getMonth() + 1)}-${pad(mtime.getDate())}`;
627
+ } catch {
628
+ return null;
629
+ }
630
+ }
631
+
632
+ // ─── Hashing and the committed declaration ────────────────────────────────────
633
+
634
+ /**
635
+ * Fingerprint a resolved composition.
636
+ *
637
+ * Covers each source's id AND body, in order, because all three of those change
638
+ * what Claude Code is actually told:
639
+ *
640
+ * - a different set of presets,
641
+ * - the same set in a different order (specific-after-broad matters),
642
+ * - the same set whose text changed under a plugin update.
643
+ *
644
+ * Hashing only the names would answer "did someone pick differently" and miss
645
+ * the third case entirely, which is the one nobody notices by eye.
646
+ *
647
+ * The integrity block is hashed too, even though it is not a source. It is text
648
+ * the model is given, it can change under a claudeup upgrade, and the third
649
+ * case above is exactly "the text changed while the names did not" — leaving it
650
+ * out would let `computeStyleStatus` report `in-sync` for a file that still
651
+ * carries the old block. Revising the block therefore costs every project one
652
+ * "project style changed — press a to re-apply", which is the honest signal.
653
+ */
654
+ export function styleHash(sources: StyleSource[]): string {
655
+ const hash = createHash("sha256");
656
+ for (const source of sources) {
657
+ hash.update(source.id);
658
+ hash.update("");
659
+ hash.update(source.body);
660
+ hash.update("");
661
+ }
662
+ hash.update(INTEGRITY_BLOCK);
663
+ return `sha256:${hash.digest("hex").slice(0, 32)}`;
664
+ }
665
+
666
+ export function declarationPath(projectPath: string): string {
667
+ return path.join(projectPath, ".claude", "style.json");
668
+ }
669
+
670
+ /** Read the committed declaration, or null when the project has none. */
671
+ export async function readStyleDeclaration(
672
+ projectPath: string,
673
+ ): Promise<StyleDeclaration | null> {
674
+ const file = declarationPath(projectPath);
675
+ try {
676
+ if (!(await fs.pathExists(file))) return null;
677
+ const parsed = (await fs.readJson(file)) as Partial<StyleDeclaration>;
678
+ if (!Array.isArray(parsed.presets) || !Array.isArray(parsed.imports)) {
679
+ return null;
680
+ }
681
+ return {
682
+ version: 1,
683
+ presets: parsed.presets.filter((v): v is string => typeof v === "string"),
684
+ imports: parsed.imports.filter((v): v is string => typeof v === "string"),
685
+ hash: typeof parsed.hash === "string" ? parsed.hash : "",
686
+ updatedAt: typeof parsed.updatedAt === "string" ? parsed.updatedAt : "",
687
+ };
688
+ } catch {
689
+ // A corrupt declaration is reported as "none" rather than crashing the
690
+ // screen; re-applying rewrites it.
691
+ return null;
692
+ }
693
+ }
694
+
695
+ async function writeStyleDeclaration(
696
+ projectPath: string,
697
+ declaration: StyleDeclaration,
698
+ ): Promise<void> {
699
+ const file = declarationPath(projectPath);
700
+ await fs.ensureDir(path.dirname(file));
701
+ await fs.writeJson(file, declaration, { spaces: 2 });
702
+ }
703
+
704
+ /**
705
+ * Resolve the declaration against what is on this machine right now.
706
+ *
707
+ * Returns the sources in composition order plus anything that no longer
708
+ * resolves — a preset the plugin dropped, or an imported style the teammate
709
+ * who committed the declaration has but this machine does not.
710
+ */
711
+ export function resolveDeclaration(
712
+ declaration: StyleDeclaration,
713
+ presets: StylePreset[],
714
+ imports: ImportedStyle[],
715
+ ): { sources: StyleSource[]; missing: string[]; fetchable: string[] } {
716
+ const missing: string[] = [];
717
+ const fetchable: string[] = [];
718
+
719
+ const chosenPresets: StylePreset[] = [];
720
+ for (const name of declaration.presets) {
721
+ const match = presets.find((preset) => preset.name === name);
722
+ if (match) chosenPresets.push(match);
723
+ else missing.push(name);
724
+ }
725
+
726
+ const chosenImports: ImportedStyle[] = [];
727
+ for (const id of declaration.imports) {
728
+ const match = imports.find((style) => style.id === id);
729
+ if (match) {
730
+ chosenImports.push(match);
731
+ continue;
732
+ }
733
+ // The whole reason community ids are registry COORDINATES rather than the
734
+ // file's own `frontmatter.name`: this lookup happens on a machine that has
735
+ // fetched nothing, so the id has to resolve before any file exists.
736
+ if (
737
+ id.startsWith(COMMUNITY_ID_PREFIX) &&
738
+ findCommunityStyle(id.slice(COMMUNITY_ID_PREFIX.length))
739
+ ) {
740
+ fetchable.push(id);
741
+ } else {
742
+ missing.push(id);
743
+ }
744
+ }
745
+
746
+ return {
747
+ sources: orderSources(chosenPresets, chosenImports),
748
+ missing,
749
+ fetchable,
750
+ };
751
+ }
752
+
753
+ /**
754
+ * Compare the committed declaration with what is actually live here.
755
+ *
756
+ * The comparison recomputes the hash from CURRENT source bodies rather than
757
+ * trusting the one stored in the declaration. That is the whole point: it
758
+ * answers "would applying this right now produce what I already have", which
759
+ * catches a pulled change, a plugin update, and a machine that never applied.
760
+ */
761
+ export function computeStyleStatus(args: {
762
+ declaration: StyleDeclaration | null;
763
+ presets: StylePreset[];
764
+ imports: ImportedStyle[];
765
+ /** Hash recorded in the local generated style file, if it exists. */
766
+ localHash: string | null;
767
+ /** Whether settings.json actually points at our generated style. */
768
+ isActive: boolean;
769
+ }): StyleSyncStatus {
770
+ const { declaration, presets, imports, localHash, isActive } = args;
771
+
772
+ if (!declaration) {
773
+ return {
774
+ state: "undeclared",
775
+ detail: "no project style committed",
776
+ missing: [],
777
+ fetchable: [],
778
+ };
779
+ }
780
+
781
+ const { sources, missing, fetchable } = resolveDeclaration(
782
+ declaration,
783
+ presets,
784
+ imports,
785
+ );
786
+ const expected = styleHash(sources);
787
+
788
+ // Said in preference to every other detail below, because it is the only one
789
+ // naming an action that CONVERGES. "press a" on a declaration whose community
790
+ // style is not on this machine re-applies a smaller set and leaves the
791
+ // difference in place, so the message would still be true next time.
792
+ const needsFetch =
793
+ fetchable.length > 0
794
+ ? `project style needs ${fetchable.length} community style${
795
+ fetchable.length === 1 ? "" : "s"
796
+ } — press f`
797
+ : null;
798
+
799
+ if (!isActive || !localHash) {
800
+ return {
801
+ state: "not-applied",
802
+ detail: needsFetch ?? "project style not applied here — press a",
803
+ missing,
804
+ fetchable,
805
+ };
806
+ }
807
+ if (localHash !== expected || needsFetch) {
808
+ // A pending fetch never reports in-sync even when the hashes agree. They
809
+ // agree because the un-fetched style was left out of BOTH sides, which is
810
+ // the composition matching itself rather than matching the declaration.
811
+ return {
812
+ state: "stale",
813
+ detail: needsFetch ?? "project style changed — press a to re-apply",
814
+ missing,
815
+ fetchable,
816
+ };
817
+ }
818
+ return {
819
+ state: "in-sync",
820
+ detail: "matches the project style",
821
+ missing,
822
+ fetchable,
823
+ };
824
+ }
825
+
826
+ // ─── Paths ────────────────────────────────────────────────────────────────────
827
+
828
+ const DEFAULT_STYLE_NAME = "composed";
829
+
830
+ /**
831
+ * The generated style's name for this project.
832
+ *
833
+ * Per-profile when a profile is active. Without this, two profiles both write
834
+ * `composed.md` and both point `outputStyle` at "composed": the second apply
835
+ * silently rewrites the first profile's style while its settings still claim
836
+ * the old one. Naming the file after the profile keeps them independent.
837
+ */
838
+ export function generatedStyleName(profile: string | null): string {
839
+ if (!profile) return DEFAULT_STYLE_NAME;
840
+ const slug = profile
841
+ .toLowerCase()
842
+ .replace(/[^a-z0-9]+/g, "-")
843
+ .replace(/^-|-$/g, "");
844
+ return slug ? `${DEFAULT_STYLE_NAME}-${slug}` : DEFAULT_STYLE_NAME;
845
+ }
846
+
847
+ function stylePathFor(projectPath: string, styleName: string): string {
848
+ return path.join(projectPath, ".claude", "output-styles", `${styleName}.md`);
849
+ }
850
+
851
+ function settingsPathFor(projectPath: string): string {
852
+ return path.join(projectPath, ".claude", "settings.json");
853
+ }
854
+
855
+ // ─── Snapshot ─────────────────────────────────────────────────────────────────
856
+
857
+ /** Read the selection a previously generated style file recorded. */
858
+ export async function readApplied(stylePath: string): Promise<{
859
+ presets: string[];
860
+ imports: string[];
861
+ hash: string | null;
862
+ } | null> {
863
+ if (!(await fs.pathExists(stylePath))) return null;
864
+ const { frontmatter } = splitFrontmatter(
865
+ await fs.readFile(stylePath, "utf8"),
866
+ );
867
+ const clean = (value: string | undefined) =>
868
+ !value || value === "none" ? [] : splitList(value);
869
+ return {
870
+ presets: clean(frontmatter["style-presets"]),
871
+ imports: clean(frontmatter["style-imports"]),
872
+ // Absent on a file written before hashing existed; treated as "unknown",
873
+ // which reads as out-of-date and prompts a re-apply. That is the safe
874
+ // direction to be wrong in.
875
+ hash: frontmatter["style-hash"] || null,
876
+ };
877
+ }
878
+
879
+ async function readOutputStyle(settingsPath: string): Promise<string | null> {
880
+ try {
881
+ if (!(await fs.pathExists(settingsPath))) return null;
882
+ const raw = (await fs.readFile(settingsPath, "utf8")).trim();
883
+ if (!raw) return null;
884
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
885
+ return typeof parsed.outputStyle === "string" ? parsed.outputStyle : null;
886
+ } catch {
887
+ return null;
888
+ }
889
+ }
890
+
891
+ export interface LoadStylesOptions {
892
+ /**
893
+ * Home directory to read user-scoped styles from. Defaults to the real one.
894
+ * Overridable so a test does not depend on whatever the developer happens to
895
+ * have in ~/.claude/output-styles — the plugin's compose-style.ts takes a
896
+ * `--home` flag for the same reason.
897
+ */
898
+ home?: string;
899
+ }
900
+
901
+ /** One read of everything the Styles screen renders. */
902
+ export async function loadStyles(
903
+ projectPath: string,
904
+ options: LoadStylesOptions = {},
905
+ ): Promise<StylesSnapshot> {
906
+ const home = options.home ?? os.homedir();
907
+ const profile = await activeProfile(projectPath);
908
+ const styleName = generatedStyleName(profile);
909
+ const stylePath = stylePathFor(projectPath, styleName);
910
+ const settingsPath = settingsPathFor(projectPath);
911
+
912
+ const presetsRoot = await discoverPresetsRoot();
913
+ const presets = await readPresets(presetsRoot);
914
+ // Exclude every name we might generate, not just the current one — a style
915
+ // left over from another profile is ours, not the user's, and importing it
916
+ // would nest a composition inside a composition.
917
+ const imports = await readImportable(
918
+ projectPath,
919
+ [DEFAULT_STYLE_NAME, styleName],
920
+ home,
921
+ );
922
+
923
+ // `applied` means "these rules are in force", which takes BOTH the generated
924
+ // file recording them and settings.json pointing at that file. Reading the
925
+ // file alone reports a cleared style as still live, because clearing removes
926
+ // the pointer and deliberately leaves the file on disk.
927
+ const currentOutputStyle = await readOutputStyle(settingsPath);
928
+ const applied =
929
+ currentOutputStyle === styleName ? await readApplied(stylePath) : null;
930
+
931
+ const declaration = await readStyleDeclaration(projectPath);
932
+ const status = computeStyleStatus({
933
+ declaration,
934
+ presets,
935
+ imports,
936
+ localHash: applied?.hash ?? null,
937
+ isActive: currentOutputStyle === styleName,
938
+ });
939
+
940
+ return {
941
+ presets,
942
+ imports,
943
+ applied,
944
+ declaration,
945
+ status,
946
+ stylePath,
947
+ settingsPath,
948
+ styleName,
949
+ presetsRoot,
950
+ profile,
951
+ currentOutputStyle,
952
+ };
953
+ }
954
+
955
+ // ─── Validation ───────────────────────────────────────────────────────────────
956
+
957
+ /**
958
+ * Why a selection cannot be applied. Empty array means it is valid.
959
+ *
960
+ * The rules are the plugin's, and they are not cosmetic: two verbosity presets
961
+ * are contradictory instructions that cancel out, producing a style that reads
962
+ * as if it were carefully configured and behaves as if it were not.
963
+ */
964
+ export function validateSelection(chosen: StylePreset[]): string[] {
965
+ const errors: string[] = [];
966
+
967
+ for (const preset of chosen) {
968
+ if (preset.template) {
969
+ errors.push(
970
+ `"${preset.name}" is a template preset — fill it in and save it to .claude/output-styles/ first, then import it.`,
971
+ );
972
+ }
973
+ }
974
+
975
+ const verbosity = chosen.filter((preset) => preset.axis === "verbosity");
976
+ if (verbosity.length > 1) {
977
+ errors.push(
978
+ `Pick exactly one verbosity preset. Got: ${verbosity
979
+ .map((preset) => preset.name)
980
+ .join(", ")}. They contradict each other and cancel out.`,
981
+ );
982
+ }
983
+
984
+ const seen = new Set<string>();
985
+ for (const preset of chosen) {
986
+ for (const conflict of preset.conflicts) {
987
+ if (!chosen.some((other) => other.name === conflict)) continue;
988
+ // Report a conflicting pair once, not once from each side.
989
+ const key = [preset.name, conflict].sort().join("|");
990
+ if (seen.has(key)) continue;
991
+ seen.add(key);
992
+ errors.push(`"${preset.name}" conflicts with "${conflict}".`);
993
+ }
994
+ }
995
+
996
+ return errors;
997
+ }
998
+
999
+ /**
1000
+ * Order the selected sources for composition.
1001
+ *
1002
+ * Imports first, then verbosity, then modifiers — specific-after-broad, so a
1003
+ * preset rule refines an imported personality rather than being buried by it.
1004
+ */
1005
+ export function orderSources(
1006
+ presets: StylePreset[],
1007
+ imports: ImportedStyle[],
1008
+ ): StyleSource[] {
1009
+ const verbosity = presets.filter((preset) => preset.axis === "verbosity");
1010
+ const modifiers = presets.filter((preset) => preset.axis === "modifier");
1011
+ return [...imports, ...verbosity.slice(0, 1), ...modifiers];
1012
+ }
1013
+
1014
+ // ─── Composition ──────────────────────────────────────────────────────────────
1015
+
1016
+ /** Claude Code truncates long descriptions in the picker; stay well under. */
1017
+ const MAX_DESCRIPTION = 200;
1018
+
1019
+ /**
1020
+ * The shared integrity block. Appended to EVERY composition, unconditionally.
1021
+ *
1022
+ * A composed style is arbitrary instruction text assembled from several
1023
+ * sources, and once the `community` origin lands one of those sources is
1024
+ * third-party writing fetched from someone else's repository. We cannot detect
1025
+ * prompt injection in it and will not pretend to. What we can do is state, at
1026
+ * the end of the prompt, the things style is never allowed to change — so a
1027
+ * rule that says "be brief" cannot be read as licence to trim a command, and a
1028
+ * rule that says "no hedging" cannot be read as licence to drop a warning.
1029
+ *
1030
+ * It lives in the BODY, not the frontmatter, because only the body reaches the
1031
+ * model. That is a real cost paid on every request, and it is the exception to
1032
+ * the "provenance goes in frontmatter" rule directly above: provenance is for
1033
+ * the human reading the file, this is for the model reading the prompt.
1034
+ *
1035
+ * It goes LAST for the same reason `orderSources` puts imports first —
1036
+ * specific-after-broad, so later text refines earlier text rather than being
1037
+ * buried by it.
1038
+ *
1039
+ * Mirrored verbatim in the style plugin's `scripts/compose-style.ts`; the
1040
+ * parity test in `__tests__/styles-manager.test.ts` reads that file and fails
1041
+ * if the two drift.
1042
+ */
1043
+ export const INTEGRITY_BLOCK = `## Style limits
1044
+
1045
+ These rules override everything above. Style decides how an answer is worded;
1046
+ it never decides what is true.
1047
+
1048
+ - Never reword, shorten, or tidy code, commands, file paths, identifiers, error
1049
+ text, log output, or numbers to fit a style rule. Reproduce them exactly,
1050
+ including the parts that read badly.
1051
+ - A brevity rule may cut prose. It may never cut a flag from a command, a
1052
+ segment from a path, a digit from a figure, or the line of a stack trace that
1053
+ names the failure.
1054
+ - Quote real output rather than paraphrasing it. When it is too long to
1055
+ include, quote the part that decides the answer and say what was left out.
1056
+ - Never soften or drop a security warning, a data-loss risk, or a caveat that
1057
+ would change what the reader does next. State it plainly, even under a rule
1058
+ that bans hedging.
1059
+ - Ask before any destructive or irreversible action and name exactly what would
1060
+ be lost. No verbosity or brevity rule suppresses that confirmation.
1061
+ - Say when something is unverified, failing, or unknown. A rule against filler
1062
+ bans padding, not honesty.`;
1063
+
1064
+ function describe(sources: StyleSource[]): string {
1065
+ const names = sources.map((source) => source.name).join(", ");
1066
+ const text = `Composed communication style: ${names || "no rules selected"}`;
1067
+ const clipped =
1068
+ text.length > MAX_DESCRIPTION
1069
+ ? `${text.slice(0, MAX_DESCRIPTION - 1)}\u2026`
1070
+ : text;
1071
+ // The value carries ": " from the label and preset names may carry anything.
1072
+ // Unquoted, YAML reads that as a nested mapping and the frontmatter fails to
1073
+ // parse — so always quote, and escape what would close the quote.
1074
+ return `"${clipped.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
1075
+ }
1076
+
1077
+ /** Render the single generated output-style file for a selection. */
1078
+ export function composeStyleFile(
1079
+ styleName: string,
1080
+ sources: StyleSource[],
1081
+ ): string {
1082
+ const presets = sources.filter(
1083
+ (source): source is StylePreset => source.kind === "preset",
1084
+ );
1085
+ const imports = sources.filter(
1086
+ (source): source is ImportedStyle => source.kind === "imported",
1087
+ );
1088
+
1089
+ // Provenance goes in FRONTMATTER, not the body: Claude Code splits the file
1090
+ // and only the body becomes the prompt, so a marker in the body is paid for
1091
+ // on every request forever.
1092
+ const lines: string[] = [
1093
+ "---",
1094
+ `name: ${styleName}`,
1095
+ `description: ${describe(sources)}`,
1096
+ // Without this, Claude Code drops its own coding-discipline block from the
1097
+ // system prompt. A style about how to COMMUNICATE has no business
1098
+ // switching off how to write code.
1099
+ "keep-coding-instructions: true",
1100
+ 'generated-by: "claudeup Styles tab. Hand edits are lost on the next apply."',
1101
+ `style-presets: ${presets.map((preset) => preset.name).join(", ") || "none"}`,
1102
+ `style-imports: ${imports.map((style) => style.id).join(", ") || "none"}`,
1103
+ // Fingerprint of the rules this file was built from. Recomputing it from
1104
+ // the declaration and comparing tells a teammate whether their local copy
1105
+ // is what the project currently asks for — see computeStyleStatus.
1106
+ `style-hash: ${styleHash(sources)}`,
1107
+ "---",
1108
+ "",
1109
+ ];
1110
+
1111
+ for (const style of imports) {
1112
+ lines.push(`## Imported: ${style.name}`, "", style.body, "");
1113
+ }
1114
+
1115
+ if (presets.length > 0) {
1116
+ lines.push("## Communication style", "");
1117
+ for (const preset of presets) {
1118
+ lines.push(preset.body, "");
1119
+ }
1120
+ }
1121
+
1122
+ // Unconditional, and last. A composition with no sources still gets it —
1123
+ // there is no selection for which "do not rewrite an error message" stops
1124
+ // applying.
1125
+ lines.push(INTEGRITY_BLOCK, "");
1126
+
1127
+ return `${lines
1128
+ .join("\n")
1129
+ .replace(/\n{3,}/g, "\n\n")
1130
+ .trimEnd()}\n`;
1131
+ }
1132
+
1133
+ // ─── Apply ────────────────────────────────────────────────────────────────────
1134
+
1135
+ /**
1136
+ * Set `outputStyle` in settings.json, preserving everything else.
1137
+ *
1138
+ * Writes in place. `.claude/settings.json` may be a symlink into an active
1139
+ * profile's materialized directory — `writeFile` follows it, which is what we
1140
+ * want. Removing and recreating the file would break the link and silently
1141
+ * detach the project from its profile.
1142
+ */
1143
+ async function setOutputStyle(
1144
+ settingsPath: string,
1145
+ styleName: string,
1146
+ ): Promise<void> {
1147
+ let settings: Record<string, unknown> = {};
1148
+ if (await fs.pathExists(settingsPath)) {
1149
+ const raw = (await fs.readFile(settingsPath, "utf8")).trim();
1150
+ if (raw) {
1151
+ try {
1152
+ settings = JSON.parse(raw) as Record<string, unknown>;
1153
+ } catch (error) {
1154
+ throw new Error(
1155
+ `${settingsPath} is not valid JSON, refusing to overwrite it: ${String(error)}`,
1156
+ );
1157
+ }
1158
+ }
1159
+ }
1160
+ settings.outputStyle = styleName;
1161
+ await fs.ensureDir(path.dirname(settingsPath));
1162
+ await fs.writeFile(
1163
+ settingsPath,
1164
+ `${JSON.stringify(settings, null, 2)}\n`,
1165
+ "utf8",
1166
+ );
1167
+ }
1168
+
1169
+ /**
1170
+ * Record the style in the committed manifest so it survives re-materialization.
1171
+ * Returns false when there is no active profile or no manifest entry for it —
1172
+ * both are normal, and neither is an error.
1173
+ */
1174
+ async function recordInManifest(
1175
+ projectPath: string,
1176
+ profile: string | null,
1177
+ styleName: string,
1178
+ ): Promise<boolean> {
1179
+ if (!profile) return false;
1180
+ if (!(await fs.pathExists(getManifestPath(projectPath)))) return false;
1181
+
1182
+ const manifest = await readManifest(projectPath);
1183
+ const entry = manifest.profiles[profile];
1184
+ if (!entry) return false;
1185
+
1186
+ entry.settings = { ...(entry.settings ?? {}), outputStyle: styleName };
1187
+ entry.updatedAt = new Date().toISOString();
1188
+ await writeManifest(manifest, projectPath);
1189
+ return true;
1190
+ }
1191
+
1192
+ export interface ApplyStylesArgs {
1193
+ projectPath: string;
1194
+ presets: StylePreset[];
1195
+ imports: ImportedStyle[];
1196
+ }
1197
+
1198
+ /**
1199
+ * Compose the selection, write it, activate it, and verify by re-reading.
1200
+ *
1201
+ * Verification is deliberate: a write that reported success but produced a
1202
+ * style file with `keep-coding-instructions` missing would strip Claude Code's
1203
+ * coding rules, and nothing downstream would notice.
1204
+ */
1205
+ export async function applyStyles({
1206
+ projectPath,
1207
+ presets,
1208
+ imports,
1209
+ }: ApplyStylesArgs): Promise<ApplyResult> {
1210
+ const errors = validateSelection(presets);
1211
+ if (errors.length > 0) throw new Error(errors.join(" "));
1212
+ if (presets.length === 0 && imports.length === 0) {
1213
+ throw new Error("Nothing selected. Pick at least one style.");
1214
+ }
1215
+
1216
+ const profile = await activeProfile(projectPath);
1217
+ const styleName = generatedStyleName(profile);
1218
+ const stylePath = stylePathFor(projectPath, styleName);
1219
+ const settingsPath = settingsPathFor(projectPath);
1220
+
1221
+ const sources = orderSources(presets, imports);
1222
+ const contents = composeStyleFile(styleName, sources);
1223
+
1224
+ await fs.ensureDir(path.dirname(stylePath));
1225
+ await fs.writeFile(stylePath, contents, "utf8");
1226
+ await setOutputStyle(settingsPath, styleName);
1227
+
1228
+ // Verify by re-reading, not by trusting the write.
1229
+ const verify = splitFrontmatter(await fs.readFile(stylePath, "utf8"));
1230
+ const problems: string[] = [];
1231
+ if (verify.frontmatter.name !== styleName) {
1232
+ problems.push(
1233
+ `style file name is "${verify.frontmatter.name}", expected "${styleName}"`,
1234
+ );
1235
+ }
1236
+ if (asBool(verify.frontmatter["keep-coding-instructions"]) !== true) {
1237
+ problems.push(
1238
+ "keep-coding-instructions is not true — coding rules would be dropped",
1239
+ );
1240
+ }
1241
+ if ((await readOutputStyle(settingsPath)) !== styleName) {
1242
+ problems.push("settings outputStyle did not take");
1243
+ }
1244
+ if (problems.length > 0) {
1245
+ throw new Error(`Verification failed — ${problems.join("; ")}`);
1246
+ }
1247
+
1248
+ const recordedInManifest = await recordInManifest(
1249
+ projectPath,
1250
+ profile,
1251
+ styleName,
1252
+ );
1253
+
1254
+ // Write the declaration LAST, once the artifact is verified. It is the thing
1255
+ // that gets committed and that teammates are told to match, so it must never
1256
+ // claim a composition that failed to produce a valid file.
1257
+ const hash = styleHash(sources);
1258
+ const declaration: StyleDeclaration = {
1259
+ version: 1,
1260
+ presets: presets.map((preset) => preset.name),
1261
+ imports: imports.map((style) => style.id),
1262
+ hash,
1263
+ updatedAt: new Date().toISOString(),
1264
+ };
1265
+ await writeStyleDeclaration(projectPath, declaration);
1266
+
1267
+ return {
1268
+ stylePath,
1269
+ settingsPath,
1270
+ styleName,
1271
+ profile,
1272
+ recordedInManifest,
1273
+ declarationPath: declarationPath(projectPath),
1274
+ hash,
1275
+ presets: declaration.presets,
1276
+ imports: declaration.imports,
1277
+ bytes: Buffer.byteLength(contents, "utf8"),
1278
+ };
1279
+ }
1280
+
1281
+ // ─── Authoring a team style ───────────────────────────────────────────────────
1282
+
1283
+ /** Filename-safe slug. Rejects anything that would escape the styles dir. */
1284
+ export function styleSlug(input: string): string {
1285
+ return input
1286
+ .trim()
1287
+ .toLowerCase()
1288
+ .replace(/[^a-z0-9]+/g, "-")
1289
+ .replace(/^-+|-+$/g, "");
1290
+ }
1291
+
1292
+ export interface CreatedStyle {
1293
+ path: string;
1294
+ name: string;
1295
+ /** True when the file already existed and was left untouched. */
1296
+ existed: boolean;
1297
+ }
1298
+
1299
+ /**
1300
+ * Scaffold a project-scoped style the team can edit and commit.
1301
+ *
1302
+ * Project scope is the whole point: `<project>/.claude/output-styles/` lives in
1303
+ * the repository, so the style travels with the project instead of being one
1304
+ * person's machine-local preference.
1305
+ *
1306
+ * This writes a starting file rather than opening an editor — claudeup owns the
1307
+ * terminal, and suspending an OpenTUI app to hand the TTY to `$EDITOR` is a
1308
+ * reliable way to leave the terminal in a broken state. The caller shows the
1309
+ * path; the user edits it and presses `r`.
1310
+ *
1311
+ * An existing file is never overwritten: that would silently destroy work the
1312
+ * team had already committed.
1313
+ */
1314
+ export async function createTeamStyle(
1315
+ projectPath: string,
1316
+ rawName: string,
1317
+ ): Promise<CreatedStyle> {
1318
+ const name = styleSlug(rawName);
1319
+ if (!name) {
1320
+ throw new Error(
1321
+ `"${rawName}" has no letters or digits to make a filename from.`,
1322
+ );
1323
+ }
1324
+ if (
1325
+ name === DEFAULT_STYLE_NAME ||
1326
+ name.startsWith(`${DEFAULT_STYLE_NAME}-`)
1327
+ ) {
1328
+ throw new Error(
1329
+ `"${name}" is the name claudeup generates. Pick another, or it would be overwritten on the next apply.`,
1330
+ );
1331
+ }
1332
+
1333
+ const file = path.join(projectPath, ".claude", "output-styles", `${name}.md`);
1334
+ if (await fs.pathExists(file)) {
1335
+ return { path: file, name, existed: true };
1336
+ }
1337
+
1338
+ const scaffold = [
1339
+ "---",
1340
+ `name: ${name}`,
1341
+ `description: "Team communication style: ${name}"`,
1342
+ // Same reasoning as the composed file: a style about how to COMMUNICATE
1343
+ // must not switch off Claude Code's own coding rules.
1344
+ "keep-coding-instructions: true",
1345
+ "---",
1346
+ "",
1347
+ `## ${name}`,
1348
+ "",
1349
+ "Replace this with the rules for this project. Write them as short,",
1350
+ "checkable instructions — each one something a reader could tell was",
1351
+ "followed or not. For example:",
1352
+ "",
1353
+ "- Name the file and line when describing a change.",
1354
+ "- Report a failing test with its actual output, never a paraphrase.",
1355
+ "",
1356
+ "Commit this file with the project so everyone gets the same style.",
1357
+ "",
1358
+ ].join("\n");
1359
+
1360
+ await fs.ensureDir(path.dirname(file));
1361
+ await fs.writeFile(file, scaffold, "utf8");
1362
+ return { path: file, name, existed: false };
1363
+ }
1364
+
1365
+ /**
1366
+ * Deactivate: drop `outputStyle` from settings.json (and the manifest entry).
1367
+ * The generated file is left on disk — it is cheap, and keeping it means an
1368
+ * accidental clear is one re-apply away from being undone.
1369
+ */
1370
+ export async function clearStyle(projectPath: string): Promise<void> {
1371
+ const settingsPath = settingsPathFor(projectPath);
1372
+ if (await fs.pathExists(settingsPath)) {
1373
+ const raw = (await fs.readFile(settingsPath, "utf8")).trim();
1374
+ if (raw) {
1375
+ const settings = JSON.parse(raw) as Record<string, unknown>;
1376
+ // The key must be ABSENT, not present-and-undefined: Claude Code reads
1377
+ // presence, and an `= undefined` assignment still answers `in` checks.
1378
+ // biome-ignore lint/performance/noDelete: removal is the intent, not a shortcut
1379
+ delete settings.outputStyle;
1380
+ await fs.writeFile(
1381
+ settingsPath,
1382
+ `${JSON.stringify(settings, null, 2)}\n`,
1383
+ "utf8",
1384
+ );
1385
+ }
1386
+ }
1387
+
1388
+ const profile = await activeProfile(projectPath);
1389
+ if (!profile) return;
1390
+ if (!(await fs.pathExists(getManifestPath(projectPath)))) return;
1391
+ const manifest = await readManifest(projectPath);
1392
+ const entry = manifest.profiles[profile];
1393
+ if (!entry?.settings || !("outputStyle" in entry.settings)) return;
1394
+ // The guard above uses `in`, so assigning undefined would make a second call
1395
+ // a no-op that still leaves the key serialized into profiles.json.
1396
+ // biome-ignore lint/performance/noDelete: removal is the intent, not a shortcut
1397
+ delete entry.settings.outputStyle;
1398
+ entry.updatedAt = new Date().toISOString();
1399
+ await writeManifest(manifest, projectPath);
1400
+ }