clap-ts 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/parser.js +5 -5
- package/dist/types.d.ts +14 -1
- package/package.json +17 -1
- package/src/__tests__/arg-options.test.ts +687 -0
- package/src/__tests__/argfile.test.ts +127 -0
- package/src/__tests__/clap-parity.test.ts +682 -0
- package/src/__tests__/command-options.test.ts +713 -0
- package/src/__tests__/completions.test.ts +423 -0
- package/src/__tests__/config.test.ts +261 -0
- package/src/__tests__/deprecation.test.ts +104 -0
- package/src/__tests__/help.test.ts +312 -0
- package/src/__tests__/install.test.ts +120 -0
- package/src/__tests__/log.test.ts +189 -0
- package/src/__tests__/man.test.ts +135 -0
- package/src/__tests__/markdown.test.ts +114 -0
- package/src/__tests__/output.test.ts +249 -0
- package/src/__tests__/parser.test.ts +627 -0
- package/src/__tests__/plugins.test.ts +182 -0
- package/src/__tests__/progress.test.ts +221 -0
- package/src/__tests__/prompt.test.ts +265 -0
- package/src/__tests__/runner.test.ts +459 -0
- package/src/__tests__/spec.test.ts +107 -0
- package/src/__tests__/testing.test.ts +93 -0
- package/src/__tests__/validation.test.ts +267 -0
- package/src/argfile.ts +188 -0
- package/src/completions.ts +865 -0
- package/src/config.ts +184 -0
- package/src/help.ts +779 -0
- package/src/index.ts +58 -0
- package/src/install.ts +226 -0
- package/src/log.ts +225 -0
- package/src/man.ts +289 -0
- package/src/markdown.ts +210 -0
- package/src/output.ts +453 -0
- package/src/parser.ts +1240 -0
- package/src/plugins.ts +193 -0
- package/src/progress.ts +295 -0
- package/src/prompt.ts +388 -0
- package/src/runner.ts +769 -0
- package/src/spec.ts +197 -0
- package/src/testing.ts +159 -0
- package/src/types.ts +618 -0
- package/src/validation.ts +627 -0
|
@@ -0,0 +1,865 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shell completion generation -- static scripts and dynamic runtime completions.
|
|
3
|
+
* Matches clap_complete's feature set: bash, zsh, fish, powershell.
|
|
4
|
+
*
|
|
5
|
+
* Two modes:
|
|
6
|
+
* 1. Static: generateCompletions() returns a shell script string to source
|
|
7
|
+
* 2. Dynamic: completeEnv() checks env vars, outputs completions, returns true if handled
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { CommandDef, Shell, ValueHint } from './types.js';
|
|
11
|
+
import { possibleValues, subCommandsOf } from './parser.js';
|
|
12
|
+
|
|
13
|
+
export type { Shell, ValueHint } from './types.js';
|
|
14
|
+
|
|
15
|
+
// ---- Internal: Command Tree Traversal ----
|
|
16
|
+
|
|
17
|
+
interface FlagInfo {
|
|
18
|
+
key: string;
|
|
19
|
+
short?: string;
|
|
20
|
+
long: string;
|
|
21
|
+
description: string;
|
|
22
|
+
takesValue: boolean;
|
|
23
|
+
possibleValues: readonly string[];
|
|
24
|
+
valueHint?: ValueHint;
|
|
25
|
+
hidden: boolean;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface PositionalInfo {
|
|
29
|
+
key: string;
|
|
30
|
+
name: string;
|
|
31
|
+
description: string;
|
|
32
|
+
required: boolean;
|
|
33
|
+
possibleValues: readonly string[];
|
|
34
|
+
valueHint?: ValueHint;
|
|
35
|
+
hidden: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface SubcommandInfo {
|
|
39
|
+
name: string;
|
|
40
|
+
description: string;
|
|
41
|
+
aliases: readonly string[];
|
|
42
|
+
hidden: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface CompletionNode {
|
|
46
|
+
name: string;
|
|
47
|
+
description: string;
|
|
48
|
+
flags: FlagInfo[];
|
|
49
|
+
positionals: PositionalInfo[];
|
|
50
|
+
subcommands: SubcommandInfo[];
|
|
51
|
+
childNodes: Map<string, CompletionNode>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Extract completion-relevant data from a CommandDef tree. */
|
|
55
|
+
function buildCompletionTree(command: CommandDef, name?: string): CompletionNode {
|
|
56
|
+
const flags: FlagInfo[] = [];
|
|
57
|
+
const positionals: PositionalInfo[] = [];
|
|
58
|
+
const argsDef = command.args ?? {};
|
|
59
|
+
|
|
60
|
+
for (const [key, def] of Object.entries(argsDef)) {
|
|
61
|
+
if (def.type === 'positional') {
|
|
62
|
+
positionals.push({
|
|
63
|
+
key,
|
|
64
|
+
name: def.valueName ?? key,
|
|
65
|
+
description: def.description ?? '',
|
|
66
|
+
required: def.required ?? false,
|
|
67
|
+
possibleValues: possibleValues(def)
|
|
68
|
+
.filter((v) => !v.hidden)
|
|
69
|
+
.map((v) => v.name),
|
|
70
|
+
valueHint: def.valueHint,
|
|
71
|
+
hidden: def.hidden ?? false,
|
|
72
|
+
});
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const longName = def.long ?? key;
|
|
76
|
+
flags.push({
|
|
77
|
+
key,
|
|
78
|
+
short: def.short,
|
|
79
|
+
long: longName,
|
|
80
|
+
description: def.description ?? '',
|
|
81
|
+
takesValue: def.type !== 'boolean' && def.action !== 'count',
|
|
82
|
+
possibleValues: possibleValues(def)
|
|
83
|
+
.filter((v) => !v.hidden)
|
|
84
|
+
.map((v) => v.name),
|
|
85
|
+
valueHint: def.valueHint,
|
|
86
|
+
hidden: def.hidden ?? false,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Always include --help and --version
|
|
91
|
+
if (command.meta.disableHelpFlag !== true) {
|
|
92
|
+
flags.push({ key: 'help', short: 'h', long: 'help', description: 'Print help', takesValue: false, possibleValues: [], hidden: false });
|
|
93
|
+
}
|
|
94
|
+
if (command.meta.version && command.meta.disableVersionFlag !== true) {
|
|
95
|
+
flags.push({ key: 'version', short: 'V', long: 'version', description: 'Print version', takesValue: false, possibleValues: [], hidden: false });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const subcommands: SubcommandInfo[] = [];
|
|
99
|
+
const childNodes = new Map<string, CompletionNode>();
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
for (const [subName, subDef] of Object.entries(subCommandsOf(command))) {
|
|
103
|
+
subcommands.push({
|
|
104
|
+
name: subName,
|
|
105
|
+
description: subDef.meta.description ?? '',
|
|
106
|
+
aliases: subDef.meta.aliases ?? [],
|
|
107
|
+
hidden: subDef.meta.hidden ?? false,
|
|
108
|
+
});
|
|
109
|
+
childNodes.set(subName, buildCompletionTree(subDef, subName));
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
name: name ?? command.meta.name,
|
|
115
|
+
description: command.meta.description ?? command.meta.about ?? '',
|
|
116
|
+
flags,
|
|
117
|
+
positionals,
|
|
118
|
+
subcommands,
|
|
119
|
+
childNodes,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---- Helpers ----
|
|
124
|
+
|
|
125
|
+
/** Escape single quotes for shell strings. */
|
|
126
|
+
function esc(s: string): string {
|
|
127
|
+
return s.replaceAll("'", "'\\''");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Escape double quotes for shell strings. */
|
|
131
|
+
function escDq(s: string): string {
|
|
132
|
+
return s.replaceAll('"', '\\"').replaceAll('$', '\\$').replaceAll('`', '\\`');
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Sanitize a name for use as a shell function/variable name. */
|
|
136
|
+
function sanitize(s: string): string {
|
|
137
|
+
return s.replaceAll(/[^a-zA-Z0-9_]/g, '_');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ---- Bash Generator ----
|
|
141
|
+
|
|
142
|
+
function generateBash(root: CompletionNode, binaryName: string): string {
|
|
143
|
+
const funcName = `_${sanitize(binaryName)}`;
|
|
144
|
+
const lines: string[] = [];
|
|
145
|
+
|
|
146
|
+
lines.push(`# bash completion for ${binaryName}`);
|
|
147
|
+
lines.push(`# Generated by clap-ts`);
|
|
148
|
+
lines.push('');
|
|
149
|
+
|
|
150
|
+
// Generate completion function for each command level
|
|
151
|
+
generateBashFunction(root, funcName, binaryName, lines);
|
|
152
|
+
|
|
153
|
+
lines.push('');
|
|
154
|
+
lines.push(`complete -o default -o bashdefault -F ${funcName} ${binaryName}`);
|
|
155
|
+
lines.push('');
|
|
156
|
+
|
|
157
|
+
return lines.join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function generateBashFunction(
|
|
161
|
+
node: CompletionNode,
|
|
162
|
+
funcName: string,
|
|
163
|
+
fullCommand: string,
|
|
164
|
+
lines: string[],
|
|
165
|
+
): void {
|
|
166
|
+
lines.push(`${funcName}() {`);
|
|
167
|
+
lines.push(' local cur prev words cword');
|
|
168
|
+
lines.push(' _init_completion || return');
|
|
169
|
+
lines.push('');
|
|
170
|
+
|
|
171
|
+
// Build flag list
|
|
172
|
+
const visibleFlags = node.flags.filter((f) => !f.hidden);
|
|
173
|
+
const flagWords: string[] = [];
|
|
174
|
+
for (const f of visibleFlags) {
|
|
175
|
+
flagWords.push(`--${f.long}`);
|
|
176
|
+
if (f.short) {
|
|
177
|
+
flagWords.push(`-${f.short}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Build subcommand list
|
|
182
|
+
const visibleSubs = node.subcommands.filter((s) => !s.hidden);
|
|
183
|
+
const subWords = visibleSubs.map((s) => s.name);
|
|
184
|
+
|
|
185
|
+
// Handle value completion for flags that take values
|
|
186
|
+
const valueFlagCases: string[] = [];
|
|
187
|
+
for (const f of visibleFlags) {
|
|
188
|
+
if (!f.takesValue) continue;
|
|
189
|
+
const flagNames = [`--${f.long}`];
|
|
190
|
+
if (f.short) flagNames.push(`-${f.short}`);
|
|
191
|
+
const pattern = flagNames.join('|');
|
|
192
|
+
|
|
193
|
+
if (f.possibleValues.length > 0) {
|
|
194
|
+
valueFlagCases.push(` ${pattern})`);
|
|
195
|
+
valueFlagCases.push(` COMPREPLY=( $(compgen -W '${f.possibleValues.join(' ')}' -- "$cur") )`);
|
|
196
|
+
valueFlagCases.push(' return ;;');
|
|
197
|
+
} else if (f.valueHint) {
|
|
198
|
+
const compgen = bashValueHintCompgen(f.valueHint);
|
|
199
|
+
if (compgen) {
|
|
200
|
+
valueFlagCases.push(` ${pattern})`);
|
|
201
|
+
valueFlagCases.push(` ${compgen}`);
|
|
202
|
+
valueFlagCases.push(' return ;;');
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (valueFlagCases.length > 0) {
|
|
208
|
+
lines.push(' case "$prev" in');
|
|
209
|
+
lines.push(...valueFlagCases);
|
|
210
|
+
lines.push(' esac');
|
|
211
|
+
lines.push('');
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Handle subcommand dispatch
|
|
215
|
+
if (visibleSubs.length > 0) {
|
|
216
|
+
lines.push(' # Check for subcommand');
|
|
217
|
+
lines.push(' local subcmd=""');
|
|
218
|
+
lines.push(' for ((i=1; i < cword; i++)); do');
|
|
219
|
+
lines.push(' case "${words[i]}" in');
|
|
220
|
+
for (const sub of visibleSubs) {
|
|
221
|
+
const allNames = [sub.name, ...sub.aliases];
|
|
222
|
+
lines.push(` ${allNames.join('|')})`);
|
|
223
|
+
lines.push(` subcmd="${sub.name}"; break ;;`);
|
|
224
|
+
}
|
|
225
|
+
lines.push(' esac');
|
|
226
|
+
lines.push(' done');
|
|
227
|
+
lines.push('');
|
|
228
|
+
|
|
229
|
+
// Dispatch to child function
|
|
230
|
+
lines.push(' case "$subcmd" in');
|
|
231
|
+
for (const sub of visibleSubs) {
|
|
232
|
+
const childFunc = `${funcName}_${sanitize(sub.name)}`;
|
|
233
|
+
lines.push(` ${sub.name})`);
|
|
234
|
+
lines.push(` ${childFunc}; return ;;`);
|
|
235
|
+
}
|
|
236
|
+
lines.push(' esac');
|
|
237
|
+
lines.push('');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Default: complete with flags and subcommands
|
|
241
|
+
const allWords = [...flagWords, ...subWords].join(' ');
|
|
242
|
+
lines.push(` COMPREPLY=( $(compgen -W '${allWords}' -- "$cur") )`);
|
|
243
|
+
lines.push('}');
|
|
244
|
+
lines.push('');
|
|
245
|
+
|
|
246
|
+
// Recurse for child commands
|
|
247
|
+
for (const sub of visibleSubs) {
|
|
248
|
+
const childNode = node.childNodes.get(sub.name);
|
|
249
|
+
if (childNode) {
|
|
250
|
+
const childFunc = `${funcName}_${sanitize(sub.name)}`;
|
|
251
|
+
generateBashFunction(childNode, childFunc, `${fullCommand} ${sub.name}`, lines);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function bashValueHintCompgen(hint: ValueHint): string | undefined {
|
|
257
|
+
switch (hint) {
|
|
258
|
+
case 'filePath': return 'COMPREPLY=( $(compgen -f -- "$cur") )';
|
|
259
|
+
case 'dirPath': return 'COMPREPLY=( $(compgen -d -- "$cur") )';
|
|
260
|
+
case 'anyPath': return 'COMPREPLY=( $(compgen -f -- "$cur") )';
|
|
261
|
+
case 'executablePath': return 'COMPREPLY=( $(compgen -c -- "$cur") )';
|
|
262
|
+
case 'commandName': return 'COMPREPLY=( $(compgen -c -- "$cur") )';
|
|
263
|
+
case 'hostname': return 'COMPREPLY=( $(compgen -A hostname -- "$cur") )';
|
|
264
|
+
case 'username': return 'COMPREPLY=( $(compgen -u -- "$cur") )';
|
|
265
|
+
case 'url': return undefined;
|
|
266
|
+
case 'emailAddress': return undefined;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ---- Zsh Generator ----
|
|
271
|
+
|
|
272
|
+
function generateZsh(root: CompletionNode, binaryName: string): string {
|
|
273
|
+
const funcName = `_${sanitize(binaryName)}`;
|
|
274
|
+
const lines: string[] = [];
|
|
275
|
+
|
|
276
|
+
lines.push(`#compdef ${binaryName}`);
|
|
277
|
+
lines.push('');
|
|
278
|
+
lines.push(`# zsh completion for ${binaryName}`);
|
|
279
|
+
lines.push('# Generated by clap-ts');
|
|
280
|
+
lines.push('');
|
|
281
|
+
|
|
282
|
+
generateZshFunction(root, funcName, lines);
|
|
283
|
+
|
|
284
|
+
lines.push('');
|
|
285
|
+
lines.push(`if [ "$funcstack[1]" = "${funcName}" ]; then`);
|
|
286
|
+
lines.push(` ${funcName} "$@"`);
|
|
287
|
+
lines.push('else');
|
|
288
|
+
lines.push(` compdef ${funcName} ${binaryName}`);
|
|
289
|
+
lines.push('fi');
|
|
290
|
+
lines.push('');
|
|
291
|
+
|
|
292
|
+
return lines.join('\n');
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function generateZshFunction(
|
|
296
|
+
node: CompletionNode,
|
|
297
|
+
funcName: string,
|
|
298
|
+
lines: string[],
|
|
299
|
+
): void {
|
|
300
|
+
lines.push(`${funcName}() {`);
|
|
301
|
+
lines.push(' local -a args');
|
|
302
|
+
lines.push('');
|
|
303
|
+
|
|
304
|
+
const visibleFlags = node.flags.filter((f) => !f.hidden);
|
|
305
|
+
const visibleSubs = node.subcommands.filter((s) => !s.hidden);
|
|
306
|
+
|
|
307
|
+
// Build _arguments specs for flags
|
|
308
|
+
const argSpecs: string[] = [];
|
|
309
|
+
for (const f of visibleFlags) {
|
|
310
|
+
const desc = escDq(f.description);
|
|
311
|
+
if (f.takesValue) {
|
|
312
|
+
const valueSpec = zshValueSpec(f);
|
|
313
|
+
if (f.short) {
|
|
314
|
+
argSpecs.push(`'(-${f.short} --${f.long})-${f.short}[${desc}]${valueSpec}'`);
|
|
315
|
+
argSpecs.push(`'(-${f.short} --${f.long})--${f.long}[${desc}]${valueSpec}'`);
|
|
316
|
+
} else {
|
|
317
|
+
argSpecs.push(`'--${f.long}[${desc}]${valueSpec}'`);
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
if (f.short) {
|
|
321
|
+
argSpecs.push(`'(-${f.short} --${f.long})-${f.short}[${desc}]'`);
|
|
322
|
+
argSpecs.push(`'(-${f.short} --${f.long})--${f.long}[${desc}]'`);
|
|
323
|
+
} else {
|
|
324
|
+
argSpecs.push(`'--${f.long}[${desc}]'`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (visibleSubs.length > 0) {
|
|
330
|
+
argSpecs.push("'1: :->command'");
|
|
331
|
+
argSpecs.push("'*::arg:->args'");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (argSpecs.length > 0) {
|
|
335
|
+
lines.push(' _arguments -C \\');
|
|
336
|
+
for (let i = 0; i < argSpecs.length; i++) {
|
|
337
|
+
const sep = i < argSpecs.length - 1 ? ' \\' : '';
|
|
338
|
+
lines.push(` ${argSpecs[i]}${sep}`);
|
|
339
|
+
}
|
|
340
|
+
lines.push('');
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (visibleSubs.length > 0) {
|
|
344
|
+
lines.push(' case "$state" in');
|
|
345
|
+
lines.push(' command)');
|
|
346
|
+
const subDescs = visibleSubs.map((s) => `'${s.name}:${escDq(s.description)}'`);
|
|
347
|
+
lines.push(` _values 'command' ${subDescs.join(' ')}`);
|
|
348
|
+
lines.push(' ;;');
|
|
349
|
+
lines.push(' args)');
|
|
350
|
+
lines.push(' case "$words[1]" in');
|
|
351
|
+
for (const sub of visibleSubs) {
|
|
352
|
+
const childFunc = `${funcName}_${sanitize(sub.name)}`;
|
|
353
|
+
const allNames = [sub.name, ...sub.aliases];
|
|
354
|
+
lines.push(` ${allNames.join('|')})`);
|
|
355
|
+
lines.push(` ${childFunc} ;;`);
|
|
356
|
+
}
|
|
357
|
+
lines.push(' esac');
|
|
358
|
+
lines.push(' ;;');
|
|
359
|
+
lines.push(' esac');
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
lines.push('}');
|
|
363
|
+
lines.push('');
|
|
364
|
+
|
|
365
|
+
// Recurse for child commands
|
|
366
|
+
for (const sub of visibleSubs) {
|
|
367
|
+
const childNode = node.childNodes.get(sub.name);
|
|
368
|
+
if (childNode) {
|
|
369
|
+
const childFunc = `${funcName}_${sanitize(sub.name)}`;
|
|
370
|
+
generateZshFunction(childNode, childFunc, lines);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function zshValueSpec(f: FlagInfo): string {
|
|
376
|
+
if (f.possibleValues.length > 0) {
|
|
377
|
+
const vals = f.possibleValues.join(' ');
|
|
378
|
+
return `:${f.key}:(${vals})`;
|
|
379
|
+
}
|
|
380
|
+
if (f.valueHint) {
|
|
381
|
+
return zshValueHintSpec(f.valueHint, f.key);
|
|
382
|
+
}
|
|
383
|
+
return `:${f.key}:`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function zshValueHintSpec(hint: ValueHint, key: string): string {
|
|
387
|
+
switch (hint) {
|
|
388
|
+
case 'filePath': return `:${key}:_files`;
|
|
389
|
+
case 'dirPath': return `:${key}:_directories`;
|
|
390
|
+
case 'anyPath': return `:${key}:_files`;
|
|
391
|
+
case 'executablePath': return `:${key}:_command_names`;
|
|
392
|
+
case 'commandName': return `:${key}:_command_names`;
|
|
393
|
+
case 'hostname': return `:${key}:_hosts`;
|
|
394
|
+
case 'username': return `:${key}:_users`;
|
|
395
|
+
case 'url': return `:${key}:_urls`;
|
|
396
|
+
case 'commandString': return `:${key}:_cmdstring`;
|
|
397
|
+
case 'commandWithArguments': return `:${key}:_command_names -e`;
|
|
398
|
+
case 'emailAddress':
|
|
399
|
+
case 'other':
|
|
400
|
+
case 'unknown':
|
|
401
|
+
return `:${key}:`;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---- Fish Generator ----
|
|
406
|
+
|
|
407
|
+
function generateFish(root: CompletionNode, binaryName: string): string {
|
|
408
|
+
const lines: string[] = [];
|
|
409
|
+
|
|
410
|
+
lines.push(`# fish completion for ${binaryName}`);
|
|
411
|
+
lines.push('# Generated by clap-ts');
|
|
412
|
+
lines.push('');
|
|
413
|
+
|
|
414
|
+
generateFishCommands(root, binaryName, [], lines);
|
|
415
|
+
|
|
416
|
+
return lines.join('\n');
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function generateFishCommands(
|
|
420
|
+
node: CompletionNode,
|
|
421
|
+
binaryName: string,
|
|
422
|
+
parentSubcommands: string[],
|
|
423
|
+
lines: string[],
|
|
424
|
+
): void {
|
|
425
|
+
const visibleFlags = node.flags.filter((f) => !f.hidden);
|
|
426
|
+
const visibleSubs = node.subcommands.filter((s) => !s.hidden);
|
|
427
|
+
|
|
428
|
+
// Build condition: only show these completions when we're at this command level
|
|
429
|
+
let condition: string;
|
|
430
|
+
if (parentSubcommands.length === 0) {
|
|
431
|
+
if (visibleSubs.length > 0) {
|
|
432
|
+
// Root level with subcommands: show only when no subcommand has been seen
|
|
433
|
+
const allSubNames = collectAllSubcommandNames(node);
|
|
434
|
+
condition = `not __fish_seen_subcommand_from ${allSubNames.join(' ')}`;
|
|
435
|
+
} else {
|
|
436
|
+
condition = '';
|
|
437
|
+
}
|
|
438
|
+
} else {
|
|
439
|
+
const lastSub = parentSubcommands[parentSubcommands.length - 1]!;
|
|
440
|
+
condition = `__fish_seen_subcommand_from ${lastSub}`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const condFlag = condition ? `-n '${condition}'` : '';
|
|
444
|
+
|
|
445
|
+
// Register flags
|
|
446
|
+
for (const f of visibleFlags) {
|
|
447
|
+
const parts = [`complete -c ${binaryName}`];
|
|
448
|
+
if (condFlag) parts.push(condFlag);
|
|
449
|
+
if (f.short) parts.push(`-s ${f.short}`);
|
|
450
|
+
parts.push(`-l ${f.long}`);
|
|
451
|
+
if (f.description) parts.push(`-d '${esc(f.description)}'`);
|
|
452
|
+
if (f.takesValue) {
|
|
453
|
+
parts.push('-r'); // requires argument
|
|
454
|
+
if (f.possibleValues.length > 0) {
|
|
455
|
+
parts.push(`-a '${f.possibleValues.join(' ')}'`);
|
|
456
|
+
} else if (f.valueHint) {
|
|
457
|
+
const fishHint = fishValueHint(f.valueHint);
|
|
458
|
+
if (fishHint) parts.push(fishHint);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
lines.push(parts.join(' '));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Register subcommands
|
|
465
|
+
for (const sub of visibleSubs) {
|
|
466
|
+
const parts = [`complete -c ${binaryName}`];
|
|
467
|
+
if (condFlag) parts.push(condFlag);
|
|
468
|
+
parts.push(`-a '${sub.name}'`);
|
|
469
|
+
if (sub.description) parts.push(`-d '${esc(sub.description)}'`);
|
|
470
|
+
lines.push(parts.join(' '));
|
|
471
|
+
|
|
472
|
+
// Also register aliases
|
|
473
|
+
for (const alias of sub.aliases) {
|
|
474
|
+
const aliasParts = [`complete -c ${binaryName}`];
|
|
475
|
+
if (condFlag) aliasParts.push(condFlag);
|
|
476
|
+
aliasParts.push(`-a '${alias}'`);
|
|
477
|
+
if (sub.description) aliasParts.push(`-d '${esc(sub.description)}'`);
|
|
478
|
+
lines.push(aliasParts.join(' '));
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
lines.push('');
|
|
483
|
+
|
|
484
|
+
// Recurse for child commands
|
|
485
|
+
for (const sub of visibleSubs) {
|
|
486
|
+
const childNode = node.childNodes.get(sub.name);
|
|
487
|
+
if (childNode) {
|
|
488
|
+
generateFishCommands(childNode, binaryName, [...parentSubcommands, sub.name], lines);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function collectAllSubcommandNames(node: CompletionNode): string[] {
|
|
494
|
+
const names: string[] = [];
|
|
495
|
+
for (const sub of node.subcommands) {
|
|
496
|
+
if (!sub.hidden) {
|
|
497
|
+
names.push(sub.name);
|
|
498
|
+
names.push(...sub.aliases);
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return names;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function fishValueHint(hint: ValueHint): string | undefined {
|
|
505
|
+
switch (hint) {
|
|
506
|
+
case 'filePath': return '-F';
|
|
507
|
+
case 'dirPath': return "-xa '(__fish_complete_directories)'";
|
|
508
|
+
case 'anyPath': return '-F';
|
|
509
|
+
case 'executablePath': return "-a '(__fish_complete_command)'";
|
|
510
|
+
case 'commandName': return "-a '(__fish_complete_command)'";
|
|
511
|
+
case 'hostname': return "-a '(__fish_print_hostnames)'";
|
|
512
|
+
case 'username': return "-a '(__fish_complete_users)'";
|
|
513
|
+
case 'url': return undefined;
|
|
514
|
+
case 'emailAddress': return undefined;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ---- PowerShell Generator ----
|
|
519
|
+
|
|
520
|
+
function generatePowerShell(root: CompletionNode, binaryName: string): string {
|
|
521
|
+
const lines: string[] = [];
|
|
522
|
+
|
|
523
|
+
lines.push(`# PowerShell completion for ${binaryName}`);
|
|
524
|
+
lines.push('# Generated by clap-ts');
|
|
525
|
+
lines.push('');
|
|
526
|
+
lines.push(`Register-ArgumentCompleter -CommandName '${binaryName}' -ScriptBlock {`);
|
|
527
|
+
lines.push(' param($wordToComplete, $commandAst, $cursorPosition)');
|
|
528
|
+
lines.push('');
|
|
529
|
+
lines.push(' $tokens = $commandAst.CommandElements | ForEach-Object { $_.ToString() }');
|
|
530
|
+
lines.push(' $tokens = $tokens[1..($tokens.Length - 1)] # Remove command name');
|
|
531
|
+
lines.push('');
|
|
532
|
+
|
|
533
|
+
generatePowerShellNode(root, ' ', '$tokens', 0, lines);
|
|
534
|
+
|
|
535
|
+
lines.push('}');
|
|
536
|
+
lines.push('');
|
|
537
|
+
|
|
538
|
+
return lines.join('\n');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function generatePowerShellNode(
|
|
542
|
+
node: CompletionNode,
|
|
543
|
+
indent: string,
|
|
544
|
+
tokensVar: string,
|
|
545
|
+
depth: number,
|
|
546
|
+
lines: string[],
|
|
547
|
+
): void {
|
|
548
|
+
const visibleFlags = node.flags.filter((f) => !f.hidden);
|
|
549
|
+
const visibleSubs = node.subcommands.filter((s) => !s.hidden);
|
|
550
|
+
|
|
551
|
+
// Check if we need to dispatch to a subcommand
|
|
552
|
+
if (visibleSubs.length > 0) {
|
|
553
|
+
lines.push(`${indent}# Check for subcommand at position ${depth}`);
|
|
554
|
+
lines.push(`${indent}if (${tokensVar}.Length -gt ${depth}) {`);
|
|
555
|
+
lines.push(`${indent} switch (${tokensVar}[${depth}]) {`);
|
|
556
|
+
for (const sub of visibleSubs) {
|
|
557
|
+
const allNames = [sub.name, ...sub.aliases].map((n) => `'${n}'`).join(', ');
|
|
558
|
+
lines.push(`${indent} {$_ -in ${allNames}} {`);
|
|
559
|
+
|
|
560
|
+
const childNode = node.childNodes.get(sub.name);
|
|
561
|
+
if (childNode) {
|
|
562
|
+
generatePowerShellNode(childNode, indent + ' ', tokensVar, depth + 1, lines);
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
lines.push(`${indent} return`);
|
|
566
|
+
lines.push(`${indent} }`);
|
|
567
|
+
}
|
|
568
|
+
lines.push(`${indent} }`);
|
|
569
|
+
lines.push(`${indent}}`);
|
|
570
|
+
lines.push('');
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// Complete flags and subcommands at this level
|
|
574
|
+
const completions: string[] = [];
|
|
575
|
+
for (const f of visibleFlags) {
|
|
576
|
+
completions.push(`${indent}[System.Management.Automation.CompletionResult]::new('--${f.long}', '--${f.long}', 'ParameterName', '${escDq(f.description)}')`);
|
|
577
|
+
if (f.short) {
|
|
578
|
+
completions.push(`${indent}[System.Management.Automation.CompletionResult]::new('-${f.short}', '-${f.short}', 'ParameterName', '${escDq(f.description)}')`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
for (const sub of visibleSubs) {
|
|
582
|
+
completions.push(`${indent}[System.Management.Automation.CompletionResult]::new('${sub.name}', '${sub.name}', 'Command', '${escDq(sub.description)}')`);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
for (const c of completions) {
|
|
586
|
+
lines.push(c);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
// ---- Elvish Generator ----
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Elvish completions are a map from command path (segments joined by ';') to a
|
|
595
|
+
* lambda emitting candidates, which is how clap_complete shapes them: the
|
|
596
|
+
* completer walks the words it has seen to build the key, then calls it.
|
|
597
|
+
*/
|
|
598
|
+
function generateElvish(root: CompletionNode, binaryName: string): string {
|
|
599
|
+
const cases: string[] = [];
|
|
600
|
+
collectElvishCases(root, [binaryName], cases);
|
|
601
|
+
|
|
602
|
+
return [
|
|
603
|
+
`# elvish completion for ${binaryName}`,
|
|
604
|
+
'# Generated by clap-ts',
|
|
605
|
+
'',
|
|
606
|
+
'use builtin;',
|
|
607
|
+
'use str;',
|
|
608
|
+
'',
|
|
609
|
+
`set edit:completion:arg-completer[${binaryName}] = {|@words|`,
|
|
610
|
+
' fn spaces {|n|',
|
|
611
|
+
" builtin:repeat $n ' ' | str:join ''",
|
|
612
|
+
' }',
|
|
613
|
+
' fn cand {|text desc|',
|
|
614
|
+
" edit:complex-candidate $text &display=$text' '(spaces (- 14 (wcswidth $text)))$desc",
|
|
615
|
+
' }',
|
|
616
|
+
` var command = '${esc(binaryName)}'`,
|
|
617
|
+
' for word $words[1..-1] {',
|
|
618
|
+
" if (str:has-prefix $word '-') {",
|
|
619
|
+
' break',
|
|
620
|
+
' }',
|
|
621
|
+
" set command = $command';'$word",
|
|
622
|
+
' }',
|
|
623
|
+
' var completions = [',
|
|
624
|
+
...cases,
|
|
625
|
+
' ]',
|
|
626
|
+
' if (has-key $completions $command) {',
|
|
627
|
+
' $completions[$command]',
|
|
628
|
+
' }',
|
|
629
|
+
'}',
|
|
630
|
+
'',
|
|
631
|
+
].join('\n');
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function collectElvishCases(
|
|
635
|
+
node: CompletionNode,
|
|
636
|
+
path: readonly string[],
|
|
637
|
+
cases: string[],
|
|
638
|
+
): void {
|
|
639
|
+
const key = path.join(';');
|
|
640
|
+
cases.push(` &'${esc(key)}'= {`);
|
|
641
|
+
|
|
642
|
+
for (const f of node.flags) {
|
|
643
|
+
if (f.hidden) {
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
const desc = esc(f.description.replaceAll('\n', ' '));
|
|
647
|
+
if (f.short) {
|
|
648
|
+
cases.push(` cand -${f.short} '${desc}'`);
|
|
649
|
+
}
|
|
650
|
+
cases.push(` cand --${f.long} '${desc}'`);
|
|
651
|
+
}
|
|
652
|
+
for (const sub of node.subcommands) {
|
|
653
|
+
if (sub.hidden) {
|
|
654
|
+
continue;
|
|
655
|
+
}
|
|
656
|
+
const desc = esc(sub.description.replaceAll('\n', ' '));
|
|
657
|
+
for (const name of [sub.name, ...sub.aliases]) {
|
|
658
|
+
cases.push(` cand ${name} '${desc}'`);
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
cases.push(' }');
|
|
663
|
+
|
|
664
|
+
for (const sub of node.subcommands) {
|
|
665
|
+
const child = node.childNodes.get(sub.name);
|
|
666
|
+
if (child) {
|
|
667
|
+
collectElvishCases(child, [...path, sub.name], cases);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// ---- Nushell Generator ----
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Nushell completes through `extern` declarations, one per command path, with
|
|
676
|
+
* a `nu-complete` helper per argument that restricts its values.
|
|
677
|
+
*/
|
|
678
|
+
function generateNushell(root: CompletionNode, binaryName: string): string {
|
|
679
|
+
const lines: string[] = [];
|
|
680
|
+
|
|
681
|
+
lines.push(`# nushell completion for ${binaryName}`);
|
|
682
|
+
lines.push('# Generated by clap-ts');
|
|
683
|
+
lines.push('');
|
|
684
|
+
lines.push('module completions {');
|
|
685
|
+
lines.push('');
|
|
686
|
+
|
|
687
|
+
collectNushellCommand(root, [binaryName], lines);
|
|
688
|
+
|
|
689
|
+
lines.push('}');
|
|
690
|
+
lines.push('');
|
|
691
|
+
lines.push('export use completions *');
|
|
692
|
+
lines.push('');
|
|
693
|
+
|
|
694
|
+
return lines.join('\n');
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/** Nushell type annotation for a value, from its hint. */
|
|
698
|
+
function nushellType(hint: ValueHint | undefined): string {
|
|
699
|
+
switch (hint) {
|
|
700
|
+
case 'filePath':
|
|
701
|
+
case 'anyPath':
|
|
702
|
+
case 'executablePath':
|
|
703
|
+
return 'path';
|
|
704
|
+
case 'dirPath':
|
|
705
|
+
return 'directory';
|
|
706
|
+
default:
|
|
707
|
+
return 'string';
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function collectNushellCommand(
|
|
712
|
+
node: CompletionNode,
|
|
713
|
+
path: readonly string[],
|
|
714
|
+
lines: string[],
|
|
715
|
+
): void {
|
|
716
|
+
const commandPath = path.join(' ');
|
|
717
|
+
const visibleFlags = node.flags.filter((f) => !f.hidden);
|
|
718
|
+
const visiblePositionals = node.positionals.filter((a) => !a.hidden);
|
|
719
|
+
|
|
720
|
+
// Value completers first: nushell resolves them by name within the module.
|
|
721
|
+
for (const arg of [...visibleFlags, ...visiblePositionals]) {
|
|
722
|
+
if (arg.possibleValues.length === 0) {
|
|
723
|
+
continue;
|
|
724
|
+
}
|
|
725
|
+
lines.push(` def "nu-complete ${commandPath} ${arg.key}" [] {`);
|
|
726
|
+
lines.push(` [ ${arg.possibleValues.map((v) => `"${escDq(v)}"`).join(' ')} ]`);
|
|
727
|
+
lines.push(' }');
|
|
728
|
+
lines.push('');
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (node.description) {
|
|
732
|
+
lines.push(` # ${node.description.replaceAll('\n', ' ')}`);
|
|
733
|
+
}
|
|
734
|
+
lines.push(` export extern "${commandPath}" [`);
|
|
735
|
+
|
|
736
|
+
for (const f of visibleFlags) {
|
|
737
|
+
const short = f.short ? `(-${f.short})` : '';
|
|
738
|
+
let value = '';
|
|
739
|
+
if (f.takesValue) {
|
|
740
|
+
value =
|
|
741
|
+
f.possibleValues.length > 0
|
|
742
|
+
? `: string@"nu-complete ${commandPath} ${f.key}"`
|
|
743
|
+
: `: ${nushellType(f.valueHint)}`;
|
|
744
|
+
}
|
|
745
|
+
const comment = f.description ? ` # ${f.description.replaceAll('\n', ' ')}` : '';
|
|
746
|
+
lines.push(` --${f.long}${short}${value}${comment}`);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
for (const arg of visiblePositionals) {
|
|
750
|
+
const optional = arg.required ? '' : '?';
|
|
751
|
+
const type =
|
|
752
|
+
arg.possibleValues.length > 0
|
|
753
|
+
? `string@"nu-complete ${commandPath} ${arg.key}"`
|
|
754
|
+
: nushellType(arg.valueHint);
|
|
755
|
+
const comment = arg.description ? ` # ${arg.description.replaceAll('\n', ' ')}` : '';
|
|
756
|
+
lines.push(` ${arg.name}${optional}: ${type}${comment}`);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
lines.push(' ]');
|
|
760
|
+
lines.push('');
|
|
761
|
+
|
|
762
|
+
for (const sub of node.subcommands) {
|
|
763
|
+
if (sub.hidden) {
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
const child = node.childNodes.get(sub.name);
|
|
767
|
+
if (child) {
|
|
768
|
+
collectNushellCommand(child, [...path, sub.name], lines);
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// ---- Public API: Static Generation ----
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* Generate a shell completion script for the given command and shell.
|
|
777
|
+
*
|
|
778
|
+
* Usage:
|
|
779
|
+
* ```ts
|
|
780
|
+
* const script = generateCompletions(rootCommand, 'bash', 'my-cli');
|
|
781
|
+
* fs.writeFileSync('completions.bash', script);
|
|
782
|
+
* ```
|
|
783
|
+
*
|
|
784
|
+
* Users source the generated script in their shell config:
|
|
785
|
+
* - bash: `source completions.bash` or copy to `~/.local/share/bash-completion/completions/`
|
|
786
|
+
* - zsh: copy to a directory in `$fpath` (e.g., `~/.zsh/completions/`)
|
|
787
|
+
* - fish: copy to `~/.config/fish/completions/`
|
|
788
|
+
* - powershell: add to `$PROFILE`
|
|
789
|
+
* - elvish: source from `~/.config/elvish/rc.elv`
|
|
790
|
+
* - nushell: save and `source` it from `$nu.config-path`
|
|
791
|
+
*/
|
|
792
|
+
export function generateCompletions(
|
|
793
|
+
command: CommandDef,
|
|
794
|
+
shell: Shell,
|
|
795
|
+
binaryName?: string,
|
|
796
|
+
): string {
|
|
797
|
+
const name = binaryName ?? command.meta.name;
|
|
798
|
+
const root = buildCompletionTree(command, name);
|
|
799
|
+
|
|
800
|
+
switch (shell) {
|
|
801
|
+
case 'bash': return generateBash(root, name);
|
|
802
|
+
case 'zsh': return generateZsh(root, name);
|
|
803
|
+
case 'fish': return generateFish(root, name);
|
|
804
|
+
case 'powershell': return generatePowerShell(root, name);
|
|
805
|
+
case 'elvish': return generateElvish(root, name);
|
|
806
|
+
case 'nushell': return generateNushell(root, name);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// ---- Public API: Auto-inject completions subcommand ----
|
|
811
|
+
|
|
812
|
+
const VALID_SHELLS: readonly Shell[] = [
|
|
813
|
+
'bash',
|
|
814
|
+
'zsh',
|
|
815
|
+
'fish',
|
|
816
|
+
'powershell',
|
|
817
|
+
'elvish',
|
|
818
|
+
'nushell',
|
|
819
|
+
];
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Return a new command with a `completions` subcommand auto-injected.
|
|
823
|
+
* The subcommand generates shell completion scripts when invoked.
|
|
824
|
+
*
|
|
825
|
+
* ```ts
|
|
826
|
+
* const root = defineCommand({ ... });
|
|
827
|
+
* runMain(withCompletions(root));
|
|
828
|
+
* ```
|
|
829
|
+
*
|
|
830
|
+
* Then users run:
|
|
831
|
+
* ```bash
|
|
832
|
+
* eval "$(my-cli completions bash)"
|
|
833
|
+
* ```
|
|
834
|
+
*/
|
|
835
|
+
export function withCompletions(rootCommand: CommandDef<any>): CommandDef<any> {
|
|
836
|
+
const completionsCmd: CommandDef = {
|
|
837
|
+
meta: {
|
|
838
|
+
name: 'completions',
|
|
839
|
+
description: 'Generate shell completion script',
|
|
840
|
+
aliases: ['completion'],
|
|
841
|
+
},
|
|
842
|
+
args: {
|
|
843
|
+
shell: {
|
|
844
|
+
type: 'positional' as const,
|
|
845
|
+
valueName: 'SHELL',
|
|
846
|
+
required: true,
|
|
847
|
+
description: `Target shell: ${VALID_SHELLS.join(', ')}`,
|
|
848
|
+
valueParser: [...VALID_SHELLS],
|
|
849
|
+
},
|
|
850
|
+
},
|
|
851
|
+
run({ args, stdout }) {
|
|
852
|
+
stdout.write(generateCompletions(rootCommand, String(args['shell']) as Shell));
|
|
853
|
+
},
|
|
854
|
+
};
|
|
855
|
+
|
|
856
|
+
return {
|
|
857
|
+
...rootCommand,
|
|
858
|
+
subCommands: {
|
|
859
|
+
...rootCommand.subCommands,
|
|
860
|
+
completions: completionsCmd,
|
|
861
|
+
},
|
|
862
|
+
lazySubCommands: rootCommand.lazySubCommands,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|