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/root-banner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'incur': patch
---

Added `banner` option to `Cli.create` for displaying custom content above root help output. Supports sync/async functions, error swallowing, and a `mode` option (`'all'` | `'human'` | `'agent'`) to target specific consumers.
107 changes: 107 additions & 0 deletions src/Cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2054,6 +2054,113 @@ describe('help', () => {
`)
})

test('banner is printed before root help', async () => {
const cli = Cli.create({
name: 'mycli',
banner: () => ' status: all good',
})
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })

const { output } = await serve(cli, [])
expect(output).toContain('status: all good')
expect(output.indexOf('status: all good')).toBeLessThan(output.indexOf('mycli'))
})

test('async banner is supported', async () => {
const cli = Cli.create({
name: 'mycli',
banner: async () => ' async banner',
})
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })

const { output } = await serve(cli, [])
expect(output).toContain('async banner')
})

test('banner returning undefined shows only help', async () => {
const cli = Cli.create({
name: 'mycli',
banner: () => undefined,
})
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })

const { output } = await serve(cli, [])
expect(output).toMatch(/^mycli/)
})

test('banner errors are swallowed', async () => {
const cli = Cli.create({
name: 'mycli',
banner: () => {
throw new Error('boom')
},
})
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })

const { output } = await serve(cli, [])
expect(output).toMatch(/^mycli/)
expect(output).not.toContain('boom')
})

test('banner is skipped for subcommands', async () => {
const cli = Cli.create({
name: 'mycli',
banner: () => 'BANNER',
})
cli.command('ping', {
description: 'Health check',
run: () => ({ pong: true }),
output: z.object({ pong: z.boolean() }),
})

const { output } = await serve(cli, ['ping'])
expect(output).not.toContain('BANNER')
})

test('banner with mode "agent" shows in non-TTY', async () => {
const cli = Cli.create({
name: 'mycli',
banner: { render: () => 'AGENT BANNER', mode: 'agent' },
})
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })

const { output } = await serve(cli, [])
expect(output).toContain('AGENT BANNER')
})

test('banner with mode "human" is skipped in non-TTY', async () => {
const cli = Cli.create({
name: 'mycli',
banner: { render: () => 'HUMAN BANNER', mode: 'human' },
})
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })

const { output } = await serve(cli, [])
expect(output).not.toContain('HUMAN BANNER')
})

test('banner object with default mode shows for all', async () => {
const cli = Cli.create({
name: 'mycli',
banner: { render: () => 'ALL BANNER' },
})
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })

const { output } = await serve(cli, [])
expect(output).toContain('ALL BANNER')
})

test('banner is skipped for --help flag', async () => {
const cli = Cli.create({
name: 'mycli',
banner: () => 'BANNER',
})
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })

const { output } = await serve(cli, ['--help'])
expect(output).not.toContain('BANNER')
})

test('--help on leaf shows command help', async () => {
const cli = Cli.create('tool')
cli.command('greet', {
Expand Down
32 changes: 32 additions & 0 deletions src/Cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ export function create(
return serveImpl(name, commands, argv, {
...serveOptions,
aliases: def.aliases,
banner: def.banner,
config: def.config,
description: def.description,
envSchema: def.env,
Expand Down Expand Up @@ -323,6 +324,19 @@ export declare namespace create {
: Record<string, string> | undefined
/** Alternative binary names for this CLI (e.g. shorter aliases in package.json `bin`). Shell completions are registered for all names. */
aliases?: string[] | undefined
/**
* Text to display above root help output (e.g. branding, live status). Only called when the CLI is invoked with no subcommand. Errors are silently swallowed.
*
* Pass a function for all consumers, or an object with `mode` to target `'human'`, `'agent'`, or `'all'` (default).
*/
banner?:
| (() => string | undefined | Promise<string | undefined>)
| {
render: () => string | undefined | Promise<string | undefined>
/** @default 'all' */
mode?: 'all' | 'human' | 'agent' | undefined
}
| undefined
/** Zod schema for positional arguments. */
args?: args | undefined
/** Enable config-file defaults for command options. */
Expand Down Expand Up @@ -869,6 +883,16 @@ async function serveImpl(
if (options.rootCommand || options.rootFetch) {
// Root command/fetch with no args — treat as root invocation
} else {
if (options.banner && !help) {
const banner = typeof options.banner === 'function' ? { render: options.banner, mode: 'all' as const } : options.banner
const mode = banner.mode ?? 'all'
if (mode === 'all' || (mode === 'human' && human) || (mode === 'agent' && !human)) {
try {
const text = await banner.render()
if (text) writeln(text)
} catch {}
}
}
writeln(
Help.formatRoot(name, {
aliases: options.aliases,
Expand Down Expand Up @@ -1948,6 +1972,14 @@ declare namespace serveImpl {
command?: string | undefined
}
| undefined
/** Banner config, called before root help. */
banner?:
| (() => string | undefined | Promise<string | undefined>)
| {
render: () => string | undefined | Promise<string | undefined>
mode?: 'all' | 'human' | 'agent' | undefined
}
| undefined
/** Root command handler, invoked when no subcommand matches. */
rootCommand?: CommandDefinition<any, any, any> | undefined
/** Root fetch handler, invoked when no subcommand matches and no rootCommand is set. */
Expand Down
Loading