@nifrajs/core 5.5.0 → 5.6.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/CHANGELOG.md +159 -0
- package/dist/assurance.d.ts.map +1 -1
- package/dist/assurance.js.map +1 -1
- package/dist/evidence.d.ts +2 -0
- package/dist/evidence.d.ts.map +1 -1
- package/dist/evidence.js +2 -0
- package/dist/evidence.js.map +1 -1
- package/dist/file-type.d.ts +30 -0
- package/dist/file-type.d.ts.map +1 -0
- package/dist/file-type.js +118 -0
- package/dist/file-type.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/internal/route-program.d.ts +11 -0
- package/dist/internal/route-program.d.ts.map +1 -1
- package/dist/internal/route-program.js +133 -85
- package/dist/internal/route-program.js.map +1 -1
- package/dist/reflection.d.ts +14 -1
- package/dist/reflection.d.ts.map +1 -1
- package/dist/reflection.js +34 -7
- package/dist/reflection.js.map +1 -1
- package/dist/response-observer.d.ts +1 -0
- package/dist/response-observer.d.ts.map +1 -1
- package/dist/response-observer.js +1 -0
- package/dist/response-observer.js.map +1 -1
- package/dist/server/bare-error-lane.d.ts +4 -1
- package/dist/server/bare-error-lane.d.ts.map +1 -1
- package/dist/server/bare-error-lane.js +9 -3
- package/dist/server/bare-error-lane.js.map +1 -1
- package/dist/server/before-handler.d.ts +10 -0
- package/dist/server/before-handler.d.ts.map +1 -0
- package/dist/server/before-handler.js +12 -0
- package/dist/server/before-handler.js.map +1 -0
- package/dist/server/body-lane.d.ts.map +1 -1
- package/dist/server/body-lane.js +8 -2
- package/dist/server/body-lane.js.map +1 -1
- package/dist/server/body-parser.d.ts.map +1 -1
- package/dist/server/body-parser.js +2 -5
- package/dist/server/body-parser.js.map +1 -1
- package/dist/server/body.d.ts +24 -8
- package/dist/server/body.d.ts.map +1 -1
- package/dist/server/body.js +86 -33
- package/dist/server/body.js.map +1 -1
- package/dist/server/core-copies.js +1 -1
- package/dist/server/http.d.ts.map +1 -1
- package/dist/server/http.js +6 -17
- package/dist/server/http.js.map +1 -1
- package/dist/server/multipart.d.ts +3 -0
- package/dist/server/multipart.d.ts.map +1 -1
- package/dist/server/multipart.js +226 -97
- package/dist/server/multipart.js.map +1 -1
- package/dist/server/proto-guard.d.ts.map +1 -1
- package/dist/server/proto-guard.js +39 -4
- package/dist/server/proto-guard.js.map +1 -1
- package/dist/server/query.d.ts.map +1 -1
- package/dist/server/query.js +8 -14
- package/dist/server/query.js.map +1 -1
- package/dist/server/repeated-prefix.d.ts +17 -0
- package/dist/server/repeated-prefix.d.ts.map +1 -0
- package/dist/server/repeated-prefix.js +17 -0
- package/dist/server/repeated-prefix.js.map +1 -0
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/request-context.js +1 -3
- package/dist/server/request-context.js.map +1 -1
- package/dist/server/respond.d.ts +1 -3
- package/dist/server/respond.d.ts.map +1 -1
- package/dist/server/respond.js +21 -16
- package/dist/server/respond.js.map +1 -1
- package/dist/server/response-contract-lane.d.ts +12 -2
- package/dist/server/response-contract-lane.d.ts.map +1 -1
- package/dist/server/response-contract-lane.js +135 -8
- package/dist/server/response-contract-lane.js.map +1 -1
- package/dist/server/runtime-core.d.ts +7 -0
- package/dist/server/runtime-core.d.ts.map +1 -1
- package/dist/server/runtime-core.js +8 -1
- package/dist/server/runtime-core.js.map +1 -1
- package/dist/server/server-types.d.ts +6 -3
- package/dist/server/server-types.d.ts.map +1 -1
- package/dist/server/server.d.ts +6 -2
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +239 -78
- package/dist/server/server.js.map +1 -1
- package/dist/server/sse.d.ts +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +5 -4
- package/dist/server/sse.js.map +1 -1
- package/dist/server/webhook.d.ts +1 -1
- package/dist/server/webhook.d.ts.map +1 -1
- package/dist/server/webhook.js +24 -19
- package/dist/server/webhook.js.map +1 -1
- package/dist/server/websocket.d.ts +18 -3
- package/dist/server/websocket.d.ts.map +1 -1
- package/dist/server/websocket.js +45 -5
- package/dist/server/websocket.js.map +1 -1
- package/dist/server/ws-bun.d.ts.map +1 -1
- package/dist/server/ws-bun.js +12 -3
- package/dist/server/ws-bun.js.map +1 -1
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +1 -0
- package/dist/server.js.map +1 -1
- package/dist/tool-cli.d.ts +64 -0
- package/dist/tool-cli.d.ts.map +1 -0
- package/dist/tool-cli.js +495 -0
- package/dist/tool-cli.js.map +1 -0
- package/package.json +11 -1
- package/src/assurance.ts +1 -0
- package/src/evidence.ts +4 -0
- package/src/file-type.ts +130 -0
- package/src/index.ts +2 -1
- package/src/internal/route-program.ts +261 -93
- package/src/reflection.ts +53 -7
- package/src/response-observer.ts +6 -0
- package/src/server/bare-error-lane.ts +9 -4
- package/src/server/before-handler.ts +14 -0
- package/src/server/body-lane.ts +19 -9
- package/src/server/body-parser.ts +1 -5
- package/src/server/body.ts +96 -36
- package/src/server/core-copies.ts +1 -1
- package/src/server/http.ts +6 -15
- package/src/server/multipart.ts +232 -100
- package/src/server/proto-guard.ts +39 -4
- package/src/server/query.ts +7 -13
- package/src/server/repeated-prefix.ts +25 -0
- package/src/server/request-context.ts +1 -3
- package/src/server/respond.ts +23 -15
- package/src/server/response-contract-lane.ts +130 -8
- package/src/server/runtime-core.ts +10 -1
- package/src/server/server-types.ts +6 -3
- package/src/server/server.ts +265 -76
- package/src/server/sse.ts +6 -5
- package/src/server/webhook.ts +29 -24
- package/src/server/websocket.ts +57 -8
- package/src/server/ws-bun.ts +17 -2
- package/src/server.ts +1 -0
- package/src/tool-cli.ts +584 -0
package/src/tool-cli.ts
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run tool contracts from a command line - the CLI adapter beside {@link createToolHttpHandler} and
|
|
3
|
+
* `@nifrajs/mcp`'s `toMcpTool`. Every call goes through {@link executeTool}, so admission, approval,
|
|
4
|
+
* dry-run, validation and evidence are the contract's, not this module's.
|
|
5
|
+
*
|
|
6
|
+
* ```ts
|
|
7
|
+
* import { runToolCli } from "@nifrajs/core/tool-cli"
|
|
8
|
+
*
|
|
9
|
+
* process.exitCode = await runToolCli([search, publish], process.argv.slice(2), {
|
|
10
|
+
* program: "acme",
|
|
11
|
+
* capabilities: [search.capability, publish.capability],
|
|
12
|
+
* })
|
|
13
|
+
* ```
|
|
14
|
+
*
|
|
15
|
+
* Grammar: `<program> <tool> [--<property> <value>]... [--input <json> | --input -] [--dry-run]
|
|
16
|
+
* [--yes] [--approve-level <n>] [--json]`. A flag names one top-level property of the tool's input
|
|
17
|
+
* schema, spelled exactly as declared. String, number, integer and boolean properties take a plain
|
|
18
|
+
* value (`--no-<property>` sets a boolean false); an array of those repeats the flag once per item;
|
|
19
|
+
* anything else takes JSON. `--input` supplies the whole input as JSON (`-` reads stdin) and flags
|
|
20
|
+
* override its fields. Approval comes only from the command line: `--yes`, or `--approve-level <n>`
|
|
21
|
+
* for a threshold policy.
|
|
22
|
+
*
|
|
23
|
+
* The result goes to stdout as the output's JSON; `--json` prints the full result envelope (the HTTP
|
|
24
|
+
* adapter's body shape) instead. Exit codes are {@link TOOL_CLI_EXIT}: usage errors and invalid input
|
|
25
|
+
* are `2`, a refusal (capability, policy, approval) is `3`, any other failure is `1`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { type ProtoPoisoning, parseJsonGuarded } from "./server/proto-guard.ts"
|
|
29
|
+
import {
|
|
30
|
+
DEFAULT_TOOL_MAX_BYTES,
|
|
31
|
+
executeTool,
|
|
32
|
+
type ToolApproval,
|
|
33
|
+
type ToolCallOptions,
|
|
34
|
+
type ToolCallResult,
|
|
35
|
+
type ToolContract,
|
|
36
|
+
type ToolError,
|
|
37
|
+
toolInputJsonSchema,
|
|
38
|
+
} from "./tool-contract.ts"
|
|
39
|
+
|
|
40
|
+
/** Process exit codes {@link runToolCli} resolves to. */
|
|
41
|
+
export const TOOL_CLI_EXIT: Readonly<{ ok: 0; failed: 1; usage: 2; refused: 3 }> = Object.freeze({
|
|
42
|
+
ok: 0,
|
|
43
|
+
failed: 1,
|
|
44
|
+
usage: 2,
|
|
45
|
+
refused: 3,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
/** Where the CLI writes. `process.stdout` / `process.stderr` / `process.stdin` satisfy it. */
|
|
49
|
+
export interface ToolCliStreams {
|
|
50
|
+
readonly stdout: { write(chunk: string): unknown }
|
|
51
|
+
readonly stderr: { write(chunk: string): unknown }
|
|
52
|
+
/** Read by `--input -`. */
|
|
53
|
+
readonly stdin?: AsyncIterable<Uint8Array | string>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ToolCliOptions extends Omit<ToolCallOptions, "approval" | "dryRun" | "ledger"> {
|
|
57
|
+
/** The command name shown in usage text. */
|
|
58
|
+
readonly program: string
|
|
59
|
+
/** Default: the process streams. */
|
|
60
|
+
readonly streams?: ToolCliStreams
|
|
61
|
+
/** Cap on `--input` JSON, inline or from stdin, in UTF-8 bytes. Default 1 MiB. */
|
|
62
|
+
readonly maxInputBytes?: number
|
|
63
|
+
/** Prototype-poisoning policy for every JSON value read. Default `"reject"`. */
|
|
64
|
+
readonly protoPoisoning?: ProtoPoisoning
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** A heterogeneous tool list. Each contract still validates its own input at execution. */
|
|
68
|
+
// biome-ignore lint/suspicious/noExplicitAny: a heterogeneous tool list is erased at this boundary; each contract validates at execution.
|
|
69
|
+
export type CliToolContract = ToolContract<any, any>
|
|
70
|
+
|
|
71
|
+
const RESERVED = new Set(["help", "input", "dry-run", "yes", "approve-level", "json"])
|
|
72
|
+
const INTEGER = /^-?(?:0|[1-9]\d*)$/
|
|
73
|
+
const DECIMAL = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?$/
|
|
74
|
+
|
|
75
|
+
type Primitive = "string" | "number" | "integer" | "boolean"
|
|
76
|
+
|
|
77
|
+
interface Flag {
|
|
78
|
+
readonly name: string
|
|
79
|
+
readonly kind: Primitive | "json"
|
|
80
|
+
readonly repeated: boolean
|
|
81
|
+
readonly required: boolean
|
|
82
|
+
readonly description: string | undefined
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface Invocation {
|
|
86
|
+
readonly input: unknown
|
|
87
|
+
readonly dryRun: boolean
|
|
88
|
+
readonly approval: ToolApproval | undefined
|
|
89
|
+
readonly json: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type Parsed =
|
|
93
|
+
| { readonly kind: "help" }
|
|
94
|
+
| { readonly kind: "run"; readonly invocation: Invocation }
|
|
95
|
+
| { readonly kind: "usage"; readonly message: string }
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Run one command line against `tools`. Resolves to the exit code; it never throws for anything a
|
|
99
|
+
* user typed. `argv` excludes the runtime and script (`process.argv.slice(2)`).
|
|
100
|
+
*/
|
|
101
|
+
export async function runToolCli(
|
|
102
|
+
tools: readonly CliToolContract[],
|
|
103
|
+
argv: readonly string[],
|
|
104
|
+
options: ToolCliOptions,
|
|
105
|
+
): Promise<number> {
|
|
106
|
+
const { program, streams, maxInputBytes, protoPoisoning, ...callOptions } = options
|
|
107
|
+
const names = new Set<string>()
|
|
108
|
+
for (const tool of tools) {
|
|
109
|
+
if (names.has(tool.name)) throw new TypeError(`runToolCli: duplicate tool ${tool.name}`)
|
|
110
|
+
names.add(tool.name)
|
|
111
|
+
}
|
|
112
|
+
const cap = maxInputBytes ?? DEFAULT_TOOL_MAX_BYTES
|
|
113
|
+
if (!Number.isSafeInteger(cap) || cap <= 0) {
|
|
114
|
+
throw new RangeError("runToolCli: maxInputBytes must be a positive safe integer")
|
|
115
|
+
}
|
|
116
|
+
const io = streams ?? processStreams()
|
|
117
|
+
const policy = protoPoisoning ?? "reject"
|
|
118
|
+
|
|
119
|
+
const [first, ...rest] = argv
|
|
120
|
+
if (first === undefined) {
|
|
121
|
+
io.stderr.write(programUsage(program, tools))
|
|
122
|
+
return TOOL_CLI_EXIT.usage
|
|
123
|
+
}
|
|
124
|
+
if (first === "--help" || first === "-h") {
|
|
125
|
+
io.stdout.write(programUsage(program, tools))
|
|
126
|
+
return TOOL_CLI_EXIT.ok
|
|
127
|
+
}
|
|
128
|
+
const tool = tools.find((candidate) => candidate.name === first)
|
|
129
|
+
if (tool === undefined) {
|
|
130
|
+
io.stderr.write(
|
|
131
|
+
`${program}: unknown tool ${JSON.stringify(first)}\n\n${programUsage(program, tools)}`,
|
|
132
|
+
)
|
|
133
|
+
return TOOL_CLI_EXIT.usage
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const flags = flagsOf(tool)
|
|
137
|
+
const parsed = await parseInvocation(rest, flags, { io, cap, policy })
|
|
138
|
+
if (parsed.kind === "help") {
|
|
139
|
+
io.stdout.write(toolUsage(program, tool, flags))
|
|
140
|
+
return TOOL_CLI_EXIT.ok
|
|
141
|
+
}
|
|
142
|
+
if (parsed.kind === "usage") {
|
|
143
|
+
io.stderr.write(
|
|
144
|
+
`${program} ${tool.name}: ${parsed.message}\nRun \`${program} ${tool.name} --help\` for its options.\n`,
|
|
145
|
+
)
|
|
146
|
+
return TOOL_CLI_EXIT.usage
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const { invocation } = parsed
|
|
150
|
+
const call: { -readonly [Key in keyof ToolCallOptions]: ToolCallOptions[Key] } = {
|
|
151
|
+
...callOptions,
|
|
152
|
+
dryRun: invocation.dryRun,
|
|
153
|
+
}
|
|
154
|
+
if (invocation.approval !== undefined) call.approval = invocation.approval
|
|
155
|
+
const result = await executeTool(tool, invocation.input, call)
|
|
156
|
+
return report(result, { program, tool, json: invocation.json, io })
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function report(
|
|
160
|
+
result: ToolCallResult<unknown>,
|
|
161
|
+
context: {
|
|
162
|
+
readonly program: string
|
|
163
|
+
readonly tool: CliToolContract
|
|
164
|
+
readonly json: boolean
|
|
165
|
+
readonly io: ToolCliStreams
|
|
166
|
+
},
|
|
167
|
+
): number {
|
|
168
|
+
const { io, program, tool } = context
|
|
169
|
+
const code = result.ok ? TOOL_CLI_EXIT.ok : exitCodeOf(result.error.code)
|
|
170
|
+
let text: string | undefined
|
|
171
|
+
try {
|
|
172
|
+
if (context.json) {
|
|
173
|
+
text = JSON.stringify(
|
|
174
|
+
result.ok
|
|
175
|
+
? { ok: true, output: result.output, dryRun: result.dryRun, evidence: result.evidence }
|
|
176
|
+
: { ok: false, error: result.error, dryRun: result.dryRun, evidence: result.evidence },
|
|
177
|
+
)
|
|
178
|
+
} else if (result.ok && result.output !== undefined) {
|
|
179
|
+
text = JSON.stringify(result.output, null, 2)
|
|
180
|
+
}
|
|
181
|
+
} catch {
|
|
182
|
+
io.stderr.write(`${program} ${tool.name}: the result is not JSON-serializable\n`)
|
|
183
|
+
return TOOL_CLI_EXIT.failed
|
|
184
|
+
}
|
|
185
|
+
if (text !== undefined) io.stdout.write(`${text}\n`)
|
|
186
|
+
if (context.json) return code
|
|
187
|
+
if (result.ok) {
|
|
188
|
+
if (result.dryRun)
|
|
189
|
+
io.stderr.write(`${program} ${tool.name}: dry run admitted; nothing executed\n`)
|
|
190
|
+
return code
|
|
191
|
+
}
|
|
192
|
+
io.stderr.write(failureText(program, tool, result.error))
|
|
193
|
+
return code
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function failureText(program: string, tool: CliToolContract, error: ToolError): string {
|
|
197
|
+
const lines = [`${program} ${tool.name}: ${error.code} (${error.stage})`]
|
|
198
|
+
for (const issue of error.issues ?? []) lines.push(` ${issuePath(issue.path)}: ${issue.message}`)
|
|
199
|
+
if (error.code === "approval_required" || error.code === "approval_denied") {
|
|
200
|
+
lines.push(
|
|
201
|
+
tool.approval.kind === "threshold"
|
|
202
|
+
? ` approve with --approve-level ${tool.approval.level}`
|
|
203
|
+
: " approve with --yes",
|
|
204
|
+
)
|
|
205
|
+
}
|
|
206
|
+
return `${lines.join("\n")}\n`
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function issuePath(path: readonly unknown[] | undefined): string {
|
|
210
|
+
if (path === undefined || path.length === 0) return "input"
|
|
211
|
+
const keys = path.map((segment) =>
|
|
212
|
+
segment !== null && typeof segment === "object" && "key" in segment ? segment.key : segment,
|
|
213
|
+
)
|
|
214
|
+
return keys.map((key) => (typeof key === "symbol" ? key.toString() : String(key))).join(".")
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function exitCodeOf(code: ToolError["code"]): number {
|
|
218
|
+
switch (code) {
|
|
219
|
+
case "input_invalid":
|
|
220
|
+
return TOOL_CLI_EXIT.usage
|
|
221
|
+
case "capability_denied":
|
|
222
|
+
case "execution_policy_unsatisfied":
|
|
223
|
+
case "approval_required":
|
|
224
|
+
case "approval_denied":
|
|
225
|
+
return TOOL_CLI_EXIT.refused
|
|
226
|
+
default:
|
|
227
|
+
return TOOL_CLI_EXIT.failed
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function parseInvocation(
|
|
232
|
+
args: readonly string[],
|
|
233
|
+
flags: ReadonlyMap<string, Flag>,
|
|
234
|
+
context: { readonly io: ToolCliStreams; readonly cap: number; readonly policy: ProtoPoisoning },
|
|
235
|
+
): Promise<Parsed> {
|
|
236
|
+
const fields = new Map<string, unknown>()
|
|
237
|
+
let inputText: string | undefined
|
|
238
|
+
let inputFromStdin = false
|
|
239
|
+
let dryRun = false
|
|
240
|
+
let yes = false
|
|
241
|
+
let level: number | undefined
|
|
242
|
+
let json = false
|
|
243
|
+
|
|
244
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
245
|
+
const arg = args[index] ?? ""
|
|
246
|
+
if (arg === "--help" || arg === "-h") return { kind: "help" }
|
|
247
|
+
if (!arg.startsWith("--") || arg === "--") {
|
|
248
|
+
return { kind: "usage", message: `unexpected argument ${JSON.stringify(arg)}` }
|
|
249
|
+
}
|
|
250
|
+
const equals = arg.indexOf("=")
|
|
251
|
+
const name = equals === -1 ? arg.slice(2) : arg.slice(2, equals)
|
|
252
|
+
const inline = equals === -1 ? undefined : arg.slice(equals + 1)
|
|
253
|
+
const takeValue = (): string | undefined => {
|
|
254
|
+
if (inline !== undefined) return inline
|
|
255
|
+
const next = args[index + 1]
|
|
256
|
+
if (next === undefined || next.startsWith("--")) return undefined
|
|
257
|
+
index += 1
|
|
258
|
+
return next
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (RESERVED.has(name)) {
|
|
262
|
+
if (name === "dry-run" || name === "yes" || name === "json") {
|
|
263
|
+
if (inline !== undefined) return { kind: "usage", message: `--${name} takes no value` }
|
|
264
|
+
if (name === "dry-run") dryRun = true
|
|
265
|
+
else if (name === "yes") yes = true
|
|
266
|
+
else json = true
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
const value = takeValue()
|
|
270
|
+
if (value === undefined) return { kind: "usage", message: `--${name} needs a value` }
|
|
271
|
+
if (name === "approve-level") {
|
|
272
|
+
if (!INTEGER.test(value) || Number(value) <= 0) {
|
|
273
|
+
return { kind: "usage", message: "--approve-level needs a positive integer" }
|
|
274
|
+
}
|
|
275
|
+
level = Number(value)
|
|
276
|
+
continue
|
|
277
|
+
}
|
|
278
|
+
if (inputText !== undefined || inputFromStdin) {
|
|
279
|
+
return { kind: "usage", message: "--input given twice" }
|
|
280
|
+
}
|
|
281
|
+
if (value === "-") inputFromStdin = true
|
|
282
|
+
else inputText = value
|
|
283
|
+
continue
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const negated = name.startsWith("no-") ? flags.get(name.slice(3)) : undefined
|
|
287
|
+
if (
|
|
288
|
+
negated !== undefined &&
|
|
289
|
+
negated.kind === "boolean" &&
|
|
290
|
+
inline === undefined &&
|
|
291
|
+
!flags.has(name)
|
|
292
|
+
) {
|
|
293
|
+
if (fields.has(negated.name))
|
|
294
|
+
return { kind: "usage", message: `--${negated.name} given twice` }
|
|
295
|
+
fields.set(negated.name, false)
|
|
296
|
+
continue
|
|
297
|
+
}
|
|
298
|
+
const flag = flags.get(name)
|
|
299
|
+
if (flag === undefined) return { kind: "usage", message: `unknown option --${name}` }
|
|
300
|
+
if (!flag.repeated && fields.has(flag.name)) {
|
|
301
|
+
return { kind: "usage", message: `--${flag.name} given twice` }
|
|
302
|
+
}
|
|
303
|
+
let raw: string | undefined
|
|
304
|
+
if (flag.kind === "boolean" && !flag.repeated && inline === undefined) raw = "true"
|
|
305
|
+
else raw = takeValue()
|
|
306
|
+
if (raw === undefined) return { kind: "usage", message: `--${flag.name} needs a value` }
|
|
307
|
+
const value = coerce(raw, flag.kind, context.policy)
|
|
308
|
+
if (!value.ok) return { kind: "usage", message: `--${flag.name}: ${value.message}` }
|
|
309
|
+
if (flag.repeated) {
|
|
310
|
+
const items = fields.get(flag.name)
|
|
311
|
+
if (Array.isArray(items)) items.push(value.value)
|
|
312
|
+
else fields.set(flag.name, [value.value])
|
|
313
|
+
} else {
|
|
314
|
+
fields.set(flag.name, value.value)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
let input: unknown
|
|
319
|
+
if (inputText !== undefined || inputFromStdin) {
|
|
320
|
+
const text = inputFromStdin ? await readStdin(context.io, context.cap) : inputText
|
|
321
|
+
if (text === undefined) return { kind: "usage", message: "--input - needs a readable stdin" }
|
|
322
|
+
if (typeof text === "object") return { kind: "usage", message: text.message }
|
|
323
|
+
if (new TextEncoder().encode(text).byteLength > context.cap) {
|
|
324
|
+
return { kind: "usage", message: `--input exceeds ${context.cap} bytes` }
|
|
325
|
+
}
|
|
326
|
+
const parsed = parseJson(text, context.policy)
|
|
327
|
+
if (!parsed.ok) return { kind: "usage", message: `--input: ${parsed.message}` }
|
|
328
|
+
input = parsed.value
|
|
329
|
+
}
|
|
330
|
+
if (fields.size > 0) {
|
|
331
|
+
if (
|
|
332
|
+
input !== undefined &&
|
|
333
|
+
(input === null || typeof input !== "object" || Array.isArray(input))
|
|
334
|
+
) {
|
|
335
|
+
return { kind: "usage", message: "options need --input to be a JSON object" }
|
|
336
|
+
}
|
|
337
|
+
const merged: Record<string, unknown> = {}
|
|
338
|
+
if (input !== undefined)
|
|
339
|
+
for (const [key, value] of Object.entries(input)) define(merged, key, value)
|
|
340
|
+
for (const [key, value] of fields) define(merged, key, value)
|
|
341
|
+
input = merged
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
let approval: ToolApproval | undefined
|
|
345
|
+
if (level !== undefined) approval = { granted: true, level }
|
|
346
|
+
else if (yes) approval = { granted: true }
|
|
347
|
+
return { kind: "run", invocation: { input: input ?? {}, dryRun, approval, json } }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// `__proto__` as a declared property name must become an own field, never the prototype.
|
|
351
|
+
function define(target: Record<string, unknown>, key: string, value: unknown): void {
|
|
352
|
+
Object.defineProperty(target, key, {
|
|
353
|
+
value,
|
|
354
|
+
enumerable: true,
|
|
355
|
+
writable: true,
|
|
356
|
+
configurable: true,
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function coerce(
|
|
361
|
+
raw: string,
|
|
362
|
+
kind: Flag["kind"],
|
|
363
|
+
policy: ProtoPoisoning,
|
|
364
|
+
):
|
|
365
|
+
| { readonly ok: true; readonly value: unknown }
|
|
366
|
+
| { readonly ok: false; readonly message: string } {
|
|
367
|
+
switch (kind) {
|
|
368
|
+
case "string":
|
|
369
|
+
return { ok: true, value: raw }
|
|
370
|
+
case "boolean":
|
|
371
|
+
if (raw === "true") return { ok: true, value: true }
|
|
372
|
+
if (raw === "false") return { ok: true, value: false }
|
|
373
|
+
return { ok: false, message: "expected true or false" }
|
|
374
|
+
case "integer": {
|
|
375
|
+
if (!INTEGER.test(raw)) return { ok: false, message: "expected an integer" }
|
|
376
|
+
const value = Number(raw)
|
|
377
|
+
return Number.isSafeInteger(value)
|
|
378
|
+
? { ok: true, value }
|
|
379
|
+
: { ok: false, message: "integer is out of the safe range" }
|
|
380
|
+
}
|
|
381
|
+
case "number": {
|
|
382
|
+
if (!DECIMAL.test(raw)) return { ok: false, message: "expected a number" }
|
|
383
|
+
const value = Number(raw)
|
|
384
|
+
return Number.isFinite(value)
|
|
385
|
+
? { ok: true, value }
|
|
386
|
+
: { ok: false, message: "number is not finite" }
|
|
387
|
+
}
|
|
388
|
+
default:
|
|
389
|
+
return parseJson(raw, policy)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function parseJson(
|
|
394
|
+
text: string,
|
|
395
|
+
policy: ProtoPoisoning,
|
|
396
|
+
):
|
|
397
|
+
| { readonly ok: true; readonly value: unknown }
|
|
398
|
+
| { readonly ok: false; readonly message: string } {
|
|
399
|
+
try {
|
|
400
|
+
return {
|
|
401
|
+
ok: true,
|
|
402
|
+
value: policy === "ignore" ? JSON.parse(text) : parseJsonGuarded(text, policy),
|
|
403
|
+
}
|
|
404
|
+
} catch {
|
|
405
|
+
return { ok: false, message: "expected JSON" }
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
async function readStdin(
|
|
410
|
+
io: ToolCliStreams,
|
|
411
|
+
cap: number,
|
|
412
|
+
): Promise<string | { readonly message: string } | undefined> {
|
|
413
|
+
if (io.stdin === undefined) return undefined
|
|
414
|
+
const encoder = new TextEncoder()
|
|
415
|
+
const parts: Uint8Array[] = []
|
|
416
|
+
let total = 0
|
|
417
|
+
for await (const chunk of io.stdin) {
|
|
418
|
+
const bytes = typeof chunk === "string" ? encoder.encode(chunk) : chunk
|
|
419
|
+
total += bytes.byteLength
|
|
420
|
+
if (total > cap) return { message: `--input exceeds ${cap} bytes` }
|
|
421
|
+
parts.push(bytes.slice())
|
|
422
|
+
}
|
|
423
|
+
const joined = new Uint8Array(total)
|
|
424
|
+
let offset = 0
|
|
425
|
+
for (const part of parts) {
|
|
426
|
+
joined.set(part, offset)
|
|
427
|
+
offset += part.byteLength
|
|
428
|
+
}
|
|
429
|
+
try {
|
|
430
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(joined)
|
|
431
|
+
} catch {
|
|
432
|
+
return { message: "--input: stdin is not UTF-8" }
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function flagsOf(tool: CliToolContract): ReadonlyMap<string, Flag> {
|
|
437
|
+
const schema = toolInputJsonSchema(tool)
|
|
438
|
+
const properties = record(schema.properties)
|
|
439
|
+
const required = Array.isArray(schema.required) ? schema.required : []
|
|
440
|
+
const flags = new Map<string, Flag>()
|
|
441
|
+
if (properties === undefined) return flags
|
|
442
|
+
for (const [name, value] of Object.entries(properties)) {
|
|
443
|
+
if (RESERVED.has(name)) continue
|
|
444
|
+
const property = record(value) ?? {}
|
|
445
|
+
const items = property.type === "array" ? record(property.items) : undefined
|
|
446
|
+
const itemKind = items === undefined ? undefined : primitiveOf(items)
|
|
447
|
+
const description = typeof property.description === "string" ? property.description : undefined
|
|
448
|
+
flags.set(name, {
|
|
449
|
+
name,
|
|
450
|
+
kind: items === undefined ? (primitiveOf(property) ?? "json") : (itemKind ?? "json"),
|
|
451
|
+
repeated: items !== undefined,
|
|
452
|
+
required: required.includes(name),
|
|
453
|
+
description,
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
return flags
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function primitiveOf(schema: Record<string, unknown>): Primitive | undefined {
|
|
460
|
+
const type = schema.type
|
|
461
|
+
if (typeof type === "string") return isPrimitive(type) ? type : undefined
|
|
462
|
+
if (Array.isArray(type)) {
|
|
463
|
+
const kinds = type.filter((entry) => entry !== "null")
|
|
464
|
+
const [only] = kinds
|
|
465
|
+
return kinds.length === 1 && typeof only === "string" && isPrimitive(only) ? only : undefined
|
|
466
|
+
}
|
|
467
|
+
const literals = Array.isArray(schema.enum)
|
|
468
|
+
? schema.enum
|
|
469
|
+
: "const" in schema
|
|
470
|
+
? [schema.const]
|
|
471
|
+
: undefined
|
|
472
|
+
if (literals !== undefined && literals.length > 0) {
|
|
473
|
+
if (literals.every((literal) => typeof literal === "string")) return "string"
|
|
474
|
+
if (literals.every((literal) => typeof literal === "number")) return "number"
|
|
475
|
+
return undefined
|
|
476
|
+
}
|
|
477
|
+
const members = Array.isArray(schema.anyOf)
|
|
478
|
+
? schema.anyOf
|
|
479
|
+
: Array.isArray(schema.oneOf)
|
|
480
|
+
? schema.oneOf
|
|
481
|
+
: undefined
|
|
482
|
+
if (members === undefined || members.length === 0) return undefined
|
|
483
|
+
const kinds = new Set(members.map((member) => primitiveOf(record(member) ?? {})))
|
|
484
|
+
const [only] = kinds
|
|
485
|
+
return kinds.size === 1 ? only : undefined
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function isPrimitive(type: string): type is Primitive {
|
|
489
|
+
return type === "string" || type === "number" || type === "integer" || type === "boolean"
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function record(value: unknown): Record<string, unknown> | undefined {
|
|
493
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined
|
|
494
|
+
const out: Record<string, unknown> = {}
|
|
495
|
+
for (const [key, entry] of Object.entries(value)) out[key] = entry
|
|
496
|
+
return out
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function programUsage(program: string, tools: readonly CliToolContract[]): string {
|
|
500
|
+
const width = Math.max(0, ...tools.map((tool) => tool.name.length))
|
|
501
|
+
const rows = tools.map((tool) => ` ${tool.name.padEnd(width)} ${oneLine(tool.description)}`)
|
|
502
|
+
return [
|
|
503
|
+
`Usage: ${program} <tool> [options]`,
|
|
504
|
+
"",
|
|
505
|
+
"Tools:",
|
|
506
|
+
...rows,
|
|
507
|
+
"",
|
|
508
|
+
`Run \`${program} <tool> --help\` for a tool's options.`,
|
|
509
|
+
"",
|
|
510
|
+
].join("\n")
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function toolUsage(
|
|
514
|
+
program: string,
|
|
515
|
+
tool: CliToolContract,
|
|
516
|
+
flags: ReadonlyMap<string, Flag>,
|
|
517
|
+
): string {
|
|
518
|
+
const rows: [string, string][] = []
|
|
519
|
+
for (const flag of flags.values()) {
|
|
520
|
+
const value = flag.kind === "boolean" && !flag.repeated ? "" : ` <${flag.kind}>`
|
|
521
|
+
const spelling =
|
|
522
|
+
flag.kind === "boolean" && !flag.repeated
|
|
523
|
+
? `--${flag.name} / --no-${flag.name}`
|
|
524
|
+
: `--${flag.name}${value}`
|
|
525
|
+
const notes = [
|
|
526
|
+
flag.required ? "required" : undefined,
|
|
527
|
+
flag.repeated ? "repeatable" : undefined,
|
|
528
|
+
flag.description === undefined ? undefined : oneLine(flag.description),
|
|
529
|
+
].filter((note) => note !== undefined)
|
|
530
|
+
rows.push([spelling, notes.join("; ")])
|
|
531
|
+
}
|
|
532
|
+
rows.push([
|
|
533
|
+
"--input <json>|-",
|
|
534
|
+
"the whole input as JSON, `-` reads stdin; options above override its fields",
|
|
535
|
+
])
|
|
536
|
+
rows.push(["--dry-run", "admit the call without running it"])
|
|
537
|
+
if (tool.approval.kind === "required")
|
|
538
|
+
rows.push(["--yes", "approve this call (the tool requires approval)"])
|
|
539
|
+
if (tool.approval.kind === "threshold") {
|
|
540
|
+
rows.push(["--approve-level <n>", `approve at level n; this tool needs ${tool.approval.level}`])
|
|
541
|
+
}
|
|
542
|
+
rows.push(["--json", "print the result envelope as JSON"])
|
|
543
|
+
const width = Math.max(...rows.map(([spelling]) => spelling.length))
|
|
544
|
+
return [
|
|
545
|
+
`Usage: ${program} ${tool.name} [options]`,
|
|
546
|
+
"",
|
|
547
|
+
oneLine(tool.description),
|
|
548
|
+
"",
|
|
549
|
+
"Options:",
|
|
550
|
+
...rows.map(([spelling, note]) => ` ${spelling.padEnd(width)} ${note}`.trimEnd()),
|
|
551
|
+
"",
|
|
552
|
+
].join("\n")
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function oneLine(text: string): string {
|
|
556
|
+
return text.replace(/\s+/g, " ").trim()
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function processStreams(): ToolCliStreams {
|
|
560
|
+
const host: unknown = Reflect.get(globalThis, "process")
|
|
561
|
+
const stream = (name: string): unknown =>
|
|
562
|
+
host !== null && typeof host === "object" ? Reflect.get(host, name) : undefined
|
|
563
|
+
const stdout = stream("stdout")
|
|
564
|
+
const stderr = stream("stderr")
|
|
565
|
+
const stdin = stream("stdin")
|
|
566
|
+
if (!isWritable(stdout) || !isWritable(stderr)) {
|
|
567
|
+
throw new TypeError("runToolCli: this runtime has no process streams; pass `streams`")
|
|
568
|
+
}
|
|
569
|
+
return isReadable(stdin) ? { stdout, stderr, stdin } : { stdout, stderr }
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function isWritable(value: unknown): value is { write(chunk: string): unknown } {
|
|
573
|
+
return (
|
|
574
|
+
value !== null && typeof value === "object" && typeof Reflect.get(value, "write") === "function"
|
|
575
|
+
)
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function isReadable(value: unknown): value is AsyncIterable<Uint8Array | string> {
|
|
579
|
+
return (
|
|
580
|
+
value !== null &&
|
|
581
|
+
typeof value === "object" &&
|
|
582
|
+
typeof Reflect.get(value, Symbol.asyncIterator) === "function"
|
|
583
|
+
)
|
|
584
|
+
}
|