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/dist/Cli.js CHANGED
@@ -1,5 +1,8 @@
1
1
  import * as Completions from './Completions.js';
2
+ import * as Filter from './Filter.js';
2
3
  import { IncurError, ValidationError } from './Errors.js';
4
+ import * as Fetch from './Fetch.js';
5
+ import * as Openapi from './Openapi.js';
3
6
  import * as Formatter from './Formatter.js';
4
7
  import * as Help from './Help.js';
5
8
  import { detectRunner } from './internal/pm.js';
@@ -13,8 +16,11 @@ export function create(nameOrDefinition, definition) {
13
16
  const name = typeof nameOrDefinition === 'string' ? nameOrDefinition : nameOrDefinition.name;
14
17
  const def = typeof nameOrDefinition === 'string' ? (definition ?? {}) : nameOrDefinition;
15
18
  const rootDef = 'run' in def ? def : undefined;
19
+ const rootFetch = 'fetch' in def ? def.fetch : undefined;
16
20
  const commands = new Map();
17
21
  const middlewares = [];
22
+ const pending = [];
23
+ const mcpHandler = createMcpHttpHandler(name, def.version ?? '0.0.0');
18
24
  const cli = {
19
25
  name,
20
26
  description: def.description,
@@ -22,6 +28,28 @@ export function create(nameOrDefinition, definition) {
22
28
  vars: def.vars,
23
29
  command(nameOrCli, def) {
24
30
  if (typeof nameOrCli === 'string') {
31
+ if (def && 'fetch' in def && typeof def.fetch === 'function') {
32
+ // OpenAPI + fetch → generate typed command group (async, resolved before serve)
33
+ if (def.openapi) {
34
+ pending.push(Openapi.generateCommands(def.openapi, def.fetch, { basePath: def.basePath }).then((generated) => {
35
+ commands.set(nameOrCli, {
36
+ _group: true,
37
+ description: def.description,
38
+ commands: generated,
39
+ ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined),
40
+ });
41
+ }));
42
+ return cli;
43
+ }
44
+ commands.set(nameOrCli, {
45
+ _fetch: true,
46
+ basePath: def.basePath,
47
+ description: def.description,
48
+ fetch: def.fetch,
49
+ ...(def.outputPolicy ? { outputPolicy: def.outputPolicy } : undefined),
50
+ });
51
+ return cli;
52
+ }
25
53
  commands.set(nameOrCli, def);
26
54
  return cli;
27
55
  }
@@ -43,7 +71,19 @@ export function create(nameOrDefinition, definition) {
43
71
  });
44
72
  return cli;
45
73
  },
74
+ async fetch(req) {
75
+ if (pending.length > 0)
76
+ await Promise.all(pending);
77
+ return fetchImpl(name, commands, req, {
78
+ mcpHandler,
79
+ middlewares,
80
+ rootCommand: rootDef,
81
+ vars: def.vars,
82
+ });
83
+ },
46
84
  async serve(argv = process.argv.slice(2), serveOptions = {}) {
85
+ if (pending.length > 0)
86
+ await Promise.all(pending);
47
87
  return serveImpl(name, commands, argv, {
48
88
  ...serveOptions,
49
89
  aliases: def.aliases,
@@ -54,6 +94,7 @@ export function create(nameOrDefinition, definition) {
54
94
  middlewares,
55
95
  outputPolicy: def.outputPolicy,
56
96
  rootCommand: rootDef,
97
+ rootFetch,
57
98
  sync: def.sync,
58
99
  vars: def.vars,
59
100
  version: def.version,
@@ -77,7 +118,7 @@ export function create(nameOrDefinition, definition) {
77
118
  async function serveImpl(name, commands, argv, options = {}) {
78
119
  const stdout = options.stdout ?? ((s) => process.stdout.write(s));
79
120
  const exit = options.exit ?? ((code) => process.exit(code));
80
- const { verbose, format: formatFlag, formatExplicit, llms, mcp: mcpFlag, help, version, rest: filtered, } = extractBuiltinFlags(argv);
121
+ const { verbose, format: formatFlag, formatExplicit, filterOutput, llms, mcp: mcpFlag, help, version, schema, rest: filtered, } = extractBuiltinFlags(argv);
81
122
  // --mcp: start as MCP stdio server
82
123
  if (mcpFlag) {
83
124
  await Mcp.serve(name, options.version ?? '0.0.0', commands);
@@ -109,7 +150,7 @@ async function serveImpl(name, commands, argv, options = {}) {
109
150
  stdout(s.endsWith('\n') ? s : `${s}\n`);
110
151
  }
111
152
  // Skills staleness check (skip for built-in commands)
112
- if (!llms && !help && !version) {
153
+ if (!llms && !schema && !help && !version) {
113
154
  const isSkillsAdd = filtered[0] === 'skills' || (filtered[0] === name && filtered[1] === 'skills');
114
155
  const isMcpAdd = filtered[0] === 'mcp' || (filtered[0] === name && filtered[1] === 'mcp');
115
156
  if (!isSkillsAdd && !isMcpAdd) {
@@ -363,8 +404,8 @@ async function serveImpl(name, commands, argv, options = {}) {
363
404
  }));
364
405
  return;
365
406
  }
366
- if (options.rootCommand) {
367
- // Root command with no args — treat as root invocation
407
+ if (options.rootCommand || options.rootFetch) {
408
+ // Root command/fetch with no args — treat as root invocation
368
409
  }
369
410
  else {
370
411
  writeln(Help.formatRoot(name, {
@@ -379,7 +420,15 @@ async function serveImpl(name, commands, argv, options = {}) {
379
420
  }
380
421
  const resolved = filtered.length === 0 && options.rootCommand
381
422
  ? { command: options.rootCommand, path: name, rest: [] }
382
- : resolveCommand(commands, filtered);
423
+ : filtered.length === 0 && options.rootFetch
424
+ ? { fetchGateway: { _fetch: true, fetch: options.rootFetch, description: options.description }, middlewares: [], path: name, rest: [] }
425
+ : resolveCommand(commands, filtered);
426
+ // --help on a fetch gateway → show fetch-specific help
427
+ if (help && 'fetchGateway' in resolved) {
428
+ const commandName = resolved.path === name ? name : `${name} ${resolved.path}`;
429
+ writeln(formatFetchHelp(commandName, resolved.fetchGateway.description));
430
+ return;
431
+ }
383
432
  // --help after a command → show help for that command
384
433
  if (help) {
385
434
  if ('help' in resolved || 'error' in resolved) {
@@ -417,30 +466,65 @@ async function serveImpl(name, commands, argv, options = {}) {
417
466
  }));
418
467
  }
419
468
  }
420
- else {
469
+ else if ('command' in resolved) {
470
+ const cmd = resolved.command;
421
471
  const isRootCmd = resolved.path === name;
422
472
  const commandName = isRootCmd ? name : `${name} ${resolved.path}`;
423
473
  const helpSubcommands = isRootCmd && options.rootCommand && commands.size > 0
424
474
  ? collectHelpCommands(commands)
425
475
  : undefined;
426
476
  writeln(Help.formatCommand(commandName, {
427
- alias: resolved.command.alias,
477
+ alias: cmd.alias,
428
478
  aliases: isRootCmd ? options.aliases : undefined,
429
- description: resolved.command.description,
479
+ description: cmd.description,
430
480
  version: isRootCmd ? options.version : undefined,
431
- args: resolved.command.args,
432
- env: resolved.command.env,
481
+ args: cmd.args,
482
+ env: cmd.env,
433
483
  envSource: options.env,
434
- hint: resolved.command.hint,
435
- options: resolved.command.options,
436
- examples: formatExamples(resolved.command.examples),
437
- usage: resolved.command.usage,
484
+ hint: cmd.hint,
485
+ options: cmd.options,
486
+ examples: formatExamples(cmd.examples),
487
+ usage: cmd.usage,
438
488
  commands: helpSubcommands,
439
489
  root: isRootCmd,
440
490
  }));
441
491
  }
442
492
  return;
443
493
  }
494
+ // --schema: output JSON Schema for a command's args, env, options, output
495
+ if (schema) {
496
+ if ('help' in resolved) {
497
+ writeln(Help.formatRoot(`${name} ${resolved.path}`, {
498
+ description: resolved.description,
499
+ commands: collectHelpCommands(resolved.commands),
500
+ }));
501
+ return;
502
+ }
503
+ if ('error' in resolved) {
504
+ const parent = resolved.path ? `${name} ${resolved.path}` : name;
505
+ writeln(`Error: '${resolved.error}' is not a command for '${parent}'.`);
506
+ exit(1);
507
+ return;
508
+ }
509
+ if ('fetchGateway' in resolved) {
510
+ writeln('--schema is not supported for fetch commands.');
511
+ exit(1);
512
+ return;
513
+ }
514
+ const cmd = resolved.command;
515
+ const format = formatExplicit ? formatFlag : 'toon';
516
+ const result = {};
517
+ if (cmd.args)
518
+ result.args = Schema.toJsonSchema(cmd.args);
519
+ if (cmd.env)
520
+ result.env = Schema.toJsonSchema(cmd.env);
521
+ if (cmd.options)
522
+ result.options = Schema.toJsonSchema(cmd.options);
523
+ if (cmd.output)
524
+ result.output = Schema.toJsonSchema(cmd.output);
525
+ writeln(Formatter.format(result, format));
526
+ return;
527
+ }
444
528
  if ('help' in resolved) {
445
529
  writeln(Help.formatRoot(`${name} ${resolved.path}`, {
446
530
  description: resolved.description,
@@ -452,14 +536,19 @@ async function serveImpl(name, commands, argv, options = {}) {
452
536
  // Resolve effective format: explicit --format/--json → command default → CLI default → toon
453
537
  const resolvedFormat = 'command' in resolved && resolved.command.format;
454
538
  const format = formatExplicit ? formatFlag : resolvedFormat || options.format || 'toon';
455
- // Fall back to root command when no subcommand matches
456
- const effective = 'error' in resolved && options.rootCommand && !resolved.path
457
- ? { command: options.rootCommand, path: name, rest: filtered }
458
- : resolved;
539
+ // Fall back to root fetch when no subcommand matches
540
+ const effective = 'error' in resolved && options.rootFetch && !resolved.path
541
+ ? { fetchGateway: { _fetch: true, fetch: options.rootFetch, description: options.description }, middlewares: [], path: name, rest: filtered }
542
+ : 'error' in resolved && options.rootCommand && !resolved.path
543
+ ? { command: options.rootCommand, path: name, rest: filtered }
544
+ : resolved;
459
545
  // Resolve outputPolicy: command/group → CLI-level → default ('all')
460
546
  const effectiveOutputPolicy = ('outputPolicy' in resolved && resolved.outputPolicy) || options.outputPolicy;
461
547
  const renderOutput = !(human && !formatExplicit && effectiveOutputPolicy === 'agent-only');
548
+ const filterPaths = filterOutput ? Filter.parse(filterOutput) : undefined;
462
549
  function write(output) {
550
+ if (filterPaths && output.ok && output.data != null)
551
+ output = { ...output, data: Filter.apply(output.data, filterPaths) };
463
552
  const cta = output.meta.cta;
464
553
  if (human && !verbose) {
465
554
  if (output.ok && output.data != null && renderOutput)
@@ -508,6 +597,114 @@ async function serveImpl(name, commands, argv, options = {}) {
508
597
  exit(1);
509
598
  return;
510
599
  }
600
+ // Fetch gateway execution path
601
+ if ('fetchGateway' in effective) {
602
+ const { fetchGateway, path, rest: fetchRest } = effective;
603
+ const fetchMiddleware = [
604
+ ...(options.middlewares ?? []),
605
+ ...(effective.middlewares ?? []),
606
+ ];
607
+ const runFetch = async () => {
608
+ const input = Fetch.parseArgv(fetchRest);
609
+ if (fetchGateway.basePath)
610
+ input.path = fetchGateway.basePath + input.path;
611
+ const request = Fetch.buildRequest(input);
612
+ const response = await fetchGateway.fetch(request);
613
+ // Streaming path — NDJSON responses pipe through handleStreaming
614
+ if (Fetch.isStreamingResponse(response)) {
615
+ const generator = Fetch.parseStreamingResponse(response);
616
+ await handleStreaming(generator, {
617
+ name,
618
+ path,
619
+ start,
620
+ format,
621
+ formatExplicit,
622
+ human,
623
+ renderOutput,
624
+ verbose,
625
+ write,
626
+ writeln,
627
+ exit,
628
+ });
629
+ return;
630
+ }
631
+ const output = await Fetch.parseResponse(response);
632
+ if (output.ok) {
633
+ write({
634
+ ok: true,
635
+ data: output.data,
636
+ meta: {
637
+ command: path,
638
+ duration: `${Math.round(performance.now() - start)}ms`,
639
+ },
640
+ });
641
+ }
642
+ else {
643
+ write({
644
+ ok: false,
645
+ error: {
646
+ code: `HTTP_${output.status}`,
647
+ message: typeof output.data === 'object' && output.data !== null && 'message' in output.data
648
+ ? String(output.data.message)
649
+ : typeof output.data === 'string' ? output.data : `HTTP ${output.status}`,
650
+ },
651
+ meta: {
652
+ command: path,
653
+ duration: `${Math.round(performance.now() - start)}ms`,
654
+ },
655
+ });
656
+ exit(1);
657
+ }
658
+ };
659
+ try {
660
+ const cliEnv = options.envSchema ? Parser.parseEnv(options.envSchema, options.env ?? process.env) : {};
661
+ if (fetchMiddleware.length > 0) {
662
+ const varsMap = options.vars ? options.vars.parse({}) : {};
663
+ const errorFn = (opts) => ({ [sentinel]: 'error', ...opts });
664
+ const mwCtx = {
665
+ agent: !human,
666
+ command: path,
667
+ env: cliEnv,
668
+ error: errorFn,
669
+ format,
670
+ formatExplicit,
671
+ name,
672
+ set(key, value) { varsMap[key] = value; },
673
+ var: varsMap,
674
+ version: options.version,
675
+ };
676
+ const handleMwSentinel = (result) => {
677
+ if (!isSentinel(result) || result[sentinel] !== 'error')
678
+ return;
679
+ const err = result;
680
+ const cta = formatCtaBlock(name, err.cta);
681
+ write({
682
+ ok: false,
683
+ error: { code: err.code, message: err.message, ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined) },
684
+ meta: { command: path, duration: `${Math.round(performance.now() - start)}ms`, ...(cta ? { cta } : undefined) },
685
+ });
686
+ exit(err.exitCode ?? 1);
687
+ };
688
+ const composed = fetchMiddleware.reduceRight((next, mw) => async () => { handleMwSentinel(await mw(mwCtx, next)); }, runFetch);
689
+ await composed();
690
+ }
691
+ else {
692
+ await runFetch();
693
+ }
694
+ }
695
+ catch (error) {
696
+ write({
697
+ ok: false,
698
+ error: {
699
+ code: error instanceof IncurError ? error.code : 'UNKNOWN',
700
+ message: error instanceof Error ? error.message : String(error),
701
+ },
702
+ meta: { command: path, duration: `${Math.round(performance.now() - start)}ms` },
703
+ });
704
+ exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1);
705
+ }
706
+ return;
707
+ }
511
708
  const { command, path, rest } = effective;
512
709
  // Collect middleware: root CLI + groups traversed + per-command
513
710
  const allMiddleware = [
@@ -539,10 +736,12 @@ async function serveImpl(name, commands, argv, options = {}) {
539
736
  agent: !human,
540
737
  args,
541
738
  env,
739
+ error: errorFn,
740
+ format,
741
+ formatExplicit,
542
742
  name,
543
- options: parsedOptions,
544
743
  ok: okFn,
545
- error: errorFn,
744
+ options: parsedOptions,
546
745
  var: varsMap,
547
746
  version: options.version,
548
747
  });
@@ -578,12 +777,13 @@ async function serveImpl(name, commands, argv, options = {}) {
578
777
  });
579
778
  }
580
779
  else {
780
+ const err = awaited;
581
781
  write({
582
782
  ok: false,
583
783
  error: {
584
- code: awaited.code,
585
- message: awaited.message,
586
- ...(awaited.retryable !== undefined ? { retryable: awaited.retryable } : undefined),
784
+ code: err.code,
785
+ message: err.message,
786
+ ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined),
587
787
  },
588
788
  meta: {
589
789
  command: path,
@@ -591,7 +791,7 @@ async function serveImpl(name, commands, argv, options = {}) {
591
791
  ...(cta ? { cta } : undefined),
592
792
  },
593
793
  });
594
- exit(1);
794
+ exit(err.exitCode ?? 1);
595
795
  }
596
796
  }
597
797
  else {
@@ -616,6 +816,8 @@ async function serveImpl(name, commands, argv, options = {}) {
616
816
  command: path,
617
817
  env: cliEnv,
618
818
  error: errorFn,
819
+ format,
820
+ formatExplicit,
619
821
  name,
620
822
  set(key, value) {
621
823
  varsMap[key] = value;
@@ -626,13 +828,14 @@ async function serveImpl(name, commands, argv, options = {}) {
626
828
  const handleMwSentinel = (result) => {
627
829
  if (!isSentinel(result) || result[sentinel] !== 'error')
628
830
  return;
629
- const cta = formatCtaBlock(name, result.cta);
831
+ const err = result;
832
+ const cta = formatCtaBlock(name, err.cta);
630
833
  write({
631
834
  ok: false,
632
835
  error: {
633
- code: result.code,
634
- message: result.message,
635
- ...(result.retryable !== undefined ? { retryable: result.retryable } : undefined),
836
+ code: err.code,
837
+ message: err.message,
838
+ ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined),
636
839
  },
637
840
  meta: {
638
841
  command: path,
@@ -640,7 +843,7 @@ async function serveImpl(name, commands, argv, options = {}) {
640
843
  ...(cta ? { cta } : undefined),
641
844
  },
642
845
  });
643
- exit(1);
846
+ exit(err.exitCode ?? 1);
644
847
  };
645
848
  const composed = allMiddleware.reduceRight((next, mw) => async () => {
646
849
  handleMwSentinel(await mw(mwCtx, next));
@@ -675,9 +878,236 @@ async function serveImpl(name, commands, argv, options = {}) {
675
878
  return;
676
879
  }
677
880
  write(errorOutput);
678
- exit(1);
881
+ exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1);
679
882
  }
680
883
  }
884
+ /** @internal Creates a lazy MCP HTTP handler scoped to a CLI instance. */
885
+ function createMcpHttpHandler(name, version) {
886
+ let transport;
887
+ return async (req, commands) => {
888
+ if (!transport) {
889
+ const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js');
890
+ const { WebStandardStreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js');
891
+ const server = new McpServer({ name, version });
892
+ for (const tool of Mcp.collectTools(commands, [])) {
893
+ const mergedShape = {
894
+ ...tool.command.args?.shape,
895
+ ...tool.command.options?.shape,
896
+ };
897
+ const hasInput = Object.keys(mergedShape).length > 0;
898
+ server.registerTool(tool.name, {
899
+ ...(tool.description ? { description: tool.description } : undefined),
900
+ ...(hasInput ? { inputSchema: mergedShape } : undefined),
901
+ }, async (...callArgs) => {
902
+ const params = hasInput ? callArgs[0] : {};
903
+ return Mcp.callTool(tool, params);
904
+ });
905
+ }
906
+ transport = new WebStandardStreamableHTTPServerTransport({
907
+ sessionIdGenerator: () => crypto.randomUUID(),
908
+ enableJsonResponse: true,
909
+ });
910
+ await server.connect(transport);
911
+ }
912
+ return transport.handleRequest(req);
913
+ };
914
+ }
915
+ /** @internal Handles an HTTP request by resolving a command and returning a JSON Response. */
916
+ async function fetchImpl(name, commands, req, options = {}) {
917
+ const start = performance.now();
918
+ const url = new URL(req.url);
919
+ const segments = url.pathname.split('/').filter(Boolean);
920
+ // MCP over HTTP: route /mcp to the MCP transport
921
+ if (segments[0] === 'mcp' && segments.length === 1 && options.mcpHandler)
922
+ return options.mcpHandler(req, commands);
923
+ // .well-known/skills/ — Agent Skills Discovery (RFC)
924
+ if (segments[0] === '.well-known' &&
925
+ segments[1] === 'skills' &&
926
+ segments.length >= 3 &&
927
+ req.method === 'GET') {
928
+ const groups = new Map();
929
+ const cmds = collectSkillCommands(commands, [], groups);
930
+ // GET /.well-known/skills/index.json
931
+ if (segments[2] === 'index.json' && segments.length === 3) {
932
+ const files = Skill.split(name, cmds, 1, groups);
933
+ const skills = files.map((f) => {
934
+ const descMatch = f.content.match(/^description:\s*(.+)$/m);
935
+ return {
936
+ name: f.dir || name,
937
+ description: descMatch?.[1] ?? '',
938
+ files: ['SKILL.md'],
939
+ };
940
+ });
941
+ return new Response(JSON.stringify({ skills }), {
942
+ status: 200,
943
+ headers: { 'content-type': 'application/json', 'cache-control': 'public, max-age=300' },
944
+ });
945
+ }
946
+ // GET /.well-known/skills/{skill-name}/SKILL.md
947
+ if (segments.length === 4 && segments[3] === 'SKILL.md') {
948
+ const skillName = segments[2];
949
+ const files = Skill.split(name, cmds, 1, groups);
950
+ const file = files.find((f) => (f.dir || name) === skillName);
951
+ if (file)
952
+ return new Response(file.content, {
953
+ status: 200,
954
+ headers: { 'content-type': 'text/markdown', 'cache-control': 'public, max-age=300' },
955
+ });
956
+ return new Response('Not Found', { status: 404 });
957
+ }
958
+ return new Response('Not Found', { status: 404 });
959
+ }
960
+ // Parse options from search params (GET) or body (non-GET)
961
+ let inputOptions = {};
962
+ if (req.method === 'GET')
963
+ for (const [key, value] of url.searchParams)
964
+ inputOptions[key] = value;
965
+ else {
966
+ try {
967
+ const contentType = req.headers.get('content-type') ?? '';
968
+ if (contentType.includes('application/json'))
969
+ inputOptions = (await req.json());
970
+ }
971
+ catch { }
972
+ }
973
+ function jsonResponse(body, status) {
974
+ return new Response(JSON.stringify(body), {
975
+ status,
976
+ headers: { 'content-type': 'application/json' },
977
+ });
978
+ }
979
+ // Resolve command from path segments
980
+ if (segments.length === 0) {
981
+ // Root path
982
+ if (options.rootCommand)
983
+ return executeCommand(name, options.rootCommand, [], inputOptions, start, options);
984
+ return jsonResponse({ ok: false, error: { code: 'COMMAND_NOT_FOUND', message: 'No root command defined.' }, meta: { command: '/', duration: `${Math.round(performance.now() - start)}ms` } }, 404);
985
+ }
986
+ const resolved = resolveCommand(commands, segments);
987
+ if ('error' in resolved)
988
+ return jsonResponse({ 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` } }, 404);
989
+ if ('help' in resolved)
990
+ return jsonResponse({ 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` } }, 404);
991
+ if ('fetchGateway' in resolved)
992
+ return resolved.fetchGateway.fetch(req);
993
+ const { command, path, rest } = resolved;
994
+ return executeCommand(path, command, rest, inputOptions, start, options);
995
+ }
996
+ /** @internal Executes a resolved command for the fetch handler and returns a JSON Response. */
997
+ async function executeCommand(path, command, rest, inputOptions, start, options) {
998
+ function jsonResponse(body, status) {
999
+ return new Response(JSON.stringify(body), {
1000
+ status,
1001
+ headers: { 'content-type': 'application/json' },
1002
+ });
1003
+ }
1004
+ const sentinel_ = Symbol.for('incur.sentinel');
1005
+ const varsMap = options.vars ? options.vars.parse({}) : {};
1006
+ let response;
1007
+ const runCommand = async () => {
1008
+ const { args } = Parser.parse(rest, { args: command.args });
1009
+ const parsedOptions = command.options ? command.options.parse(inputOptions) : {};
1010
+ const okFn = (data) => ({ [sentinel_]: 'ok', data });
1011
+ const errorFn = (opts) => ({ [sentinel_]: 'error', ...opts });
1012
+ const result = command.run({
1013
+ agent: true,
1014
+ args,
1015
+ env: {},
1016
+ error: errorFn,
1017
+ format: 'json',
1018
+ formatExplicit: true,
1019
+ name: path,
1020
+ ok: okFn,
1021
+ options: parsedOptions,
1022
+ var: varsMap,
1023
+ version: undefined,
1024
+ });
1025
+ // Streaming path — async generator → NDJSON response
1026
+ if (isAsyncGenerator(result)) {
1027
+ const stream = new ReadableStream({
1028
+ async start(controller) {
1029
+ const encoder = new TextEncoder();
1030
+ try {
1031
+ let returnValue;
1032
+ while (true) {
1033
+ const { value, done } = await result.next();
1034
+ if (done) {
1035
+ returnValue = value;
1036
+ break;
1037
+ }
1038
+ if (isSentinel(value) && value[sentinel] === 'error') {
1039
+ const tagged = value;
1040
+ controller.enqueue(encoder.encode(JSON.stringify({ type: 'error', ok: false, error: { code: tagged.code, message: tagged.message } }) + '\n'));
1041
+ controller.close();
1042
+ return;
1043
+ }
1044
+ controller.enqueue(encoder.encode(JSON.stringify({ type: 'chunk', data: value }) + '\n'));
1045
+ }
1046
+ const meta = { command: path };
1047
+ if (isSentinel(returnValue) && returnValue[sentinel] === 'error') {
1048
+ const tagged = returnValue;
1049
+ controller.enqueue(encoder.encode(JSON.stringify({ type: 'error', ok: false, error: { code: tagged.code, message: tagged.message } }) + '\n'));
1050
+ }
1051
+ else {
1052
+ controller.enqueue(encoder.encode(JSON.stringify({ type: 'done', ok: true, meta }) + '\n'));
1053
+ }
1054
+ }
1055
+ catch (error) {
1056
+ controller.enqueue(encoder.encode(JSON.stringify({ type: 'error', ok: false, error: { code: 'UNKNOWN', message: error instanceof Error ? error.message : String(error) } }) + '\n'));
1057
+ }
1058
+ controller.close();
1059
+ },
1060
+ });
1061
+ response = new Response(stream, { status: 200, headers: { 'content-type': 'application/x-ndjson' } });
1062
+ return;
1063
+ }
1064
+ const awaited = await result;
1065
+ const duration = `${Math.round(performance.now() - start)}ms`;
1066
+ if (typeof awaited === 'object' && awaited !== null && sentinel_ in awaited) {
1067
+ const tagged = awaited;
1068
+ if (tagged[sentinel_] === 'error')
1069
+ response = jsonResponse({ ok: false, error: { code: tagged.code, message: tagged.message }, meta: { command: path, duration } }, 500);
1070
+ else
1071
+ response = jsonResponse({ ok: true, data: tagged.data, meta: { command: path, duration } }, 200);
1072
+ return;
1073
+ }
1074
+ response = jsonResponse({ ok: true, data: awaited, meta: { command: path, duration } }, 200);
1075
+ };
1076
+ try {
1077
+ const allMiddleware = options.middlewares ?? [];
1078
+ if (allMiddleware.length > 0) {
1079
+ const errorFn = (opts) => {
1080
+ const duration = `${Math.round(performance.now() - start)}ms`;
1081
+ response = jsonResponse({ ok: false, error: { code: opts.code, message: opts.message }, meta: { command: path, duration } }, 500);
1082
+ return undefined;
1083
+ };
1084
+ const mwCtx = {
1085
+ agent: true,
1086
+ command: path,
1087
+ env: {},
1088
+ error: errorFn,
1089
+ format: 'json',
1090
+ formatExplicit: true,
1091
+ name: path,
1092
+ set(key, value) { varsMap[key] = value; },
1093
+ var: varsMap,
1094
+ version: undefined,
1095
+ };
1096
+ const composed = allMiddleware.reduceRight((next, mw) => async () => { await mw(mwCtx, next); }, runCommand);
1097
+ await composed();
1098
+ }
1099
+ else {
1100
+ await runCommand();
1101
+ }
1102
+ }
1103
+ catch (error) {
1104
+ const duration = `${Math.round(performance.now() - start)}ms`;
1105
+ if (error instanceof ValidationError)
1106
+ return jsonResponse({ ok: false, error: { code: 'VALIDATION_ERROR', message: error.message }, meta: { command: path, duration } }, 400);
1107
+ return jsonResponse({ ok: false, error: { code: error instanceof IncurError ? error.code : 'UNKNOWN', message: error instanceof Error ? error.message : String(error) }, meta: { command: path, duration } }, 500);
1108
+ }
1109
+ return response;
1110
+ }
681
1111
  /** @internal Formats a validation error for TTY with usage hint. */
682
1112
  function formatHumanValidationError(cli, path, command, error, envSource) {
683
1113
  const lines = [];
@@ -708,6 +1138,17 @@ function resolveCommand(commands, tokens) {
708
1138
  let remaining = rest;
709
1139
  let inheritedOutputPolicy;
710
1140
  const collectedMiddlewares = [];
1141
+ // Fetch gateway — all remaining tokens go to the fetch handler
1142
+ if (isFetchGateway(entry)) {
1143
+ const outputPolicy = entry.outputPolicy ?? inheritedOutputPolicy;
1144
+ return {
1145
+ fetchGateway: entry,
1146
+ middlewares: collectedMiddlewares,
1147
+ path: path.join(' '),
1148
+ rest: remaining,
1149
+ ...(outputPolicy ? { outputPolicy } : undefined),
1150
+ };
1151
+ }
711
1152
  while (isGroup(entry)) {
712
1153
  if (entry.outputPolicy)
713
1154
  inheritedOutputPolicy = entry.outputPolicy;
@@ -728,6 +1169,16 @@ function resolveCommand(commands, tokens) {
728
1169
  path.push(next);
729
1170
  remaining = remaining.slice(1);
730
1171
  entry = child;
1172
+ if (isFetchGateway(entry)) {
1173
+ const outputPolicy = entry.outputPolicy ?? inheritedOutputPolicy;
1174
+ return {
1175
+ fetchGateway: entry,
1176
+ middlewares: collectedMiddlewares,
1177
+ path: path.join(' '),
1178
+ rest: remaining,
1179
+ ...(outputPolicy ? { outputPolicy } : undefined),
1180
+ };
1181
+ }
731
1182
  }
732
1183
  const outputPolicy = entry.outputPolicy ?? inheritedOutputPolicy;
733
1184
  return {
@@ -745,8 +1196,10 @@ function extractBuiltinFlags(argv) {
745
1196
  let mcp = false;
746
1197
  let help = false;
747
1198
  let version = false;
1199
+ let schema = false;
748
1200
  let format = 'toon';
749
1201
  let formatExplicit = false;
1202
+ let filterOutput;
750
1203
  const rest = [];
751
1204
  for (let i = 0; i < argv.length; i++) {
752
1205
  const token = argv[i];
@@ -760,6 +1213,8 @@ function extractBuiltinFlags(argv) {
760
1213
  help = true;
761
1214
  else if (token === '--version')
762
1215
  version = true;
1216
+ else if (token === '--schema')
1217
+ schema = true;
763
1218
  else if (token === '--json') {
764
1219
  format = 'json';
765
1220
  formatExplicit = true;
@@ -769,26 +1224,51 @@ function extractBuiltinFlags(argv) {
769
1224
  formatExplicit = true;
770
1225
  i++;
771
1226
  }
1227
+ else if (token === '--filter-output' && argv[i + 1]) {
1228
+ filterOutput = argv[i + 1];
1229
+ i++;
1230
+ }
772
1231
  else
773
1232
  rest.push(token);
774
1233
  }
775
- return { verbose, format, formatExplicit, llms, mcp, help, version, rest };
1234
+ return { verbose, format, formatExplicit, filterOutput, llms, mcp, help, version, schema, rest };
776
1235
  }
777
1236
  /** @internal Collects immediate child commands/groups for help output. */
778
1237
  function collectHelpCommands(commands) {
779
1238
  const result = [];
780
1239
  for (const [name, entry] of commands) {
781
- if (isGroup(entry))
782
- result.push({ name, description: entry.description });
783
- else
784
- result.push({ name, description: entry.description });
1240
+ result.push({ name, description: entry.description });
785
1241
  }
786
1242
  return result.sort((a, b) => a.name.localeCompare(b.name));
787
1243
  }
1244
+ /** @internal Formats help text for a fetch gateway command. */
1245
+ function formatFetchHelp(name, description) {
1246
+ const lines = [];
1247
+ if (description)
1248
+ lines.push(`${name} — ${description}`);
1249
+ else
1250
+ lines.push(name);
1251
+ lines.push('');
1252
+ lines.push(`Usage: ${name} <path> [options]`);
1253
+ lines.push('');
1254
+ lines.push('Path segments are joined into the request URL path.');
1255
+ lines.push('');
1256
+ lines.push('Options:');
1257
+ lines.push(' -X, --method <METHOD> HTTP method (default: GET, POST if body present)');
1258
+ lines.push(' -H, --header "Key: Val" Set a request header (repeatable)');
1259
+ lines.push(' -d, --data <json> Request body (implies POST)');
1260
+ lines.push(' --body <json> Request body (implies POST)');
1261
+ lines.push(' --<key> <value> Query string parameter');
1262
+ return lines.join('\n');
1263
+ }
788
1264
  /** @internal Type guard for command groups. */
789
1265
  function isGroup(entry) {
790
1266
  return '_group' in entry;
791
1267
  }
1268
+ /** @internal Type guard for fetch gateways. */
1269
+ function isFetchGateway(entry) {
1270
+ return '_fetch' in entry;
1271
+ }
792
1272
  /** @internal Maps CLI instances to their command maps. */
793
1273
  export const toCommands = new WeakMap();
794
1274
  /** @internal Maps CLI instances to their middleware arrays. */
@@ -866,7 +1346,7 @@ async function handleStreaming(generator, ctx) {
866
1346
  }));
867
1347
  else
868
1348
  ctx.writeln(formatHumanError({ code: tagged.code, message: tagged.message }));
869
- ctx.exit(1);
1349
+ ctx.exit(tagged.exitCode ?? 1);
870
1350
  return;
871
1351
  }
872
1352
  }
@@ -890,7 +1370,7 @@ async function handleStreaming(generator, ctx) {
890
1370
  }));
891
1371
  else
892
1372
  ctx.writeln(formatHumanError({ code: err.code, message: err.message }));
893
- ctx.exit(1);
1373
+ ctx.exit(err.exitCode ?? 1);
894
1374
  return;
895
1375
  }
896
1376
  const cta = isSentinel(returnValue) && returnValue[sentinel] === 'ok'
@@ -924,7 +1404,7 @@ async function handleStreaming(generator, ctx) {
924
1404
  code: 'UNKNOWN',
925
1405
  message: error instanceof Error ? error.message : String(error),
926
1406
  }));
927
- ctx.exit(1);
1407
+ ctx.exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1);
928
1408
  }
929
1409
  }
930
1410
  else {
@@ -953,7 +1433,7 @@ async function handleStreaming(generator, ctx) {
953
1433
  duration: `${Math.round(performance.now() - ctx.start)}ms`,
954
1434
  },
955
1435
  });
956
- ctx.exit(1);
1436
+ ctx.exit(tagged.exitCode ?? 1);
957
1437
  return;
958
1438
  }
959
1439
  }
@@ -973,7 +1453,7 @@ async function handleStreaming(generator, ctx) {
973
1453
  duration: `${Math.round(performance.now() - ctx.start)}ms`,
974
1454
  },
975
1455
  });
976
- ctx.exit(1);
1456
+ ctx.exit(err.exitCode ?? 1);
977
1457
  return;
978
1458
  }
979
1459
  const cta = isSentinel(returnValue) && returnValue[sentinel] === 'ok'
@@ -1001,7 +1481,7 @@ async function handleStreaming(generator, ctx) {
1001
1481
  duration: `${Math.round(performance.now() - ctx.start)}ms`,
1002
1482
  },
1003
1483
  });
1004
- ctx.exit(1);
1484
+ ctx.exit(error instanceof IncurError ? (error.exitCode ?? 1) : 1);
1005
1485
  }
1006
1486
  }
