Skip to content

Commit 0720a24

Browse files
frankladjxom
andauthored
feat: custom global options (#98)
* 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). * 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. * 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. * 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. * 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. * fix: rename rest to commandRest to avoid redeclaration in serveImpl * 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. * 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 * 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 * 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. * chore: add changeset for custom global options * fix: CI type errors — exactOptionalPropertyTypes and ts-expect-error - Add undefined to GlobalsDescriptor.alias for exactOptionalPropertyTypes - Restructure globalAlias type test to avoid tsc -b @ts-expect-error quirk * 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) * 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) * fix: handle global option edge cases * test: cover global options e2e * chore: update changeset wording * test: stabilize lazy import tracking --------- Co-authored-by: jxom <7336481+jxom@users.noreply.github.com>
1 parent 713bb95 commit 0720a24

13 files changed

Lines changed: 1205 additions & 45 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"incur": minor
3+
---
4+
5+
Added custom global options support via `globals` and `globalAlias` on `Cli.create()`.

src/Cli.test-d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,3 +422,24 @@ test('create() accepts config-file defaults options', () => {
422422
config: { files: [42] },
423423
})
424424
})
425+
426+
test('globals type flows to middleware context', () => {
427+
Cli.create('test', {
428+
globals: z.object({ apiKey: z.string().optional() }),
429+
}).use(async (c, next) => {
430+
expectTypeOf(c.globals.apiKey).toEqualTypeOf<string | undefined>()
431+
await next()
432+
})
433+
})
434+
435+
test('globalAlias keys are constrained to globals schema keys', () => {
436+
Cli.create('test', {
437+
globals: z.object({ apiKey: z.string() }),
438+
globalAlias: { apiKey: 'k' },
439+
})
440+
441+
const globals = z.object({ apiKey: z.string() })
442+
// @ts-expect-error — 'foo' is not a key of the globals schema
443+
const badAlias: Partial<Record<keyof z.output<typeof globals>, string>> = { foo: 'f' }
444+
void badAlias
445+
})

src/Cli.test.ts

Lines changed: 291 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4310,7 +4310,7 @@ describe('Command.execute', () => {
43104310
})
43114311
})
43124312

4313-
async function fetchJson(cli: Cli.Cli<any, any, any>, req: Request) {
4313+
async function fetchJson(cli: Cli.Cli<any, any, any, any>, req: Request) {
43144314
const res = await cli.fetch(req)
43154315
const body = await res.json()
43164316
body.meta.duration = '<stripped>'
@@ -5366,6 +5366,296 @@ describe('displayName', () => {
53665366
})
53675367
})
53685368

