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
76 changes: 75 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,13 +262,87 @@ program.parse();
```

The Commander integration supports customising the command name to generate the shell completion script. The default is `complete`. If you use a custom name
like `completion` then it will be visible in the help as `completion <shell>`, while the runtime suggestions will be hiddden (`complete -- [args...]`).
like `completion` then it will be visible in the help as `completion <shell>`, while the runtime suggestions will be hidden (`complete -- [args...]`).
You'll need to use your custom command when following examples on this page to generate the shell completion script.

```javascript
const completion = tab(program, { completionCommandName: 'completion' });
```

## 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, options?)` — 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',
{
// Defaults to ['complete', '--'], so generated scripts call:
// my-cli complete -- <inputs>
completionEntrypoint: ['complete', '--'],
}
);
} 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.

If your CLI exposes a different hidden completion endpoint, configure the
generated script to call that endpoint instead:

```typescript
script('zsh', 'my-cli', 'my-cli', {
completionEntrypoint: ['--complete'],
});
```

The generated script will request completions with
`my-cli --complete <inputs>` instead of the default
`my-cli complete -- <inputs>`.

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

### Custom Integrations

tab uses a standardized completion protocol that any CLI can implement:
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 @@ -39,6 +39,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.

16 changes: 14 additions & 2 deletions src/bash.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { ShellCompDirective } from './t';
import {
formatCompletionEntrypointForShell,
type ScriptOptions,
} from './completion-entrypoint';

export function generate(name: string, exec: string): string {
export function generate(
name: string,
exec: string,
options: ScriptOptions = {}
): string {
// Replace '-' and ':' with '_' for variable names
const nameForVar = name.replace(/[-:]/g, '_');
const completionEntrypoint = formatCompletionEntrypointForShell(options);
const completionEntrypointSegment = completionEntrypoint
? ` ${completionEntrypoint}`
: '';

// Shell completion directives
const ShellCompDirectiveError = ShellCompDirective.ShellCompDirectiveError;
Expand Down Expand Up @@ -42,7 +54,7 @@ __${nameForVar}_complete() {
local requestComp out directive

# Build the command to get completions
requestComp="${exec} complete -- \${words[@]:1}"
requestComp="${exec}${completionEntrypointSegment} \${words[@]:1}"

# Add an empty parameter if the last parameter is complete
if [[ -z "$cur" ]]; then
Expand Down
44 changes: 44 additions & 0 deletions src/completion-entrypoint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
export type CompletionEntrypoint = readonly string[];

export interface ScriptOptions {
readonly completionEntrypoint?: CompletionEntrypoint;
}

export const DEFAULT_COMPLETION_ENTRYPOINT = ['complete', '--'] as const;

const SAFE_POSIX_ARG = /^[A-Za-z0-9_./:=+-]+$/;
const SAFE_POWERSHELL_ARG = /^[A-Za-z0-9_./:=+-]+$/;

function quotePosixArg(value: string): string {
if (value !== '' && SAFE_POSIX_ARG.test(value)) {
return value;
}

return `'${value.replace(/'/g, `'\\''`)}'`;
}

function quotePowerShellArg(value: string): string {
if (value !== '--' && value !== '' && SAFE_POWERSHELL_ARG.test(value)) {
return value;
}

return `'${value.replace(/'/g, `''`)}'`;
}

export function getCompletionEntrypoint(
options: ScriptOptions = {}
): CompletionEntrypoint {
return options.completionEntrypoint ?? DEFAULT_COMPLETION_ENTRYPOINT;
}

export function formatCompletionEntrypointForShell(
options: ScriptOptions = {}
): string {
return getCompletionEntrypoint(options).map(quotePosixArg).join(' ');
}

export function formatCompletionEntrypointForPowerShell(
options: ScriptOptions = {}
): string {
return getCompletionEntrypoint(options).map(quotePowerShellArg).join(' ');
}
18 changes: 15 additions & 3 deletions src/fish.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { ShellCompDirective } from './t';

export function generate(name: string, exec: string): string {
import {
formatCompletionEntrypointForShell,
type ScriptOptions,
} from './completion-entrypoint';

export function generate(
name: string,
exec: string,
options: ScriptOptions = {}
): string {
// Replace '-' and ':' with '_' for variable names
const nameForVar = name.replace(/[-:]/g, '_');
const completionEntrypoint = formatCompletionEntrypointForShell(options);
const completionEntrypointSegment = completionEntrypoint
? ` ${completionEntrypoint}`
: '';

// Shell completion directives
const ShellCompDirectiveError = ShellCompDirective.ShellCompDirectiveError;
Expand Down Expand Up @@ -38,7 +50,7 @@ function __${nameForVar}_perform_completion
__${nameForVar}_debug "last arg: $lastArg"

# Build the completion request command
set -l requestComp "${exec} complete -- (string join ' ' -- (string escape -- $args[2..-1])) $lastArg"
set -l requestComp "${exec}${completionEntrypointSegment} (string join ' ' -- (string escape -- $args[2..-1])) $lastArg"

__${nameForVar}_debug "Calling $requestComp"
set -l results (eval $requestComp 2> /dev/null)
Expand Down
Loading
Loading