Skip to content

Commit 41f0045

Browse files
committed
Add tools/captureToolSchemas.mjs — turnkey capture of all 35 schemas
Bundles a tiny HTTP intercept server with the four-run capture loop so that, on any machine with `claude -p` installed, running node tools/captureToolSchemas.mjs regenerates the full `tool-schemas/` directory in ~15s, with no external proxy, no API key, no Anthropic account, no upstream call at all. How it works: 1. Server listens on 127.0.0.1:4099 (configurable via CAPTURE_PORT). 2. Spawns `claude -p "ok"` four times, each with the env that surfaces a different tool set (default, --agent-teams, local-agent entrypoint, brief / KAIROS). 3. On each spawn, claude POSTs to /v1/messages through ANTHROPIC_BASE_URL pointed at us. The server pulls `tools[]` out of the request body and replies with a stub 403 — claude exits immediately without retrying, and we move on to the next env. 4. After all four runs, the unioned `tools[]` (first-seen wins per name) is sorted by tool name, each `input_schema` is recursively lexically key-sorted for byte-stable diffs, StructuredOutput is dropped (its schema is caller-supplied), and one file per tool is written under tool-schemas/. Why a stub 403 instead of forwarding to api.anthropic.com: Anthropic's OAuth bearer tokens are bound to the TLS / connection profile of the proxy that established them. A fresh Node proxy forwarding identical headers gets `403 Request not allowed` consistently. So we don't forward — we capture the request body (which is what we actually want) and stub a fast-exit error response. Claude still emits the full tools[] before we reply. Verified end-to-end on Claude Code v2.1.168: 33/35 output files are byte-identical to PR #24's committed schemas; the 2 deltas (agent.json, …) reflect real-world wire changes since #24 was captured. Two back-to-back runs produce byte-identical output.
1 parent 50555f2 commit 41f0045

1 file changed

Lines changed: 197 additions & 0 deletions

File tree

