incur 0.3.3 → 0.3.5

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/e2e.test.ts CHANGED
@@ -963,10 +963,10 @@ describe('help', () => {
963
963
  stream-throw Stream that throws
964
964
  validate-fail Fails validation
965
965
 
966
- Built-in Commands:
966
+ Integrations:
967
967
  completions Generate shell completion script
968
- mcp add Register as an MCP server
969
- skills add Sync skill files to your agent
968
+ mcp add Register as MCP server
969
+ skills add Sync skill files to agents
970
970
 
971
971
  Global Options:
972
972
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
@@ -974,7 +974,7 @@ describe('help', () => {
974
974
  --help Show help
975
975
  --llms, --llms-full Print LLM-readable manifest
976
976
  --mcp Start as MCP stdio server
977
- --schema Show JSON Schema for a command
977
+ --schema Show JSON Schema for command
978
978
  --token-count Print token count of output (instead of output)
979
979
  --token-limit <n> Limit output to n tokens
980
980
  --token-offset <n> Skip first n tokens of output
@@ -1007,7 +1007,7 @@ describe('help', () => {
1007
1007
  --format <toon|json|yaml|md|jsonl> Output format
1008
1008
  --help Show help
1009
1009
  --llms, --llms-full Print LLM-readable manifest
1010
- --schema Show JSON Schema for a command
1010
+ --schema Show JSON Schema for command
1011
1011
  --token-count Print token count of output (instead of output)
1012
1012
  --token-limit <n> Limit output to n tokens
1013
1013
  --token-offset <n> Skip first n tokens of output
@@ -1034,7 +1034,7 @@ describe('help', () => {
1034
1034
  --format <toon|json|yaml|md|jsonl> Output format
1035
1035
  --help Show help
1036
1036
  --llms, --llms-full Print LLM-readable manifest
1037
- --schema Show JSON Schema for a command
1037
+ --schema Show JSON Schema for command
1038
1038
  --token-count Print token count of output (instead of output)
1039
1039
  --token-limit <n> Limit output to n tokens
1040
1040
  --token-offset <n> Skip first n tokens of output
@@ -1060,7 +1060,7 @@ describe('help', () => {
1060
1060
  --format <toon|json|yaml|md|jsonl> Output format
1061
1061
  --help Show help
1062
1062
  --llms, --llms-full Print LLM-readable manifest
1063
- --schema Show JSON Schema for a command
1063
+ --schema Show JSON Schema for command
1064
1064
  --token-count Print token count of output (instead of output)
1065
1065
  --token-limit <n> Limit output to n tokens
1066
1066
  --token-offset <n> Skip first n tokens of output
@@ -1092,7 +1092,7 @@ describe('help', () => {
1092
1092
  --format <toon|json|yaml|md|jsonl> Output format
1093
1093
  --help Show help
1094
1094
  --llms, --llms-full Print LLM-readable manifest
1095
- --schema Show JSON Schema for a command
1095
+ --schema Show JSON Schema for command
1096
1096
  --token-count Print token count of output (instead of output)
1097
1097
  --token-limit <n> Limit output to n tokens
1098
1098
  --token-offset <n> Skip first n tokens of output
@@ -1738,10 +1738,10 @@ describe('root command with subcommands', () => {
1738
1738
  info Show info
1739
1739
  version Show version
1740
1740
 
1741
- Built-in Commands:
1741
+ Integrations:
1742
1742
  completions Generate shell completion script
1743
- mcp add Register as an MCP server
1744
- skills add Sync skill files to your agent
1743
+ mcp add Register as MCP server
1744
+ skills add Sync skill files to agents
1745
1745
 
1746
1746
  Global Options:
1747
1747
  --filter-output <keys> Filter output by key paths (e.g. foo,bar.baz,a[0,3])
@@ -1749,7 +1749,7 @@ describe('root command with subcommands', () => {
1749
1749
  --help Show help
1750
1750
  --llms, --llms-full Print LLM-readable manifest
1751
1751
  --mcp Start as MCP stdio server
1752
- --schema Show JSON Schema for a command
1752
+ --schema Show JSON Schema for command
1753
1753
  --token-count Print token count of output (instead of output)
1754
1754
  --token-limit <n> Limit output to n tokens
1755
1755
  --token-offset <n> Skip first n tokens of output
@@ -1925,7 +1925,7 @@ describe('env', () => {
1925
1925
  --format <toon|json|yaml|md|jsonl> Output format
1926
1926
  --help Show help
1927
1927
  --llms, --llms-full Print LLM-readable manifest
1928
- --schema Show JSON Schema for a command
1928
+ --schema Show JSON Schema for command
1929
1929
  --token-count Print token count of output (instead of output)
1930
1930
  --token-limit <n> Limit output to n tokens
1931
1931
  --token-offset <n> Skip first n tokens of output
@@ -2441,6 +2441,18 @@ describe('fetch api', () => {
2441
2441
  },
2442
2442
  "meta": {
2443
2443
  "command": "project create",
2444
+ "cta": {
2445
+ "commands": [
2446
+ {
2447
+ "command": "app project get p-new",
2448
+ "description": "View "MyProject"",
2449
+ },
2450
+ {
2451
+ "command": "app project list",
2452
+ },
2453
+ ],
2454
+ "description": "Suggested commands:",
2455
+ },
2444
2456
  "duration": "<stripped>",
2445
2457
  },
2446
2458
  "ok": true,
@@ -2524,21 +2536,22 @@ describe('fetch api', () => {
2524
2536
  const cli = createApp()
2525
2537
  expect(await fetchJson(cli, new Request('http://localhost/explode-clac')))
2526
2538
  .toMatchInlineSnapshot(`
2527
- {
2528
- "body": {
2529
- "error": {
2530
- "code": "QUOTA_EXCEEDED",
2531
- "message": "Rate limit exceeded",
2532
- },
2533
- "meta": {
2534
- "command": "explode-clac",
2535
- "duration": "<stripped>",
2539
+ {
2540
+ "body": {
2541
+ "error": {
2542
+ "code": "QUOTA_EXCEEDED",
2543
+ "message": "Rate limit exceeded",
2544
+ "retryable": true,
2545
+ },
2546
+ "meta": {
2547
+ "command": "explode-clac",
2548
+ "duration": "<stripped>",
2549
+ },
2550
+ "ok": false,
2536
2551
  },
2537
- "ok": false,
2538
- },
2539
- "status": 500,
2540
- }
2541
- `)
2552
+ "status": 500,
2553
+ }
2554
+ `)
2542
2555
  })
2543
2556
 
2544
2557
  test('validation error → 400', async () => {
@@ -0,0 +1,425 @@
1
+ import { z } from 'zod'
2
+
3
+ import type { FieldError } from '../Errors.js'
4
+ import { IncurError, ValidationError } from '../Errors.js'
5
+ import type { Context as MiddlewareContext, Handler as MiddlewareHandler } from '../middleware.js'
6
+ import * as Parser from '../Parser.js'
7
+
8
+ /** @internal Sentinel symbol for `ok()` and `error()` return values. */
9
+ const sentinel = Symbol.for('incur.sentinel')
10
+
11
+ /** @internal CTA block for command output. */
12
+ export type CtaBlock = {
13
+ commands: unknown[]
14
+ description?: string | undefined
15
+ }
16
+
17
+ /** @internal A tagged ok result. */
18
+ type OkResult = {
19
+ [sentinel]: 'ok'
20
+ data: unknown
21
+ cta?: CtaBlock | undefined
22
+ }
23
+
24
+ /** @internal A tagged error result. */
25
+ type ErrorResult = {
26
+ [sentinel]: 'error'
27
+ code: string
28
+ message: string
29
+ retryable?: boolean | undefined
30
+ exitCode?: number | undefined
31
+ cta?: CtaBlock | undefined
32
+ }
33
+
34
+ /** @internal Unified command execution used by CLI, HTTP, and MCP transports. */
35
+ export async function execute(command: any, options: execute.Options): Promise<execute.Result> {
36
+ const {
37
+ argv,
38
+ inputOptions,
39
+ agent,
40
+ format,
41
+ formatExplicit,
42
+ name,
43
+ path,
44
+ version,
45
+ envSource = process.env,
46
+ env: envSchema,
47
+ vars: varsSchema,
48
+ middlewares = [],
49
+ } = options
50
+ const parseMode = options.parseMode ?? 'argv'
51
+
52
+ const varsMap: Record<string, unknown> = varsSchema ? varsSchema.parse({}) : {}
53
+ let result: execute.Result | undefined
54
+ // For streaming with middleware: runCommand suspends on streamConsumed so middleware "after"
55
+ // runs after the stream is consumed. The wrapped generator resolves it in its finally block.
56
+ // resultReady signals that result has been set (for streams, before the chain finishes).
57
+ let streamConsumed: Promise<void> | undefined
58
+ let resolveStreamConsumed: (() => void) | undefined
59
+ let resolveResultReady: (() => void) | undefined
60
+ const resultReady = new Promise<void>((r) => {
61
+ resolveResultReady = r
62
+ })
63
+
64
+ const runCommand = async () => {
65
+ // Parse args and options
66
+ let args: Record<string, unknown>
67
+ let parsedOptions: Record<string, unknown>
68
+
69
+ if (parseMode === 'argv') {
70
+ // CLI mode: parse both args and options from argv tokens
71
+ const parsed = Parser.parse(argv, {
72
+ alias: command.alias as Record<string, string> | undefined,
73
+ args: command.args,
74
+ options: command.options,
75
+ })
76
+ args = parsed.args
77
+ parsedOptions = parsed.options
78
+ } else if (parseMode === 'split') {
79
+ // HTTP mode: positional args from URL path segments, options from body/query
80
+ const parsed = Parser.parse(argv, { args: command.args })
81
+ args = parsed.args
82
+ parsedOptions = command.options ? command.options.parse(inputOptions) : {}
83
+ } else {
84
+ // MCP mode: all params come from inputOptions, split into args vs options
85
+ const split = splitParams(inputOptions, command)
86
+ args = command.args ? command.args.parse(split.args) : {}
87
+ parsedOptions = command.options ? command.options.parse(split.options) : {}
88
+ }
89
+
90
+ // Parse env
91
+ const commandEnv = command.env ? Parser.parseEnv(command.env, envSource) : {}
92
+
93
+ // Build sentinel helpers
94
+ const okFn = (data: unknown, meta: { cta?: CtaBlock | undefined } = {}): never =>
95
+ ({ [sentinel]: 'ok', data, cta: meta.cta }) as never
96
+ const errorFn = (opts: {
97
+ code: string
98
+ cta?: CtaBlock | undefined
99
+ exitCode?: number | undefined
100
+ message: string
101
+ retryable?: boolean | undefined
102
+ }): never => ({ [sentinel]: 'error', ...opts }) as never
103
+
104
+ const raw = command.run({
105
+ agent,
106
+ args,
107
+ env: commandEnv,
108
+ error: errorFn,
109
+ format,
110
+ formatExplicit,
111
+ name,
112
+ ok: okFn,
113
+ options: parsedOptions,
114
+ var: varsMap,
115
+ version,
116
+ })
117
+
118
+ // Streaming: wrap the generator so middleware "after" runs after consumption.
119
+ // When middleware is active, runCommand suspends until the stream is fully consumed,
120
+ // keeping the middleware chain alive around the stream's lifetime.
121
+ if (isAsyncGenerator(raw)) {
122
+ if (middlewares.length > 0) {
123
+ streamConsumed = new Promise<void>((r) => {
124
+ resolveStreamConsumed = r
125
+ })
126
+ async function* wrapped() {
127
+ try {
128
+ yield* raw as AsyncGenerator<unknown, unknown, unknown>
129
+ } finally {
130
+ resolveStreamConsumed!()
131
+ }
132
+ }
133
+ result = { stream: wrapped() }
134
+ resolveResultReady!()
135
+ await streamConsumed
136
+ } else {
137
+ result = { stream: raw }
138
+ }
139
+ return
140
+ }
141
+
142
+ const awaited = await raw
143
+
144
+ if (isSentinel(awaited)) {
145
+ if (awaited[sentinel] === 'ok') {
146
+ const ok = awaited as OkResult
147
+ result = { ok: true, data: ok.data, ...(ok.cta ? { cta: ok.cta } : undefined) }
148
+ } else {
149
+ const err = awaited as ErrorResult
150
+ result = {
151
+ ok: false,
152
+ error: {
153
+ code: err.code,
154
+ message: err.message,
155
+ ...(err.retryable !== undefined ? { retryable: err.retryable } : undefined),
156
+ },
157
+ ...(err.cta ? { cta: err.cta } : undefined),
158
+ ...(err.exitCode !== undefined ? { exitCode: err.exitCode } : undefined),
159
+ }
160
+ }
161
+ return
162
+ }
163
+
164
+ result = { ok: true, data: awaited }
165
+ }
166
+
167
+ try {
168
+ // Parse CLI-level env
169
+ const cliEnv = envSchema ? Parser.parseEnv(envSchema, envSource) : {}
170
+
171
+ if (middlewares.length > 0) {
172
+ const errorFn = (opts: {
173
+ code: string
174
+ cta?: CtaBlock | undefined
175
+ exitCode?: number | undefined
176
+ message: string
177
+ retryable?: boolean | undefined
178
+ }): never => {
179
+ // Side-effect: set result directly (handles both `return c.error()` and bare `c.error()`)
180
+ result = {
181
+ ok: false,
182
+ error: {
183
+ code: opts.code,
184
+ message: opts.message,
185
+ ...(opts.retryable !== undefined ? { retryable: opts.retryable } : undefined),
186
+ },
187
+ ...(opts.cta ? { cta: opts.cta } : undefined),
188
+ ...(opts.exitCode !== undefined ? { exitCode: opts.exitCode } : undefined),
189
+ }
190
+ return undefined as never
191
+ }
192
+
193
+ const mwCtx: MiddlewareContext = {
194
+ agent,
195
+ command: path,
196
+ env: cliEnv,
197
+ error: errorFn,
198
+ format: format as any,
199
+ formatExplicit,
200
+ name,
201
+ set(key: string, value: unknown) {
202
+ varsMap[key] = value
203
+ },
204
+ var: varsMap,
205
+ version,
206
+ }
207
+
208
+ const composed = middlewares.reduceRight(
209
+ (next: () => Promise<void>, mw) => async () => {
210
+ await mw(mwCtx, next)
211
+ },
212
+ runCommand,
213
+ )
214
+ // Start the chain and race against resultReady. For streams with middleware,
215
+ // runCommand suspends on streamConsumed (keeping middleware "after" deferred)
216
+ // but signals resultReady so we can return the stream immediately. The transport
217
+ // consumes the stream, which resolves streamConsumed, letting middleware "after" run.
218
+ const chainPromise = composed()
219
+ await Promise.race([chainPromise, resultReady])
220
+ if (streamConsumed) return result!
221
+ await chainPromise
222
+ } else {
223
+ await runCommand()
224
+ }
225
+ } catch (error) {
226
+ if (error instanceof ValidationError)
227
+ return {
228
+ ok: false,
229
+ error: {
230
+ code: 'VALIDATION_ERROR',
231
+ message: error.message,
232
+ fieldErrors: error.fieldErrors,
233
+ },
234
+ }
235
+ return {
236
+ ok: false,
237
+ error: {
238
+ code: error instanceof IncurError ? error.code : 'UNKNOWN',
239
+ message: error instanceof Error ? error.message : String(error),
240
+ ...(error instanceof IncurError ? { retryable: error.retryable } : undefined),
241
+ },
242
+ ...(error instanceof IncurError && error.exitCode !== undefined
243
+ ? { exitCode: error.exitCode }
244
+ : undefined),
245
+ }
246
+ }
247
+
248
+ return result ?? { ok: true, data: undefined }
249
+ }
250
+
251
+ /** @internal Splits flat params into args vs options using schema shapes. */
252
+ function splitParams(
253
+ params: Record<string, unknown>,
254
+ command: any,
255
+ ): { args: Record<string, unknown>; options: Record<string, unknown> } {
256
+ const argKeys = new Set(command.args ? Object.keys(command.args.shape) : [])
257
+ const a: Record<string, unknown> = {}
258
+ const o: Record<string, unknown> = {}
259
+ for (const [key, value] of Object.entries(params))
260
+ if (argKeys.has(key)) a[key] = value
261
+ else o[key] = value
262
+ return { args: a, options: o }
263
+ }
264
+
265
+ export declare namespace execute {
266
+ /** Options for the unified execute function. */
267
+ type Options = {
268
+ /** Whether the consumer is an agent. */
269
+ agent: boolean
270
+ /** Raw positional tokens (already separated from flags). For HTTP/MCP, pass `[]`. */
271
+ argv: string[]
272
+ /** CLI-level env schema. */
273
+ env?: z.ZodObject<any> | undefined
274
+ /** Source for environment variables. Defaults to `process.env`. */
275
+ envSource?: Record<string, string | undefined> | undefined
276
+ /** The resolved output format. */
277
+ format: string
278
+ /** Whether the format was explicitly requested. */
279
+ formatExplicit: boolean
280
+ /** Raw parsed options (from query params, JSON body, or MCP params). For CLI, pass `{}`. */
281
+ inputOptions: Record<string, unknown>
282
+ /** Middleware handlers (root + group + command, already collected). */
283
+ middlewares?: MiddlewareHandler[] | undefined
284
+ /** The CLI name. */
285
+ name: string
286
+ /**
287
+ * How to parse input:
288
+ * - `'argv'` (default): parse both args and options from argv tokens (CLI mode)
289
+ * - `'split'`: args from argv, options from inputOptions (HTTP mode)
290
+ * - `'flat'`: all params from inputOptions, split by schema shapes (MCP mode)
291
+ */
292
+ parseMode?: 'argv' | 'split' | 'flat' | undefined
293
+ /** The resolved command path. */
294
+ path: string
295
+ /** Vars schema for middleware variables. */
296
+ vars?: z.ZodObject<any> | undefined
297
+ /** CLI version string. */
298
+ version: string | undefined
299
+ }
300
+
301
+ /** Result of executing a command. */
302
+ type Result =
303
+ | { ok: true; data: unknown; cta?: CtaBlock | undefined }
304
+ | {
305
+ ok: false
306
+ error: {
307
+ code: string
308
+ message: string
309
+ retryable?: boolean | undefined
310
+ fieldErrors?: FieldError[] | undefined
311
+ }
312
+ cta?: CtaBlock | undefined
313
+ exitCode?: number | undefined
314
+ }
315
+ | { stream: AsyncGenerator<unknown, unknown, unknown> }
316
+ }
317
+
318
+ /** @internal Type guard for sentinel results. */
319
+ function isSentinel(value: unknown): value is OkResult | ErrorResult {
320
+ return typeof value === 'object' && value !== null && sentinel in value
321
+ }
322
+
323
+ /** @internal Type guard for async generators. */
324
+ function isAsyncGenerator(value: unknown): value is AsyncGenerator<unknown, unknown, unknown> {
325
+ return (
326
+ typeof value === 'object' &&
327
+ value !== null &&
328
+ Symbol.asyncIterator in value &&
329
+ typeof (value as any).next === 'function'
330
+ )
331
+ }
332
+
333
+ /** Common metadata shared by command definitions and built-in commands. */
334
+ export type CommandMeta<options extends z.ZodObject<any> | undefined = undefined> = {
335
+ /** Map of option names to single-char aliases. */
336
+ alias?: options extends z.ZodObject<any>
337
+ ? Partial<Record<keyof z.output<options>, string>>
338
+ : Record<string, string> | undefined
339
+ /** A short description of what the command does. */
340
+ description?: string | undefined
341
+ /** Zod schema for named options/flags. */
342
+ options?: options | undefined
343
+ }
344
+
345
+ /** @internal Creates a builtin subcommand with typesafe alias inference. */
346
+ function subcommand<const options extends z.ZodObject<any> | undefined = undefined>(
347
+ def: CommandMeta<options> & { name: string },
348
+ ) {
349
+ return def
350
+ }
351
+
352
+ /** Supported shell names for completions. */
353
+ export const shells = ['bash', 'fish', 'nushell', 'zsh'] as const
354
+
355
+ /** A supported shell name. */
356
+ export type Shell = (typeof shells)[number]
357
+
358
+ /** Built-in command metadata shared by help, completions, and handler logic. */
359
+ export const builtinCommands = [
360
+ {
361
+ name: 'completions',
362
+ description: 'Generate shell completion script',
363
+ args: z.object({
364
+ shell: z.enum(shells).describe('Shell to generate completions for'),
365
+ }),
366
+ hint(name) {
367
+ const rows = [
368
+ ['bash', `eval "$(${name} completions bash)"`, '# add to ~/.bashrc'],
369
+ ['fish', `${name} completions fish | source`, '# add to ~/.config/fish/config.fish'],
370
+ ['nushell', `see \`${name} completions nushell\``, '# add to config.nu'],
371
+ ['zsh', `eval "$(${name} completions zsh)"`, '# add to ~/.zshrc'],
372
+ ] as const
373
+ const shellW = Math.max(...rows.map((r) => r[0].length))
374
+ const cmdW = Math.max(...rows.map((r) => r[1].length))
375
+ return (
376
+ 'Setup:\n' +
377
+ rows
378
+ .map(([s, cmd, comment]) => ` ${s.padEnd(shellW)} ${cmd.padEnd(cmdW)} ${comment}`)
379
+ .join('\n')
380
+ )
381
+ },
382
+ },
383
+ {
384
+ name: 'mcp',
385
+ description: 'Register as MCP server',
386
+ subcommands: [
387
+ subcommand({
388
+ name: 'add',
389
+ description: 'Register as MCP server',
390
+ alias: { command: 'c' },
391
+ options: z.object({
392
+ agent: z
393
+ .string()
394
+ .optional()
395
+ .describe('Target a specific agent (e.g. claude-code, cursor)'),
396
+ command: z
397
+ .string()
398
+ .optional()
399
+ .describe('Override the command agents will run (e.g. "pnpm my-cli --mcp")'),
400
+ noGlobal: z.boolean().optional().describe('Install to project instead of globally'),
401
+ }),
402
+ }),
403
+ ],
404
+ },
405
+ {
406
+ name: 'skills',
407
+ description: 'Sync skill files to agents',
408
+ subcommands: [
409
+ subcommand({
410
+ name: 'add',
411
+ description: 'Sync skill files to agents',
412
+ options: z.object({
413
+ depth: z.number().optional().describe('Grouping depth for skill files (default: 1)'),
414
+ noGlobal: z.boolean().optional().describe('Install to project instead of globally'),
415
+ }),
416
+ }),
417
+ ],
418
+ },
419
+ ] satisfies {
420
+ name: string
421
+ args?: z.ZodObject<any> | undefined
422
+ description: string
423
+ hint?: ((name: string) => string) | undefined
424
+ subcommands?: (CommandMeta<z.ZodObject<any>> & { name: string })[] | undefined
425
+ }[]