From df8b6766a4cc97f98065d8681e85e4528172b2a7 Mon Sep 17 00:00:00 2001 From: Andy00L <89641810+Andy00L@users.noreply.github.com> Date: Sun, 5 Jul 2026 20:26:50 -0400 Subject: [PATCH] feat(cli): add "testsprite completion" for bash/zsh/fish Emit a shell completion script for bash, zsh, or fish. Command names, subcommands, and global flags are derived from the fully-assembled Commander tree at call time (buildCompletionSpec walks program.commands), so the script can never drift from the real command surface. The shell auto-detects from $SHELL when the argument is omitted. Fixes #74 --- src/commands/completion.test.ts | 91 ++++++++++ src/commands/completion.ts | 164 ++++++++++++++++++ src/index.ts | 23 +++ test/__snapshots__/help.snapshot.test.ts.snap | 1 + 4 files changed, 279 insertions(+) create mode 100644 src/commands/completion.test.ts create mode 100644 src/commands/completion.ts diff --git a/src/commands/completion.test.ts b/src/commands/completion.test.ts new file mode 100644 index 0000000..8388a09 --- /dev/null +++ b/src/commands/completion.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; +import type { CompletionSpec } from './completion.js'; +import { createCompletionCommand, detectShell, isShell, renderCompletion } from './completion.js'; + +const SPEC: CompletionSpec = { + program: 'testsprite', + commands: ['setup', 'auth', 'test', 'doctor', 'completion', 'help'], + subcommands: { auth: ['status', 'remove'], test: ['run', 'wait'] }, + globalFlags: ['--output', '--profile', '--help'], +}; + +describe('isShell / detectShell', () => { + it('recognizes the three supported shells', () => { + expect(isShell('bash')).toBe(true); + expect(isShell('zsh')).toBe(true); + expect(isShell('fish')).toBe(true); + expect(isShell('powershell')).toBe(false); + }); + + it('detects the shell from a $SHELL path', () => { + expect(detectShell({ SHELL: '/bin/bash' })).toBe('bash'); + expect(detectShell({ SHELL: '/usr/bin/zsh' })).toBe('zsh'); + expect(detectShell({ SHELL: '/usr/local/bin/fish' })).toBe('fish'); + }); + + it('returns undefined for an unknown or missing shell', () => { + expect(detectShell({ SHELL: '/bin/sh' })).toBeUndefined(); + expect(detectShell({})).toBeUndefined(); + }); +}); + +describe('renderCompletion', () => { + it('bash script wires a completion function and lists commands, subcommands, flags', () => { + const script = renderCompletion('bash', SPEC); + expect(script).toContain('complete -F _testsprite_completion testsprite'); + expect(script).toContain('setup'); + expect(script).toContain('auth) COMPREPLY'); + expect(script).toContain('status remove'); + expect(script).toContain('--output'); + }); + + it('zsh script declares #compdef and per-group subcommands', () => { + const script = renderCompletion('zsh', SPEC); + expect(script.startsWith('#compdef testsprite')).toBe(true); + expect(script).toContain('compdef _testsprite testsprite'); + expect(script).toContain('run wait'); + }); + + it('fish script uses complete -c with subcommand conditions and flags', () => { + const script = renderCompletion('fish', SPEC); + expect(script).toContain('complete -c testsprite -f'); + expect(script).toContain('__fish_seen_subcommand_from auth'); + expect(script).toContain('-l output'); + }); +}); + +describe('createCompletionCommand', () => { + function run(args: string[], env: NodeJS.ProcessEnv): Promise { + const out: string[] = []; + const cmd = createCompletionCommand(() => SPEC, { env, stdout: line => out.push(line) }); + return cmd.parseAsync(args, { from: 'user' }).then(() => out); + } + + it('prints the requested shell script from an explicit argument', async () => { + const out = await run(['bash'], {}); + expect(out.join('\n')).toContain('complete -F'); + }); + + it('auto-detects the shell from $SHELL when no argument is given', async () => { + const out = await run([], { SHELL: '/usr/bin/zsh' }); + expect(out.join('\n')).toContain('#compdef testsprite'); + }); + + it('rejects an unsupported shell with VALIDATION_ERROR (exit 5)', async () => { + const cmd = createCompletionCommand(() => SPEC, { env: {}, stdout: () => undefined }); + await expect(cmd.parseAsync(['powershell'], { from: 'user' })).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + }); + }); + + it('errors when the shell cannot be detected and none is given', async () => { + const cmd = createCompletionCommand(() => SPEC, { env: {}, stdout: () => undefined }); + await expect(cmd.parseAsync([], { from: 'user' })).rejects.toMatchObject({ + code: 'VALIDATION_ERROR', + }); + }); + + it('is named "completion"', () => { + expect(createCompletionCommand(() => SPEC).name()).toBe('completion'); + }); +}); diff --git a/src/commands/completion.ts b/src/commands/completion.ts new file mode 100644 index 0000000..15cf7a4 --- /dev/null +++ b/src/commands/completion.ts @@ -0,0 +1,164 @@ +/** + * `testsprite completion [bash|zsh|fish]` — emit a shell completion script. + * + * The command names, per-group subcommands, and global flags are NOT hardcoded: + * `index.ts` builds a {@link CompletionSpec} by walking the fully-assembled + * Commander program and passes it in, so the generated script can never drift + * from the real command tree. `renderCompletion` is a pure function of the spec, + * which keeps it unit-testable without a live program. + * + * Usage: + * bash: eval "$(testsprite completion bash)" (add to ~/.bashrc) + * zsh: testsprite completion zsh > ~/.zsh/_testsprite (on your fpath) + * fish: testsprite completion fish | source (add to config.fish) + */ + +import { Command } from 'commander'; +import { localValidationError } from '../lib/errors.js'; + +export const SUPPORTED_SHELLS = ['bash', 'zsh', 'fish'] as const; +export type Shell = (typeof SUPPORTED_SHELLS)[number]; + +export interface CompletionSpec { + /** Binary name, e.g. "testsprite". */ + program: string; + /** Top-level command names. */ + commands: string[]; + /** command name -> its subcommand names (only groups that have subcommands). */ + subcommands: Record; + /** Global long option flags (e.g. "--output"). */ + globalFlags: string[]; +} + +export interface CompletionDeps { + env?: NodeJS.ProcessEnv; + stdout?: (line: string) => void; +} + +export function isShell(value: string): value is Shell { + return (SUPPORTED_SHELLS as readonly string[]).includes(value); +} + +/** Best-effort shell detection from `$SHELL` (e.g. "/bin/zsh" -> "zsh"). */ +export function detectShell(env: NodeJS.ProcessEnv): Shell | undefined { + const shellPath = env.SHELL ?? ''; + const base = shellPath.slice(shellPath.lastIndexOf('/') + 1); + return isShell(base) ? base : undefined; +} + +export function renderCompletion(shell: Shell, spec: CompletionSpec): string { + switch (shell) { + case 'bash': + return renderBash(spec); + case 'zsh': + return renderZsh(spec); + case 'fish': + return renderFish(spec); + } +} + +function renderBash(spec: CompletionSpec): string { + const fn = `_${spec.program}_completion`; + const lines = [ + `# ${spec.program} bash completion. Enable with: eval "$(${spec.program} completion bash)"`, + `${fn}() {`, + ' local cur prev', + ' cur="${COMP_WORDS[COMP_CWORD]}"', + ' prev="${COMP_WORDS[COMP_CWORD-1]}"', + ` local commands="${spec.commands.join(' ')}"`, + ` local global_flags="${spec.globalFlags.join(' ')}"`, + ' case "$prev" in', + ...Object.entries(spec.subcommands).map( + ([group, subs]) => + ` ${group}) COMPREPLY=( $(compgen -W "${subs.join(' ')}" -- "$cur") ); return;;`, + ), + ' esac', + ' if [[ "$cur" == -* ]]; then', + ' COMPREPLY=( $(compgen -W "$global_flags" -- "$cur") ); return', + ' fi', + ' COMPREPLY=( $(compgen -W "$commands" -- "$cur") )', + '}', + `complete -F ${fn} ${spec.program}`, + ]; + return lines.join('\n'); +} + +function renderZsh(spec: CompletionSpec): string { + const fn = `_${spec.program}`; + const lines = [ + `#compdef ${spec.program}`, + `# ${spec.program} zsh completion. Enable with: ${spec.program} completion zsh > "$fpath[1]/_${spec.program}"`, + `${fn}() {`, + ' local -a commands', + ` commands=(${spec.commands.join(' ')})`, + ' if (( CURRENT == 2 )); then', + " _describe 'command' commands", + ' return', + ' fi', + ' case "${words[2]}" in', + ...Object.entries(spec.subcommands).map( + ([group, subs]) => + ` ${group}) local -a subs; subs=(${subs.join(' ')}); _describe 'subcommand' subs;;`, + ), + ' esac', + '}', + `compdef ${fn} ${spec.program}`, + ]; + return lines.join('\n'); +} + +function renderFish(spec: CompletionSpec): string { + const lines = [ + `# ${spec.program} fish completion. Enable with: ${spec.program} completion fish | source`, + `complete -c ${spec.program} -f`, + ...spec.commands.map( + command => `complete -c ${spec.program} -n '__fish_use_subcommand' -a '${command}'`, + ), + ...Object.entries(spec.subcommands).flatMap(([group, subs]) => + subs.map( + sub => `complete -c ${spec.program} -n '__fish_seen_subcommand_from ${group}' -a '${sub}'`, + ), + ), + ...spec.globalFlags.map(flag => `complete -c ${spec.program} -l ${flag.replace(/^--/, '')}`), + ]; + return lines.join('\n'); +} + +export function createCompletionCommand( + getSpec: () => CompletionSpec, + deps: CompletionDeps = {}, +): Command { + return new Command('completion') + .description('Print a shell completion script (bash|zsh|fish)') + .argument( + '[shell]', + 'Shell to generate for (bash|zsh|fish); auto-detected from $SHELL when omitted', + ) + .addHelpText( + 'after', + '\nExamples:\n' + + ' eval "$(testsprite completion bash)" # bash, current session\n' + + ' testsprite completion zsh > ~/.zsh/_testsprite\n' + + ' testsprite completion fish | source # fish, current session', + ) + .action((shellArg: string | undefined, _cmdOpts: unknown) => { + const env = deps.env ?? process.env; + const shell = shellArg ?? detectShell(env); + if (shell === undefined) { + throw localValidationError( + 'shell', + `could not detect the shell from $SHELL; pass one explicitly (${SUPPORTED_SHELLS.join(', ')})`, + [...SUPPORTED_SHELLS], + ); + } + if (!isShell(shell)) { + throw localValidationError( + 'shell', + `unsupported shell "${shell}"; use one of: ${SUPPORTED_SHELLS.join(', ')}`, + [...SUPPORTED_SHELLS], + ); + } + const write = deps.stdout ?? ((line: string) => process.stdout.write(`${line}\n`)); + write(renderCompletion(shell, getSpec())); + }); +} diff --git a/src/index.ts b/src/index.ts index 806f6e4..25839f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import { Command, CommanderError } from 'commander'; import { createAgentCommand } from './commands/agent.js'; import { createAuthCommand } from './commands/auth.js'; +import { createCompletionCommand, type CompletionSpec } from './commands/completion.js'; import { createDoctorCommand } from './commands/doctor.js'; import { createDeprecatedInitCommand, @@ -92,6 +93,28 @@ program.addCommand(createTestCommand()); program.addCommand(createAgentCommand({})); program.addCommand(createUsageCommand()); program.addCommand(createDoctorCommand()); +program.addCommand(createCompletionCommand(() => buildCompletionSpec())); + +// Derive the shell-completion spec from the fully-assembled command tree at call +// time (not module-load), so `testsprite completion` can never drift from the +// real commands, subcommands, and global flags. +function buildCompletionSpec(): CompletionSpec { + const subcommands: Record = {}; + for (const command of program.commands) { + const subs = command.commands.map(sub => sub.name()).filter(name => name !== 'help'); + if (subs.length > 0) subcommands[command.name()] = subs; + } + const flags = program.options + .map(option => option.long) + .filter((long): long is string => typeof long === 'string'); + if (!flags.includes('--help')) flags.push('--help'); + return { + program: 'testsprite', + commands: [...new Set([...program.commands.map(command => command.name()), 'help'])], + subcommands, + globalFlags: flags, + }; +} // Buffer Commander error messages instead of writing immediately. The catch // block re-emits in the correct format (JSON or text) once the requested diff --git a/test/__snapshots__/help.snapshot.test.ts.snap b/test/__snapshots__/help.snapshot.test.ts.snap index fcc3d85..edd0611 100644 --- a/test/__snapshots__/help.snapshot.test.ts.snap +++ b/test/__snapshots__/help.snapshot.test.ts.snap @@ -667,6 +667,7 @@ Commands: (proactive pre-flight before a large test run) doctor Diagnose CLI setup: version, Node, profile, endpoint, credentials, connectivity, skill + completion [shell] Print a shell completion script (bash|zsh|fish) help [command] display help for command " `;