tools/captureToolSchemas.mjs

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
#!/usr/bin/env node
2+
// Capture Claude Code's tool `input_schema` values into tool-schemas/<slug>.json
3+
// by running a tiny intercepting HTTP server, pointing `claude -p` at it via
4+
// ANTHROPIC_BASE_URL, and pulling `tools[]` out of the POST /v1/messages
5+
// request body Claude Code sends.
6+
//
7+
// The server captures the request body and then replies with a stub auth
8+
// error — so it never forwards anywhere and doesn't need a valid upstream
9+
// session. Claude exits immediately on the error, the script collects the
10+
// captured tools, moves on to the next run, and finally writes one JSON
11+
// Schema document per tool name to tool-schemas/<slug>.json.
12+
//
13+
// Usage:
14+
// node tools/captureToolSchemas.mjs
15+
//
16+
// Requires:
17+
// - `claude` CLI on PATH (Claude Code v2.1.168+).
18+
// - Claude Code does not need to actually succeed in talking to Anthropic;
19+
// the script just needs Claude Code to *send* its request. So no API key,
20+
// no valid OAuth, no network access to Anthropic — anything that lets
21+
// `claude -p` get as far as the first POST is enough.
22+
//
23+
// What this script produces:
24+
// - One file per tool name under tool-schemas/, e.g. `bash.json`,
25+
// `ask-user-question.json`, `lsp.json`.
26+
// - Four runs cover the four surface conditions the existing PR captures:
27+
// default (29), --agent-teams (+3), local-agent entrypoint (+2),
28+
// brief / KAIROS (+1).
29+
// - StructuredOutput is intentionally skipped — its `input_schema` is
30+
// supplied per-call by the workflow that spawned the sub-agent, so
31+
// there is no stable shape to record (see tool-schemas/README.md).
32+
// - KAIROS-family build-time-gated tools (SendUserFile, WebBrowser, Snip)
33+
// and the computer-use MCP tools simply don't appear in `tools[]` on
34+
// this binary; the script writes whatever it sees and skips the rest.
35+
36+
import { spawn } from 'node:child_process';
37+
import { createServer } from 'node:http';
38+
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
39+
import { join, dirname } from 'node:path';
40+
import { fileURLToPath } from 'node:url';
41+
42+
const __filename = fileURLToPath(import.meta.url);
43+
const __dirname = dirname(__filename);
44+
const ROOT_DIR = join(__dirname, '..');
45+
const OUT_DIR = join(ROOT_DIR, 'tool-schemas');
46+
47+
const PORT = Number(process.env.CAPTURE_PORT ?? 4099);
48+
49+
// Inverse of updatePrompts.js's SCHEMA_DISPLAY_NAME_OVERRIDES — when the
50+
// wire-level tool name doesn't kebab-case mechanically. Add entries here
51+
// (and the matching override in updatePrompts.js) as future tools appear.
52+
const NAME_TO_KEBAB_OVERRIDES = {
53+
LSP: 'lsp',
54+
};
55+
56+
function toKebab(name) {
57+
if (NAME_TO_KEBAB_OVERRIDES[name]) return NAME_TO_KEBAB_OVERRIDES[name];
58+
return name
59+
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
60+
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1-$2')
61+
.toLowerCase();
62+
}
63+
64+
// Sort all object keys lexically so two runs (or two captures) produce
65+
// byte-stable output. The wire-level payload's emit order is not stable
66+
// across Claude Code processes — only the content is.
67+
function sortKeysDeep(value) {
68+
if (Array.isArray(value)) return value.map(sortKeysDeep);
69+
if (value === null || typeof value !== 'object') return value;
70+
return Object.keys(value)
71+
.sort()
72+
.reduce((acc, k) => {
73+
acc[k] = sortKeysDeep(value[k]);
74+
return acc;
75+
}, {});
76+
}
77+
78+
// In-flight capture state for the current run.
79+
let capturedTools = null;
80+
81+
function startStubServer() {
82+
return new Promise((resolve) => {
83+
const server = createServer((req, res) => {
84+
const chunks = [];
85+
req.on('data', (c) => chunks.push(c));
86+
req.on('end', () => {
87+
// Pull tools[] out of the first POST /v1/messages we see this run.
88+
if (
89+
capturedTools === null &&
90+
req.method === 'POST' &&
91+
req.url.includes('/v1/messages')
92+
) {
93+
try {
94+
const parsed = JSON.parse(Buffer.concat(chunks).toString('utf8'));
95+
if (Array.isArray(parsed.tools)) capturedTools = parsed.tools;
96+
} catch {
97+
// not JSON, or no tools — leave capturedTools null
98+
}
99+
}
100+
101+
// Stub a 403 so `claude -p` exits immediately. 401 triggers up to
102+
// four internal retries; 403 surfaces straight through as
103+
// "Failed to authenticate" and the process exits. We don't care
104+
// what claude prints — we already have the tools[] in memory.
105+
res.writeHead(403, { 'content-type': 'application/json' });
106+
res.end(
107+
JSON.stringify({
108+
type: 'error',
109+
error: {
110+
type: 'forbidden',
111+
message: 'capture stub — tools[] already collected',
112+
},
113+
}),
114+
);
115+
});
116+
});
117+
server.listen(PORT, '127.0.0.1', () => resolve(server));
118+
});
119+
}
120+
121+
function runClaude(extraEnv) {
122+
return new Promise((resolve) => {
123+
const child = spawn('claude', ['-p', 'ok'], {
124+
env: {
125+
...process.env,
126+
ANTHROPIC_BASE_URL: `http://127.0.0.1:${PORT}`,
127+
...extraEnv,
128+
},
129+
stdio: ['ignore', 'ignore', 'ignore'],
130+
});
131+
// We expect claude to exit non-zero (we stubbed an auth error). Resolve
132+
// either way; the capture happened during the request body.
133+
child.on('exit', () => resolve());
134+
child.on('error', () => resolve());
135+
});
136+
}
137+
138+
async function captureRun(label, extraEnv) {
139+
capturedTools = null;
140+
process.stdout.write(` ${label.padEnd(16)} `);
141+
await runClaude(extraEnv);
142+
if (!capturedTools) {
143+
console.log('no tools[] seen');
144+
return [];
145+
}
146+
console.log(`captured ${capturedTools.length}`);
147+
return capturedTools;
148+
}
149+
150+
function writeSchemas(tools) {
151+
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
152+
const written = [];
153+
for (const t of tools) {
154+
if (!t.name || !t.input_schema) continue;
155+
const file = `${toKebab(t.name)}.json`;
156+
writeFileSync(
157+
join(OUT_DIR, file),
158+
JSON.stringify(sortKeysDeep(t.input_schema), null, 2) + '\n',
159+
);
160+
written.push(file);
161+
}
162+
return written;
163+
}
164+
165+
async function main() {
166+
console.log(`Listening on http://127.0.0.1:${PORT} (intercept, no upstream)`);
167+
const server = await startStubServer();
168+
169+
const runs = [
170+
{ label: 'default', env: {} },
171+
{ label: 'agent-teams', env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1' } },
172+
{ label: 'local-agent', env: { CLAUDE_CODE_ENTRYPOINT: 'local-agent' } },
173+
{ label: 'brief', env: { CLAUDE_CODE_BRIEF: '1' } },
174+
];
175+
176+
try {
177+
const merged = new Map();
178+
for (const run of runs) {
179+
const tools = await captureRun(run.label, run.env);
180+
for (const t of tools) {
181+
if (!merged.has(t.name)) merged.set(t.name, t);
182+
}
183+
}
184+
185+
merged.delete('StructuredOutput');
186+
187+
const written = writeSchemas([...merged.values()]);
188+
console.log(`\nWrote ${written.length} files under ${OUT_DIR}/`);
189+
} finally {
190+
server.close();
191+
}
192+
}
193+
194+
main().catch((err) => {
195+
console.error(err);
196+
process.exit(1);
197+
});

0 commit comments

Comments
 (0)