diff --git a/examples/nested-commands.ts b/examples/nested-commands.ts index 77b93bb..3173f42 100644 --- a/examples/nested-commands.ts +++ b/examples/nested-commands.ts @@ -4,18 +4,25 @@ * * A git-like CLI that demonstrates: * - * - Nested command groups (e.g., `git remote add`) + * - Nested command groups with aliases (e.g., `git r add` for `git remote add`) * - Factory pattern for full type inference of parent globals * - Unlimited nesting depth * - Parent globals flowing to nested handlers * - Default subcommands + * - Command aliases at both parent and nested levels * - * Usage: npx tsx examples/nested-commands.ts remote add origin - * https://github.com/... npx tsx examples/nested-commands.ts remote remove - * origin npx tsx examples/nested-commands.ts config get user.name npx tsx - * examples/nested-commands.ts --verbose remote add origin https://... npx tsx - * examples/nested-commands.ts --help npx tsx examples/nested-commands.ts remote - * --help + * @example + * + * ```sh + * npx tsx examples/nested-commands.ts remote add origin https://github.com/... + * npx tsx examples/nested-commands.ts r add origin https://... # 'r' alias + * npx tsx examples/nested-commands.ts remote rm origin # 'rm' alias + * npx tsx examples/nested-commands.ts config get user.name + * npx tsx examples/nested-commands.ts cfg get user.name # 'cfg' alias + * npx tsx examples/nested-commands.ts --verbose remote add origin https://... + * npx tsx examples/nested-commands.ts --help + * npx tsx examples/nested-commands.ts remote --help + * ``` */ import { bargs, opt, pos } from '../src/index.js'; @@ -53,6 +60,7 @@ await bargs('git-like', { // ───────────────────────────────────────────────────────────────────────────── // FACTORY PATTERN: Full type inference for parent globals! // The factory receives a builder that already has parent globals typed. + // Nested command groups can have aliases too (e.g., 'r' for 'remote') // ───────────────────────────────────────────────────────────────────────────── .command( 'remote', @@ -95,7 +103,8 @@ await bargs('git-like', { console.log(`Removed remote '${name}'`); } }, - 'Remove a remote', + // Subcommands can have aliases too + { aliases: ['rm', 'del'], description: 'Remove a remote' }, ) .command( 'list', @@ -114,10 +123,11 @@ await bargs('git-like', { } } }, - 'List remotes', + { aliases: ['ls'], description: 'List remotes' }, ) .defaultCommand('list'), - 'Manage remotes', + // Parent command group has aliases + { aliases: ['r'], description: 'Manage remotes' }, ) // ───────────────────────────────────────────────────────────────────────────── @@ -139,7 +149,7 @@ await bargs('git-like', { } console.log(value); }, - 'Get a config value', + { aliases: ['g'], description: 'Get a config value' }, ) .command( 'set', @@ -155,9 +165,9 @@ await bargs('git-like', { console.log(`Set ${key} = ${value}`); } }, - 'Set a config value', + { aliases: ['s'], description: 'Set a config value' }, ), - 'Manage configuration', + { aliases: ['cfg', 'c'], description: 'Manage configuration' }, ) // ───────────────────────────────────────────────────────────────────────────── @@ -174,7 +184,7 @@ await bargs('git-like', { console.log(`Config entries: ${config.size}`); } }, - 'Show status', + { aliases: ['st', 's'], description: 'Show status' }, ) .defaultCommand('status') .parseAsync(); diff --git a/examples/tasks.ts b/examples/tasks.ts index 76fb595..ac68e28 100644 --- a/examples/tasks.ts +++ b/examples/tasks.ts @@ -4,15 +4,17 @@ * * A task manager that demonstrates: * - * - Multiple commands (add, list, done) + * - Multiple commands with aliases (add/a/new, list/ls, done/complete/x) * - Global options (--verbose, --file) * - Command-specific options (--priority for add) * - Command positionals (task text) * - Full type inference with the (Parser, handler) API * * Usage: npx tsx examples/tasks.ts add "Buy groceries" --priority high npx tsx - * examples/tasks.ts list npx tsx examples/tasks.ts done 1 npx tsx - * examples/tasks.ts --help npx tsx examples/tasks.ts add --help + * examples/tasks.ts a "Buy groceries" # same as 'add' npx tsx examples/tasks.ts + * list npx tsx examples/tasks.ts ls # same as 'list' npx tsx examples/tasks.ts + * done 1 npx tsx examples/tasks.ts x 1 # same as 'done' npx tsx + * examples/tasks.ts --help */ import { bargs, opt, pos } from '../src/index.js'; @@ -77,6 +79,7 @@ await bargs('tasks', { }) .globals(globalOptions) // The handler receives merged global + command types + // Use { description, aliases } for command aliases .command( 'add', addParser, @@ -98,7 +101,7 @@ await bargs('tasks', { console.log(`Added task #${task.id}: ${text}`); } }, - 'Add a new task', + { aliases: ['a', 'new'], description: 'Add a new task' }, ) .command( 'list', @@ -131,7 +134,7 @@ await bargs('tasks', { } } }, - 'List all tasks', + { aliases: ['ls'], description: 'List all tasks' }, ) .command( 'done', @@ -156,7 +159,7 @@ await bargs('tasks', { console.log(`Completed task #${id}: ${task.text}`); } }, - 'Mark a task as complete', + { aliases: ['complete', 'x'], description: 'Mark a task as complete' }, ) .defaultCommand('list') .parseAsync(); diff --git a/src/bargs.ts b/src/bargs.ts index c473e4b..b505999 100644 --- a/src/bargs.ts +++ b/src/bargs.ts @@ -11,6 +11,7 @@ import type { CamelCaseKeys, CliBuilder, Command, + CommandOptions, CreateOptions, HandlerFn, Parser, @@ -42,11 +43,13 @@ export type TransformFn< // A command entry can be either a leaf command or a nested builder type CommandEntry = | { + aliases?: string[]; builder: CliBuilder; description?: string; type: 'nested'; } | { + aliases?: string[]; cmd: Command; description?: string; type: 'command'; @@ -59,6 +62,8 @@ type CommandWithTransform = Command & { }; interface InternalCliState { + /** Alias-to-canonical-name lookup map */ + aliasMap: Map; commands: Map; defaultCommandName?: string; globalParser?: Parser; @@ -67,6 +72,54 @@ interface InternalCliState { parentGlobals?: ParseResult; theme: Theme; } + +/** + * Parse a command options parameter (string or CommandOptions object). + * + * @function + */ +const parseCommandOptions = ( + options: CommandOptions | string | undefined, +): { aliases?: string[]; description?: string } => { + if (options === undefined) { + return {}; + } + if (typeof options === 'string') { + return { description: options }; + } + return { aliases: options.aliases, description: options.description }; +}; + +/** + * Register command aliases in the alias map. + * + * @function + */ +const registerAliases = ( + aliasMap: Map, + commands: Map, + canonicalName: string, + aliases?: string[], +): void => { + if (!aliases) { + return; + } + for (const alias of aliases) { + // Check if alias conflicts with an existing alias + if (aliasMap.has(alias)) { + throw new BargsError( + `Command alias "${alias}" is already registered for command "${aliasMap.get(alias)}"`, + ); + } + // Check if alias conflicts with an existing command name + if (commands.has(alias)) { + throw new BargsError( + `Command alias "${alias}" conflicts with existing command name "${alias}"`, + ); + } + aliasMap.set(alias, canonicalName); + } +}; // Type for parsers that may have transforms type ParserWithTransform = Parser & { __transform?: ( @@ -419,6 +472,7 @@ export const bargs = ( const theme = options.theme ? getTheme(options.theme) : defaultTheme; return createCliBuilder, readonly []>({ + aliasMap: new Map(), commands: new Map(), name, options, @@ -473,8 +527,8 @@ const createCliBuilder = ( | Promise & { command?: string }>; }, - // Overloaded command(): accepts (name, factory, desc?), (name, CliBuilder, desc?), - // (name, Command, desc?), or (name, Parser, handler, desc?) + // Overloaded command(): accepts (name, factory, options?), (name, CliBuilder, options?), + // (name, Command, options?), or (name, Parser, handler, options?) command( name: string, cmdOrParserOrBuilderOrFactory: @@ -482,10 +536,10 @@ const createCliBuilder = ( | CliBuilder | Command | Parser, - handlerOrDesc?: HandlerFn | string, - maybeDesc?: string, + handlerOrDescOrOpts?: CommandOptions | HandlerFn | string, + maybeDescOrOpts?: CommandOptions | string, ): CliBuilder { - // Form 4: command(name, factory, description?) - factory for nested commands with parent globals + // Form 4: command(name, factory, options?) - factory for nested commands with parent globals // Check this FIRST before isCliBuilder/isParser since those check for __brand which a plain function won't have if ( typeof cmdOrParserOrBuilderOrFactory === 'function' && @@ -496,12 +550,15 @@ const createCliBuilder = ( const factory = cmdOrParserOrBuilderOrFactory as ( b: CliBuilder, ) => CliBuilder; - const description = handlerOrDesc as string | undefined; + const { aliases, description } = parseCommandOptions( + handlerOrDescOrOpts as CommandOptions | string | undefined, + ); // Create a child builder with parent global TYPES (for type inference) // but NOT the globalParser (parent globals are passed via parentGlobals at runtime, // not re-parsed from args) const childBuilder = createCliBuilder({ + aliasMap: new Map(), commands: new Map(), globalParser: undefined, // Parent globals come via parentGlobals, not re-parsing name, @@ -513,37 +570,50 @@ const createCliBuilder = ( const nestedBuilder = factory(childBuilder); state.commands.set(name, { + aliases, builder: nestedBuilder as CliBuilder, description, type: 'nested', }); + registerAliases(state.aliasMap, state.commands, name, aliases); return this; } - // Form 3: command(name, CliBuilder, description?) - nested commands + // Form 3: command(name, CliBuilder, options?) - nested commands if (isCliBuilder(cmdOrParserOrBuilderOrFactory)) { const builder = cmdOrParserOrBuilderOrFactory; - const description = handlerOrDesc as string | undefined; + const { aliases, description } = parseCommandOptions( + handlerOrDescOrOpts as CommandOptions | string | undefined, + ); state.commands.set(name, { + aliases, builder: builder as CliBuilder, description, type: 'nested', }); + registerAliases(state.aliasMap, state.commands, name, aliases); return this; } let cmd: Command; + let aliases: string[] | undefined; let description: string | undefined; if (isCommand(cmdOrParserOrBuilderOrFactory)) { - // Form 1: command(name, Command, description?) + // Form 1: command(name, Command, options?) cmd = cmdOrParserOrBuilderOrFactory; - description = handlerOrDesc as string | undefined; + const opts = parseCommandOptions( + handlerOrDescOrOpts as CommandOptions | string | undefined, + ); + aliases = opts.aliases; + description = opts.description; } else if (isParser(cmdOrParserOrBuilderOrFactory)) { - // Form 2: command(name, Parser, handler, description?) + // Form 2: command(name, Parser, handler, options?) const parser = cmdOrParserOrBuilderOrFactory; - const handler = handlerOrDesc as HandlerFn; - description = maybeDesc; + const handler = handlerOrDescOrOpts as HandlerFn; + const opts = parseCommandOptions(maybeDescOrOpts); + aliases = opts.aliases; + description = opts.description; // Create Command from Parser + handler const parserWithTransform = parser as ParserWithTransform; @@ -566,7 +636,8 @@ const createCliBuilder = ( ); } - state.commands.set(name, { cmd, description, type: 'command' }); + state.commands.set(name, { aliases, cmd, description, type: 'command' }); + registerAliases(state.aliasMap, state.commands, name, aliases); return this; }, @@ -699,7 +770,7 @@ const parseCore = ( | Promise< ParseResult & { command?: string } > => { - const { commands, options, theme } = state; + const { aliasMap, commands, options, theme } = state; // Handle --help if (args.includes('--help') || args.includes('-h')) { @@ -708,7 +779,9 @@ const parseCore = ( const commandIndex = args.findIndex((a) => !a.startsWith('-')); if (commandIndex >= 0 && commandIndex < helpIndex && commands.size > 0) { - const commandName = args[commandIndex]!; + const rawCommandName = args[commandIndex]!; + // Resolve alias to canonical name if needed + const commandName = aliasMap.get(rawCommandName) ?? rawCommandName; const commandEntry = commands.get(commandName); if (commandEntry) { @@ -848,14 +921,15 @@ const generateCommandHelpNew = ( * @function */ const generateHelpNew = (state: InternalCliState, theme: Theme): string => { - // TODO: Implement proper help generation for new structure - // For now, delegate to existing help generator with minimal config + // Delegate to existing help generator with config including aliases const config = { commands: Object.fromEntries( - Array.from(state.commands.entries()).map(([name, { description }]) => [ - name, - { description: description ?? '' }, - ]), + Array.from(state.commands.entries()).map( + ([name, { aliases, description }]) => [ + name, + { aliases, description: description ?? '' }, + ], + ), ), description: state.options.description, name: state.name, @@ -1018,20 +1092,25 @@ const runWithCommands = ( | Promise< ParseResult & { command?: string } > => { - const { commands, defaultCommandName, globalParser } = state; + const { aliasMap, commands, defaultCommandName, globalParser } = state; // Find command name (first non-flag argument) const commandIndex = args.findIndex((arg) => !arg.startsWith('-')); const potentialCommandName = commandIndex >= 0 ? args[commandIndex] : undefined; - // Check if it's a known command + // Check if it's a known command or alias let commandName: string | undefined; let remainingArgs: string[]; - if (potentialCommandName && commands.has(potentialCommandName)) { - // It's a known command - remove it from args - commandName = potentialCommandName; + // Resolve alias to canonical name if needed + const resolvedName = potentialCommandName + ? (aliasMap.get(potentialCommandName) ?? potentialCommandName) + : undefined; + + if (resolvedName && commands.has(resolvedName)) { + // It's a known command (or resolved alias) - remove it from args + commandName = resolvedName; remainingArgs = [ ...args.slice(0, commandIndex), ...args.slice(commandIndex + 1), @@ -1230,7 +1309,10 @@ const runWithCommands = ( const handlerResult = cmd.handler(result); checkAsync(handlerResult, 'handler'); if (handlerResult instanceof Promise) { - return handlerResult.then(() => ({ ...result, command: commandName })); + return handlerResult.then(() => ({ + ...result, + command: commandName, + })); } return { ...result, command: commandName }; }; diff --git a/src/help.ts b/src/help.ts index dfa709d..be50be3 100644 --- a/src/help.ts +++ b/src/help.ts @@ -34,6 +34,8 @@ export interface HelpConfig { commands?: Record< string, { + /** Alternative names for this command */ + aliases?: string[]; description: string; options?: OptionsSchema; positionals?: PositionalsSchema; @@ -313,9 +315,18 @@ export const generateHelp = ( if (hasCommands(config)) { lines.push(styler.sectionHeader('COMMANDS')); for (const [name, cmd] of Object.entries(config.commands)) { - const padding = Math.max(0, 14 - name.length); + // Build command name with aliases: "add, a, new" or just "add" + // Calculate raw length for padding (without ANSI codes) + const rawAliasStr = + cmd.aliases && cmd.aliases.length > 0 + ? `${name}, ${cmd.aliases.join(', ')}` + : name; + const padding = Math.max(2, 20 - rawAliasStr.length); + const styledCmd = cmd.aliases?.length + ? `${styler.command(name)}, ${styler.commandAlias(cmd.aliases.join(', '))}` + : styler.command(name); lines.push( - ` ${styler.command(name)}${' '.repeat(padding)}${styler.description(cmd.description)}`, + ` ${styledCmd}${' '.repeat(padding)}${styler.description(cmd.description)}`, ); } lines.push(''); diff --git a/src/index.ts b/src/index.ts index d525fc2..75276de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -75,6 +75,8 @@ export type { CliResult, Command, CommandDef, + // Command configuration + CommandOptions, CountOption, CreateOptions, EnumArrayOption, diff --git a/src/theme.ts b/src/theme.ts index 685c1ce..7e9bf5f 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -33,6 +33,8 @@ export interface Theme { export interface ThemeColors { /** Command names (e.g., "init", "build") */ command: string; + /** Command aliases (e.g., "a", "ls") - shown dimmer than command names */ + commandAlias: string; /** The "default: " label text */ defaultText: string; /** Default value annotations (e.g., "false", ""hello"") */ @@ -125,6 +127,7 @@ export const ansi = { */ const defaultColors: ThemeColors = { command: ansi.bold, + commandAlias: ansi.dim, defaultText: ansi.dim, defaultValue: ansi.white, description: ansi.white, @@ -152,6 +155,7 @@ export const themes = { mono: { colors: { command: '', + commandAlias: '', defaultText: '', defaultValue: '', description: '', @@ -171,6 +175,7 @@ export const themes = { ocean: { colors: { command: ansi.bold + ansi.brightCyan, + commandAlias: ansi.cyan, defaultText: ansi.blue, defaultValue: ansi.green, description: ansi.white, @@ -190,6 +195,7 @@ export const themes = { warm: { colors: { command: ansi.bold + ansi.yellow, + commandAlias: ansi.dim + ansi.yellow, defaultText: ansi.dim + ansi.yellow, defaultValue: ansi.brightYellow, description: ansi.white, @@ -237,6 +243,7 @@ export type StyleFn = (text: string) => string; */ export interface Styler { command: StyleFn; + commandAlias: StyleFn; defaultText: StyleFn; defaultValue: StyleFn; description: StyleFn; @@ -279,6 +286,7 @@ export const createStyler = (theme: Theme): Styler => { const resolved = getTheme(theme as ThemeInput); return { command: makeStyleFn(resolved.colors.command), + commandAlias: makeStyleFn(resolved.colors.commandAlias), defaultText: makeStyleFn(resolved.colors.defaultText), defaultValue: makeStyleFn(resolved.colors.defaultValue), description: makeStyleFn(resolved.colors.description), diff --git a/src/types.ts b/src/types.ts index 4b538d7..ed52d8c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -68,7 +68,7 @@ export interface CliBuilder< command( name: string, cmd: Command, - description?: string, + options?: CommandOptions | string, ): CliBuilder; /** @@ -81,7 +81,7 @@ export interface CliBuilder< name: string, parser: Parser, handler: HandlerFn, - description?: string, + options?: CommandOptions | string, ): CliBuilder; /** @@ -93,7 +93,7 @@ export interface CliBuilder< command( name: string, nestedBuilder: CliBuilder, - description?: string, + options?: CommandOptions | string, ): CliBuilder; /** @@ -123,7 +123,7 @@ export interface CliBuilder< factory: ( builder: CliBuilder, ) => CliBuilder, - description?: string, + options?: CommandOptions | string, ): CliBuilder; /** @@ -228,6 +228,36 @@ export interface CommandDef< readonly name: string; } +/** + * Options for command registration. + * + * Used as an alternative to a simple string description when registering + * commands, allowing additional configuration like aliases. + * + * @example + * + * ```typescript + * .command('add', addParser, handler, { + * description: 'Add a new item', + * aliases: ['a', 'new'] + * }) + * ``` + */ +export interface CommandOptions { + /** + * Alternative names for this command. + * + * @example + * + * ```typescript + * { "aliases": ["co", "sw"] } // 'checkout' can be invoked as 'co' or 'sw' + * ``` + */ + aliases?: string[]; + /** Command description displayed in help text */ + description?: string; +} + /** * Count option definition (--verbose --verbose = 2). */ diff --git a/test/bargs.test.ts b/test/bargs.test.ts index 7b88c5e..c9ff681 100644 --- a/test/bargs.test.ts +++ b/test/bargs.test.ts @@ -676,3 +676,158 @@ describe('nested commands via factory pattern', () => { }); }); }); + +describe('command aliases', () => { + describe('leaf commands', () => { + it('resolves alias to canonical command (string alias)', async () => { + let executed = false; + const cli = bargs('test-cli').command( + 'add', + opt.options({}), + () => { + executed = true; + }, + { aliases: ['a', 'new'], description: 'Add an item' }, + ); + + await cli.parseAsync(['a']); + expect(executed, 'to be', true); + }); + + it('resolves multiple aliases', async () => { + const calls: string[] = []; + const cli = bargs('test-cli').command( + 'list', + opt.options({}), + () => { + calls.push('list'); + }, + { aliases: ['ls', 'l'], description: 'List items' }, + ); + + await cli.parseAsync(['ls']); + await cli.parseAsync(['l']); + await cli.parseAsync(['list']); + + expect(calls, 'to have length', 3); + }); + + it('passes options through when using alias', async () => { + let result: unknown; + const cli = bargs('test-cli') + .globals(opt.options({ verbose: opt.boolean() })) + .command( + 'remove', + opt.options({ force: opt.boolean() }), + ({ values }) => { + result = values; + }, + { aliases: ['rm', 'del'] }, + ); + + await cli.parseAsync(['--verbose', 'rm', '--force']); + + expect(result, 'to satisfy', { + force: true, + verbose: true, + }); + }); + + it('allows description-only string (backward compatible)', async () => { + let executed = false; + const cli = bargs('test-cli').command( + 'greet', + opt.options({}), + () => { + executed = true; + }, + 'Say hello', // string description, no aliases + ); + + await cli.parseAsync(['greet']); + expect(executed, 'to be', true); + }); + }); + + describe('nested commands', () => { + it('resolves alias for nested command group', async () => { + let executed = false; + const cli = bargs('test-cli').command( + 'remote', + (remote) => + remote.command( + 'add', + opt.options({}), + () => { + executed = true; + }, + 'Add remote', + ), + { aliases: ['r'], description: 'Manage remotes' }, + ); + + await cli.parseAsync(['r', 'add']); + expect(executed, 'to be', true); + }); + + it('resolves aliases at multiple levels', async () => { + let result: unknown; + const cli = bargs('test-cli') + .globals(opt.options({ verbose: opt.boolean() })) + .command( + 'config', + (cfg) => + cfg.command( + 'get', + pos.positionals(pos.string({ name: 'key', required: true })), + ({ positionals, values }) => { + result = { key: positionals[0], values }; + }, + { aliases: ['g'] }, + ), + { aliases: ['cfg', 'c'] }, + ); + + await cli.parseAsync(['--verbose', 'c', 'g', 'user.name']); + + expect(result, 'to satisfy', { + key: 'user.name', + values: { verbose: true }, + }); + }); + }); + + describe('error handling', () => { + it('throws BargsError when alias conflicts with existing command', () => { + expect( + () => + bargs('test-cli') + .command('add', opt.options({}), () => {}, { aliases: ['a'] }) + .command( + 'append', + opt.options({}), + () => {}, + { aliases: ['a'] }, // Conflict! + ), + 'to throw', + /alias "a" is already registered/, + ); + }); + + it('throws BargsError when alias conflicts with command name', () => { + expect( + () => + bargs('test-cli') + .command('add', opt.options({}), () => {}) + .command( + 'append', + opt.options({}), + () => {}, + { aliases: ['add'] }, // Conflict with command name! + ), + 'to throw', + /alias "add" conflicts with existing command name/, + ); + }); + }); +});