incur 0.4.7 → 0.4.9

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 (94) hide show
  1. package/dist/Cli.d.ts +65 -19
  2. package/dist/Cli.d.ts.map +1 -1
  3. package/dist/Cli.js +294 -66
  4. package/dist/Cli.js.map +1 -1
  5. package/dist/Completions.d.ts +2 -1
  6. package/dist/Completions.d.ts.map +1 -1
  7. package/dist/Completions.js +52 -13
  8. package/dist/Completions.js.map +1 -1
  9. package/dist/Formatter.d.ts.map +1 -1
  10. package/dist/Formatter.js +6 -5
  11. package/dist/Formatter.js.map +1 -1
  12. package/dist/Help.d.ts +5 -0
  13. package/dist/Help.d.ts.map +1 -1
  14. package/dist/Help.js +19 -5
  15. package/dist/Help.js.map +1 -1
  16. package/dist/Mcp.d.ts +21 -0
  17. package/dist/Mcp.d.ts.map +1 -1
  18. package/dist/Mcp.js +25 -8
  19. package/dist/Mcp.js.map +1 -1
  20. package/dist/Openapi.d.ts +14 -0
  21. package/dist/Openapi.d.ts.map +1 -1
  22. package/dist/Openapi.js +85 -1
  23. package/dist/Openapi.js.map +1 -1
  24. package/dist/Parser.d.ts +14 -0
  25. package/dist/Parser.d.ts.map +1 -1
  26. package/dist/Parser.js +127 -1
  27. package/dist/Parser.js.map +1 -1
  28. package/dist/Skill.d.ts.map +1 -1
  29. package/dist/Skill.js +5 -2
  30. package/dist/Skill.js.map +1 -1
  31. package/dist/Skillgen.d.ts.map +1 -1
  32. package/dist/Skillgen.js +4 -38
  33. package/dist/Skillgen.js.map +1 -1
  34. package/dist/SyncMcp.d.ts.map +1 -1
  35. package/dist/SyncMcp.js +75 -8
  36. package/dist/SyncMcp.js.map +1 -1
  37. package/dist/SyncSkills.d.ts +9 -0
  38. package/dist/SyncSkills.d.ts.map +1 -1
  39. package/dist/SyncSkills.js +13 -74
  40. package/dist/SyncSkills.js.map +1 -1
  41. package/dist/bin.d.ts +1 -1
  42. package/dist/bin.d.ts.map +1 -1
  43. package/dist/internal/command.d.ts +2 -0
  44. package/dist/internal/command.d.ts.map +1 -1
  45. package/dist/internal/command.js +6 -5
  46. package/dist/internal/command.js.map +1 -1
  47. package/dist/internal/cta.d.ts +22 -0
  48. package/dist/internal/cta.d.ts.map +1 -0
  49. package/dist/internal/cta.js +25 -0
  50. package/dist/internal/cta.js.map +1 -0
  51. package/dist/internal/json.d.ts +5 -0
  52. package/dist/internal/json.d.ts.map +1 -0
  53. package/dist/internal/json.js +13 -0
  54. package/dist/internal/json.js.map +1 -0
  55. package/dist/internal/yaml.d.ts +12 -0
  56. package/dist/internal/yaml.d.ts.map +1 -0
  57. package/dist/internal/yaml.js +20 -0
  58. package/dist/internal/yaml.js.map +1 -0
  59. package/dist/middleware.d.ts +8 -4
  60. package/dist/middleware.d.ts.map +1 -1
  61. package/dist/middleware.js +1 -1
  62. package/dist/middleware.js.map +1 -1
  63. package/package.json +1 -1
  64. package/src/Cli.test-d.ts +71 -0
  65. package/src/Cli.test.ts +1164 -65
  66. package/src/Cli.ts +456 -101
  67. package/src/Completions.test.ts +84 -0
  68. package/src/Completions.ts +48 -9
  69. package/src/Formatter.test.ts +17 -0
  70. package/src/Formatter.ts +7 -5
  71. package/src/Help.test.ts +31 -0
  72. package/src/Help.ts +42 -4
  73. package/src/Mcp.test.ts +193 -0
  74. package/src/Mcp.ts +49 -8
  75. package/src/Openapi.test.ts +179 -0
  76. package/src/Openapi.ts +122 -2
  77. package/src/Parser.test.ts +173 -0
  78. package/src/Parser.ts +132 -1
  79. package/src/Skill.test.ts +6 -0
  80. package/src/Skill.ts +8 -5
  81. package/src/Skillgen.test.ts +55 -6
  82. package/src/Skillgen.ts +9 -35
  83. package/src/SyncMcp.test.ts +89 -0
  84. package/src/SyncMcp.ts +72 -8
  85. package/src/SyncSkills.test.ts +78 -1
  86. package/src/SyncSkills.ts +20 -78
  87. package/src/bun-compile.test.ts +5 -0
  88. package/src/e2e.test.ts +162 -8
  89. package/src/internal/command.ts +8 -4
  90. package/src/internal/cta.ts +58 -0
  91. package/src/internal/json.ts +16 -0
  92. package/src/internal/yaml.ts +23 -0
  93. package/src/lazy-imports.test.ts +94 -0
  94. package/src/middleware.ts +12 -3
