claudeup 4.39.1 → 4.40.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/README.md CHANGED
@@ -67,9 +67,90 @@ check. So does `claudeup install --check`.
67
67
  | CLI Tools | Install claudish, mnemex and friends via the right package manager |
68
68
  | Git State | Gitignore conventions and repo hygiene |
69
69
  | Alias | Shell alias + flag management for launching `claude` |
70
+ | Styles | Communication style presets, composed into one native output style |
70
71
 
71
72
  Navigate with `↑/↓` or `j/k`, `Enter` to select, `r` to refresh, `?` for help,
72
- `q`/`Escape` to go back. Number keys `1`–`8` jump straight to a screen.
73
+ `q`/`Escape` to go back. Number keys `1`–`9` jump straight to a screen.
74
+
75
+ ## Styles
76
+
77
+ Claude Code activates exactly one output style at a time. The Styles screen (`9`)
78
+ composes several rule blocks into that one style, so choices that would otherwise
79
+ compete for the single slot end up in one file.
80
+
81
+ One **verbosity axis** — pick exactly one of `direct`, `explanatory`, or `terse` —
82
+ plus any number of free modifiers:
83
+
84
+ | Preset | Effect |
85
+ |---|---|
86
+ | `direct` | Lead with the answer, minimal preamble |
87
+ | `explanatory` | Teach as you go, more context per step |
88
+ | `terse` | Shortest useful output |
89
+ | `no-slop` | Bans filler, hype, and AI vocabulary |
90
+ | `asd-ste100` | ASD-STE100 Simplified Technical English: sentences that survive one read |
91
+ | `evidence-first` | Claims must carry a citation, command output, or file reference |
92
+ | `calibrated` | State confidence honestly; no false certainty |
93
+ | `plain-language` | Prefer plain words over jargon |
94
+ | `structured` | Headings, tables, and lists over long paragraphs |
95
+ | `terminology` | Enforce project-specific vocabulary, filled in from your codebase |
96
+
97
+ Two verbosity presets is the one combination that does not work: `terse` says "do
98
+ not explain unless asked" and `explanatory` says "explain the specific choice", so
99
+ together they cancel and the model resolves the contradiction differently every
100
+ turn. Everything else stacks.
101
+
102
+ The presets live in `src/data/styles/*.md` and are compiled into the binary, so
103
+ they are always the version this claudeup was built from.
104
+
105
+ ### What gets written
106
+
107
+ ```
108
+ .claude/output-styles/composed.md the composed rules (generated — do not hand-edit)
109
+ .claude/settings.json "outputStyle": "composed"
110
+ .claude/style.json the declaration — commit this to share it
111
+ ```
112
+
113
+ With a profile active the file is named `composed-<profile>` and the choice is
114
+ also recorded in `.claude/profiles.json`, because `claudeup install` rebuilds
115
+ `settings.json` from the manifest and would otherwise drop it.
116
+
117
+ `style.json` is the reviewable statement of what the project wants; `composed.md`
118
+ is the artifact Claude Code reads. A teammate who clones the repo gets the
119
+ declaration, and claudeup tells them when their local artifact does not match it.
120
+
121
+ ### Styles you already have
122
+
123
+ Output styles you wrote yourself compose too — claudeup finds them in
124
+ `~/.claude/output-styles/` and `.claude/output-styles/` and lists them alongside
125
+ the presets. Community styles can be fetched from their source repository on an
126
+ explicit action, and are cached outside `~/.claude/output-styles/` so a re-fetch
127
+ never overwrites something you wrote.
128
+
129
+ Built-in styles ship inside the Claude Code binary rather than on disk, so there
130
+ is no file to import until you capture one. `--discover` names the set the
131
+ installed version actually has — it was `Concise`, `Explanatory`, `Learning` and
132
+ `Proactive` on Claude Code 2.1.239, and Anthropic adds to it:
133
+
134
+ ```bash
135
+ bun scripts/capture-builtin.ts --discover # what Anthropic ships today
136
+ bun scripts/capture-builtin.ts --all # capture every built-in
137
+ bun scripts/capture-builtin.ts --check # what fell behind after an upgrade
138
+ ```
139
+
140
+ Each capture runs one real `claude -p` round trip behind a transparent proxy and
141
+ records the system prompt Claude Code actually sent, writing
142
+ `~/.claude/output-styles/builtin-<name>.md`. Captures are per machine and stamped
143
+ with the Claude Code version they came from — **re-run `--check` after every
144
+ upgrade**. Nothing is committed to this repo: the text is Anthropic's, and it
145
+ changes on their release schedule, not ours.
146
+
147
+ ### Coding rules stay on
148
+
149
+ Every generated style sets `keep-coding-instructions: true`. Without that flag
150
+ Claude Code drops its own coding-discipline rules from the system prompt, and a
151
+ setting about how to *communicate* has no business switching off how code gets
152
+ written. `force-for-plugin` is never set either — that would override your own
153
+ `/output-style` choice.
73
154
 
