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.
- package/README.md +83 -22
- package/SKILL.md +6 -6
- package/dist/Cli.d.ts +46 -26
- package/dist/Cli.d.ts.map +1 -1
- package/dist/Cli.js +728 -441
- package/dist/Cli.js.map +1 -1
- package/dist/Completions.d.ts +4 -3
- package/dist/Completions.d.ts.map +1 -1
- package/dist/Completions.js +17 -10
- package/dist/Completions.js.map +1 -1
- package/dist/Fetch.d.ts.map +1 -1
- package/dist/Fetch.js +10 -9
- package/dist/Fetch.js.map +1 -1
- package/dist/Filter.js +0 -18
- package/dist/Filter.js.map +1 -1
- package/dist/Formatter.d.ts.map +1 -1
- package/dist/Formatter.js +6 -1
- package/dist/Formatter.js.map +1 -1
- package/dist/Help.d.ts +7 -1
- package/dist/Help.d.ts.map +1 -1
- package/dist/Help.js +44 -27
- package/dist/Help.js.map +1 -1
- package/dist/Mcp.d.ts +37 -5
- package/dist/Mcp.d.ts.map +1 -1
- package/dist/Mcp.js +71 -72
- package/dist/Mcp.js.map +1 -1
- package/dist/Openapi.d.ts.map +1 -1
- package/dist/Openapi.js +22 -14
- package/dist/Openapi.js.map +1 -1
- package/dist/Parser.d.ts +4 -0
- package/dist/Parser.d.ts.map +1 -1
- package/dist/Parser.js +70 -38
- package/dist/Parser.js.map +1 -1
- package/dist/Schema.d.ts +5 -1
- package/dist/Schema.d.ts.map +1 -1
- package/dist/Schema.js +13 -2
- package/dist/Schema.js.map +1 -1
- package/dist/Skill.d.ts +2 -1
- package/dist/Skill.d.ts.map +1 -1
- package/dist/Skill.js +33 -19
- package/dist/Skill.js.map +1 -1
- package/dist/Skillgen.js +1 -1
- package/dist/Skillgen.js.map +1 -1
- package/dist/SyncSkills.d.ts +48 -0
- package/dist/SyncSkills.d.ts.map +1 -1
- package/dist/SyncSkills.js +108 -10
- package/dist/SyncSkills.js.map +1 -1
- package/dist/Typegen.js +4 -2
- package/dist/Typegen.js.map +1 -1
- package/dist/bin.d.ts +2 -1
- package/dist/bin.d.ts.map +1 -1
- package/dist/bin.js +17 -2
- package/dist/bin.js.map +1 -1
- package/dist/internal/command.d.ts +170 -0
- package/dist/internal/command.d.ts.map +1 -0
- package/dist/internal/command.js +292 -0
- package/dist/internal/command.js.map +1 -0
- package/dist/internal/configSchema.d.ts +8 -0
- package/dist/internal/configSchema.d.ts.map +1 -0
- package/dist/internal/configSchema.js +57 -0
- package/dist/internal/configSchema.js.map +1 -0
- package/dist/internal/dereference.d.ts +12 -0
- package/dist/internal/dereference.d.ts.map +1 -0
- package/dist/internal/dereference.js +71 -0
- package/dist/internal/dereference.js.map +1 -0
- package/dist/internal/helpers.d.ts +9 -0
- package/dist/internal/helpers.d.ts.map +1 -0
- package/dist/internal/helpers.js +54 -0
- package/dist/internal/helpers.js.map +1 -0
- package/dist/middleware.d.ts +6 -8
- package/dist/middleware.d.ts.map +1 -1
- package/dist/middleware.js +1 -1
- package/dist/middleware.js.map +1 -1
- package/examples/npm/.npmrc.json +21 -0
- package/examples/npm/config.schema.json +134 -0
- package/package.json +6 -29
- package/src/Cli.test-d.ts +44 -33
- package/src/Cli.test.ts +1231 -101
- package/src/Cli.ts +877 -569
- package/src/Completions.test.ts +136 -12
- package/src/Completions.ts +18 -13
- package/src/Fetch.test.ts +21 -0
- package/src/Fetch.ts +8 -10
- package/src/Filter.ts +0 -17
- package/src/Formatter.test.ts +15 -2
- package/src/Formatter.ts +5 -1
- package/src/Help.test.ts +184 -20
- package/src/Help.ts +52 -28
- package/src/Mcp.test.ts +159 -0
- package/src/Mcp.ts +108 -86
- package/src/Openapi.test.ts +17 -5
- package/src/Openapi.ts +21 -15
- package/src/Parser.test-d.ts +22 -0
- package/src/Parser.test.ts +89 -0
- package/src/Parser.ts +87 -36
- package/src/Schema.test.ts +29 -0
- package/src/Schema.ts +12 -2
- package/src/Skill.test.ts +87 -6
- package/src/Skill.ts +38 -21
- package/src/Skillgen.ts +1 -1
- package/src/SyncMcp.test.ts +6 -8
- package/src/SyncSkills.test.ts +146 -3
- package/src/SyncSkills.ts +191 -10
- package/src/Typegen.test.ts +15 -0
- package/src/Typegen.ts +4 -2
- package/src/bin.ts +21 -2
- package/src/e2e.test.ts +188 -98
- package/src/internal/command.ts +449 -0
- package/src/internal/configSchema.test.ts +193 -0
- package/src/internal/configSchema.ts +66 -0
- package/src/internal/dereference.test.ts +695 -0
- package/src/internal/dereference.ts +75 -0
- package/src/internal/helpers.test.ts +75 -0
- package/src/internal/helpers.ts +59 -0
- package/src/middleware.ts +5 -12
package/src/Mcp.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { McpServer } from '@modelcontextprotocol/
|
|
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,
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
for await (const chunk of
|
|
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 &&
|
|
98
|
-
await
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
119
|
-
} catch (err) {
|
|
137
|
+
if (!result.ok)
|
|
120
138
|
return {
|
|
121
|
-
content: [{ type: 'text', text:
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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(
|
|
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)
|
|
151
|
-
|
|
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
|
-
}
|
package/src/Openapi.test.ts
CHANGED
|
@@ -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('--
|
|
145
|
+
test('--full-output wraps in envelope', async () => {
|
|
134
146
|
const { output } = await serve(createCli(), [
|
|
135
147
|
'api',
|
|
136
148
|
'healthCheck',
|
|
137
|
-
'--
|
|
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('--
|
|
257
|
+
test('--full-output wraps in envelope', async () => {
|
|
246
258
|
const { output } = await serve(createCli(), [
|
|
247
259
|
'api',
|
|
248
260
|
'healthCheck',
|
|
249
|
-
'--
|
|
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 =
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/Parser.test-d.ts
CHANGED
|
@@ -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
|
+
})
|
package/src/Parser.test.ts
CHANGED
|
@@ -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
|
})
|