diff --git a/.changeset/root-banner.md b/.changeset/root-banner.md new file mode 100644 index 0000000..c4c3e65 --- /dev/null +++ b/.changeset/root-banner.md @@ -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. 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. */