incur 0.1.17 → 0.2.1

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 (52) hide show
  1. package/README.md +204 -9
  2. package/SKILL.md +173 -0
  3. package/dist/Cli.d.ts +39 -6
  4. package/dist/Cli.d.ts.map +1 -1
  5. package/dist/Cli.js +536 -43
  6. package/dist/Cli.js.map +1 -1
  7. package/dist/Errors.d.ts +4 -0
  8. package/dist/Errors.d.ts.map +1 -1
  9. package/dist/Errors.js +3 -0
  10. package/dist/Errors.js.map +1 -1
  11. package/dist/Fetch.d.ts +26 -0
  12. package/dist/Fetch.d.ts.map +1 -0
  13. package/dist/Fetch.js +150 -0
  14. package/dist/Fetch.js.map +1 -0
  15. package/dist/Filter.d.ts +14 -0
  16. package/dist/Filter.d.ts.map +1 -0
  17. package/dist/Filter.js +134 -0
  18. package/dist/Filter.js.map +1 -0
  19. package/dist/Help.js +2 -0
  20. package/dist/Help.js.map +1 -1
  21. package/dist/Mcp.d.ts +26 -0
  22. package/dist/Mcp.d.ts.map +1 -1
  23. package/dist/Mcp.js +2 -2
  24. package/dist/Mcp.js.map +1 -1
  25. package/dist/Openapi.d.ts +20 -0
  26. package/dist/Openapi.d.ts.map +1 -0
  27. package/dist/Openapi.js +136 -0
  28. package/dist/Openapi.js.map +1 -0
  29. package/dist/index.d.ts +3 -0
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +3 -0
  32. package/dist/index.js.map +1 -1
  33. package/dist/middleware.d.ts +8 -2
  34. package/dist/middleware.d.ts.map +1 -1
  35. package/dist/middleware.js.map +1 -1
  36. package/package.json +4 -1
  37. package/src/Cli.test-d.ts +27 -2
  38. package/src/Cli.test.ts +1007 -0
  39. package/src/Cli.ts +676 -47
  40. package/src/Errors.ts +5 -0
  41. package/src/Fetch.test.ts +274 -0
  42. package/src/Fetch.ts +170 -0
  43. package/src/Filter.test.ts +237 -0
  44. package/src/Filter.ts +139 -0
  45. package/src/Help.test.ts +14 -0
  46. package/src/Help.ts +2 -0
  47. package/src/Mcp.ts +3 -3
  48. package/src/Openapi.test.ts +320 -0
  49. package/src/Openapi.ts +196 -0
  50. package/src/e2e.test.ts +778 -0
  51. package/src/index.ts +3 -0
  52. package/src/middleware.ts +9 -2
package/src/Cli.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  import type { z } from 'zod'
2
2
 
3
3
  import * as Completions from './Completions.js'
4
+ import * as Filter from './Filter.js'
4
5
  import type { FieldError } from './Errors.js'
5
6
  import { IncurError, ValidationError } from './Errors.js'
7
+ import * as Fetch from './Fetch.js'
8
+ import * as Openapi from './Openapi.js'
6
9
  import * as Formatter from './Formatter.js'
7
10
  import * as Help from './Help.js'
8
11
  import { detectRunner } from './internal/pm.js'
@@ -56,6 +59,11 @@ export type Cli<
56
59
  vars,
57
60
  env
58
61
  >
62
+ /** Mounts a fetch handler as a command, optionally with OpenAPI spec for typed subcommands. */
63
+ <const name extends string>(
64
+ name: name,
65
+ definition: { basePath?: string | undefined; description?: string | undefined; fetch: FetchHandler; openapi?: Openapi.OpenAPISpec | undefined; outputPolicy?: OutputPolicy | undefined },
66
+ ): Cli<commands, vars, env>
59
67
  }
60
68
  /** A short description of the CLI. */
61
69
  description?: string | undefined
@@ -63,6 +71,8 @@ export type Cli<
63
71
  env: env
64
72
  /** The name of the CLI application. */
65
73
  name: string
74
+ /** Handles an incoming HTTP request, resolves the matching command, and returns a JSON Response. */
75
+ fetch(req: Request): Promise<Response>
66
76
  /** Parses argv, runs the matched command, and writes the output envelope to stdout. */
67
77
  serve(argv?: string[], options?: serve.Options): Promise<void>
68
78
  /** Registers middleware that runs around every command. */
