incur 0.3.4 → 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.
@@ -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
+ }[]