incur 0.1.17 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/Cli.ts CHANGED
@@ -3,6 +3,8 @@ import type { z } from 'zod'
3
3
  import * as Completions from './Completions.js'
4
4
  import type { FieldError } from './Errors.js'
5
5
  import { IncurError, ValidationError } from './Errors.js'
6
+ import * as Fetch from './Fetch.js'
7
+ import * as Openapi from './Openapi.js'
6
8
  import * as Formatter from './Formatter.js'
7
9
  import * as Help from './Help.js'
8
10
  import { detectRunner } from './internal/pm.js'
@@ -56,6 +58,11 @@ export type Cli<
56
58
  vars,
57
59
  env
58
60
  >
61
+ /** Mounts a fetch handler as a command, optionally with OpenAPI spec for typed subcommands. */
62
+ <const name extends string>(
63
+ name: name,
64
+ definition: { basePath?: string | undefined; description?: string | undefined; fetch: FetchHandler; openapi?: Openapi.OpenAPISpec | undefined; outputPolicy?: OutputPolicy | undefined },
65
+ ): Cli<commands, vars, env>
59
66
  }
60
67
  /** A short description of the CLI. */
61
68
  description?: string | undefined
@@ -174,9 +181,11 @@ export function create(
174
181
  const name = typeof nameOrDefinition === 'string' ? nameOrDefinition : nameOrDefinition.name
175
182
  const def = typeof nameOrDefinition === 'string' ? (definition ?? {}) : nameOrDefinition
176
183
  const rootDef = 'run' in def ? (def as CommandDefinition<any, any, any>) : undefined
184
+ const rootFetch = 'fetch' in def ? (def.fetch as FetchHandler) : undefined
177
185
 
178
186
  const commands = new Map<string, CommandEntry>()
179
187
  const middlewares: MiddlewareHandler[] = []
188
+ const pending: Promise<void>[] = []
180
189
 
181
190
  const cli: Cli = {
182
191
  name,
@@ -186,6 +195,30 @@ export function create(
186
195
 
187
196
  command(nameOrCli: any, def?: any): any {
188
197
  if (typeof nameOrCli === 'string') {
198
+ if (def && 'fetch' in def && typeof def.fetch === 'function') {
199
+ // OpenAPI + fetch → generate typed command group (async, resolved before serve)
200
+ if (def.openapi) {
201
+ pending.push(
202
+ Openapi.generateCommands(def.openapi, def.fetch, { basePath: def.basePath }).then((generated) => {
203
+ commands.set(nameOrCli, {
204
+ _group: true,
205
+ description: def.description,
206
+ commands: generated as Map<string, CommandEntry>,
207
+ ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined),
208
+ } as InternalGroup)
209
+ }),
210
+ )
211
+ return cli
212
+ }
213
+ commands.set(nameOrCli, {
214
+ _fetch: true,
215
+ basePath: def.basePath,
216
+ description: def.description,
217
+ fetch: def.fetch,
218
+ ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined),
219
+ } as InternalFetchGateway)
220
+ return cli
221
+ }
189
222
  commands.set(nameOrCli, def)
190
223
  return cli
191
224
  }
@@ -209,6 +242,7 @@ export function create(
209
242
  },
210
243
 
211
244
  async serve(argv = process.argv.slice(2), serveOptions: serve.Options = {}) {
245
+ if (pending.length > 0) await Promise.all(pending)
212
246
  return serveImpl(name, commands, argv, {
213
247
  ...serveOptions,
214
248
  aliases: def.aliases,
@@ -219,6 +253,7 @@ export function create(
219
253
  middlewares,
220
254
  outputPolicy: def.outputPolicy,
221
255
  rootCommand: rootDef,
256
+ rootFetch,
222
257
  sync: def.sync,
223
258
  vars: def.vars,
224
259
  version: def.version,
@@ -261,6 +296,8 @@ export declare namespace create {
261
296
  env?: env | undefined
262
297
  /** Usage examples for this command. */
263
298
  examples?: Example<args, options>[] | undefined
299
+ /** A fetch handler to use as the root command. All argv tokens are interpreted as path segments and curl-style flags. */
300
+ fetch?: FetchHandler | undefined
264
301
  /** Default output format. Overridden by `--format` or `--json`. */
265
302
  format?: Formatter.Format | undefined
266
303
  /** Zod schema for named options/flags. */
@@ -689,8 +726,8 @@ async function serveImpl(
689
726
  )
690
727
  return
691
728
  }
692
- if (options.rootCommand) {
693
- // Root command with no args — treat as root invocation
729
+ if (options.rootCommand || options.rootFetch) {
730
+ // Root command/fetch with no args — treat as root invocation
694
731
  } else {
695
732
  writeln(
696
733
  Help.formatRoot(name, {
@@ -708,7 +745,16 @@ async function serveImpl(
708
745
  const resolved =
709
746
  filtered.length === 0 && options.rootCommand
710
747
  ? { command: options.rootCommand, path: name, rest: [] as string[] }
711
- : resolveCommand(commands, filtered)
748
+ : filtered.length === 0 && options.rootFetch
749
+ ? { fetchGateway: { _fetch: true as const, fetch: options.rootFetch, description: options.description }, middlewares: [] as MiddlewareHandler[], path: name, rest: [] as string[] }
750
+ : resolveCommand(commands, filtered)
751
+
752
+ // --help on a fetch gateway → show fetch-specific help
753
+ if (help && 'fetchGateway' in resolved) {
754
+ const commandName = resolved.path === name ? name : `${name} ${resolved.path}`
755
+ writeln(formatFetchHelp(commandName, resolved.fetchGateway.description))
756
+ return
757
+ }
712
758
 
713
759
  // --help after a command → show help for that command
714
760
  if (help) {
@@ -749,7 +795,8 @@ async function serveImpl(
749
795
  }),
750
796
  )
751
797
  }
752
- } else {
798
+ } else if ('command' in resolved) {
799
+ const cmd = resolved.command
753
800
  const isRootCmd = resolved.path === name
754
801
  const commandName = isRootCmd ? name : `${name} ${resolved.path}`
755
802
  const helpSubcommands =
@@ -758,17 +805,17 @@ async function serveImpl(
758
805
  : undefined
759
806
  writeln(
760
807
  Help.formatCommand(commandName, {
761
- alias: resolved.command.alias as Record<string, string> | undefined,
808
+ alias: cmd.alias as Record<string, string> | undefined,
762
809
  aliases: isRootCmd ? options.aliases : undefined,
763
- description: resolved.command.description,
810
+ description: cmd.description,
764
811
  version: isRootCmd ? options.version : undefined,
765
- args: resolved.command.args,
766
- env: resolved.command.env,
812
+ args: cmd.args,
813
+ env: cmd.env,
767
814
  envSource: options.env,
768
- hint: resolved.command.hint,
769
- options: resolved.command.options,
770
- examples: formatExamples(resolved.command.examples),
771
- usage: resolved.command.usage,
815
+ hint: cmd.hint,
816
+ options: cmd.options,
817
+ examples: formatExamples(cmd.examples),
818
+ usage: cmd.usage,
772
819
  commands: helpSubcommands,
773
820
  root: isRootCmd,
774
821
  }),
@@ -790,14 +837,16 @@ async function serveImpl(
790
837
  const start = performance.now()
791
838
 
792
839
  // Resolve effective format: explicit --format/--json → command default → CLI default → toon
793
- const resolvedFormat = 'command' in resolved && resolved.command.format
840
+ const resolvedFormat = 'command' in resolved && (resolved as any).command.format
794
841
  const format = formatExplicit ? formatFlag : resolvedFormat || options.format || 'toon'
795
842
 
796
- // Fall back to root command when no subcommand matches
843
+ // Fall back to root fetch when no subcommand matches
797
844
  const effective =
798
- 'error' in resolved && options.rootCommand && !resolved.path
799
- ? { command: options.rootCommand, path: name, rest: filtered }
800
- : resolved
845
+ 'error' in resolved && options.rootFetch && !resolved.path
846
+ ? { fetchGateway: { _fetch: true as const, fetch: options.rootFetch, description: options.description }, middlewares: [] as MiddlewareHandler[], path: name, rest: filtered }
847
+ : 'error' in resolved && options.rootCommand && !resolved.path
848
+ ? { command: options.rootCommand, path: name, rest: filtered }
849
+ : resolved
801
850
 
802
851
  // Resolve outputPolicy: command/group → CLI-level → default ('all')
803
852
  const effectiveOutputPolicy =
@@ -852,6 +901,116 @@ async function serveImpl(
852
901
  return
853
902
  }
854
903
 
904
+ // Fetch gateway execution path
905
+ if ('fetchGateway' in effective) {
906
+ const { fetchGateway, path, rest: fetchRest } = effective
907
+ const fetchMiddleware = [
908
+ ...(options.middlewares ?? []),
909
+ ...((effective as any).middlewares ?? []),
910
+ ]
911
+
912
+ const runFetch = async () => {
913
+ const input = Fetch.parseArgv(fetchRest)
914
+ if (fetchGateway.basePath) input.path = fetchGateway.basePath + input.path
915
+ const request = Fetch.buildRequest(input)
916
+ const response = await fetchGateway.fetch(request)
917
+
918
+ // Streaming path — NDJSON responses pipe through handleStreaming
919
+ if (Fetch.isStreamingResponse(response)) {
920
+ const generator = Fetch.parseStreamingResponse(response)
921
+ await handleStreaming(generator, {
922
+ name,
923
+ path,
924
+ start,
925
+ format,
926
+ formatExplicit,
927
+ human,
928
+ renderOutput,
929
+ verbose,
930
+ write,
931
+ writeln,
932
+ exit,
933
+ })
934
+ return
935
+ }
936
+
937
+ const output = await Fetch.parseResponse(response)
938
+
939
+ if (output.ok) {
940
+ write({
941
+ ok: true,
942
+ data: output.data,
943
+ meta: {
944
+ command: path,
945
+ duration: `${Math.round(performance.now() - start)}ms`,
946
+ },
947
+ })
948
+ } else {
949
+ write({
950
+ ok: false,
951
+ error: {
952
+ code: `HTTP_${output.status}`,
953
+ message: typeof output.data === 'object' && output.data !== null && 'message' in output.data
954
+ ? String((output.data as any).message)
955
+ : typeof output.data === 'string' ? output.data : `HTTP ${output.status}`,
956
+ },
957
+ meta: {
958
+ command: path,
959
+ duration: `${Math.round(performance.now() - start)}ms`,
960
+ },
961
+ })
962
+ exit(1)
963
+ }
964
+ }
965
+
966
+ try {
967
+ const cliEnv = options.envSchema ? Parser.parseEnv(options.envSchema, options.env ?? process.env) : {}
968
+ if (fetchMiddleware.length > 0) {
969
+ const varsMap: Record<string, unknown> = options.vars ? options.vars.parse({}) : {}
970
+ const errorFn = (opts: { code: string; message: string; retryable?: boolean | undefined; cta?: CtaBlock | undefined }): never =>
971
+ ({ [sentinel]: 'error', ...opts }) as never
972
+ const mwCtx: MiddlewareContext = {
973
+ agent: !human,
974
+ command: path,
975
+ env: cliEnv,
976
+ error: errorFn,
977
+ name,
978
+ set(key: string, value: unknown) { varsMap[key] = value },
979
+ var: varsMap,
980
+ version: options.version,
981
+ }
982
+ const handleMwSentinel = (result: unknown) => {
983
+ if (!isSentinel(result) || result[sentinel] !== 'error') return
984
+ const cta = formatCtaBlock(name, result.cta)
985
+ write({
986
+ ok: false,
987
+ error: { code: result.code, message: result.message, ...(result.retryable !== undefined ? { retryable: result.retryable } : undefined) },
988
+ meta: { command: path, duration: `${Math.round(performance.now() - start)}ms`, ...(cta ? { cta } : undefined) },
989
+ })
990
+ exit(1)
991
+ }
992
+ const composed = fetchMiddleware.reduceRight(
993
+ (next: () => Promise<void>, mw) => async () => { handleMwSentinel(await mw(mwCtx, next)) },
994
+ runFetch,
995
+ )
996
+ await composed()
997
+ } else {
998
+ await runFetch()
999
+ }
1000
+ } catch (error) {
1001
+ write({
1002
+ ok: false,
1003
+ error: {
1004
+ code: error instanceof IncurError ? error.code : 'UNKNOWN',
1005
+ message: error instanceof Error ? error.message : String(error),
1006
+ },
1007
+ meta: { command: path, duration: `${Math.round(performance.now() - start)}ms` },
1008
+ })
1009
+ exit(1)
1010
+ }
1011
+ return
1012
+ }
1013
+
855
1014
  const { command, path, rest } = effective
856
1015
 
857
1016
  // Collect middleware: root CLI + groups traversed + per-command
@@ -1090,6 +1249,13 @@ function resolveCommand(
1090
1249
  path: string
1091
1250
  rest: string[]
1092
1251
  }
1252
+ | {
1253
+ fetchGateway: InternalFetchGateway
1254
+ middlewares: MiddlewareHandler[]
1255
+ outputPolicy?: OutputPolicy | undefined
1256
+ path: string
1257
+ rest: string[]
1258
+ }
1093
1259
  | {
1094
1260
  help: true
1095
1261
  path: string
@@ -1107,6 +1273,18 @@ function resolveCommand(
1107
1273
  let inheritedOutputPolicy: OutputPolicy | undefined
1108
1274
  const collectedMiddlewares: MiddlewareHandler[] = []
1109
1275
 
1276
+ // Fetch gateway — all remaining tokens go to the fetch handler
1277
+ if (isFetchGateway(entry)) {
1278
+ const outputPolicy = entry.outputPolicy ?? inheritedOutputPolicy
1279
+ return {
1280
+ fetchGateway: entry,
1281
+ middlewares: collectedMiddlewares,
1282
+ path: path.join(' '),
1283
+ rest: remaining,
1284
+ ...(outputPolicy ? { outputPolicy } : undefined),
1285
+ }
1286
+ }
1287
+
1110
1288
  while (isGroup(entry)) {
1111
1289
  if (entry.outputPolicy) inheritedOutputPolicy = entry.outputPolicy
1112
1290
  if (entry.middlewares) collectedMiddlewares.push(...entry.middlewares)
@@ -1127,6 +1305,17 @@ function resolveCommand(
1127
1305
  path.push(next)
1128
1306
  remaining = remaining.slice(1)
1129
1307
  entry = child
1308
+
1309
+ if (isFetchGateway(entry)) {
1310
+ const outputPolicy = entry.outputPolicy ?? inheritedOutputPolicy
1311
+ return {
1312
+ fetchGateway: entry,
1313
+ middlewares: collectedMiddlewares,
1314
+ path: path.join(' '),
1315
+ rest: remaining,
1316
+ ...(outputPolicy ? { outputPolicy } : undefined),
1317
+ }
1318
+ }
1130
1319
  }
1131
1320
 
1132
1321
  const outputPolicy = entry.outputPolicy ?? inheritedOutputPolicy
@@ -1161,6 +1350,8 @@ declare namespace serveImpl {
1161
1350
  | undefined
1162
1351
  /** Root command handler, invoked when no subcommand matches. */
1163
1352
  rootCommand?: CommandDefinition<any, any, any> | undefined
1353
+ /** Root fetch handler, invoked when no subcommand matches and no rootCommand is set. */
1354
+ rootFetch?: FetchHandler | undefined
1164
1355
  sync?:
1165
1356
  | {
1166
1357
  cwd?: string | undefined
@@ -1212,24 +1403,45 @@ function collectHelpCommands(
1212
1403
  ): { name: string; description?: string | undefined }[] {
1213
1404
  const result: { name: string; description?: string | undefined }[] = []
1214
1405
  for (const [name, entry] of commands) {
1215
- if (isGroup(entry)) result.push({ name, description: entry.description })
1216
- else result.push({ name, description: entry.description })
1406
+ result.push({ name, description: entry.description })
1217
1407
  }
1218
1408
  return result.sort((a, b) => a.name.localeCompare(b.name))
1219
1409
  }
1220
1410
 
1411
+ /** @internal Formats help text for a fetch gateway command. */
1412
+ function formatFetchHelp(name: string, description?: string): string {
1413
+ const lines: string[] = []
1414
+ if (description) lines.push(`${name} — ${description}`)
1415
+ else lines.push(name)
1416
+ lines.push('')
1417
+ lines.push(`Usage: ${name} <path> [options]`)
1418
+ lines.push('')
1419
+ lines.push('Path segments are joined into the request URL path.')
1420
+ lines.push('')
1421
+ lines.push('Options:')
1422
+ lines.push(' -X, --method <METHOD> HTTP method (default: GET, POST if body present)')
1423
+ lines.push(' -H, --header "Key: Val" Set a request header (repeatable)')
1424
+ lines.push(' -d, --data <json> Request body (implies POST)')
1425
+ lines.push(' --body <json> Request body (implies POST)')
1426
+ lines.push(' --<key> <value> Query string parameter')
1427
+ return lines.join('\n')
1428
+ }
1429
+
1221
1430
  /** Shape of the commands map accumulated through `.command()` chains. */
1222
1431
  export type CommandsMap = Record<
1223
1432
  string,
1224
1433
  { args: Record<string, unknown>; options: Record<string, unknown> }
1225
1434
  >
1226
1435
 
1227
- /** @internal Entry stored in a command map — either a leaf definition or a group. */
1228
- type CommandEntry = CommandDefinition<any, any, any> | InternalGroup
1436
+ /** @internal Entry stored in a command map — either a leaf definition, a group, or a fetch gateway. */
1437
+ type CommandEntry = CommandDefinition<any, any, any> | InternalGroup | InternalFetchGateway
1229
1438
 
1230
1439
  /** Controls when output data is displayed. `'all'` displays to both humans and agents. `'agent-only'` suppresses data output in human/TTY mode. */
1231
1440
  export type OutputPolicy = 'agent-only' | 'all'
1232
1441
 
1442
+ /** A standard Fetch API handler. */
1443
+ type FetchHandler = (req: Request) => Response | Promise<Response>
1444
+
1233
1445
  /** @internal A command group's internal storage. */
1234
1446
  type InternalGroup = {
1235
1447
  _group: true
@@ -1239,11 +1451,25 @@ type InternalGroup = {
1239
1451
  commands: Map<string, CommandEntry>
1240
1452
  }
1241
1453
 
1454
+ /** @internal A fetch gateway entry. */
1455
+ type InternalFetchGateway = {
1456
+ _fetch: true
1457
+ basePath?: string | undefined
1458
+ description?: string | undefined
1459
+ fetch: FetchHandler
1460
+ outputPolicy?: OutputPolicy | undefined
1461
+ }
1462
+
1242
1463
  /** @internal Type guard for command groups. */
1243
1464
  function isGroup(entry: CommandEntry): entry is InternalGroup {
1244
1465
  return '_group' in entry
1245
1466
  }
1246
1467
 
1468
+ /** @internal Type guard for fetch gateways. */
1469
+ function isFetchGateway(entry: CommandEntry): entry is InternalFetchGateway {
1470
+ return '_fetch' in entry
1471
+ }
1472
+
1247
1473
  /** @internal Maps CLI instances to their command maps. */
1248
1474
  export const toCommands = new WeakMap<Cli, Map<string, CommandEntry>>()
1249
1475
 
@@ -1570,7 +1796,11 @@ function collectCommands(
1570
1796
  const result: ReturnType<typeof collectCommands> = []
1571
1797
  for (const [name, entry] of commands) {
1572
1798
  const path = [...prefix, name]
1573
- if (isGroup(entry)) {
1799
+ if (isFetchGateway(entry)) {
1800
+ const cmd: (typeof result)[number] = { name: path.join(' ') }
1801
+ if (entry.description) cmd.description = entry.description
1802
+ result.push(cmd)
1803
+ } else if (isGroup(entry)) {
1574
1804
  result.push(...collectCommands(entry.commands, path))
1575
1805
  } else {
1576
1806
  const cmd: (typeof result)[number] = { name: path.join(' ') }
@@ -1609,7 +1839,12 @@ function collectSkillCommands(
1609
1839
  const result: Skill.CommandInfo[] = []
1610
1840
  for (const [name, entry] of commands) {
1611
1841
  const path = [...prefix, name]
1612
- if (isGroup(entry)) {
1842
+ if (isFetchGateway(entry)) {
1843
+ const cmd: Skill.CommandInfo = { name: path.join(' ') }
1844
+ if (entry.description) cmd.description = entry.description
1845
+ cmd.hint = 'Fetch gateway. Pass path segments and curl-style flags (-X, -H, -d, --key value).'
1846
+ result.push(cmd)
1847
+ } else if (isGroup(entry)) {
1613
1848
  if (entry.description) groups.set(path.join(' '), entry.description)
1614
1849
  result.push(...collectSkillCommands(entry.commands, path, groups))
1615
1850
  } else {