74
155
  ## Files claudeup touches
75
156
 
@@ -79,6 +160,8 @@ Navigate with `↑/↓` or `j/k`, `Enter` to select, `r` to refresh, `?` for hel
79
160
  | `.claude/_profiles/<name>/` | claudeup, gitignored | generated build output |
80
161
  | `.claude/settings.json` | Claude Code | in profile mode, a symlink into `_profiles/` |
81
162
  | `.claude/settings.local.json` | you, gitignored | env values collected during install |
163
+ | `.claude/style.json` | you, committed | the project's declared style — written on apply |
164
+ | `.claude/output-styles/composed*.md` | claudeup, committed | the generated style Claude Code reads |
82
165
  | `.mcp.json` | Claude Code | in profile mode, a symlink into `_profiles/` |
83
166
  | `~/.claude/plugins/*` | Claude Code | never written directly — always via the `claude` CLI |
84
167
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudeup",
3
- "version": "4.39.1",
3
+ "version": "4.40.0",
4
4
  "description": "TUI tool for managing Claude Code plugins, MCPs, and configuration",
5
5
  "type": "module",
6
6
  "main": "src/main.tsx",
@@ -64,8 +64,8 @@
64
64
  "typescript": "^5.6.3"
65
65
  },
66
66
  "optionalDependencies": {
67
- "claudeup-darwin-arm64": "4.39.1",
68
- "claudeup-darwin-x64": "4.39.1",
69
- "claudeup-linux-x64": "4.39.1"
67
+ "claudeup-darwin-arm64": "4.40.0",
68
+ "claudeup-darwin-x64": "4.40.0",
69
+ "claudeup-linux-x64": "4.40.0"
70
70
  }
71
71
  }
