incur 0.4.0 → 0.4.2

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 (115) hide show
  1. package/README.md +83 -22
  2. package/SKILL.md +6 -6
  3. package/dist/Cli.d.ts +46 -26
  4. package/dist/Cli.d.ts.map +1 -1
  5. package/dist/Cli.js +728 -441
  6. package/dist/Cli.js.map +1 -1
  7. package/dist/Completions.d.ts +4 -3
  8. package/dist/Completions.d.ts.map +1 -1
  9. package/dist/Completions.js +17 -10
  10. package/dist/Completions.js.map +1 -1
  11. package/dist/Fetch.d.ts.map +1 -1
  12. package/dist/Fetch.js +10 -9
  13. package/dist/Fetch.js.map +1 -1
  14. package/dist/Filter.js +0 -18
  15. package/dist/Filter.js.map +1 -1
  16. package/dist/Formatter.d.ts.map +1 -1
  17. package/dist/Formatter.js +6 -1
  18. package/dist/Formatter.js.map +1 -1
  19. package/dist/Help.d.ts +7 -1
  20. package/dist/Help.d.ts.map +1 -1
  21. package/dist/Help.js +44 -27
  22. package/dist/Help.js.map +1 -1
  23. package/dist/Mcp.d.ts +37 -5
  24. package/dist/Mcp.d.ts.map +1 -1
  25. package/dist/Mcp.js +71 -72
  26. package/dist/Mcp.js.map +1 -1
  27. package/dist/Openapi.d.ts.map +1 -1
  28. package/dist/Openapi.js +22 -14
  29. package/dist/Openapi.js.map +1 -1
  30. package/dist/Parser.d.ts +4 -0
  31. package/dist/Parser.d.ts.map +1 -1
  32. package/dist/Parser.js +70 -38
  33. package/dist/Parser.js.map +1 -1
  34. package/dist/Schema.d.ts +5 -1
  35. package/dist/Schema.d.ts.map +1 -1
  36. package/dist/Schema.js +13 -2
  37. package/dist/Schema.js.map +1 -1
  38. package/dist/Skill.d.ts +2 -1
  39. package/dist/Skill.d.ts.map +1 -1
  40. package/dist/Skill.js +33 -19
  41. package/dist/Skill.js.map +1 -1
  42. package/dist/Skillgen.js +1 -1
  43. package/dist/Skillgen.js.map +1 -1
  44. package/dist/SyncSkills.d.ts +48 -0
  45. package/dist/SyncSkills.d.ts.map +1 -1
  46. package/dist/SyncSkills.js +108 -10
  47. package/dist/SyncSkills.js.map +1 -1
  48. package/dist/Typegen.js +4 -2
  49. package/dist/Typegen.js.map +1 -1
  50. package/dist/bin.d.ts +2 -1
  51. package/dist/bin.d.ts.map +1 -1
  52. package/dist/bin.js +17 -2
  53. package/dist/bin.js.map +1 -1
  54. package/dist/internal/command.d.ts +170 -0
  55. package/dist/internal/command.d.ts.map +1 -0
  56. package/dist/internal/command.js +292 -0
  57. package/dist/internal/command.js.map +1 -0
  58. package/dist/internal/configSchema.d.ts +8 -0
  59. package/dist/internal/configSchema.d.ts.map +1 -0
  60. package/dist/internal/configSchema.js +57 -0
  61. package/dist/internal/configSchema.js.map +1 -0
  62. package/dist/internal/dereference.d.ts +12 -0
  63. package/dist/internal/dereference.d.ts.map +1 -0
  64. package/dist/internal/dereference.js +71 -0
  65. package/dist/internal/dereference.js.map +1 -0
  66. package/dist/internal/helpers.d.ts +9 -0
  67. package/dist/internal/helpers.d.ts.map +1 -0
  68. package/dist/internal/helpers.js +54 -0
  69. package/dist/internal/helpers.js.map +1 -0
  70. package/dist/middleware.d.ts +6 -8
  71. package/dist/middleware.d.ts.map +1 -1
  72. package/dist/middleware.js +1 -1
  73. package/dist/middleware.js.map +1 -1
  74. package/examples/npm/.npmrc.json +21 -0
  75. package/examples/npm/config.schema.json +134 -0
  76. package/package.json +6 -29
  77. package/src/Cli.test-d.ts +44 -33
  78. package/src/Cli.test.ts +1231 -101
  79. package/src/Cli.ts +877 -569
  80. package/src/Completions.test.ts +136 -12
  81. package/src/Completions.ts +18 -13
  82. package/src/Fetch.test.ts +21 -0
  83. package/src/Fetch.ts +8 -10
  84. package/src/Filter.ts +0 -17
  85. package/src/Formatter.test.ts +15 -2
  86. package/src/Formatter.ts +5 -1
  87. package/src/Help.test.ts +184 -20
  88. package/src/Help.ts +52 -28
  89. package/src/Mcp.test.ts +159 -0
  90. package/src/Mcp.ts +108 -86
  91. package/src/Openapi.test.ts +17 -5
  92. package/src/Openapi.ts +21 -15
  93. package/src/Parser.test-d.ts +22 -0
  94. package/src/Parser.test.ts +89 -0
  95. package/src/Parser.ts +87 -36
  96. package/src/Schema.test.ts +29 -0
  97. package/src/Schema.ts +12 -2
  98. package/src/Skill.test.ts +87 -6
  99. package/src/Skill.ts +38 -21
  100. package/src/Skillgen.ts +1 -1
  101. package/src/SyncMcp.test.ts +6 -8
  102. package/src/SyncSkills.test.ts +146 -3
  103. package/src/SyncSkills.ts +191 -10
  104. package/src/Typegen.test.ts +15 -0
  105. package/src/Typegen.ts +4 -2
  106. package/src/bin.ts +21 -2
  107. package/src/e2e.test.ts +188 -98
  108. package/src/internal/command.ts +449 -0
  109. package/src/internal/configSchema.test.ts +193 -0
  110. package/src/internal/configSchema.ts +66 -0
  111. package/src/internal/dereference.test.ts +695 -0
  112. package/src/internal/dereference.ts +75 -0
  113. package/src/internal/helpers.test.ts +75 -0
  114. package/src/internal/helpers.ts +59 -0
  115. package/src/middleware.ts +5 -12
