Skip to content

Commit 759a1c7

Browse files
authored
feat(cli): add "testsprite completion" for bash/zsh/fish (#227)
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
1 parent 34f5d43 commit 759a1c7

4 files changed

Lines changed: 279 additions & 0 deletions

File tree

src/commands/completion.test.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { CompletionSpec } from './completion.js';
3+
import { createCompletionCommand, detectShell, isShell, renderCompletion } from './completion.js';
4+
5+
const SPEC: CompletionSpec = {
6+
program: 'testsprite',
7+
commands: ['setup', 'auth', 'test', 'doctor', 'completion', 'help'],
8+
subcommands: { auth: ['status', 'remove'], test: ['run', 'wait'] },
9+
globalFlags: ['--output', '--profile', '--help'],
10+
};
11+
12+
describe('isShell / detectShell', () => {
13+
it('recognizes the three supported shells', () => {
14+
expect(isShell('bash')).toBe(true);
15+
expect(isShell('zsh')).toBe(true);
16+
expect(isShell('fish')).toBe(true);
17+
expect(isShell('powershell')).toBe(false);
18+
});
19+
20+
it('detects the shell from a $SHELL path', () => {
21+
expect(detectShell({ SHELL: '/bin/bash' })).toBe('bash');
22+
expect(detectShell({ SHELL: '/usr/bin/zsh' })).toBe('zsh');
23+
expect(detectShell({ SHELL: '/usr/local/bin/fish' })).toBe('fish');
24+
});
25+
26+
it('returns undefined for an unknown or missing shell', () => {
27+
expect(detectShell({ SHELL: '/bin/sh' })).toBeUndefined();
28+
expect(detectShell({})).toBeUndefined();
29+
});
30+
});
31+
32+
describe('renderCompletion', () => {
33+
it('bash script wires a completion function and lists commands, subcommands, flags', () => {
34+
const script = renderCompletion('bash', SPEC);
35+
expect(script).toContain('complete -F _testsprite_completion testsprite');
36+
expect(script).toContain('setup');
37+
expect(script).toContain('auth) COMPREPLY');
38+
expect(script).toContain('status remove');
39+
expect(script).toContain('--output');
40+
});
41+
42+
it('zsh script declares #compdef and per-group subcommands', () => {
43+
const script = renderCompletion('zsh', SPEC);
44+
expect(script.startsWith('#compdef testsprite')).toBe(true);
45+
expect(script).toContain('compdef _testsprite testsprite');
46+
expect(script).toContain('run wait');
47+
});
48+
49+
it('fish script uses complete -c with subcommand conditions and flags', () => {
50+
const script = renderCompletion('fish', SPEC);
51+
expect(script).toContain('complete -c testsprite -f');
52+
expect(script).toContain('__fish_seen_subcommand_from auth');
53+
expect(script).toContain('-l output');
54+
});
55+
});
56+
57+
describe('createCompletionCommand', () => {
58+
function run(args: string[], env: NodeJS.ProcessEnv): Promise<string[]> {
59+
const out: string[] = [];
60+
const cmd = createCompletionCommand(() => SPEC, { env, stdout: line => out.push(line) });
61+
return cmd.parseAsync(args, { from: 'user' }).then(() => out);
62+
}
63+
64+
it('prints the requested shell script from an explicit argument', async () => {
65+
const out = await run(['bash'], {});
66+
expect(out.join('\n')).toContain('complete -F');
67+
});
68+
69+
it('auto-detects the shell from $SHELL when no argument is given', async () => {
70+
const out = await run([], { SHELL: '/usr/bin/zsh' });
71+
expect(out.join('\n')).toContain('#compdef testsprite');
72+
});
73+
74+
it('rejects an unsupported shell with VALIDATION_ERROR (exit 5)', async () => {
75+
const cmd = createCompletionCommand(() => SPEC, { env: {}, stdout: () => undefined });
76+
await expect(cmd.parseAsync(['powershell'], { from: 'user' })).rejects.toMatchObject({
77+
code: 'VALIDATION_ERROR',
78+
});
79+
});
80+
81+
it('errors when the shell cannot be detected and none is given', async () => {
82+
const cmd = createCompletionCommand(() => SPEC, { env: {}, stdout: () => undefined });
83+
await expect(cmd.parseAsync([], { from: 'user' })).rejects.toMatchObject({
84+
code: 'VALIDATION_ERROR',
85+
});
86+
});
87+
88+
it('is named "completion"', () => {
89+
expect(createCompletionCommand(() => SPEC).name()).toBe('completion');
90+
});
91+
});

src/commands/completion.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* `testsprite completion [bash|zsh|fish]` — emit a shell completion script.
3+
*
4+
* The command names, per-group subcommands, and global flags are NOT hardcoded:
5+
* `index.ts` builds a {@link CompletionSpec} by walking the fully-assembled
6+
* Commander program and passes it in, so the generated script can never drift
7+
* from the real command tree. `renderCompletion` is a pure function of the spec,
8+
* which keeps it unit-testable without a live program.
9+
*
10+
* Usage:
11+
* bash: eval "$(testsprite completion bash)" (add to ~/.bashrc)
12+
* zsh: testsprite completion zsh > ~/.zsh/_testsprite (on your fpath)
13+
* fish: testsprite completion fish | source (add to config.fish)
14+
*/
15+
16+
import { Command } from 'commander';
17+
import { localValidationError } from '../lib/errors.js';
18+
19+
export const SUPPORTED_SHELLS = ['bash', 'zsh', 'fish'] as const;
20+
export type Shell = (typeof SUPPORTED_SHELLS)[number];
21+
22+
export interface CompletionSpec {
23+
/** Binary name, e.g. "testsprite". */
24+
program: string;
25+
/** Top-level command names. */
26+
commands: string[];
27+
/** command name -> its subcommand names (only groups that have subcommands). */
28+
subcommands: Record<string, string[]>;
29+
/** Global long option flags (e.g. "--output"). */
30+
globalFlags: string[];
31+
}
32+
33+
export interface CompletionDeps {
34+
env?: NodeJS.ProcessEnv;
35+
stdout?: (line: string) => void;
36+
}
37+
38+
export function isShell(value: string): value is Shell {
39+
return (SUPPORTED_SHELLS as readonly string[]).includes(value);
40+
}
41+
42+
/** Best-effort shell detection from `$SHELL` (e.g. "/bin/zsh" -> "zsh"). */
43+
export function detectShell(env: NodeJS.ProcessEnv): Shell | undefined {
44+
const shellPath = env.SHELL ?? '';
45+
const base = shellPath.slice(shellPath.lastIndexOf('/') + 1);
46+
return isShell(base) ? base : undefined;
47+
}
48+
49+
export function renderCompletion(shell: Shell, spec: CompletionSpec): string {
50+
switch (shell) {
51+
case 'bash':
52+
return renderBash(spec);
53+
case 'zsh':
54+
return renderZsh(spec);
55+
case 'fish':
56+
return renderFish(spec);
57+
}
58+
}
59+
60+
function renderBash(spec: CompletionSpec): string {
61+
const fn = `_${spec.program}_completion`;
62+
const lines = [
63+
`# ${spec.program} bash completion. Enable with: eval "$(${spec.program} completion bash)"`,
64+
`${fn}() {`,
65+
' local cur prev',
66+
' cur="${COMP_WORDS[COMP_CWORD]}"',
67+
' prev="${COMP_WORDS[COMP_CWORD-1]}"',
68+
` local commands="${spec.commands.join(' ')}"`,
69+
` local global_flags="${spec.globalFlags.join(' ')}"`,
70+
' case "$prev" in',
71+
...Object.entries(spec.subcommands).map(
72+
([group, subs]) =>
73+
` ${group}) COMPREPLY=( $(compgen -W "${subs.join(' ')}" -- "$cur") ); return;;`,
74+
),
75+
' esac',
76+
' if [[ "$cur" == -* ]]; then',
77+
' COMPREPLY=( $(compgen -W "$global_flags" -- "$cur") ); return',
78+
' fi',
79+
' COMPREPLY=( $(compgen -W "$commands" -- "$cur") )',
80+
'}',
81+
`complete -F ${fn} ${spec.program}`,
82+
];
83+
return lines.join('\n');
84+
}
85+
86+
function renderZsh(spec: CompletionSpec): string {
87+
const fn = `_${spec.program}`;
88+
const lines = [
89+
`#compdef ${spec.program}`,
90+
`# ${spec.program} zsh completion. Enable with: ${spec.program} completion zsh > "$fpath[1]/_${spec.program}"`,
91+
`${fn}() {`,
92+
' local -a commands',
93+
` commands=(${spec.commands.join(' ')})`,
94+
' if (( CURRENT == 2 )); then',
95+
" _describe 'command' commands",
96+
' return',
97+
' fi',
98+
' case "${words[2]}" in',
99+
...Object.entries(spec.subcommands).map(
100+
([group, subs]) =>
101+
` ${group}) local -a subs; subs=(${subs.join(' ')}); _describe 'subcommand' subs;;`,
102+
),
103+
' esac',
104+
'}',
105+
`compdef ${fn} ${spec.program}`,
106+
];
107+
return lines.join('\n');
108+
}
109+
110+
function renderFish(spec: CompletionSpec): string {
111+
const lines = [
112+
`# ${spec.program} fish completion. Enable with: ${spec.program} completion fish | source`,
113+
`complete -c ${spec.program} -f`,
114+
...spec.commands.map(
115+
command => `complete -c ${spec.program} -n '__fish_use_subcommand' -a '${command}'`,
116+
),
117+
...Object.entries(spec.subcommands).flatMap(([group, subs]) =>
118+
subs.map(
119+
sub => `complete -c ${spec.program} -n '__fish_seen_subcommand_from ${group}' -a '${sub}'`,
120+
),
121+
),
122+
...spec.globalFlags.map(flag => `complete -c ${spec.program} -l ${flag.replace(/^--/, '')}`),
123+
];
124+
return lines.join('\n');
125+
}
126+
127+
export function createCompletionCommand(
128+
getSpec: () => CompletionSpec,
129+
deps: CompletionDeps = {},
130+
): Command {
131+
return new Command('completion')
132+
.description('Print a shell completion script (bash|zsh|fish)')
133+
.argument(
134+
'[shell]',
135+
'Shell to generate for (bash|zsh|fish); auto-detected from $SHELL when omitted',
136+
)
137+
.addHelpText(
138+
'after',
139+
'\nExamples:\n' +
140+
' eval "$(testsprite completion bash)" # bash, current session\n' +
141+
' testsprite completion zsh > ~/.zsh/_testsprite\n' +
142+
' testsprite completion fish | source # fish, current session',
143+
)
144+
.action((shellArg: string | undefined, _cmdOpts: unknown) => {
145+
const env = deps.env ?? process.env;
146+
const shell = shellArg ?? detectShell(env);
147+
if (shell === undefined) {
148+
throw localValidationError(
149+
'shell',
150+
`could not detect the shell from $SHELL; pass one explicitly (${SUPPORTED_SHELLS.join(', ')})`,
151+
[...SUPPORTED_SHELLS],
152+
);
153+
}
154+
if (!isShell(shell)) {
155+
throw localValidationError(
156+
'shell',
157+
`unsupported shell "${shell}"; use one of: ${SUPPORTED_SHELLS.join(', ')}`,
158+
[...SUPPORTED_SHELLS],
159+
);
160+
}
161+
const write = deps.stdout ?? ((line: string) => process.stdout.write(`${line}\n`));
162+
write(renderCompletion(shell, getSpec()));
163+
});
164+
}

src/index.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Command, CommanderError } from 'commander';
44
import { createAgentCommand } from './commands/agent.js';
55
import { createAuthCommand } from './commands/auth.js';
6+
import { createCompletionCommand, type CompletionSpec } from './commands/completion.js';
67
import { createDoctorCommand } from './commands/doctor.js';
78
import {
89
createDeprecatedInitCommand,
@@ -92,6 +93,28 @@ program.addCommand(createTestCommand());
9293
program.addCommand(createAgentCommand({}));
9394
program.addCommand(createUsageCommand());
9495
program.addCommand(createDoctorCommand());
96+
program.addCommand(createCompletionCommand(() => buildCompletionSpec()));
97+
98+
// Derive the shell-completion spec from the fully-assembled command tree at call
99+
// time (not module-load), so `testsprite completion` can never drift from the
100+
// real commands, subcommands, and global flags.
101+
function buildCompletionSpec(): CompletionSpec {
102+
const subcommands: Record<string, string[]> = {};
103+
for (const command of program.commands) {
104+
const subs = command.commands.map(sub => sub.name()).filter(name => name !== 'help');
105+
if (subs.length > 0) subcommands[command.name()] = subs;
106+
}
107+
const flags = program.options
108+
.map(option => option.long)
109+
.filter((long): long is string => typeof long === 'string');
110+
if (!flags.includes('--help')) flags.push('--help');
111+
return {
112+
program: 'testsprite',
113+
commands: [...new Set([...program.commands.map(command => command.name()), 'help'])],
114+
subcommands,
115+
globalFlags: flags,
116+
};
117+
}
95118

96119
// Buffer Commander error messages instead of writing immediately. The catch
97120
// block re-emits in the correct format (JSON or text) once the requested

test/__snapshots__/help.snapshot.test.ts.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,6 +749,7 @@ Commands:
749749
(proactive pre-flight before a large test run)
750750
doctor Diagnose CLI setup: version, Node, profile,
751751
endpoint, credentials, connectivity, skill
752+
completion [shell] Print a shell completion script (bash|zsh|fish)
752753
help [command] display help for command
753754
"
754755
`;

0 commit comments

Comments
 (0)