@nifrajs/core 5.5.1 → 5.7.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.
Files changed (209) hide show
  1. package/CHANGELOG.md +246 -0
  2. package/README.md +5 -0
  3. package/dist/assurance.d.ts.map +1 -1
  4. package/dist/assurance.js.map +1 -1
  5. package/dist/binary.d.ts +15 -1
  6. package/dist/binary.d.ts.map +1 -1
  7. package/dist/binary.js +1 -0
  8. package/dist/binary.js.map +1 -1
  9. package/dist/edge-kit.d.ts +1 -0
  10. package/dist/edge-kit.d.ts.map +1 -1
  11. package/dist/edge-kit.js +1 -0
  12. package/dist/edge-kit.js.map +1 -1
  13. package/dist/errors.d.ts +1 -1
  14. package/dist/errors.d.ts.map +1 -1
  15. package/dist/errors.js.map +1 -1
  16. package/dist/evidence.d.ts +4 -0
  17. package/dist/evidence.d.ts.map +1 -1
  18. package/dist/evidence.js +4 -0
  19. package/dist/evidence.js.map +1 -1
  20. package/dist/file-type.d.ts +30 -0
  21. package/dist/file-type.d.ts.map +1 -0
  22. package/dist/file-type.js +118 -0
  23. package/dist/file-type.js.map +1 -0
  24. package/dist/index.d.ts +3 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +3 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/internal/hook-origins.d.ts +2 -0
  29. package/dist/internal/hook-origins.d.ts.map +1 -1
  30. package/dist/internal/hook-origins.js +12 -0
  31. package/dist/internal/hook-origins.js.map +1 -1
  32. package/dist/internal/route-program.d.ts +11 -0
  33. package/dist/internal/route-program.d.ts.map +1 -1
  34. package/dist/internal/route-program.js +133 -85
  35. package/dist/internal/route-program.js.map +1 -1
  36. package/dist/reflection.d.ts +19 -1
  37. package/dist/reflection.d.ts.map +1 -1
  38. package/dist/reflection.js +46 -7
  39. package/dist/reflection.js.map +1 -1
  40. package/dist/response-observer.d.ts +1 -0
  41. package/dist/response-observer.d.ts.map +1 -1
  42. package/dist/response-observer.js +1 -0
  43. package/dist/response-observer.js.map +1 -1
  44. package/dist/server/answer-step.d.ts +27 -0
  45. package/dist/server/answer-step.d.ts.map +1 -0
  46. package/dist/server/answer-step.js +33 -0
  47. package/dist/server/answer-step.js.map +1 -0
  48. package/dist/server/bare-error-lane.d.ts +4 -1
  49. package/dist/server/bare-error-lane.d.ts.map +1 -1
  50. package/dist/server/bare-error-lane.js +9 -3
  51. package/dist/server/bare-error-lane.js.map +1 -1
  52. package/dist/server/base64-answers.d.ts +5 -0
  53. package/dist/server/base64-answers.d.ts.map +1 -0
  54. package/dist/server/base64-answers.js +132 -0
  55. package/dist/server/base64-answers.js.map +1 -0
  56. package/dist/server/before-handler.d.ts +10 -0
  57. package/dist/server/before-handler.d.ts.map +1 -0
  58. package/dist/server/before-handler.js +12 -0
  59. package/dist/server/before-handler.js.map +1 -0
  60. package/dist/server/body-lane.d.ts.map +1 -1
  61. package/dist/server/body-lane.js +8 -2
  62. package/dist/server/body-lane.js.map +1 -1
  63. package/dist/server/body-parser.d.ts.map +1 -1
  64. package/dist/server/body-parser.js +2 -5
  65. package/dist/server/body-parser.js.map +1 -1
  66. package/dist/server/body.d.ts +24 -8
  67. package/dist/server/body.d.ts.map +1 -1
  68. package/dist/server/body.js +86 -33
  69. package/dist/server/body.js.map +1 -1
  70. package/dist/server/core-copies.d.ts.map +1 -1
  71. package/dist/server/core-copies.js +5 -2
  72. package/dist/server/core-copies.js.map +1 -1
  73. package/dist/server/http.d.ts.map +1 -1
  74. package/dist/server/http.js +6 -17
  75. package/dist/server/http.js.map +1 -1
  76. package/dist/server/install.d.ts +2 -0
  77. package/dist/server/install.d.ts.map +1 -1
  78. package/dist/server/install.js +2 -0
  79. package/dist/server/install.js.map +1 -1
  80. package/dist/server/json-response.d.ts +3 -0
  81. package/dist/server/json-response.d.ts.map +1 -0
  82. package/dist/server/json-response.js +14 -0
  83. package/dist/server/json-response.js.map +1 -0
  84. package/dist/server/json-text.d.ts +16 -0
  85. package/dist/server/json-text.d.ts.map +1 -0
  86. package/dist/server/json-text.js +34 -0
  87. package/dist/server/json-text.js.map +1 -0
  88. package/dist/server/multipart.d.ts +3 -0
  89. package/dist/server/multipart.d.ts.map +1 -1
  90. package/dist/server/multipart.js +226 -97
  91. package/dist/server/multipart.js.map +1 -1
  92. package/dist/server/node-outcome.d.ts +5 -0
  93. package/dist/server/node-outcome.d.ts.map +1 -1
  94. package/dist/server/node-outcome.js +3 -4
  95. package/dist/server/node-outcome.js.map +1 -1
  96. package/dist/server/proto-guard.d.ts.map +1 -1
  97. package/dist/server/proto-guard.js +39 -4
  98. package/dist/server/proto-guard.js.map +1 -1
  99. package/dist/server/query.d.ts.map +1 -1
  100. package/dist/server/query.js +8 -14
  101. package/dist/server/query.js.map +1 -1
  102. package/dist/server/repeated-prefix.d.ts +17 -0
  103. package/dist/server/repeated-prefix.d.ts.map +1 -0
  104. package/dist/server/repeated-prefix.js +17 -0
  105. package/dist/server/repeated-prefix.js.map +1 -0
  106. package/dist/server/request-context.d.ts.map +1 -1
  107. package/dist/server/request-context.js +5 -5
  108. package/dist/server/request-context.js.map +1 -1
  109. package/dist/server/respond.d.ts +3 -4
  110. package/dist/server/respond.d.ts.map +1 -1
  111. package/dist/server/respond.js +45 -37
  112. package/dist/server/respond.js.map +1 -1
  113. package/dist/server/response-contract-lane.d.ts +12 -2
  114. package/dist/server/response-contract-lane.d.ts.map +1 -1
  115. package/dist/server/response-contract-lane.js +137 -9
  116. package/dist/server/response-contract-lane.js.map +1 -1
  117. package/dist/server/route-template.d.ts +10 -0
  118. package/dist/server/route-template.d.ts.map +1 -0
  119. package/dist/server/route-template.js +21 -0
  120. package/dist/server/route-template.js.map +1 -0
  121. package/dist/server/runtime-core.d.ts +7 -0
  122. package/dist/server/runtime-core.d.ts.map +1 -1
  123. package/dist/server/runtime-core.js +10 -2
  124. package/dist/server/runtime-core.js.map +1 -1
  125. package/dist/server/server-types.d.ts +17 -8
  126. package/dist/server/server-types.d.ts.map +1 -1
  127. package/dist/server/server.d.ts +27 -12
  128. package/dist/server/server.d.ts.map +1 -1
  129. package/dist/server/server.js +326 -108
  130. package/dist/server/server.js.map +1 -1
  131. package/dist/server/sse.d.ts +1 -1
  132. package/dist/server/sse.d.ts.map +1 -1
  133. package/dist/server/sse.js +7 -5
  134. package/dist/server/sse.js.map +1 -1
  135. package/dist/server/text-slots.d.ts +8 -0
  136. package/dist/server/text-slots.d.ts.map +1 -0
  137. package/dist/server/text-slots.js +19 -0
  138. package/dist/server/text-slots.js.map +1 -0
  139. package/dist/server/webhook.d.ts +1 -1
  140. package/dist/server/webhook.d.ts.map +1 -1
  141. package/dist/server/webhook.js +24 -19
  142. package/dist/server/webhook.js.map +1 -1
  143. package/dist/server/websocket.d.ts +18 -3
  144. package/dist/server/websocket.d.ts.map +1 -1
  145. package/dist/server/websocket.js +45 -5
  146. package/dist/server/websocket.js.map +1 -1
  147. package/dist/server/ws-bun.d.ts.map +1 -1
  148. package/dist/server/ws-bun.js +7 -4
  149. package/dist/server/ws-bun.js.map +1 -1
  150. package/dist/server.d.ts +2 -0
  151. package/dist/server.d.ts.map +1 -1
  152. package/dist/server.js +2 -0
  153. package/dist/server.js.map +1 -1
  154. package/dist/single-copy.d.ts +5 -0
  155. package/dist/single-copy.d.ts.map +1 -1
  156. package/dist/single-copy.js +26 -17
  157. package/dist/single-copy.js.map +1 -1
  158. package/dist/tool-cli.d.ts +64 -0
  159. package/dist/tool-cli.d.ts.map +1 -0
  160. package/dist/tool-cli.js +495 -0
  161. package/dist/tool-cli.js.map +1 -0
  162. package/dist/transport-codec.d.ts.map +1 -1
  163. package/dist/transport-codec.js +2 -1
  164. package/dist/transport-codec.js.map +1 -1
  165. package/package.json +11 -1
  166. package/src/assurance.ts +1 -0
  167. package/src/binary.ts +17 -0
  168. package/src/edge-kit.ts +1 -0
  169. package/src/errors.ts +1 -0
  170. package/src/evidence.ts +8 -0
  171. package/src/file-type.ts +130 -0
  172. package/src/index.ts +3 -1
  173. package/src/internal/hook-origins.ts +10 -0
  174. package/src/internal/route-program.ts +261 -93
  175. package/src/reflection.ts +71 -7
  176. package/src/response-observer.ts +6 -0
  177. package/src/server/answer-step.ts +68 -0
  178. package/src/server/bare-error-lane.ts +9 -4
  179. package/src/server/base64-answers.ts +138 -0
  180. package/src/server/before-handler.ts +14 -0
  181. package/src/server/body-lane.ts +19 -9
  182. package/src/server/body-parser.ts +1 -5
  183. package/src/server/body.ts +96 -36
  184. package/src/server/core-copies.ts +5 -2
  185. package/src/server/http.ts +6 -15
  186. package/src/server/install.ts +3 -0
  187. package/src/server/json-response.ts +13 -0
  188. package/src/server/json-text.ts +34 -0
  189. package/src/server/multipart.ts +232 -100
  190. package/src/server/node-outcome.ts +4 -5
  191. package/src/server/proto-guard.ts +39 -4
  192. package/src/server/query.ts +7 -13
  193. package/src/server/repeated-prefix.ts +25 -0
  194. package/src/server/request-context.ts +5 -5
  195. package/src/server/respond.ts +47 -30
  196. package/src/server/response-contract-lane.ts +132 -9
  197. package/src/server/route-template.ts +24 -0
  198. package/src/server/runtime-core.ts +12 -2
  199. package/src/server/server-types.ts +18 -6
  200. package/src/server/server.ts +361 -112
  201. package/src/server/sse.ts +8 -6
  202. package/src/server/text-slots.ts +21 -0
  203. package/src/server/webhook.ts +29 -24
  204. package/src/server/websocket.ts +57 -8
  205. package/src/server/ws-bun.ts +7 -3
  206. package/src/server.ts +2 -0
  207. package/src/single-copy.ts +31 -15
  208. package/src/tool-cli.ts +584 -0
  209. package/src/transport-codec.ts +3 -1
@@ -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
+ }
@@ -3,6 +3,8 @@
3
3
  * remains the zero-configuration fast path; rich wire is explicit and preserves non-JSON values.
4
4
  */
5
5
 
6
+ import { jsonText } from "./server/json-text.ts"
7
+
6
8
  const DEFAULT_MAX_BYTES = 16 * 1024 * 1024
7
9
  const TOKEN = /^[a-z][a-z0-9_.-]{0,63}$/
8
10
 
@@ -81,7 +83,7 @@ export const plainJsonCodec: TransportCodec = Object.freeze({
81
83
  version: 1,
82
84
  mediaType: "application/json",
83
85
  encode(value: unknown): string {
84
- const encoded = JSON.stringify(value)
86
+ const encoded = jsonText(value)
85
87
  if (encoded === undefined) throw new TransportCodecError("value is not JSON serializable")
86
88
  return encoded
87
89
  },