From 7545753e4e7c802b825f0fec43d6aa4d1960e9a3 Mon Sep 17 00:00:00 2001 From: Frank Bagherzadeh Date: Fri, 27 Mar 2026 09:47:10 -0400 Subject: [PATCH 01/14] feat: add Parser.parseGlobals for custom global option extraction Permissive argv scanner that extracts known global flags and passes unknown flags + positionals through to rest. Reuses all existing Parser internals (createOptionNames, normalizeOptionName, coerce, etc). --- src/Parser.ts | 124 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/src/Parser.ts b/src/Parser.ts index 7bb6521..a44806b 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -314,6 +314,130 @@ function coerce(value: unknown, name: string, schema: z.ZodObject): unknown return value } +/** Parses known global options from argv, passing unknown flags and positionals through to `rest`. */ +export function parseGlobals>( + argv: string[], + schema: globals, + alias?: Record, +): { parsed: z.output; rest: string[] } { + const optionNames = createOptionNames(schema, alias) + + const rest: string[] = [] + const rawOptions: Record = {} + + let i = 0 + while (i < argv.length) { + const token = argv[i]! + + if (token.startsWith('--no-') && token.length > 5) { + const name = normalizeOptionName(token.slice(5), optionNames) + if (!name) { + rest.push(token) + } else { + rawOptions[name] = false + } + i++ + } else if (token.startsWith('--')) { + const eqIdx = token.indexOf('=') + if (eqIdx !== -1) { + // --flag=value + const raw = token.slice(2, eqIdx) + const name = normalizeOptionName(raw, optionNames) + if (!name) { + rest.push(token) + } else { + setOption(rawOptions, name, token.slice(eqIdx + 1), schema) + } + i++ + } else { + // --flag [value] + const name = normalizeOptionName(token.slice(2), optionNames) + if (!name) { + // Unknown flag — pass through with its potential value + rest.push(token) + i++ + if (i < argv.length && !argv[i]!.startsWith('-')) { + rest.push(argv[i]!) + i++ + } + } else if (isCountOption(name, schema)) { + rawOptions[name] = ((rawOptions[name] as number) ?? 0) + 1 + i++ + } else if (isBooleanOption(name, schema)) { + rawOptions[name] = true + i++ + } else { + const value = argv[i + 1] + if (value === undefined) + throw new ParseError({ message: `Missing value for flag: ${token}` }) + setOption(rawOptions, name, value, schema) + i += 2 + } + } + } else if (token.startsWith('-') && !token.startsWith('--') && token.length >= 2) { + // Short flag(s) + const chars = token.slice(1) + let allKnown = true + for (let j = 0; j < chars.length; j++) { + if (!optionNames.aliasToName.has(chars[j]!)) { + allKnown = false + break + } + } + + if (!allKnown) { + // Unknown short flag — pass through with its potential value + rest.push(token) + i++ + if (i < argv.length && !argv[i]!.startsWith('-')) { + rest.push(argv[i]!) + i++ + } + } else { + for (let j = 0; j < chars.length; j++) { + const short = chars[j]! + const name = optionNames.aliasToName.get(short)! + const isLast = j === chars.length - 1 + if (!isLast) { + if (isCountOption(name, schema)) { + rawOptions[name] = ((rawOptions[name] as number) ?? 0) + 1 + } else if (isBooleanOption(name, schema)) { + rawOptions[name] = true + } else { + throw new ParseError({ + message: `Non-boolean flag -${short} must be last in a stacked alias`, + }) + } + } else if (isCountOption(name, schema)) { + rawOptions[name] = ((rawOptions[name] as number) ?? 0) + 1 + } else if (isBooleanOption(name, schema)) { + rawOptions[name] = true + } else { + const value = argv[i + 1] + if (value === undefined) + throw new ParseError({ message: `Missing value for flag: -${short}` }) + setOption(rawOptions, name, value, schema) + i++ + } + } + i++ + } + } else { + // Positional — pass through + rest.push(token) + i++ + } + } + + // Coerce raw option values before zod validation + for (const [name, value] of Object.entries(rawOptions)) { + rawOptions[name] = coerce(value, name, schema) + } + + const parsed = zodParse(schema, rawOptions) as z.output + return { parsed, rest } +} + /** Returns the best available env source for the current runtime. */ function defaultEnvSource(): Record { if (typeof globalThis !== 'undefined') { From a48316ed576b6bf81571d6d93645fc84ebc21813 Mon Sep 17 00:00:00 2001 From: Frank Bagherzadeh Date: Fri, 27 Mar 2026 09:58:21 -0400 Subject: [PATCH 02/14] feat: add globals generic to middleware Context and command execution Adds globals as a third generic on Handler, Context, and middleware() factory following the existing InferVars/InferEnv pattern. Wires globals into execute.Options and mwCtx construction. --- src/internal/command.ts | 4 ++++ src/middleware.ts | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/internal/command.ts b/src/internal/command.ts index d6e7147..9187b71 100644 --- a/src/internal/command.ts +++ b/src/internal/command.ts @@ -44,6 +44,7 @@ export async function execute(command: any, options: execute.Options): Promise | undefined /** Raw parsed options (from query params, JSON body, or MCP params). For CLI, pass `{}`. */ inputOptions: Record /** Middleware handlers (root + group + command, already collected). */ diff --git a/src/middleware.ts b/src/middleware.ts index 7c59276..1122047 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -10,11 +10,16 @@ type InferVars | undefined> = type InferEnv | undefined> = env extends z.ZodObject ? z.output : {} +/** @internal Infers the output type of a globals schema, or `{}` if undefined. */ +type InferGlobals | undefined> = + globals extends z.ZodObject ? z.output : {} + /** Middleware handler that runs before/after command execution. */ export type Handler< vars extends z.ZodObject | undefined = undefined, env extends z.ZodObject | undefined = undefined, -> = (context: Context, next: () => Promise) => Promise | void + globals extends z.ZodObject | undefined = undefined, +> = (context: Context, next: () => Promise) => Promise | void /** CTA block for middleware error/ok responses. */ type CtaBlock = { @@ -28,6 +33,7 @@ type CtaBlock = { export type Context< vars extends z.ZodObject | undefined = undefined, env extends z.ZodObject | undefined = undefined, + globals extends z.ZodObject | undefined = undefined, > = { /** Whether the consumer is an agent (stdout is not a TTY). */ agent: boolean @@ -49,6 +55,8 @@ export type Context< format: Formatter.Format /** Whether the user explicitly passed `--format` or `--json`. */ formatExplicit: boolean + /** Parsed global options from the CLI-level globals schema. */ + globals: InferGlobals /** The CLI name. */ name: string /** Set a typed variable for downstream middleware and handlers. */ @@ -59,10 +67,11 @@ export type Context< version: string | undefined } -/** Creates a strictly typed middleware handler. Pass the vars schema as a generic for typed `c.set()` and `c.var`, and the env schema for typed `c.env`. */ +/** Creates a strictly typed middleware handler. Pass the vars schema as a generic for typed `c.set()` and `c.var`, the env schema for typed `c.env`, and the globals schema for typed `c.globals`. */ export default function middleware< const vars extends z.ZodObject | undefined = undefined, const env extends z.ZodObject | undefined = undefined, ->(handler: Handler): Handler { + const globals extends z.ZodObject | undefined = undefined, +>(handler: Handler): Handler { return handler } From 01d97b68b75d083caa383630f85b2730a657bc31 Mon Sep 17 00:00:00 2001 From: Frank Bagherzadeh Date: Fri, 27 Mar 2026 10:03:32 -0400 Subject: [PATCH 03/14] feat: render custom global options in help output Extends globalOptionsLines, formatRoot, and formatCommand to accept an optional globals schema. Custom globals render as a separate block above the built-in global options using the existing optionEntries helper. --- src/Help.ts | 45 +++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/src/Help.ts b/src/Help.ts index 6c919b4..984840b 100644 --- a/src/Help.ts +++ b/src/Help.ts @@ -5,7 +5,15 @@ import { toKebab } from './internal/helpers.js' /** Formats help text for a router CLI or command group. */ export function formatRoot(name: string, options: formatRoot.Options = {}): string { - const { aliases, configFlag, description, version, commands = [], root = false } = options + const { + aliases, + configFlag, + description, + globals, + version, + commands = [], + root = false, + } = options const lines: string[] = [] // Header @@ -30,7 +38,7 @@ export function formatRoot(name: string, options: formatRoot.Options = {}): stri } } - lines.push(...globalOptionsLines(root, configFlag)) + lines.push(...globalOptionsLines(root, configFlag, globals)) return lines.join('\n') } @@ -45,6 +53,8 @@ export declare namespace formatRoot { commands?: { name: string; description?: string | undefined }[] | undefined /** A short description of the CLI or group. */ description?: string | undefined + /** Custom global options schema and alias map. */ + globals?: { schema: z.ZodObject; alias?: Record } | undefined /** Show root-level built-in commands and flags. */ root?: boolean | undefined /** CLI version string. */ @@ -70,6 +80,8 @@ export declare namespace formatCommand { env?: z.ZodObject | undefined /** Override environment variable source for "set:" display. Defaults to `process.env`. */ envSource?: Record | undefined + /** Custom global options schema and alias map. */ + globals?: { schema: z.ZodObject; alias?: Record } | undefined /** Formatted usage examples. */ examples?: { command: string; description?: string }[] | undefined /** Plain text hint displayed after examples and before global options. */ @@ -101,6 +113,7 @@ export function formatCommand(name: string, options: formatCommand.Options = {}) aliases, configFlag, description, + globals, version, args, env, @@ -205,7 +218,7 @@ export function formatCommand(name: string, options: formatCommand.Options = {}) } } - if (!options.hideGlobalOptions) lines.push(...globalOptionsLines(root, configFlag)) + if (!options.hideGlobalOptions) lines.push(...globalOptionsLines(root, configFlag, globals)) // Environment Variables if (env) { @@ -334,7 +347,11 @@ function extractDeprecated(schema: unknown): boolean | undefined { } /** Renders the built-in commands and global options block. Root-only items are hidden for subcommands. */ -function globalOptionsLines(root = false, configFlag?: string): string[] { +function globalOptionsLines( + root = false, + configFlag?: string, + globals?: { schema: z.ZodObject; alias?: Record }, +): string[] { const lines: string[] = [] if (root) { @@ -355,6 +372,26 @@ function globalOptionsLines(root = false, configFlag?: string): string[] { ) } + if (globals) { + const entries = optionEntries(globals.schema, globals.alias) + if (entries.length > 0) { + const maxLen = Math.max(...entries.map((e) => e.flag.length)) + lines.push( + '', + 'Custom Global Options:', + ...entries.map((entry) => { + const padding = ' '.repeat(maxLen - entry.flag.length) + const prefix = entry.deprecated ? '[deprecated] ' : '' + const desc = + entry.defaultValue !== undefined + ? `${prefix}${entry.description} (default: ${entry.defaultValue})` + : `${prefix}${entry.description}` + return ` ${entry.flag}${padding} ${desc}` + }), + ) + } + } + const flags = [ ...(configFlag ? [{ flag: `--${configFlag} `, desc: 'Load JSON option defaults from a file' }] From 4dbea33abe139479091f7af0689b1f2d1433ecac Mon Sep 17 00:00:00 2001 From: Frank Bagherzadeh Date: Fri, 27 Mar 2026 10:07:50 -0400 Subject: [PATCH 04/14] feat: add global options to shell completions Extends complete() to accept an optional globals descriptor. Global flags are suggested alongside command-specific options with dedup. Value resolution falls through from command options to globals. --- src/Completions.ts | 58 +++++++++++++++++++++++++++++++++++++++------- 1 file changed, 50 insertions(+), 8 deletions(-) diff --git a/src/Completions.ts b/src/Completions.ts index 9d654b3..0ad327c 100644 --- a/src/Completions.ts +++ b/src/Completions.ts @@ -39,6 +39,12 @@ export function register(shell: Shell, name: string): string { } } +/** Global options descriptor for completion generation. */ +export type GlobalOptions = { + alias?: Record | undefined + schema: z.ZodObject +} + /** * Computes completion candidates for the given argv words and cursor index. * Walks the command tree to resolve the active command, then suggests @@ -49,6 +55,7 @@ export function complete( rootCommand: CommandEntry | undefined, argv: string[], index: number, + globals?: GlobalOptions | undefined, ): Candidate[] { const current = argv[index] ?? '' @@ -94,6 +101,25 @@ export function complete( } } } + // Global options + if (globals) { + const globalShape = globals.schema.shape as Record + for (const key of Object.keys(globalShape)) { + const kebab = key.replace(/[A-Z]/g, (c: string) => `-${c.toLowerCase()}`) + const flag = `--${kebab}` + if (flag.startsWith(current) && !candidates.some((c) => c.value === flag)) + candidates.push({ value: flag, description: descriptionOf(globalShape[key]) }) + } + // Global short aliases + if (globals.alias) + for (const [name, short] of Object.entries(globals.alias)) { + const flag = `-${short}` + if (flag.startsWith(current) && !candidates.some((c) => c.value === flag)) { + const desc = descriptionOf(globalShape[name]) + candidates.push({ value: flag, description: desc }) + } + } + } return candidates } @@ -101,15 +127,31 @@ export function complete( if (index > 0) { const prev = argv[index - 1]! const leaf = scope.leaf - if (leaf?.options && prev.startsWith('-')) { - const name = resolveOptionName(prev, leaf) - if (name) { - const values = possibleValues(name, leaf.options) - if (values) { - for (const v of values) if (v.startsWith(current)) candidates.push({ value: v }) - return candidates + if (prev.startsWith('-')) { + // Try command-specific options first + if (leaf?.options) { + const name = resolveOptionName(prev, leaf) + if (name) { + const values = possibleValues(name, leaf.options) + if (values) { + for (const v of values) if (v.startsWith(current)) candidates.push({ value: v }) + return candidates + } + if (!isBooleanOption(name, leaf.options)) return candidates + } + } + // Try global options + if (globals) { + const globalEntry: CommandEntry = { options: globals.schema, alias: globals.alias } + const name = resolveOptionName(prev, globalEntry) + if (name) { + const values = possibleValues(name, globals.schema) + if (values) { + for (const v of values) if (v.startsWith(current)) candidates.push({ value: v }) + return candidates + } + if (!isBooleanOption(name, globals.schema)) return candidates } - if (!isBooleanOption(name, leaf.options)) return candidates } } } From 2989572fb94856b04d79d34b3b917356f76eb1d0 Mon Sep 17 00:00:00 2001 From: Frank Bagherzadeh Date: Fri, 27 Mar 2026 10:20:06 -0400 Subject: [PATCH 05/14] feat: wire global options into Cli.ts Adds globals as 4th generic on Cli type, all create() overloads, and create.Options. Stores globals config in WeakMap. Validates against builtin flag collisions at create() and command option collisions at command(). Extracts globals via Parser.parseGlobals after builtin extraction in serveImpl. Passes globals to middleware context, Help output, Completions, --llms manifests, and --schema output. --- src/Cli.ts | 166 +++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 148 insertions(+), 18 deletions(-) diff --git a/src/Cli.ts b/src/Cli.ts index f7a2f82..8094921 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -32,6 +32,7 @@ export type Cli< commands extends CommandsMap = {}, vars extends z.ZodObject | undefined = undefined, env extends z.ZodObject | undefined = undefined, + globals extends z.ZodObject | undefined = undefined, > = { /** Registers a root command or mounts a sub-CLI as a command group. */ command: { @@ -48,12 +49,18 @@ export type Cli< ): Cli< commands & { [key in name]: { args: InferOutput; options: InferOutput } }, vars, - env + env, + globals > /** Mounts a sub-CLI as a command group. */ ( - cli: Cli & { name: name }, - ): Cli + cli: Cli & { name: name }, + ): Cli< + commands & { [key in keyof sub & string as `${name} ${key}`]: sub[key] }, + vars, + env, + globals + > /** Mounts a root CLI as a single command. */ < const name extends string, @@ -64,7 +71,8 @@ export type Cli< ): Cli< commands & { [key in name]: { args: InferOutput; options: InferOutput } }, vars, - env + env, + globals > /** Mounts a fetch handler as a command, optionally with OpenAPI spec for typed subcommands. */ ( @@ -76,7 +84,7 @@ export type Cli< openapi?: Openapi.OpenAPISpec | undefined outputPolicy?: OutputPolicy | undefined }, - ): Cli + ): Cli } /** A short description of the CLI. */ description?: string | undefined @@ -89,7 +97,7 @@ export type Cli< /** Parses argv, runs the matched command, and writes the output envelope to stdout. */ serve(argv?: string[], options?: serve.Options): Promise /** Registers middleware that runs around every command. */ - use(handler: MiddlewareHandler): Cli + use(handler: MiddlewareHandler): Cli /** The vars schema, if declared. Use `typeof cli.vars` with `middleware()` for typed middleware. */ vars: vars } @@ -154,10 +162,16 @@ export function create< const opts extends z.ZodObject | undefined = undefined, const output extends z.ZodType | undefined = undefined, const vars extends z.ZodObject | undefined = undefined, + const globals extends z.ZodObject | undefined = undefined, >( name: string, - definition: create.Options & { run: Function }, -): Cli<{ [key in typeof name]: { args: InferOutput; options: InferOutput } }, vars, env> + definition: create.Options & { run: Function }, +): Cli< + { [key in typeof name]: { args: InferOutput; options: InferOutput } }, + vars, + env, + globals +> /** Creates a router CLI that registers subcommands. */ export function create< const args extends z.ZodObject | undefined = undefined, @@ -165,7 +179,11 @@ export function create< const opts extends z.ZodObject | undefined = undefined, const output extends z.ZodType | undefined = undefined, const vars extends z.ZodObject | undefined = undefined, ->(name: string, definition?: create.Options): Cli<{}, vars, env> + const globals extends z.ZodObject | undefined = undefined, +>( + name: string, + definition?: create.Options, +): Cli<{}, vars, env, globals> /** Creates a CLI with a root handler from a single options object. Can still register subcommands. */ export function create< const args extends z.ZodObject | undefined = undefined, @@ -173,14 +191,19 @@ export function create< const opts extends z.ZodObject | undefined = undefined, const output extends z.ZodType | undefined = undefined, const vars extends z.ZodObject | undefined = undefined, + const globals extends z.ZodObject | undefined = undefined, >( - definition: create.Options & { name: string; run: Function }, + definition: create.Options & { + name: string + run: Function + }, ): Cli< { [key in (typeof definition)['name']]: { args: InferOutput; options: InferOutput } }, vars, - env + env, + globals > /** Creates a router CLI from a single options object (e.g. package.json). */ export function create< @@ -189,7 +212,10 @@ export function create< const opts extends z.ZodObject | undefined = undefined, const output extends z.ZodType | undefined = undefined, const vars extends z.ZodObject | undefined = undefined, ->(definition: create.Options & { name: string }): Cli<{}, vars, env> + const globals extends z.ZodObject | undefined = undefined, +>( + definition: create.Options & { name: string }, +): Cli<{}, vars, env, globals> export function create( nameOrDefinition: string | (any & { name: string }), definition?: any, @@ -238,6 +264,17 @@ export function create( } as InternalFetchGateway) return cli } + const globalsDesc = toGlobals.get(cli) + if (globalsDesc && def?.options) { + const globalKeys = Object.keys(globalsDesc.schema.shape) + const optionKeys = Object.keys(def.options.shape) + for (const key of optionKeys) { + if (globalKeys.includes(key)) + throw new Error( + `Command '${nameOrCli}' option '${key}' conflicts with a global option. Choose a different name.`, + ) + } + } commands.set(nameOrCli, def) return cli } @@ -262,8 +299,10 @@ export function create( async fetch(req: Request) { if (pending.length > 0) await Promise.all(pending) + const globalsDesc = toGlobals.get(cli) return fetchImpl(name, commands, req, { envSchema: def.env, + globals: globalsDesc, mcpHandler, middlewares, name, @@ -275,6 +314,7 @@ export function create( async serve(argv = process.argv.slice(2), serveOptions: serve.Options = {}) { if (pending.length > 0) await Promise.all(pending) + const globalsDesc = toGlobals.get(cli) return serveImpl(name, commands, argv, { ...serveOptions, aliases: def.aliases, @@ -282,6 +322,7 @@ export function create( description: def.description, envSchema: def.env, format: def.format, + globals: globalsDesc, mcp: def.mcp, middlewares, outputPolicy: def.outputPolicy, @@ -303,6 +344,32 @@ export function create( if (def.options) toRootOptions.set(cli, def.options) if (def.config !== undefined) toConfigEnabled.set(cli, true) if (def.outputPolicy) toOutputPolicy.set(cli, def.outputPolicy) + if (def.globals) { + toGlobals.set(cli, { schema: def.globals, alias: def.globalAlias as any }) + const builtinNames = [ + 'verbose', + 'format', + 'json', + 'llms', + 'llmsFull', + 'mcp', + 'help', + 'version', + 'schema', + 'filterOutput', + 'tokenLimit', + 'tokenOffset', + 'tokenCount', + ...(def.config?.flag ? [def.config.flag] : []), + ] + const globalKeys = Object.keys(def.globals.shape) + for (const key of globalKeys) { + if (builtinNames.includes(key)) + throw new Error( + `Global option '${key}' conflicts with a built-in flag. Choose a different name.`, + ) + } + } toMiddlewares.set(cli, middlewares) toCommands.set(cli, commands) return cli @@ -316,6 +383,7 @@ export declare namespace create { options extends z.ZodObject | undefined = undefined, output extends z.ZodType | undefined = undefined, vars extends z.ZodObject | undefined = undefined, + globals extends z.ZodObject | undefined = undefined, > = { /** Map of option names to single-char aliases. */ alias?: options extends z.ZodObject @@ -353,6 +421,12 @@ export declare namespace create { fetch?: FetchHandler | undefined /** Default output format. Overridden by `--format` or `--json`. */ format?: Formatter.Format | undefined + /** Map of global option names to single-char aliases. */ + globalAlias?: globals extends z.ZodObject + ? Partial, string>> + : Record | undefined + /** Zod schema for global options available to all commands. Parsed before command resolution and passed to middleware and command handlers. */ + globals?: globals | undefined /** Zod schema for named options/flags. */ options?: options | undefined /** Zod schema for the return value. */ @@ -490,9 +564,18 @@ async function serveImpl( schema, configPath, configDisabled, - rest: filtered, + rest, } = builtinFlags + // Parse global options from argv remainder + let globals: Record = {} + let filtered = rest + if (options.globals) { + const result = Parser.parseGlobals(rest, options.globals.schema, options.globals.alias) + globals = result.parsed + filtered = result.rest + } + // --mcp: start as MCP stdio server if (mcpFlag) { await Mcp.serve(name, options.version ?? '0.0.0', commands, { @@ -516,7 +599,15 @@ async function serveImpl( stdout(names.map((n) => Completions.register(completeShell, n)).join('\n')) } else { const index = Number(process.env._COMPLETE_INDEX ?? words.length - 1) - const candidates = Completions.complete(commands, options.rootCommand, words, index) + const candidates = Completions.complete( + commands, + options.rootCommand, + words, + index, + options.globals + ? { schema: options.globals.schema, alias: options.globals.alias } + : undefined, + ) // Add built-in commands (completions, mcp, skills) to completions const current = words[index] ?? '' const nonFlags = words.slice(0, index).filter((w) => !w.startsWith('-')) @@ -595,7 +686,12 @@ async function serveImpl( writeln(Skill.generate(scopedName, cmds, groups)) return } - writeln(Formatter.format(buildManifest(scopedCommands, prefix), formatFlag)) + writeln( + Formatter.format( + buildManifest(scopedCommands, prefix, options.globals?.schema), + formatFlag, + ), + ) return } @@ -606,7 +702,12 @@ async function serveImpl( writeln(Skill.index(scopedName, cmds, scopedDescription)) return } - writeln(Formatter.format(buildIndexManifest(scopedCommands, prefix), formatFlag)) + writeln( + Formatter.format( + buildIndexManifest(scopedCommands, prefix, options.globals?.schema), + formatFlag, + ), + ) return } @@ -852,6 +953,7 @@ async function serveImpl( aliases: options.aliases, configFlag, description: cmd.description ?? options.description, + globals: options.globals, version: options.version, args: cmd.args, env: cmd.env, @@ -874,6 +976,7 @@ async function serveImpl( aliases: options.aliases, configFlag, description: options.description, + globals: options.globals, version: options.version, commands: collectHelpCommands(commands), root: true, @@ -923,6 +1026,7 @@ async function serveImpl( aliases: options.aliases, configFlag, description: cmd.description ?? options.description, + globals: options.globals, version: options.version, args: cmd.args, env: cmd.env, @@ -941,6 +1045,7 @@ async function serveImpl( aliases: isRoot ? options.aliases : undefined, configFlag, description: helpDesc, + globals: options.globals, version: isRoot ? options.version : undefined, commands: collectHelpCommands(helpCmds), root: isRoot, @@ -961,6 +1066,7 @@ async function serveImpl( aliases: isRootCmd ? options.aliases : undefined, configFlag, description: cmd.description, + globals: options.globals, version: isRootCmd ? options.version : undefined, args: cmd.args, env: cmd.env, @@ -984,6 +1090,7 @@ async function serveImpl( Help.formatRoot(`${name} ${resolved.path}`, { configFlag, description: resolved.description, + globals: options.globals, commands: collectHelpCommands(resolved.commands), }), ) @@ -1009,6 +1116,7 @@ async function serveImpl( if (cmd.env) result.env = Schema.toJsonSchema(cmd.env) if (cmd.options) result.options = Schema.toJsonSchema(cmd.options) if (cmd.output) result.output = Schema.toJsonSchema(cmd.output) + if (options.globals?.schema) result.globals = Schema.toJsonSchema(options.globals.schema) writeln(Formatter.format(result, format)) return } @@ -1018,6 +1126,7 @@ async function serveImpl( Help.formatRoot(`${name} ${resolved.path}`, { configFlag, description: resolved.description, + globals: options.globals, commands: collectHelpCommands(resolved.commands), }), ) @@ -1366,6 +1475,7 @@ async function serveImpl( envSource: options.env, format, formatExplicit, + globals, inputOptions: {}, middlewares: allMiddleware, name, @@ -1452,6 +1562,8 @@ declare namespace fetchImpl { type Options = { /** CLI-level env schema. */ envSchema?: z.ZodObject | undefined + /** Global options schema and alias map. */ + globals?: { schema: z.ZodObject; alias?: Record } | undefined /** Group-level middleware collected during command resolution. */ groupMiddlewares?: MiddlewareHandler[] | undefined mcpHandler?: @@ -1938,6 +2050,8 @@ declare namespace serveImpl { envSchema?: z.ZodObject | undefined /** CLI-level default output format. */ format?: Formatter.Format | undefined + /** Global options schema and alias map. */ + globals?: { schema: z.ZodObject; alias?: Record } | undefined /** Middleware handlers registered on the root CLI. */ middlewares?: MiddlewareHandler[] | undefined /** CLI-level default output policy. */ @@ -2307,6 +2421,12 @@ export const toConfigEnabled = new WeakMap() /** @internal Maps CLI instances to their output policy. */ const toOutputPolicy = new WeakMap() +/** @internal Maps CLI instances to their globals schema and alias map. */ +const toGlobals = new WeakMap< + Cli, + { schema: z.ZodObject; alias?: Record } +>() + /** @internal Sentinel symbol for `ok()` and `error()` return values. */ const sentinel = Symbol.for('incur.sentinel') @@ -2597,10 +2717,15 @@ function formatCta(name: string, cta: Cta): FormattedCta { } /** @internal Builds the `--llms` index manifest (name + description only) from the command tree. */ -function buildIndexManifest(commands: Map, prefix: string[] = []) { +function buildIndexManifest( + commands: Map, + prefix: string[] = [], + globalsSchema?: z.ZodObject, +) { return { version: 'incur.v1', commands: collectIndexCommands(commands, prefix).sort((a, b) => a.name.localeCompare(b.name)), + ...(globalsSchema ? { globals: Schema.toJsonSchema(globalsSchema) } : undefined), } } @@ -2626,10 +2751,15 @@ function collectIndexCommands( } /** @internal Builds the `--llms` manifest from the command tree. */ -function buildManifest(commands: Map, prefix: string[] = []) { +function buildManifest( + commands: Map, + prefix: string[] = [], + globalsSchema?: z.ZodObject, +) { return { version: 'incur.v1', commands: collectCommands(commands, prefix).sort((a, b) => a.name.localeCompare(b.name)), + ...(globalsSchema ? { globals: Schema.toJsonSchema(globalsSchema) } : undefined), } } From 631d99bfe41ca65a092aa74063753dede2aa5aa5 Mon Sep 17 00:00:00 2001 From: Frank Bagherzadeh Date: Fri, 27 Mar 2026 10:35:03 -0400 Subject: [PATCH 06/14] fix: rename rest to commandRest to avoid redeclaration in serveImpl --- src/Cli.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Cli.ts b/src/Cli.ts index 8094921..44980a7 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -1425,7 +1425,7 @@ async function serveImpl( return } - const { command, path, rest } = effective + const { command, path, rest: commandRest } = effective // Collect middleware: root CLI + groups traversed + per-command const allMiddleware = [ @@ -1438,7 +1438,7 @@ async function serveImpl( if (human) emitDeprecationWarnings( - rest, + commandRest, command.options, command.alias as Record | undefined, ) @@ -1468,7 +1468,7 @@ async function serveImpl( const result = await Command.execute(command, { agent: !human, - argv: rest, + argv: commandRest, defaults, displayName, env: options.envSchema, From c8797139929271ade7cf4d9a4f222239b021418b Mon Sep 17 00:00:00 2001 From: Frank Bagherzadeh Date: Fri, 27 Mar 2026 10:55:20 -0400 Subject: [PATCH 07/14] test: add globals integration, type-level, and parser unit tests 13 integration tests (middleware flow, aliases, defaults, routing, help, --llms, --schema, conflicts, negation, position flexibility), 2 type-level tests (generic flow, alias constraint), and 8 parser unit tests for parseGlobals. --- src/Cli.test-d.ts | 21 +++++ src/Cli.test.ts | 223 +++++++++++++++++++++++++++++++++++++++++++++ src/Parser.test.ts | 63 +++++++++++++ 3 files changed, 307 insertions(+) diff --git a/src/Cli.test-d.ts b/src/Cli.test-d.ts index 1679727..57c7d9e 100644 --- a/src/Cli.test-d.ts +++ b/src/Cli.test-d.ts @@ -337,3 +337,24 @@ test('create() accepts config-file defaults options', () => { config: { files: [42] }, }) }) + +test('globals type flows to middleware context', () => { + Cli.create('test', { + globals: z.object({ apiKey: z.string().optional() }), + }).use(async (c, next) => { + expectTypeOf(c.globals.apiKey).toEqualTypeOf() + await next() + }) +}) + +test('globalAlias keys are constrained to globals schema keys', () => { + Cli.create('test', { + globals: z.object({ apiKey: z.string() }), + globalAlias: { apiKey: 'k' }, + }) + // @ts-expect-error — 'foo' is not a globals key + Cli.create('test', { + globals: z.object({ apiKey: z.string() }), + globalAlias: { foo: 'f' }, + }) +}) diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 729b717..9366d50 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -4445,3 +4445,226 @@ describe('displayName', () => { expect(parsed.meta.cta.commands[0].command).toBe('mc login') }) }) + +describe('globals', () => { + test('globals are parsed and available in middleware', async () => { + const cli = Cli.create('test', { + globals: z.object({ rpcUrl: z.string() }), + vars: z.object({ rpcUrl: z.string().default('') }), + }) + .use(async (c, next) => { + c.set('rpcUrl', c.globals.rpcUrl) + await next() + }) + .command('ping', { + run(c) { + return { url: c.var.rpcUrl } + }, + }) + + const { output } = await serve(cli, ['--rpc-url', 'http://example.com', 'ping', '--json']) + expect(JSON.parse(output)).toEqual({ url: 'http://example.com' }) + }) + + test('globals aliases work', async () => { + const cli = Cli.create('test', { + globals: z.object({ rpcUrl: z.string() }), + globalAlias: { rpcUrl: 'r' }, + vars: z.object({ rpcUrl: z.string().default('') }), + }) + .use(async (c, next) => { + c.set('rpcUrl', c.globals.rpcUrl) + await next() + }) + .command('ping', { + run(c) { + return { url: c.var.rpcUrl } + }, + }) + + const { output } = await serve(cli, ['-r', 'http://example.com', 'ping', '--json']) + expect(JSON.parse(output)).toEqual({ url: 'http://example.com' }) + }) + + test('globals with defaults work when not provided', async () => { + const cli = Cli.create('test', { + globals: z.object({ chain: z.string().default('mainnet') }), + vars: z.object({ chain: z.string().default('') }), + }) + .use(async (c, next) => { + c.set('chain', c.globals.chain) + await next() + }) + .command('ping', { + run(c) { + return { chain: c.var.chain } + }, + }) + + const { output } = await serve(cli, ['ping', '--json']) + expect(JSON.parse(output)).toEqual({ chain: 'mainnet' }) + }) + + test('globals are stripped from argv before command routing', async () => { + const cli = Cli.create('test', { + globals: z.object({ rpcUrl: z.string() }), + vars: z.object({ rpcUrl: z.string().default('') }), + }) + .use(async (c, next) => { + c.set('rpcUrl', c.globals.rpcUrl) + await next() + }) + .command('ping', { + run(c) { + return { url: c.var.rpcUrl } + }, + }) + + const { output } = await serve(cli, [ + '--rpc-url', + 'http://example.com', + 'ping', + '--json', + ]) + expect(JSON.parse(output)).toEqual({ url: 'http://example.com' }) + }) + + test('globals appear in --help output', async () => { + const cli = Cli.create('test', { + globals: z.object({ + rpcUrl: z.string().optional().describe('RPC endpoint URL'), + }), + globalAlias: { rpcUrl: 'r' }, + }).command('ping', { run: () => ({}) }) + + const { output } = await serve(cli, ['--help']) + expect(output).toContain('Custom Global Options') + expect(output).toContain('--rpc-url') + }) + + test('globals appear in --llms manifest', async () => { + const cli = Cli.create('test', { + globals: z.object({ + rpcUrl: z.string().optional().describe('RPC endpoint URL'), + }), + }).command('ping', { description: 'Health check', run: () => ({}) }) + + const { output } = await serve(cli, ['--llms', '--format', 'json']) + const manifest = JSON.parse(output) + expect(manifest.globals).toBeDefined() + expect(manifest.globals.properties.rpcUrl).toBeDefined() + }) + + test('globals validation error throws', async () => { + const cli = Cli.create('test', { + globals: z.object({ limit: z.number() }), + }).command('ping', { run: () => ({}) }) + + await expect(serve(cli, ['--limit', 'not-a-number', 'ping'])).rejects.toThrow( + expect.objectContaining({ name: 'Incur.ValidationError' }), + ) + }) + + test('globals work with subcommands', async () => { + const cli = Cli.create('test', { + globals: z.object({ rpcUrl: z.string() }), + vars: z.object({ rpcUrl: z.string().default('') }), + }) + .use(async (c, next) => { + c.set('rpcUrl', c.globals.rpcUrl) + await next() + }) + .command('deploy', { + run(c) { + return { url: c.var.rpcUrl } + }, + }) + + const { output } = await serve(cli, [ + '--rpc-url', + 'http://x', + 'deploy', + '--json', + ]) + expect(JSON.parse(output)).toEqual({ url: 'http://x' }) + }) + + test('globals position is flexible', async () => { + const cli = Cli.create('test', { + globals: z.object({ rpcUrl: z.string() }), + vars: z.object({ rpcUrl: z.string().default('') }), + }) + .use(async (c, next) => { + c.set('rpcUrl', c.globals.rpcUrl) + await next() + }) + .command('deploy', { + run(c) { + return { url: c.var.rpcUrl } + }, + }) + + const { output } = await serve(cli, [ + 'deploy', + '--rpc-url', + 'http://x', + '--json', + ]) + expect(JSON.parse(output)).toEqual({ url: 'http://x' }) + }) + + test('globals conflict with builtins errors at create() time', () => { + expect(() => + Cli.create('test', { + globals: z.object({ format: z.string() }), + }), + ).toThrow(/conflicts with a built-in flag/) + }) + + test('command option conflicting with global errors at command() time', () => { + const cli = Cli.create('test', { + globals: z.object({ rpcUrl: z.string() }), + }) + expect(() => + cli.command('deploy', { + options: z.object({ rpcUrl: z.string() }), + run: () => ({}), + }), + ).toThrow(/conflicts with a global option/) + }) + + test('boolean globals handle --no- negation', async () => { + const cli = Cli.create('test', { + globals: z.object({ dryRun: z.boolean().default(true) }), + vars: z.object({ dryRun: z.boolean().default(false) }), + }) + .use(async (c, next) => { + c.set('dryRun', c.globals.dryRun) + await next() + }) + .command('ping', { + run(c) { + return { dryRun: c.var.dryRun } + }, + }) + + const { output } = await serve(cli, ['--no-dry-run', 'ping', '--json']) + expect(JSON.parse(output)).toEqual({ dryRun: false }) + }) + + test('globals appear in --schema output', async () => { + const cli = Cli.create('test', { + globals: z.object({ + rpcUrl: z.string().optional().describe('RPC endpoint URL'), + }), + }).command('ping', { + args: z.object({ target: z.string() }), + run: () => ({}), + }) + + const { output } = await serve(cli, ['ping', '--schema', '--format', 'json']) + const parsed = JSON.parse(output) + expect(parsed.globals).toBeDefined() + expect(parsed.globals.properties.rpcUrl).toBeDefined() + }) +}) diff --git a/src/Parser.test.ts b/src/Parser.test.ts index 5061ce8..ce4c25b 100644 --- a/src/Parser.test.ts +++ b/src/Parser.test.ts @@ -342,3 +342,66 @@ describe('parse', () => { expect(result.options).toEqual({ min: 1, max: 3 }) }) }) + +describe('parseGlobals', () => { + test('extracts known globals and returns rest', () => { + const schema = z.object({ rpcUrl: z.string() }) + const result = Parser.parseGlobals(['--rpc-url', 'http://example.com', 'deploy'], schema) + expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' }) + expect(result.rest).toEqual(['deploy']) + }) + + test('unknown flags pass through to rest', () => { + const schema = z.object({ rpcUrl: z.string() }) + const result = Parser.parseGlobals( + ['--rpc-url', 'http://example.com', '--unknown', 'val', 'deploy'], + schema, + ) + expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' }) + expect(result.rest).toEqual(['--unknown', 'val', 'deploy']) + }) + + test('handles --flag=value syntax', () => { + const schema = z.object({ rpcUrl: z.string() }) + const result = Parser.parseGlobals(['--rpc-url=http://example.com', 'deploy'], schema) + expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' }) + expect(result.rest).toEqual(['deploy']) + }) + + test('handles short aliases', () => { + const schema = z.object({ rpcUrl: z.string() }) + const result = Parser.parseGlobals( + ['-r', 'http://example.com', 'deploy'], + schema, + { rpcUrl: 'r' }, + ) + expect(result.parsed).toEqual({ rpcUrl: 'http://example.com' }) + expect(result.rest).toEqual(['deploy']) + }) + + test('handles boolean globals', () => { + const schema = z.object({ verbose: z.boolean().default(false) }) + const result = Parser.parseGlobals(['--verbose', 'deploy'], schema) + expect(result.parsed).toEqual({ verbose: true }) + expect(result.rest).toEqual(['deploy']) + }) + + test('validates against schema', () => { + const schema = z.object({ count: z.number() }) + expect(() => Parser.parseGlobals(['--count', 'not-a-number'], schema)).toThrow() + }) + + test('coerces string to number', () => { + const schema = z.object({ limit: z.number() }) + const result = Parser.parseGlobals(['--limit', '42', 'deploy'], schema) + expect(result.parsed).toEqual({ limit: 42 }) + expect(result.rest).toEqual(['deploy']) + }) + + test('positionals pass through to rest', () => { + const schema = z.object({ verbose: z.boolean().default(false) }) + const result = Parser.parseGlobals(['deploy', 'contract', '--verbose'], schema) + expect(result.parsed).toEqual({ verbose: true }) + expect(result.rest).toEqual(['deploy', 'contract']) + }) +}) From 1f58b215ea1fbd86530075756cbb7d4a60fd276f Mon Sep 17 00:00:00 2001 From: Frank Bagherzadeh Date: Fri, 27 Mar 2026 11:20:29 -0400 Subject: [PATCH 08/14] fix: globals parsing edge cases and collision detection - Stop unknown flags from greedily eating the next token as a value - Add -- separator handling in parseGlobals - Wrap parseGlobals in try/catch for clean error envelopes - Validate globalAlias values against reserved short flags (-h) - Check command alias collisions with global aliases - Include --no-{config} negation in builtin collision list - Add globals to fetch gateway middleware context --- src/Cli.ts | 36 ++++++++++++++++++++++++++++++++---- src/Parser.ts | 17 +++++++---------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/src/Cli.ts b/src/Cli.ts index 44980a7..4fa20d8 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -275,6 +275,15 @@ export function create( ) } } + if (globalsDesc?.alias && def?.alias) { + const globalAliasValues = new Set(Object.values(globalsDesc.alias)) + for (const [name, short] of Object.entries(def.alias as Record)) { + if (globalAliasValues.has(short)) + throw new Error( + `Command '${nameOrCli}' alias '-${short}' for '${name}' conflicts with a global alias. Choose a different alias.`, + ) + } + } commands.set(nameOrCli, def) return cli } @@ -360,7 +369,7 @@ export function create( 'tokenLimit', 'tokenOffset', 'tokenCount', - ...(def.config?.flag ? [def.config.flag] : []), + ...(def.config?.flag ? [def.config.flag, `no${def.config.flag[0].toUpperCase()}${def.config.flag.slice(1)}`] : []), ] const globalKeys = Object.keys(def.globals.shape) for (const key of globalKeys) { @@ -369,6 +378,16 @@ export function create( `Global option '${key}' conflicts with a built-in flag. Choose a different name.`, ) } + // Check globalAlias values against reserved short aliases + const reservedShorts = new Set(['h']) + if (def.globalAlias) { + for (const [name, short] of Object.entries(def.globalAlias as Record)) { + if (reservedShorts.has(short)) + throw new Error( + `Global alias '-${short}' for '${name}' conflicts with a built-in short flag. Choose a different alias.`, + ) + } + } } toMiddlewares.set(cli, middlewares) toCommands.set(cli, commands) @@ -571,9 +590,17 @@ async function serveImpl( let globals: Record = {} let filtered = rest if (options.globals) { - const result = Parser.parseGlobals(rest, options.globals.schema, options.globals.alias) - globals = result.parsed - filtered = result.rest + try { + const result = Parser.parseGlobals(rest, options.globals.schema, options.globals.alias) + globals = result.parsed + filtered = result.rest + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (human) writeln(formatHumanError({ code: 'UNKNOWN', message })) + else writeln(Formatter.format({ code: 'UNKNOWN', message }, 'toon')) + exit(1) + return + } } // --mcp: start as MCP stdio server @@ -1375,6 +1402,7 @@ async function serveImpl( error: errorFn, format, formatExplicit, + globals, name, set(key: string, value: unknown) { varsMap[key] = value diff --git a/src/Parser.ts b/src/Parser.ts index a44806b..bd7f0c8 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -329,6 +329,11 @@ export function parseGlobals>( while (i < argv.length) { const token = argv[i]! + if (token === '--') { + for (let j = i; j < argv.length; j++) rest.push(argv[j]!) + break + } + if (token.startsWith('--no-') && token.length > 5) { const name = normalizeOptionName(token.slice(5), optionNames) if (!name) { @@ -353,13 +358,9 @@ export function parseGlobals>( // --flag [value] const name = normalizeOptionName(token.slice(2), optionNames) if (!name) { - // Unknown flag — pass through with its potential value + // Unknown flag — pass through as-is rest.push(token) i++ - if (i < argv.length && !argv[i]!.startsWith('-')) { - rest.push(argv[i]!) - i++ - } } else if (isCountOption(name, schema)) { rawOptions[name] = ((rawOptions[name] as number) ?? 0) + 1 i++ @@ -386,13 +387,9 @@ export function parseGlobals>( } if (!allKnown) { - // Unknown short flag — pass through with its potential value + // Unknown short flag — pass through as-is rest.push(token) i++ - if (i < argv.length && !argv[i]!.startsWith('-')) { - rest.push(argv[i]!) - i++ - } } else { for (let j = 0; j < chars.length; j++) { const short = chars[j]! From de4ec0c04a66e7617c05ee1d57cccccc123a6786 Mon Sep 17 00:00:00 2001 From: Frank Bagherzadeh Date: Fri, 27 Mar 2026 11:21:05 -0400 Subject: [PATCH 09/14] chore: remove duplicate tests and use toKebab in completions - Delete redundant "stripped from argv" and "work with subcommands" tests - Update validation error test to match new try/catch behavior - Use toKebab import instead of inline regex in Completions.ts --- src/Cli.test.ts | 56 ++++------------------------------------------ src/Completions.ts | 5 +++-- 2 files changed, 7 insertions(+), 54 deletions(-) diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 9366d50..91f9aeb 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -4505,30 +4505,6 @@ describe('globals', () => { expect(JSON.parse(output)).toEqual({ chain: 'mainnet' }) }) - test('globals are stripped from argv before command routing', async () => { - const cli = Cli.create('test', { - globals: z.object({ rpcUrl: z.string() }), - vars: z.object({ rpcUrl: z.string().default('') }), - }) - .use(async (c, next) => { - c.set('rpcUrl', c.globals.rpcUrl) - await next() - }) - .command('ping', { - run(c) { - return { url: c.var.rpcUrl } - }, - }) - - const { output } = await serve(cli, [ - '--rpc-url', - 'http://example.com', - 'ping', - '--json', - ]) - expect(JSON.parse(output)).toEqual({ url: 'http://example.com' }) - }) - test('globals appear in --help output', async () => { const cli = Cli.create('test', { globals: z.object({ @@ -4555,38 +4531,14 @@ describe('globals', () => { expect(manifest.globals.properties.rpcUrl).toBeDefined() }) - test('globals validation error throws', async () => { + test('globals validation error shows message and exits 1', async () => { const cli = Cli.create('test', { globals: z.object({ limit: z.number() }), }).command('ping', { run: () => ({}) }) - await expect(serve(cli, ['--limit', 'not-a-number', 'ping'])).rejects.toThrow( - expect.objectContaining({ name: 'Incur.ValidationError' }), - ) - }) - - test('globals work with subcommands', async () => { - const cli = Cli.create('test', { - globals: z.object({ rpcUrl: z.string() }), - vars: z.object({ rpcUrl: z.string().default('') }), - }) - .use(async (c, next) => { - c.set('rpcUrl', c.globals.rpcUrl) - await next() - }) - .command('deploy', { - run(c) { - return { url: c.var.rpcUrl } - }, - }) - - const { output } = await serve(cli, [ - '--rpc-url', - 'http://x', - 'deploy', - '--json', - ]) - expect(JSON.parse(output)).toEqual({ url: 'http://x' }) + const { output, exitCode } = await serve(cli, ['--limit', 'not-a-number', 'ping']) + expect(exitCode).toBe(1) + expect(output).toContain('Invalid input') }) test('globals position is flexible', async () => { diff --git a/src/Completions.ts b/src/Completions.ts index 0ad327c..4d1665c 100644 --- a/src/Completions.ts +++ b/src/Completions.ts @@ -1,6 +1,7 @@ import type { z } from 'zod' import type { Shell } from './internal/command.js' +import { toKebab } from './internal/helpers.js' /** A completion candidate with an optional description. */ export type Candidate = { @@ -86,7 +87,7 @@ export function complete( if (leaf?.options) { const shape = leaf.options.shape as Record for (const key of Object.keys(shape)) { - const kebab = key.replace(/[A-Z]/g, (c: string) => `-${c.toLowerCase()}`) + const kebab = toKebab(key) const flag = `--${kebab}` if (flag.startsWith(current)) candidates.push({ value: flag, description: descriptionOf(shape[key]) }) @@ -105,7 +106,7 @@ export function complete( if (globals) { const globalShape = globals.schema.shape as Record for (const key of Object.keys(globalShape)) { - const kebab = key.replace(/[A-Z]/g, (c: string) => `-${c.toLowerCase()}`) + const kebab = toKebab(key) const flag = `--${kebab}` if (flag.startsWith(current) && !candidates.some((c) => c.value === flag)) candidates.push({ value: flag, description: descriptionOf(globalShape[key]) }) From 480bd4b3ebbdc18659709d8dcc922cc7835901fd Mon Sep 17 00:00:00 2001 From: Frank Bagherzadeh Date: Fri, 27 Mar 2026 11:24:22 -0400 Subject: [PATCH 10/14] refactor: extract GlobalsDescriptor type, deduplicate across files Define GlobalsDescriptor once in Cli.ts, replace 6 inline repetitions across Cli.ts, Help.ts, and Completions.ts. --- src/Cli.ts | 12 ++++++------ src/Completions.ts | 8 ++------ src/Help.ts | 7 ++++--- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/src/Cli.ts b/src/Cli.ts index 4fa20d8..644168f 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -1591,7 +1591,7 @@ declare namespace fetchImpl { /** CLI-level env schema. */ envSchema?: z.ZodObject | undefined /** Global options schema and alias map. */ - globals?: { schema: z.ZodObject; alias?: Record } | undefined + globals?: GlobalsDescriptor | undefined /** Group-level middleware collected during command resolution. */ groupMiddlewares?: MiddlewareHandler[] | undefined mcpHandler?: @@ -2079,7 +2079,7 @@ declare namespace serveImpl { /** CLI-level default output format. */ format?: Formatter.Format | undefined /** Global options schema and alias map. */ - globals?: { schema: z.ZodObject; alias?: Record } | undefined + globals?: GlobalsDescriptor | undefined /** Middleware handlers registered on the root CLI. */ middlewares?: MiddlewareHandler[] | undefined /** CLI-level default output policy. */ @@ -2449,11 +2449,11 @@ export const toConfigEnabled = new WeakMap() /** @internal Maps CLI instances to their output policy. */ const toOutputPolicy = new WeakMap() +/** Descriptor for a CLI's custom global options schema and aliases. */ +export type GlobalsDescriptor = { schema: z.ZodObject; alias?: Record } + /** @internal Maps CLI instances to their globals schema and alias map. */ -const toGlobals = new WeakMap< - Cli, - { schema: z.ZodObject; alias?: Record } ->() +const toGlobals = new WeakMap() /** @internal Sentinel symbol for `ok()` and `error()` return values. */ const sentinel = Symbol.for('incur.sentinel') diff --git a/src/Completions.ts b/src/Completions.ts index 4d1665c..b3393eb 100644 --- a/src/Completions.ts +++ b/src/Completions.ts @@ -1,5 +1,6 @@ import type { z } from 'zod' +import type { GlobalsDescriptor } from './Cli.js' import type { Shell } from './internal/command.js' import { toKebab } from './internal/helpers.js' @@ -40,11 +41,6 @@ export function register(shell: Shell, name: string): string { } } -/** Global options descriptor for completion generation. */ -export type GlobalOptions = { - alias?: Record | undefined - schema: z.ZodObject -} /** * Computes completion candidates for the given argv words and cursor index. @@ -56,7 +52,7 @@ export function complete( rootCommand: CommandEntry | undefined, argv: string[], index: number, - globals?: GlobalOptions | undefined, + globals?: GlobalsDescriptor | undefined, ): Candidate[] { const current = argv[index] ?? '' diff --git a/src/Help.ts b/src/Help.ts index 984840b..cde209a 100644 --- a/src/Help.ts +++ b/src/Help.ts @@ -1,5 +1,6 @@ import { z } from 'zod' +import type { GlobalsDescriptor } from './Cli.js' import { builtinCommands } from './internal/command.js' import { toKebab } from './internal/helpers.js' @@ -54,7 +55,7 @@ export declare namespace formatRoot { /** A short description of the CLI or group. */ description?: string | undefined /** Custom global options schema and alias map. */ - globals?: { schema: z.ZodObject; alias?: Record } | undefined + globals?: GlobalsDescriptor | undefined /** Show root-level built-in commands and flags. */ root?: boolean | undefined /** CLI version string. */ @@ -81,7 +82,7 @@ export declare namespace formatCommand { /** Override environment variable source for "set:" display. Defaults to `process.env`. */ envSource?: Record | undefined /** Custom global options schema and alias map. */ - globals?: { schema: z.ZodObject; alias?: Record } | undefined + globals?: GlobalsDescriptor | undefined /** Formatted usage examples. */ examples?: { command: string; description?: string }[] | undefined /** Plain text hint displayed after examples and before global options. */ @@ -350,7 +351,7 @@ function extractDeprecated(schema: unknown): boolean | undefined { function globalOptionsLines( root = false, configFlag?: string, - globals?: { schema: z.ZodObject; alias?: Record }, + globals?: GlobalsDescriptor, ): string[] { const lines: string[] = [] From 9c7f07cff77b581f98f917fd026c72bc18a79f20 Mon Sep 17 00:00:00 2001 From: Frank Bagherzadeh Date: Fri, 27 Mar 2026 11:26:35 -0400 Subject: [PATCH 11/14] chore: add changeset for custom global options --- .changeset/custom-global-options.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/custom-global-options.md diff --git a/.changeset/custom-global-options.md b/.changeset/custom-global-options.md new file mode 100644 index 0000000..86791e7 --- /dev/null +++ b/.changeset/custom-global-options.md @@ -0,0 +1,5 @@ +--- +"incur": minor +--- + +Add custom global options support via `globals` and `globalAlias` on `Cli.create()`. Global options are parsed before command resolution, available in middleware via `c.globals`, and rendered in `--help`, `--llms`, `--schema`, and shell completions. From 4bddd33a28faa29a71854e82e65bf0d5245f2a91 Mon Sep 17 00:00:00 2001 From: Frank Bagherzadeh Date: Fri, 27 Mar 2026 11:30:15 -0400 Subject: [PATCH 12/14] =?UTF-8?q?fix:=20CI=20type=20errors=20=E2=80=94=20e?= =?UTF-8?q?xactOptionalPropertyTypes=20and=20ts-expect-error?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add undefined to GlobalsDescriptor.alias for exactOptionalPropertyTypes - Restructure globalAlias type test to avoid tsc -b @ts-expect-error quirk --- src/Cli.test-d.ts | 10 +++++----- src/Cli.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Cli.test-d.ts b/src/Cli.test-d.ts index 57c7d9e..597b33b 100644 --- a/src/Cli.test-d.ts +++ b/src/Cli.test-d.ts @@ -352,9 +352,9 @@ test('globalAlias keys are constrained to globals schema keys', () => { globals: z.object({ apiKey: z.string() }), globalAlias: { apiKey: 'k' }, }) - // @ts-expect-error — 'foo' is not a globals key - Cli.create('test', { - globals: z.object({ apiKey: z.string() }), - globalAlias: { foo: 'f' }, - }) + + const globals = z.object({ apiKey: z.string() }) + // @ts-expect-error — 'foo' is not a key of the globals schema + const badAlias: Partial, string>> = { foo: 'f' } + void badAlias }) diff --git a/src/Cli.ts b/src/Cli.ts index 644168f..2cbabd5 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -2450,7 +2450,7 @@ export const toConfigEnabled = new WeakMap() const toOutputPolicy = new WeakMap() /** Descriptor for a CLI's custom global options schema and aliases. */ -export type GlobalsDescriptor = { schema: z.ZodObject; alias?: Record } +export type GlobalsDescriptor = { schema: z.ZodObject; alias?: Record | undefined } /** @internal Maps CLI instances to their globals schema and alias map. */ const toGlobals = new WeakMap() From a793a9bfdc961026b8582d695fe7cae238871e4f Mon Sep 17 00:00:00 2001 From: Frank Bagherzadeh Date: Fri, 27 Mar 2026 11:41:01 -0400 Subject: [PATCH 13/14] test: close coverage gaps in completions, parser, cli, and help - 5 completions tests (global flags, aliases, dedup, enum values, booleans) - 7 parser tests (-- separator, stacked shorts, count, array, unknown --no-*, unknown --flag=value, missing value error) - 3 cli tests (parseGlobals error envelope, -h alias conflict, command alias conflict) - 2 help tests (deprecated globals rendering, globals with defaults in formatRoot) --- src/Cli.test.ts | 33 ++++++++++++++++ src/Completions.test.ts | 84 +++++++++++++++++++++++++++++++++++++++++ src/Help.test.ts | 31 +++++++++++++++ src/Parser.test.ts | 58 ++++++++++++++++++++++++++++ 4 files changed, 206 insertions(+) diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 91f9aeb..731909a 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -4604,6 +4604,39 @@ describe('globals', () => { expect(JSON.parse(output)).toEqual({ dryRun: false }) }) + test('parseGlobals error produces clean error output with exit code 1', async () => { + const cli = Cli.create('test', { + globals: z.object({ rpcUrl: z.string() }), + }).command('ping', { run: () => ({}) }) + + const { output, exitCode } = await serve(cli, ['--rpc-url']) + expect(exitCode).toBe(1) + expect(output).toContain('Missing value for flag') + }) + + test('global alias collision with -h throws at create() time', () => { + expect(() => + Cli.create('test', { + globals: z.object({ host: z.string().optional() }), + globalAlias: { host: 'h' }, + }), + ).toThrow(/conflicts with a built-in short flag/) + }) + + test('command alias collision with global alias throws at command() time', () => { + const cli = Cli.create('test', { + globals: z.object({ rpcUrl: z.string().optional() }), + globalAlias: { rpcUrl: 'r' }, + }) + expect(() => + cli.command('deploy', { + options: z.object({ region: z.string().optional() }), + alias: { region: 'r' }, + run: () => ({}), + }), + ).toThrow(/conflicts with a global alias/) + }) + test('globals appear in --schema output', async () => { const cli = Cli.create('test', { globals: z.object({ diff --git a/src/Completions.test.ts b/src/Completions.test.ts index 9d5f5f2..efdc975 100644 --- a/src/Completions.test.ts +++ b/src/Completions.test.ts @@ -183,6 +183,90 @@ describe('complete', () => { }) }) +describe('complete with globals', () => { + const globalSchema = z.object({ + rpcUrl: z.string().optional().describe('RPC endpoint URL'), + chain: z.enum(['mainnet', 'sepolia', 'goerli']).default('mainnet').describe('Target chain'), + dryRun: z.boolean().default(false).describe('Dry run mode'), + }) + const globalAlias = { rpcUrl: 'r', dryRun: 'd' } + const globals = { schema: globalSchema, alias: globalAlias } + + test('global flags appear as completion candidates when typing --', () => { + const cli = makeCli() + const commands = Cli.toCommands.get(cli)! + const candidates = Completions.complete(commands, undefined, ['mycli', 'build', '--'], 2, globals) + const values = candidates.map((c) => c.value) + expect(values).toContain('--rpc-url') + expect(values).toContain('--chain') + expect(values).toContain('--dry-run') + }) + + test('global short aliases appear as candidates when typing -', () => { + const cli = makeCli() + const commands = Cli.toCommands.get(cli)! + const candidates = Completions.complete(commands, undefined, ['mycli', 'build', '-'], 2, globals) + const values = candidates.map((c) => c.value) + expect(values).toContain('-r') + expect(values).toContain('-d') + }) + + test('global flags do not duplicate command-specific flags', () => { + // Create a CLI where a command has --chain already + const cli = Cli.create('mycli', { version: '1.0.0' }) + cli.command('deploy', { + description: 'Deploy', + options: z.object({ + chain: z.string().optional().describe('Chain override'), + }), + run: () => ({}), + }) + const commands = Cli.toCommands.get(cli)! + const rootCmd = { options: z.object({ chain: z.string().optional() }) } + const dupeGlobals = { schema: z.object({ chain: z.string().optional().describe('Global chain') }) } + const candidates = Completions.complete( + commands, + rootCmd, + ['mycli', 'deploy', '--'], + 2, + dupeGlobals, + ) + const chainCount = candidates.filter((c) => c.value === '--chain').length + expect(chainCount).toBe(1) + }) + + test('global option value completion for enum', () => { + const cli = makeCli() + const commands = Cli.toCommands.get(cli)! + const candidates = Completions.complete( + commands, + undefined, + ['mycli', 'build', '--chain', ''], + 3, + globals, + ) + const values = candidates.map((c) => c.value) + expect(values).toEqual(['mainnet', 'sepolia', 'goerli']) + }) + + test('global boolean flag does not consume next token as value in completions', () => { + const cli = makeCli() + const commands = Cli.toCommands.get(cli)! + // After --dry-run (boolean), the next token should suggest subcommands, not be consumed as value + const candidates = Completions.complete( + commands, + undefined, + ['mycli', '--dry-run', ''], + 2, + globals, + ) + const values = candidates.map((c) => c.value) + // Should suggest subcommands since --dry-run is boolean and doesn't consume next token + expect(values).toContain('build') + expect(values).toContain('test') + }) +}) + describe('format', () => { const candidates: Completions.Candidate[] = [ { value: '--target', description: 'Build target' }, diff --git a/src/Help.test.ts b/src/Help.test.ts index 7630a10..8a85a27 100644 --- a/src/Help.test.ts +++ b/src/Help.test.ts @@ -324,4 +324,35 @@ describe('formatRoot', () => { --version Show version" `) }) + + test('formatCommand shows custom global options with deprecated flag', () => { + const result = Help.formatCommand('tool deploy', { + description: 'Deploy app', + globals: { + schema: z.object({ + rpcUrl: z.string().optional().describe('RPC endpoint URL'), + oldRpc: z.string().optional().describe('Old RPC endpoint').meta({ deprecated: true }), + }), + alias: { rpcUrl: 'r' }, + }, + }) + expect(result).toContain('Custom Global Options:') + expect(result).toContain('--rpc-url, -r ') + expect(result).toContain('RPC endpoint URL') + expect(result).toContain('[deprecated] Old RPC endpoint') + }) + + test('formatRoot shows custom global options', () => { + const result = Help.formatRoot('tool', { + globals: { + schema: z.object({ + chain: z.string().default('mainnet').describe('Target chain'), + }), + }, + commands: [{ name: 'deploy', description: 'Deploy' }], + }) + expect(result).toContain('Custom Global Options:') + expect(result).toContain('--chain ') + expect(result).toContain('Target chain (default: mainnet)') + }) }) diff --git a/src/Parser.test.ts b/src/Parser.test.ts index ce4c25b..e944332 100644 --- a/src/Parser.test.ts +++ b/src/Parser.test.ts @@ -404,4 +404,62 @@ describe('parseGlobals', () => { expect(result.parsed).toEqual({ verbose: true }) expect(result.rest).toEqual(['deploy', 'contract']) }) + + test('-- separator: everything after -- passes through to rest including the --', () => { + const schema = z.object({ verbose: z.boolean().default(false) }) + const result = Parser.parseGlobals( + ['--verbose', '--', '--unknown', 'positional', '--also-unknown'], + schema, + ) + expect(result.parsed).toEqual({ verbose: true }) + expect(result.rest).toEqual(['--', '--unknown', 'positional', '--also-unknown']) + }) + + test('stacked short aliases: -rv where both are known boolean globals', () => { + const schema = z.object({ + recursive: z.boolean().default(false), + verbose: z.boolean().default(false), + }) + const result = Parser.parseGlobals(['-rv', 'deploy'], schema, { + recursive: 'r', + verbose: 'v', + }) + expect(result.parsed).toEqual({ recursive: true, verbose: true }) + expect(result.rest).toEqual(['deploy']) + }) + + test('count options: --verbose --verbose accumulates', () => { + const schema = z.object({ verbose: z.number().default(0).meta({ count: true }) }) + const result = Parser.parseGlobals(['--verbose', '--verbose', 'deploy'], schema) + expect(result.parsed).toEqual({ verbose: 2 }) + expect(result.rest).toEqual(['deploy']) + }) + + test('array options: --tag foo --tag bar collects into array', () => { + const schema = z.object({ tag: z.array(z.string()).default([]) }) + const result = Parser.parseGlobals(['--tag', 'foo', '--tag', 'bar', 'deploy'], schema) + expect(result.parsed).toEqual({ tag: ['foo', 'bar'] }) + expect(result.rest).toEqual(['deploy']) + }) + + test('unknown --no-* flags pass through to rest', () => { + const schema = z.object({ verbose: z.boolean().default(false) }) + const result = Parser.parseGlobals(['--no-color', '--verbose'], schema) + expect(result.parsed).toEqual({ verbose: true }) + expect(result.rest).toEqual(['--no-color']) + }) + + test('unknown --flag=value passes through as single token', () => { + const schema = z.object({ verbose: z.boolean().default(false) }) + const result = Parser.parseGlobals(['--output=json', '--verbose'], schema) + expect(result.parsed).toEqual({ verbose: true }) + expect(result.rest).toEqual(['--output=json']) + }) + + test('missing value for known flag throws ParseError', () => { + const schema = z.object({ rpcUrl: z.string() }) + expect(() => Parser.parseGlobals(['--rpc-url'], schema)).toThrow( + expect.objectContaining({ name: 'Incur.ParseError' }), + ) + }) }) From 68c502d34f257fb80fc82024dfd7843ac31f5805 Mon Sep 17 00:00:00 2001 From: Frank Bagherzadeh Date: Fri, 27 Mar 2026 11:50:55 -0400 Subject: [PATCH 14/14] test: close remaining coverage gaps in parseGlobals and serveImpl - 6 parser tests (stacked short count/non-boolean/value-taking, short missing value, known --no- negation, known --flag=value with array) - 1 cli test (agent-mode error formatting for globals validation) --- src/Cli.test.ts | 12 +++++++++++ src/Parser.test.ts | 52 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 731909a..b2fe683 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -4637,6 +4637,18 @@ describe('globals', () => { ).toThrow(/conflicts with a global alias/) }) + test('globals validation error in agent mode outputs toon format', async () => { + ;(process.stdout as any).isTTY = false + const cli = Cli.create('test', { + globals: z.object({ limit: z.number() }), + }).command('ping', { run: () => ({}) }) + + const { output, exitCode } = await serve(cli, ['--limit', 'abc', 'ping']) + expect(exitCode).toBe(1) + expect(output).toContain('UNKNOWN') + ;(process.stdout as any).isTTY = true + }) + test('globals appear in --schema output', async () => { const cli = Cli.create('test', { globals: z.object({ diff --git a/src/Parser.test.ts b/src/Parser.test.ts index e944332..39bfa9b 100644 --- a/src/Parser.test.ts +++ b/src/Parser.test.ts @@ -462,4 +462,56 @@ describe('parseGlobals', () => { expect.objectContaining({ name: 'Incur.ParseError' }), ) }) + + test('stacked short: count in non-last position', () => { + const schema = z.object({ + verbose: z.number().default(0).meta({ count: true }), + recursive: z.boolean().default(false), + }) + const result = Parser.parseGlobals(['-vr'], schema, { verbose: 'v', recursive: 'r' }) + expect(result.parsed).toEqual({ verbose: 1, recursive: true }) + }) + + test('stacked short: non-boolean in non-last position throws', () => { + const schema = z.object({ + output: z.string(), + verbose: z.boolean().default(false), + }) + expect(() => Parser.parseGlobals(['-ov', 'file'], schema, { output: 'o', verbose: 'v' })).toThrow( + /must be last/, + ) + }) + + test('short flag value-taking as last in stacked alias', () => { + const schema = z.object({ + verbose: z.boolean().default(false), + output: z.string(), + }) + const result = Parser.parseGlobals(['-vo', 'file', 'deploy'], schema, { + verbose: 'v', + output: 'o', + }) + expect(result.parsed).toEqual({ verbose: true, output: 'file' }) + expect(result.rest).toEqual(['deploy']) + }) + + test('short flag missing value throws ParseError', () => { + const schema = z.object({ output: z.string() }) + expect(() => Parser.parseGlobals(['-o'], schema, { output: 'o' })).toThrow( + expect.objectContaining({ name: 'Incur.ParseError' }), + ) + }) + + test('known --no- negation for boolean global', () => { + const schema = z.object({ verbose: z.boolean().default(true) }) + const result = Parser.parseGlobals(['--no-verbose', 'deploy'], schema) + expect(result.parsed).toEqual({ verbose: false }) + expect(result.rest).toEqual(['deploy']) + }) + + test('known --flag=value with setOption', () => { + const schema = z.object({ tag: z.array(z.string()).default([]) }) + const result = Parser.parseGlobals(['--tag=foo', '--tag=bar'], schema) + expect(result.parsed).toEqual({ tag: ['foo', 'bar'] }) + }) })