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
@@ -0,0 +1,449 @@
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 displayName = options.displayName ?? name
51
+ const parseMode = options.parseMode ?? 'argv'
52
+
53
+ const varsMap: Record<string, unknown> = varsSchema ? varsSchema.parse({}) : {}
54
+ let result: execute.Result | undefined
55
+ // For streaming with middleware: runCommand suspends on streamConsumed so middleware "after"
56
+ // runs after the stream is consumed. The wrapped generator resolves it in its finally block.
57
+ // resultReady signals that result has been set (for streams, before the chain finishes).
58
+ let streamConsumed: Promise<void> | undefined
59
+ let resolveStreamConsumed: (() => void) | undefined
60
+ let resolveResultReady: (() => void) | undefined
61
+ const resultReady = new Promise<void>((r) => {
62
+ resolveResultReady = r
63
+ })
64
+
65
+ const runCommand = async () => {
66
+ // Parse args and options
67
+ let args: Record<string, unknown>
68
+ let parsedOptions: Record<string, unknown>
69
+
70
+ if (parseMode === 'argv') {
71
+ // CLI mode: parse both args and options from argv tokens
72
+ const parsed = Parser.parse(argv, {
73
+ alias: command.alias as Record<string, string> | undefined,
74
+ args: command.args,
75
+ defaults: options.defaults,
76
+ options: command.options,
77
+ })
78
+ args = parsed.args
79
+ parsedOptions = parsed.options
80
+ } else if (parseMode === 'split') {
81
+ // HTTP mode: positional args from URL path segments, options from body/query
82
+ const parsed = Parser.parse(argv, { args: command.args })
83
+ args = parsed.args
84
+ parsedOptions = command.options ? command.options.parse(inputOptions) : {}
85
+ } else {
86
+ // MCP mode: all params come from inputOptions, split into args vs options
87
+ const split = splitParams(inputOptions, command)
88
+ args = command.args ? command.args.parse(split.args) : {}
89
+ parsedOptions = command.options ? command.options.parse(split.options) : {}
90
+ }
91
+
92
+ // Parse env
93
+ const commandEnv = command.env ? Parser.parseEnv(command.env, envSource) : {}
94
+
95
+ // Build sentinel helpers
96
+ const okFn = (data: unknown, meta: { cta?: CtaBlock | undefined } = {}): never =>
97
+ ({ [sentinel]: 'ok', data, cta: meta.cta }) as never
98
+ const errorFn = (opts: {
99
+ code: string
100
+ cta?: CtaBlock | undefined
101
+ exitCode?: number | undefined
102
+ message: string
103
+ retryable?: boolean | undefined
104
+ }): never => ({ [sentinel]: 'error', ...opts }) as never
105
+
106
+ const raw = command.run({
107
+ agent,
108
+ args,
109
+ displayName,
110
+ env: commandEnv,
111
+ error: errorFn,
112
+ format,
113
+ formatExplicit,
114
+ name,
115
+ ok: okFn,
116
+ options: parsedOptions,
117
+ var: varsMap,
118
+ version,
119
+ })
120
+
121
+ // Streaming: wrap the generator so middleware "after" runs after consumption.
122
+ // When middleware is active, runCommand suspends until the stream is fully consumed,
123
+ // keeping the middleware chain alive around the stream's lifetime.
124
+ if (isAsyncGenerator(raw)) {
125
+ if (middlewares.length > 0) {
126
+ streamConsumed = new Promise<void>((r) => {
127
+ resolveStreamConsumed = r
128
+ })
129
+ async function* wrapped() {
130
+ try {
131
+ yield* raw as AsyncGenerator<unknown, unknown, unknown>
132
+ } finally {
133
+ resolveStreamConsumed!()
134
+ }
135
+ }
136
+ result = { stream: wrapped() }
137
+ resolveResultReady!()
138
+ await streamConsumed
139
+ } else {
140
+ result = { stream: raw }
141
+ }
142
+ return
143
+ }
144
+
145
+ const awaited = await raw
146
+
147
+ if (isSentinel(awaited)) {
148
+ if (awaited[sentinel] === 'ok') {
149
+ const ok = awaited as OkResult
150
+ result = { ok: true, data: ok.data, ...(ok.cta ? { cta: ok.cta } : undefined) }
151
+ } else {
152
+ const err = awaited as ErrorResult
153
+ result = {
154
+ ok: false,
155
+ error: {
156
+ code: err.code,
157
+ message: err.message,
158
+ ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined),
159
+ },
160
+ ...(err.cta ? { cta: err.cta } : undefined),
161
+ ...(err.exitCode !== undefined ? { exitCode: err.exitCode } : undefined),
162
+ }
163
+ }
164
+ return
165
+ }
166
+
167
+ result = { ok: true, data: awaited }
168
+ }
169
+
170
+ try {
171
+ // Parse CLI-level env
172
+ const cliEnv = envSchema ? Parser.parseEnv(envSchema, envSource) : {}
173
+
174
+ if (middlewares.length > 0) {
175
+ const errorFn = (opts: {
176
+ code: string
177
+ cta?: CtaBlock | undefined
178
+ exitCode?: number | undefined
179
+ message: string
180
+ retryable?: boolean | undefined
181
+ }): never => {
182
+ // Side-effect: set result directly (handles both `return c.error()` and bare `c.error()`)
183
+ result = {
184
+ ok: false,
185
+ error: {
186
+ code: opts.code,
187
+ message: opts.message,
188
+ ...(opts.retryable !== undefined ? { retryable: opts.retryable } : undefined),
189
+ },
190
+ ...(opts.cta ? { cta: opts.cta } : undefined),
191
+ ...(opts.exitCode !== undefined ? { exitCode: opts.exitCode } : undefined),
192
+ }
193
+ return undefined as never
194
+ }
195
+
196
+ const mwCtx: MiddlewareContext = {
197
+ agent,
198
+ command: path,
199
+ displayName,
200
+ env: cliEnv,
201
+ error: errorFn,
202
+ format: format as any,
203
+ formatExplicit,
204
+ name,
205
+ set(key: string, value: unknown) {
206
+ varsMap[key] = value
207
+ },
208
+ var: varsMap,
209
+ version,
210
+ }
211
+
212
+ const composed = middlewares.reduceRight(
213
+ (next: () => Promise<void>, mw) => async () => {
214
+ await mw(mwCtx, next)
215
+ },
216
+ runCommand,
217
+ )
218
+ // Start the chain and race against resultReady. For streams with middleware,
219
+ // runCommand suspends on streamConsumed (keeping middleware "after" deferred)
220
+ // but signals resultReady so we can return the stream immediately. The transport
221
+ // consumes the stream, which resolves streamConsumed, letting middleware "after" run.
222
+ const chainPromise = composed()
223
+ await Promise.race([chainPromise, resultReady])
224
+ if (streamConsumed) return result!
225
+ await chainPromise
226
+ } else {
227
+ await runCommand()
228
+ }
229
+ } catch (error) {
230
+ if (error instanceof ValidationError)
231
+ return {
232
+ ok: false,
233
+ error: {
234
+ code: 'VALIDATION_ERROR',
235
+ message: error.message,
236
+ fieldErrors: error.fieldErrors,
237
+ },
238
+ }
239
+ return {
240
+ ok: false,
241
+ error: {
242
+ code: error instanceof IncurError ? error.code : 'UNKNOWN',
243
+ message: error instanceof Error ? error.message : String(error),
244
+ ...(error instanceof IncurError ? { retryable: error.retryable } : undefined),
245
+ },
246
+ ...(error instanceof IncurError && error.exitCode !== undefined
247
+ ? { exitCode: error.exitCode }
248
+ : undefined),
249
+ }
250
+ }
251
+
252
+ return result ?? { ok: true, data: undefined }
253
+ }
254
+
255
+ /** @internal Splits flat params into args vs options using schema shapes. */
256
+ function splitParams(
257
+ params: Record<string, unknown>,
258
+ command: any,
259
+ ): { args: Record<string, unknown>; options: Record<string, unknown> } {
260
+ const argKeys = new Set(command.args ? Object.keys(command.args.shape) : [])
261
+ const a: Record<string, unknown> = {}
262
+ const o: Record<string, unknown> = {}
263
+ for (const [key, value] of Object.entries(params))
264
+ if (argKeys.has(key)) a[key] = value
265
+ else o[key] = value
266
+ return { args: a, options: o }
267
+ }
268
+
269
+ export declare namespace execute {
270
+ /** Options for the unified execute function. */
271
+ type Options = {
272
+ /** Whether the consumer is an agent. */
273
+ agent: boolean
274
+ /** Raw positional tokens (already separated from flags). For HTTP/MCP, pass `[]`. */
275
+ argv: string[]
276
+ /** Default option values from config file. */
277
+ defaults?: Record<string, unknown> | undefined
278
+ /** The resolved binary name the user invoked (e.g. an alias). Falls back to `name`. */
279
+ displayName?: string | undefined
280
+ /** CLI-level env schema. */
281
+ env?: z.ZodObject<any> | undefined
282
+ /** Source for environment variables. Defaults to `process.env`. */
283
+ envSource?: Record<string, string | undefined> | undefined
284
+ /** The resolved output format. */
285
+ format: string
286
+ /** Whether the format was explicitly requested. */
287
+ formatExplicit: boolean
288
+ /** Raw parsed options (from query params, JSON body, or MCP params). For CLI, pass `{}`. */
289
+ inputOptions: Record<string, unknown>
290
+ /** Middleware handlers (root + group + command, already collected). */
291
+ middlewares?: MiddlewareHandler[] | undefined
292
+ /** The CLI name. */
293
+ name: string
294
+ /**
295
+ * How to parse input:
296
+ * - `'argv'` (default): parse both args and options from argv tokens (CLI mode)
297
+ * - `'split'`: args from argv, options from inputOptions (HTTP mode)
298
+ * - `'flat'`: all params from inputOptions, split by schema shapes (MCP mode)
299
+ */
300
+ parseMode?: 'argv' | 'split' | 'flat' | undefined
301
+ /** The resolved command path. */
302
+ path: string
303
+ /** Vars schema for middleware variables. */
304
+ vars?: z.ZodObject<any> | undefined
305
+ /** CLI version string. */
306
+ version: string | undefined
307
+ }
308
+
309
+ /** Result of executing a command. */
310
+ type Result =
311
+ | { ok: true; data: unknown; cta?: CtaBlock | undefined }
312
+ | {
313
+ ok: false
314
+ error: {
315
+ code: string
316
+ message: string
317
+ retryable?: boolean | undefined
318
+ fieldErrors?: FieldError[] | undefined
319
+ }
320
+ cta?: CtaBlock | undefined
321
+ exitCode?: number | undefined
322
+ }
323
+ | { stream: AsyncGenerator<unknown, unknown, unknown> }
324
+ }
325
+
326
+ /** @internal Type guard for sentinel results. */
327
+ function isSentinel(value: unknown): value is OkResult | ErrorResult {
328
+ return typeof value === 'object' && value !== null && sentinel in value
329
+ }
330
+
331
+ /** @internal Type guard for async generators. */
332
+ function isAsyncGenerator(value: unknown): value is AsyncGenerator<unknown, unknown, unknown> {
333
+ return (
334
+ typeof value === 'object' &&
335
+ value !== null &&
336
+ Symbol.asyncIterator in value &&
337
+ typeof (value as any).next === 'function'
338
+ )
339
+ }
340
+
341
+ /** Common metadata shared by command definitions and built-in commands. */
342
+ export type CommandMeta<options extends z.ZodObject<any> | undefined = undefined> = {
343
+ /** Map of option names to single-char aliases. */
344
+ alias?: options extends z.ZodObject<any>
345
+ ? Partial<Record<keyof z.output<options>, string>>
346
+ : Record<string, string> | undefined
347
+ /** A short description of what the command does. */
348
+ description?: string | undefined
349
+ /** Zod schema for named options/flags. */
350
+ options?: options | undefined
351
+ }
352
+
353
+ /** @internal Creates a builtin subcommand with typesafe alias inference. */
354
+ function subcommand<const options extends z.ZodObject<any> | undefined = undefined>(
355
+ def: CommandMeta<options> & { name: string },
356
+ ) {
357
+ return def
358
+ }
359
+
360
+ /** Supported shell names for completions. */
361
+ export const shells = ['bash', 'fish', 'nushell', 'zsh'] as const
362
+
363
+ /** A supported shell name. */
364
+ export type Shell = (typeof shells)[number]
365
+
366
+ /** Built-in command metadata shared by help, completions, and handler logic. */
367
+ export const builtinCommands = [
368
+ {
369
+ name: 'completions',
370
+ description: 'Generate shell completion script',
371
+ args: z.object({
372
+ shell: z.enum(shells).describe('Shell to generate completions for'),
373
+ }),
374
+ hint(name) {
375
+ const rows = [
376
+ ['bash', `eval "$(${name} completions bash)"`, '# add to ~/.bashrc'],
377
+ ['fish', `${name} completions fish | source`, '# add to ~/.config/fish/config.fish'],
378
+ ['nushell', `see \`${name} completions nushell\``, '# add to config.nu'],
379
+ ['zsh', `eval "$(${name} completions zsh)"`, '# add to ~/.zshrc'],
380
+ ] as const
381
+ const shellW = Math.max(...rows.map((r) => r[0].length))
382
+ const cmdW = Math.max(...rows.map((r) => r[1].length))
383
+ return (
384
+ 'Setup:\n' +
385
+ rows
386
+ .map(([s, cmd, comment]) => ` ${s.padEnd(shellW)} ${cmd.padEnd(cmdW)} ${comment}`)
387
+ .join('\n')
388
+ )
389
+ },
390
+ },
391
+ {
392
+ name: 'mcp',
393
+ description: 'Register as MCP server',
394
+ subcommands: [
395
+ subcommand({
396
+ name: 'add',
397
+ description: 'Register as MCP server',
398
+ alias: { command: 'c' },
399
+ options: z.object({
400
+ agent: z
401
+ .string()
402
+ .optional()
403
+ .describe('Target a specific agent (e.g. claude-code, cursor)'),
404
+ command: z
405
+ .string()
406
+ .optional()
407
+ .describe('Override the command agents will run (e.g. "pnpm my-cli --mcp")'),
408
+ noGlobal: z.boolean().optional().describe('Install to project instead of globally'),
409
+ }),
410
+ }),
411
+ ],
412
+ },
413
+ {
414
+ name: 'skills',
415
+ aliases: ['skill'],
416
+ description: 'Sync skill files to agents',
417
+ subcommands: [
418
+ subcommand({
419
+ name: 'add',
420
+ description: 'Sync skill files to agents',
421
+ options: z.object({
422
+ depth: z.number().optional().describe('Grouping depth for skill files (default: 1)'),
423
+ noGlobal: z.boolean().optional().describe('Install to project instead of globally'),
424
+ }),
425
+ }),
426
+ subcommand({
427
+ name: 'list',
428
+ description: 'List skills',
429
+ }),
430
+ ],
431
+ },
432
+ ] satisfies {
433
+ name: string
434
+ aliases?: string[] | undefined
435
+ args?: z.ZodObject<any> | undefined
436
+ description: string
437
+ hint?: ((name: string) => string) | undefined
438
+ subcommands?: (CommandMeta<z.ZodObject<any>> & { name: string })[] | undefined
439
+ }[]
440
+
441
+ /** @internal Finds a builtin command by its name or alias. */
442
+ export function findBuiltin(token: string) {
443
+ return builtinCommands.find((b) => b.name === token || b.aliases?.includes(token))
444
+ }
445
+
446
+ /** @internal Checks if a token matches a builtin command by name or alias. */
447
+ export function isBuiltin(token: string) {
448
+ return builtinCommands.some((b) => b.name === token || b.aliases?.includes(token))
449
+ }
@@ -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
+ })
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs/promises'
2
+ import type { z } from 'zod'
3
+
4
+ import * as Cli from '../Cli.js'
5
+ import * as Schema from '../Schema.js'
6
+ import { importCli } from './utils.js'
7
+
8
+ /** Returns `true` if the CLI has `config` enabled on `Cli.create()`. */
9
+ export function hasConfig(cli: Cli.Cli): boolean {
10
+ return Cli.toConfigEnabled.get(cli) === true
11
+ }
12
+
13
+ /** Imports a CLI from `input` (must `export default` a `Cli`), generates the JSON Schema, and writes it to `output`. */
14
+ export async function generate(input: string, output: string): Promise<void> {
15
+ const cli = await importCli(input)
16
+ await fs.writeFile(output, JSON.stringify(fromCli(cli), null, 2) + '\n')
17
+ }
18
+
19
+ /** Generates a JSON Schema describing the config file structure for a CLI. */
20
+ export function fromCli(cli: Cli.Cli): Record<string, unknown> {
21
+ const commands = Cli.toCommands.get(cli)
22
+ if (!commands) return { type: 'object' }
23
+
24
+ const rootOptions = Cli.toRootOptions.get(cli)
25
+ const node = buildNode(commands, rootOptions)
26
+ const properties = (node.properties ?? {}) as Record<string, unknown>
27
+ properties.$schema = { type: 'string' }
28
+ node.properties = properties
29
+ return node
30
+ }
31
+
32
+ /** Builds a JSON Schema node for a command level. */
33
+ function buildNode(
34
+ commands: Map<string, any>,
35
+ options?: z.ZodObject<any>,
36
+ ): Record<string, unknown> {
37
+ const properties: Record<string, unknown> = {}
38
+
39
+ // Add `options` property from the options schema
40
+ if (options) {
41
+ const optSchema = Schema.toJsonSchema(options)
42
+ const props = optSchema.properties as Record<string, unknown> | undefined
43
+ if (props && Object.keys(props).length > 0)
44
+ properties.options = { type: 'object', additionalProperties: false, properties: props }
45
+ }
46
+
47
+ // Add `commands` property with subcommand namespaces
48
+ const commandProps: Record<string, unknown> = {}
49
+ for (const [name, entry] of commands) {
50
+ if ('_group' in entry && entry._group) {
51
+ commandProps[name] = buildNode(entry.commands, undefined)
52
+ } else if (!('_fetch' in entry)) {
53
+ const cmd = entry as { options?: z.ZodObject<any> }
54
+ commandProps[name] = buildNode(new Map(), cmd.options)
55
+ }
56
+ }
57
+ if (Object.keys(commandProps).length > 0)
58
+ properties.commands = { type: 'object', additionalProperties: false, properties: commandProps }
59
+
60
+ const node: Record<string, unknown> = {
61
+ type: 'object',
62
+ additionalProperties: false,
63
+ }
64
+ if (Object.keys(properties).length > 0) node.properties = properties
65
+ return node
66
+ }