Skip to content

Commit 24426a0

Browse files
committed
feat: add banner option for root command output
Allows `Cli.create` to accept a `banner` option that renders custom text (branding, live status, warnings) above the auto-generated root help. Supports sync/async functions with silent error swallowing and a `mode` option ('all' | 'human' | 'agent') to target TTY or non-TTY consumers.
1 parent 14e4d64 commit 24426a0

File tree

3 files changed

+144
-0
lines changed

3 files changed

+144
-0
lines changed

.changeset/root-banner.md

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 `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.

src/Cli.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2054,6 +2054,113 @@ describe('help', () => {
20542054
`)
20552055
})
20562056

2057+
test('banner is printed before root help', async () => {
2058+
const cli = Cli.create({
2059+
name: 'mycli',
2060+
banner: () => ' status: all good',
2061+
})
2062+
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2063+
2064+
const { output } = await serve(cli, [])
2065+
expect(output).toContain('status: all good')
2066+
expect(output.indexOf('status: all good')).toBeLessThan(output.indexOf('mycli'))
2067+
})
2068+
2069+
test('async banner is supported', async () => {
2070+
const cli = Cli.create({
2071+
name: 'mycli',
2072+
banner: async () => ' async banner',
2073+
})
2074+
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2075+
2076+
const { output } = await serve(cli, [])
2077+
expect(output).toContain('async banner')
2078+
})
2079+
2080+
test('banner returning undefined shows only help', async () => {
2081+
const cli = Cli.create({
2082+
name: 'mycli',
2083+
banner: () => undefined,
2084+
})
2085+
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2086+
2087+
const { output } = await serve(cli, [])
2088+
expect(output).toMatch(/^mycli/)
2089+
})
2090+
2091+
test('banner errors are swallowed', async () => {
2092+
const cli = Cli.create({
2093+
name: 'mycli',
2094+
banner: () => {
2095+
throw new Error('boom')
2096+
},
2097+
})
2098+
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2099+
2100+
const { output } = await serve(cli, [])
2101+
expect(output).toMatch(/^mycli/)
2102+
expect(output).not.toContain('boom')
2103+
})
2104+
2105+
test('banner is skipped for subcommands', async () => {
2106+
const cli = Cli.create({
2107+
name: 'mycli',
2108+
banner: () => 'BANNER',
2109+
})
2110+
cli.command('ping', {
2111+
description: 'Health check',
2112+
run: () => ({ pong: true }),
2113+
output: z.object({ pong: z.boolean() }),
2114+
})
2115+
2116+
const { output } = await serve(cli, ['ping'])
2117+
expect(output).not.toContain('BANNER')
2118+
})
2119+
2120+
test('banner with mode "agent" shows in non-TTY', async () => {
2121+
const cli = Cli.create({
2122+
name: 'mycli',
2123+
banner: { render: () => 'AGENT BANNER', mode: 'agent' },
2124+
})
2125+
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2126+
2127+
const { output } = await serve(cli, [])
2128+
expect(output).toContain('AGENT BANNER')
2129+
})
2130+
2131+
test('banner with mode "human" is skipped in non-TTY', async () => {
2132+
const cli = Cli.create({
2133+
name: 'mycli',
2134+
banner: { render: () => 'HUMAN BANNER', mode: 'human' },
2135+
})
2136+
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2137+
2138+
const { output } = await serve(cli, [])
2139+
expect(output).not.toContain('HUMAN BANNER')
2140+
})
2141+
2142+
test('banner object with default mode shows for all', async () => {
2143+
const cli = Cli.create({
2144+
name: 'mycli',
2145+
banner: { render: () => 'ALL BANNER' },
2146+
})
2147+
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2148+
2149+
const { output } = await serve(cli, [])
2150+
expect(output).toContain('ALL BANNER')
2151+
})
2152+
2153+
test('banner is skipped for --help flag', async () => {
2154+
const cli = Cli.create({
2155+
name: 'mycli',
2156+
banner: () => 'BANNER',
2157+
})
2158+
cli.command('ping', { description: 'Health check', run: () => ({ pong: true }) })
2159+
2160+
const { output } = await serve(cli, ['--help'])
2161+
expect(output).not.toContain('BANNER')
2162+
})
2163+
20572164
test('--help on leaf shows command help', async () => {
20582165
const cli = Cli.create('tool')
20592166
cli.command('greet', {

src/Cli.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -278,6 +278,7 @@ export function create(
278278
return serveImpl(name, commands, argv, {
279279
...serveOptions,
280280
aliases: def.aliases,
281+
banner: def.banner,
281282
config: def.config,
282283
description: def.description,
283284
envSchema: def.env,
@@ -323,6 +324,19 @@ export declare namespace create {
323324
: Record<string, string> | undefined
324325
/** Alternative binary names for this CLI (e.g. shorter aliases in package.json `bin`). Shell completions are registered for all names. */
325326
aliases?: string[] | undefined
327+
/**
328+
* 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.
329+
*
330+
* Pass a function for all consumers, or an object with `mode` to target `'human'`, `'agent'`, or `'all'` (default).
331+
*/
332+
banner?:
333+
| (() => string | undefined | Promise<string | undefined>)
334+
| {
335+
render: () => string | undefined | Promise<string | undefined>
336+
/** @default 'all' */
337+
mode?: 'all' | 'human' | 'agent' | undefined
338+
}
339+
| undefined
326340
/** Zod schema for positional arguments. */
327341
args?: args | undefined
328342
/** Enable config-file defaults for command options. */
@@ -869,6 +883,16 @@ async function serveImpl(
869883
if (options.rootCommand || options.rootFetch) {
870884
// Root command/fetch with no args — treat as root invocation
871885
} else {
886+
if (options.banner && !help) {
887+
const banner = typeof options.banner === 'function' ? { render: options.banner, mode: 'all' as const } : options.banner
888+
const mode = banner.mode ?? 'all'
889+
if (mode === 'all' || (mode === 'human' && human) || (mode === 'agent' && !human)) {
890+
try {
891+
const text = await banner.render()
892+
if (text) writeln(text)
893+
} catch {}
894+
}
895+
}
872896
writeln(
873897
Help.formatRoot(name, {
874898
aliases: options.aliases,
@@ -1948,6 +1972,14 @@ declare namespace serveImpl {
19481972
command?: string | undefined
19491973
}
19501974
| undefined
1975+
/** Banner config, called before root help. */
1976+
banner?:
1977+
| (() => string | undefined | Promise<string | undefined>)
1978+
| {
1979+
render: () => string | undefined | Promise<string | undefined>
1980+
mode?: 'all' | 'human' | 'agent' | undefined
1981+
}
1982+
| undefined
19511983
/** Root command handler, invoked when no subcommand matches. */
19521984
rootCommand?: CommandDefinition<any, any, any> | undefined
19531985
/** Root fetch handler, invoked when no subcommand matches and no rootCommand is set. */

0 commit comments

Comments
 (0)