From 24426a044c66fdae5f3560d91c9045ba0ce6c377 Mon Sep 17 00:00:00 2001 From: Frank Bagherzadeh Date: Wed, 1 Apr 2026 01:41:44 -0400 Subject: [PATCH 1/2] 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. --- .changeset/root-banner.md | 5 ++ src/Cli.test.ts | 107 ++++++++++++++++++++++++++++++++++++++ src/Cli.ts | 32 ++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 .changeset/root-banner.md diff --git a/.changeset/root-banner.md b/.changeset/root-banner.md new file mode 100644 index 0000000..1cc7540 --- /dev/null +++ b/.changeset/root-banner.md @@ -0,0 +1,5 @@ +--- +'incur': minor +--- + +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. diff --git a/src/Cli.test.ts b/src/Cli.test.ts index 729b717..ae5591d 100644 --- a/src/Cli.test.ts +++ b/src/Cli.test.ts @@ -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', { diff --git a/src/Cli.ts b/src/Cli.ts index f7a2f82..1b238ad 100644 --- a/src/Cli.ts +++ b/src/Cli.ts @@ -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, @@ -323,6 +324,19 @@ export declare namespace create { : Record | 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) + | { + render: () => string | undefined | Promise + /** @default 'all' */ + mode?: 'all' | 'human' | 'agent' | undefined + } + | undefined /** Zod schema for positional arguments. */ args?: args | undefined /** Enable config-file defaults for command options. */ @@ -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, @@ -1948,6 +1972,14 @@ declare namespace serveImpl { command?: string | undefined } | undefined + /** Banner config, called before root help. */ + banner?: + | (() => string | undefined | Promise) + | { + render: () => string | undefined | Promise + mode?: 'all' | 'human' | 'agent' | undefined + } + | undefined /** Root command handler, invoked when no subcommand matches. */ rootCommand?: CommandDefinition | undefined /** Root fetch handler, invoked when no subcommand matches and no rootCommand is set. */ From a1afd3a2cedef0dfe7dafe2c61106bae7559f714 Mon Sep 17 00:00:00 2001 From: Frank Bagherzadeh Date: Wed, 1 Apr 2026 01:54:58 -0400 Subject: [PATCH 2/2] chore: change banner changeset to patch --- .changeset/root-banner.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/root-banner.md b/.changeset/root-banner.md index 1cc7540..c4c3e65 100644 --- a/.changeset/root-banner.md +++ b/.changeset/root-banner.md @@ -1,5 +1,5 @@ --- -'incur': minor +'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.