From 8910dd47c5cc47d4c2eea101d84f2d4df4fb7cfc Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Sat, 31 Jan 2026 19:26:21 -0800 Subject: [PATCH 1/4] feat: add shell completion generation for bash, zsh, and fish Implement dynamic shell completion via two internal flags: - --completion-script : outputs a completion script to source - --get-bargs-completions : returns candidates Enable with `completion: true` in bargs config. Supports: - Command and subcommand completion (including nested commands) - Option completion with aliases and --no- for booleans - Enum value completion for options and positionals - Global options accumulated across nested command levels Closes #22 --- cspell.json | 6 +- examples/completion.ts | 154 ++++++++ src/bargs.ts | 57 +++ src/completion.ts | 852 ++++++++++++++++++++++++++++++++++++++++ src/types.ts | 21 + test/ansi.test.ts | 30 -- test/completion.test.ts | 830 ++++++++++++++++++++++++++++++++++++++ test/exports.test.ts | 17 +- 8 files changed, 1928 insertions(+), 39 deletions(-) create mode 100755 examples/completion.ts create mode 100644 src/completion.ts delete mode 100644 test/ansi.test.ts create mode 100644 test/completion.test.ts diff --git a/cspell.json b/cspell.json index 2cbe93e..69307f4 100644 --- a/cspell.json +++ b/cspell.json @@ -48,6 +48,7 @@ "msuccess", "mwarning", "mycli", + "mytool", "barg", "argumentis", "mhello", @@ -62,7 +63,10 @@ "realfavicongenerator", "frickin", "TSES", - "ghostty" + "ghostty", + "CWORD", + "fpath", + "compdef" ], "words": [ "bupkis", diff --git a/examples/completion.ts b/examples/completion.ts new file mode 100755 index 0000000..e3dd1d9 --- /dev/null +++ b/examples/completion.ts @@ -0,0 +1,154 @@ +#!/usr/bin/env npx tsx +/** + * Shell completion example + * + * Demonstrates how to enable shell completion for a CLI. + * + * To enable completions for this example: + * + * # Bash (add to ~/.bashrc) + * + * Npx tsx examples/completion.ts --completion-script bash >> ~/.bashrc source + * ~/.bashrc + * + * # Zsh (add to ~/.zshrc) + * + * Npx tsx examples/completion.ts --completion-script zsh >> ~/.zshrc source + * ~/.zshrc + * + * # Fish (save to completions directory) + * + * Npx tsx examples/completion.ts --completion-script fish > + * ~/.config/fish/completions/completion-demo.fish + * + * Then try pressing TAB after typing partial commands or options. + * + * Usage: npx tsx examples/completion.ts build --target prod npx tsx + * examples/completion.ts test --coverage npx tsx examples/completion.ts lint + * --fix + */ +import { bargs, opt, pos } from '../src/index.js'; + +// ═══════════════════════════════════════════════════════════════════════════════ +// GLOBAL OPTIONS +// ═══════════════════════════════════════════════════════════════════════════════ + +const globalOptions = opt.options({ + config: opt.string({ + aliases: ['c'], + description: 'Path to config file', + }), + verbose: opt.boolean({ + aliases: ['v'], + default: false, + description: 'Enable verbose output', + }), +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// BUILD COMMAND +// ═══════════════════════════════════════════════════════════════════════════════ + +const buildParser = opt.options({ + minify: opt.boolean({ + aliases: ['m'], + default: false, + description: 'Minify output', + }), + // Enum option - completions will suggest these choices + target: opt.enum(['dev', 'staging', 'prod'], { + aliases: ['t'], + default: 'dev', + description: 'Build target environment', + }), +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// TEST COMMAND +// ═══════════════════════════════════════════════════════════════════════════════ + +const testParser = pos.positionals( + // Enum positional - completions will suggest these choices + pos.enum(['unit', 'integration', 'e2e'], { + description: 'Test type to run', + name: 'type', + }), +)( + opt.options({ + coverage: opt.boolean({ + default: false, + description: 'Collect coverage', + }), + watch: opt.boolean({ + aliases: ['w'], + default: false, + description: 'Watch for changes', + }), + }), +); + +// ═══════════════════════════════════════════════════════════════════════════════ +// LINT COMMAND +// ═══════════════════════════════════════════════════════════════════════════════ + +const lintParser = opt.options({ + fix: opt.boolean({ + default: false, + description: 'Auto-fix issues', + }), + // Enum option - completions will suggest these choices + format: opt.enum(['stylish', 'json', 'compact'], { + default: 'stylish', + description: 'Output format', + }), +}); + +// ═══════════════════════════════════════════════════════════════════════════════ +// CLI +// ═══════════════════════════════════════════════════════════════════════════════ + +await bargs('completion-demo', { + // Enable shell completion support! + completion: true, + description: 'Example CLI with shell completion support', + version: '1.0.0', +}) + .globals(globalOptions) + .command( + 'build', + buildParser, + ({ values }) => { + console.log('Building for:', values.target); + console.log('Minify:', values.minify); + if (values.verbose) { + console.log('Config:', values.config ?? '(default)'); + } + }, + { aliases: ['b'], description: 'Build the project' }, + ) + .command( + 'test', + testParser, + ({ positionals, values }) => { + console.log('Running tests:', positionals[0] ?? 'all'); + console.log('Coverage:', values.coverage); + console.log('Watch:', values.watch); + if (values.verbose) { + console.log('Config:', values.config ?? '(default)'); + } + }, + { aliases: ['t'], description: 'Run tests' }, + ) + .command( + 'lint', + lintParser, + ({ values }) => { + console.log('Linting with format:', values.format); + console.log('Fix:', values.fix); + if (values.verbose) { + console.log('Config:', values.config ?? '(default)'); + } + }, + { aliases: ['l'], description: 'Lint source files' }, + ) + .parseAsync(); diff --git a/src/bargs.ts b/src/bargs.ts index ab58952..150e575 100644 --- a/src/bargs.ts +++ b/src/bargs.ts @@ -18,6 +18,11 @@ import type { ParseResult, } from './types.js'; +import { + generateCompletionScript, + getCompletionCandidates, + validateShell, +} from './completion.js'; import { BargsError, HelpError } from './errors.js'; import { generateCommandHelp, generateHelp } from './help.js'; import { parseSimple } from './parser.js'; @@ -527,6 +532,7 @@ const isCommand = (x: unknown): x is Command => { // Internal type for CliBuilder with internal methods type InternalCliBuilder = CliBuilder & { + __getState: () => InternalCliState; __parseWithParentGlobals: ( args: string[], parentGlobals: ParseResult, @@ -545,6 +551,11 @@ const createCliBuilder = ( state: InternalCliState, ): CliBuilder => { const builder: InternalCliBuilder = { + // Internal method for completion support - not part of public API + __getState(): InternalCliState { + return state; + }, + // Internal method for nested command support - not part of public API __parseWithParentGlobals( args: string[], @@ -852,6 +863,52 @@ const parseCore = ( process.exit(0); } } + + // Handle shell completion (when enabled) + if (options.completion) { + // Handle --completion-script + const completionScriptIndex = args.indexOf('--completion-script'); + if (completionScriptIndex >= 0) { + const shellArg = args[completionScriptIndex + 1]; + if (!shellArg) { + console.error( + 'Error: --completion-script requires a shell argument (bash, zsh, or fish)', + ); + process.exit(1); + } + try { + const shell = validateShell(shellArg); + console.log(generateCompletionScript(state.name, shell)); + process.exit(0); + } catch (err) { + console.error(`Error: ${(err as Error).message}`); + process.exit(1); + } + } + + // Handle --get-bargs-completions <...words> + const getCompletionsIndex = args.indexOf('--get-bargs-completions'); + if (getCompletionsIndex >= 0) { + const shellArg = args[getCompletionsIndex + 1]; + if (!shellArg) { + // No shell specified, output nothing + process.exit(0); + } + try { + const shell = validateShell(shellArg); + // Words are everything after the shell argument + const words = args.slice(getCompletionsIndex + 2); + const candidates = getCompletionCandidates(state, shell, words); + if (candidates.length > 0) { + console.log(candidates.join('\n')); + } + process.exit(0); + } catch { + // Invalid shell, output nothing + process.exit(0); + } + } + } /* c8 ignore stop */ // If we have commands, dispatch to the appropriate one diff --git a/src/completion.ts b/src/completion.ts new file mode 100644 index 0000000..6897cb8 --- /dev/null +++ b/src/completion.ts @@ -0,0 +1,852 @@ +/** + * Shell completion script generation for bargs CLIs. + * + * Provides dynamic shell completion support for bash, zsh, and fish shells. The + * generated scripts call back to the CLI to get completion candidates. + * + * @packageDocumentation + */ + +import type { OptionDef, OptionsSchema, PositionalsSchema } from './types.js'; + +/** + * Supported shell types for completion script generation. + * + * @group Completion + */ +export type Shell = 'bash' | 'fish' | 'zsh'; + +/** + * Command metadata for completion. + */ +interface CommandCompletionInfo { + /** Alternative names for this command */ + aliases: string[]; + /** Command description for help text */ + description?: string; + /** Canonical command name */ + name: string; + /** Nested CLI builder for subcommands */ + nestedBuilder?: unknown; + /** Options specific to this command */ + options: OptionCompletionInfo[]; + /** Positional arguments for this command */ + positionals: PositionalCompletionInfo[]; +} + +/** + * Command entry in internal state. + */ +type CommandEntry = + | { + /** Alternative names for this command */ + aliases?: string[]; + /** Command definition with schemas */ + cmd: { + /** Options schema for this command */ + __optionsSchema: OptionsSchema; + /** Positionals schema for this command */ + __positionalsSchema: PositionalsSchema; + }; + /** Command description for help text */ + description?: string; + /** Discriminator for leaf commands */ + type: 'command'; + } + | { + /** Alternative names for this command */ + aliases?: string[]; + /** Nested CLI builder for subcommands */ + builder: unknown; + /** Command description for help text */ + description?: string; + /** Discriminator for nested command groups */ + type: 'nested'; + }; + +/** + * Internal metadata structure for completion generation. + */ +interface CompletionMetadata { + /** Map of command names to their completion metadata */ + commands: Map; + /** Global options available to all commands */ + globalOptions: OptionCompletionInfo[]; + /** CLI executable name */ + name: string; +} + +/** + * Internal CLI builder with state access (mirrors InternalCliBuilder from + * bargs.ts). + */ +interface InternalBuilder { + /** Get the internal CLI state for completion traversal */ + __getState: () => InternalCliState; +} + +/** + * Internal CLI state structure (matches bargs.ts InternalCliState). + */ +interface InternalCliState { + /** Map of command aliases to canonical command names */ + aliasMap: Map; + /** Map of command names to their entries */ + commands: Map; + /** Global parser with options and positionals schemas */ + globalParser?: { + /** Global options schema */ + __optionsSchema: OptionsSchema; + /** Global positionals schema */ + __positionalsSchema: PositionalsSchema; + }; + /** CLI executable name */ + name: string; +} + +/** + * Option metadata for completion. + */ +interface OptionCompletionInfo { + /** Alternative names for this option (e.g., `-v` for `--verbose`) */ + aliases: string[]; + /** Valid values for enum options */ + choices?: readonly string[]; + /** Option description for help text */ + description?: string; + /** Option name including `--` prefix (e.g., `--verbose`) */ + name: string; + /** Whether this option requires a value argument */ + takesValue: boolean; + /** Option type (string, boolean, number, enum, array, count) */ + type: string; +} + +/** + * Result from option value candidate lookup. + */ +interface OptionValueResult { + /** The completion candidates (may be empty to allow file completion) */ + candidates: string[]; + /** Whether we found a matching option that takes a value */ + found: boolean; +} + +/** + * Positional metadata for completion. + */ +interface PositionalCompletionInfo { + /** Valid values for enum positionals */ + choices?: readonly string[]; + /** Positional description for help text */ + description?: string; + /** Display name for this positional */ + name: string; + /** Positional type (string, number, enum, variadic) */ + type: string; +} + +/** + * Sanitize a CLI name for use as a shell function name. + * + * Ensures the result is a valid POSIX identifier: starts with a letter or + * underscore, contains only alphanumeric characters and underscores. + * + * @function + * @param name - The CLI name to sanitize + * @returns A valid shell function name + */ +const sanitizeFunctionName = (name: string): string => { + // Replace any non-alphanumeric character with underscore + let sanitized = name.replace(/[^a-zA-Z0-9]/g, '_'); + + // Collapse multiple consecutive underscores + sanitized = sanitized.replace(/_+/g, '_'); + + // Remove leading/trailing underscores + sanitized = sanitized.replace(/^_+|_+$/g, ''); + + // Ensure it starts with a letter or underscore (not a digit) + if (/^[0-9]/.test(sanitized)) { + sanitized = `_${sanitized}`; + } + + // Fallback for empty result + if (!sanitized) { + sanitized = 'cli'; + } + + return sanitized; +}; + +/** + * Generate bash completion script. + * + * @function + */ +const generateBashScript = (cliName: string): string => { + const funcName = `_${sanitizeFunctionName(cliName)}_completions`; + + return `# bash completion for ${cliName} +# Add to ~/.bashrc or ~/.bash_profile: +# source <(${cliName} --completion-script bash) +# Or: +# ${cliName} --completion-script bash >> ~/.bashrc + +${funcName}() { + local IFS=$'\\n' + local cur="\${COMP_WORDS[COMP_CWORD]}" + + # Call CLI to get completions + local completions + completions=($("${cliName}" --get-bargs-completions bash "\${COMP_WORDS[@]}")) + + # Filter by current word prefix + COMPREPLY=($(compgen -W "\${completions[*]}" -- "\${cur}")) + + # Fall back to file completion if no matches and not completing an option + if [[ \${#COMPREPLY[@]} -eq 0 && "\${cur}" != -* ]]; then + compopt -o default + fi +} + +complete -o default -F ${funcName} ${cliName} +`; +}; + +/** + * Generate zsh completion script. + * + * @function + */ +const generateZshScript = (cliName: string): string => { + const funcName = `_${sanitizeFunctionName(cliName)}`; + + return `#compdef ${cliName} +# zsh completion for ${cliName} +# Add to ~/.zshrc: +# source <(${cliName} --completion-script zsh) +# Or save to a file in your $fpath: +# ${cliName} --completion-script zsh > ~/.zsh/completions/_${cliName} + +${funcName}() { + local completions + + # Call CLI to get completions with descriptions + completions=("\${(@f)$("${cliName}" --get-bargs-completions zsh "\${words[@]}")}") + + if [[ \${#completions[@]} -gt 0 && -n "\${completions[1]}" ]]; then + # Check if completions have descriptions (format: "value:description") + if [[ "\${completions[1]}" == *":"* ]]; then + _describe 'completions' completions + else + compadd -a completions + fi + fi +} + +compdef ${funcName} ${cliName} +`; +}; + +/** + * Generate fish completion script. + * + * @function + */ +const generateFishScript = (cliName: string): string => { + const funcName = `__fish_${sanitizeFunctionName(cliName)}_complete`; + + return `# fish completion for ${cliName} +# Save to ~/.config/fish/completions/${cliName}.fish: +# ${cliName} --completion-script fish > ~/.config/fish/completions/${cliName}.fish + +function ${funcName} + set -l tokens (commandline -opc) + ${cliName} --get-bargs-completions fish $tokens +end + +# Disable file completions by default, let the CLI decide +complete -c ${cliName} -f -a '(${funcName})' +`; +}; + +/** + * Generate a shell completion script for the given CLI. + * + * The generated script calls back to the CLI with `--get-bargs-completions` to + * get completion candidates dynamically. + * + * @example + * + * ```typescript + * // Output script for bash + * console.log(generateCompletionScript('mytool', 'bash')); + * // Redirect to shell config: mytool --completion-script bash >> ~/.bashrc + * ``` + * + * @function + * @param cliName - The name of the CLI executable + * @param shell - The target shell ('bash', 'zsh', or 'fish') + * @returns The completion script as a string + * @group Completion + */ +export const generateCompletionScript = ( + cliName: string, + shell: Shell, +): string => { + switch (shell) { + case 'bash': + return generateBashScript(cliName); + case 'fish': + return generateFishScript(cliName); + case 'zsh': + return generateZshScript(cliName); + default: + throw new Error(`Unsupported shell: ${shell as string}`); + } +}; + +/** + * Extract completion metadata from internal CLI state. + * + * @function + */ +const extractCompletionMetadata = ( + state: InternalCliState, +): CompletionMetadata => { + const globalOptions = extractOptionsInfo( + state.globalParser?.__optionsSchema ?? {}, + ); + + const commands = new Map(); + for (const [name, entry] of state.commands) { + // Skip the internal default command marker + if (name === '__default__') { + continue; + } + + if (entry.type === 'command') { + commands.set(name, { + aliases: entry.aliases ?? [], + description: entry.description, + name, + options: extractOptionsInfo(entry.cmd.__optionsSchema), + positionals: extractPositionalsInfo(entry.cmd.__positionalsSchema), + }); + } else if (entry.type === 'nested') { + commands.set(name, { + aliases: entry.aliases ?? [], + description: entry.description, + name, + nestedBuilder: entry.builder, + options: [], + positionals: [], + }); + } + } + + return { + commands, + globalOptions, + name: state.name, + }; +}; + +/** + * Check if a value is an internal builder with __getState method. + * + * @function + */ +const isInternalBuilder = (value: unknown): value is InternalBuilder => { + return ( + typeof value === 'object' && + value !== null && + '__getState' in value && + typeof (value as InternalBuilder).__getState === 'function' + ); +}; + +/** + * Extract metadata from a nested builder. + * + * @function + */ +const extractNestedMetadata = ( + nestedBuilder: unknown, +): CompletionMetadata | undefined => { + if (!isInternalBuilder(nestedBuilder)) { + return undefined; + } + return extractCompletionMetadata(nestedBuilder.__getState()); +}; + +/** + * Extract option info from options schema. + * + * @function + */ +const extractOptionsInfo = (schema: OptionsSchema): OptionCompletionInfo[] => { + const options: OptionCompletionInfo[] = []; + + for (const [name, def] of Object.entries(schema)) { + // Skip hidden options + if ((def as { hidden?: boolean }).hidden) { + continue; + } + + const aliases: string[] = []; + if ('aliases' in def && Array.isArray(def.aliases)) { + for (const alias of def.aliases) { + if (alias.length === 1) { + aliases.push(`-${alias}`); + } else { + aliases.push(`--${alias}`); + } + } + } + + options.push({ + aliases, + choices: getChoices(def), + description: def.description, + name: `--${name}`, + takesValue: def.type !== 'boolean' && def.type !== 'count', + type: def.type, + }); + + // Add --no- for boolean options + if (def.type === 'boolean') { + options.push({ + aliases: [], + description: def.description ? `Disable ${def.description}` : undefined, + name: `--no-${name}`, + takesValue: false, + type: 'boolean', + }); + } + } + + return options; +}; + +/** + * Extract positional info from positionals schema. + * + * @function + */ +const extractPositionalsInfo = ( + schema: PositionalsSchema, +): PositionalCompletionInfo[] => + schema.map((pos) => ({ + choices: getChoices(pos as OptionDef), + description: pos.description, + name: pos.name ?? 'arg', + type: pos.type, + })); + +/** + * Get choices from an option or positional definition. + * + * @function + */ +const getChoices = (def: OptionDef): readonly string[] | undefined => { + if ('choices' in def && Array.isArray(def.choices)) { + return def.choices as readonly string[]; + } + return undefined; +}; + +/** + * Result of command context analysis. + */ +interface CommandContextResult { + /** Accumulated global options from all parent levels */ + accumulatedOptions: OptionCompletionInfo[]; + /** Available subcommands at the current level */ + availableCommands: CommandCompletionInfo[]; + /** The current leaf command (if we've reached one) */ + currentCommand?: CommandCompletionInfo; + /** Whether we need a command/subcommand to be specified */ + needsCommand: boolean; + /** Index of the next positional argument for the current command */ + positionalIndex: number; +} + +/** + * Find a command by name or alias in the metadata. + * + * @function + */ +const findCommand = ( + metadata: CompletionMetadata, + name: string, +): CommandCompletionInfo | undefined => { + // Try direct match + const direct = metadata.commands.get(name); + if (direct) { + return direct; + } + + // Try alias match + for (const [, cmd] of metadata.commands) { + if (cmd.aliases.includes(name)) { + return cmd; + } + } + + return undefined; +}; + +/** + * Analyze command context from args, recursively handling nested commands. + * + * @function + */ +const getCommandContext = ( + metadata: CompletionMetadata, + args: string[], + accumulatedOptions: OptionCompletionInfo[] = [], +): CommandContextResult => { + // Accumulate global options from this level + const options = [...accumulatedOptions, ...metadata.globalOptions]; + + if (metadata.commands.size === 0) { + return { + accumulatedOptions: options, + availableCommands: [], + needsCommand: false, + positionalIndex: 0, + }; + } + + // Find the first non-option argument (potential command) + // Note: The last arg is the current word being completed, so we don't count it + // as a completed positional + let commandName: string | undefined; + let commandArgIndex = -1; + + // Process all args except the last one (which is being completed) + const completedArgs = args.slice(0, -1); + + for (let i = 0; i < completedArgs.length; i++) { + const arg = completedArgs[i]!; + if (!arg.startsWith('-')) { + // First non-option is the command at this level + commandName = arg; + commandArgIndex = i; + break; + } + } + + // Check if the command name matches a known command or alias + if (commandName) { + const cmd = findCommand(metadata, commandName); + + if (cmd) { + // Check if this is a nested command - if so, recurse + if (cmd.nestedBuilder) { + const nestedMetadata = extractNestedMetadata(cmd.nestedBuilder); + if (nestedMetadata) { + // Get remaining args after this command + const remainingArgs = completedArgs.slice(commandArgIndex + 1); + // Add the current word being completed + if (args.length > 0) { + remainingArgs.push(args[args.length - 1]!); + } + return getCommandContext(nestedMetadata, remainingArgs, options); + } + } + + // It's a leaf command - calculate positional index + let positionalIndex = 0; + for (let i = commandArgIndex + 1; i < completedArgs.length; i++) { + const arg = completedArgs[i]!; + if (!arg.startsWith('-')) { + positionalIndex++; + } + } + + return { + accumulatedOptions: options, + availableCommands: [], + currentCommand: cmd, + needsCommand: false, + positionalIndex, + }; + } + } + + // No valid command yet at this level - need to show available commands + return { + accumulatedOptions: options, + availableCommands: Array.from(metadata.commands.values()), + needsCommand: true, + positionalIndex: 0, + }; +}; + +/** + * Get all options available in the current context. + * + * @function + */ +const getAllOptionsForContext = ( + metadata: CompletionMetadata, + args: string[], +): OptionCompletionInfo[] => { + // getCommandContext now accumulates global options from all parent levels + const commandContext = getCommandContext(metadata, args); + + // Start with accumulated options (includes all global options from parent levels) + const options = [...commandContext.accumulatedOptions]; + + // Add command-specific options if we're in a leaf command + if (commandContext.currentCommand) { + options.push(...commandContext.currentCommand.options); + } + + return options; +}; + +/** + * Format candidates for shell output. + * + * @function + */ +const formatCandidates = ( + candidates: Array<{ description?: string; value: string }>, + shell: Shell, +): string[] => { + switch (shell) { + case 'fish': + // fish supports descriptions with tab separator + return candidates.map((c) => + c.description ? `${c.value}\t${c.description}` : c.value, + ); + case 'zsh': + // zsh supports descriptions in format "value:description" + return candidates.map((c) => + c.description ? `${c.value}:${c.description}` : c.value, + ); + case 'bash': + default: + // bash doesn't support descriptions in basic completion + return candidates.map((c) => c.value); + } +}; + +/** + * Get command candidates. + * + * @function + */ +const getCommandCandidates = ( + commands: CommandCompletionInfo[], + _currentWord: string, + shell: Shell, +): string[] => { + const candidates: Array<{ description?: string; value: string }> = []; + + for (const cmd of commands) { + candidates.push({ description: cmd.description, value: cmd.name }); + for (const alias of cmd.aliases) { + candidates.push({ + description: cmd.description ? `(alias) ${cmd.description}` : '(alias)', + value: alias, + }); + } + } + + return formatCandidates(candidates, shell); +}; + +/** + * Get option candidates. + * + * @function + */ +const getOptionCandidates = ( + metadata: CompletionMetadata, + args: string[], + shell: Shell, +): string[] => { + const allOptions = getAllOptionsForContext(metadata, args); + const candidates: Array<{ description?: string; value: string }> = []; + + for (const opt of allOptions) { + candidates.push({ description: opt.description, value: opt.name }); + for (const alias of opt.aliases) { + candidates.push({ description: opt.description, value: alias }); + } + } + + return formatCandidates(candidates, shell); +}; + +/** + * Get candidates for option values (enum choices). + * + * @function + */ +const getOptionValueCandidates = ( + metadata: CompletionMetadata, + prevWord: string, + args: string[], + shell: Shell, +): OptionValueResult => { + // Find the option definition + const allOptions = getAllOptionsForContext(metadata, args); + + for (const opt of allOptions) { + if (opt.name === prevWord || opt.aliases.includes(prevWord)) { + if (opt.choices && opt.choices.length > 0) { + return { + candidates: formatCandidates( + opt.choices.map((c) => ({ description: undefined, value: c })), + shell, + ), + found: true, + }; + } + // Option takes a value but no specific choices - let shell do file completion + if (opt.takesValue) { + return { candidates: [], found: true }; + } + // Boolean/count option - doesn't take a value, so prev word isn't an option expecting a value + return { candidates: [], found: false }; + } + } + + // Option not found + return { candidates: [], found: false }; +}; + +/** + * Get positional candidates (for enum positionals). + * + * @function + */ +const getPositionalCandidates = ( + command: CommandCompletionInfo, + positionalIndex: number, + shell: Shell, +): string[] => { + if (positionalIndex >= command.positionals.length) { + // Check for variadic last positional + const lastPos = command.positionals[command.positionals.length - 1]; + if (lastPos?.type !== 'variadic') { + return []; + } + // Use the variadic positional's choices if any + if (lastPos.choices && lastPos.choices.length > 0) { + return formatCandidates( + lastPos.choices.map((c) => ({ description: undefined, value: c })), + shell, + ); + } + return []; + } + + const pos = command.positionals[positionalIndex]; + if (!pos || !pos.choices || pos.choices.length === 0) { + return []; + } + + return formatCandidates( + pos.choices.map((c) => ({ description: undefined, value: c })), + shell, + ); +}; + +/** + * Get completion candidates for the current command line state. + * + * Analyzes the provided words to determine context and returns appropriate + * completion suggestions. + * + * @function + * @param state - Internal CLI state containing commands and options + * @param shell - The shell requesting completions (affects output format) + * @param words - The command line words (COMP_WORDS in bash) + * @returns Array of completion candidates (one per line when output) + * @group Completion + */ +export const getCompletionCandidates = ( + state: InternalCliState, + shell: Shell, + words: string[], +): string[] => { + const metadata = extractCompletionMetadata(state); + + // Remove the CLI name from words if present + const args = words.length > 1 ? words.slice(1) : []; + const currentWord = args.length > 0 ? (args[args.length - 1] ?? '') : ''; + const prevWord = args.length > 1 ? args[args.length - 2] : undefined; + + // Check if we're completing an option value + if (prevWord?.startsWith('-')) { + const result = getOptionValueCandidates(metadata, prevWord, args, shell); + // If we found the option and it takes a value, return the result + // (which may be empty to allow file completion) + if (result.found) { + return result.candidates; + } + } + + // Check if current word is an option + if (currentWord.startsWith('-')) { + return getOptionCandidates(metadata, args, shell); + } + + // Check if we need to complete a command + const commandContext = getCommandContext(metadata, args); + if (commandContext.needsCommand) { + return getCommandCandidates( + commandContext.availableCommands, + currentWord, + shell, + ); + } + + // Check if we're in a command and need positional completion + if (commandContext.currentCommand) { + const positionalCandidates = getPositionalCandidates( + commandContext.currentCommand, + commandContext.positionalIndex, + shell, + ); + if (positionalCandidates.length > 0) { + return positionalCandidates; + } + } + + // Default: offer commands if we have them, or options + if (metadata.commands.size > 0) { + return getCommandCandidates( + Array.from(metadata.commands.values()), + currentWord, + shell, + ); + } + + return getOptionCandidates(metadata, args, shell); +}; + +/** + * Validate that a shell name is supported. + * + * @function + * @param shell - The shell name to validate + * @returns The validated shell type + * @throws Error if the shell is not supported + * @group Completion + */ +export const validateShell = (shell: string): Shell => { + if (shell === 'bash' || shell === 'zsh' || shell === 'fish') { + return shell; + } + throw new Error( + `Unsupported shell: "${shell}". Supported shells: bash, zsh, fish`, + ); +}; diff --git a/src/types.ts b/src/types.ts index 31ee9db..a97e678 100644 --- a/src/types.ts +++ b/src/types.ts @@ -278,6 +278,27 @@ export interface CountOption extends OptionBase { * @group Core API */ export interface CreateOptions { + /** + * Enable shell completion support. + * + * When `true`, the CLI will respond to: + * + * - `--completion-script ` - Output shell completion script + * - `--get-bargs-completions <...words>` - Return completion candidates + * + * Supported shells: bash, zsh, fish + * + * @example + * + * ```typescript + * bargs('mytool', { completion: true }) + * .command('build', ...) + * .parseAsync(); + * + * // Then run: mytool --completion-script bash >> ~/.bashrc + * ``` + */ + completion?: boolean; /** Description shown in help */ description?: string; /** Epilog text shown after help output */ diff --git a/test/ansi.test.ts b/test/ansi.test.ts deleted file mode 100644 index eb969ce..0000000 --- a/test/ansi.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -// test/ansi.test.ts -import { expect } from 'bupkis'; -import { describe, it } from 'node:test'; - -import { stripAnsi } from '../src/theme.js'; - -// ANSI codes for test construction -const RED = '\x1b[31m'; -const BOLD = '\x1b[1m'; -const RESET = '\x1b[0m'; - -describe('stripAnsi', () => { - it('removes ANSI codes from string', () => { - const colored = `${RED}red${RESET}`; - expect(stripAnsi(colored), 'to be', 'red'); - }); - - it('passes through plain text', () => { - expect(stripAnsi('hello'), 'to be', 'hello'); - }); - - it('removes multiple ANSI codes', () => { - const multiStyled = `${BOLD}${RED}bold red${RESET}`; - expect(stripAnsi(multiStyled), 'to be', 'bold red'); - }); - - it('handles empty string', () => { - expect(stripAnsi(''), 'to be', ''); - }); -}); diff --git a/test/completion.test.ts b/test/completion.test.ts new file mode 100644 index 0000000..c5752d8 --- /dev/null +++ b/test/completion.test.ts @@ -0,0 +1,830 @@ +/** + * Tests for shell completion generation. + */ +import { expect } from 'bupkis'; +import { describe, it } from 'node:test'; + +import { + generateCompletionScript, + getCompletionCandidates, + type Shell, + validateShell, +} from '../src/completion.js'; + +describe('validateShell()', () => { + it('accepts "bash"', () => { + expect(validateShell('bash'), 'to equal', 'bash'); + }); + + it('accepts "zsh"', () => { + expect(validateShell('zsh'), 'to equal', 'zsh'); + }); + + it('accepts "fish"', () => { + expect(validateShell('fish'), 'to equal', 'fish'); + }); + + it('throws for unsupported shell', () => { + expect( + () => validateShell('powershell'), + 'to throw', + /Unsupported shell: "powershell"/, + ); + }); + + it('throws with helpful message listing supported shells', () => { + expect( + () => validateShell('invalid'), + 'to throw', + /Supported shells: bash, zsh, fish/, + ); + }); +}); + +describe('generateCompletionScript()', () => { + describe('bash', () => { + it('generates valid bash completion script', () => { + const script = generateCompletionScript('mytool', 'bash'); + + expect(script, 'to contain', '# bash completion for mytool'); + expect(script, 'to contain', '_mytool_completions()'); + expect( + script, + 'to contain', + 'complete -o default -F _mytool_completions mytool', + ); + expect(script, 'to contain', '--get-bargs-completions bash'); + }); + + it('sanitizes CLI names with dashes', () => { + const script = generateCompletionScript('my-cool-tool', 'bash'); + + expect(script, 'to contain', '_my_cool_tool_completions()'); + expect( + script, + 'to contain', + 'complete -o default -F _my_cool_tool_completions my-cool-tool', + ); + }); + + it('sanitizes CLI names starting with digits', () => { + const script = generateCompletionScript('123tool', 'bash'); + + // Should prefix with underscore since POSIX identifiers can't start with digit + expect(script, 'to contain', '__123tool_completions()'); + }); + + it('sanitizes CLI names with special characters', () => { + const script = generateCompletionScript('@scope/my-pkg', 'bash'); + + // Should replace @ and / with underscores, collapse consecutive underscores + expect(script, 'to contain', '_scope_my_pkg_completions()'); + }); + + it('includes usage instructions as comments', () => { + const script = generateCompletionScript('mytool', 'bash'); + + expect(script, 'to contain', 'Add to ~/.bashrc'); + expect(script, 'to contain', '--completion-script bash'); + }); + }); + + describe('zsh', () => { + it('generates valid zsh completion script', () => { + const script = generateCompletionScript('mytool', 'zsh'); + + expect(script, 'to contain', '#compdef mytool'); + expect(script, 'to contain', '_mytool()'); + expect(script, 'to contain', 'compdef _mytool mytool'); + expect(script, 'to contain', '--get-bargs-completions zsh'); + }); + + it('sanitizes CLI names with dashes', () => { + const script = generateCompletionScript('my-cool-tool', 'zsh'); + + expect(script, 'to contain', '_my_cool_tool()'); + expect(script, 'to contain', 'compdef _my_cool_tool my-cool-tool'); + }); + + it('includes usage instructions as comments', () => { + const script = generateCompletionScript('mytool', 'zsh'); + + expect(script, 'to contain', 'Add to ~/.zshrc'); + }); + }); + + describe('fish', () => { + it('generates valid fish completion script', () => { + const script = generateCompletionScript('mytool', 'fish'); + + expect(script, 'to contain', '# fish completion for mytool'); + expect(script, 'to contain', 'function __fish_mytool_complete'); + expect(script, 'to contain', 'complete -c mytool'); + expect(script, 'to contain', '--get-bargs-completions fish'); + }); + + it('sanitizes CLI names with dashes', () => { + const script = generateCompletionScript('my-cool-tool', 'fish'); + + expect(script, 'to contain', 'function __fish_my_cool_tool_complete'); + expect(script, 'to contain', 'complete -c my-cool-tool'); + }); + + it('includes usage instructions as comments', () => { + const script = generateCompletionScript('mytool', 'fish'); + + expect(script, 'to contain', '~/.config/fish/completions/mytool.fish'); + }); + }); + + it('throws for unsupported shell', () => { + expect( + () => generateCompletionScript('mytool', 'invalid' as Shell), + 'to throw', + /Unsupported shell/, + ); + }); +}); + +describe('getCompletionCandidates()', () => { + // Helper to create a minimal state that's compatible with getCompletionCandidates + // We use 'as never' to bypass strict type checking for test purposes since + // the internal state type isn't exported from completion.ts + type StateConfig = { + commands?: Map< + string, + { + aliases?: string[]; + builder?: { __getState: () => ReturnType }; + cmd?: { + __optionsSchema: Record; + __positionalsSchema: readonly unknown[]; + }; + description?: string; + type: 'command' | 'nested'; + } + >; + globalOptions?: Record; + name?: string; + }; + + /** + * @function + */ + const createState = (config: StateConfig) => + ({ + aliasMap: new Map(), + commands: config.commands ?? new Map(), + globalParser: config.globalOptions + ? { + __optionsSchema: config.globalOptions, + __positionalsSchema: [] as const, + } + : undefined, + name: config.name ?? 'test-cli', + }) as Parameters[0]; + + /** + * Helper to create a mock nested builder with __getState method. + * + * @function + */ + const createNestedBuilder = (config: StateConfig) => ({ + /** + * @function + */ + __getState: () => createState(config), + }); + + describe('command completion', () => { + it('returns command names when at command position', () => { + const state = createState({ + commands: new Map([ + [ + 'build', + { + cmd: { __optionsSchema: {}, __positionalsSchema: [] }, + description: 'Build the project', + type: 'command', + }, + ], + [ + 'test', + { + cmd: { __optionsSchema: {}, __positionalsSchema: [] }, + description: 'Run tests', + type: 'command', + }, + ], + ]), + }); + + const candidates = getCompletionCandidates(state, 'bash', [ + 'test-cli', + '', + ]); + + expect(candidates, 'to contain', 'build'); + expect(candidates, 'to contain', 'test'); + }); + + it('returns command aliases', () => { + const state = createState({ + commands: new Map([ + [ + 'build', + { + aliases: ['b'], + cmd: { __optionsSchema: {}, __positionalsSchema: [] }, + description: 'Build the project', + type: 'command', + }, + ], + ]), + }); + + const candidates = getCompletionCandidates(state, 'bash', [ + 'test-cli', + '', + ]); + + expect(candidates, 'to contain', 'build'); + expect(candidates, 'to contain', 'b'); + }); + + it('includes descriptions for zsh', () => { + const state = createState({ + commands: new Map([ + [ + 'build', + { + cmd: { __optionsSchema: {}, __positionalsSchema: [] }, + description: 'Build the project', + type: 'command', + }, + ], + ]), + }); + + const candidates = getCompletionCandidates(state, 'zsh', [ + 'test-cli', + '', + ]); + + expect(candidates, 'to contain', 'build:Build the project'); + }); + + it('includes descriptions for fish with tab separator', () => { + const state = createState({ + commands: new Map([ + [ + 'build', + { + cmd: { __optionsSchema: {}, __positionalsSchema: [] }, + description: 'Build the project', + type: 'command', + }, + ], + ]), + }); + + const candidates = getCompletionCandidates(state, 'fish', [ + 'test-cli', + '', + ]); + + expect(candidates, 'to contain', 'build\tBuild the project'); + }); + + it('excludes __default__ command', () => { + const state = createState({ + commands: new Map([ + [ + '__default__', + { + cmd: { __optionsSchema: {}, __positionalsSchema: [] }, + type: 'command', + }, + ], + [ + 'build', + { + cmd: { __optionsSchema: {}, __positionalsSchema: [] }, + type: 'command', + }, + ], + ]), + }); + + const candidates = getCompletionCandidates(state, 'bash', [ + 'test-cli', + '', + ]); + + expect(candidates, 'not to contain', '__default__'); + expect(candidates, 'to contain', 'build'); + }); + }); + + describe('option completion', () => { + it('returns global options when word starts with -', () => { + const state = createState({ + globalOptions: { + output: { description: 'Output file', type: 'string' }, + verbose: { description: 'Verbose output', type: 'boolean' }, + }, + }); + + const candidates = getCompletionCandidates(state, 'bash', [ + 'test-cli', + '-', + ]); + + expect(candidates, 'to contain', '--verbose'); + expect(candidates, 'to contain', '--output'); + }); + + it('returns option aliases', () => { + const state = createState({ + globalOptions: { + verbose: { aliases: ['v'], type: 'boolean' }, + }, + }); + + const candidates = getCompletionCandidates(state, 'bash', [ + 'test-cli', + '-', + ]); + + expect(candidates, 'to contain', '--verbose'); + expect(candidates, 'to contain', '-v'); + }); + + it('returns --no- for boolean options', () => { + const state = createState({ + globalOptions: { + verbose: { type: 'boolean' }, + }, + }); + + const candidates = getCompletionCandidates(state, 'bash', [ + 'test-cli', + '-', + ]); + + expect(candidates, 'to contain', '--verbose'); + expect(candidates, 'to contain', '--no-verbose'); + }); + + it('excludes hidden options', () => { + const state = createState({ + globalOptions: { + hidden: { hidden: true, type: 'boolean' }, + visible: { type: 'boolean' }, + }, + }); + + const candidates = getCompletionCandidates(state, 'bash', [ + 'test-cli', + '-', + ]); + + expect(candidates, 'to contain', '--visible'); + expect(candidates, 'not to contain', '--hidden'); + }); + + it('includes command-specific options when in command context', () => { + const state = createState({ + commands: new Map([ + [ + 'build', + { + cmd: { + __optionsSchema: { + minify: { type: 'boolean' }, + }, + __positionalsSchema: [], + }, + type: 'command', + }, + ], + ]), + globalOptions: { + verbose: { type: 'boolean' }, + }, + }); + + const candidates = getCompletionCandidates(state, 'bash', [ + 'test-cli', + 'build', + '-', + ]); + + expect(candidates, 'to contain', '--verbose'); // global + expect(candidates, 'to contain', '--minify'); // command-specific + }); + }); + + describe('option value completion', () => { + it('returns enum choices when completing option value', () => { + const state = createState({ + globalOptions: { + level: { + choices: ['debug', 'info', 'warn', 'error'], + type: 'enum', + }, + }, + }); + + const candidates = getCompletionCandidates(state, 'bash', [ + 'test-cli', + '--level', + '', + ]); + + expect(candidates, 'to contain', 'debug'); + expect(candidates, 'to contain', 'info'); + expect(candidates, 'to contain', 'warn'); + expect(candidates, 'to contain', 'error'); + }); + + it('returns no candidates for non-enum options (allows file completion)', () => { + const state = createState({ + globalOptions: { + output: { type: 'string' }, + }, + }); + + const candidates = getCompletionCandidates(state, 'bash', [ + 'test-cli', + '--output', + '', + ]); + + expect(candidates, 'to be empty'); + }); + }); + + describe('positional completion', () => { + it('returns enum choices for enum positionals', () => { + const state = createState({ + commands: new Map([ + [ + 'build', + { + cmd: { + __optionsSchema: {}, + __positionalsSchema: [ + { + choices: ['dev', 'prod', 'staging'], + name: 'target', + type: 'enum', + }, + ], + }, + type: 'command', + }, + ], + ]), + }); + + const candidates = getCompletionCandidates(state, 'bash', [ + 'test-cli', + 'build', + '', + ]); + + expect(candidates, 'to contain', 'dev'); + expect(candidates, 'to contain', 'prod'); + expect(candidates, 'to contain', 'staging'); + }); + }); + + describe('edge cases', () => { + it('handles empty words array', () => { + const state = createState({}); + + const candidates = getCompletionCandidates(state, 'bash', []); + + // Should return options or empty, not throw + expect(candidates, 'to be an', 'array'); + }); + + it('handles CLI with no commands or options', () => { + const state = createState({}); + + const candidates = getCompletionCandidates(state, 'bash', [ + 'test-cli', + '', + ]); + + expect(candidates, 'to be an', 'array'); + }); + }); + + describe('nested command completion', () => { + it('returns subcommands when inside a nested command', () => { + // Create nested builder for 'remote' command with 'add' and 'remove' subcommands + const remoteBuilder = createNestedBuilder({ + commands: new Map([ + [ + 'add', + { + cmd: { __optionsSchema: {}, __positionalsSchema: [] }, + description: 'Add a remote', + type: 'command', + }, + ], + [ + 'remove', + { + aliases: ['rm'], + cmd: { __optionsSchema: {}, __positionalsSchema: [] }, + description: 'Remove a remote', + type: 'command', + }, + ], + ]), + name: 'remote', + }); + + const state = createState({ + commands: new Map([ + [ + 'remote', + { + aliases: ['r'], + builder: remoteBuilder, + description: 'Manage remotes', + type: 'nested', + }, + ], + ]), + }); + + // Complete after 'git remote ' + const candidates = getCompletionCandidates(state, 'bash', [ + 'git', + 'remote', + '', + ]); + + expect(candidates, 'to contain', 'add'); + expect(candidates, 'to contain', 'remove'); + expect(candidates, 'to contain', 'rm'); // alias + }); + + it('returns subcommands when using parent alias', () => { + const remoteBuilder = createNestedBuilder({ + commands: new Map([ + [ + 'add', + { + cmd: { __optionsSchema: {}, __positionalsSchema: [] }, + type: 'command', + }, + ], + ]), + name: 'remote', + }); + + const state = createState({ + commands: new Map([ + [ + 'remote', + { + aliases: ['r'], + builder: remoteBuilder, + type: 'nested', + }, + ], + ]), + }); + + // Complete after 'git r ' (using alias) + const candidates = getCompletionCandidates(state, 'bash', [ + 'git', + 'r', + '', + ]); + + expect(candidates, 'to contain', 'add'); + }); + + it('returns options from leaf command inside nested command', () => { + const remoteBuilder = createNestedBuilder({ + commands: new Map([ + [ + 'add', + { + cmd: { + __optionsSchema: { + force: { aliases: ['f'], type: 'boolean' }, + }, + __positionalsSchema: [], + }, + type: 'command', + }, + ], + ]), + name: 'remote', + }); + + const state = createState({ + commands: new Map([ + [ + 'remote', + { + builder: remoteBuilder, + type: 'nested', + }, + ], + ]), + }); + + // Complete after 'git remote add -' + const candidates = getCompletionCandidates(state, 'bash', [ + 'git', + 'remote', + 'add', + '-', + ]); + + expect(candidates, 'to contain', '--force'); + expect(candidates, 'to contain', '-f'); + }); + + it('accumulates global options from parent levels', () => { + const remoteBuilder = createNestedBuilder({ + commands: new Map([ + [ + 'add', + { + cmd: { + __optionsSchema: { + force: { type: 'boolean' }, + }, + __positionalsSchema: [], + }, + type: 'command', + }, + ], + ]), + globalOptions: { + // Nested level global option (use kebab-case directly) + 'dry-run': { aliases: ['n'], type: 'boolean' }, + }, + name: 'remote', + }); + + const state = createState({ + commands: new Map([ + [ + 'remote', + { + builder: remoteBuilder, + type: 'nested', + }, + ], + ]), + // Top-level global option + globalOptions: { + verbose: { aliases: ['v'], type: 'boolean' }, + }, + }); + + // Complete after 'git remote add -' + const candidates = getCompletionCandidates(state, 'bash', [ + 'git', + 'remote', + 'add', + '-', + ]); + + // Should have options from all levels + expect(candidates, 'to contain', '--verbose'); // top-level global + expect(candidates, 'to contain', '-v'); + expect(candidates, 'to contain', '--dry-run'); // nested global + expect(candidates, 'to contain', '-n'); + expect(candidates, 'to contain', '--force'); // command-specific + }); + + it('handles deeply nested commands (3+ levels)', () => { + // git -> stash -> push -> save + const pushBuilder = createNestedBuilder({ + commands: new Map([ + [ + 'save', + { + cmd: { __optionsSchema: {}, __positionalsSchema: [] }, + description: 'Save stash', + type: 'command', + }, + ], + ]), + name: 'push', + }); + + const stashBuilder = createNestedBuilder({ + commands: new Map([ + [ + 'pop', + { + cmd: { __optionsSchema: {}, __positionalsSchema: [] }, + description: 'Pop from stash', + type: 'command', + }, + ], + [ + 'push', + { + builder: pushBuilder, + description: 'Push to stash', + type: 'nested', + }, + ], + ]), + name: 'stash', + }); + + const state = createState({ + commands: new Map([ + [ + 'stash', + { + builder: stashBuilder, + description: 'Stash changes', + type: 'nested', + }, + ], + ]), + }); + + // Complete at first nested level: 'git stash ' + let candidates = getCompletionCandidates(state, 'bash', [ + 'git', + 'stash', + '', + ]); + expect(candidates, 'to contain', 'push'); + expect(candidates, 'to contain', 'pop'); + + // Complete at second nested level: 'git stash push ' + candidates = getCompletionCandidates(state, 'bash', [ + 'git', + 'stash', + 'push', + '', + ]); + expect(candidates, 'to contain', 'save'); + }); + + it('returns positional choices for nested leaf command', () => { + const remoteBuilder = createNestedBuilder({ + commands: new Map([ + [ + 'add', + { + cmd: { + __optionsSchema: {}, + __positionalsSchema: [ + { + choices: ['origin', 'upstream', 'fork'], + name: 'name', + type: 'enum', + }, + ], + }, + type: 'command', + }, + ], + ]), + name: 'remote', + }); + + const state = createState({ + commands: new Map([ + [ + 'remote', + { + builder: remoteBuilder, + type: 'nested', + }, + ], + ]), + }); + + // Complete positional after 'git remote add ' + const candidates = getCompletionCandidates(state, 'bash', [ + 'git', + 'remote', + 'add', + '', + ]); + + expect(candidates, 'to contain', 'origin'); + expect(candidates, 'to contain', 'upstream'); + expect(candidates, 'to contain', 'fork'); + }); + }); +}); diff --git a/test/exports.test.ts b/test/exports.test.ts index 08297fc..62aff87 100644 --- a/test/exports.test.ts +++ b/test/exports.test.ts @@ -8,6 +8,9 @@ import type { CreateOptions, Theme, ThemeColors } from '../src/index.js'; import { createStyler, defaultTheme, themes } from '../src/index.js'; +// TODO: Add tests for each exported function and type. +// TODO: rename as "contract.test.js" or something + describe('public exports', () => { it('exports theme utilities', () => { expect(themes, 'to be defined'); @@ -16,16 +19,14 @@ describe('public exports', () => { }); it('exports theme types (compiles = passes)', () => { - // Type-only test - if this compiles, types are exported - const _theme: Theme = themes.default; - const _colors: ThemeColors = themes.default.colors; - expect(_theme, 'to be defined'); - expect(_colors, 'to be defined'); + const theme: Theme = themes.default; + const colors: ThemeColors = themes.default.colors; + expect(theme, 'to be defined'); + expect(colors, 'to be defined'); }); it('exports CreateOptions type (compiles = passes)', () => { - // Type-only test - if this compiles, CreateOptions is exported - const _opts: CreateOptions = { theme: 'mono', version: '1.0.0' }; - expect(_opts, 'to be defined'); + const opts: CreateOptions = { theme: 'mono', version: '1.0.0' }; + expect(opts, 'to be defined'); }); }); From b073c1b2f9c41890c6be8d4eb0e1b9de8970825d Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Mon, 2 Feb 2026 13:44:13 -0800 Subject: [PATCH 2/4] docs: add shell completion section to README --- README.md | 61 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/README.md b/README.md index 64f3680..e1f4dcf 100644 --- a/README.md +++ b/README.md @@ -644,6 +644,67 @@ Available theme color slots: > [!TIP] > You don't need to specify all color slots. Missing colors fall back to the default theme. +## Shell Completion + +**bargs** can generate shell completion scripts for bash, zsh, and fish. Enable it with the `completion` option: + +```typescript +bargs('my-cli', { + completion: true, + version: '1.0.0', +}); +``` + +Then generate and install the completion script for your shell: + +### Bash + +```bash +# Add to ~/.bashrc (or ~/.bash_profile on macOS) +my-cli --completion-script bash >> ~/.bashrc +source ~/.bashrc +``` + +### Zsh + +```bash +# Add to ~/.zshrc +my-cli --completion-script zsh >> ~/.zshrc +source ~/.zshrc + +# Or save to a file in your $fpath +my-cli --completion-script zsh > ~/.zsh/completions/_my-cli +``` + +### Fish + +```bash +# Save to completions directory +my-cli --completion-script fish > ~/.config/fish/completions/my-cli.fish +``` + +### What Gets Completed + +Once installed, pressing Tab will complete: + +- **Commands and subcommands** (including nested commands and aliases) +- **Options** (`--verbose`, `-v`, `--no-verbose` for booleans) +- **Enum values** for options and positionals with defined choices +- **Global options** at any command level + +```shell +$ my-cli +build test lint + +$ my-cli build --target +dev staging prod + +$ my-cli -- +--verbose --config --help --version +``` + +See `examples/completion.ts` for a complete example. + ## Advanced Usage ### Error Handling From 23c097b28cb378e64821af2993abd30e6840126d Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Mon, 2 Feb 2026 13:48:14 -0800 Subject: [PATCH 3/4] feat(help): show --completion-script in help when completion enabled --- src/bargs.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/bargs.ts b/src/bargs.ts index 150e575..4867786 100644 --- a/src/bargs.ts +++ b/src/bargs.ts @@ -1004,6 +1004,18 @@ const generateCommandHelpNew = ( */ /* c8 ignore start -- only called from help paths that call process.exit() */ const generateHelpNew = (state: InternalCliState, theme: Theme): string => { + // Build options schema, adding --completion-script if completion is enabled + let options = state.globalParser?.__optionsSchema; + if (state.options.completion) { + options = { + ...options, + 'completion-script': { + description: 'Output shell completion script (bash, zsh, fish)', + type: 'string' as const, + }, + }; + } + // Delegate to existing help generator with config including aliases const config = { commands: Object.fromEntries( @@ -1016,7 +1028,7 @@ const generateHelpNew = (state: InternalCliState, theme: Theme): string => { ), description: state.options.description, name: state.name, - options: state.globalParser?.__optionsSchema, + options, version: state.options.version, }; return generateHelp(config as Parameters[0], theme); From 0b17487147fc17adc4ae09d954d3c1c34baeefa7 Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Mon, 2 Feb 2026 13:51:44 -0800 Subject: [PATCH 4/4] fix(help): align option descriptions across varying flag widths Calculate max flag width across all options before formatting, ensuring descriptions start at the same column regardless of individual flag lengths. --- README.md | 2 +- examples/completion.ts | 2 +- src/help.ts | 97 +++++++++++++++++++++++++++++++----------- 3 files changed, 73 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index e1f4dcf..24335f4 100644 --- a/README.md +++ b/README.md @@ -685,7 +685,7 @@ my-cli --completion-script fish > ~/.config/fish/completions/my-cli.fish ### What Gets Completed -Once installed, pressing Tab will complete: +Once installed, pressing `Tab` will complete: - **Commands and subcommands** (including nested commands and aliases) - **Options** (`--verbose`, `-v`, `--no-verbose` for booleans) diff --git a/examples/completion.ts b/examples/completion.ts index e3dd1d9..306dd8f 100755 --- a/examples/completion.ts +++ b/examples/completion.ts @@ -107,7 +107,7 @@ const lintParser = opt.options({ // CLI // ═══════════════════════════════════════════════════════════════════════════════ -await bargs('completion-demo', { +await bargs('completion', { // Enable shell completion support! completion: true, description: 'Example CLI with shell completion support', diff --git a/src/help.ts b/src/help.ts index cdd681a..d2f4335 100644 --- a/src/help.ts +++ b/src/help.ts @@ -186,25 +186,12 @@ const getTypeLabel = (def: OptionDef): string => { }; /** - * Format a single option for help output. - * - * For boolean options with `default: true`, shows `--no-` instead of - * `--` since that's how users would turn it off. - * - * Displays aliases in order: short alias first (-v), then multi-char aliases - * sorted by length (--verb), then the canonical name (--verbose). + * Get the flag text for an option (used for width calculation and display). * * @function */ -const formatOptionHelp = ( - name: string, - def: OptionDef, - styler: Styler, -): string => { - const parts: string[] = []; - +const getOptionFlagText = (name: string, def: OptionDef): string => { // For boolean options with default: true, show --no- - // since that's how users would turn it off const displayName = def.type === 'boolean' && def.default === true ? `no-${name}` : name; @@ -215,7 +202,6 @@ const formatOptionHelp = ( .sort((a, b) => a.length - b.length); // Build flag string: -v, --verb, --verbose - // Don't show short alias for negated booleans const flagParts: string[] = []; if (shortAlias && displayName === name) { flagParts.push(`-${shortAlias}`); @@ -226,14 +212,51 @@ const formatOptionHelp = ( flagParts.push(`--${displayName}`); // If no short alias and no long aliases, add padding - const flagText = - flagParts.length === 1 && !shortAlias - ? ` ${flagParts[0]}` - : flagParts.join(', '); + return flagParts.length === 1 && !shortAlias + ? ` ${flagParts[0]}` + : flagParts.join(', '); +}; + +/** + * Calculate the max flag width for a set of options. + * + * @function + */ +const calculateMaxFlagWidth = ( + options: Array<{ def: OptionDef; name: string }>, +): number => { + let maxWidth = 0; + for (const { def, name } of options) { + const flagText = getOptionFlagText(name, def); + maxWidth = Math.max(maxWidth, flagText.length); + } + return maxWidth; +}; + +/** + * Format a single option for help output. + * + * For boolean options with `default: true`, shows `--no-` instead of + * `--` since that's how users would turn it off. + * + * Displays aliases in order: short alias first (-v), then multi-char aliases + * sorted by length (--verb), then the canonical name (--verbose). + * + * @function + */ +const formatOptionHelp = ( + name: string, + def: OptionDef, + styler: Styler, + maxFlagWidth?: number, +): string => { + const parts: string[] = []; + + const flagText = getOptionFlagText(name, def); parts.push(` ${styler.flag(flagText)}`); - // Pad to align descriptions (increase base padding for longer alias chains) - const basePadding = Math.max(24, flagText.length + 4); + // Pad to align descriptions using provided maxFlagWidth or calculate dynamically + const basePadding = Math.max(24, (maxFlagWidth ?? flagText.length) + 4); const padding = Math.max(0, basePadding - flagText.length - 2); parts.push(' '.repeat(padding)); @@ -354,11 +377,15 @@ export const generateHelp = ( } } + // Calculate max flag width across all visible options for alignment + const allOptions = [...ungrouped, ...Array.from(groups.values()).flat()]; + const maxFlagWidth = calculateMaxFlagWidth(allOptions); + // Print grouped options for (const [groupName, options] of Array.from(groups.entries())) { lines.push(styler.sectionHeader(groupName.toUpperCase())); for (const opt of options) { - lines.push(formatOptionHelp(opt.name, opt.def, styler)); + lines.push(formatOptionHelp(opt.name, opt.def, styler, maxFlagWidth)); } lines.push(''); } @@ -368,7 +395,7 @@ export const generateHelp = ( const label = hasCommands(config) ? 'GLOBAL OPTIONS' : 'OPTIONS'; lines.push(styler.sectionHeader(label)); for (const opt of ungrouped) { - lines.push(formatOptionHelp(opt.name, opt.def, styler)); + lines.push(formatOptionHelp(opt.name, opt.def, styler, maxFlagWidth)); } lines.push(''); } @@ -451,6 +478,24 @@ export const generateCommandHelp = ( lines.push(styler.usage(` ${usageParts}`)); lines.push(''); + // Collect all visible options for alignment calculation + const allOptions: Array<{ def: OptionDef; name: string }> = []; + if (command.options) { + for (const [name, def] of Object.entries(command.options)) { + if (!def.hidden) { + allOptions.push({ def, name }); + } + } + } + if (config.options) { + for (const [name, def] of Object.entries(config.options)) { + if (!def.hidden) { + allOptions.push({ def, name }); + } + } + } + const maxFlagWidth = calculateMaxFlagWidth(allOptions); + // Command options if (command.options && Object.keys(command.options).length > 0) { lines.push(styler.sectionHeader('OPTIONS')); @@ -458,7 +503,7 @@ export const generateCommandHelp = ( if (def.hidden) { continue; } - lines.push(formatOptionHelp(name, def, styler)); + lines.push(formatOptionHelp(name, def, styler, maxFlagWidth)); } lines.push(''); } @@ -470,7 +515,7 @@ export const generateCommandHelp = ( if (def.hidden) { continue; } - lines.push(formatOptionHelp(name, def, styler)); + lines.push(formatOptionHelp(name, def, styler, maxFlagWidth)); } lines.push(''); }