incur 0.3.4 → 0.3.6

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 (68) hide show
  1. package/README.md +62 -1
  2. package/dist/Cli.d.ts +17 -7
  3. package/dist/Cli.d.ts.map +1 -1
  4. package/dist/Cli.js +435 -365
  5. package/dist/Cli.js.map +1 -1
  6. package/dist/Completions.d.ts +1 -2
  7. package/dist/Completions.d.ts.map +1 -1
  8. package/dist/Completions.js.map +1 -1
  9. package/dist/Filter.js +0 -18
  10. package/dist/Filter.js.map +1 -1
  11. package/dist/Help.d.ts +6 -0
  12. package/dist/Help.d.ts.map +1 -1
  13. package/dist/Help.js +35 -22
  14. package/dist/Help.js.map +1 -1
  15. package/dist/Mcp.d.ts +25 -5
  16. package/dist/Mcp.d.ts.map +1 -1
  17. package/dist/Mcp.js +61 -69
  18. package/dist/Mcp.js.map +1 -1
  19. package/dist/Parser.d.ts +2 -0
  20. package/dist/Parser.d.ts.map +1 -1
  21. package/dist/Parser.js +69 -37
  22. package/dist/Parser.js.map +1 -1
  23. package/dist/Skill.d.ts.map +1 -1
  24. package/dist/Skill.js +5 -1
  25. package/dist/Skill.js.map +1 -1
  26. package/dist/SyncSkills.d.ts.map +1 -1
  27. package/dist/SyncSkills.js +10 -1
  28. package/dist/SyncSkills.js.map +1 -1
  29. package/dist/bin.d.ts +1 -0
  30. package/dist/bin.d.ts.map +1 -1
  31. package/dist/bin.js +17 -2
  32. package/dist/bin.js.map +1 -1
  33. package/dist/internal/command.d.ts +118 -0
  34. package/dist/internal/command.d.ts.map +1 -0
  35. package/dist/internal/command.js +276 -0
  36. package/dist/internal/command.js.map +1 -0
  37. package/dist/internal/configSchema.d.ts +8 -0
  38. package/dist/internal/configSchema.d.ts.map +1 -0
  39. package/dist/internal/configSchema.js +57 -0
  40. package/dist/internal/configSchema.js.map +1 -0
  41. package/dist/internal/helpers.d.ts +5 -0
  42. package/dist/internal/helpers.d.ts.map +1 -0
  43. package/dist/internal/helpers.js +9 -0
  44. package/dist/internal/helpers.js.map +1 -0
  45. package/examples/npm/.npmrc.json +21 -0
  46. package/examples/npm/config.schema.json +137 -0
  47. package/package.json +1 -1
  48. package/src/Cli.test-d.ts +39 -0
  49. package/src/Cli.test.ts +704 -6
  50. package/src/Cli.ts +551 -448
  51. package/src/Completions.test.ts +35 -9
  52. package/src/Completions.ts +1 -2
  53. package/src/Filter.ts +0 -17
  54. package/src/Help.test.ts +77 -0
  55. package/src/Help.ts +39 -21
  56. package/src/Mcp.test.ts +143 -0
  57. package/src/Mcp.ts +92 -84
  58. package/src/Parser.test-d.ts +22 -0
  59. package/src/Parser.test.ts +89 -0
  60. package/src/Parser.ts +86 -35
  61. package/src/Skill.ts +5 -1
  62. package/src/SyncSkills.ts +11 -1
  63. package/src/bin.ts +21 -2
  64. package/src/e2e.test.ts +30 -17
  65. package/src/internal/command.ts +428 -0
  66. package/src/internal/configSchema.test.ts +193 -0
  67. package/src/internal/configSchema.ts +66 -0
  68. package/src/internal/helpers.ts +9 -0
