Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions src/commands/completion.test.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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');
});
});
164 changes: 164 additions & 0 deletions src/commands/completion.ts
Original file line number Diff line number Diff line change
@@ -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<string, string[]>;
/** 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()));
});
}
23 changes: 23 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, string[]> = {};
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
Expand Down
1 change: 1 addition & 0 deletions test/__snapshots__/help.snapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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
"
`;
Loading