diff --git a/AGENTS.md b/AGENTS.md index fdb9c72..ee90ad3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,7 +29,8 @@ src/ │ ├── suggest.ts # Core suggestion flow: diff → LLM → display → optional commit │ ├── history.ts # View learned style profile and recent commit history │ ├── config.ts # View current config -│ └── batch.ts # Process multiple git repos in batch mode +│ ├── batch.ts # Process multiple git repos in batch mode +│ └── completion.ts # Generate shell completion scripts (bash, zsh, fish) ├── providers/ │ ├── index.ts # Provider factory: createProvider(), complete(), completeStream(), fetchModels() │ ├── registry.ts # BUILTIN_PROVIDERS array (OpenAI, Anthropic, Google, Mistral, etc.) @@ -77,6 +78,7 @@ src/ | `commit-echo history --json` | Output history as JSON | | `commit-echo config` | View current configuration | | `commit-echo batch [dir]` | Process multiple repos (add `--recursive` for nested) | +| `commit-echo completion` | Generate shell completion scripts (bash, zsh, fish) | Global flags: `--yes`/`--auto` (auto-accept first suggestion), `--no-color`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 231d5be..a0753ca 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,7 +36,7 @@ src/ ├── index.ts # CLI entry point (Commander) ├── types.ts # Shared types -├── commands/ # Command implementations (init, suggest, history) +├── commands/ # Command implementations (init, suggest, history, completion, batch) ├── config/ # Config persistence ├── git/ # Git operations (diff) ├── history/ # History JSONL + style learner diff --git a/src/commands/completion.ts b/src/commands/completion.ts new file mode 100644 index 0000000..f983c18 --- /dev/null +++ b/src/commands/completion.ts @@ -0,0 +1,540 @@ +import pc from 'picocolors'; + +export type SupportedShell = 'bash' | 'zsh' | 'fish'; + +const VALID_SHELLS: readonly SupportedShell[] = ['bash', 'zsh', 'fish']; + +/** A single subcommand option. `value` is set for options that take an argument. */ +interface SubcommandOption { + /** Long flag, including any leading dashes (e.g. `--model`). */ + flag: string; + /** Optional short alias, including leading dashes (e.g. `-m`). */ + short?: string; + /** Human-readable description shown in completion tooltips. */ + description: string; + /** When set, the option consumes the next token; completion is suppressed for that slot. */ + value?: string; +} + +interface Subcommand { + name: string; + description: string; + options: SubcommandOption[]; +} + +/** + * Single source of truth for completion metadata. The three shell scripts are + * generated from this structure, so adding a subcommand or option only requires + * editing one place. + * + * **Sync requirement**: this array must mirror the Commander definitions in + * `src/index.ts`. When adding or changing a subcommand or option in the CLI, + * update this array at the same time. The test + * "completion scripts contain all options from every subcommand help" catches + * drift by comparing each subcommand's --help output against the generated + * scripts. + * + * Note: the `hook` subcommand is registered on the root program (see + * src/index.ts) with `{ hidden: true }` because it is invoked by Git, not + * humans. It is intentionally omitted from completion so users do not see it. + */ +const SUBCOMMANDS: readonly Subcommand[] = [ + { + name: 'init', + description: 'Run interactive setup wizard', + options: [ + { flag: '--install-hook', description: 'Install a prepare-commit-msg hook in the current repository' }, + { flag: '--help', description: 'Display help for init' }, + ], + }, + { + name: 'config', + description: 'View current configuration', + options: [{ flag: '--help', description: 'Display help for config' }], + }, + { + name: 'suggest', + description: 'Generate commit suggestions', + options: [ + { flag: '--commit', description: 'Commit the selected suggestion' }, + { flag: '--yes', short: '-y', description: 'Automatically select the first suggestion and skip prompts' }, + { flag: '--verbose', short: '-v', description: 'Print diagnostic information about the suggestion request' }, + { flag: '--model', short: '-m', description: 'Override the configured LLM model', value: 'model' }, + { flag: '--stream', description: 'Stream suggestions as they are generated' }, + { flag: '--dry-run', short: '-n', description: 'Show the LLM input without generating suggestions' }, + { flag: '--no-commit', description: 'Deprecated alias' }, + { flag: '--auto', description: 'Alias for --yes' }, + { flag: '--help', description: 'Display help for suggest' }, + ], + }, + { + name: 'history', + description: 'View learned style profile and recent commit history', + options: [ + { flag: '--json', description: 'Output the style profile and recent commits as JSON' }, + { flag: '--help', description: 'Display help for history' }, + ], + }, + { + name: 'batch', + description: 'Process multiple git repositories in batch mode', + options: [ + { flag: '--recursive', short: '-r', description: 'Recursively search subdirectories for git repos' }, + { flag: '--yes', short: '-y', description: 'Automatically accept the first suggestion and commit without prompts' }, + { flag: '--auto', description: 'Alias for --yes' }, + { flag: '--help', description: 'Display help for batch' }, + ], + }, + { + name: 'completion', + description: 'Generate shell completion scripts', + options: [{ flag: '--help', description: 'Display help for completion' }], + }, + { + name: 'help', + description: 'Display help for a command', + options: [], + }, +] as const; + +const GLOBAL_OPTIONS: readonly SubcommandOption[] = [ + { flag: '--yes', description: 'Automatically accept the first suggestion and commit without prompts' }, + { flag: '--auto', description: 'Alias for --yes' }, + { flag: '--no-color', description: 'Disable colored output' }, + { flag: '--version', description: 'Output the version' }, + { flag: '--help', description: 'Display help' }, +]; + +const SUBCOMMAND_NAMES: readonly string[] = SUBCOMMANDS.map((s) => s.name); +const SHELL_NAMES_LIST: string = VALID_SHELLS.join(' '); +const VALUE_TAKING_FLAGS: ReadonlySet = new Set( + SUBCOMMANDS.flatMap((s) => s.options.filter((o) => o.value).map((o) => o.flag)), +); + +/** Returns all flag forms (short + long) for an option, long form first. */ +function allFlags(o: SubcommandOption): string[] { + return o.short ? [o.flag, o.short] : [o.flag]; +} + +/** Escapes single quotes for safe use inside zsh single-quoted strings. */ +function zshEscape(s: string): string { + return s.replace(/'/g, "'\\''"); +} + +/** Joins lines with ` \\` continuation, omitting the trailing backslash on the last line. */ +function joinContinuationLines(lines: string[]): string { + if (lines.length <= 1) return lines.join(''); + return lines.slice(0, -1).map((l) => `${l} \\`).join('\n') + '\n' + lines[lines.length - 1]; +} + +/* --------------------------------------------------------------------------- + * Bash script generation + * ------------------------------------------------------------------------- */ + +/** Generates a bash completion script using `complete -F` with per-subcommand option cases. */ +function generateBashScript(): string { + const subcommandList = [...SUBCOMMAND_NAMES].join(' '); + const globalOpts = GLOBAL_OPTIONS.map((o) => allFlags(o).join(' ')).join(' '); + + // Per-subcommand option cases for `merged_opts` (long + short forms). + const optionCases = SUBCOMMANDS.filter((s) => s.options.length > 0) + .map( + (s) => + ` ${s.name})\n merged_opts="\${merged_opts} ${s.options.map((o) => allFlags(o).join(' ')).join(' ')}"\n ;;`, + ) + .join('\n'); + + // Flags that consume a value; skip completion after one of these. + // Rendered as a `case` pattern list so we don't depend on extglob. + // Also match the glued form: `--model=gpt-4` (anything after `=` is the value). + const valueFlagCases = [...VALUE_TAKING_FLAGS] + .flatMap((f) => [`${f}) return 0 ;;`, `${f}=*) return 0 ;;`]) + .map((line) => ` ${line}`) + .join('\n'); + + return `#!/usr/bin/env bash +# bash completion for commit-echo -*- shell-script -*- + +_commit_echo() +{ + local cur commands + COMPREPLY=() + cur="\${COMP_WORDS[COMP_CWORD]}" + commands="${subcommandList}" + + # Global options + local global_opts="${globalOpts}" + + # Find the first non-option word as the subcommand + local subcmd="" + local i + for ((i=1; i ` '${s.name}:${zshEscape(s.description)}'`).join(' \\\n'); + // Global flags available before any subcommand. Zsh supports multiple specs + // per option, so short and long forms can be listed together: `-y[...]:--yes`. + const globalArgs = GLOBAL_OPTIONS.flatMap((o) => { + const specs = [` '${o.flag}[${zshEscape(o.description)}]'`]; + if (o.short) specs.push(` '${o.short}[${zshEscape(o.description)}]'`); + return specs; + }).join(' \\\n'); + + // Per-subcommand _arguments block (emitted into the `args` state below). + const subcommandBlocks = SUBCOMMANDS.map((s) => { + const optionLines = s.options + .map((o) => { + const valuePart = o.value ? `:${o.value}:` : ''; + if (o.short) { + const shortValuePart = o.value ? `:${o.value}:` : ''; + return ` '${o.short}[${zshEscape(o.description)}]${shortValuePart}' \\\n '${o.flag}[${zshEscape(o.description)}]${valuePart}' \\`; + } + return ` '${o.flag}[${zshEscape(o.description)}]${valuePart}' \\`; + }) + .join('\n'); + // The completion subcommand takes a positional shell argument. + // The batch subcommand takes an optional directory argument. + const positionalArg = + s.name === 'completion' + ? ` \\\n '1:shell:(${SHELL_NAMES_LIST})'` + : s.name === 'batch' + ? ` \\\n '1::directory:_files -/'` + : ''; + // Skip _arguments entirely when there are no options and no positional arg. + if (!optionLines && !positionalArg) { + return ` ${s.name}) + ;; +`; + } + return ` ${s.name}) + _arguments \\ +${optionLines}${positionalArg} + ;;`; + }).join('\n'); + + return `#compdef commit-echo +# zsh completion for commit-echo -*- shell-script -*- + +_commit_echo() { + local -a commands + commands=( +${commands} + ) + + # Two-state completion machine: + # - command state: offer the top-level subcommand list. + # - args state: dispatch into the per-subcommand _arguments block. + _arguments -C \\ +${globalArgs} + '1:command:->command' \\ + '*::args:->args' + + case \$state in + command) + _describe 'command' commands + ;; + args) + # Derive the first non-option argument as the subcommand + local subcmd='' + local w + for w in \$words[1,-1]; do + case \$w in + -*) ;; + *) subcmd=\$w; break ;; + esac + done + case \$subcmd in +${subcommandBlocks} + esac + ;; + esac +} + +compdef _commit_echo commit-echo +`; +} + +/* --------------------------------------------------------------------------- + * Fish script generation + * ------------------------------------------------------------------------- */ + +/** Generates a fish completion script using `complete -c` with helper functions for subcommands and options. */ +function generateFishScript(): string { + const subcommandListLines = SUBCOMMANDS.map( + (s) => ` "${s.name}\\t${s.description}"`, + ); + const subcommandList = + subcommandListLines.slice(0, -1).map((l) => `${l} \\`).join('\n') + + '\n' + + subcommandListLines[subcommandListLines.length - 1]; + + const optionCases = SUBCOMMANDS.map((s) => { + const rawOptionLines = s.options.flatMap((o) => { + const lines = [` "${o.flag}\\t${o.description}"`]; + if (o.short) lines.push(` "${o.short}\\t${o.description}"`); + return lines; + }); + // The completion subcommand offers shell names as positional completions. + if (s.name === 'completion') { + for (const sh of VALID_SHELLS) { + rawOptionLines.push(` "${sh}\\t${sh} completion script"`); + } + } + const joined = joinContinuationLines(rawOptionLines); + return ` case ${s.name} + printf "%s\\n" \\ +${joined}`; + }).join('\n'); + + const globalFishOptsLines = GLOBAL_OPTIONS.flatMap((o) => { + const lines = [` "${o.flag}\\t${o.description}"`]; + if (o.short) lines.push(` "${o.short}\\t${o.description}"`); + return lines; + }); + const globalFishOpts = joinContinuationLines(globalFishOptsLines); + + // Value-taking flags, with their short forms (if any) and the glued + // --flag=value form. We also include each short form (e.g. '-m') so that + // 'commit-echo suggest -m ' doesn't try to complete the model value. + const valueFlagPatterns = [...VALUE_TAKING_FLAGS].flatMap((f) => { + const opt = SUBCOMMANDS.flatMap((s) => s.options).find((o) => o.flag === f); + const forms = opt?.short ? [opt.short, f] : [f]; + return forms.flatMap((form) => [form, `${form}=*`]); + }); + const valueFlags = valueFlagPatterns.map((f) => `'${f}'`).join(' '); + + return `# fish completion for commit-echo -*- shell-script -*- + +# Helper: complete options for a given subcommand +function __commit_echo_complete_options + set -l subcmd $argv[1] + switch $subcmd +${optionCases} + end +end + +function __commit_echo_subcommands + printf "%s\\n" \\ +${subcommandList} +end + +function __commit_echo_completions + set -l cmd (commandline -opc) + set -l argc (count $cmd) + + # If we are at position 1 (just the program name), suggest subcommands + if test $argc -eq 1 + __commit_echo_subcommands + return + end + + # If the previous token is a flag with a value (like --model), don't complete + switch $cmd[-1] + case ${valueFlags} + return + end + + # Find the first non-option token as the subcommand + set -l subcmd "" + for token in $cmd[2..-1] + if not string match -q -- '-*' $token + set subcmd $token + break + end + end + + # If no subcommand selected yet, suggest subcommands or global options + set -l token (commandline -ct) + if test -z "$subcmd" + if string match -q -- '-*' $token + printf "%s\\n" \\ +${globalFishOpts} + else + __commit_echo_subcommands + end + return + end + + # If the current token starts with -, suggest global + subcommand options + if string match -q -- '-*' $token + # Global options + printf "%s\\n" \\ +${globalFishOpts} + # Subcommand options + __commit_echo_complete_options $subcmd + return + end + + # If we have a subcommand with non-option completions, delegate + if test "$subcmd" = "completion" + __commit_echo_complete_options $subcmd + end + if test "$subcmd" = "batch" + set -l has_positional 0 + set -l found_subcmd 0 + for token in $cmd[2..-1] + if test $found_subcmd -eq 0 + if not string match -q -- '-*' $token + set found_subcmd 1 + end + else + if not string match -q -- '-*' $token + set has_positional 1 + break + end + end + end + if test $has_positional -eq 0 + __fish_complete_directories + end + end +end + +complete -c commit-echo -f -a '(__commit_echo_completions)' +`; +} + +/** Returns the completion script for the given shell. */ +function getCompletionScript(shell: SupportedShell): string { + switch (shell) { + case 'bash': + return generateBashScript(); + case 'zsh': + return generateZshScript(); + case 'fish': + return generateFishScript(); + } +} + +/** Type guard: returns true if `s` is a supported shell name. */ +function isSupportedShell(s: string): s is SupportedShell { + return (VALID_SHELLS as readonly string[]).includes(s); +} + +/** + * Returns true if color output should be enabled. + * + * Follows the de-facto CLI convention: `NO_COLOR` (any value, including empty) + * disables color per https://no-color.org/; `FORCE_COLOR` (any value) forces + * it on; otherwise, the `--no-color` CLI flag is honored. + */ +function shouldUseColor(argv: readonly string[]): boolean { + if (process.env.NO_COLOR !== undefined) return false; + if (process.env.FORCE_COLOR !== undefined) return true; + return !argv.includes('--no-color'); +} + +/** Prints a help message showing available shell targets. */ +function printHelp(useColor: boolean): void { + const bold = useColor ? pc.bold : (s: string) => s; + const cyan = useColor ? pc.cyan : (s: string) => s; + const green = useColor ? pc.green : (s: string) => s; + const dim = useColor ? pc.dim : (s: string) => s; + + console.log(bold('Generate shell completion scripts for commit-echo.\n')); + console.log(cyan('Usage:')); + console.log(' commit-echo completion \n'); + console.log(cyan('Supported shells:')); + console.log(` ${green('bash')} - Bash completion script`); + console.log(` ${green('zsh')} - Zsh completion script`); + console.log(` ${green('fish')} - Fish completion script\n`); + console.log(cyan('Examples:')); + console.log(` ${dim('# Bash (system-wide, recommended)')}`); + console.log(' sudo commit-echo completion bash > /etc/bash_completion.d/commit-echo\n'); + console.log(` ${dim('# Bash (per-user, requires bash-completion on PATH)')}`); + console.log(' commit-echo completion bash > ~/.local/share/bash-completion/completions/commit-echo\n'); + console.log(` ${dim('# Zsh')}`); + console.log(' commit-echo completion zsh > ~/.zfunc/_commit-echo\n'); + console.log(` ${dim('# Fish')}`); + console.log(' commit-echo completion fish > ~/.config/fish/completions/commit-echo.fish'); +} + +/** The completion command action: outputs the requested shell script. */ +export function completionCommand(shell?: string): void { + const useColor = shouldUseColor(process.argv); + + if (!shell) { + printHelp(useColor); + return; + } + + const normalized = shell.toLowerCase(); + + if (!isSupportedShell(normalized)) { + const error = `Unsupported shell: "${shell}". Supported shells: ${VALID_SHELLS.join(', ')}`; + console.error(useColor ? pc.red(error) : error); + process.exit(1); + } + + process.stdout.write(getCompletionScript(normalized)); +} diff --git a/src/index.ts b/src/index.ts index 2667d0f..5271fc7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { suggestCommand } from './commands/suggest.js'; import { historyCommand } from './commands/history.js'; import { configCommand } from './commands/config.js'; import { batchCommand } from './commands/batch.js'; +import { completionCommand } from './commands/completion.js'; import { getAvailableTemplateVars } from './llm/prompt.js'; import { runPostCommitHook, runPrepareCommitMsgHook } from './git/hook.js'; @@ -46,6 +47,7 @@ ${pc.dim('Examples:')} ${pc.cyan('commit-echo batch')} Process all git repos in current directory ${pc.cyan('commit-echo batch --recursive')} Search subdirectories for repos ${pc.cyan('commit-echo batch --yes')} Auto-commit repos with staged changes + ${pc.cyan('commit-echo completion')} Generate shell completion scripts ${pc.dim('Custom prompt template variables:')} ${getAvailableTemplateVars() @@ -114,6 +116,14 @@ program }); }); +program + .command('completion') + .description('Generate shell completion scripts for bash, zsh, and fish') + .argument('[shell]', 'Target shell: bash, zsh, or fish') + .action((shell?: string) => { + completionCommand(shell); + }); + const hookCommand = new Command('hook') .description('Internal Git hook entry point') .argument('', 'Git hook name') diff --git a/tests/completion-command.test.mjs b/tests/completion-command.test.mjs new file mode 100644 index 0000000..8c49967 --- /dev/null +++ b/tests/completion-command.test.mjs @@ -0,0 +1,334 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { unlink, writeFile } from 'node:fs/promises'; +import test from 'node:test'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); + +async function runCompletion(args = []) { + return execFileAsync(process.execPath, ['dist/index.js', '--no-color', 'completion', ...args], { + env: { ...process.env, NO_COLOR: '1' }, + }); +} + +test('completion with no arguments prints help message', async () => { + const { stdout } = await runCompletion([]); + assert.match(stdout, /bash/); + assert.match(stdout, /zsh/); + assert.match(stdout, /fish/); + assert.match(stdout, /Usage:/); +}); + +test('completion bash outputs a bash completion script', async () => { + const { stdout } = await runCompletion(['bash']); + assert.match(stdout, /complete -F _commit_echo commit-echo/); + assert.match(stdout, /_commit_echo\(\)/); +}); + +test('completion zsh outputs a zsh completion script', async () => { + const { stdout } = await runCompletion(['zsh']); + assert.match(stdout, /#compdef commit-echo/); + assert.match(stdout, /_commit_echo\(\)/); +}); + +test('completion fish outputs a fish completion script', async () => { + const { stdout } = await runCompletion(['fish']); + assert.match(stdout, /complete -c commit-echo/); + assert.match(stdout, /__commit_echo_completions/); +}); + +test('completion bash script includes all subcommands', async () => { + const { stdout } = await runCompletion(['bash']); + const expectedSubcommands = ['init', 'config', 'suggest', 'history', 'batch', 'completion', 'help']; + for (const subcmd of expectedSubcommands) { + assert.ok(stdout.includes(subcmd), `Expected stdout to contain subcommand: ${subcmd}`); + } +}); + +test('completion zsh script includes all subcommands', async () => { + const { stdout } = await runCompletion(['zsh']); + const expectedSubcommands = ['init', 'config', 'suggest', 'history', 'batch', 'completion', 'help']; + for (const subcmd of expectedSubcommands) { + assert.ok(stdout.includes(subcmd), `Expected stdout to contain subcommand: ${subcmd}`); + } +}); + +test('completion fish script includes all subcommands', async () => { + const { stdout } = await runCompletion(['fish']); + const expectedSubcommands = ['init', 'config', 'suggest', 'history', 'batch', 'completion', 'help']; + for (const subcmd of expectedSubcommands) { + assert.ok(stdout.includes(subcmd), `Expected stdout to contain subcommand: ${subcmd}`); + } +}); + +test('completion prints error and exits for unsupported shell', async () => { + try { + await runCompletion(['powershell']); + assert.fail('Expected process to exit with error'); + } catch (err) { + assert.match(err.stderr || '', /Unsupported shell/); + assert.match(err.stderr || '', /powershell/); + } +}); + +test('completion bash is case-insensitive for shell name', async () => { + const { stdout } = await runCompletion(['BASH']); + assert.match(stdout, /complete -F _commit_echo commit-echo/); +}); + +test('completion zsh is case-insensitive for shell name', async () => { + const { stdout } = await runCompletion(['ZSH']); + assert.match(stdout, /#compdef commit-echo/); +}); + +test('completion fish is case-insensitive for shell name', async () => { + const { stdout } = await runCompletion(['FISH']); + assert.match(stdout, /complete -c commit-echo/); +}); + +test('completion bash script includes global options', async () => { + const { stdout } = await runCompletion(['bash']); + assert.match(stdout, /--yes/); + assert.match(stdout, /--auto/); + assert.match(stdout, /--no-color/); +}); + +test('completion zsh script includes suggest subcommand options', async () => { + const { stdout } = await runCompletion(['zsh']); + // All nine options registered for `suggest` (long forms) must be present + // so that tab-completion covers every flag the CLI accepts. + const expected = ['--commit', '--yes', '--verbose', '--model', '--stream', '--dry-run', '--no-commit', '--auto', '--help']; + for (const opt of expected) { + assert.ok(stdout.includes(`'${opt}[`), `Expected zsh script to include option: ${opt}`); + } + // And the value-taking marker for --model + assert.match(stdout, /'--model\[[^\]]+\]:model:'/); +}); + +test('completion fish script includes global options', async () => { + const { stdout } = await runCompletion(['fish']); + assert.match(stdout, /--yes/); + assert.match(stdout, /--auto/); + assert.match(stdout, /--no-color/); +}); + +test('completion fish script includes suggest subcommand options', async () => { + const { stdout } = await runCompletion(['fish']); + const expected = ['--commit', '--yes', '--verbose', '--model', '--stream', '--dry-run', '--no-commit', '--auto', '--help']; + for (const opt of expected) { + assert.ok(stdout.includes(`"${opt}\\t`), `Expected fish script to include option: ${opt}`); + } +}); + +test('completion --help shows command usage', async () => { + const { stdout } = await runCompletion(['--help']); + assert.match(stdout, /Usage: commit-echo completion/); + assert.match(stdout, /Target shell: bash, zsh, or fish/); +}); + +test('completion bash script includes short flag aliases', async () => { + const { stdout } = await runCompletion(['bash']); + // Short aliases for suggest: -y, -v, -m, -n + assert.match(stdout, /-y/); + assert.match(stdout, /-v/); + assert.match(stdout, /-m/); + // Short alias for batch: -r + assert.match(stdout, /-r/); +}); + +test('completion zsh script includes short flag aliases', async () => { + const { stdout } = await runCompletion(['zsh']); + // Zsh emits short forms as separate specs: `'-y[desc]'` for boolean flags + // and `'-m[desc]:model:'` for value-taking flags. + assert.match(stdout, /'-y\[/); + assert.match(stdout, /'-y\[[^\]]+\]'/); + assert.match(stdout, /'-m\[/); + assert.match(stdout, /'-m\[[^\]]+\]:model:'/); +}); + +test('completion fish script includes short flag aliases', async () => { + const { stdout } = await runCompletion(['fish']); + // Fish prints both forms as separate `printf` lines under the subcommand case. + assert.match(stdout, /"-y\\t/); + assert.match(stdout, /"-v\\t/); + assert.match(stdout, /"-m\\t/); +}); + +test('completion scripts suggest shell names for the completion subcommand', async () => { + // Bash: compgen -W "bash zsh fish" in the completion case + const { stdout: bashScript } = await runCompletion(['bash']); + assert.match(bashScript, /completion\)[\s\S]*compgen -W.*bash.*zsh.*fish/); + // Zsh: '1:shell:(bash zsh fish)' positional arg + const { stdout: zshScript } = await runCompletion(['zsh']); + assert.match(zshScript, /'1:shell:\(bash zsh fish\)'/); + // Fish: printf lines for each shell name in the completion case + const { stdout: fishScript } = await runCompletion(['fish']); + assert.match(fishScript, /case completion[\s\S]*"bash\\t/); + assert.match(fishScript, /case completion[\s\S]*"zsh\\t/); + assert.match(fishScript, /case completion[\s\S]*"fish\\t/); +}); + +test('completion bash script handles --flag=value glued form', async () => { + const { stdout } = await runCompletion(['bash']); + // After a value-taking flag in `--flag=value` form, completion should also bail out. + assert.match(stdout, /--model=\*\) return 0/); +}); + +test('completion bash script guards value-taking flags like --model', async () => { + const { stdout } = await runCompletion(['bash']); + // The bash script uses a `case` statement (not extglob) to bail out after --model + assert.match(stdout, /case "\$\{COMP_WORDS\[COMP_CWORD-1\]\}"/); + assert.match(stdout, /--model\) return 0/); +}); + +test('completion zsh script marks --model as value-taking', async () => { + const { stdout } = await runCompletion(['zsh']); + assert.match(stdout, /'--model\[[^\]]+\]:model:'/); +}); + +test('completion fish script guards value-taking flags like --model', async () => { + const { stdout } = await runCompletion(['fish']); + // The fish script guards both the long and short forms, plus the glued --flag=value form. + assert.match(stdout, /case '.*--model'.*'--model=\*'/); + assert.match(stdout, /'-m'/); + assert.match(stdout, /'-m=\*'/); +}); + +test('completion fish script suggests global options when typing flags before subcommand', async () => { + const { stdout } = await runCompletion(['fish']); + // When no subcommand is selected and the user types a flag like "--y", + // the fish script should suggest global options (--yes, --auto, --no-color) + // instead of falling through to suggest subcommands. The script achieves this + // by checking for flag tokens in the empty-subcmd branch. + assert.match(stdout, /if test -z "\$subcmd"[\s\S]*if string match -q -- '-\*' \$token/); +}); + +test('completion error path does not emit ANSI when --no-color is set', async () => { + const ansiPattern = /\u001b\[[0-9;]*m/; + try { + await runCompletion(['powershell']); + assert.fail('Expected process to exit with error'); + } catch (err) { + const stderr = (err.stderr || '').toString(); + assert.match(stderr, /Unsupported shell/); + assert.doesNotMatch(stderr, ansiPattern, 'Expected no ANSI codes with --no-color'); + } +}); + +test('NO_COLOR disables color even when set to an empty string (no-color.org spec)', async () => { + // no-color.org: "Any form of the NO_COLOR environment variable ... will + // disable color." An explicitly empty value still counts. + const ansiPattern = /\u001b\[[0-9;]*m/; + + try { + await execFileAsync(process.execPath, ['dist/index.js', '--no-color', 'completion', 'powershell'], { + env: { ...process.env, NO_COLOR: '' }, + }); + assert.fail('Expected process to exit with error'); + } catch (err) { + const stderr = (err.stderr || '').toString(); + assert.match(stderr, /Unsupported shell/); + assert.doesNotMatch(stderr, ansiPattern, 'Expected no ANSI codes with NO_COLOR=""'); + } +}); + +test('completion bash script is syntactically valid bash', async () => { + const { stdout } = await runCompletion(['bash']); + // Use a relative path in cwd — Git Bash on Windows mangles absolute Windows + // paths (backslashes get stripped). The cwd of the test runner is the repo + // root, which is safe because we always clean up. + const scriptPath = `./.test-completion-${process.pid}-${Date.now()}.sh`; + try { + await writeFile(scriptPath, stdout, 'utf8'); + // `bash -n` parses the script without executing it + await execFileAsync('bash', ['-n', scriptPath]); + } finally { + await unlink(scriptPath).catch(() => {}); + } +}); + +test('completion zsh script is syntactically valid zsh (if zsh is available)', async () => { + // Skip on platforms without zsh + let probe; + try { + probe = await execFileAsync('zsh', ['-c', 'exit 0']); + } catch { + return; // zsh not installed — skip silently + } + if (probe.stderr) return; + + const { stdout } = await runCompletion(['zsh']); + const scriptPath = `./.test-completion-${process.pid}-${Date.now()}.zsh`; + try { + await writeFile(scriptPath, stdout, 'utf8'); + await execFileAsync('zsh', ['-n', scriptPath]); + } finally { + await unlink(scriptPath).catch(() => {}); + } +}); + +test('completion fish script is syntactically valid fish (if fish is available)', async () => { + // Skip on platforms without fish + try { + await execFileAsync('fish', ['-c', 'exit 0']); + } catch { + return; // fish not installed — skip silently + } + + const { stdout } = await runCompletion(['fish']); + const scriptPath = `./.test-completion-${process.pid}-${Date.now()}.fish`; + try { + await writeFile(scriptPath, stdout, 'utf8'); + await execFileAsync('fish', ['-n', scriptPath]); + } finally { + await unlink(scriptPath).catch(() => {}); + } +}); + +test('completion zsh script skips _arguments for subcommands with no options', async () => { + const { stdout } = await runCompletion(['zsh']); + // The `help` subcommand has no options, so it should not have a dangling + // `_arguments \` continuation. Instead it should be a bare `help)\n;;`. + assert.match(stdout, /help\)\n\s+;;/); + assert.doesNotMatch(stdout, /help\)\n\s+_arguments/); +}); + +test('completion scripts contain all options from every subcommand help', async () => { + // `hook` is intentionally excluded: it is hidden (invoked by Git, not humans) + // and registered with `{ hidden: true }` in Commander, so it has no --help output. + const subcommands = ['init', 'config', 'suggest', 'history', 'batch', 'completion']; + const allHelpFlags = new Set(); + + // Collect --long flags from root help (global options) + const { stdout: rootHelp } = await execFileAsync(process.execPath, [ + 'dist/index.js', + '--no-color', + '--help', + ], { env: { ...process.env, NO_COLOR: '1' } }); + for (const flag of rootHelp.match(/--[\w-]+/g) || []) allHelpFlags.add(flag); + + // Collect --long flags from each subcommand's help + for (const subcmd of subcommands) { + const { stdout: subHelp } = await execFileAsync(process.execPath, [ + 'dist/index.js', + '--no-color', + subcmd, + '--help', + ], { env: { ...process.env, NO_COLOR: '1' } }); + for (const flag of subHelp.match(/--[\w-]+/g) || []) allHelpFlags.add(flag); + } + + // Get completion scripts + const { stdout: bashScript } = await runCompletion(['bash']); + const { stdout: zshScript } = await runCompletion(['zsh']); + const { stdout: fishScript } = await runCompletion(['fish']); + + // Every flag shown in any --help output must appear in all three scripts. + // If this test fails, update SUBCOMMANDS in src/commands/completion.ts. + for (const flag of allHelpFlags) { + assert.ok(bashScript.includes(flag), `Bash completion missing ${flag}`); + assert.ok(zshScript.includes(flag), `Zsh completion missing ${flag}`); + assert.ok(fishScript.includes(flag), `Fish completion missing ${flag}`); + } +});