@@ -174,9 +184,12 @@ export function create(
174
184
  const name = typeof nameOrDefinition === 'string' ? nameOrDefinition : nameOrDefinition.name
175
185
  const def = typeof nameOrDefinition === 'string' ? (definition ?? {}) : nameOrDefinition
176
186
  const rootDef = 'run' in def ? (def as CommandDefinition<any, any, any>) : undefined
187
+ const rootFetch = 'fetch' in def ? (def.fetch as FetchHandler) : undefined
177
188
 
178
189
  const commands = new Map<string, CommandEntry>()
179
190
  const middlewares: MiddlewareHandler[] = []
191
+ const pending: Promise<void>[] = []
192
+ const mcpHandler = createMcpHttpHandler(name, def.version ?? '0.0.0')
180
193
 
181
194
  const cli: Cli = {
182
195
  name,
@@ -186,6 +199,30 @@ export function create(
186
199
 
187
200
  command(nameOrCli: any, def?: any): any {
188
201
  if (typeof nameOrCli === 'string') {
202
+ if (def && 'fetch' in def && typeof def.fetch === 'function') {
203
+ // OpenAPI + fetch → generate typed command group (async, resolved before serve)
204
+ if (def.openapi) {
205
+ pending.push(
206
+ Openapi.generateCommands(def.openapi, def.fetch, { basePath: def.basePath }).then((generated) => {
207
+ commands.set(nameOrCli, {
208
+ _group: true,
209
+ description: def.description,
210
+ commands: generated as Map<string, CommandEntry>,
211
+ ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined),
212
+ } as InternalGroup)
213
+ }),
214
+ )
215
+ return cli
216
+ }
217
+ commands.set(nameOrCli, {
218
+ _fetch: true,
219
+ basePath: def.basePath,
220
+ description: def.description,
221
+ fetch: def.fetch,
222
+ ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined),
223
+ } as InternalFetchGateway)
224
+ return cli
225
+ }
189
226
  commands.set(nameOrCli, def)
190
227
  return cli
191
228
  }
@@ -208,7 +245,18 @@ export function create(
208
245
  return cli
209
246
  },
210
247
 
248
+ async fetch(req: Request) {
249
+ if (pending.length > 0) await Promise.all(pending)
250
+ return fetchImpl(name, commands, req, {
251
+ mcpHandler,
252
+ middlewares,
253
+ rootCommand: rootDef,
254
+ vars: def.vars,
255
+ })
256
+ },
257
+
211
258
  async serve(argv = process.argv.slice(2), serveOptions: serve.Options = {}) {
259
+ if (pending.length > 0) await Promise.all(pending)
212
260
  return serveImpl(name, commands, argv, {
213
261
  ...serveOptions,
214
262
  aliases: def.aliases,
@@ -219,6 +267,7 @@ export function create(
219
267
  middlewares,
220
268
  outputPolicy: def.outputPolicy,
221
269
  rootCommand: rootDef,
270
+ rootFetch,
222
271
  sync: def.sync,
223
272
  vars: def.vars,
224
273
  version: def.version,
@@ -261,6 +310,8 @@ export declare namespace create {
261
310
  env?: env | undefined
262
311
  /** Usage examples for this command. */
263
312
  examples?: Example<args, options>[] | undefined
313
+ /** A fetch handler to use as the root command. All argv tokens are interpreted as path segments and curl-style flags. */
314
+ fetch?: FetchHandler | undefined
264
315
  /** Default output format. Overridden by `--format` or `--json`. */
265
316
  format?: Formatter.Format | undefined
266
317
  /** Zod schema for named options/flags. */
@@ -287,17 +338,22 @@ export declare namespace create {
287
338
  agent: boolean
288
339
  /** Positional arguments. */
289
340
  args: InferOutput<args>
290
- /** The CLI name. */
291
- name: string
292
341
  /** Parsed environment variables. */
293
342
  env: InferOutput<env>
294
343
  /** Return an error result with optional CTAs. */
295
344
  error: (options: {
296
345
  code: string
297
346
  cta?: CtaBlock | undefined
347
+ exitCode?: number | undefined
298
348
  message: string
299
349
  retryable?: boolean | undefined
300
350
  }) => never
351
+ /** The resolved output format (e.g. `'toon'`, `'json'`, `'jsonl'`). */
352
+ format: Formatter.Format
353
+ /** Whether the user explicitly passed `--format` or `--json`. */
354
+ formatExplicit: boolean
355
+ /** The CLI name. */
356
+ name: string
301
357
  /** Return a success result with optional metadata (e.g. CTAs). */
302
358
  ok: (data: InferReturn<output>, meta?: { cta?: CtaBlock | undefined }) => never
303
359
  options: InferOutput<options>
@@ -362,10 +418,12 @@ async function serveImpl(
362
418
  verbose,
363
419
  format: formatFlag,
364
420
  formatExplicit,
421
+ filterOutput,
365
422
  llms,
366
423
  mcp: mcpFlag,
367
424
  help,
368
425
  version,
426
+ schema,
369
427
  rest: filtered,
370
428
  } = extractBuiltinFlags(argv)
371
429
 
@@ -402,7 +460,7 @@ async function serveImpl(
402
460
  }
403
461
 
404
462
  // Skills staleness check (skip for built-in commands)
405
- if (!llms && !help && !version) {
463
+ if (!llms && !schema && !help && !version) {
406
464
  const isSkillsAdd =
407
465
  filtered[0] === 'skills' || (filtered[0] === name && filtered[1] === 'skills')
408
466
  const isMcpAdd = filtered[0] === 'mcp' || (filtered[0] === name && filtered[1] === 'mcp')
@@ -689,8 +747,8 @@ async function serveImpl(
689
747
  )
690
748
  return
691
749
  }
692
- if (options.rootCommand) {
693
- // Root command with no args — treat as root invocation
750
+ if (options.rootCommand || options.rootFetch) {
751
+ // Root command/fetch with no args — treat as root invocation
694
752
  } else {
695
753
  writeln(
696
754
  Help.formatRoot(name, {
@@ -708,7 +766,16 @@ async function serveImpl(
708
766
  const resolved =
709
767
  filtered.length === 0 && options.rootCommand
710
768
  ? { command: options.rootCommand, path: name, rest: [] as string[] }
711
- : resolveCommand(commands, filtered)
769
+ : filtered.length === 0 && options.rootFetch
770
+ ? { fetchGateway: { _fetch: true as const, fetch: options.rootFetch, description: options.description }, middlewares: [] as MiddlewareHandler[], path: name, rest: [] as string[] }
771
+ : resolveCommand(commands, filtered)
772
+
773
+ // --help on a fetch gateway → show fetch-specific help
774
+ if (help && 'fetchGateway' in resolved) {
775
+ const commandName = resolved.path === name ? name : `${name} ${resolved.path}`
776
+ writeln(formatFetchHelp(commandName, resolved.fetchGateway.description))
777
+ return
778
+ }
712
779
 
713
780
  // --help after a command → show help for that command
714
781
  if (help) {
@@ -749,7 +816,8 @@ async function serveImpl(
749
816
  }),
750
817
  )
751
818
  }
752
- } else {
819
+ } else if ('command' in resolved) {
820
+ const cmd = resolved.command
753
821
  const isRootCmd = resolved.path === name
754
822
  const commandName = isRootCmd ? name : `${name} ${resolved.path}`
755
823
  const helpSubcommands =
@@ -758,17 +826,17 @@ async function serveImpl(
758
826
  : undefined
759
827
  writeln(
760
828
  Help.formatCommand(commandName, {
761
- alias: resolved.command.alias as Record<string, string> | undefined,
829
+ alias: cmd.alias as Record<string, string> | undefined,
762
830
  aliases: isRootCmd ? options.aliases : undefined,
763
- description: resolved.command.description,
831
+ description: cmd.description,
764
832
  version: isRootCmd ? options.version : undefined,
765
- args: resolved.command.args,
766
- env: resolved.command.env,
833
+ args: cmd.args,
834
+ env: cmd.env,
767
835
  envSource: options.env,
768
- hint: resolved.command.hint,
769
- options: resolved.command.options,
770
- examples: formatExamples(resolved.command.examples),
771
- usage: resolved.command.usage,
836
+ hint: cmd.hint,
837
+ options: cmd.options,
838
+ examples: formatExamples(cmd.examples),
839
+ usage: cmd.usage,
772
840
  commands: helpSubcommands,
773
841
  root: isRootCmd,
774
842
  }),
@@ -777,6 +845,39 @@ async function serveImpl(
777
845
  return
778
846
  }
779
847
 
848
+ // --schema: output JSON Schema for a command's args, env, options, output
849
+ if (schema) {
850
+ if ('help' in resolved) {
851
+ writeln(
852
+ Help.formatRoot(`${name} ${resolved.path}`, {
853
+ description: resolved.description,
854
+ commands: collectHelpCommands(resolved.commands),
855
+ }),
856
+ )
857
+ return
858
+ }
859
+ if ('error' in resolved) {
860
+ const parent = resolved.path ? `${name} ${resolved.path}` : name
861
+ writeln(`Error: '${resolved.error}' is not a command for '${parent}'.`)
862
+ exit(1)
863
+ return
864
+ }
865
+ if ('fetchGateway' in resolved) {
866
+ writeln('--schema is not supported for fetch commands.')
867
+ exit(1)
868
+ return
869
+ }
870
+ const cmd = resolved.command
871
+ const format = formatExplicit ? formatFlag : 'toon'
872
+ const result: Record<string, unknown> = {}
873
+ if (cmd.args) result.args = Schema.toJsonSchema(cmd.args)
874
+ if (cmd.env) result.env = Schema.toJsonSchema(cmd.env)
875
+ if (cmd.options) result.options = Schema.toJsonSchema(cmd.options)
876
+ if (cmd.output) result.output = Schema.toJsonSchema(cmd.output)
877
+ writeln(Formatter.format(result, format))
878
+ return
879
+ }
880
+
780
881
  if ('help' in resolved) {
781
882
  writeln(
782
883
  Help.formatRoot(`${name} ${resolved.path}`, {
@@ -790,21 +891,27 @@ async function serveImpl(
790
891
  const start = performance.now()
791
892
 
792
893
  // Resolve effective format: explicit --format/--json → command default → CLI default → toon
793
- const resolvedFormat = 'command' in resolved && resolved.command.format
894
+ const resolvedFormat = 'command' in resolved && (resolved as any).command.format
794
895
  const format = formatExplicit ? formatFlag : resolvedFormat || options.format || 'toon'
795
896
 
796
- // Fall back to root command when no subcommand matches
897
+ // Fall back to root fetch when no subcommand matches
797
898
  const effective =
798
- 'error' in resolved && options.rootCommand && !resolved.path
799
- ? { command: options.rootCommand, path: name, rest: filtered }
800
- : resolved
899
+ 'error' in resolved && options.rootFetch && !resolved.path
900
+ ? { fetchGateway: { _fetch: true as const, fetch: options.rootFetch, description: options.description }, middlewares: [] as MiddlewareHandler[], path: name, rest: filtered }
901
+ : 'error' in resolved && options.rootCommand && !resolved.path
902
+ ? { command: options.rootCommand, path: name, rest: filtered }
903
+ : resolved
801
904
 
802
905
  // Resolve outputPolicy: command/group → CLI-level → default ('all')
803
906
  const effectiveOutputPolicy =
804
907
  ('outputPolicy' in resolved && resolved.outputPolicy) || options.outputPolicy
805
908
  const renderOutput = !(human && !formatExplicit && effectiveOutputPolicy === 'agent-only')
806
909
 
910
+ const filterPaths = filterOutput ? Filter.parse(filterOutput) : undefined
911
+
807
912
  function write(output: Output) {
913
+ if (filterPaths && output.ok && output.data != null)
914
+ output = { ...output, data: Filter.apply(output.data, filterPaths) }
808
915
  const cta = output.meta.cta
809
916
  if (human && !verbose) {
810
917
  if (output.ok && output.data != null && renderOutput)
@@ -852,6 +959,119 @@ async function serveImpl(
852
959
  return
853
960
  }
854
961
 
962
+ // Fetch gateway execution path
963
+ if ('fetchGateway' in effective) {
964
+ const { fetchGateway, path, rest: fetchRest } = effective
965
+ const fetchMiddleware = [
966
+ ...(options.middlewares ?? []),
967
+ ...((effective as any).middlewares ?? []),
968
+ ]
969
+
970
+ const runFetch = async () => {
971
+ const input = Fetch.parseArgv(fetchRest)
972
+ if (fetchGateway.basePath) input.path = fetchGateway.basePath + input.path
973
+ const request = Fetch.buildRequest(input)
974
+ const response = await fetchGateway.fetch(request)
975
+
976
+ // Streaming path — NDJSON responses pipe through handleStreaming
977
+ if (Fetch.isStreamingResponse(response)) {
978
+ const generator = Fetch.parseStreamingResponse(response)
979
+ await handleStreaming(generator, {
980
+ name,
981
+ path,
982
+ start,
983
+ format,
984
+ formatExplicit,
985
+ human,
986
+ renderOutput,
987
+ verbose,
988
+ write,
989
+ writeln,
990
+ exit,
991
+ })
992
+ return
993
+ }
994
+
995
+ const output = await Fetch.parseResponse(response)
996
+
997
+ if (output.ok) {
998
+ write({
999
+ ok: true,
1000
+ data: output.data,
1001
+ meta: {
1002
+ command: path,
1003
+ duration: `${Math.round(performance.now() - start)}ms`,
1004
+ },
1005
+ })
1006
+ } else {
1007
+ write({
1008
+ ok: false,
1009
+ error: {
1010
+ code: `HTTP_${output.status}`,
1011
+ message: typeof output.data === 'object' && output.data !== null && 'message' in output.data
1012
+ ? String((output.data as any).message)
1013
+ : typeof output.data === 'string' ? output.data : `HTTP ${output.status}`,
1014
+ },
1015
+ meta: {
1016
+ command: path,
1017
+ duration: `${Math.round(performance.now() - start)}ms`,
1018
+ },
1019
+ })
1020
+ exit(1)
1021
+ }
1022
+ }
1023
+
1024
+ try {
1025
+ const cliEnv = options.envSchema ? Parser.parseEnv(options.envSchema, options.env ?? process.env) : {}
1026
+ if (fetchMiddleware.length > 0) {
1027
+ const varsMap: Record<string, unknown> = options.vars ? options.vars.parse({}) : {}
1028
+ const errorFn = (opts: { code: string; exitCode?: number | undefined; message: string; retryable?: boolean | undefined; cta?: CtaBlock | undefined }): never =>
1029
+ ({ [sentinel]: 'error', ...opts }) as never
1030
+ const mwCtx: MiddlewareContext = {
1031
+ agent: !human,
1032
+ command: path,
1033
+ env: cliEnv,
1034
+ error: errorFn,
1035
+ format,
1036
+ formatExplicit,
1037
+ name,
1038
+ set(key: string, value: unknown) { varsMap[key] = value },
1039
+ var: varsMap,
1040
+ version: options.version,
1041
+ }
1042
+ const handleMwSentinel = (result: unknown) => {
1043
+ if (!isSentinel(result) || result[sentinel] !== 'error') return
1044
+ const err = result as ErrorResult
1045
+ const cta = formatCtaBlock(name, err.cta)
1046
+ write({
1047
+ ok: false,
1048
+ error: { code: err.code, message: err.message, ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined) },
1049
+ meta: { command: path, duration: `${Math.round(performance.now() - start)}ms`, ...(cta ? { cta } : undefined) },
1050
+ })
1051
+ exit(err.exitCode ?? 1)
1052
+ }
1053
+ const composed = fetchMiddleware.reduceRight(
1054
+ (next: () => Promise<void>, mw) => async () => { handleMwSentinel(await mw(mwCtx, next)) },
1055
+ runFetch,
1056
+ )
1057
+ await composed()
1058
+ } else {
1059
+ await runFetch()
1060
+ }
1061
+ } catch (error) {
1062
+ write({
1063
+ ok: false,
1064
+ error: {
1065
+ code: error instanceof IncurError ? error.code : 'UNKNOWN',
1066
+ message: error instanceof Error ? error.message : String(error),
1067
+ },
1068
+ meta: { command: path, duration: `${Math.round(performance.now() - start)}ms` },
1069
+ })
1070
+ exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1)
1071
+ }
1072
+ return
1073
+ }
1074
+
855
1075
  const { command, path, rest } = effective
856
1076
 
857
1077
  // Collect middleware: root CLI + groups traversed + per-command
@@ -888,6 +1108,7 @@ async function serveImpl(
888
1108
  }
889
1109
  const errorFn = (opts: {
890
1110
  code: string
1111
+ exitCode?: number | undefined
891
1112
  message: string
892
1113
  retryable?: boolean | undefined
893
1114
  cta?: CtaBlock | undefined
@@ -899,10 +1120,12 @@ async function serveImpl(
899
1120
  agent: !human,
900
1121
  args,
901
1122
  env,
1123
+ error: errorFn,
1124
+ format,
1125
+ formatExplicit,
902
1126
  name,
903
- options: parsedOptions,
904
1127
  ok: okFn,
905
- error: errorFn,
1128
+ options: parsedOptions,
906
1129
  var: varsMap,
907
1130
  version: options.version,
908
1131
  })
@@ -940,12 +1163,13 @@ async function serveImpl(
940
1163
  },
941
1164
  })
942
1165
  } else {
1166
+ const err = awaited as ErrorResult
943
1167
  write({
944
1168
  ok: false,
945
1169
  error: {
946
- code: awaited.code,
947
- message: awaited.message,
948
- ...(awaited.retryable !== undefined ? { retryable: awaited.retryable } : undefined),
1170
+ code: err.code,
1171
+ message: err.message,
1172
+ ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined),
949
1173
  },
950
1174
  meta: {
951
1175
  command: path,
@@ -953,7 +1177,7 @@ async function serveImpl(
953
1177
  ...(cta ? { cta } : undefined),
954
1178
  },
955
1179
  })
956
- exit(1)
1180
+ exit(err.exitCode ?? 1)
957
1181
  }
958
1182
  } else {
959
1183
  write({
@@ -973,6 +1197,7 @@ async function serveImpl(
973
1197
  if (allMiddleware.length > 0) {
974
1198
  const errorFn = (opts: {
975
1199
  code: string
1200
+ exitCode?: number | undefined
976
1201
  message: string
977
1202
  retryable?: boolean | undefined
978
1203
  cta?: CtaBlock | undefined
@@ -984,6 +1209,8 @@ async function serveImpl(
984
1209
  command: path,
985
1210
  env: cliEnv,
986
1211
  error: errorFn,
1212
+ format,
1213
+ formatExplicit,
987
1214
  name,
988
1215
  set(key: string, value: unknown) {
989
1216
  varsMap[key] = value
@@ -993,13 +1220,14 @@ async function serveImpl(
993
1220
  }
994
1221
  const handleMwSentinel = (result: unknown) => {
995
1222
  if (!isSentinel(result) || result[sentinel] !== 'error') return
996
- const cta = formatCtaBlock(name, result.cta)
1223
+ const err = result as ErrorResult
1224
+ const cta = formatCtaBlock(name, err.cta)
997
1225
  write({
998
1226
  ok: false,
999
1227
  error: {
1000
- code: result.code,
1001
- message: result.message,
1002
- ...(result.retryable !== undefined ? { retryable: result.retryable } : undefined),
1228
+ code: err.code,
1229
+ message: err.message,
1230
+ ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined),
1003
1231
  },
1004
1232
  meta: {
1005
1233
  command: path,
@@ -1007,7 +1235,7 @@ async function serveImpl(
1007
1235
  ...(cta ? { cta } : undefined),
1008
1236
  },
1009
1237
  })
1010
- exit(1)
1238
+ exit(err.exitCode ?? 1)
1011
1239
  }
1012
1240
  const composed = allMiddleware.reduceRight(
1013
1241
  (next: () => Promise<void>, mw) => async () => {
@@ -1046,8 +1274,321 @@ async function serveImpl(
1046
1274
  }
1047
1275
 
1048
1276
  write(errorOutput)
1049
- exit(1)
1277
+ exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1)
1278
+ }
1279
+ }
1280
+
1281
+ /** @internal Options for fetchImpl. */
1282
+ declare namespace fetchImpl {
1283
+ type Options = {
1284
+ mcpHandler?: ((req: Request, commands: Map<string, CommandEntry>) => Promise<Response>) | undefined
1285
+ middlewares?: MiddlewareHandler[] | undefined
1286
+ rootCommand?: CommandDefinition<any, any, any> | undefined
1287
+ vars?: z.ZodObject<any> | undefined
1288
+ }
1289
+ }
1290
+
1291
+ /** @internal Creates a lazy MCP HTTP handler scoped to a CLI instance. */
1292
+ function createMcpHttpHandler(name: string, version: string) {
1293
+ let transport: any
1294
+
1295
+ return async (req: Request, commands: Map<string, CommandEntry>): Promise<Response> => {
1296
+ if (!transport) {
1297
+ const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js')
1298
+ const { WebStandardStreamableHTTPServerTransport } = await import(
1299
+ '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'
1300
+ )
1301
+
1302
+ const server = new McpServer({ name, version })
1303
+
1304
+ for (const tool of Mcp.collectTools(commands, [])) {
1305
+ const mergedShape: Record<string, any> = {
1306
+ ...tool.command.args?.shape,
1307
+ ...tool.command.options?.shape,
1308
+ }
1309
+ const hasInput = Object.keys(mergedShape).length > 0
1310
+
1311
+ server.registerTool(
1312
+ tool.name,
1313
+ {
1314
+ ...(tool.description ? { description: tool.description } : undefined),
1315
+ ...(hasInput ? { inputSchema: mergedShape } : undefined),
1316
+ },
1317
+ async (...callArgs: any[]) => {
1318
+ const params = hasInput ? (callArgs[0] as Record<string, unknown>) : {}
1319
+ return Mcp.callTool(tool, params)
1320
+ },
1321
+ )
1322
+ }
1323
+
1324
+ transport = new WebStandardStreamableHTTPServerTransport({
1325
+ sessionIdGenerator: () => crypto.randomUUID(),
1326
+ enableJsonResponse: true,
1327
+ })
1328
+ await server.connect(transport)
1329
+ }
1330
+ return transport.handleRequest(req)
1331
+ }
1332
+ }
1333
+
1334
+ /** @internal Handles an HTTP request by resolving a command and returning a JSON Response. */
1335
+ async function fetchImpl(
1336
+ name: string,
1337
+ commands: Map<string, CommandEntry>,
1338
+ req: Request,
1339
+ options: fetchImpl.Options = {},
1340
+ ): Promise<Response> {
1341
+ const start = performance.now()
1342
+
1343
+ const url = new URL(req.url)
1344
+ const segments = url.pathname.split('/').filter(Boolean)
1345
+
1346
+ // MCP over HTTP: route /mcp to the MCP transport
1347
+ if (segments[0] === 'mcp' && segments.length === 1 && options.mcpHandler)
1348
+ return options.mcpHandler(req, commands)
1349
+
1350
+ // .well-known/skills/ — Agent Skills Discovery (RFC)
1351
+ if (
1352
+ segments[0] === '.well-known' &&
1353
+ segments[1] === 'skills' &&
1354
+ segments.length >= 3 &&
1355
+ req.method === 'GET'
1356
+ ) {
1357
+ const groups = new Map<string, string>()
1358
+ const cmds = collectSkillCommands(commands, [], groups)
1359
+
1360
+ // GET /.well-known/skills/index.json
1361
+ if (segments[2] === 'index.json' && segments.length === 3) {
1362
+ const files = Skill.split(name, cmds, 1, groups)
1363
+ const skills = files.map((f) => {
1364
+ const descMatch = f.content.match(/^description:\s*(.+)$/m)
1365
+ return {
1366
+ name: f.dir || name,
1367
+ description: descMatch?.[1] ?? '',
1368
+ files: ['SKILL.md'],
1369
+ }
1370
+ })
1371
+ return new Response(JSON.stringify({ skills }), {
1372
+ status: 200,
1373
+ headers: { 'content-type': 'application/json', 'cache-control': 'public, max-age=300' },
1374
+ })
1375
+ }
1376
+
1377
+ // GET /.well-known/skills/{skill-name}/SKILL.md
1378
+ if (segments.length === 4 && segments[3] === 'SKILL.md') {
1379
+ const skillName = segments[2]!
1380
+ const files = Skill.split(name, cmds, 1, groups)
1381
+ const file = files.find((f) => (f.dir || name) === skillName)
1382
+ if (file)
1383
+ return new Response(file.content, {
1384
+ status: 200,
1385
+ headers: { 'content-type': 'text/markdown', 'cache-control': 'public, max-age=300' },
1386
+ })
1387
+ return new Response('Not Found', { status: 404 })
1388
+ }
1389
+
1390
+ return new Response('Not Found', { status: 404 })
1391
+ }
1392
+
1393
+ // Parse options from search params (GET) or body (non-GET)
1394
+ let inputOptions: Record<string, unknown> = {}
1395
+ if (req.method === 'GET')
1396
+ for (const [key, value] of url.searchParams) inputOptions[key] = value
1397
+ else {
1398
+ try {
1399
+ const contentType = req.headers.get('content-type') ?? ''
1400
+ if (contentType.includes('application/json')) inputOptions = (await req.json()) as Record<string, unknown>
1401
+ } catch {}
1402
+ }
1403
+
1404
+ function jsonResponse(body: unknown, status: number) {
1405
+ return new Response(JSON.stringify(body), {
1406
+ status,
1407
+ headers: { 'content-type': 'application/json' },
1408
+ })
1409
+ }
1410
+
1411
+ // Resolve command from path segments
1412
+ if (segments.length === 0) {
1413
+ // Root path
1414
+ if (options.rootCommand)
1415
+ return executeCommand(name, options.rootCommand, [], inputOptions, start, options)
1416
+ return jsonResponse(
1417
+ { ok: false, error: { code: 'COMMAND_NOT_FOUND', message: 'No root command defined.' }, meta: { command: '/', duration: `${Math.round(performance.now() - start)}ms` } },
1418
+ 404,
1419
+ )
1420
+ }
1421
+
1422
+ const resolved = resolveCommand(commands, segments)
1423
+
1424
+ if ('error' in resolved)
1425
+ return jsonResponse(
1426
+ { ok: false, error: { code: 'COMMAND_NOT_FOUND', message: `'${resolved.error}' is not a command for '${resolved.path ? `${name} ${resolved.path}` : name}'.` }, meta: { command: resolved.error, duration: `${Math.round(performance.now() - start)}ms` } },
1427
+ 404,
1428
+ )
1429
+
1430
+ if ('help' in resolved)
1431
+ return jsonResponse(
1432
+ { ok: false, error: { code: 'COMMAND_NOT_FOUND', message: `'${resolved.path}' is a command group. Specify a subcommand.` }, meta: { command: resolved.path, duration: `${Math.round(performance.now() - start)}ms` } },
1433
+ 404,
1434
+ )
1435
+
1436
+ if ('fetchGateway' in resolved)
1437
+ return resolved.fetchGateway.fetch(req)
1438
+
1439
+ const { command, path, rest } = resolved
1440
+ return executeCommand(path, command, rest, inputOptions, start, options)
1441
+ }
1442
+
1443
+ /** @internal Executes a resolved command for the fetch handler and returns a JSON Response. */
1444
+ async function executeCommand(
1445
+ path: string,
1446
+ command: CommandDefinition<any, any, any>,
1447
+ rest: string[],
1448
+ inputOptions: Record<string, unknown>,
1449
+ start: number,
1450
+ options: fetchImpl.Options,
1451
+ ): Promise<Response> {
1452
+ function jsonResponse(body: unknown, status: number) {
1453
+ return new Response(JSON.stringify(body), {
1454
+ status,
1455
+ headers: { 'content-type': 'application/json' },
1456
+ })
1457
+ }
1458
+
1459
+ const sentinel_ = Symbol.for('incur.sentinel')
1460
+ const varsMap: Record<string, unknown> = options.vars ? options.vars.parse({}) : {}
1461
+ let response: Response | undefined
1462
+
1463
+ const runCommand = async () => {
1464
+ const { args } = Parser.parse(rest, { args: command.args })
1465
+ const parsedOptions = command.options ? command.options.parse(inputOptions) : {}
1466
+
1467
+ const okFn = (data: unknown): never => ({ [sentinel_]: 'ok', data }) as never
1468
+ const errorFn = (opts: { code: string; message: string; exitCode?: number | undefined }): never =>
1469
+ ({ [sentinel_]: 'error', ...opts }) as never
1470
+
1471
+ const result = command.run({
1472
+ agent: true,
1473
+ args,
1474
+ env: {},
1475
+ error: errorFn,
1476
+ format: 'json',
1477
+ formatExplicit: true,
1478
+ name: path,
1479
+ ok: okFn,
1480
+ options: parsedOptions,
1481
+ var: varsMap,
1482
+ version: undefined,
1483
+ })
1484
+
1485
+ // Streaming path — async generator → NDJSON response
1486
+ if (isAsyncGenerator(result)) {
1487
+ const stream = new ReadableStream({
1488
+ async start(controller) {
1489
+ const encoder = new TextEncoder()
1490
+ try {
1491
+ let returnValue: unknown
1492
+ while (true) {
1493
+ const { value, done } = await result.next()
1494
+ if (done) {
1495
+ returnValue = value
1496
+ break
1497
+ }
1498
+ if (isSentinel(value) && (value as any)[sentinel] === 'error') {
1499
+ const tagged = value as any
1500
+ controller.enqueue(encoder.encode(JSON.stringify({ type: 'error', ok: false, error: { code: tagged.code, message: tagged.message } }) + '\n'))
1501
+ controller.close()
1502
+ return
1503
+ }
1504
+ controller.enqueue(encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n'))
1505
+ }
1506
+ const meta: Record<string, unknown> = { command: path }
1507
+ if (isSentinel(returnValue) && (returnValue as any)[sentinel] === 'error') {
1508
+ const tagged = returnValue as any
1509
+ controller.enqueue(encoder.encode(JSON.stringify({ type: 'error', ok: false, error: { code: tagged.code, message: tagged.message } }) + '\n'))
1510
+ } else {
1511
+ controller.enqueue(encoder.encode(JSON.stringify({ type: 'done', ok: true, meta }) + '\n'))
1512
+ }
1513
+ } catch (error) {
1514
+ controller.enqueue(encoder.encode(JSON.stringify({ type: 'error', ok: false, error: { code: 'UNKNOWN', message: error instanceof Error ? error.message : String(error) } }) + '\n'))
1515
+ }
1516
+ controller.close()
1517
+ },
1518
+ })
1519
+ response = new Response(stream, { status: 200, headers: { 'content-type': 'application/x-ndjson' } })
1520
+ return
1521
+ }
1522
+
1523
+ const awaited = await result
1524
+ const duration = `${Math.round(performance.now() - start)}ms`
1525
+
1526
+ if (typeof awaited === 'object' && awaited !== null && sentinel_ in awaited) {
1527
+ const tagged = awaited as any
1528
+ if (tagged[sentinel_] === 'error')
1529
+ response = jsonResponse(
1530
+ { ok: false, error: { code: tagged.code, message: tagged.message }, meta: { command: path, duration } },
1531
+ 500,
1532
+ )
1533
+ else
1534
+ response = jsonResponse(
1535
+ { ok: true, data: tagged.data, meta: { command: path, duration } },
1536
+ 200,
1537
+ )
1538
+ return
1539
+ }
1540
+
1541
+ response = jsonResponse(
1542
+ { ok: true, data: awaited, meta: { command: path, duration } },
1543
+ 200,
1544
+ )
1545
+ }
1546
+
1547
+ try {
1548
+ const allMiddleware = options.middlewares ?? []
1549
+ if (allMiddleware.length > 0) {
1550
+ const errorFn = (opts: { code: string; message: string; exitCode?: number | undefined }): never => {
1551
+ const duration = `${Math.round(performance.now() - start)}ms`
1552
+ response = jsonResponse(
1553
+ { ok: false, error: { code: opts.code, message: opts.message }, meta: { command: path, duration } },
1554
+ 500,
1555
+ )
1556
+ return undefined as never
1557
+ }
1558
+ const mwCtx: MiddlewareContext = {
1559
+ agent: true,
1560
+ command: path,
1561
+ env: {},
1562
+ error: errorFn,
1563
+ format: 'json',
1564
+ formatExplicit: true,
1565
+ name: path,
1566
+ set(key: string, value: unknown) { varsMap[key] = value },
1567
+ var: varsMap,
1568
+ version: undefined,
1569
+ }
1570
+ const composed = allMiddleware.reduceRight(
1571
+ (next: () => Promise<void>, mw) => async () => { await mw(mwCtx, next) },
1572
+ runCommand,
1573
+ )
1574
+ await composed()
1575
+ } else {
1576
+ await runCommand()
1577
+ }
1578
+ } catch (error) {
1579
+ const duration = `${Math.round(performance.now() - start)}ms`
1580
+ if (error instanceof ValidationError)
1581
+ return jsonResponse(
1582
+ { ok: false, error: { code: 'VALIDATION_ERROR', message: error.message }, meta: { command: path, duration } },
1583
+ 400,
1584
+ )
1585
+ return jsonResponse(
1586
+ { ok: false, error: { code: error instanceof IncurError ? error.code : 'UNKNOWN', message: error instanceof Error ? error.message : String(error) }, meta: { command: path, duration } },
1587
+ 500,
1588
+ )
1050
1589
  }
1590
+
1591
+ return response!
1051
1592
  }
1052
1593
 
1053
1594
  /** @internal Formats a validation error for TTY with usage hint. */
@@ -1090,6 +1631,13 @@ function resolveCommand(
1090
1631
  path: string
1091
1632
  rest: string[]
1092
1633
  }
1634
+ | {
1635
+ fetchGateway: InternalFetchGateway
1636
+ middlewares: MiddlewareHandler[]
1637
+ outputPolicy?: OutputPolicy | undefined
1638
+ path: string
1639
+ rest: string[]
1640
+ }
1093
1641
  | {
1094
1642
  help: true
1095
1643
  path: string
@@ -1107,6 +1655,18 @@ function resolveCommand(
1107
1655
  let inheritedOutputPolicy: OutputPolicy | undefined
1108
1656
  const collectedMiddlewares: MiddlewareHandler[] = []
1109
1657
 
1658
+ // Fetch gateway — all remaining tokens go to the fetch handler
1659
+ if (isFetchGateway(entry)) {
1660
+ const outputPolicy = entry.outputPolicy ?? inheritedOutputPolicy
1661
+ return {
1662
+ fetchGateway: entry,
1663
+ middlewares: collectedMiddlewares,
1664
+ path: path.join(' '),
1665
+ rest: remaining,
1666
+ ...(outputPolicy ? { outputPolicy } : undefined),
1667
+ }
1668
+ }
1669
+
1110
1670
  while (isGroup(entry)) {
1111
1671
  if (entry.outputPolicy) inheritedOutputPolicy = entry.outputPolicy
1112
1672
  if (entry.middlewares) collectedMiddlewares.push(...entry.middlewares)
@@ -1127,6 +1687,17 @@ function resolveCommand(
1127
1687
  path.push(next)
1128
1688
  remaining = remaining.slice(1)
1129
1689
  entry = child
1690
+
1691
+ if (isFetchGateway(entry)) {
1692
+ const outputPolicy = entry.outputPolicy ?? inheritedOutputPolicy
1693
+ return {
1694
+ fetchGateway: entry,
1695
+ middlewares: collectedMiddlewares,
1696
+ path: path.join(' '),
1697
+ rest: remaining,
1698
+ ...(outputPolicy ? { outputPolicy } : undefined),
1699
+ }
1700
+ }
1130
1701
  }
1131
1702
 
1132
1703
  const outputPolicy = entry.outputPolicy ?? inheritedOutputPolicy
@@ -1161,6 +1732,8 @@ declare namespace serveImpl {
1161
1732
  | undefined
1162
1733
  /** Root command handler, invoked when no subcommand matches. */
1163
1734
  rootCommand?: CommandDefinition<any, any, any> | undefined
1735
+ /** Root fetch handler, invoked when no subcommand matches and no rootCommand is set. */
1736
+ rootFetch?: FetchHandler | undefined
1164
1737
  sync?:
1165
1738
  | {
1166
1739
  cwd?: string | undefined
@@ -1182,8 +1755,10 @@ function extractBuiltinFlags(argv: string[]) {
1182
1755
  let mcp = false
1183
1756
  let help = false
1184
1757
  let version = false
1758
+ let schema = false
1185
1759
  let format: Formatter.Format = 'toon'
1186
1760
  let formatExplicit = false
1761
+ let filterOutput: string | undefined
1187
1762
  const rest: string[] = []
1188
1763
 
1189
1764
  for (let i = 0; i < argv.length; i++) {
@@ -1193,6 +1768,7 @@ function extractBuiltinFlags(argv: string[]) {
1193
1768
  else if (token === '--mcp') mcp = true
1194
1769
  else if (token === '--help' || token === '-h') help = true
1195
1770
  else if (token === '--version') version = true
1771
+ else if (token === '--schema') schema = true
1196
1772
  else if (token === '--json') {
1197
1773
  format = 'json'
1198
1774
  formatExplicit = true
@@ -1200,10 +1776,13 @@ function extractBuiltinFlags(argv: string[]) {
1200
1776
  format = argv[i + 1] as Formatter.Format
1201
1777
  formatExplicit = true
1202
1778
  i++
1779
+ } else if (token === '--filter-output' && argv[i + 1]) {
1780
+ filterOutput = argv[i + 1]!
1781
+ i++
1203
1782
  } else rest.push(token)
1204
1783
  }
1205
1784
 
1206
- return { verbose, format, formatExplicit, llms, mcp, help, version, rest }
1785
+ return { verbose, format, formatExplicit, filterOutput, llms, mcp, help, version, schema, rest }
1207
1786
  }
1208
1787
 
1209
1788
  /** @internal Collects immediate child commands/groups for help output. */
@@ -1212,24 +1791,45 @@ function collectHelpCommands(
1212
1791
  ): { name: string; description?: string | undefined }[] {
1213
1792
  const result: { name: string; description?: string | undefined }[] = []
1214
1793
  for (const [name, entry] of commands) {
1215
- if (isGroup(entry)) result.push({ name, description: entry.description })
1216
- else result.push({ name, description: entry.description })
1794
+ result.push({ name, description: entry.description })
1217
1795
  }
1218
1796
  return result.sort((a, b) => a.name.localeCompare(b.name))
1219
1797
  }
1220
1798
 
1799
+ /** @internal Formats help text for a fetch gateway command. */
1800
+ function formatFetchHelp(name: string, description?: string): string {
1801
+ const lines: string[] = []
1802
+ if (description) lines.push(`${name} — ${description}`)
1803
+ else lines.push(name)
1804
+ lines.push('')
1805
+ lines.push(`Usage: ${name} <path> [options]`)
1806
+ lines.push('')
1807
+ lines.push('Path segments are joined into the request URL path.')
1808
+ lines.push('')
1809
+ lines.push('Options:')
1810
+ lines.push(' -X, --method <METHOD> HTTP method (default: GET, POST if body present)')
1811
+ lines.push(' -H, --header "Key: Val" Set a request header (repeatable)')
1812
+ lines.push(' -d, --data <json> Request body (implies POST)')
1813
+ lines.push(' --body <json> Request body (implies POST)')
1814
+ lines.push(' --<key> <value> Query string parameter')
1815
+ return lines.join('\n')
1816
+ }
1817
+
1221
1818
  /** Shape of the commands map accumulated through `.command()` chains. */
1222
1819
  export type CommandsMap = Record<
1223
1820
  string,
1224
1821
  { args: Record<string, unknown>; options: Record<string, unknown> }
1225
1822
  >
1226
1823
 
1227
- /** @internal Entry stored in a command map — either a leaf definition or a group. */
1228
- type CommandEntry = CommandDefinition<any, any, any> | InternalGroup
1824
+ /** @internal Entry stored in a command map — either a leaf definition, a group, or a fetch gateway. */
1825
+ type CommandEntry = CommandDefinition<any, any, any> | InternalGroup | InternalFetchGateway
1229
1826
 
1230
1827
  /** Controls when output data is displayed. `'all'` displays to both humans and agents. `'agent-only'` suppresses data output in human/TTY mode. */
1231
1828
  export type OutputPolicy = 'agent-only' | 'all'
1232
1829
 
1830
+ /** A standard Fetch API handler. */
1831
+ type FetchHandler = (req: Request) => Response | Promise<Response>
1832
+
1233
1833
  /** @internal A command group's internal storage. */
1234
1834
  type InternalGroup = {
1235
1835
  _group: true
@@ -1239,11 +1839,25 @@ type InternalGroup = {
1239
1839
  commands: Map<string, CommandEntry>
1240
1840
  }
1241
1841
 
1842
+ /** @internal A fetch gateway entry. */
1843
+ type InternalFetchGateway = {
1844
+ _fetch: true
1845
+ basePath?: string | undefined
1846
+ description?: string | undefined
1847
+ fetch: FetchHandler
1848
+ outputPolicy?: OutputPolicy | undefined
1849
+ }
1850
+
1242
1851
  /** @internal Type guard for command groups. */
1243
1852
  function isGroup(entry: CommandEntry): entry is InternalGroup {
1244
1853
  return '_group' in entry
1245
1854
  }
1246
1855
 
1856
+ /** @internal Type guard for fetch gateways. */
1857
+ function isFetchGateway(entry: CommandEntry): entry is InternalFetchGateway {
1858
+ return '_fetch' in entry
1859
+ }
1860
+
1247
1861
  /** @internal Maps CLI instances to their command maps. */
1248
1862
  export const toCommands = new WeakMap<Cli, Map<string, CommandEntry>>()
1249
1863
 
@@ -1272,6 +1886,7 @@ type ErrorResult = {
1272
1886
  code: string
1273
1887
  message: string
1274
1888
  retryable?: boolean | undefined
1889
+ exitCode?: number | undefined
1275
1890
  cta?: CtaBlock | undefined
1276
1891
  }
1277
1892
 
@@ -1377,7 +1992,7 @@ async function handleStreaming(
1377
1992
  }),
1378
1993
  )
1379
1994
  else ctx.writeln(formatHumanError({ code: tagged.code, message: tagged.message }))
1380
- ctx.exit(1)
1995
+ ctx.exit(tagged.exitCode ?? 1)
1381
1996
  return
1382
1997
  }
1383
1998
  }
@@ -1401,7 +2016,7 @@ async function handleStreaming(
1401
2016
  }),
1402
2017
  )
1403
2018
  else ctx.writeln(formatHumanError({ code: err.code, message: err.message }))
1404
- ctx.exit(1)
2019
+ ctx.exit(err.exitCode ?? 1)
1405
2020
  return
1406
2021
  }
1407
2022
 
@@ -1442,7 +2057,7 @@ async function handleStreaming(
1442
2057
  message: error instanceof Error ? error.message : String(error),
1443
2058
  }),
1444
2059
  )
1445
- ctx.exit(1)
2060
+ ctx.exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1)
1446
2061
  }
1447
2062
  } else {
1448
2063
  // Buffered output: collect all chunks, write as single value
@@ -1470,7 +2085,7 @@ async function handleStreaming(
1470
2085
  duration: `${Math.round(performance.now() - ctx.start)}ms`,
1471
2086
  },
1472
2087
  })
1473
- ctx.exit(1)
2088
+ ctx.exit(tagged.exitCode ?? 1)
1474
2089
  return
1475
2090
  }
1476
2091
  }
@@ -1491,7 +2106,7 @@ async function handleStreaming(
1491
2106
  duration: `${Math.round(performance.now() - ctx.start)}ms`,
1492
2107
  },
1493
2108
  })
1494
- ctx.exit(1)
2109
+ ctx.exit(err.exitCode ?? 1)
1495
2110
  return
1496
2111
  }
1497
2112
 
@@ -1521,7 +2136,7 @@ async function handleStreaming(
1521
2136
  duration: `${Math.round(performance.now() - ctx.start)}ms`,
1522
2137
  },
1523
2138
  })
1524
- ctx.exit(1)
2139
+ ctx.exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1)
1525
2140
  }
1526
2141
  }
1527
2142
  }
@@ -1570,7 +2185,11 @@ function collectCommands(
1570
2185
  const result: ReturnType<typeof collectCommands> = []
1571
2186
  for (const [name, entry] of commands) {
1572
2187
  const path = [...prefix, name]
1573
- if (isGroup(entry)) {
2188
+ if (isFetchGateway(entry)) {
2189
+ const cmd: (typeof result)[number] = { name: path.join(' ') }
2190
+ if (entry.description) cmd.description = entry.description
2191
+ result.push(cmd)
2192
+ } else if (isGroup(entry)) {
1574
2193
  result.push(...collectCommands(entry.commands, path))
1575
2194
  } else {
1576
2195
  const cmd: (typeof result)[number] = { name: path.join(' ') }
@@ -1609,7 +2228,12 @@ function collectSkillCommands(
1609
2228
  const result: Skill.CommandInfo[] = []
1610
2229
  for (const [name, entry] of commands) {
1611
2230
  const path = [...prefix, name]
1612
- if (isGroup(entry)) {
2231
+ if (isFetchGateway(entry)) {
2232
+ const cmd: Skill.CommandInfo = { name: path.join(' ') }
2233
+ if (entry.description) cmd.description = entry.description
2234
+ cmd.hint = 'Fetch gateway. Pass path segments and curl-style flags (-X, -H, -d, --key value).'
2235
+ result.push(cmd)
2236
+ } else if (isGroup(entry)) {
1613
2237
  if (entry.description) groups.set(path.join(' '), entry.description)
1614
2238
  result.push(...collectSkillCommands(entry.commands, path, groups))
1615
2239
  } else {
@@ -1809,17 +2433,22 @@ type CommandDefinition<
1809
2433
  agent: boolean
1810
2434
  /** Positional arguments. */
1811
2435
  args: InferOutput<args>
1812
- /** The CLI name. */
1813
- name: string
1814
2436
  /** Parsed environment variables. */
1815
2437
  env: InferOutput<env>
1816
2438
  /** Return an error result with optional CTAs. */
1817
2439
  error: (options: {
1818
2440
  code: string
1819
2441
  cta?: CtaBlock | undefined
2442
+ exitCode?: number | undefined
1820
2443
  message: string
1821
2444
  retryable?: boolean | undefined
1822
2445
  }) => never
2446
+ /** The resolved output format (e.g. `'toon'`, `'json'`, `'jsonl'`). */
2447
+ format: Formatter.Format
2448
+ /** Whether the user explicitly passed `--format` or `--json`. */
2449
+ formatExplicit: boolean
2450
+ /** The CLI name. */
2451
+ name: string
1823
2452
  /** Return a success result with optional metadata (e.g. CTAs). */
1824
2453
  ok: (data: InferReturn<output>, meta?: { cta?: CtaBlock | undefined }) => never
1825
2454
  options: InferOutput<options>