Skip to content

Commit 30d4e8c

Browse files
committed
feat: add shell completion generation for bash, zsh, and fish
Implement dynamic shell completion via two internal flags: - --completion-script <shell>: outputs a completion script to source - --get-bargs-completions <shell> <words...>: returns candidates Enable with `completion: true` in bargs config. Supports: - Command and subcommand completion (including nested commands) - Option completion with aliases and --no-<name> for booleans - Enum value completion for options and positionals - Global options accumulated across nested command levels Closes #22
1 parent 33d8e4d commit 30d4e8c

8 files changed

Lines changed: 1928 additions & 39 deletions

File tree

cspell.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"msuccess",
4949
"mwarning",
5050
"mycli",
51+
"mytool",
5152
"barg",
5253
"argumentis",
5354
"mhello",
@@ -62,7 +63,10 @@
6263
"realfavicongenerator",
6364
"frickin",
6465
"TSES",
65-
"ghostty"
66+
"ghostty",
67+
"CWORD",
68+
"fpath",
69+
"compdef"
6670
],
6771
"words": [
6872
"bupkis",

examples/completion.ts

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
#!/usr/bin/env npx tsx
2+
/**
3+
* Shell completion example
4+
*
5+
* Demonstrates how to enable shell completion for a CLI.
6+
*
7+
* To enable completions for this example:
8+
*
9+
* # Bash (add to ~/.bashrc)
10+
*
11+
* Npx tsx examples/completion.ts --completion-script bash >> ~/.bashrc source
12+
* ~/.bashrc
13+
*
14+
* # Zsh (add to ~/.zshrc)
15+
*
16+
* Npx tsx examples/completion.ts --completion-script zsh >> ~/.zshrc source
17+
* ~/.zshrc
18+
*
19+
* # Fish (save to completions directory)
20+
*
21+
* Npx tsx examples/completion.ts --completion-script fish >
22+
* ~/.config/fish/completions/completion-demo.fish
23+
*
24+
* Then try pressing TAB after typing partial commands or options.
25+
*
26+
* Usage: npx tsx examples/completion.ts build --target prod npx tsx
27+
* examples/completion.ts test --coverage npx tsx examples/completion.ts lint
28+
* --fix
29+
*/
30+
import { bargs, opt, pos } from '../src/index.js';
31+
32+
// ═══════════════════════════════════════════════════════════════════════════════
33+
// GLOBAL OPTIONS
34+
// ═══════════════════════════════════════════════════════════════════════════════
35+
36+
const globalOptions = opt.options({
37+
config: opt.string({
38+
aliases: ['c'],
39+
description: 'Path to config file',
40+
}),
41+
verbose: opt.boolean({
42+
aliases: ['v'],
43+
default: false,
44+
description: 'Enable verbose output',
45+
}),
46+
});
47+
48+
// ═══════════════════════════════════════════════════════════════════════════════
49+
// BUILD COMMAND
50+
// ═══════════════════════════════════════════════════════════════════════════════
51+
52+
const buildParser = opt.options({
53+
minify: opt.boolean({
54+
aliases: ['m'],
55+
default: false,
56+
description: 'Minify output',
57+
}),
58+
// Enum option - completions will suggest these choices
59+
target: opt.enum(['dev', 'staging', 'prod'], {
60+
aliases: ['t'],
61+
default: 'dev',
62+
description: 'Build target environment',
63+
}),
64+
});
65+
66+
// ═══════════════════════════════════════════════════════════════════════════════
67+
// TEST COMMAND
68+
// ═══════════════════════════════════════════════════════════════════════════════
69+
70+
const testParser = pos.positionals(
71+
// Enum positional - completions will suggest these choices
72+
pos.enum(['unit', 'integration', 'e2e'], {
73+
description: 'Test type to run',
74+
name: 'type',
75+
}),
76+
)(
77+
opt.options({
78+
coverage: opt.boolean({
79+
default: false,
80+
description: 'Collect coverage',
81+
}),
82+
watch: opt.boolean({
83+
aliases: ['w'],
84+
default: false,
85+
description: 'Watch for changes',
86+
}),
87+
}),
88+
);
89+
90+
// ═══════════════════════════════════════════════════════════════════════════════
91+
// LINT COMMAND
92+
// ═══════════════════════════════════════════════════════════════════════════════
93+
94+
const lintParser = opt.options({
95+
fix: opt.boolean({
96+
default: false,
97+
description: 'Auto-fix issues',
98+
}),
99+
// Enum option - completions will suggest these choices
100+
format: opt.enum(['stylish', 'json', 'compact'], {
101+
default: 'stylish',
102+
description: 'Output format',
103+
}),
104+
});
105+
106+
// ═══════════════════════════════════════════════════════════════════════════════
107+
// CLI
108+
// ═══════════════════════════════════════════════════════════════════════════════
109+
110+
await bargs('completion-demo', {
111+
// Enable shell completion support!
112+
completion: true,
113+
description: 'Example CLI with shell completion support',
114+
version: '1.0.0',
115+
})
116+
.globals(globalOptions)
117+
.command(
118+
'build',
119+
buildParser,
120+
({ values }) => {
121+
console.log('Building for:', values.target);
122+
console.log('Minify:', values.minify);
123+
if (values.verbose) {
124+
console.log('Config:', values.config ?? '(default)');
125+
}
126+
},
127+
{ aliases: ['b'], description: 'Build the project' },
128+
)
129+
.command(
130+
'test',
131+
testParser,
132+
({ positionals, values }) => {
133+
console.log('Running tests:', positionals[0] ?? 'all');
134+
console.log('Coverage:', values.coverage);
135+
console.log('Watch:', values.watch);
136+
if (values.verbose) {
137+
console.log('Config:', values.config ?? '(default)');
138+
}
139+
},
140+
{ aliases: ['t'], description: 'Run tests' },
141+
)
142+
.command(
143+
'lint',
144+
lintParser,
145+
({ values }) => {
146+
console.log('Linting with format:', values.format);
147+
console.log('Fix:', values.fix);
148+
if (values.verbose) {
149+
console.log('Config:', values.config ?? '(default)');
150+
}
151+
},
152+
{ aliases: ['l'], description: 'Lint source files' },
153+
)
154+
.parseAsync();

src/bargs.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ import type {
1818
ParseResult,
1919
} from './types.js';
2020

21+
import {
22+
generateCompletionScript,
23+
getCompletionCandidates,
24+
validateShell,
25+
} from './completion.js';
2126
import { BargsError, HelpError } from './errors.js';
2227
import { generateCommandHelp, generateHelp } from './help.js';
2328
import { parseSimple } from './parser.js';
@@ -527,6 +532,7 @@ const isCommand = (x: unknown): x is Command<unknown, readonly unknown[]> => {
527532

528533
// Internal type for CliBuilder with internal methods
529534
type InternalCliBuilder<V, P extends readonly unknown[]> = CliBuilder<V, P> & {
535+
__getState: () => InternalCliState;
530536
__parseWithParentGlobals: (
531537
args: string[],
532538
parentGlobals: ParseResult<unknown, readonly unknown[]>,
@@ -545,6 +551,11 @@ const createCliBuilder = <V, P extends readonly unknown[]>(
545551
state: InternalCliState,
546552
): CliBuilder<V, P> => {
547553
const builder: InternalCliBuilder<V, P> = {
554+
// Internal method for completion support - not part of public API
555+
__getState(): InternalCliState {
556+
return state;
557+
},
558+
548559
// Internal method for nested command support - not part of public API
549560
__parseWithParentGlobals(
550561
args: string[],
@@ -852,6 +863,52 @@ const parseCore = (
852863
process.exit(0);
853864
}
854865
}
866+
867+
// Handle shell completion (when enabled)
868+
if (options.completion) {
869+
// Handle --completion-script <shell>
870+
const completionScriptIndex = args.indexOf('--completion-script');
871+
if (completionScriptIndex >= 0) {
872+
const shellArg = args[completionScriptIndex + 1];
873+
if (!shellArg) {
874+
console.error(
875+
'Error: --completion-script requires a shell argument (bash, zsh, or fish)',
876+
);
877+
process.exit(1);
878+
}
879+
try {
880+
const shell = validateShell(shellArg);
881+
console.log(generateCompletionScript(state.name, shell));
882+
process.exit(0);
883+
} catch (err) {
884+
console.error(`Error: ${(err as Error).message}`);
885+
process.exit(1);
886+
}
887+
}
888+
889+
// Handle --get-bargs-completions <shell> <...words>
890+
const getCompletionsIndex = args.indexOf('--get-bargs-completions');
891+
if (getCompletionsIndex >= 0) {
892+
const shellArg = args[getCompletionsIndex + 1];
893+
if (!shellArg) {
894+
// No shell specified, output nothing
895+
process.exit(0);
896+
}
897+
try {
898+
const shell = validateShell(shellArg);
899+
// Words are everything after the shell argument
900+
const words = args.slice(getCompletionsIndex + 2);
901+
const candidates = getCompletionCandidates(state, shell, words);
902+
if (candidates.length > 0) {
903+
console.log(candidates.join('\n'));
904+
}
905+
process.exit(0);
906+
} catch {
907+
// Invalid shell, output nothing
908+
process.exit(0);
909+
}
910+
}
911+
}
855912
/* c8 ignore stop */
856913

857914
// If we have commands, dispatch to the appropriate one

0 commit comments

Comments
 (0)