package/src/Mcp.ts CHANGED
@@ -1,7 +1,9 @@
1
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
2
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
1
+ import { McpServer, StdioServerTransport } from '@modelcontextprotocol/server'
3
2
  import type { Readable, Writable } from 'node:stream'
3
+ import { z } from 'zod'
4
4
 
5
+ import * as Command from './internal/command.js'
6
+ import type { Handler as MiddlewareHandler } from './middleware.js'
5
7
  import * as Schema from './Schema.js'
6
8
 
7
9
  /** Starts a stdio MCP server that exposes commands as tools. */
@@ -24,13 +26,22 @@ export async function serve(
24
26
  tool.name,
25
27
  {
26
28
  ...(tool.description ? { description: tool.description } : undefined),
27
- ...(hasInput ? { inputSchema: mergedShape } : undefined),
28
- },
29
+ ...(hasInput ? { inputSchema: z.object(mergedShape) } : undefined),
30
+ ...(tool.outputSchema ? { outputSchema: tool.outputSchema } : undefined),
31
+ } as never,
29
32
  async (...callArgs: any[]) => {
30
33
  // registerTool passes (args, extra) when inputSchema is set, (extra) when not
31
34
  const params = hasInput ? (callArgs[0] as Record<string, unknown>) : {}
32
35
  const extra = hasInput ? callArgs[1] : callArgs[0]
33
- return callTool(tool, params, extra)
36
+ return callTool(tool, params, {
37
+ extra,
38
+ sendNotification: (n) => server.server.notification(n),
39
+ name,
40
+ version,
41
+ middlewares: options.middlewares,
42
+ env: options.env,
43
+ vars: options.vars,
44
+ })
34
45
  },
35
46
  )
36
47
  }
