Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/custom-global-options.md
Original file line number Diff line number Diff line change
@@ -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.
21 changes: 21 additions & 0 deletions src/Cli.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | undefined>()
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<Record<keyof z.output<typeof globals>, string>> = { foo: 'f' }
void badAlias
})
220 changes: 220 additions & 0 deletions src/Cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
})
Loading
Loading