@@ -0,0 +1,412 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * capture-builtin.ts — turn a built-in Claude Code output style into an
4
+ * importable file on THIS machine.
5
+ *
6
+ * Built-in styles (Explanatory, Learning, Proactive) ship inside the Claude
7
+ * Code binary, so there is no file for the composer to read. This captures the
8
+ * real text instead of guessing it: a transparent proxy sits in front of the
9
+ * Anthropic API, one `claude -p` round trip runs with the style active, and
10
+ * the system prompt it actually sent is recorded.
11
+ *
12
+ * Why capture rather than grep the binary: the binary's symbol names are
13
+ * minified and change every release, so a grep breaks silently. A capture
14
+ * reads what the harness genuinely sent, for the exact version installed.
15
+ *
16
+ * Why on the user's machine rather than committed here: a snapshot in this
17
+ * repo would be Anthropic's prompt text redistributed, and would go stale on
18
+ * their release schedule rather than ours.
19
+ *
20
+ * Section boundaries are found by DIFF, not by heading. The style's own body
21
+ * contains H1 headings of its own (Explanatory ships "# Explanatory Style
22
+ * Active"), so splitting on the next "# " truncates it. Capturing once with
23
+ * the style and once without isolates exactly the inserted block.
24
+ *
25
+ * Usage:
26
+ * bun capture-builtin.ts --discover # names Anthropic ships today
27
+ * bun capture-builtin.ts --check # which captures fell behind
28
+ * bun capture-builtin.ts --all # capture every built-in
29
+ * bun capture-builtin.ts --style Explanatory # capture one
30
+ * [--out DIR] [--port 8899] [--timeout 180] [--dry-run]
31
+ *
32
+ * RE-RUN AFTER EVERY CLAUDE CODE UPGRADE. A capture records the version it
33
+ * came from; `--check` compares that against the installed binary and reports
34
+ * both stale files and built-ins that exist but were never captured, which is
35
+ * how a newly introduced style gets noticed.
36
+ *
37
+ * Costs one real API round trip per style, plus one shared baseline.
38
+ * Exit code: 1 on any failure, or from --check when anything is stale.
39
+ */
40
+
41
+ import {
42
+ existsSync,
43
+ mkdirSync,
44
+ mkdtempSync,
45
+ readFileSync,
46
+ readdirSync,
47
+ rmSync,
48
+ writeFileSync,
49
+ } from "node:fs";
50
+ import { homedir, tmpdir } from "node:os";
51
+ import { join } from "node:path";
52
+ import { parseArgs } from "node:util";
53
+
54
+ import { splitFrontmatter } from "../src/services/styles-manager.js";
55
+
56
+ const UPSTREAM = "https://api.anthropic.com";
57
+
58
+ interface CaptureResult {
59
+ system: string;
60
+ blocks: number;
61
+ }
62
+
63
+ /**
64
+ * Run one `claude -p` through a recording proxy and return the system prompt
65
+ * it sent. `styleName` of null captures the baseline with no style active.
66
+ */
67
+ async function captureSystem(
68
+ styleName: string | null,
69
+ port: number,
70
+ timeoutMs: number,
71
+ ): Promise<CaptureResult> {
72
+ const project = mkdtempSync(join(tmpdir(), "style-capture-"));
73
+ mkdirSync(join(project, ".claude"), { recursive: true });
74
+ writeFileSync(
75
+ join(project, ".claude", "settings.json"),
76
+ `${JSON.stringify(styleName ? { outputStyle: styleName } : {}, null, 2)}\n`,
77
+ "utf8",
78
+ );
79
+
80
+ let captured: unknown = null;
81
+
82
+ const server = Bun.serve({
83
+ port,
84
+ idleTimeout: 240,
85
+ async fetch(req) {
86
+ const url = new URL(req.url);
87
+ const body =
88
+ req.method === "GET" || req.method === "HEAD" ? undefined : await req.arrayBuffer();
89
+
90
+ if (body && captured === null) {
91
+ try {
92
+ const parsed = JSON.parse(new TextDecoder().decode(body)) as { system?: unknown };
93
+ if (parsed.system) captured = parsed.system;
94
+ } catch {
95
+ // Not JSON. Forward it regardless — recording is best effort.
96
+ }
97
+ }
98
+
99
+ const headers = new Headers(req.headers);
100
+ headers.set("host", new URL(UPSTREAM).host);
101
+ headers.delete("content-length");
102
+ // fetch() transparently decompresses, so forwarding the upstream
103
+ // content-encoding would tell the client to decompress plaintext.
104
+ // Ask for identity and drop the header on the way back.
105
+ headers.set("accept-encoding", "identity");
106
+
107
+ const upstream = await fetch(`${UPSTREAM}${url.pathname}${url.search}`, {
108
+ method: req.method,
109
+ headers,
110
+ body,
111
+ });
112
+ const out = new Headers(upstream.headers);
113
+ out.delete("content-encoding");
114
+ out.delete("content-length");
115
+ return new Response(upstream.body, { status: upstream.status, headers: out });
116
+ },
117
+ });
118
+
119
+ try {
120
+ const proc = Bun.spawn(["claude", "-p", "reply with the single word ok"], {
121
+ cwd: project,
122
+ env: { ...process.env, ANTHROPIC_BASE_URL: `http://127.0.0.1:${port}` },
123
+ stdout: "pipe",
124
+ stderr: "pipe",
125
+ });
126
+
127
+ const timer = setTimeout(() => proc.kill(), timeoutMs);
128
+ const exitCode = await proc.exited;
129
+ clearTimeout(timer);
130
+
131
+ if (captured === null) {
132
+ const stderr = await new Response(proc.stderr).text();
133
+ throw new Error(
134
+ `no system prompt captured (claude exited ${exitCode}). ${stderr.trim().slice(0, 300)}`,
135
+ );
136
+ }
137
+ } finally {
138
+ server.stop(true);
139
+ rmSync(project, { recursive: true, force: true });
140
+ }
141
+
142
+ const blocks = Array.isArray(captured) ? captured : [captured];
143
+ const system = blocks
144
+ .map((block) =>
145
+ typeof block === "string" ? block : String((block as { text?: string })?.text ?? ""),
146
+ )
147
+ .join("\n");
148
+
149
+ return { system, blocks: blocks.length };
150
+ }
151
+
152
+ /**
153
+ * The inserted block is what the styled capture has and the baseline does not.
154
+ *
155
+ * Given baseline = A + B and styled = A + X + B, the end of X is fixed by
156
+ * ARITHMETIC, not by walking back from the end. A common-suffix walk keeps
157
+ * going whenever X's last characters happen to match B's last characters, and
158
+ * it does happen: the first version of this truncated Explanatory mid-sentence
159
+ * at "general programming concepts", eating the closing two sentences.
160
+ */
161
+ /**
162
+ * Extract the section beginning at `marker` from `styled`.
163
+ *
164
+ * The end is found by heading, with `baseline` deciding which headings are
165
+ * STRUCTURAL. Splitting naively on the next `# ` truncates, because a style's
166
+ * own body contains H1s (Explanatory ships "# Explanatory Style Active"); but
167
+ * a heading that also appears in the baseline belongs to the harness, so it
168
+ * marks the real boundary.
169
+ *
170
+ * Character-level diffing was tried first and abandoned. If styled = A + X + B
171
+ * and baseline = A + B, the split is genuinely ambiguous whenever X starts or
172
+ * ends the way the surrounding text does — every rotation of X satisfies the
173
+ * length constraint. All three variants failed on real captures: a suffix walk
174
+ * cut Explanatory mid-sentence at "general programming concepts", a prefix
175
+ * walk slid past the marker entirely, and smallest-valid-split returned
176
+ * "D\nINSERTE" for "INSERTED\n".
177
+ *
178
+ * Residual limitation: a style whose body contains an H1 that also appears in
179
+ * the baseline would be cut there. None of the three built-ins does.
180
+ */
181
+ export function extractSection(baseline: string, styled: string, marker: string): string {
182
+ const start = styled.indexOf(marker);
183
+ if (start === -1) return "";
184
+
185
+ const structural = new Set(
186
+ baseline
187
+ .split("\n")
188
+ .filter((line) => line.startsWith("# "))
189
+ .map((line) => line.trimEnd()),
190
+ );
191
+
192
+ const lines = styled.slice(start).split("\n");
193
+ const out: string[] = [lines[0]];
194
+ for (const line of lines.slice(1)) {
195
+ if (line.startsWith("# ") && structural.has(line.trimEnd())) break;
196
+ out.push(line);
197
+ }
198
+ return out.join("\n").trimEnd();
199
+ }
200
+
201
+ /** Strip the harness's own `# Output Style: <name>` header off the block. */
202
+ export function stripSectionHeader(block: string, styleName: string): string {
203
+ const marker = `# Output Style: ${styleName}`;
204
+ const at = block.indexOf(marker);
205
+ if (at === -1) return block.trim();
206
+ return block.slice(at + marker.length).trim();
207
+ }
208
+
209
+ export function slugFor(styleName: string): string {
210
+ return `builtin-${styleName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`;
211
+ }
212
+
213
+ export function toStyleFile(styleName: string, body: string, version: string): string {
214
+ return [
215
+ "---",
216
+ `name: ${slugFor(styleName)}`,
217
+ `description: "Captured built-in output style: ${styleName}"`,
218
+ // Our composed file sets this itself; setting it here too means the file
219
+ // is also safe to activate directly.
220
+ "keep-coding-instructions: true",
221
+ // Machine-readable so --check can tell when Claude Code moved on. Keep it
222
+ // a bare version: --check compares it, a human sentence would not parse.
223
+ `captured-from: ${version}`,
224
+ `captured-style: ${styleName}`,
225
+ 'generated-by: "capture-builtin.ts. Re-run after a Claude Code upgrade."',
226
+ "---",
227
+ "",
228
+ body,
229
+ "",
230
+ ].join("\n");
231
+ }
232
+
233
+ /** "2.1.233 (Claude Code)" -> "2.1.233". --check compares these. */
234
+ export function normalizeVersion(raw: string): string {
235
+ return /(\d+\.\d+\.\d+)/.exec(raw)?.[1] ?? raw.trim() ?? "unknown";
236
+ }
237
+
238
+ async function run(cmd: string[]): Promise<string> {
239
+ try {
240
+ const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "ignore" });
241
+ await proc.exited;
242
+ return (await new Response(proc.stdout).text()).trim();
243
+ } catch {
244
+ return "";
245
+ }
246
+ }
247
+
248
+ async function claudeVersion(): Promise<string> {
249
+ return normalizeVersion(await run(["claude", "--version"])) || "unknown";
250
+ }
251
+
252
+ /**
253
+ * Names of the built-in styles, read out of the installed binary.
254
+ *
255
+ * This is the one place a minified-bundle grep is acceptable: it recovers
256
+ * NAMES, which are cheap to verify by eye against `/output-style` and
257
+ * harmless to get wrong. The prompt TEXT is never read this way — that is
258
+ * what the capture is for. If a future release changes the shape and this
259
+ * returns nothing, read the names off the `/output-style` picker and pass
260
+ * `--style` directly.
261
+ */
262
+ export async function discoverBuiltins(): Promise<string[]> {
263
+ const binary = await run(["sh", "-c", "readlink -f \"$(command -v claude)\" 2>/dev/null || command -v claude"]);
264
+ if (!binary) return [];
265
+ const out = await run([
266
+ "grep",
267
+ "-o",
268
+ "-a",
269
+ "-E",
270
+ 'name:"[A-Za-z0-9 -]+",source:"built-in"',
271
+ binary,
272
+ ]);
273
+ const names = new Set<string>();
274
+ for (const line of out.split("\n")) {
275
+ const match = /name:"([^"]+)"/.exec(line);
276
+ if (match) names.add(match[1]);
277
+ }
278
+ return [...names].sort();
279
+ }
280
+
281
+ async function main(): Promise<number> {
282
+ const { values } = parseArgs({
283
+ args: process.argv.slice(2),
284
+ options: {
285
+ style: { type: "string" },
286
+ all: { type: "boolean", default: false },
287
+ discover: { type: "boolean", default: false },
288
+ check: { type: "boolean", default: false },
289
+ out: { type: "string" },
290
+ port: { type: "string", default: "8899" },
291
+ timeout: { type: "string", default: "180" },
292
+ "dry-run": { type: "boolean", default: false },
293
+ },
294
+ allowPositionals: false,
295
+ });
296
+
297
+ const port = Number(values.port);
298
+ const timeoutMs = Number(values.timeout) * 1000;
299
+ const outDir = values.out ?? join(homedir(), ".claude", "output-styles");
300
+ const version = await claudeVersion();
301
+
302
+ if (values.discover) {
303
+ const names = await discoverBuiltins();
304
+ if (names.length === 0) {
305
+ console.error(
306
+ "Could not read the built-in names from the binary. Open /output-style and\n" +
307
+ "pass the names you see with --style.",
308
+ );
309
+ return 1;
310
+ }
311
+ console.log(`Built-in output styles in Claude Code ${version}:`);
312
+ for (const name of names) console.log(` ${name}`);
313
+ return 0;
314
+ }
315
+
316
+ if (values.check) {
317
+ const captured = existsSync(outDir)
318
+ ? readdirSync(outDir).filter((f) => f.startsWith("builtin-") && f.endsWith(".md"))
319
+ : [];
320
+ const known = await discoverBuiltins();
321
+
322
+ console.log(`Claude Code ${version}`);
323
+ console.log("════════════════════════════════════════");
324
+ let stale = 0;
325
+ for (const file of captured.sort()) {
326
+ const { frontmatter } = splitFrontmatter(readFileSync(join(outDir, file), "utf8"));
327
+ const from = frontmatter["captured-from"] ?? "unknown";
328
+ const ok = from === version;
329
+ if (!ok) stale++;
330
+ console.log(` ${ok ? "current" : "STALE "} ${file} (captured from ${from})`);
331
+ }
332
+ const missing = known.filter((name) => !captured.includes(`${slugFor(name)}.md`));
333
+ for (const name of missing) console.log(` MISSING ${name}`);
334
+ console.log("════════════════════════════════════════");
335
+ if (stale > 0 || missing.length > 0) {
336
+ console.log("Re-capture with: bun capture-builtin.ts --all");
337
+ return 1;
338
+ }
339
+ console.log("All built-ins captured at the current version.");
340
+ return 0;
341
+ }
342
+
343
+ let targets: string[];
344
+ if (values.all) {
345
+ targets = await discoverBuiltins();
346
+ if (targets.length === 0) {
347
+ console.error("error: --all found no built-in names. Use --style with an explicit name.");
348
+ return 1;
349
+ }
350
+ } else if (values.style) {
351
+ targets = [values.style];
352
+ } else {
353
+ console.error(
354
+ "error: pass --style <name>, or --all, or --discover to list what exists.",
355
+ );
356
+ return 1;
357
+ }
358
+
359
+ // One baseline serves every style — it is the same prompt minus the section.
360
+ console.error(`[capture] baseline (no output style) …`);
361
+ const baseline = await captureSystem(null, port, timeoutMs);
362
+ console.error(`[capture] baseline: ${baseline.system.length} chars, ${baseline.blocks} blocks`);
363
+
364
+ let failures = 0;
365
+ for (const [index, styleName] of targets.entries()) {
366
+ console.error(`[capture] with ${styleName} …`);
367
+ const styled = await captureSystem(styleName, port + 1 + index, timeoutMs);
368
+
369
+ const marker = `# Output Style: ${styleName}`;
370
+ const section = extractSection(baseline.system, styled.system, marker);
371
+ if (!section) {
372
+ console.error(
373
+ styled.system.includes(marker)
374
+ ? `error: ${styleName}: found the marker but not where the section ends.`
375
+ : `error: ${styleName}: "${marker}" is not in the captured prompt. Real built-in?`,
376
+ );
377
+ failures++;
378
+ continue;
379
+ }
380
+
381
+ const body = stripSectionHeader(section, styleName);
382
+ const file = toStyleFile(styleName, body, version);
383
+ const path = join(outDir, `${slugFor(styleName)}.md`);
384
+
385
+ if (values["dry-run"]) {
386
+ console.log(`--- ${path}`);
387
+ console.log(file);
388
+ continue;
389
+ }
390
+
391
+ mkdirSync(outDir, { recursive: true });
392
+ writeFileSync(path, file, "utf8");
393
+ console.log(
394
+ `captured ${styleName.padEnd(14)} ${body.length} chars -> --import user:${slugFor(styleName)}`,
395
+ );
396
+ }
397
+
398
+ if (!values["dry-run"] && failures === 0) {
399
+ console.log(`\nAll captures are from Claude Code ${version}. Re-run after an upgrade;`);
400
+ console.log(`"bun capture-builtin.ts --check" reports when they fall behind.`);
401
+ }
402
+ return failures > 0 ? 1 : 0;
403
+ }
404
+
405
+ if (import.meta.main) {
406
+ try {
407
+ process.exit(await main());
408
+ } catch (error) {
409
+ console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
410
+ process.exit(1);
411
+ }
412
+ }
@@ -0,0 +1,92 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import {
4
+ extractSection,
5
+ normalizeVersion,
6
+ slugFor,
7
+ stripSectionHeader,
8
+ toStyleFile,
9
+ } from "../../scripts/capture-builtin.ts";
10
+ import { splitFrontmatter } from "../services/styles-manager.js";
11
+
12
+ const MARKER = "# Output Style: X";
13
+
14
+ const BASELINE = "# First\nalpha\n\n# Second\nbeta";
15
+
16
+ describe("extractSection", () => {
17
+ test("stops at the next structural heading", () => {
18
+ const styled = `# First\nalpha\n\n${MARKER}\nbody\n\n# Second\nbeta`;
19
+ expect(extractSection(BASELINE, styled, MARKER)).toBe(`${MARKER}\nbody`);
20
+ });
21
+
22
+ test("keeps an H1 that belongs to the style, not to the harness", () => {
23
+ // Explanatory really does ship "# Explanatory Style Active" inside its
24
+ // own body. Splitting on the next "# " truncates it.
25
+ const styled = `# First\nalpha\n\n${MARKER}\nbody\n\n# X Style Active\nmore\n\n# Second\nbeta`;
26
+ const got = extractSection(BASELINE, styled, MARKER);
27
+ expect(got).toContain("# X Style Active");
28
+ expect(got).toContain("more");
29
+ expect(got).not.toContain("beta");
30
+ });
31
+
32
+ test("does not truncate when the section ends the way the tail ends", () => {
33
+ // A character-level suffix walk cut Explanatory mid-sentence here.
34
+ const styled = `# First\nalpha\n\n${MARKER}\nProvide them as you write code.\n\n# Second\nbeta`;
35
+ expect(extractSection(BASELINE, styled, MARKER)).toBe(
36
+ `${MARKER}\nProvide them as you write code.`,
37
+ );
38
+ });
39
+
40
+ test("runs to the end when the section is last", () => {
41
+ const styled = `# First\nalpha\n\n${MARKER}\nbody`;
42
+ expect(extractSection(BASELINE, styled, MARKER)).toBe(`${MARKER}\nbody`);
43
+ });
44
+
45
+ test("returns empty when the marker is absent", () => {
46
+ expect(extractSection(BASELINE, BASELINE, MARKER)).toBe("");
47
+ });
48
+ });
49
+
50
+ describe("stripSectionHeader", () => {
51
+ test("removes the harness header and keeps H1s belonging to the style", () => {
52
+ const block =
53
+ "# Output Style: Explanatory\nBody line.\n\n# Explanatory Style Active\n\nMore.";
54
+ const body = stripSectionHeader(block, "Explanatory");
55
+ expect(body.startsWith("Body line.")).toBe(true);
56
+ // A heading-based split would have cut here; the style owns this H1.
57
+ expect(body).toContain("# Explanatory Style Active");
58
+ });
59
+
60
+ test("passes the block through when the header is absent", () => {
61
+ expect(stripSectionHeader(" no header here ", "Explanatory")).toBe(
62
+ "no header here",
63
+ );
64
+ });
65
+ });
66
+
67
+ describe("toStyleFile", () => {
68
+ test("produces an importable file with coding instructions kept", () => {
69
+ const file = toStyleFile("Explanatory", "Body text.", "2.1.233");
70
+ const { frontmatter, body } = splitFrontmatter(file);
71
+ expect(frontmatter.name).toBe("builtin-explanatory");
72
+ expect(frontmatter["keep-coding-instructions"]).toBe("true");
73
+ expect(frontmatter.description).toBe(
74
+ "Captured built-in output style: Explanatory",
75
+ );
76
+ expect(frontmatter["captured-from"]).toBe("2.1.233");
77
+ expect(frontmatter["captured-style"]).toBe("Explanatory");
78
+ expect(body).toBe("Body text.");
79
+ });
80
+
81
+ test("slugs a multi-word style name into a usable filename", () => {
82
+ expect(slugFor("Explanatory")).toBe("builtin-explanatory");
83
+ expect(slugFor("Deep Research")).toBe("builtin-deep-research");
84
+ });
85
+ });
86
+
87
+ describe("normalizeVersion", () => {
88
+ test("reduces the CLI banner to a bare version so --check can compare it", () => {
89
+ expect(normalizeVersion("2.1.233 (Claude Code)")).toBe("2.1.233");
90
+ expect(normalizeVersion("2.1.233")).toBe("2.1.233");
91
+ });
92
+ });