@@ -44,10 +55,18 @@ export async function serve(
44
55
  export declare namespace serve {
45
56
  /** Options for the MCP server. */
46
57
  type Options = {
58
+ /** CLI-level env schema. */
59
+ env?: z.ZodObject<any> | undefined
47
60
  /** Override input stream. Defaults to `process.stdin`. */
48
61
  input?: Readable | undefined
62
+ /** Middleware handlers registered on the root CLI. */
63
+ middlewares?: MiddlewareHandler[] | undefined
49
64
  /** Override output stream. Defaults to `process.stdout`. */
50
65
  output?: Writable | undefined
66
+ /** Vars schema for middleware variables. */
67
+ vars?: z.ZodObject<any> | undefined
68
+ /** CLI version string. */
69
+ version?: string | undefined
51
70
  }
52
71
  }
53
72
 
@@ -55,83 +74,85 @@ export declare namespace serve {
55
74
  export async function callTool(
56
75
  tool: ToolEntry,
57
76
  params: Record<string, unknown>,
58
- extra?: {
59
- _meta?: { progressToken?: string | number }
60
- sendNotification?: (n: any) => Promise<void>
61
- },
62
- ): Promise<{ content: { type: 'text'; text: string }[]; isError?: boolean }> {
63
- try {
64
- const { args, options } = splitParams(params, tool.command)
65
- const parsedArgs = tool.command.args ? tool.command.args.parse(args) : {}
66
- const parsedOptions = tool.command.options ? tool.command.options.parse(options) : {}
67
- const parsedEnv = tool.command.env ? tool.command.env.parse(process.env) : {}
68
-
69
- const sentinel = Symbol.for('incur.sentinel')
70
- const okFn = (data: unknown): never => ({ [sentinel]: 'ok', data }) as never
71
- const errorFn = (opts: { code: string; message: string }): never =>
72
- ({ [sentinel]: 'error', ...opts }) as never
73
-
74
- const raw = tool.command.run({
75
- args: parsedArgs,
76
- env: parsedEnv,
77
- options: parsedOptions,
78
- ok: okFn,
79
- error: errorFn,
80
- })
81
-
77
+ options: {
78
+ extra?: {
79
+ mcpReq?: { _meta?: { progressToken?: string | number } }
80
+ }
81
+ sendNotification?: (n: ProgressNotification) => Promise<void>
82
+ name?: string | undefined
83
+ version?: string | undefined
84
+ middlewares?: MiddlewareHandler[] | undefined
85
+ env?: z.ZodObject<any> | undefined
86
+ vars?: z.ZodObject<any> | undefined
87
+ } = {},
88
+ ): Promise<{
89
+ content: { type: 'text'; text: string }[]
90
+ structuredContent?: Record<string, unknown>
91
+ isError?: boolean
92
+ }> {
93
+ const allMiddleware = [
94
+ ...(options.middlewares ?? []),
95
+ ...((tool.middlewares as MiddlewareHandler[] | undefined) ?? []),
96
+ ...((tool.command.middleware as MiddlewareHandler[] | undefined) ?? []),
97
+ ]
98
+
99
+ const result = await Command.execute(tool.command, {
100
+ agent: true,
101
+ argv: [],
102
+ env: options.env,
103
+ format: 'json',
104
+ formatExplicit: true,
105
+ inputOptions: params,
106
+ middlewares: allMiddleware,
107
+ name: options.name ?? tool.name,
108
+ parseMode: 'flat',
109
+ path: tool.name,
110
+ vars: options.vars,
111
+ version: options.version,
112
+ })
113
+
114
+ if ('stream' in result) {
82
115
  // Streaming: send progress notifications per chunk, then return buffered result
83
- if (isAsyncGenerator(raw)) {
84
- const chunks: unknown[] = []
85
- const progressToken = extra?._meta?.progressToken
86
- let i = 0
87
- for await (const chunk of raw) {
88
- if (typeof chunk === 'object' && chunk !== null && sentinel in chunk) {
89
- const tagged = chunk as any
90
- if (tagged[sentinel] === 'error')
91
- return {
92
- content: [{ type: 'text', text: tagged.message ?? 'Command failed' }],
93
- isError: true,
94
- }
95
- }
116
+ const chunks: unknown[] = []
117
+ const progressToken = options.extra?.mcpReq?._meta?.progressToken
118
+ let i = 0
119
+ try {
120
+ for await (const chunk of result.stream) {
96
121
  chunks.push(chunk)
97
- if (progressToken !== undefined && extra?.sendNotification)
98
- await extra.sendNotification({
122
+ if (progressToken !== undefined && options.sendNotification)
123
+ await options.sendNotification({
99
124
  method: 'notifications/progress' as const,
100
125
  params: { progressToken, progress: ++i, message: JSON.stringify(chunk) },
101
126
  })
102
127
  }
103
- return { content: [{ type: 'text', text: JSON.stringify(chunks) }] }
104
- }
105
-
106
- const awaited = await raw
107
-
108
- if (typeof awaited === 'object' && awaited !== null && sentinel in awaited) {
109
- const tagged = awaited as any
110
- if (tagged[sentinel] === 'error')
111
- return {
112
- content: [{ type: 'text', text: tagged.message ?? 'Command failed' }],
113
- isError: true,
114
- }
115
- return { content: [{ type: 'text', text: JSON.stringify(tagged.data ?? null) }] }
128
+ } catch (err) {
129
+ return {
130
+ content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }],
131
+ isError: true,
132
+ }
116
133
  }
134
+ return { content: [{ type: 'text', text: JSON.stringify(chunks) }] }
135
+ }
117
136
 
118
- return { content: [{ type: 'text', text: JSON.stringify(awaited ?? null) }] }
119
- } catch (err) {
137
+ if (!result.ok)
120
138
  return {
121
- content: [{ type: 'text', text: err instanceof Error ? err.message : String(err) }],
139
+ content: [{ type: 'text', text: result.error.message ?? 'Command failed' }],
122
140
  isError: true,
123
141
  }
142
+
143
+ const data = result.data ?? null
144
+ return {
145
+ content: [{ type: 'text', text: JSON.stringify(data) }],
146
+ ...(data !== null && tool.outputSchema
147
+ ? { structuredContent: data as Record<string, unknown> }
148
+ : undefined),
124
149
  }
125
150
  }
126
151
 
127
- /** @internal Type guard for async generators. */
128
- function isAsyncGenerator(value: unknown): value is AsyncGenerator<unknown, unknown, unknown> {
129
- return (
130
- typeof value === 'object' &&
131
- value !== null &&
132
- Symbol.asyncIterator in value &&
133
- typeof (value as any).next === 'function'
134
- )
152
+ /** @internal A progress notification sent during streaming tool calls. */
153
+ type ProgressNotification = {
154
+ method: 'notifications/progress'
155
+ params: { progressToken: string | number; progress: number; message: string }
135
156
  }
136
157
 
137
158
  /** @internal A resolved tool entry from the command tree. */
@@ -139,21 +160,37 @@ export type ToolEntry = {
139
160
  name: string
140
161
  description?: string | undefined
141
162
  inputSchema: { type: 'object'; properties: Record<string, unknown>; required?: string[] }
163
+ outputSchema?: Record<string, unknown> | undefined
142
164
  command: any
165
+ middlewares?: MiddlewareHandler[] | undefined
143
166
  }
144
167
 
145
168
  /** @internal Recursively collects leaf commands as tool entries. */
146
- export function collectTools(commands: Map<string, any>, prefix: string[]): ToolEntry[] {
169
+ export function collectTools(
170
+ commands: Map<string, any>,
171
+ prefix: string[],
172
+ parentMiddlewares: MiddlewareHandler[] = [],
173
+ ): ToolEntry[] {
147
174
  const result: ToolEntry[] = []
148
175
  for (const [name, entry] of commands) {
176
+ if ('_alias' in entry) continue
149
177
  const path = [...prefix, name]
150
- if ('_group' in entry && entry._group) result.push(...collectTools(entry.commands, path))
151
- else {
178
+ if ('_group' in entry && entry._group) {
179
+ const groupMw = [
180
+ ...parentMiddlewares,
181
+ ...((entry.middlewares as MiddlewareHandler[] | undefined) ?? []),
182
+ ]
183
+ result.push(...collectTools(entry.commands, path, groupMw))
184
+ } else {
152
185
  result.push({
153
186
  name: path.join('_'),
154
187
  description: entry.description,
155
188
  inputSchema: buildToolSchema(entry.args, entry.options),
189
+ ...(entry.output
190
+ ? { outputSchema: Schema.toJsonSchema(entry.output) as Record<string, unknown> }
191
+ : undefined),
156
192
  command: entry,
193
+ ...(parentMiddlewares.length > 0 ? { middlewares: parentMiddlewares } : undefined),
157
194
  })
158
195
  }
159
196
  }
@@ -178,18 +215,3 @@ function buildToolSchema(
178
215
  if (required.length > 0) return { type: 'object', properties, required }
179
216
  return { type: 'object', properties }
180
217
  }
181
-
182
- /** @internal Splits flat params into args vs options using schema shapes. */
183
- function splitParams(
184
- params: Record<string, unknown>,
185
- command: any,
186
- ): { args: Record<string, unknown>; options: Record<string, unknown> } {
187
- const argKeys = new Set(command.args ? Object.keys(command.args.shape) : [])
188
- const a: Record<string, unknown> = {}
189
- const o: Record<string, unknown> = {}
190
- for (const [key, value] of Object.entries(params)) {
191
- if (argKeys.has(key)) a[key] = value
192
- else o[key] = value
193
- }
194
- return { args: a, options: o }
195
- }
@@ -1,4 +1,9 @@
1
- import { describe, expect, test } from 'vitest'
1
+ import { describe, expect, test, vi } from 'vitest'
2
+
3
+ vi.mock('./SyncSkills.js', async (importOriginal) => {
4
+ const actual = await importOriginal<typeof import('./SyncSkills.js')>()
5
+ return { ...actual, readHash: () => undefined }
6
+ })
2
7
 
3
8
  import { app as prefixedApp } from '../test/fixtures/hono-api-prefixed.js'
4
9
  import { app } from '../test/fixtures/hono-api.js'
@@ -42,6 +47,13 @@ describe('generateCommands', () => {
42
47
  const cmd = commands.get('listUsers')!
43
48
  expect(cmd.description).toBe('List users')
44
49
  })
50
+
51
+ test('coerced number params preserve description', async () => {
52
+ const commands = await Openapi.generateCommands(spec, app.fetch)
53
+ const cmd = commands.get('listUsers')!
54
+ const limitSchema = cmd.options!.shape.limit
55
+ expect(limitSchema.description).toBe('Max results')
56
+ })
45
57
  })
46
58
 
47
59
  describe('cli integration', () => {
@@ -130,11 +142,11 @@ describe('cli integration', () => {
130
142
  expect(json(output)).toEqual({ ok: true })
131
143
  })
132
144
 
133
- test('--verbose wraps in envelope', async () => {
145
+ test('--full-output wraps in envelope', async () => {
134
146
  const { output } = await serve(createCli(), [
135
147
  'api',
136
148
  'healthCheck',
137
- '--verbose',
149
+ '--full-output',
138
150
  '--format',
139
151
  'json',
140
152
  ])
@@ -242,11 +254,11 @@ describe('@hono/zod-openapi integration', () => {
242
254
  expect(json(output)).toEqual({ ok: true })
243
255
  })
244
256
 
245
- test('--verbose wraps in envelope', async () => {
257
+ test('--full-output wraps in envelope', async () => {
246
258
  const { output } = await serve(createCli(), [
247
259
  'api',
248
260
  'healthCheck',
249
- '--verbose',
261
+ '--full-output',
250
262
  '--format',
251
263
  'json',
252
264
  ])
package/src/Openapi.ts CHANGED
@@ -1,7 +1,7 @@
1
- import { dereference } from '@readme/openapi-parser'
2
1
  import { z } from 'zod'
3
2
 
4
3
  import * as Fetch from './Fetch.js'
4
+ import { dereference } from './internal/dereference.js'
5
5
 
6
6
  /** A minimal OpenAPI 3.x spec shape. Accepts both hand-written specs and generated ones (e.g. from `@hono/zod-openapi`). */
7
7
  export type OpenAPISpec = { paths?: {} | undefined }
@@ -46,7 +46,7 @@ export async function generateCommands(
46
46
  fetch: FetchHandler,
47
47
  options: { basePath?: string | undefined } = {},
48
48
  ): Promise<Map<string, GeneratedCommand>> {
49
- const resolved = (await dereference(structuredClone(spec) as any)) as unknown as OpenAPISpec
49
+ const resolved = dereference(structuredClone(spec)) as OpenAPISpec
50
50
  const commands = new Map<string, GeneratedCommand>()
51
51
  const paths = (resolved.paths ?? {}) as Record<string, Record<string, unknown>>
52
52
 
@@ -186,20 +186,26 @@ function coerceIfNeeded(schema: z.ZodType): z.ZodType {
186
186
  const isOptional = schema instanceof z.ZodOptional
187
187
  const inner = isOptional ? schema.unwrap() : schema
188
188
 
189
- // Direct number/boolean
190
- if (inner instanceof z.ZodNumber)
191
- return isOptional ? z.coerce.number().optional() : z.coerce.number()
192
- if (inner instanceof z.ZodBoolean)
193
- return isOptional ? z.coerce.boolean().optional() : z.coerce.boolean()
194
-
195
- // Union containing number (e.g. type: ["number", "null"] from OpenAPI 3.1)
196
- if (inner instanceof z.ZodUnion) {
197
- const options = (inner as any)._zod?.def?.options as z.ZodType[] | undefined
198
- if (options?.some((o: z.ZodType) => o instanceof z.ZodNumber))
189
+ const coerced = (() => {
190
+ // Direct number
191
+ if (inner instanceof z.ZodNumber)
199
192
  return isOptional ? z.coerce.number().optional() : z.coerce.number()
200
- if (options?.some((o: z.ZodType) => o instanceof z.ZodBoolean))
193
+ // Direct boolean
194
+ if (inner instanceof z.ZodBoolean)
201
195
  return isOptional ? z.coerce.boolean().optional() : z.coerce.boolean()
202
- }
196
+ // Union containing number or boolean (e.g. type: ["number", "null"] from OpenAPI 3.1)
197
+ if (inner instanceof z.ZodUnion) {
198
+ const options = (inner as any)._zod?.def?.options as z.ZodType[] | undefined
199
+ if (options?.some((o: z.ZodType) => o instanceof z.ZodNumber))
200
+ return isOptional ? z.coerce.number().optional() : z.coerce.number()
201
+ if (options?.some((o: z.ZodType) => o instanceof z.ZodBoolean))
202
+ return isOptional ? z.coerce.boolean().optional() : z.coerce.boolean()
203
+ }
204
+ // No coercion needed
205
+ return undefined
206
+ })()
203
207
 
204
- return schema
208
+ if (!coerced) return schema
209
+ const desc = (schema as any).description ?? (inner as any).description
210
+ return desc ? coerced.describe(desc) : coerced
205
211
  }
@@ -43,3 +43,25 @@ test('narrows both args and options together', () => {
43
43
  expectTypeOf(result.args).toEqualTypeOf<{ repo: string }>()
44
44
  expectTypeOf(result.options).toEqualTypeOf<{ limit: number }>()
45
45
  })
46
+
47
+ test('defaults are typed from z.input of the options schema', () => {
48
+ const result = Parser.parse([], {
49
+ defaults: { limit: '5' },
50
+ options: z.object({ limit: z.coerce.number().default(30) }),
51
+ })
52
+ expectTypeOf(result.options).toEqualTypeOf<{ limit: number }>()
53
+ })
54
+
55
+ test('defaults do not leak any', () => {
56
+ type Options = z.ZodObject<{
57
+ limit: z.ZodDefault<z.ZodNumber>
58
+ saveDev: z.ZodOptional<z.ZodBoolean>
59
+ }>
60
+
61
+ expectTypeOf<Parser.parse.Options<undefined, Options>>().toEqualTypeOf<{
62
+ args?: undefined
63
+ alias?: Record<string, string> | undefined
64
+ defaults?: Partial<z.input<Options>> | undefined
65
+ options?: Options
66
+ }>()
67
+ })
@@ -252,4 +252,93 @@ describe('parse', () => {
252
252
  expect(result.args).toEqual({ repo: 'myrepo' })
253
253
  expect(result.options).toEqual({ limit: 5 })
254
254
  })
255
+
256
+ test('applies config defaults when argv omits an option', () => {
257
+ const result = Parser.parse([], {
258
+ defaults: { limit: 10 },
259
+ options: z.object({ limit: z.number().default(30) }),
260
+ })
261
+ expect(result.options).toEqual({ limit: 10 })
262
+ })
263
+
264
+ test('argv overrides config defaults', () => {
265
+ const result = Parser.parse(['--limit', '5'], {
266
+ defaults: { limit: 10 },
267
+ options: z.object({ limit: z.number().default(30) }),
268
+ })
269
+ expect(result.options).toEqual({ limit: 5 })
270
+ })
271
+
272
+ test('argv arrays replace config arrays', () => {
273
+ const result = Parser.parse(['--label', 'bug', '--label', 'feature'], {
274
+ defaults: { label: ['ops'] },
275
+ options: z.object({ label: z.array(z.string()).default([]) }),
276
+ })
277
+ expect(result.options).toEqual({ label: ['bug', 'feature'] })
278
+ })
279
+
280
+ test('kebab-case config keys map to camelCase schema names', () => {
281
+ const result = Parser.parse([], {
282
+ defaults: { 'save-dev': true } as any,
283
+ options: z.object({ saveDev: z.boolean().default(false) }),
284
+ })
285
+ expect(result.options).toEqual({ saveDev: true })
286
+ })
287
+
288
+ test('throws ParseError on unknown config option keys', () => {
289
+ expect(() =>
290
+ Parser.parse([], {
291
+ defaults: { missing: true } as any,
292
+ options: z.object({ saveDev: z.boolean().default(false) }),
293
+ }),
294
+ ).toThrow(expect.objectContaining({ name: 'Incur.ParseError' }))
295
+ })
296
+
297
+ test('throws ValidationError for invalid config defaults when argv does not override them', () => {
298
+ expect(() =>
299
+ Parser.parse([], {
300
+ defaults: { limit: 'oops' } as any,
301
+ options: z.object({ limit: z.number() }),
302
+ }),
303
+ ).toThrow(expect.objectContaining({ name: 'Incur.ValidationError' }))
304
+ })
305
+
306
+ test('argv overrides invalid config defaults', () => {
307
+ const result = Parser.parse(['--limit', '5'], {
308
+ defaults: { limit: 'oops' } as any,
309
+ options: z.object({ limit: z.number() }),
310
+ })
311
+ expect(result.options).toEqual({ limit: 5 })
312
+ })
313
+
314
+ test('defaults with no options schema throws on non-empty defaults', () => {
315
+ expect(() =>
316
+ Parser.parse([], {
317
+ defaults: { limit: 10 } as any,
318
+ }),
319
+ ).toThrow(expect.objectContaining({ name: 'Incur.ParseError' }))
320
+ })
321
+
322
+ test('defaults with no options schema and empty defaults is a no-op', () => {
323
+ const result = Parser.parse([], { defaults: {} as any })
324
+ expect(result.options).toEqual({})
325
+ })
326
+
327
+ test('config array defaults are used when argv omits the option', () => {
328
+ const result = Parser.parse([], {
329
+ defaults: { label: ['bug', 'feature'] },
330
+ options: z.object({ label: z.array(z.string()).default([]) }),
331
+ })
332
+ expect(result.options).toEqual({ label: ['bug', 'feature'] })
333
+ })
334
+
335
+ test('refined option schemas validate only the merged winning values', () => {
336
+ const result = Parser.parse(['--min', '1', '--max', '3'], {
337
+ defaults: { min: 'oops' } as any,
338
+ options: z
339
+ .object({ min: z.number(), max: z.number() })
340
+ .refine((value) => value.min < value.max, { message: 'min must be less than max' }),
341
+ })
342
+ expect(result.options).toEqual({ min: 1, max: 3 })
343
+ })
255
344
  })