Turn shell commands into typed tools the model sees from the first turn

Turns shell commands into typed tools. Each one is registered with its schema in the prompt from the first turn (not behind ToolSearch), so the model calls it directly as mcp__cli-tools__<name>.
tools is a JSON array of:
| Field | |
|---|---|
name | 1-64 letters, digits, _, - |
description | what the model reads |
argv | the command as an array; {{param}} is replaced by the call's value |
params | { name: { type, description, required?, enum?, allowDash? } }; type is string, number, integer or boolean; required defaults to true |
timeoutMs | default 30000, at most 600000 |
Example:
[
{
"name": "service_logs",
"description": "Last lines of a service's logs",
"argv": ["svc", "logs", "{{service}}", "--tail={{lines}}"],
"params": {
"service": { "type": "string", "description": "Service name" },
"lines": { "type": "integer", "description": "How many lines (default 100)", "required": false }
},
"timeoutMs": 20000
},
{
"name": "disk_free",
"description": "Free disk space on the machine",
"argv": ["df", "-h"],
"params": {}
}
]
params: required present, types right, enum respected, no unknown fields. Problems come back to the model as an error, and nothing runs.argv is built. The command is never run through a shell, so characters like ; or $(...) in a value are just text. An argument that uses an optional parameter the call left out is dropped, whole (--tail={{lines}} disappears when lines is absent).- is refused, since the command would read it as an option (--exec=...). Set "allowDash": true on that param to allow it.$.process.run runs it in the session's working directory with timeoutMs.exit code: N, then stdout: and stderr:, each cut to maxOutputChars (default 20000). A non-zero exit or a timeout is marked as an error./cli-tools lists what is registered and any problems found in the setting (bad entries are skipped; the rest load).
stdout is read whole before it is returned; very long-running or streaming commands (tail -f) do not fit. The engine keeps the first 4 MiB of each stream.hooks/register.tsx 65 lines1import { atom, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CliTool } from '../types'
5import { buildArgv, checkArgs, formatResult, inputSchema, parseTools } from './lib'
6
7const registered = atom({ plugin: 'cli-tools', key: 'tools' } as const, [] as string[])
8// Fields of a tool.call event that are the engine's, not the model's arguments.
9const ENGINE_FIELDS = new Set(['tool', 'tool_use_id', 'agentId', 'requestMeta'])
10
11async function registerAll($: EngineInterface, tools: readonly CliTool[]) {
12 const names: string[] = []
13 for (const t of tools) {
14 await $.tool.register({ name: t.name, description: t.description, inputSchema: inputSchema(t), isDeferred: false })
15 names.push(t.name)
16 }
17 await update($, registered, () => names)
18}
19
20async function runTool($: EngineInterface, tool: CliTool, e: Record<string, unknown>, maxChars: number) {
21 const input: Record<string, unknown> = {}
22 for (const [k, v] of Object.entries(e)) if (!ENGINE_FIELDS.has(k)) input[k] = v
23 const checked = checkArgs(tool, input)
24 if ('error' in checked) return { result: `${tool.name}: ${checked.error}`, isError: true as const }
25 const built = buildArgv(tool, checked.values)
26 if ('error' in built) return { result: `${tool.name}: ${built.error}`, isError: true as const }
27 try {
28 const r = await $.process.run(built.argv, { timeoutMs: tool.timeoutMs })
29 return { result: formatResult(r, maxChars), ...(r.exitCode === 0 ? {} : { isError: true as const }) }
30 } catch (err) {
31 const why = err instanceof Error ? err.message : String(err)
32 return { result: `${tool.name}: could not run ${built.argv[0]}: ${why} (timeout ${tool.timeoutMs} ms)`, isError: true as const }
33 }
34}
35
36export const register: Register = (on, options) => {
37 const parsed = parseTools(typeof options.tools === 'string' ? options.tools : '')
38 const byName = new Map(parsed.tools.map(t => [t.name, t]))
39 const maxChars = typeof options.maxOutputChars === 'number' && options.maxOutputChars > 0 ? options.maxOutputChars : 20000
40
41 on('session.start', async ($, e, next) => {
42 await registerAll($, parsed.tools)
43 await $.command.register({ name: 'cli-tools', description: 'List the shell commands exposed as tools' })
44 if (parsed.errors.length) $.ui.toast(`cli-tools: ${parsed.errors.length} problem(s) in the tools setting; run /cli-tools`)
45 return next(e)
46 })
47
48 on('command.run', { command: 'cli-tools' }, () => {
49 const lines = parsed.tools.map(t => `${t.name}: ${t.argv.join(' ')}`)
50 const problems = parsed.errors.map(m => `problem: ${m}`)
51 const all = [...lines, ...problems]
52 return { text: all.length ? all.join('\n') : 'No tools configured. Set "tools" in the plugin settings.' }
53 })
54
55 on('tool.call', async ($, e, next) => {
56 const prefix = `mcp__${$.plugin.name}__`
57 const name = String(e.tool)
58 const tool = name.startsWith(prefix) ? byName.get(name.slice(prefix.length)) : undefined
59 if (!tool) return next(e)
60 return runTool($, tool, e as unknown as Record<string, unknown>, maxChars) as never
61 }).catch(($, e, next) =>
62 String(e.tool).startsWith(`mcp__${$.plugin.name}__`) ? { deny: 'cli-tools: the tool failed unexpectedly; retry once.' } : next(e),
63 )
64}
65hooks/lib.ts 139 lines1// Pure helpers of the cli-tools mod: config parsing, argument checks, argv building, output shaping.
2import type { CliTool, ToolParam } from '../types'
3
4const NAME = /^[A-Za-z0-9_-]{1,64}$/
5const PARAM = /^[A-Za-z_][A-Za-z0-9_]*$/
6const PLACEHOLDER = /\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/g
7const TYPES = ['string', 'number', 'integer', 'boolean']
8export const DEFAULT_TIMEOUT_MS = 30000
9export const MAX_TIMEOUT_MS = 600000
10
11const isRecord = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v)
12
13export function parseTools(raw: string): { tools: CliTool[]; errors: string[] } {
14 const text = raw.trim()
15 if (!text) return { tools: [], errors: [] }
16 let json: unknown
17 try {
18 json = JSON.parse(text)
19 } catch {
20 return { tools: [], errors: ['tools: not valid JSON'] }
21 }
22 if (!Array.isArray(json)) return { tools: [], errors: ['tools: expected a JSON array'] }
23 const tools: CliTool[] = []
24 const errors: string[] = []
25 const seen = new Set<string>()
26 json.forEach((item: unknown, i) => {
27 const at = `tools[${i}]`
28 if (!isRecord(item)) return void errors.push(`${at}: not an object`)
29 const name = typeof item.name === 'string' ? item.name : ''
30 if (!NAME.test(name)) return void errors.push(`${at}: name must be 1-64 letters, digits, _ or -`)
31 if (seen.has(name)) return void errors.push(`${at} (${name}): duplicate name`)
32 const description = typeof item.description === 'string' ? item.description.trim() : ''
33 if (!description) return void errors.push(`${at} (${name}): description is required`)
34 const argv = item.argv
35 if (!Array.isArray(argv) || argv.length === 0 || !argv.every(a => typeof a === 'string') || argv[0] === '')
36 return void errors.push(`${at} (${name}): argv must be a non-empty array of strings`)
37 if ((argv[0] as string).includes('{{')) return void errors.push(`${at} (${name}): the command itself cannot be a parameter`)
38
39 const params: Record<string, ToolParam> = {}
40 let isBad = false
41 for (const [key, def] of Object.entries(isRecord(item.params) ? item.params : {})) {
42 if (!PARAM.test(key) || !isRecord(def) || !TYPES.includes(String(def.type))) {
43 errors.push(`${at} (${name}): param "${key}" needs a type of string, number, integer or boolean`)
44 isBad = true
45 continue
46 }
47 params[key] = {
48 type: def.type as ToolParam['type'],
49 description: typeof def.description === 'string' ? def.description : '',
50 required: def.required !== false,
51 enum: Array.isArray(def.enum) && def.enum.every(v => typeof v === 'string') ? (def.enum as string[]) : undefined,
52 allowDash: def.allowDash === true,
53 }
54 }
55 if (isBad) return
56 for (const a of argv as string[])
57 for (const m of a.matchAll(PLACEHOLDER)) {
58 if (params[m[1] ?? ''] === undefined) {
59 errors.push(`${at} (${name}): argv uses {{${m[1]}}}, which params does not declare`)
60 isBad = true
61 }
62 }
63 if (isBad) return
64 const timeout = typeof item.timeoutMs === 'number' && item.timeoutMs > 0 ? Math.min(item.timeoutMs, MAX_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS
65 seen.add(name)
66 tools.push({ name, description, argv: argv as string[], params, timeoutMs: timeout })
67 })
68 return { tools, errors }
69}
70
71export function inputSchema(tool: CliTool): Record<string, unknown> {
72 const properties: Record<string, unknown> = {}
73 const required: string[] = []
74 for (const [key, p] of Object.entries(tool.params)) {
75 properties[key] = { type: p.type, ...(p.description ? { description: p.description } : {}), ...(p.enum ? { enum: p.enum } : {}) }
76 if (p.required) required.push(key)
77 }
78 return { type: 'object', properties, ...(required.length ? { required } : {}), additionalProperties: false }
79}
80
81// Returns the error the model should read, or the cleaned values.
82export function checkArgs(tool: CliTool, input: Record<string, unknown>): { error: string } | { values: Record<string, string> } {
83 const values: Record<string, string> = {}
84 for (const key of Object.keys(input)) if (!(key in tool.params)) return { error: `unknown parameter "${key}"; expected ${Object.keys(tool.params).join(', ') || 'none'}` }
85 for (const [key, p] of Object.entries(tool.params)) {
86 const v = input[key]
87 if (v === undefined || v === null) {
88 if (p.required) return { error: `missing required parameter "${key}"` }
89 continue
90 }
91 if (p.type === 'string' && typeof v !== 'string') return { error: `parameter "${key}" must be a string` }
92 if (p.type === 'boolean' && typeof v !== 'boolean') return { error: `parameter "${key}" must be a boolean` }
93 if ((p.type === 'number' || p.type === 'integer') && (typeof v !== 'number' || !Number.isFinite(v))) return { error: `parameter "${key}" must be a number` }
94 if (p.type === 'integer' && !Number.isInteger(v)) return { error: `parameter "${key}" must be an integer` }
95 const s = String(v)
96 if (s.includes('\0')) return { error: `parameter "${key}" contains a NUL character` }
97 if (p.enum && !p.enum.includes(s)) return { error: `parameter "${key}" must be one of: ${p.enum.join(', ')}` }
98 values[key] = s
99 }
100 return { values }
101}
102
103// An argument that uses an optional parameter the call left out is dropped, whole.
104// A string value that would become an option (leading "-" as a whole argument or at the start of one) is refused unless the param allows it.
105export function buildArgv(tool: CliTool, values: Record<string, string>): { error: string } | { argv: string[] } {
106 const argv: string[] = []
107 for (const part of tool.argv) {
108 const whole = /^\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}$/.exec(part)
109 if (whole) {
110 const key = whole[1] ?? ''
111 const v = values[key]
112 if (v === undefined) continue
113 if (v.startsWith('-') && tool.params[key]?.type === 'string' && !tool.params[key]?.allowDash)
114 return { error: `parameter "${key}" cannot start with "-" (it would be read as an option)` }
115 argv.push(v)
116 } else {
117 const keys = [...part.matchAll(PLACEHOLDER)].map(m => m[1] ?? '')
118 if (keys.some(k => values[k] === undefined)) continue
119 const lead = /^\{\{([A-Za-z_][A-Za-z0-9_]*)\}\}/.exec(part)?.[1] ?? ''
120 if (lead && values[lead]?.startsWith('-') && tool.params[lead]?.type === 'string' && !tool.params[lead]?.allowDash)
121 return { error: `parameter "${lead}" cannot start with "-" (it would be read as an option)` }
122 argv.push(part.replace(PLACEHOLDER, (_m, key: string) => values[key] ?? ''))
123 }
124 }
125 return { argv }
126}
127
128export function cap(text: string, max: number): string {
129 return text.length > max ? `${text.slice(0, max)}\n[cut: ${text.length - max} more characters]` : text
130}
131
132export function formatResult(r: { exitCode: number; stdout: string; stderr: string }, max: number): string {
133 const parts = [`exit code: ${r.exitCode}`]
134 if (r.stdout) parts.push(`stdout:\n${cap(r.stdout.replace(/\s+$/, ''), max)}`)
135 if (r.stderr) parts.push(`stderr:\n${cap(r.stderr.replace(/\s+$/, ''), max)}`)
136 if (!r.stdout && !r.stderr) parts.push('(no output)')
137 return parts.join('\n')
138}
139types/index.d.ts 23 lines1export type ToolParam = {
2 type: 'string' | 'number' | 'integer' | 'boolean'
3 description: string
4 required: boolean
5 enum?: string[]
6 /** Lets a string value start with "-" when it fills a whole argument. Off by default. */
7 allowDash: boolean
8}
9
10export type CliTool = {
11 name: string
12 description: string
13 argv: string[]
14 params: Record<string, ToolParam>
15 timeoutMs: number
16}
17
18declare module 'claude-code' {
19 interface PluginState {
20 'cli-tools': { tools: string[] }
21 }
22}
23