Skip to content
Draft
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
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,63 @@ if (portOption) {
program.parse();
```

## Bring Your Own Completion Logic

If your CLI framework already implements the logic for figuring out what to
suggest from a partial argv (the "what" half), you can use tab purely for the
shell-side glue (the "how" half) — generated shell scripts and wire-protocol
emission — across bash, zsh, fish, and powershell, without redeclaring your
CLI's structure to tab.

Two public functions cover this case:

- `script(shell, name, exec)` — print the shell-side completion script.
- `emitCompletions(completions, directive)` — write a finished
`Completion[]` plus a directive in the wire format the shell scripts
consume (`value\tdescription\n…\n:N\n`).

```typescript
import {
emitCompletions,
script,
ShellCompDirective,
type Completion,
type Directive,
} from '@bomb.sh/tab';

// Your CLI's existing logic — tab is not told about its structure.
declare function myCliResolveCompletions(
argv: readonly string[]
): Promise<Completion[]>;

const argv = process.argv.slice(2);

if (argv[0] === 'complete') {
const second = argv[1];
if (['bash', 'zsh', 'fish', 'powershell'].includes(second)) {
script(
second as 'bash' | 'zsh' | 'fish' | 'powershell',
'my-cli',
'my-cli'
);
} else if (second === '--') {
const completions = await myCliResolveCompletions(argv.slice(2));
const directive: Directive =
ShellCompDirective.ShellCompDirectiveNoFileComp;
emitCompletions(completions, directive);
}
}
```

`emitCompletions` performs no filtering, deduplication, or sanitization — it
emits exactly what you pass. Values and descriptions must not contain TAB or
newline characters, since those are the protocol delimiters.

A working integration with [stricli](https://github.com/bloomberg/stricli) is
in `examples/demo.stricli.ts`.

---

tab uses a standardized completion protocol that any CLI can implement:

```bash
Expand Down
136 changes: 136 additions & 0 deletions examples/demo.stricli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* Stricli + tab integration demo.
*
* This shows how a CLI that already implements its own completion-resolution
* logic (the (a) half) can plug into tab purely for the shell-protocol /
* shell-script half (the (b) half).
*
* The shape is:
* - `<exec> complete <shell>` -> tab generates the shell script
* - `<exec> complete -- <argv>` -> stricli computes completions, tab emits
* them in the wire format the script reads
*
* Run any of:
* pnpm tsx examples/demo.stricli.ts complete -- ""
* pnpm tsx examples/demo.stricli.ts complete -- dev --port=
* pnpm tsx examples/demo.stricli.ts complete -- dev --mode prod
* pnpm tsx examples/demo.stricli.ts complete bash
*/
import {
buildApplication,
buildCommand,
buildRouteMap,
numberParser,
proposeCompletions,
type InputCompletion,
} from '@stricli/core';
import {
emitCompletions,
script,
ShellCompDirective,
type Completion,
type Directive,
} from '../src/t';

// --- (1) Build a tiny stricli application ----------------------------------

const devCommand = buildCommand({
loader: async () => () => {
/* impl not needed for completion demo */
},
parameters: {
flags: {
port: {
kind: 'parsed',
parse: numberParser,
brief: 'Port to listen on',
optional: true,
},
mode: {
kind: 'enum',
values: ['development', 'production'] as const,
brief: 'Build mode',
optional: true,
},
verbose: {
kind: 'boolean',
brief: 'Enable verbose logging',
optional: true,
},
},
},
docs: { brief: 'Start dev server' },
});

const buildCmd = buildCommand({
loader: async () => () => {},
parameters: { flags: {} },
docs: { brief: 'Build the project' },
});

const root = buildRouteMap({
routes: { dev: devCommand, build: buildCmd },
docs: { brief: 'Demo CLI using stricli for (a) and tab for (b)' },
});

const app = buildApplication(root, {
name: 'demo-stricli',
versionInfo: { currentVersion: '0.0.0' },
});

// --- (2) Wire up the `complete` subcommand ---------------------------------

async function main() {
const argv = process.argv.slice(2);

if (argv[0] !== 'complete') {
console.log('Demo CLI. Use "complete <shell>" or "complete -- <args>".');
return;
}

const second = argv[1];
const SUPPORTED_SHELLS = ['bash', 'zsh', 'fish', 'powershell'] as const;
type Shell = (typeof SUPPORTED_SHELLS)[number];

// a) `complete <shell>` -> use tab to print the shell-side completion script
if (second && (SUPPORTED_SHELLS as readonly string[]).includes(second)) {
script(
second as Shell,
'demo-stricli',
'pnpm tsx examples/demo.stricli.ts'
);
return;
}

// b) `complete -- <args>` -> use stricli to compute completions,
// then hand the finished list to tab to emit on the wire.
if (second === '--') {
const inputs = argv.slice(2);
const stricliCompletions = await proposeCompletions(app, inputs, {
process,
});

const completions = stricliCompletions.map(toTabCompletion);
const directive: Directive =
ShellCompDirective.ShellCompDirectiveNoFileComp;
emitCompletions(completions, directive);
return;
}

console.error('Usage: complete <shell> | complete -- <args>');
process.exit(1);
}

/**
* Map stricli's `InputCompletion` shape ({ kind, completion, brief }) to tab's
* `Completion` shape ({ value, description }). The `kind` is informational
* only — tab's wire format doesn't care about it.
*/
function toTabCompletion(c: InputCompletion): Completion {
return { value: c.completion, description: c.brief };
}

main().catch((err) => {
console.error(err);
process.exit(1);
});
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"devDependencies": {
"@changesets/cli": "^2.29.6",
"@eslint/js": "^9.33.0",
"@stricli/core": "^1.2.6",
"@types/node": "^22.7.4",
"cac": "^6.7.14",
"citty": "^0.2.0",
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 49 additions & 6 deletions src/t.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ export const ShellCompDirective = {
ShellCompDirectiveDefault: 0,
};

/**
* Bitmask of `ShellCompDirective` values describing how the shell should treat
* the emitted completions (e.g. whether to disable file fallback, suppress the
* trailing space, preserve order, etc.).
*/
export type Directive = number;

export type OptionsMap = Map<string, Option>;

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

export interface EmitCompletionsOptions {
/**
* Stream to write the completion protocol to. Defaults to `process.stdout`,
* which is what the shell wrappers generated by `setup()` / `script()` read
* from.
*/
stream?: NodeJS.WritableStream;
}

/**
* Write a list of completions and a trailing directive line to a stream in the
* wire format expected by the shell scripts produced by `setup()` / `script()`.
*
* Format:
* <value>\t<description>\n
* ...
* :<directive>\n
*
* This is the (b) "shell-protocol" half of tab. Use it when you already have
* your own logic for producing the completion list (the (a) half), and only
* want tab for shell-script generation + protocol emission.
*
* No filtering, deduplication, or sanitization is performed. Callers are
* expected to pass the final list as it should appear to the shell. Values
* and descriptions must not contain TAB or newline characters.
*/
export function emitCompletions(
completions: readonly Completion[],
directive: Directive = ShellCompDirective.ShellCompDirectiveDefault,
options: EmitCompletionsOptions = {}
): void {
const stream = options.stream ?? process.stdout;
for (const comp of completions) {
stream.write(`${comp.value}\t${comp.description ?? ''}\n`);
}
stream.write(`:${directive}\n`);
}

export type ArgumentHandler = (
this: Argument,
complete: Complete,
Expand Down Expand Up @@ -390,7 +435,7 @@ export class RootCommand extends Command {
this.directive = ShellCompDirective.ShellCompDirectiveNoFileComp;

const seen = new Set<string>();
this.completions
const filtered = this.completions
.filter((comp) => {
if (seen.has(comp.value)) return false;
seen.add(comp.value);
Expand All @@ -403,11 +448,9 @@ export class RootCommand extends Command {
return comp.value.startsWith(valueToComplete);
}
return comp.value.startsWith(toComplete);
})
.forEach((comp) =>
console.log(`${comp.value}\t${comp.description ?? ''}`)
);
console.log(`:${this.directive}`);
});

emitCompletions(filtered, this.directive);
}

parse(args: string[]) {
Expand Down
Loading
Loading