5369+
describe('globals', () => {
5370+
test('globals are parsed and available in middleware', async () => {
5371+
const cli = Cli.create('test', {
5372+
globals: z.object({ rpcUrl: z.string() }),
5373+
vars: z.object({ rpcUrl: z.string().default('') }),
5374+
})
5375+
.use(async (c, next) => {
5376+
c.set('rpcUrl', c.globals.rpcUrl)
5377+
await next()
5378+
})
5379+
.command('ping', {
5380+
run(c) {
5381+
return { url: c.var.rpcUrl }
5382+
},
5383+
})
5384+
5385+
const { output } = await serve(cli, ['--rpc-url', 'http://example.com', 'ping', '--json'])
5386+
expect(JSON.parse(output)).toEqual({ url: 'http://example.com' })
5387+
})
5388+
5389+
test('globals aliases work', async () => {
5390+
const cli = Cli.create('test', {
5391+
globals: z.object({ rpcUrl: z.string() }),
5392+
globalAlias: { rpcUrl: 'r' },
5393+
vars: z.object({ rpcUrl: z.string().default('') }),
5394+
})
5395+
.use(async (c, next) => {
5396+
c.set('rpcUrl', c.globals.rpcUrl)
5397+
await next()
5398+
})
5399+
.command('ping', {
5400+
run(c) {
5401+
return { url: c.var.rpcUrl }
5402+
},
5403+
})
5404+
5405+
const { output } = await serve(cli, ['-r', 'http://example.com', 'ping', '--json'])
5406+
expect(JSON.parse(output)).toEqual({ url: 'http://example.com' })
5407+
})
5408+
5409+
test('globals with defaults work when not provided', async () => {
5410+
const cli = Cli.create('test', {
5411+
globals: z.object({ chain: z.string().default('mainnet') }),
5412+
vars: z.object({ chain: z.string().default('') }),
5413+
})
5414+
.use(async (c, next) => {
5415+
c.set('chain', c.globals.chain)
5416+
await next()
5417+
})
5418+
.command('ping', {
5419+
run(c) {
5420+
return { chain: c.var.chain }
5421+
},
5422+
})
5423+
5424+
const { output } = await serve(cli, ['ping', '--json'])
5425+
expect(JSON.parse(output)).toEqual({ chain: 'mainnet' })
5426+
})
5427+
5428+
test('globals appear in --help output', async () => {
5429+
const cli = Cli.create('test', {
5430+
globals: z.object({
5431+
rpcUrl: z.string().optional().describe('RPC endpoint URL'),
5432+
}),
5433+
globalAlias: { rpcUrl: 'r' },
5434+
}).command('ping', { run: () => ({}) })
5435+
5436+
const { output } = await serve(cli, ['--help'])
5437+
expect(output).toContain('Custom Global Options')
5438+
expect(output).toContain('--rpc-url')
5439+
})
5440+
5441+
test('informational commands do not require globals', async () => {
5442+
const cli = Cli.create('test', {
5443+
version: '1.0.0',
5444+
globals: z.object({
5445+
rpcUrl: z.string().describe('RPC endpoint URL'),
5446+
}),
5447+
}).command('ping', {
5448+
args: z.object({ target: z.string() }),
5449+
run: () => ({}),
5450+
})
5451+
5452+
const help = await serve(cli, ['--help'])
5453+
expect(help.exitCode).toBeUndefined()
5454+
expect(help.output).toContain('--rpc-url')
5455+
5456+
const schema = await serve(cli, ['ping', '--schema', '--format', 'json'])
5457+
expect(schema.exitCode).toBeUndefined()
5458+
expect(JSON.parse(schema.output).globals.properties.rpcUrl).toBeDefined()
5459+
5460+
const llms = await serve(cli, ['--llms', '--format', 'json'])
5461+
expect(llms.exitCode).toBeUndefined()
5462+
expect(JSON.parse(llms.output).globals.properties.rpcUrl).toBeDefined()
5463+
5464+
const version = await serve(cli, ['--version'])
5465+
expect(version.exitCode).toBeUndefined()
5466+
expect(version.output).toBe('1.0.0\n')
5467+
})
5468+
5469+
test('globals appear in --llms manifest', async () => {
5470+
const cli = Cli.create('test', {
5471+
globals: z.object({
5472+
rpcUrl: z.string().optional().describe('RPC endpoint URL'),
5473+
}),
5474+
}).command('ping', { description: 'Health check', run: () => ({}) })
5475+
5476+
const { output } = await serve(cli, ['--llms', '--format', 'json'])
5477+
const manifest = JSON.parse(output)
5478+
expect(manifest.globals).toBeDefined()
5479+
expect(manifest.globals.properties.rpcUrl).toBeDefined()
5480+
})
5481+
5482+
test('globals validation error shows message and exits 1', async () => {
5483+
const cli = Cli.create('test', {
5484+
globals: z.object({ limit: z.number() }),
5485+
}).command('ping', { run: () => ({}) })
5486+
5487+
const { output, exitCode } = await serve(cli, ['--limit', 'not-a-number', 'ping'])
5488+
expect(exitCode).toBe(1)
5489+
expect(output).toContain('Invalid input')
5490+
})
5491+
5492+
test('globals position is flexible', async () => {
5493+
const cli = Cli.create('test', {
5494+
globals: z.object({ rpcUrl: z.string() }),
5495+
vars: z.object({ rpcUrl: z.string().default('') }),
5496+
})
5497+
.use(async (c, next) => {
5498+
c.set('rpcUrl', c.globals.rpcUrl)
5499+
await next()
5500+
})
5501+
.command('deploy', {
5502+
run(c) {
5503+
return { url: c.var.rpcUrl }
5504+
},
5505+
})
5506+
5507+
const { output } = await serve(cli, ['deploy', '--rpc-url', 'http://x', '--json'])
5508+
expect(JSON.parse(output)).toEqual({ url: 'http://x' })
5509+
})
5510+
5511+
test('globals conflict with builtins errors at create() time', () => {
5512+
expect(() =>
5513+
Cli.create('test', {
5514+
globals: z.object({ format: z.string() }),
5515+
}),
5516+
).toThrow(/conflicts with a built-in flag/)
5517+
})
5518+
5519+
test('command option conflicting with global errors at command() time', () => {
5520+
const cli = Cli.create('test', {
5521+
globals: z.object({ rpcUrl: z.string() }),
5522+
})
5523+
expect(() =>
5524+
cli.command('deploy', {
5525+
options: z.object({ rpcUrl: z.string() }),
5526+
run: () => ({}),
5527+
}),
5528+
).toThrow(/conflicts with a global option/)
5529+
})
5530+
5531+
test('mounted root command option conflicting with global errors at command() time', () => {
5532+
const cli = Cli.create('test', {
5533+
globals: z.object({ rpcUrl: z.string() }),
5534+
})
5535+
const deploy = Cli.create('deploy', {
5536+
options: z.object({ rpcUrl: z.string() }),
5537+
run: () => ({}),
5538+
})
5539+
5540+
expect(() => cli.command(deploy)).toThrow(/conflicts with a global option/)
5541+
})
5542+
5543+
test('mounted subcommand option conflicting with global errors at command() time', () => {
5544+
const cli = Cli.create('test', {
5545+
globals: z.object({ rpcUrl: z.string() }),
5546+
})
5547+
const admin = Cli.create('admin').command('deploy', {
5548+
options: z.object({ rpcUrl: z.string() }),
5549+
run: () => ({}),
5550+
})
5551+
5552+
expect(() => cli.command(admin)).toThrow(/conflicts with a global option/)
5553+
})
5554+
5555+
test('boolean globals handle --no- negation', async () => {
5556+
const cli = Cli.create('test', {
5557+
globals: z.object({ dryRun: z.boolean().default(true) }),
5558+
vars: z.object({ dryRun: z.boolean().default(false) }),
5559+
})
5560+
.use(async (c, next) => {
5561+
c.set('dryRun', c.globals.dryRun)
5562+
await next()
5563+
})
5564+
.command('ping', {
5565+
run(c) {
5566+
return { dryRun: c.var.dryRun }
5567+
},
5568+
})
5569+
5570+
const { output } = await serve(cli, ['--no-dry-run', 'ping', '--json'])
5571+
expect(JSON.parse(output)).toEqual({ dryRun: false })
5572+
})
5573+
5574+
test('parseGlobals error produces clean error output with exit code 1', async () => {
5575+
const cli = Cli.create('test', {
5576+
globals: z.object({ rpcUrl: z.string() }),
5577+
}).command('ping', { run: () => ({}) })
5578+
5579+
const { output, exitCode } = await serve(cli, ['--rpc-url'])
5580+
expect(exitCode).toBe(1)
5581+
expect(output).toContain('Missing value for flag')
5582+
})
5583+
5584+
test('global alias collision with -h throws at create() time', () => {
5585+
expect(() =>
5586+
Cli.create('test', {
5587+
globals: z.object({ host: z.string().optional() }),
5588+
globalAlias: { host: 'h' },
5589+
}),
5590+
).toThrow(/conflicts with a built-in short flag/)
5591+
})
5592+
5593+
test('command alias collision with global alias throws at command() time', () => {
5594+
const cli = Cli.create('test', {
5595+
globals: z.object({ rpcUrl: z.string().optional() }),
5596+
globalAlias: { rpcUrl: 'r' },
5597+
})
5598+
expect(() =>
5599+
cli.command('deploy', {
5600+
options: z.object({ region: z.string().optional() }),
5601+
alias: { region: 'r' },
5602+
run: () => ({}),
5603+
}),
5604+
).toThrow(/conflicts with a global alias/)
5605+
})
5606+
5607+
test('globals validation error in agent mode outputs toon format', async () => {
5608+
;(process.stdout as any).isTTY = false
5609+
const cli = Cli.create('test', {
5610+
globals: z.object({ limit: z.number() }),
5611+
}).command('ping', { run: () => ({}) })
5612+
5613+
const { output, exitCode } = await serve(cli, ['--limit', 'abc', 'ping'])
5614+
expect(exitCode).toBe(1)
5615+
expect(output).toContain('UNKNOWN')
5616+
;(process.stdout as any).isTTY = true
5617+
})
5618+
5619+
test('globals appear in --schema output', async () => {
5620+
const cli = Cli.create('test', {
5621+
globals: z.object({
5622+
rpcUrl: z.string().optional().describe('RPC endpoint URL'),
5623+
}),
5624+
}).command('ping', {
5625+
args: z.object({ target: z.string() }),
5626+
run: () => ({}),
5627+
})
5628+
5629+
const { output } = await serve(cli, ['ping', '--schema', '--format', 'json'])
5630+
const parsed = JSON.parse(output)
5631+
expect(parsed.globals).toBeDefined()
5632+
expect(parsed.globals.properties.rpcUrl).toBeDefined()
5633+
})
5634+
5635+
test('globals are available in fetch middleware', async () => {
5636+
const cli = Cli.create('test', {
5637+
globals: z.object({ rpcUrl: z.string().default('fallback') }),
5638+
vars: z.object({ rpcUrl: z.string().default('') }),
5639+
})
5640+
.use(async (c, next) => {
5641+
c.set('rpcUrl', c.globals.rpcUrl)
5642+
await next()
5643+
})
5644+
.command('ping', {
5645+
options: z.object({ limit: z.coerce.number().default(0) }),
5646+
run(c) {
5647+
return { limit: c.options.limit, url: c.var.rpcUrl }
5648+
},
5649+
})
5650+
5651+
const { body } = await fetchJson(
5652+
cli,
5653+
new Request('http://localhost/ping?rpcUrl=http://example.com&limit=3'),
5654+
)
5655+
expect(body.data).toEqual({ limit: 3, url: 'http://example.com' })
5656+
})
5657+
})
5658+
53695659
test('--format rejects invalid format values', async () => {
53705660
const cli = Cli.create('test').command('hello', {
53715661
run: (c) => c.ok({ message: 'hi' }),

0 commit comments

Comments
 (0)