package/src/Mcp.ts CHANGED
@@ -1,8 +1,9 @@
1
- import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'
2
1
  import type { Readable, Writable } from 'node:stream'
3
2
  import { z } from 'zod'
4
3
 
5
4
  import * as Command from './internal/command.js'
5
+ import { formatCtaBlock, type FormattedCtaBlock } from './internal/cta.js'
6
+ import * as Json from './internal/json.js'
6
7
  import type { Handler as MiddlewareHandler } from './middleware.js'
7
8
  import * as Schema from './Schema.js'
8
9
 
@@ -13,7 +14,14 @@ export async function serve(
13
14
  commands: Map<string, any>,
14
15
  options: serve.Options = {},
15
16
  ): Promise<void> {
16
- const server = new McpServer({ name, version })
17
+ // Lazy: only runs when actually serving MCP, so plain command runs don't pay for the SDK import.
18
+ const { fromJsonSchema, McpServer, StdioServerTransport } =
19
+ await import('@modelcontextprotocol/server')
20
+
21
+ const server = new McpServer(
22
+ { name, version },
23
+ options.instructions ? { instructions: options.instructions } : undefined,
24
+ )
17
25
 
18
26
  for (const tool of collectTools(commands, [])) {
19
27
  const mergedShape: Record<string, any> = {
@@ -27,7 +35,9 @@ export async function serve(
27
35
  {
28
36
  ...(tool.description ? { description: tool.description } : undefined),
29
37
  ...(hasInput ? { inputSchema: z.object(mergedShape) } : undefined),
30
- ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : undefined),
38
+ ...(tool.outputSchema ? { outputSchema: fromJsonSchema(tool.outputSchema) } : undefined),
39
+ ...(tool.annotations ? { annotations: tool.annotations } : undefined),
40
+ ...(tool.instructions ? { _meta: { instructions: tool.instructions } } : undefined),
31
41
  } as never,
32
42
  async (...callArgs: any[]) => {
33
43
  // registerTool passes (args, extra) when inputSchema is set, (extra) when not
@@ -67,6 +77,8 @@ export declare namespace serve {
67
77
  vars?: z.ZodObject<any> | undefined
68
78
  /** CLI version string. */
69
79
  version?: string | undefined
80
+ /** Instructions describing how to use the server and its features. */
81
+ instructions?: string | undefined
70
82
  }
71
83
  }
72
84
 
@@ -88,6 +100,7 @@ export async function callTool(
88
100
  ): Promise<{
89
101
  content: { type: 'text'; text: string }[]
90
102
  structuredContent?: Record<string, unknown>
103
+ _meta?: { cta: FormattedCtaBlock } | undefined
91
104
  isError?: boolean
92
105
  }> {
93
106
  const allMiddleware = [
@@ -122,7 +135,7 @@ export async function callTool(
122
135
  if (progressToken !== undefined && options.sendNotification)
123
136
  await options.sendNotification({
124
137
  method: 'notifications/progress' as const,
125
- params: { progressToken, progress: ++i, message: JSON.stringify(chunk) },
138
+ params: { progressToken, progress: ++i, message: Json.stringify(chunk) },
126
139
  })
127
140
  }
128
141
  } catch (err) {
@@ -131,21 +144,31 @@ export async function callTool(
131
144
  isError: true,
132
145
  }
133
146
  }
134
- return { content: [{ type: 'text', text: JSON.stringify(chunks) }] }
147
+ return { content: [{ type: 'text', text: Json.stringify(chunks) }] }
135
148
  }
136
149
 
137
150
  if (!result.ok)
138
151
  return {
139
- content: [{ type: 'text', text: result.error.message ?? 'Command failed' }],
152
+ content: [
153
+ {
154
+ type: 'text',
155
+ text: result.error.fieldErrors
156
+ ? JSON.stringify(result.error)
157
+ : (result.error.message ?? 'Command failed'),
158
+ },
159
+ ],
140
160
  isError: true,
141
161
  }
142
162
 
143
163
  const data = result.data ?? null
164
+ const jsonData = Json.normalize(data)
165
+ const cta = formatCtaBlock(options.name ?? tool.name, result.cta as Command.CtaBlock | undefined)
144
166
  return {
145
- content: [{ type: 'text', text: JSON.stringify(data) }],
167
+ content: [{ type: 'text', text: Json.stringify(jsonData) }],
146
168
  ...(data !== null && tool.outputSchema
147
- ? { structuredContent: data as Record<string, unknown> }
169
+ ? { structuredContent: jsonData as Record<string, unknown> }
148
170
  : undefined),
171
+ ...(cta ? { _meta: { cta } } : undefined),
149
172
  }
150
173
  }
151
174
 
@@ -161,10 +184,26 @@ export type ToolEntry = {
161
184
  description?: string | undefined
162
185
  inputSchema: { type: 'object'; properties: Record<string, unknown>; required?: string[] }
163
186
  outputSchema?: Record<string, unknown> | undefined
187
+ annotations?: ToolAnnotations | undefined
188
+ instructions?: string | undefined
164
189
  command: any
165
190
  middlewares?: MiddlewareHandler[] | undefined
166
191
  }
167
192
 
193
+ /** MCP tool annotations that describe tool behavior to clients. */
194
+ export type ToolAnnotations = {
195
+ /** A human-readable title for the tool. */
196
+ title?: string | undefined
197
+ /** If true, the tool does not modify its environment. Default: false. */
198
+ readOnlyHint?: boolean | undefined
199
+ /** If true, the tool may perform destructive updates to its environment. Meaningful only when readOnlyHint is false. Default: true. */
200
+ destructiveHint?: boolean | undefined
201
+ /** If true, calling the tool repeatedly with the same arguments has no additional effect. Meaningful only when readOnlyHint is false. Default: false. */
202
+ idempotentHint?: boolean | undefined
203
+ /** If true, the tool may interact with an open world of external entities. Default: true. */
204
+ openWorldHint?: boolean | undefined
205
+ }
206
+
168
207
  /** @internal Recursively collects leaf commands as tool entries. */
169
208
  export function collectTools(
170
209
  commands: Map<string, any>,
@@ -189,6 +228,8 @@ export function collectTools(
189
228
  ...(entry.output
190
229
  ? { outputSchema: Schema.toJsonSchema(entry.output) as Record<string, unknown> }
191
230
  : undefined),
231
+ ...(entry.mcp?.annotations ? { annotations: entry.mcp.annotations } : undefined),
232
+ ...(entry.mcp?.instructions ? { instructions: entry.mcp.instructions } : undefined),
192
233
  command: entry,
193
234
  ...(parentMiddlewares.length > 0 ? { middlewares: parentMiddlewares } : undefined),
194
235
  })
@@ -203,6 +203,69 @@ describe('cli integration', () => {
203
203
  })
204
204
  }
205
205
 
206
+ function createSecurityHeaderCli() {
207
+ return Cli.create('test', { description: 'test' }).command('api', {
208
+ fetch(request) {
209
+ return Response.json({ apiKey: request.headers.get('x-api-key') })
210
+ },
211
+ openapi: {
212
+ openapi: '3.0.0',
213
+ info: { title: 'Test API', version: '1.0.0' },
214
+ components: {
215
+ securitySchemes: {
216
+ tokenAuth: {
217
+ type: 'apiKey',
218
+ in: 'header',
219
+ name: 'x-api-key',
220
+ description: 'Access token',
221
+ },
222
+ },
223
+ },
224
+ security: [{ tokenAuth: [] }],
225
+ paths: {
226
+ '/secret': {
227
+ get: {
228
+ operationId: 'getSecret',
229
+ summary: 'Get secret',
230
+ responses: { '200': { description: 'OK' } },
231
+ },
232
+ },
233
+ },
234
+ },
235
+ })
236
+ }
237
+
238
+ function createBearerAuthCli() {
239
+ return Cli.create('test', { description: 'test' }).command('api', {
240
+ fetch(request) {
241
+ return Response.json({ authorization: request.headers.get('authorization') })
242
+ },
243
+ openapi: {
244
+ openapi: '3.0.0',
245
+ info: { title: 'Test API', version: '1.0.0' },
246
+ components: {
247
+ securitySchemes: {
248
+ bearerAuth: {
249
+ type: 'http',
250
+ scheme: 'bearer',
251
+ description: 'Bearer credential',
252
+ },
253
+ },
254
+ },
255
+ security: [{ bearerAuth: [] }],
256
+ paths: {
257
+ '/secret': {
258
+ get: {
259
+ operationId: 'getSecret',
260
+ summary: 'Get secret',
261
+ responses: { '200': { description: 'OK' } },
262
+ },
263
+ },
264
+ },
265
+ },
266
+ })
267
+ }
268
+
206
269
  test('GET /users via operationId', async () => {
207
270
  const { output } = await serve(createCli(), ['api', 'listUsers'])
208
271
  expect(output).toContain('Alice')
@@ -230,6 +293,122 @@ describe('cli integration', () => {
230
293
  expect(json(output).limit).toBe(5)
231
294
  })
232
295
 
296
+ test('security headers become generated command options', async () => {
297
+ const cli = createSecurityHeaderCli()
298
+ const { output } = await serve(cli, [
299
+ 'api',
300
+ 'getSecret',
301
+ '--x-api-key',
302
+ 'secret',
303
+ '--format',
304
+ 'json',
305
+ ])
306
+
307
+ expect(json(output).apiKey).toMatchInlineSnapshot(`"secret"`)
308
+ })
309
+
310
+ test('security header options appear in generated command help', async () => {
311
+ const { output } = await serve(createSecurityHeaderCli(), ['api', 'getSecret', '--help'])
312
+
313
+ expect(output).toMatchInlineSnapshot(`
314
+ "test api getSecret — Get secret
315
+
316
+ Usage: test api getSecret [options]
317
+
318
+ Options:
319
+ --x-api-key <string> Access token
320
+
321
+ Global Options:
322
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
323
+ --format <toon|json|yaml|md|jsonl> Output format
324
+ --full-output Show full output envelope
325
+ --help Show help
326
+ --llms, --llms-full Print LLM-readable manifest
327
+ --schema Show JSON Schema for command
328
+ --token-count Print token count of output (instead of output)
329
+ --token-limit <n> Limit output to n tokens
330
+ --token-offset <n> Skip first n tokens of output
331
+ "
332
+ `)
333
+ })
334
+
335
+ test('bearer auth becomes an authorization option', async () => {
336
+ const { output } = await serve(createBearerAuthCli(), [
337
+ 'api',
338
+ 'getSecret',
339
+ '--authorization',
340
+ 'Bearer secret',
341
+ '--format',
342
+ 'json',
343
+ ])
344
+
345
+ expect(json(output).authorization).toMatchInlineSnapshot(`"Bearer secret"`)
346
+ })
347
+
348
+ test('bearer auth option appears in generated command help', async () => {
349
+ const { output } = await serve(createBearerAuthCli(), ['api', 'getSecret', '--help'])
350
+
351
+ expect(output).toMatchInlineSnapshot(`
352
+ "test api getSecret — Get secret
353
+
354
+ Usage: test api getSecret [options]
355
+
356
+ Options:
357
+ --authorization <string> Bearer credential
358
+
359
+ Global Options:
360
+ --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
361
+ --format <toon|json|yaml|md|jsonl> Output format
362
+ --full-output Show full output envelope
363
+ --help Show help
364
+ --llms, --llms-full Print LLM-readable manifest
365
+ --schema Show JSON Schema for command
366
+ --token-count Print token count of output (instead of output)
367
+ --token-limit <n> Limit output to n tokens
368
+ --token-offset <n> Skip first n tokens of output
369
+ "
370
+ `)
371
+ })
372
+
373
+ test('header parameters become generated command options', async () => {
374
+ const cli = Cli.create('test', { description: 'test' }).command('api', {
375
+ fetch(request) {
376
+ return Response.json({ requestId: request.headers.get('x-request-id') })
377
+ },
378
+ openapi: {
379
+ openapi: '3.0.0',
380
+ info: { title: 'Test API', version: '1.0.0' },
381
+ paths: {
382
+ '/secret': {
383
+ get: {
384
+ operationId: 'getSecret',
385
+ summary: 'Get secret',
386
+ parameters: [
387
+ {
388
+ name: 'x-request-id',
389
+ in: 'header',
390
+ schema: { type: 'string' },
391
+ description: 'Request ID',
392
+ },
393
+ ],
394
+ responses: { '200': { description: 'OK' } },
395
+ },
396
+ },
397
+ },
398
+ },
399
+ })
400
+ const { output } = await serve(cli, [
401
+ 'api',
402
+ 'getSecret',
403
+ '--x-request-id',
404
+ 'request_test',
405
+ '--format',
406
+ 'json',
407
+ ])
408
+
409
+ expect(json(output).requestId).toMatchInlineSnapshot(`"request_test"`)
410
+ })
411
+
233
412
  test('loads OpenAPI commands from a spec URL string', async () => {
234
413
  const cli = Cli.create('test', { description: 'test' }).command('api', {
235
414
  fetch: app.fetch,
package/src/Openapi.ts CHANGED
@@ -12,7 +12,17 @@ import { dereference } from './internal/dereference.js'
12
12
  import * as Schema from './Schema.js'
13
13
 
14
14
  /** A minimal OpenAPI 3.x spec shape. Accepts both hand-written specs and generated ones (e.g. from `@hono/zod-openapi`). */
15
- export type OpenAPISpec = { paths?: {} | undefined }
15
+ export type OpenAPISpec = {
16
+ components?:
17
+ | {
18
+ securitySchemes?: Record<string, SecurityScheme> | undefined
19
+ }
20
+ | undefined
21
+ info?: Record<string, unknown> | undefined
22
+ openapi?: string | undefined
23
+ paths?: {} | undefined
24
+ security?: readonly SecurityRequirement[] | undefined
25
+ }
16
26
 
17
27
  /** OpenAPI document source accepted by fetch-backed CLI commands. */
18
28
  export type OpenAPISource = OpenAPISpec | string | URL
@@ -72,6 +82,7 @@ type Operation = {
72
82
  parameters?: readonly Parameter[] | undefined
73
83
  requestBody?: RequestBody | undefined
74
84
  responses?: Record<string, unknown> | undefined
85
+ security?: readonly SecurityRequirement[] | undefined
75
86
  summary?: string | undefined
76
87
  }
77
88
 
@@ -88,6 +99,20 @@ type RequestBody = {
88
99
  required?: boolean | undefined
89
100
  }
90
101
 
102
+ type SecurityRequirement = Record<string, readonly string[]>
103
+
104
+ type SecurityScheme = {
105
+ description?: string | undefined
106
+ in?: 'cookie' | 'header' | 'query' | undefined
107
+ name?: string | undefined
108
+ scheme?: string | undefined
109
+ type?: string | undefined
110
+ }
111
+
112
+ type HeaderParameter = Parameter & {
113
+ optionName: string
114
+ }
115
+
91
116
  /** A fetch handler. */
92
117
  type FetchHandler = (req: Request) => Response | Promise<Response>
93
118
 
@@ -356,6 +381,10 @@ export async function generateCommands(
356
381
 
357
382
  const pathParams = (op.parameters ?? []).filter((p) => p.in === 'path')
358
383
  const queryParams = (op.parameters ?? []).filter((p) => p.in === 'query')
384
+ const headerParams = headerOptions([
385
+ ...(op.parameters ?? []).filter((p) => p.in === 'header'),
386
+ ...securityHeaderParams(resolved, op),
387
+ ])
359
388
 
360
389
  const bodySchema = op.requestBody?.content?.['application/json']?.schema
361
390
  const bodyProps = (bodySchema?.properties ?? {}) as Record<string, Record<string, unknown>>
@@ -376,16 +405,28 @@ export async function generateCommands(
376
405
 
377
406
  // Build options Zod schema from query params + body properties
378
407
  const optShape: Record<string, z.ZodType> = {}
408
+ const usedOptionNames = new Set<string>()
379
409
  for (const p of queryParams) {
380
410
  let zodType = p.schema ? toZod(p.schema) : z.string()
381
411
  if (!p.required) zodType = zodType.optional()
382
412
  if (p.description) zodType = zodType.describe(p.description)
383
413
  optShape[p.name] = coerceIfNeeded(zodType)
414
+ usedOptionNames.add(p.name)
384
415
  }
385
416
  for (const [key, schema] of Object.entries(bodyProps)) {
386
417
  let zodType = toZod(schema)
387
418
  if (!bodyRequired.has(key)) zodType = zodType.optional()
388
419
  optShape[key] = zodType
420
+ usedOptionNames.add(key)
421
+ }
422
+ for (const p of headerParams) {
423
+ const optionName = resolveHeaderOptionName(p.optionName, usedOptionNames)
424
+ p.optionName = optionName
425
+ let zodType = p.schema ? toZod(p.schema) : z.string()
426
+ if (!p.required) zodType = zodType.optional()
427
+ zodType = zodType.describe(p.description ?? `${p.name} header`)
428
+ optShape[optionName] = coerceIfNeeded(zodType)
429
+ usedOptionNames.add(optionName)
389
430
  }
390
431
  const optionsSchema = Object.keys(optShape).length > 0 ? z.object(optShape) : undefined
391
432
 
@@ -398,6 +439,7 @@ export async function generateCommands(
398
439
  fetch,
399
440
  httpMethod,
400
441
  path,
442
+ headerParams,
401
443
  pathParams,
402
444
  queryParams,
403
445
  bodyProps,
@@ -438,6 +480,77 @@ function openapiOperations(paths: Record<string, Record<string, unknown>>) {
438
480
  return operations
439
481
  }
440
482
 
483
+ function securityHeaderParams(spec: OpenAPISpec, operation: Operation): Parameter[] {
484
+ const schemes = spec.components?.securitySchemes ?? {}
485
+ const requirements = operation.security ?? spec.security ?? []
486
+ const headers: Parameter[] = []
487
+
488
+ for (const requirement of requirements)
489
+ for (const name of Object.keys(requirement)) {
490
+ const scheme = schemes[name]
491
+ const parameter = securityHeaderParam(name, scheme)
492
+ if (parameter) headers.push(parameter)
493
+ }
494
+
495
+ return headers
496
+ }
497
+
498
+ function securityHeaderParam(
499
+ name: string,
500
+ scheme: SecurityScheme | undefined,
501
+ ): Parameter | undefined {
502
+ if (!scheme) return undefined
503
+ // `apiKey` is OpenAPI's generic name for a credential carried in a
504
+ // header/query/cookie, not an incur- or Cadent-specific API key concept.
505
+ if (scheme.type === 'apiKey' && scheme.in === 'header' && scheme.name)
506
+ return {
507
+ description: scheme.description ?? `${scheme.name} header`,
508
+ in: 'header',
509
+ name: scheme.name,
510
+ required: false,
511
+ schema: { type: 'string' },
512
+ }
513
+
514
+ if (scheme.type === 'http' && authorizationSchemes.has(scheme.scheme?.toLowerCase() ?? ''))
515
+ return {
516
+ description: scheme.description ?? `${name} authorization header`,
517
+ in: 'header',
518
+ name: 'authorization',
519
+ required: false,
520
+ schema: { type: 'string' },
521
+ }
522
+
523
+ return undefined
524
+ }
525
+
526
+ const authorizationSchemes = new Set(['basic', 'bearer'])
527
+
528
+ function headerOptions(parameters: Parameter[]): HeaderParameter[] {
529
+ const seen = new Set<string>()
530
+ const headers: HeaderParameter[] = []
531
+
532
+ for (const parameter of parameters) {
533
+ const normalized = parameter.name.toLowerCase()
534
+ if (seen.has(normalized)) continue
535
+ seen.add(normalized)
536
+ headers.push({ ...parameter, optionName: normalized })
537
+ }
538
+
539
+ return headers
540
+ }
541
+
542
+ function resolveHeaderOptionName(optionName: string, used: Set<string>) {
543
+ if (!used.has(optionName)) return optionName
544
+
545
+ const prefix = `header-${optionName}`
546
+ if (!used.has(prefix)) return prefix
547
+
548
+ for (let index = 2; ; index++) {
549
+ const candidate = `${prefix}-${index}`
550
+ if (!used.has(candidate)) return candidate
551
+ }
552
+ }
553
+
441
554
  function getNamespaceInfo(operations: OperationEntry[]) {
442
555
  const pathOperations = new Map<string, number>()
443
556
  const parentPaths = new Set<string>()
@@ -565,6 +678,7 @@ function createHandler(config: {
565
678
  basePath?: string | undefined
566
679
  bodyProps: Record<string, Record<string, unknown>>
567
680
  fetch: FetchHandler
681
+ headerParams: HeaderParameter[]
568
682
  httpMethod: string
569
683
  path: string
570
684
  pathParams: Parameter[]
@@ -604,7 +718,13 @@ function createHandler(config: {
604
718
  query,
605
719
  }
606
720
 
607
- if (body) input.headers.set('content-type', 'application/json')
721
+ for (const p of config.headerParams) {
722
+ const value = options[p.optionName]
723
+ if (value !== undefined) input.headers.set(p.name, String(value))
724
+ }
725
+
726
+ if (body && !input.headers.has('content-type'))
727
+ input.headers.set('content-type', 'application/json')
608
728
 
609
729
  const request = Fetch.buildRequest(input)
610
730
  const response = await config.fetch(request)
@@ -376,3 +376,176 @@ describe('parse', () => {
376
376
  expect(result.options).toEqual({ min: 1, max: 3 })
377
377
  })
378
378
  })
379
+
380
+ describe('parseGlobals', () => {
381
+ test('extracts known globals and returns rest', () => {
382
+ const schema = z.object({ rpcUrl: z.string() })
383
+ const result = Parser.parseGlobals(['--rpc-url', 'http://example.com', 'deploy'], schema)
384
+ expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' })
385
+ expect(result.rest).toEqual(['deploy'])
386
+ })
387
+
388
+ test('unknown flags pass through to rest', () => {
389
+ const schema = z.object({ rpcUrl: z.string() })
390
+ const result = Parser.parseGlobals(
391
+ ['--rpc-url', 'http://example.com', '--unknown', 'val', 'deploy'],
392
+ schema,
393
+ )
394
+ expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' })
395
+ expect(result.rest).toEqual(['--unknown', 'val', 'deploy'])
396
+ })
397
+
398
+ test('handles --flag=value syntax', () => {
399
+ const schema = z.object({ rpcUrl: z.string() })
400
+ const result = Parser.parseGlobals(['--rpc-url=http://example.com', 'deploy'], schema)
401
+ expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' })
402
+ expect(result.rest).toEqual(['deploy'])
403
+ })
404
+
405
+ test('handles short aliases', () => {
406
+ const schema = z.object({ rpcUrl: z.string() })
407
+ const result = Parser.parseGlobals(
408
+ ['-r', 'http://example.com', 'deploy'],
409
+ schema,
410
+ { rpcUrl: 'r' },
411
+ )
412
+ expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' })
413
+ expect(result.rest).toEqual(['deploy'])
414
+ })
415
+
416
+ test('handles boolean globals', () => {
417
+ const schema = z.object({ verbose: z.boolean().default(false) })
418
+ const result = Parser.parseGlobals(['--verbose', 'deploy'], schema)
419
+ expect(result.parsed).toEqual({ verbose: true })
420
+ expect(result.rest).toEqual(['deploy'])
421
+ })
422
+
423
+ test('validates against schema', () => {
424
+ const schema = z.object({ count: z.number() })
425
+ expect(() => Parser.parseGlobals(['--count', 'not-a-number'], schema)).toThrow()
426
+ })
427
+
428
+ test('coerces string to number', () => {
429
+ const schema = z.object({ limit: z.number() })
430
+ const result = Parser.parseGlobals(['--limit', '42', 'deploy'], schema)
431
+ expect(result.parsed).toEqual({ limit: 42 })
432
+ expect(result.rest).toEqual(['deploy'])
433
+ })
434
+
435
+ test('positionals pass through to rest', () => {
436
+ const schema = z.object({ verbose: z.boolean().default(false) })
437
+ const result = Parser.parseGlobals(['deploy', 'contract', '--verbose'], schema)
438
+ expect(result.parsed).toEqual({ verbose: true })
439
+ expect(result.rest).toEqual(['deploy', 'contract'])
440
+ })
441
+
442
+ test('-- separator: everything after -- passes through to rest including the --', () => {
443
+ const schema = z.object({ verbose: z.boolean().default(false) })
444
+ const result = Parser.parseGlobals(
445
+ ['--verbose', '--', '--unknown', 'positional', '--also-unknown'],
446
+ schema,
447
+ )
448
+ expect(result.parsed).toEqual({ verbose: true })
449
+ expect(result.rest).toEqual(['--', '--unknown', 'positional', '--also-unknown'])
450
+ })
451
+
452
+ test('stacked short aliases: -rv where both are known boolean globals', () => {
453
+ const schema = z.object({
454
+ recursive: z.boolean().default(false),
455
+ verbose: z.boolean().default(false),
456
+ })
457
+ const result = Parser.parseGlobals(['-rv', 'deploy'], schema, {
458
+ recursive: 'r',
459
+ verbose: 'v',
460
+ })
461
+ expect(result.parsed).toEqual({ recursive: true, verbose: true })
462
+ expect(result.rest).toEqual(['deploy'])
463
+ })
464
+
465
+ test('count options: --verbose --verbose accumulates', () => {
466
+ const schema = z.object({ verbose: z.number().default(0).meta({ count: true }) })
467
+ const result = Parser.parseGlobals(['--verbose', '--verbose', 'deploy'], schema)
468
+ expect(result.parsed).toEqual({ verbose: 2 })
469
+ expect(result.rest).toEqual(['deploy'])
470
+ })
471
+
472
+ test('array options: --tag foo --tag bar collects into array', () => {
473
+ const schema = z.object({ tag: z.array(z.string()).default([]) })
474
+ const result = Parser.parseGlobals(['--tag', 'foo', '--tag', 'bar', 'deploy'], schema)
475
+ expect(result.parsed).toEqual({ tag: ['foo', 'bar'] })
476
+ expect(result.rest).toEqual(['deploy'])
477
+ })
478
+
479
+ test('unknown --no-* flags pass through to rest', () => {
480
+ const schema = z.object({ verbose: z.boolean().default(false) })
481
+ const result = Parser.parseGlobals(['--no-color', '--verbose'], schema)
482
+ expect(result.parsed).toEqual({ verbose: true })
483
+ expect(result.rest).toEqual(['--no-color'])
484
+ })
485
+
486
+ test('unknown --flag=value passes through as single token', () => {
487
+ const schema = z.object({ verbose: z.boolean().default(false) })
488
+ const result = Parser.parseGlobals(['--output=json', '--verbose'], schema)
489
+ expect(result.parsed).toEqual({ verbose: true })
490
+ expect(result.rest).toEqual(['--output=json'])
491
+ })
492
+
493
+ test('missing value for known flag throws ParseError', () => {
494
+ const schema = z.object({ rpcUrl: z.string() })
495
+ expect(() => Parser.parseGlobals(['--rpc-url'], schema)).toThrow(
496
+ expect.objectContaining({ name: 'Incur.ParseError' }),
497
+ )
498
+ })
499
+
500
+ test('stacked short: count in non-last position', () => {
501
+ const schema = z.object({
502
+ verbose: z.number().default(0).meta({ count: true }),
503
+ recursive: z.boolean().default(false),
504
+ })
505
+ const result = Parser.parseGlobals(['-vr'], schema, { verbose: 'v', recursive: 'r' })
506
+ expect(result.parsed).toEqual({ verbose: 1, recursive: true })
507
+ })
508
+
509
+ test('stacked short: non-boolean in non-last position throws', () => {
510
+ const schema = z.object({
511
+ output: z.string(),
512
+ verbose: z.boolean().default(false),
513
+ })
514
+ expect(() => Parser.parseGlobals(['-ov', 'file'], schema, { output: 'o', verbose: 'v' })).toThrow(
515
+ /must be last/,
516
+ )
517
+ })
518
+
519
+ test('short flag value-taking as last in stacked alias', () => {
520
+ const schema = z.object({
521
+ verbose: z.boolean().default(false),
522
+ output: z.string(),
523
+ })
524
+ const result = Parser.parseGlobals(['-vo', 'file', 'deploy'], schema, {
525
+ verbose: 'v',
526
+ output: 'o',
527
+ })
528
+ expect(result.parsed).toEqual({ verbose: true, output: 'file' })
529
+ expect(result.rest).toEqual(['deploy'])
530
+ })
531
+
532
+ test('short flag missing value throws ParseError', () => {
533
+ const schema = z.object({ output: z.string() })
534
+ expect(() => Parser.parseGlobals(['-o'], schema, { output: 'o' })).toThrow(
535
+ expect.objectContaining({ name: 'Incur.ParseError' }),
536
+ )
537
+ })
538
+
539
+ test('known --no- negation for boolean global', () => {
540
+ const schema = z.object({ verbose: z.boolean().default(true) })
541
+ const result = Parser.parseGlobals(['--no-verbose', 'deploy'], schema)
542
+ expect(result.parsed).toEqual({ verbose: false })
543
+ expect(result.rest).toEqual(['deploy'])
544
+ })
545
+
546
+ test('known --flag=value with setOption', () => {
547
+ const schema = z.object({ tag: z.array(z.string()).default([]) })
548
+ const result = Parser.parseGlobals(['--tag=foo', '--tag=bar'], schema)
549
+ expect(result.parsed).toEqual({ tag: ['foo', 'bar'] })
550
+ })
551
+ })