claudeup 4.39.1 → 4.40.1
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__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/content-drift.test.ts +128 -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/claude-settings.ts +39 -6
- package/src/services/content-drift.ts +81 -4
- package/src/services/plugin-manager.ts +1 -0
- package/src/services/styles-manager.ts +42 -126
- 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.1",
|
|
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.1",
|
|
68
|
+
"claudeup-darwin-x64": "4.40.1",
|
|
69
|
+
"claudeup-linux-x64": "4.40.1"
|
|
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,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REGRESSION: every installed plugin rendered "stale — reinstall" at once.
|
|
3
|
+
*
|
|
4
|
+
* `updateInstalledPluginsRegistry` re-copies a plugin's files out of the
|
|
5
|
+
* marketplace clone on every in-place update, but used to carry the pre-update
|
|
6
|
+
* `gitCommitSha` forward verbatim. The recorded commit therefore named whichever
|
|
7
|
+
* release the plugin was FIRST installed from — measured 2026-08-22 on one
|
|
8
|
+
* machine: dev claimed 4.3.0 against a sha holding 3.3.0, browser-use 1.4.1
|
|
9
|
+
* against 1.1.3 — and the subtree diff across those releases is always
|
|
10
|
+
* non-empty. Fixed in /dev:fix session dev-fix-20260822-125510-03db093b.
|
|
11
|
+
*
|
|
12
|
+
* Hermetic: exercises the provenance rule directly, with a real git repo. The
|
|
13
|
+
* registry write itself resolves through os.homedir() and is not sandboxable
|
|
14
|
+
* without new test infrastructure, so the decision is tested where it lives.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { promises as fs } from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { resolveCacheProvenance } from "../services/claude-settings.js";
|
|
23
|
+
|
|
24
|
+
let repo: string;
|
|
25
|
+
let plain: string;
|
|
26
|
+
|
|
27
|
+
const git = (args: string[]) =>
|
|
28
|
+
spawnSync("git", ["-c", "commit.gpgsign=false", ...args], {
|
|
29
|
+
cwd: repo,
|
|
30
|
+
encoding: "utf-8",
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
beforeEach(async () => {
|
|
34
|
+
repo = await fs.mkdtemp(path.join(os.tmpdir(), "provenance-repo-"));
|
|
35
|
+
plain = await fs.mkdtemp(path.join(os.tmpdir(), "provenance-plain-"));
|
|
36
|
+
await fs.mkdir(path.join(repo, "plugins", "alpha"), { recursive: true });
|
|
37
|
+
await fs.writeFile(path.join(repo, "plugins", "alpha", "plugin.json"), "{}");
|
|
38
|
+
git(["init", "-q", "-b", "main"]);
|
|
39
|
+
git(["config", "user.email", "probe@example.invalid"]);
|
|
40
|
+
git(["config", "user.name", "probe"]);
|
|
41
|
+
git(["add", "-A"]);
|
|
42
|
+
git(["commit", "-q", "-m", "release"]);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
afterEach(async () => {
|
|
46
|
+
await fs.rm(repo, { recursive: true, force: true });
|
|
47
|
+
await fs.rm(plain, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe("resolveCacheProvenance", () => {
|
|
51
|
+
test("stamps the source repo's HEAD when files were copied", async () => {
|
|
52
|
+
const head = git(["rev-parse", "HEAD"]).stdout.trim();
|
|
53
|
+
const stale = "0".repeat(40);
|
|
54
|
+
|
|
55
|
+
expect(
|
|
56
|
+
await resolveCacheProvenance(path.join(repo, "plugins", "alpha"), stale),
|
|
57
|
+
).toBe(head);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("does NOT carry a stale sha forward after a successful copy", async () => {
|
|
61
|
+
// The bug in one line: the old code returned `stale` here, so the
|
|
62
|
+
// recorded commit fell further behind with every release.
|
|
63
|
+
const stale = "0".repeat(40);
|
|
64
|
+
expect(
|
|
65
|
+
await resolveCacheProvenance(path.join(repo, "plugins", "alpha"), stale),
|
|
66
|
+
).not.toBe(stale);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("keeps the previous sha when nothing was copied", async () => {
|
|
70
|
+
// A failed copy leaves the cache untouched, so the old sha still
|
|
71
|
+
// describes it. Advancing it would claim the cache holds newer files
|
|
72
|
+
// than it does, which hides real drift.
|
|
73
|
+
const previous = "a".repeat(40);
|
|
74
|
+
expect(await resolveCacheProvenance(null, previous)).toBe(previous);
|
|
75
|
+
expect(await resolveCacheProvenance(null, undefined)).toBeUndefined();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("records no sha for a source outside any git repo", async () => {
|
|
79
|
+
// Directory-type marketplaces have no commit to name. Inventing one
|
|
80
|
+
// would assert a provenance that is false; content-drift reads an
|
|
81
|
+
// absent sha as "cannot answer" and stays silent.
|
|
82
|
+
expect(await resolveCacheProvenance(plain, "b".repeat(40))).toBeUndefined();
|
|
83
|
+
});
|
|
84
|
+
});
|