Skip to content

Commit ee3848d

Browse files
committed
preview wip
1 parent a2ad133 commit ee3848d

6 files changed

Lines changed: 347 additions & 6 deletions

File tree

README.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,63 @@ if (portOption) {
220220
program.parse();
221221
```
222222

223+
## Bring Your Own Completion Logic
224+
225+
If your CLI framework already implements the logic for figuring out what to
226+
suggest from a partial argv (the "what" half), you can use tab purely for the
227+
shell-side glue (the "how" half) — generated shell scripts and wire-protocol
228+
emission — across bash, zsh, fish, and powershell, without redeclaring your
229+
CLI's structure to tab.
230+
231+
Two public functions cover this case:
232+
233+
- `script(shell, name, exec)` — print the shell-side completion script.
234+
- `emitCompletions(completions, directive)` — write a finished
235+
`Completion[]` plus a directive in the wire format the shell scripts
236+
consume (`value\tdescription\n…\n:N\n`).
237+
238+
```typescript
239+
import {
240+
emitCompletions,
241+
script,
242+
ShellCompDirective,
243+
type Completion,
244+
type Directive,
245+
} from '@bomb.sh/tab';
246+
247+
// Your CLI's existing logic — tab is not told about its structure.
248+
declare function myCliResolveCompletions(
249+
argv: readonly string[]
250+
): Promise<Completion[]>;
251+
252+
const argv = process.argv.slice(2);
253+
254+
if (argv[0] === 'complete') {
255+
const second = argv[1];
256+
if (['bash', 'zsh', 'fish', 'powershell'].includes(second)) {
257+
script(
258+
second as 'bash' | 'zsh' | 'fish' | 'powershell',
259+
'my-cli',
260+
'my-cli'
261+
);
262+
} else if (second === '--') {
263+
const completions = await myCliResolveCompletions(argv.slice(2));
264+
const directive: Directive =
265+
ShellCompDirective.ShellCompDirectiveNoFileComp;
266+
emitCompletions(completions, directive);
267+
}
268+
}
269+
```
270+
271+
`emitCompletions` performs no filtering, deduplication, or sanitization — it
272+
emits exactly what you pass. Values and descriptions must not contain TAB or
273+
newline characters, since those are the protocol delimiters.
274+
275+
A working integration with [stricli](https://github.com/bloomberg/stricli) is
276+
in `examples/demo.stricli.ts`.
277+
278+
---
279+
223280
tab uses a standardized completion protocol that any CLI can implement:
224281

225282
```bash

examples/demo.stricli.ts

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
/**
2+
* Stricli + tab integration demo.
3+
*
4+
* This shows how a CLI that already implements its own completion-resolution
5+
* logic (the (a) half) can plug into tab purely for the shell-protocol /
6+
* shell-script half (the (b) half).
7+
*
8+
* The shape is:
9+
* - `<exec> complete <shell>` -> tab generates the shell script
10+
* - `<exec> complete -- <argv>` -> stricli computes completions, tab emits
11+
* them in the wire format the script reads
12+
*
13+
* Run any of:
14+
* pnpm tsx examples/demo.stricli.ts complete -- ""
15+
* pnpm tsx examples/demo.stricli.ts complete -- dev --port=
16+
* pnpm tsx examples/demo.stricli.ts complete -- dev --mode prod
17+
* pnpm tsx examples/demo.stricli.ts complete bash
18+
*/
19+
import {
20+
buildApplication,
21+
buildCommand,
22+
buildRouteMap,
23+
numberParser,
24+
proposeCompletions,
25+
type InputCompletion,
26+
} from '@stricli/core';
27+
import {
28+
emitCompletions,
29+
script,
30+
ShellCompDirective,
31+
type Completion,
32+
type Directive,
33+
} from '../src/t';
34+
35+
// --- (1) Build a tiny stricli application ----------------------------------
36+
37+
const devCommand = buildCommand({
38+
loader: async () => () => {
39+
/* impl not needed for completion demo */
40+
},
41+
parameters: {
42+
flags: {
43+
port: {
44+
kind: 'parsed',
45+
parse: numberParser,
46+
brief: 'Port to listen on',
47+
optional: true,
48+
},
49+
mode: {
50+
kind: 'enum',
51+
values: ['development', 'production'] as const,
52+
brief: 'Build mode',
53+
optional: true,
54+
},
55+
verbose: {
56+
kind: 'boolean',
57+
brief: 'Enable verbose logging',
58+
optional: true,
59+
},
60+
},
61+
},
62+
docs: { brief: 'Start dev server' },
63+
});
64+
65+
const buildCmd = buildCommand({
66+
loader: async () => () => {},
67+
parameters: { flags: {} },
68+
docs: { brief: 'Build the project' },
69+
});
70+
71+
const root = buildRouteMap({
72+
routes: { dev: devCommand, build: buildCmd },
73+
docs: { brief: 'Demo CLI using stricli for (a) and tab for (b)' },
74+
});
75+
76+
const app = buildApplication(root, {
77+
name: 'demo-stricli',
78+
versionInfo: { currentVersion: '0.0.0' },
79+
});
80+
81+
// --- (2) Wire up the `complete` subcommand ---------------------------------
82+
83+
async function main() {
84+
const argv = process.argv.slice(2);
85+
86+
if (argv[0] !== 'complete') {
87+
console.log('Demo CLI. Use "complete <shell>" or "complete -- <args>".');
88+
return;
89+
}
90+
91+
const second = argv[1];
92+
const SUPPORTED_SHELLS = ['bash', 'zsh', 'fish', 'powershell'] as const;
93+
type Shell = (typeof SUPPORTED_SHELLS)[number];
94+
95+
// a) `complete <shell>` -> use tab to print the shell-side completion script
96+
if (second && (SUPPORTED_SHELLS as readonly string[]).includes(second)) {
97+
script(
98+
second as Shell,
99+
'demo-stricli',
100+
'pnpm tsx examples/demo.stricli.ts'
101+
);
102+
return;
103+
}
104+
105+
// b) `complete -- <args>` -> use stricli to compute completions,
106+
// then hand the finished list to tab to emit on the wire.
107+
if (second === '--') {
108+
const inputs = argv.slice(2);
109+
const stricliCompletions = await proposeCompletions(app, inputs, {
110+
process,
111+
});
112+
113+
const completions = stricliCompletions.map(toTabCompletion);
114+
const directive: Directive =
115+
ShellCompDirective.ShellCompDirectiveNoFileComp;
116+
emitCompletions(completions, directive);
117+
return;
118+
}
119+
120+
console.error('Usage: complete <shell> | complete -- <args>');
121+
process.exit(1);
122+
}
123+
124+
/**
125+
* Map stricli's `InputCompletion` shape ({ kind, completion, brief }) to tab's
126+
* `Completion` shape ({ value, description }). The `kind` is informational
127+
* only — tab's wire format doesn't care about it.
128+
*/
129+
function toTabCompletion(c: InputCompletion): Completion {
130+
return { value: c.completion, description: c.brief };
131+
}
132+
133+
main().catch((err) => {
134+
console.error(err);
135+
process.exit(1);
136+
});

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"devDependencies": {
3838
"@changesets/cli": "^2.29.6",
3939
"@eslint/js": "^9.33.0",
40+
"@stricli/core": "^1.2.6",
4041
"@types/node": "^22.7.4",
4142
"cac": "^6.7.14",
4243
"citty": "^0.2.0",

pnpm-lock.yaml

Lines changed: 8 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/t.ts

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@ export const ShellCompDirective = {
1010
ShellCompDirectiveDefault: 0,
1111
};
1212

13+
/**
14+
* Bitmask of `ShellCompDirective` values describing how the shell should treat
15+
* the emitted completions (e.g. whether to disable file fallback, suppress the
16+
* trailing space, preserve order, etc.).
17+
*/
18+
export type Directive = number;
19+
1320
export type OptionsMap = Map<string, Option>;
1421

1522
export type Complete = (value: string, description: string) => void;
@@ -25,6 +32,44 @@ export interface Completion {
2532
value: string;
2633
}
2734

35+
export interface EmitCompletionsOptions {
36+
/**
37+
* Stream to write the completion protocol to. Defaults to `process.stdout`,
38+
* which is what the shell wrappers generated by `setup()` / `script()` read
39+
* from.
40+
*/
41+
stream?: NodeJS.WritableStream;
42+
}
43+
44+
/**
45+
* Write a list of completions and a trailing directive line to a stream in the
46+
* wire format expected by the shell scripts produced by `setup()` / `script()`.
47+
*
48+
* Format:
49+
* <value>\t<description>\n
50+
* ...
51+
* :<directive>\n
52+
*
53+
* This is the (b) "shell-protocol" half of tab. Use it when you already have
54+
* your own logic for producing the completion list (the (a) half), and only
55+
* want tab for shell-script generation + protocol emission.
56+
*
57+
* No filtering, deduplication, or sanitization is performed. Callers are
58+
* expected to pass the final list as it should appear to the shell. Values
59+
* and descriptions must not contain TAB or newline characters.
60+
*/
61+
export function emitCompletions(
62+
completions: readonly Completion[],
63+
directive: Directive = ShellCompDirective.ShellCompDirectiveDefault,
64+
options: EmitCompletionsOptions = {}
65+
): void {
66+
const stream = options.stream ?? process.stdout;
67+
for (const comp of completions) {
68+
stream.write(`${comp.value}\t${comp.description ?? ''}\n`);
69+
}
70+
stream.write(`:${directive}\n`);
71+
}
72+
2873
export type ArgumentHandler = (
2974
this: Argument,
3075
complete: Complete,
@@ -390,7 +435,7 @@ export class RootCommand extends Command {
390435
this.directive = ShellCompDirective.ShellCompDirectiveNoFileComp;
391436

392437
const seen = new Set<string>();
393-
this.completions
438+
const filtered = this.completions
394439
.filter((comp) => {
395440
if (seen.has(comp.value)) return false;
396441
seen.add(comp.value);
@@ -403,11 +448,9 @@ export class RootCommand extends Command {
403448
return comp.value.startsWith(valueToComplete);
404449
}
405450
return comp.value.startsWith(toComplete);
406-
})
407-
.forEach((comp) =>
408-
console.log(`${comp.value}\t${comp.description ?? ''}`)
409-
);
410-
console.log(`:${this.directive}`);
451+
});
452+
453+
emitCompletions(filtered, this.directive);
411454
}
412455

413456
parse(args: string[]) {

0 commit comments

Comments
 (0)