/** * Bash completion generator. Walks a CommandSpec tree and emits a single * self-contained bash function `_` plus the `complete -F _ ` * directive that registers it. The bin name is injected by the caller so the * same generator serves any host CLI. * * Generated script architecture: * 1. `__path()`: walks COMP_WORDS forward, skipping options and option * values, returns the current command-path string (e.g., "anthropic cost"). * 2. `__options()` / `__subcommands()` / `__option_takes_value()` / * `__option_choices()`: table-lookup functions, generated as big * case statements from the spec tree. * 3. `_()`: main entry. Sets COMPREPLY based on whether the cursor is * on a subcommand name, option name (--), or value. * 4. Dynamic value paths shell out to ` __complete -- "$cur"`. * * Important escaping rules: * - Single-quoted strings in bash don't honor escapes. We replace `'` in * descriptions/choices with `'\''` (the standard bash single-quote escape). * - We avoid descriptions in bash output because bash COMPREPLY can't render * them; descriptions are a zsh/fish feature. */ import type { CommandSpec, OptionSpec, PositionalSpec } from "./walk.js"; /** * Derive a valid shell function-name prefix from the bin name (e.g. `my-cli` * → `_my_cli`). Non-identifier characters collapse to `_`. */ function fnPrefix(binName: string): string { return `_${binName.replace(/[^a-zA-Z0-9_]/g, "_")}`; } /** * Encode a positional/option value source as a single string the bash driver * can parse: `DYN:` for dynamic, `ENUM:val1 val2 val3` for static * choices, or empty when there's nothing to suggest. */ function valueSpec(opt: { dynamicProvider?: string; valueChoices?: string[] }): string { if (opt.dynamicProvider) return `DYN:${opt.dynamicProvider}`; if (opt.valueChoices && opt.valueChoices.length > 0) { return `ENUM:${opt.valueChoices.join(" ")}`; } return ""; } export function generateBash(root: CommandSpec, binName: string): string { const fn = fnPrefix(binName); // Build lookup tables keyed by command path. Path "" represents the root. const subcommandsByPath = new Map(); const optionsByPath = new Map(); const positionalsByPath = new Map(); const walk = (s: CommandSpec): void => { subcommandsByPath.set(s.path, s.subcommands.map((c) => c.name).sort()); optionsByPath.set(s.path, s.options); positionalsByPath.set(s.path, s.positionals); for (const sub of s.subcommands) walk(sub); }; walk(root); const out: string[] = []; out.push( `# ${binName} bash completion, generated by \`${binName} completion bash\`. Do not edit.`, ); out.push(`# Source via: eval "$(${binName} completion bash)"`); out.push(""); out.push(`${fn}_subcommands() {`); out.push(` case "$1" in`); for (const [path, names] of [...subcommandsByPath.entries()].sort()) { if (names.length === 0) continue; out.push(` ${bashCase(path)}) echo '${names.join(" ")}' ;;`); } out.push(" esac"); out.push("}"); out.push(""); out.push(`${fn}_options() {`); out.push(` case "$1" in`); for (const [path, opts] of [...optionsByPath.entries()].sort()) { if (opts.length === 0) continue; const flags = opts.flatMap((o) => [o.long, o.short]).filter((f) => f.length > 0); out.push(` ${bashCase(path)}) echo '${flags.join(" ")}' ;;`); } out.push(" esac"); out.push("}"); out.push(""); out.push(`${fn}_option_takes_value() {`); out.push( ` # echo "1" if option takes a value, empty otherwise. $1 = command path, $2 = option flag.`, ); out.push(` case "$1|$2" in`); for (const [path, opts] of [...optionsByPath.entries()].sort()) { for (const o of opts) { if (!o.takesValue) continue; for (const flag of [o.long, o.short].filter((f) => f.length > 0)) { out.push(` ${bashCase(path)}\\|${bashEscape(flag)}) echo 1 ;;`); } } } out.push(" esac"); out.push("}"); out.push(""); out.push(`${fn}_option_completion() {`); out.push(` # Echo "DYN:" or "ENUM:val1 val2 ...". Empty if no spec.`); out.push(` case "$1|$2" in`); for (const [path, opts] of [...optionsByPath.entries()].sort()) { for (const o of opts) { if (!o.takesValue) continue; const spec = valueSpec(o); if (!spec) continue; for (const flag of [o.long, o.short].filter((f) => f.length > 0)) { out.push(` ${bashCase(path)}\\|${bashEscape(flag)}) echo '${bashEscape(spec)}' ;;`); } } } out.push(" esac"); out.push("}"); out.push(""); out.push(`${fn}_positional_completion() {`); out.push( ` # Echo "DYN:" or "ENUM:val1 val2 ...". $1 = command path, $2 = positional index.`, ); out.push(` case "$1|$2" in`); for (const [path, positionals] of [...positionalsByPath.entries()].sort()) { positionals.forEach((p, i) => { const spec = valueSpec(p); if (!spec) return; out.push(` ${bashCase(path)}\\|${i}) echo '${bashEscape(spec)}' ;;`); }); } out.push(" esac"); out.push("}"); out.push(""); out.push(bashDriver(fn, binName)); out.push(""); out.push(`complete -F ${fn} ${binName}`); return `${out.join("\n")}\n`; } /** * Converts a command path to a bash `case` pattern. Empty path → "ROOT". * Replace spaces with `__` since bash case patterns don't tolerate spaces well * inside an unquoted pattern. The driver substitutes the same way. */ function bashCase(path: string): string { if (path === "") return "ROOT"; return path.replace(/ /g, "__"); } /** * Escape a string for safe embedding in a single-quoted bash string. Inside * single quotes the only character that needs escaping is the single quote * itself, which we close, escape, and reopen: `'\''`. */ function bashEscape(s: string): string { return s.replace(/'/g, `'\\''`); } /** * The driver: the parts of the completion that don't depend on the command * tree. Walks COMP_WORDS to determine the current command path, then dispatches * to subcommand / option / value completion. `fn` is the function-name prefix * and `binName` is the CLI name used for the `__complete` callback. */ /** * DYNAMIC bash completion: a thin, tree-independent shim. Instead of baking the * command tree into case-tables (which go stale on any command/flag change), * it hands the live command line to ` __complete-line` on every and * lets the binary — which always knows its own current tree — answer. Install * once; never regenerate. The trailing `\x1f:` line carries the directive * bitmask (bit 0 = fall back to file completion). */ export function generateBashDynamic(binName: string): string { const fn = fnPrefix(binName); return `# ${binName} bash completion (dynamic; install once, never stale). Do not edit. # Source via: eval "$(${binName} completion bash --dynamic)" ${fn}() { local cur="\${COMP_WORDS[COMP_CWORD]}" local raw line directive=0 local -a cands=() raw="$(${binName} __complete-line "$COMP_CWORD" -- "\${COMP_WORDS[@]}" 2>/dev/null)" || return while IFS= read -r line; do [ -z "$line" ] && continue if [ "\${line:0:2}" = $'\\x1f:' ]; then directive="\${line:2}" else cands+=( "\${line%%$'\\t'*}" ) fi done <<< "$raw" if (( directive & 1 )); then COMPREPLY=( $(compgen -f -- "$cur") ) return fi local IFS=$'\\n' COMPREPLY=( $(compgen -W "\${cands[*]}" -- "$cur") ) } complete -F ${fn} ${binName} `; } function bashDriver(fn: string, binName: string): string { return `${fn}() { local cur prev words cword COMPREPLY=() cur="\${COMP_WORDS[COMP_CWORD]}" prev="\${COMP_WORDS[COMP_CWORD-1]}" # Walk forward through words, skipping options and option-values, to # determine which command path we're currently inside. Stop when we hit # COMP_CWORD (the position the cursor is on). local -a path_parts=() local i=1 local path_str="ROOT" while [ $i -lt $COMP_CWORD ]; do local w="\${COMP_WORDS[$i]}" case "$w" in -*) # Option. If it takes a value, skip the next word too. if [ "$(${fn}_option_takes_value "$path_str" "$w")" = "1" ]; then i=$((i+1)) fi ;; *) path_parts+=("$w") local joined joined=$(printf '%s__' "\${path_parts[@]}") joined="\${joined%__}" # If this word is a known subcommand at our current path, descend. local known known=$(${fn}_subcommands "$path_str") if [[ " $known " == *" $w "* ]]; then path_str="$joined" fi ;; esac i=$((i+1)) done # --- decide what we're completing ---------------------------------------- # Case 1: previous word is an option that takes a value → complete that value. if [ -n "$prev" ] && [ "$(${fn}_option_takes_value "$path_str" "$prev")" = "1" ]; then local spec spec=$(${fn}_option_completion "$path_str" "$prev") ${fn}_emit_values "$spec" "$cur" return fi # Case 2: current word starts with - → complete option name. if [[ "$cur" == -* ]]; then local opts opts=$(${fn}_options "$path_str") if [ -n "$opts" ]; then COMPREPLY=( $(compgen -W "$opts" -- "$cur") ) fi return fi # Case 3: complete subcommand name (and any positional values for this command). local subs subs=$(${fn}_subcommands "$path_str") # How many positional args have we accumulated for the current path? Count # path_parts entries beyond the part that matched a subcommand. local pos_index=0 if [ -n "$subs" ]; then # If subs are still available here, the cursor is on a subcommand slot. COMPREPLY=( $(compgen -W "$subs" -- "$cur") ) return fi # No more subcommands, we're on a positional. Determine which positional # index we're on by counting non-option words after the last matched subcommand. local after_cmd=0 local j=1 local seen_path="ROOT" while [ $j -lt $COMP_CWORD ]; do local w="\${COMP_WORDS[$j]}" case "$w" in -*) if [ "$(${fn}_option_takes_value "$seen_path" "$w")" = "1" ]; then j=$((j+1)) fi ;; *) local joined if [ "$seen_path" = "ROOT" ]; then joined="$w"; else joined="\${seen_path}__\${w}"; fi local known known=$(${fn}_subcommands "$seen_path") if [[ " $known " == *" $w "* ]]; then seen_path="$joined" else after_cmd=$((after_cmd+1)) fi ;; esac j=$((j+1)) done pos_index=$after_cmd local pos_spec pos_spec=$(${fn}_positional_completion "$path_str" "$pos_index") if [ -n "$pos_spec" ]; then ${fn}_emit_values "$pos_spec" "$cur" return fi # Fallback: file completion. COMPREPLY=( $(compgen -f -- "$cur") ) } ${fn}_emit_values() { local spec="$1" local cur="$2" if [[ "$spec" == DYN:* ]]; then local provider="\${spec#DYN:}" local values values=$(${binName} __complete "$provider" -- "$cur" 2>/dev/null) if [ -n "$values" ]; then COMPREPLY=( $(compgen -W "$values" -- "$cur") ) fi elif [[ "$spec" == ENUM:* ]]; then local choices="\${spec#ENUM:}" COMPREPLY=( $(compgen -W "$choices" -- "$cur") ) fi }`; }