incur 0.1.16 → 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,8 +242,10 @@ 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,
248
+ aliases: def.aliases,
214
249
  description: def.description,
215
250
  envSchema: def.env,
216
251
  format: def.format,
@@ -218,6 +253,7 @@ export function create(
218
253
  middlewares,
219
254
  outputPolicy: def.outputPolicy,
220
255
  rootCommand: rootDef,
256
+ rootFetch,
221
257
  sync: def.sync,
222
258
  vars: def.vars,
223
259
  version: def.version,
@@ -250,6 +286,8 @@ export declare namespace create {
250
286
  alias?: options extends z.ZodObject<any>
251
287
  ? Partial<Record<keyof z.output<options>, string>>
252
288
  : Record<string, string> | undefined
289
+ /** Alternative binary names for this CLI (e.g. shorter aliases in package.json `bin`). Shell completions are registered for all names. */
290
+ aliases?: string[] | undefined
253
291
  /** Zod schema for positional arguments. */
254
292
  args?: args | undefined
255
293
  /** A short description of what the CLI does. */
@@ -258,6 +296,8 @@ export declare namespace create {
258
296
  env?: env | undefined
259
297
  /** Usage examples for this command. */
260
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
261
301
  /** Default output format. Overridden by `--format` or `--json`. */
262
302
  format?: Formatter.Format | undefined
263
303
  /** Zod schema for named options/flags. */
@@ -379,8 +419,9 @@ async function serveImpl(
379
419
  const sepIdx = argv.indexOf('--')
380
420
  const words = sepIdx !== -1 ? argv.slice(sepIdx + 1) : argv
381
421
  if (words.length === 0) {
382
- // Registration mode: print shell hook script
383
- stdout(Completions.register(completeShell, name))
422
+ // Registration mode: print shell hook script for primary name + aliases
423
+ const names = [name, ...(options.aliases ?? [])]
424
+ stdout(names.map((n) => Completions.register(completeShell, n)).join('\n'))
384
425
  } else {
385
426
  const index = Number(process.env._COMPLETE_INDEX ?? words.length - 1)
386
427
  const candidates = Completions.complete(commands, options.rootCommand, words, index)
@@ -501,7 +542,8 @@ async function serveImpl(
501
542
  exit(1)
502
543
  return
503
544
  }
504
- writeln(Completions.register(shell as Completions.Shell, name))
545
+ const names = [name, ...(options.aliases ?? [])]
546
+ writeln(names.map((n) => Completions.register(shell as Completions.Shell, n)).join('\n'))
505
547
  return
506
548
  }
507
549
 
@@ -668,6 +710,7 @@ async function serveImpl(
668
710
  writeln(
669
711
  Help.formatCommand(name, {
670
712
  alias: cmd.alias as Record<string, string> | undefined,
713
+ aliases: options.aliases,
671
714
  description: cmd.description ?? options.description,
672
715
  version: options.version,
673
716
  args: cmd.args,
@@ -683,11 +726,12 @@ async function serveImpl(
683
726
  )
684
727
  return
685
728
  }
686
- if (options.rootCommand) {
687
- // 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
688
731
  } else {
689
732
  writeln(
690
733
  Help.formatRoot(name, {
734
+ aliases: options.aliases,
691
735
  description: options.description,
692
736
  version: options.version,
693
737
  commands: collectHelpCommands(commands),
@@ -701,7 +745,16 @@ async function serveImpl(
701
745
  const resolved =
702
746
  filtered.length === 0 && options.rootCommand
703
747
  ? { command: options.rootCommand, path: name, rest: [] as string[] }
704
- : 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
+ }
705
758
 
706
759
  // --help after a command → show help for that command
707
760
  if (help) {
@@ -717,6 +770,7 @@ async function serveImpl(
717
770
  writeln(
718
771
  Help.formatCommand(name, {
719
772
  alias: cmd.alias as Record<string, string> | undefined,
773
+ aliases: options.aliases,
720
774
  description: cmd.description ?? options.description,
721
775
  version: options.version,
722
776
  args: cmd.args,
@@ -733,6 +787,7 @@ async function serveImpl(
733
787
  } else {
734
788
  writeln(
735
789
  Help.formatRoot(helpName, {
790
+ aliases: isRoot ? options.aliases : undefined,
736
791
  description: helpDesc,
737
792
  version: isRoot ? options.version : undefined,
738
793
  commands: collectHelpCommands(helpCmds),
@@ -740,7 +795,8 @@ async function serveImpl(
740
795
  }),
741
796
  )
742
797
  }
743
- } else {
798
+ } else if ('command' in resolved) {
799
+ const cmd = resolved.command
744
800
  const isRootCmd = resolved.path === name
745
801
  const commandName = isRootCmd ? name : `${name} ${resolved.path}`
746
802
  const helpSubcommands =
@@ -749,16 +805,17 @@ async function serveImpl(
749
805
  : undefined
750
806
  writeln(
751
807
  Help.formatCommand(commandName, {
752
- alias: resolved.command.alias as Record<string, string> | undefined,
753
- description: resolved.command.description,
808
+ alias: cmd.alias as Record<string, string> | undefined,
809
+ aliases: isRootCmd ? options.aliases : undefined,
810
+ description: cmd.description,
754
811
  version: isRootCmd ? options.version : undefined,
755
- args: resolved.command.args,
756
- env: resolved.command.env,
812
+ args: cmd.args,
813
+ env: cmd.env,
757
814
  envSource: options.env,
758
- hint: resolved.command.hint,
759
- options: resolved.command.options,
760
- examples: formatExamples(resolved.command.examples),
761
- usage: resolved.command.usage,
815
+ hint: cmd.hint,
816
+ options: cmd.options,
817
+ examples: formatExamples(cmd.examples),
818
+ usage: cmd.usage,
762
819
  commands: helpSubcommands,
763
820
  root: isRootCmd,
764
821
  }),
@@ -780,14 +837,16 @@ async function serveImpl(
780
837
  const start = performance.now()
781
838
 
782
839
  // Resolve effective format: explicit --format/--json → command default → CLI default → toon
783
- const resolvedFormat = 'command' in resolved && resolved.command.format
840
+ const resolvedFormat = 'command' in resolved && (resolved as any).command.format
784
841
  const format = formatExplicit ? formatFlag : resolvedFormat || options.format || 'toon'
785
842
 
786
- // Fall back to root command when no subcommand matches
843
+ // Fall back to root fetch when no subcommand matches
787
844
  const effective =
788
- 'error' in resolved && options.rootCommand && !resolved.path
789
- ? { command: options.rootCommand, path: name, rest: filtered }
790
- : 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
791
850
 
792
851
  // Resolve outputPolicy: command/group → CLI-level → default ('all')
793
852
  const effectiveOutputPolicy =
@@ -842,6 +901,116 @@ async function serveImpl(
842
901
  return
843
902
  }
844
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
+
845
1014
  const { command, path, rest } = effective
846
1015
 
847
1016
  // Collect middleware: root CLI + groups traversed + per-command
@@ -1080,6 +1249,13 @@ function resolveCommand(
1080
1249
  path: string
1081
1250
  rest: string[]
1082
1251
  }
1252
+ | {
1253
+ fetchGateway: InternalFetchGateway
1254
+ middlewares: MiddlewareHandler[]
1255
+ outputPolicy?: OutputPolicy | undefined
1256
+ path: string
1257
+ rest: string[]
1258
+ }
1083
1259
  | {
1084
1260
  help: true
1085
1261
  path: string
@@ -1097,6 +1273,18 @@ function resolveCommand(
1097
1273
  let inheritedOutputPolicy: OutputPolicy | undefined
1098
1274
  const collectedMiddlewares: MiddlewareHandler[] = []
1099
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
+
1100
1288
  while (isGroup(entry)) {
1101
1289
  if (entry.outputPolicy) inheritedOutputPolicy = entry.outputPolicy
1102
1290
  if (entry.middlewares) collectedMiddlewares.push(...entry.middlewares)
@@ -1117,6 +1305,17 @@ function resolveCommand(
1117
1305
  path.push(next)
1118
1306
  remaining = remaining.slice(1)
1119
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
+ }
1120
1319
  }
1121
1320
 
1122
1321
  const outputPolicy = entry.outputPolicy ?? inheritedOutputPolicy
@@ -1132,6 +1331,8 @@ function resolveCommand(
1132
1331
  /** @internal Options for serveImpl, extending public serve.Options with internal metadata. */
1133
1332
  declare namespace serveImpl {
1134
1333
  type Options = serve.Options & {
1334
+ /** Alternative binary names for this CLI. */
1335
+ aliases?: string[] | undefined
1135
1336
  description?: string | undefined
1136
1337
  /** CLI-level env schema. Parsed before middleware runs. */
1137
1338
  envSchema?: z.ZodObject<any> | undefined
@@ -1149,6 +1350,8 @@ declare namespace serveImpl {
1149
1350
  | undefined
1150
1351
  /** Root command handler, invoked when no subcommand matches. */
1151
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
1152
1355
  sync?:
1153
1356
  | {
1154
1357
  cwd?: string | undefined
@@ -1200,24 +1403,45 @@ function collectHelpCommands(
1200
1403
  ): { name: string; description?: string | undefined }[] {
1201
1404
  const result: { name: string; description?: string | undefined }[] = []
1202
1405
  for (const [name, entry] of commands) {
1203
- if (isGroup(entry)) result.push({ name, description: entry.description })
1204
- else result.push({ name, description: entry.description })
1406
+ result.push({ name, description: entry.description })
1205
1407
  }
1206
1408
  return result.sort((a, b) => a.name.localeCompare(b.name))
1207
1409
  }
1208
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
+
1209
1430
  /** Shape of the commands map accumulated through `.command()` chains. */
1210
1431
  export type CommandsMap = Record<
1211
1432
  string,
1212
1433
  { args: Record<string, unknown>; options: Record<string, unknown> }
1213
1434
  >
1214
1435
 
1215
- /** @internal Entry stored in a command map — either a leaf definition or a group. */
1216
- 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
1217
1438
 
1218
1439
  /** Controls when output data is displayed. `'all'` displays to both humans and agents. `'agent-only'` suppresses data output in human/TTY mode. */
1219
1440
  export type OutputPolicy = 'agent-only' | 'all'
1220
1441
 
1442
+ /** A standard Fetch API handler. */
1443
+ type FetchHandler = (req: Request) => Response | Promise<Response>
1444
+
1221
1445
  /** @internal A command group's internal storage. */
1222
1446
  type InternalGroup = {
1223
1447
  _group: true
@@ -1227,11 +1451,25 @@ type InternalGroup = {
1227
1451
  commands: Map<string, CommandEntry>
1228
1452
  }
1229
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
+
1230
1463
  /** @internal Type guard for command groups. */
1231
1464
  function isGroup(entry: CommandEntry): entry is InternalGroup {
1232
1465
  return '_group' in entry
1233
1466
  }
1234
1467
 
1468
+ /** @internal Type guard for fetch gateways. */
1469
+ function isFetchGateway(entry: CommandEntry): entry is InternalFetchGateway {
1470
+ return '_fetch' in entry
1471
+ }
1472
+
1235
1473
  /** @internal Maps CLI instances to their command maps. */
1236
1474
  export const toCommands = new WeakMap<Cli, Map<string, CommandEntry>>()
1237
1475
 
@@ -1558,7 +1796,11 @@ function collectCommands(
1558
1796
  const result: ReturnType<typeof collectCommands> = []
1559
1797
  for (const [name, entry] of commands) {
1560
1798
  const path = [...prefix, name]
1561
- 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)) {
1562
1804
  result.push(...collectCommands(entry.commands, path))
1563
1805
  } else {
1564
1806
  const cmd: (typeof result)[number] = { name: path.join(' ') }
@@ -1597,7 +1839,12 @@ function collectSkillCommands(
1597
1839
  const result: Skill.CommandInfo[] = []
1598
1840
  for (const [name, entry] of commands) {
1599
1841
  const path = [...prefix, name]
1600
- 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)) {
1601
1848
  if (entry.description) groups.set(path.join(' '), entry.description)
1602
1849
  result.push(...collectSkillCommands(entry.commands, path, groups))
1603
1850
  } else {
@@ -324,6 +324,55 @@ describe('completions built-in command', () => {
324
324
  })
325
325
  })
326
326
 
327
+ describe('aliases', () => {
328
+ function makeAliasedCli() {
329
+ const cli = Cli.create('my-tool', {
330
+ version: '1.0.0',
331
+ description: 'A test CLI',
332
+ aliases: ['mt', 'myt'],
333
+ })
334
+ cli.command('fetch', {
335
+ description: 'Fetch a URL',
336
+ run() {
337
+ return { ok: true }
338
+ },
339
+ })
340
+ return cli
341
+ }
342
+
343
+ test('completions fish outputs registration for all names', async () => {
344
+ const cli = makeAliasedCli()
345
+ const output = await serve(cli, ['completions', 'fish'])
346
+ expect(output).toContain('--command my-tool')
347
+ expect(output).toContain('--command mt')
348
+ expect(output).toContain('--command myt')
349
+ })
350
+
351
+ test('completions bash outputs registration for all names', async () => {
352
+ const cli = makeAliasedCli()
353
+ const output = await serve(cli, ['completions', 'bash'])
354
+ expect(output).toContain('_incur_complete_my_tool()')
355
+ expect(output).toContain('_incur_complete_mt()')
356
+ expect(output).toContain('_incur_complete_myt()')
357
+ })
358
+
359
+ test('completions zsh outputs registration for all names', async () => {
360
+ const cli = makeAliasedCli()
361
+ const output = await serve(cli, ['completions', 'zsh'])
362
+ expect(output).toContain('#compdef my-tool')
363
+ expect(output).toContain('compdef _incur_complete_mt mt')
364
+ expect(output).toContain('compdef _incur_complete_myt myt')
365
+ })
366
+
367
+ test('COMPLETE env var registers all names', async () => {
368
+ const cli = makeAliasedCli()
369
+ const output = await serve(cli, [], { COMPLETE: 'fish' })
370
+ expect(output).toContain('--command my-tool')
371
+ expect(output).toContain('--command mt')
372
+ expect(output).toContain('--command myt')
373
+ })
374
+ })
375
+
327
376
  describe('serve integration', () => {
328
377
  test('COMPLETE=bash with no words outputs registration script', async () => {
329
378
  const cli = makeCli()