1007
1487
  }
@@ -1040,7 +1520,13 @@ function collectCommands(commands, prefix) {
1040
1520
  const result = [];
1041
1521
  for (const [name, entry] of commands) {
1042
1522
  const path = [...prefix, name];
1043
- if (isGroup(entry)) {
1523
+ if (isFetchGateway(entry)) {
1524
+ const cmd = { name: path.join(' ') };
1525
+ if (entry.description)
1526
+ cmd.description = entry.description;
1527
+ result.push(cmd);
1528
+ }
1529
+ else if (isGroup(entry)) {
1044
1530
  result.push(...collectCommands(entry.commands, path));
1045
1531
  }
1046
1532
  else {
@@ -1078,7 +1564,14 @@ function collectSkillCommands(commands, prefix, groups) {
1078
1564
  const result = [];
1079
1565
  for (const [name, entry] of commands) {
1080
1566
  const path = [...prefix, name];
1081
- if (isGroup(entry)) {
1567
+ if (isFetchGateway(entry)) {
1568
+ const cmd = { name: path.join(' ') };
1569
+ if (entry.description)
1570
+ cmd.description = entry.description;
1571
+ cmd.hint = 'Fetch gateway. Pass path segments and curl-style flags (-X, -H, -d, --key value).';
1572
+ result.push(cmd);
1573
+ }
1574
+ else if (isGroup(entry)) {
1082
1575
  if (entry.description)
1083
1576
  groups.set(path.join(' '), entry.description);
1084
1577
  result.push(...collectSkillCommands(entry.commands, path, groups));