package/src/e2e.test.ts CHANGED
@@ -963,7 +963,7 @@ describe('help', () => {
963
963
  stream-throw Stream that throws
964
964
  validate-fail Fails validation
965
965
 
966
- Built-in Commands:
966
+ Integrations:
967
967
  completions Generate shell completion script
968
968
  mcp add Register as MCP server
969
969
  skills add Sync skill files to agents
@@ -1738,7 +1738,7 @@ describe('root command with subcommands', () => {
1738
1738
  info Show info
1739
1739
  version Show version
1740
1740
 
1741
- Built-in Commands:
1741
+ Integrations:
1742
1742
  completions Generate shell completion script
1743
1743
  mcp add Register as MCP server
1744
1744
  skills add Sync skill files to agents
@@ -2204,7 +2204,7 @@ describe('fetch gateway', () => {
2204
2204
  })
2205
2205
 
2206
2206
  test('query params from --key value', async () => {
2207
- const { output } = await serve(createApp(), ['api', 'users', '--limit', '5'])
2207
+ await serve(createApp(), ['api', 'users', '--limit', '5'])
2208
2208
  const { output: jsonOut } = await serve(createApp(), [
2209
2209
  'api',
2210
2210
  'users',
@@ -2441,6 +2441,18 @@ describe('fetch api', () => {
2441
2441
  },
2442
2442
  "meta": {
2443
2443
  "command": "project create",
2444
+ "cta": {
2445
+ "commands": [
2446
+ {
2447
+ "command": "app project get p-new",
2448
+ "description": "View "MyProject"",
2449
+ },
2450
+ {
2451
+ "command": "app project list",
2452
+ },
2453
+ ],
2454
+ "description": "Suggested commands:",
2455
+ },
2444
2456
  "duration": "<stripped>",
2445
2457
  },
2446
2458
  "ok": true,
@@ -2524,21 +2536,22 @@ describe('fetch api', () => {
2524
2536
  const cli = createApp()
2525
2537
  expect(await fetchJson(cli, new Request('http://localhost/explode-clac')))
2526
2538
  .toMatchInlineSnapshot(`
2527
- {
2528
- "body": {
2529
- "error": {
2530
- "code": "QUOTA_EXCEEDED",
2531
- "message": "Rate limit exceeded",
2532
- },
2533
- "meta": {
2534
- "command": "explode-clac",
2535
- "duration": "<stripped>",
2539
+ {
2540
+ "body": {
2541
+ "error": {
2542
+ "code": "QUOTA_EXCEEDED",
2543
+ "message": "Rate limit exceeded",
2544
+ "retryable": true,
2545
+ },
2546
+ "meta": {
2547
+ "command": "explode-clac",
2548
+ "duration": "<stripped>",
2549
+ },
2550
+ "ok": false,
2536
2551
  },
2537
- "ok": false,
2538
- },
2539
- "status": 500,
2540
- }
2541
- `)
2552
+ "status": 500,
2553
+ }
2554
+ `)
2542
2555
  })
2543
2556
 
2544
2557
  test('validation error → 400', async () => {
@@ -0,0 +1,428 @@
1
+ import { z } from 'zod'
2
+
3
+ import type { FieldError } from '../Errors.js'
4
+ import { IncurError, ValidationError } from '../Errors.js'
5
+ import type { Context as MiddlewareContext, Handler as MiddlewareHandler } from '../middleware.js'
6
+ import * as Parser from '../Parser.js'
7
+
8
+ /** @internal Sentinel symbol for `ok()` and `error()` return values. */
9
+ const sentinel = Symbol.for('incur.sentinel')
10
+
11
+ /** @internal CTA block for command output. */
12
+ export type CtaBlock = {
13
+ commands: unknown[]
14
+ description?: string | undefined
15
+ }
16
+
17
+ /** @internal A tagged ok result. */
18
+ type OkResult = {
19
+ [sentinel]: 'ok'
20
+ data: unknown
21
+ cta?: CtaBlock | undefined
22
+ }
23
+
24
+ /** @internal A tagged error result. */
25
+ type ErrorResult = {
26
+ [sentinel]: 'error'
27
+ code: string
28
+ message: string
29
+ retryable?: boolean | undefined
30
+ exitCode?: number | undefined
31
+ cta?: CtaBlock | undefined
32
+ }
33
+
34
+ /** @internal Unified command execution used by CLI, HTTP, and MCP transports. */
35
+ export async function execute(command: any, options: execute.Options): Promise<execute.Result> {
36
+ const {
37
+ argv,
38
+ inputOptions,
39
+ agent,
40
+ format,
41
+ formatExplicit,
42
+ name,
43
+ path,
44
+ version,
45
+ envSource = process.env,
46
+ env: envSchema,
47
+ vars: varsSchema,
48
+ middlewares = [],
49
+ } = options
50
+ const parseMode = options.parseMode ?? 'argv'
51
+
52
+ const varsMap: Record<string, unknown> = varsSchema ? varsSchema.parse({}) : {}
53
+ let result: execute.Result | undefined
54
+ // For streaming with middleware: runCommand suspends on streamConsumed so middleware "after"
55
+ // runs after the stream is consumed. The wrapped generator resolves it in its finally block.
56
+ // resultReady signals that result has been set (for streams, before the chain finishes).
57
+ let streamConsumed: Promise<void> | undefined
58
+ let resolveStreamConsumed: (() => void) | undefined
59
+ let resolveResultReady: (() => void) | undefined
60
+ const resultReady = new Promise<void>((r) => {
61
+ resolveResultReady = r
62
+ })
63
+
64
+ const runCommand = async () => {
65
+ // Parse args and options
66
+ let args: Record<string, unknown>
67
+ let parsedOptions: Record<string, unknown>
68
+
69
+ if (parseMode === 'argv') {
70
+ // CLI mode: parse both args and options from argv tokens
71
+ const parsed = Parser.parse(argv, {
72
+ alias: command.alias as Record<string, string> | undefined,
73
+ args: command.args,
74
+ defaults: options.defaults,
75
+ options: command.options,
76
+ })
77
+ args = parsed.args
78
+ parsedOptions = parsed.options
79
+ } else if (parseMode === 'split') {
80
+ // HTTP mode: positional args from URL path segments, options from body/query
81
+ const parsed = Parser.parse(argv, { args: command.args })
82
+ args = parsed.args
83
+ parsedOptions = command.options ? command.options.parse(inputOptions) : {}
84
+ } else {
85
+ // MCP mode: all params come from inputOptions, split into args vs options
86
+ const split = splitParams(inputOptions, command)
87
+ args = command.args ? command.args.parse(split.args) : {}
88
+ parsedOptions = command.options ? command.options.parse(split.options) : {}
89
+ }
90
+
91
+ // Parse env
92
+ const commandEnv = command.env ? Parser.parseEnv(command.env, envSource) : {}
93
+
94
+ // Build sentinel helpers
95
+ const okFn = (data: unknown, meta: { cta?: CtaBlock | undefined } = {}): never =>
96
+ ({ [sentinel]: 'ok', data, cta: meta.cta }) as never
97
+ const errorFn = (opts: {
98
+ code: string
99
+ cta?: CtaBlock | undefined
100
+ exitCode?: number | undefined
101
+ message: string
102
+ retryable?: boolean | undefined
103
+ }): never => ({ [sentinel]: 'error', ...opts }) as never
104
+
105
+ const raw = command.run({
106
+ agent,
107
+ args,
108
+ env: commandEnv,
109
+ error: errorFn,
110
+ format,
111
+ formatExplicit,
112
+ name,
113
+ ok: okFn,
114
+ options: parsedOptions,
115
+ var: varsMap,
116
+ version,
117
+ })
118
+
119
+ // Streaming: wrap the generator so middleware "after" runs after consumption.
120
+ // When middleware is active, runCommand suspends until the stream is fully consumed,
121
+ // keeping the middleware chain alive around the stream's lifetime.
122
+ if (isAsyncGenerator(raw)) {
123
+ if (middlewares.length > 0) {
124
+ streamConsumed = new Promise<void>((r) => {
125
+ resolveStreamConsumed = r
126
+ })
127
+ async function* wrapped() {
128
+ try {
129
+ yield* raw as AsyncGenerator<unknown, unknown, unknown>
130
+ } finally {
131
+ resolveStreamConsumed!()
132
+ }
133
+ }
134
+ result = { stream: wrapped() }
135
+ resolveResultReady!()
136
+ await streamConsumed
137
+ } else {
138
+ result = { stream: raw }
139
+ }
140
+ return
141
+ }
142
+
143
+ const awaited = await raw
144
+
145
+ if (isSentinel(awaited)) {
146
+ if (awaited[sentinel] === 'ok') {
147
+ const ok = awaited as OkResult
148
+ result = { ok: true, data: ok.data, ...(ok.cta ? { cta: ok.cta } : undefined) }
149
+ } else {
150
+ const err = awaited as ErrorResult
151
+ result = {
152
+ ok: false,
153
+ error: {
154
+ code: err.code,
155
+ message: err.message,
156
+ ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined),
157
+ },
158
+ ...(err.cta ? { cta: err.cta } : undefined),
159
+ ...(err.exitCode !== undefined ? { exitCode: err.exitCode } : undefined),
160
+ }
161
+ }
162
+ return
163
+ }
164
+
165
+ result = { ok: true, data: awaited }
166
+ }
167
+
168
+ try {
169
+ // Parse CLI-level env
170
+ const cliEnv = envSchema ? Parser.parseEnv(envSchema, envSource) : {}
171
+
172
+ if (middlewares.length > 0) {
173
+ const errorFn = (opts: {
174
+ code: string
175
+ cta?: CtaBlock | undefined
176
+ exitCode?: number | undefined
177
+ message: string
178
+ retryable?: boolean | undefined
179
+ }): never => {
180
+ // Side-effect: set result directly (handles both `return c.error()` and bare `c.error()`)
181
+ result = {
182
+ ok: false,
183
+ error: {
184
+ code: opts.code,
185
+ message: opts.message,
186
+ ...(opts.retryable !== undefined ? { retryable: opts.retryable } : undefined),
187
+ },
188
+ ...(opts.cta ? { cta: opts.cta } : undefined),
189
+ ...(opts.exitCode !== undefined ? { exitCode: opts.exitCode } : undefined),
190
+ }
191
+ return undefined as never
192
+ }
193
+
194
+ const mwCtx: MiddlewareContext = {
195
+ agent,
196
+ command: path,
197
+ env: cliEnv,
198
+ error: errorFn,
199
+ format: format as any,
200
+ formatExplicit,
201
+ name,
202
+ set(key: string, value: unknown) {
203
+ varsMap[key] = value
204
+ },
205
+ var: varsMap,
206
+ version,
207
+ }
208
+
209
+ const composed = middlewares.reduceRight(
210
+ (next: () => Promise<void>, mw) => async () => {
211
+ await mw(mwCtx, next)
212
+ },
213
+ runCommand,
214
+ )
215
+ // Start the chain and race against resultReady. For streams with middleware,
216
+ // runCommand suspends on streamConsumed (keeping middleware "after" deferred)
217
+ // but signals resultReady so we can return the stream immediately. The transport
218
+ // consumes the stream, which resolves streamConsumed, letting middleware "after" run.
219
+ const chainPromise = composed()
220
+ await Promise.race([chainPromise, resultReady])
221
+ if (streamConsumed) return result!
222
+ await chainPromise
223
+ } else {
224
+ await runCommand()
225
+ }
226
+ } catch (error) {
227
+ if (error instanceof ValidationError)
228
+ return {
229
+ ok: false,
230
+ error: {
231
+ code: 'VALIDATION_ERROR',
232
+ message: error.message,
233
+ fieldErrors: error.fieldErrors,
234
+ },
235
+ }
236
+ return {
237
+ ok: false,
238
+ error: {
239
+ code: error instanceof IncurError ? error.code : 'UNKNOWN',
240
+ message: error instanceof Error ? error.message : String(error),
241
+ ...(error instanceof IncurError ? { retryable: error.retryable } : undefined),
242
+ },
243
+ ...(error instanceof IncurError && error.exitCode !== undefined
244
+ ? { exitCode: error.exitCode }
245
+ : undefined),
246
+ }
247
+ }
248
+
249
+ return result ?? { ok: true, data: undefined }
250
+ }
251
+
252
+ /** @internal Splits flat params into args vs options using schema shapes. */
253
+ function splitParams(
254
+ params: Record<string, unknown>,
255
+ command: any,
256
+ ): { args: Record<string, unknown>; options: Record<string, unknown> } {
257
+ const argKeys = new Set(command.args ? Object.keys(command.args.shape) : [])
258
+ const a: Record<string, unknown> = {}
259
+ const o: Record<string, unknown> = {}
260
+ for (const [key, value] of Object.entries(params))
261
+ if (argKeys.has(key)) a[key] = value
262
+ else o[key] = value
263
+ return { args: a, options: o }
264
+ }
265
+
266
+ export declare namespace execute {
267
+ /** Options for the unified execute function. */
268
+ type Options = {
269
+ /** Whether the consumer is an agent. */
270
+ agent: boolean
271
+ /** Raw positional tokens (already separated from flags). For HTTP/MCP, pass `[]`. */
272
+ argv: string[]
273
+ /** Default option values from config file. */
274
+ defaults?: Record<string, unknown> | undefined
275
+ /** CLI-level env schema. */
276
+ env?: z.ZodObject<any> | undefined
277
+ /** Source for environment variables. Defaults to `process.env`. */
278
+ envSource?: Record<string, string | undefined> | undefined
279
+ /** The resolved output format. */
280
+ format: string
281
+ /** Whether the format was explicitly requested. */
282
+ formatExplicit: boolean
283
+ /** Raw parsed options (from query params, JSON body, or MCP params). For CLI, pass `{}`. */
284
+ inputOptions: Record<string, unknown>
285
+ /** Middleware handlers (root + group + command, already collected). */
286
+ middlewares?: MiddlewareHandler[] | undefined
287
+ /** The CLI name. */
288
+ name: string
289
+ /**
290
+ * How to parse input:
291
+ * - `'argv'` (default): parse both args and options from argv tokens (CLI mode)
292
+ * - `'split'`: args from argv, options from inputOptions (HTTP mode)
293
+ * - `'flat'`: all params from inputOptions, split by schema shapes (MCP mode)
294
+ */
295
+ parseMode?: 'argv' | 'split' | 'flat' | undefined
296
+ /** The resolved command path. */
297
+ path: string
298
+ /** Vars schema for middleware variables. */
299
+ vars?: z.ZodObject<any> | undefined
300
+ /** CLI version string. */
301
+ version: string | undefined
302
+ }
303
+
304
+ /** Result of executing a command. */
305
+ type Result =
306
+ | { ok: true; data: unknown; cta?: CtaBlock | undefined }
307
+ | {
308
+ ok: false
309
+ error: {
310
+ code: string
311
+ message: string
312
+ retryable?: boolean | undefined
313
+ fieldErrors?: FieldError[] | undefined
314
+ }
315
+ cta?: CtaBlock | undefined
316
+ exitCode?: number | undefined
317
+ }
318
+ | { stream: AsyncGenerator<unknown, unknown, unknown> }
319
+ }
320
+
321
+ /** @internal Type guard for sentinel results. */
322
+ function isSentinel(value: unknown): value is OkResult | ErrorResult {
323
+ return typeof value === 'object' && value !== null && sentinel in value
324
+ }
325
+
326
+ /** @internal Type guard for async generators. */
327
+ function isAsyncGenerator(value: unknown): value is AsyncGenerator<unknown, unknown, unknown> {
328
+ return (
329
+ typeof value === 'object' &&
330
+ value !== null &&
331
+ Symbol.asyncIterator in value &&
332
+ typeof (value as any).next === 'function'
333
+ )
334
+ }
335
+
336
+ /** Common metadata shared by command definitions and built-in commands. */
337
+ export type CommandMeta<options extends z.ZodObject<any> | undefined = undefined> = {
338
+ /** Map of option names to single-char aliases. */
339
+ alias?: options extends z.ZodObject<any>
340
+ ? Partial<Record<keyof z.output<options>, string>>
341
+ : Record<string, string> | undefined
342
+ /** A short description of what the command does. */
343
+ description?: string | undefined
344
+ /** Zod schema for named options/flags. */
345
+ options?: options | undefined
346
+ }
347
+
348
+ /** @internal Creates a builtin subcommand with typesafe alias inference. */
349
+ function subcommand<const options extends z.ZodObject<any> | undefined = undefined>(
350
+ def: CommandMeta<options> & { name: string },
351
+ ) {
352
+ return def
353
+ }
354
+
355
+ /** Supported shell names for completions. */
356
+ export const shells = ['bash', 'fish', 'nushell', 'zsh'] as const
357
+
358
+ /** A supported shell name. */
359
+ export type Shell = (typeof shells)[number]
360
+
361
+ /** Built-in command metadata shared by help, completions, and handler logic. */
362
+ export const builtinCommands = [
363
+ {
364
+ name: 'completions',
365
+ description: 'Generate shell completion script',
366
+ args: z.object({
367
+ shell: z.enum(shells).describe('Shell to generate completions for'),
368
+ }),
369
+ hint(name) {
370
+ const rows = [
371
+ ['bash', `eval "$(${name} completions bash)"`, '# add to ~/.bashrc'],
372
+ ['fish', `${name} completions fish | source`, '# add to ~/.config/fish/config.fish'],
373
+ ['nushell', `see \`${name} completions nushell\``, '# add to config.nu'],
374
+ ['zsh', `eval "$(${name} completions zsh)"`, '# add to ~/.zshrc'],
375
+ ] as const
376
+ const shellW = Math.max(...rows.map((r) => r[0].length))
377
+ const cmdW = Math.max(...rows.map((r) => r[1].length))
378
+ return (
379
+ 'Setup:\n' +
380
+ rows
381
+ .map(([s, cmd, comment]) => ` ${s.padEnd(shellW)} ${cmd.padEnd(cmdW)} ${comment}`)
382
+ .join('\n')
383
+ )
384
+ },
385
+ },
386
+ {
387
+ name: 'mcp',
388
+ description: 'Register as MCP server',
389
+ subcommands: [
390
+ subcommand({
391
+ name: 'add',
392
+ description: 'Register as MCP server',
393
+ alias: { command: 'c' },
394
+ options: z.object({
395
+ agent: z
396
+ .string()
397
+ .optional()
398
+ .describe('Target a specific agent (e.g. claude-code, cursor)'),
399
+ command: z
400
+ .string()
401
+ .optional()
402
+ .describe('Override the command agents will run (e.g. "pnpm my-cli --mcp")'),
403
+ noGlobal: z.boolean().optional().describe('Install to project instead of globally'),
404
+ }),
405
+ }),
406
+ ],
407
+ },
408
+ {
409
+ name: 'skills',
410
+ description: 'Sync skill files to agents',
411
+ subcommands: [
412
+ subcommand({
413
+ name: 'add',
414
+ description: 'Sync skill files to agents',
415
+ options: z.object({
416
+ depth: z.number().optional().describe('Grouping depth for skill files (default: 1)'),
417
+ noGlobal: z.boolean().optional().describe('Install to project instead of globally'),
418
+ }),
419
+ }),
420
+ ],
421
+ },
422
+ ] satisfies {
423
+ name: string
424
+ args?: z.ZodObject<any> | undefined
425
+ description: string
426
+ hint?: ((name: string) => string) | undefined
427
+ subcommands?: (CommandMeta<z.ZodObject<any>> & { name: string })[] | undefined
428
+ }[]
@@ -0,0 +1,193 @@
1
+ import { Cli, z } from 'incur'
2
+
3
+ import * as ConfigSchema from './configSchema.js'
4
+
5
+ describe('fromCli', () => {
6
+ test('generates schema for root options and leaf commands', () => {
7
+ const cli = Cli.create('test', {
8
+ options: z.object({
9
+ verbose: z.boolean().default(false),
10
+ }),
11
+ })
12
+ cli.command('echo', {
13
+ options: z.object({
14
+ prefix: z.string().default(''),
15
+ upper: z.boolean().default(false),
16
+ }),
17
+ run: (c) => c.options,
18
+ })
19
+
20
+ const schema = ConfigSchema.fromCli(cli)
21
+ expect(schema).toMatchInlineSnapshot(`
22
+ {
23
+ "additionalProperties": false,
24
+ "properties": {
25
+ "$schema": {
26
+ "type": "string",
27
+ },
28
+ "commands": {
29
+ "additionalProperties": false,
30
+ "properties": {
31
+ "echo": {
32
+ "additionalProperties": false,
33
+ "properties": {
34
+ "options": {
35
+ "additionalProperties": false,
36
+ "properties": {
37
+ "prefix": {
38
+ "default": "",
39
+ "type": "string",
40
+ },
41
+ "upper": {
42
+ "default": false,
43
+ "type": "boolean",
44
+ },
45
+ },
46
+ "type": "object",
47
+ },
48
+ },
49
+ "type": "object",
50
+ },
51
+ },
52
+ "type": "object",
53
+ },
54
+ "options": {
55
+ "additionalProperties": false,
56
+ "properties": {
57
+ "verbose": {
58
+ "default": false,
59
+ "type": "boolean",
60
+ },
61
+ },
62
+ "type": "object",
63
+ },
64
+ },
65
+ "type": "object",
66
+ }
67
+ `)
68
+ })
69
+
70
+ test('generates schema for nested command groups', () => {
71
+ const project = Cli.create('project')
72
+ project.command('list', {
73
+ options: z.object({
74
+ limit: z.number().default(10),
75
+ label: z.array(z.string()).default([]),
76
+ }),
77
+ run: (c) => c.options,
78
+ })
79
+
80
+ const cli = Cli.create('test')
81
+ cli.command(project)
82
+
83
+ const schema = ConfigSchema.fromCli(cli)
84
+ expect(schema).toMatchInlineSnapshot(`
85
+ {
86
+ "additionalProperties": false,
87
+ "properties": {
88
+ "$schema": {
89
+ "type": "string",
90
+ },
91
+ "commands": {
92
+ "additionalProperties": false,
93
+ "properties": {
94
+ "project": {
95
+ "additionalProperties": false,
96
+ "properties": {
97
+ "commands": {
98
+ "additionalProperties": false,
99
+ "properties": {
100
+ "list": {
101
+ "additionalProperties": false,
102
+ "properties": {
103
+ "options": {
104
+ "additionalProperties": false,
105
+ "properties": {
106
+ "label": {
107
+ "default": [],
108
+ "items": {
109
+ "type": "string",
110
+ },
111
+ "type": "array",
112
+ },
113
+ "limit": {
114
+ "default": 10,
115
+ "type": "number",
116
+ },
117
+ },
118
+ "type": "object",
119
+ },
120
+ },
121
+ "type": "object",
122
+ },
123
+ },
124
+ "type": "object",
125
+ },
126
+ },
127
+ "type": "object",
128
+ },
129
+ },
130
+ "type": "object",
131
+ },
132
+ },
133
+ "type": "object",
134
+ }
135
+ `)
136
+ })
137
+
138
+ test('returns schema with only $schema for cli with no commands', () => {
139
+ const cli = Cli.create('test')
140
+ const schema = ConfigSchema.fromCli(cli)
141
+ expect(schema).toEqual({
142
+ type: 'object',
143
+ additionalProperties: false,
144
+ properties: { $schema: { type: 'string' } },
145
+ })
146
+ })
147
+
148
+ test('skips fetch gateway commands', () => {
149
+ const cli = Cli.create('test')
150
+ cli.command('echo', {
151
+ options: z.object({ prefix: z.string().default('') }),
152
+ run: (c) => c.options,
153
+ })
154
+ cli.command('api', {
155
+ description: 'API gateway',
156
+ fetch: () => new Response('ok'),
157
+ })
158
+
159
+ const schema = ConfigSchema.fromCli(cli)
160
+ const commandKeys = Object.keys((schema as any).properties.commands.properties)
161
+ expect(commandKeys).toEqual(['echo'])
162
+ })
163
+
164
+ test('includes commands without options as empty objects', () => {
165
+ const cli = Cli.create('test')
166
+ cli.command('ping', {
167
+ run: () => 'pong',
168
+ })
169
+
170
+ const schema = ConfigSchema.fromCli(cli)
171
+ expect(schema).toMatchInlineSnapshot(`
172
+ {
173
+ "additionalProperties": false,
174
+ "properties": {
175
+ "$schema": {
176
+ "type": "string",
177
+ },
178
+ "commands": {
179
+ "additionalProperties": false,
180
+ "properties": {
181
+ "ping": {
182
+ "additionalProperties": false,
183
+ "type": "object",
184
+ },
185
+ },
186
+ "type": "object",
187
+ },
188
+ },
189
+ "type": "object",
190
+ }
191
+ `)
192
+ })
193
+ })