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. diff --git a/src/Cli.test-d.ts b/src/Cli.test-d.ts index 1679727..597b33b 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' }, + }) + + 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.test.ts b/src/Cli.test.ts index 729b717..b2fe683 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -4445,3 +4445,223 @@ 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 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 shows message and exits 1', async () => { + const cli = Cli.create('test', { + globals: z.object({ limit: z.number() }), + }).command('ping', { run: () => ({}) }) + + 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 () => { + 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('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 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({ + 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/Cli.ts b/src/Cli.ts index f7a2f82..2cbabd5 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,26 @@ 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.`, + ) + } + } + 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 } @@ -262,8 +308,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 +323,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 +331,7 @@ export function create( description: def.description, envSchema: def.env, format: def.format, + globals: globalsDesc, mcp: def.mcp, middlewares, outputPolicy: def.outputPolicy, @@ -303,6 +353,42 @@ 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, `no${def.config.flag[0].toUpperCase()}${def.config.flag.slice(1)}`] : []), + ] + 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.`, + ) + } + // 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) return cli @@ -316,6 +402,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 +440,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 +583,26 @@ 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) { + 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 if (mcpFlag) { await Mcp.serve(name, options.version ?? '0.0.0', commands, { @@ -516,7 +626,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 +713,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 +729,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 +980,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 +1003,7 @@ async function serveImpl( aliases: options.aliases, configFlag, description: options.description, + globals: options.globals, version: options.version, commands: collectHelpCommands(commands), root: true, @@ -923,6 +1053,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 +1072,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 +1093,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 +1117,7 @@ async function serveImpl( Help.formatRoot(`${name} ${resolved.path}`, { configFlag, description: resolved.description, + globals: options.globals, commands: collectHelpCommands(resolved.commands), }), ) @@ -1009,6 +1143,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 +1153,7 @@ async function serveImpl( Help.formatRoot(`${name} ${resolved.path}`, { configFlag, description: resolved.description, + globals: options.globals, commands: collectHelpCommands(resolved.commands), }), ) @@ -1266,6 +1402,7 @@ async function serveImpl( error: errorFn, format, formatExplicit, + globals, name, set(key: string, value: unknown) { varsMap[key] = value @@ -1316,7 +1453,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 = [ @@ -1329,7 +1466,7 @@ async function serveImpl( if (human) emitDeprecationWarnings( - rest, + commandRest, command.options, command.alias as Record | undefined, ) @@ -1359,13 +1496,14 @@ async function serveImpl( const result = await Command.execute(command, { agent: !human, - argv: rest, + argv: commandRest, defaults, displayName, env: options.envSchema, envSource: options.env, format, formatExplicit, + globals, inputOptions: {}, middlewares: allMiddleware, name, @@ -1452,6 +1590,8 @@ declare namespace fetchImpl { type Options = { /** CLI-level env schema. */ envSchema?: z.ZodObject | undefined + /** Global options schema and alias map. */ + globals?: GlobalsDescriptor | undefined /** Group-level middleware collected during command resolution. */ groupMiddlewares?: MiddlewareHandler[] | undefined mcpHandler?: @@ -1938,6 +2078,8 @@ declare namespace serveImpl { envSchema?: z.ZodObject | undefined /** CLI-level default output format. */ format?: Formatter.Format | undefined + /** Global options schema and alias map. */ + globals?: GlobalsDescriptor | undefined /** Middleware handlers registered on the root CLI. */ middlewares?: MiddlewareHandler[] | undefined /** CLI-level default output policy. */ @@ -2307,6 +2449,12 @@ 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 | undefined } + +/** @internal Maps CLI instances to their globals schema and alias map. */ +const toGlobals = new WeakMap() + /** @internal Sentinel symbol for `ok()` and `error()` return values. */ const sentinel = Symbol.for('incur.sentinel') @@ -2597,10 +2745,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 +2779,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), } } 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/Completions.ts b/src/Completions.ts index 9d654b3..b3393eb 100644 --- a/src/Completions.ts +++ b/src/Completions.ts @@ -1,6 +1,8 @@ import type { z } from 'zod' +import type { GlobalsDescriptor } from './Cli.js' import type { Shell } from './internal/command.js' +import { toKebab } from './internal/helpers.js' /** A completion candidate with an optional description. */ export type Candidate = { @@ -39,6 +41,7 @@ export function register(shell: Shell, name: string): string { } } + /** * Computes completion candidates for the given argv words and cursor index. * Walks the command tree to resolve the active command, then suggests @@ -49,6 +52,7 @@ export function complete( rootCommand: CommandEntry | undefined, argv: string[], index: number, + globals?: GlobalsDescriptor | undefined, ): Candidate[] { const current = argv[index] ?? '' @@ -79,7 +83,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]) }) @@ -94,6 +98,25 @@ export function complete( } } } + // Global options + if (globals) { + const globalShape = globals.schema.shape as Record + for (const key of Object.keys(globalShape)) { + 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]) }) + } + // 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 +124,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 } } } 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/Help.ts b/src/Help.ts index 6c919b4..cde209a 100644 --- a/src/Help.ts +++ b/src/Help.ts @@ -1,11 +1,20 @@ import { z } from 'zod' +import type { GlobalsDescriptor } from './Cli.js' import { builtinCommands } from './internal/command.js' 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 +39,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 +54,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?: GlobalsDescriptor | undefined /** Show root-level built-in commands and flags. */ root?: boolean | undefined /** CLI version string. */ @@ -70,6 +81,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?: GlobalsDescriptor | undefined /** Formatted usage examples. */ examples?: { command: string; description?: string }[] | undefined /** Plain text hint displayed after examples and before global options. */ @@ -101,6 +114,7 @@ export function formatCommand(name: string, options: formatCommand.Options = {}) aliases, configFlag, description, + globals, version, args, env, @@ -205,7 +219,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 +348,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?: GlobalsDescriptor, +): string[] { const lines: string[] = [] if (root) { @@ -355,6 +373,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' }] diff --git a/src/Parser.test.ts b/src/Parser.test.ts index 5061ce8..39bfa9b 100644 --- a/src/Parser.test.ts +++ b/src/Parser.test.ts @@ -342,3 +342,176 @@ 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']) + }) + + 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' }), + ) + }) + + 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'] }) + }) +}) diff --git a/src/Parser.ts b/src/Parser.ts index 7bb6521..bd7f0c8 100644 --- a/src/Parser.ts +++ b/src/Parser.ts @@ -314,6 +314,127 @@ 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 === '--') { + 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) { + 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 as-is + rest.push(token) + 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 as-is + rest.push(token) + 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') { 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 }