claudeup 4.39.0 → 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 +84 -1
- package/package.json +4 -4
- package/scripts/capture-builtin.ts +412 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/styles-manager.test.ts +75 -101
- package/src/__tests__/styles-screen-state.test.ts +0 -3
- package/src/__tests__/terminology-filler.test.ts +2 -2
- package/src/data/community-styles.ts +3 -3
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/markdown.d.ts +13 -0
- package/src/services/styles-manager.ts +42 -126
- package/src/ui/components/CategoryHeader.tsx +28 -11
- package/src/ui/components/ScrollableList.tsx +8 -0
- package/src/ui/screens/StylesScreen.tsx +5 -31
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`–`
|
|
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.
|
|
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.
|
|
68
|
-
"claudeup-darwin-x64": "4.
|
|
69
|
-
"claudeup-linux-x64": "4.
|
|
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
|
+
});
|