|
| 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