diff --git a/README.md b/README.md index e691729..3808b0f 100644 --- a/README.md +++ b/README.md @@ -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 `, while the runtime suggestions will be hiddden (`complete -- [args...]`). +like `completion` then it will be visible in the help as `completion `, 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; + +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 -- + 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 ` instead of the default +`my-cli complete -- `. + +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: diff --git a/examples/demo.stricli.ts b/examples/demo.stricli.ts new file mode 100644 index 0000000..f3fcdd4 --- /dev/null +++ b/examples/demo.stricli.ts @@ -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: + * - ` complete ` -> tab generates the shell script + * - ` complete -- ` -> 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 " or "complete -- ".'); + return; + } + + const second = argv[1]; + const SUPPORTED_SHELLS = ['bash', 'zsh', 'fish', 'powershell'] as const; + type Shell = (typeof SUPPORTED_SHELLS)[number]; + + // a) `complete ` -> 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 -- ` -> 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 | complete -- '); + 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); +}); diff --git a/package.json b/package.json index c881c13..557044a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8666a04..af9cb52 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@eslint/js': specifier: ^9.33.0 version: 9.33.0 + '@stricli/core': + specifier: ^1.2.6 + version: 1.2.6 '@types/node': specifier: ^22.7.4 version: 22.7.5 @@ -713,6 +716,9 @@ packages: cpu: [x64] os: [win32] + '@stricli/core@1.2.6': + resolution: {integrity: sha512-j5fa1wyOLrP9WJqqLFEJeQviqb3cK46K+FXTuISEkG/H5C820YfKDoVQ3CDVdM5WLhEx1ARdpiW6+hU4tYHjCQ==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -2285,6 +2291,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.24.2': optional: true + '@stricli/core@1.2.6': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 diff --git a/src/bash.ts b/src/bash.ts index 3e3a4f2..852b078 100644 --- a/src/bash.ts +++ b/src/bash.ts @@ -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; @@ -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 diff --git a/src/completion-entrypoint.ts b/src/completion-entrypoint.ts new file mode 100644 index 0000000..de855c5 --- /dev/null +++ b/src/completion-entrypoint.ts @@ -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(' '); +} diff --git a/src/fish.ts b/src/fish.ts index 28e016b..1dd8a05 100644 --- a/src/fish.ts +++ b/src/fish.ts @@ -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; @@ -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) diff --git a/src/powershell.ts b/src/powershell.ts index 44e1bc4..7b83255 100644 --- a/src/powershell.ts +++ b/src/powershell.ts @@ -1,10 +1,22 @@ import { ShellCompDirective } from './t'; +import { + formatCompletionEntrypointForPowerShell, + type ScriptOptions, +} from './completion-entrypoint'; // TODO: issue with -- -- completions -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 = formatCompletionEntrypointForPowerShell(options); + const completionEntrypointSegment = completionEntrypoint + ? ` ${completionEntrypoint}` + : ''; // Determine the completion command // const compCmd = includeDesc ? "complete" : "complete"; @@ -74,7 +86,7 @@ export function generate(name: string, exec: string): string { $QuotedArgs = ($Arguments -split ' ' | ForEach-Object { "'" + ($_ -replace "'", "''") + "'" }) -join ' ' __${name}_debug "QuotedArgs: $QuotedArgs" - $RequestComp = "& ${exec} complete '--' $QuotedArgs" + $RequestComp = "& ${exec}${completionEntrypointSegment} $QuotedArgs" __${name}_debug "RequestComp: $RequestComp" # we cannot use $WordToComplete because it diff --git a/src/t.ts b/src/t.ts index b22cd9c..442d10f 100644 --- a/src/t.ts +++ b/src/t.ts @@ -1,4 +1,11 @@ // Shell directive constants +import type { ScriptOptions } from './completion-entrypoint'; + +export type { + CompletionEntrypoint, + ScriptOptions, +} from './completion-entrypoint'; + export const ShellCompDirective = { ShellCompDirectiveError: 1 << 0, ShellCompDirectiveNoSpace: 1 << 1, @@ -10,6 +17,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; export type Complete = (value: string, description: string) => void; @@ -25,6 +39,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: + * \t\n + * ... + * :\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, @@ -390,7 +442,7 @@ export class RootCommand extends Command { this.directive = ShellCompDirective.ShellCompDirectiveNoFileComp; const seen = new Set(); - this.completions + const filtered = this.completions .filter((comp) => { if (seen.has(comp.value)) return false; seen.add(comp.value); @@ -403,11 +455,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[]) { @@ -456,7 +506,12 @@ export class RootCommand extends Command { this.complete(toComplete); } - setup(name: string, executable: string, shell: string) { + setup( + name: string, + executable: string, + shell: string, + options: ScriptOptions = {} + ) { assert( shell === 'zsh' || shell === 'bash' || @@ -467,22 +522,22 @@ export class RootCommand extends Command { switch (shell) { case 'zsh': { - const script = zsh.generate(name, executable); + const script = zsh.generate(name, executable, options); console.log(script); break; } case 'bash': { - const script = bash.generate(name, executable); + const script = bash.generate(name, executable, options); console.log(script); break; } case 'fish': { - const script = fish.generate(name, executable); + const script = fish.generate(name, executable, options); console.log(script); break; } case 'powershell': { - const script = powershell.generate(name, executable); + const script = powershell.generate(name, executable, options); console.log(script); break; } @@ -492,8 +547,13 @@ export class RootCommand extends Command { const t = new RootCommand(); -export function script(shell: string, name: string, executable: string) { - t.setup(name, executable, shell); +export function script( + shell: string, + name: string, + executable: string, + options: ScriptOptions = {} +) { + t.setup(name, executable, shell, options); } export default t; diff --git a/src/zsh.ts b/src/zsh.ts index 46d3512..af1409e 100644 --- a/src/zsh.ts +++ b/src/zsh.ts @@ -1,6 +1,19 @@ import { ShellCompDirective } from './t'; +import { + formatCompletionEntrypointForShell, + type ScriptOptions, +} from './completion-entrypoint'; + +export function generate( + name: string, + exec: string, + options: ScriptOptions = {} +) { + const completionEntrypoint = formatCompletionEntrypointForShell(options); + const completionEntrypointSegment = completionEntrypoint + ? ` ${completionEntrypoint}` + : ''; -export function generate(name: string, exec: string) { return `#compdef ${name} compdef _${name} ${name} @@ -59,7 +72,7 @@ _${name}() { local quoted_args=("\${(@q)args_to_quote}") # Join the main command and the quoted arguments into a single string for eval - requestComp="${exec} complete -- \${quoted_args[*]}" + requestComp="${exec}${completionEntrypointSegment} \${quoted_args[*]}" __${name}_debug "About to call: eval \${requestComp}" diff --git a/tests/cli.test.ts b/tests/cli.test.ts index 742a509..81ee0dc 100644 --- a/tests/cli.test.ts +++ b/tests/cli.test.ts @@ -1,5 +1,6 @@ import { exec } from 'child_process'; -import { describe, it, expect, test } from 'vitest'; +import { describe, it, expect, test, vi } from 'vitest'; +import { script, type ScriptOptions } from '../src/t'; function runCommand(command: string): Promise { return new Promise((resolve, reject) => { @@ -13,6 +14,21 @@ function runCommand(command: string): Promise { }); } +function captureGeneratedScript( + shell: string, + options: ScriptOptions = {} +): string { + const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + try { + script(shell, 'demo', 'demo', options); + expect(consoleSpy).toHaveBeenCalledTimes(1); + return String(consoleSpy.mock.calls[0][0]); + } finally { + consoleSpy.mockRestore(); + } +} + const cliTools = ['t', 'citty', 'cac', 'commander']; describe.each(cliTools)('cli completion tests for %s', (cliTool) => { @@ -466,4 +482,34 @@ describe('shell completion script generation', () => { expect(output).toContain(`# ${shell} completion for`); expect(output.length).toBeGreaterThan(100); // Ensure we got a substantial script }); + + test.each(shells)( + 'should keep the default %s completion entrypoint', + (shell) => { + const output = captureGeneratedScript(shell); + + if (shell === 'powershell') { + expect(output).toContain("$RequestComp = \"& demo complete '--' "); + } else { + expect(output).toContain('demo complete -- '); + } + } + ); + + test.each(shells)( + 'should generate %s script with a custom completion entrypoint', + (shell) => { + const output = captureGeneratedScript(shell, { + completionEntrypoint: ['--complete'], + }); + + if (shell === 'powershell') { + expect(output).toContain('$RequestComp = "& demo --complete '); + } else { + expect(output).toContain('demo --complete '); + } + + expect(output).not.toContain('demo complete -- '); + } + ); }); diff --git a/tests/emit-completions.test.ts b/tests/emit-completions.test.ts new file mode 100644 index 0000000..5f3bd1a --- /dev/null +++ b/tests/emit-completions.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { Writable } from 'node:stream'; +import { emitCompletions, ShellCompDirective, type Completion } from '../src/t'; + +function captureOutput(fn: (stream: NodeJS.WritableStream) => void): string { + const chunks: Buffer[] = []; + const stream = new Writable({ + write(chunk, _enc, cb) { + chunks.push(Buffer.from(chunk)); + cb(); + }, + }); + fn(stream); + return Buffer.concat(chunks).toString('utf8'); +} + +describe('emitCompletions', () => { + it('writes value/description lines followed by a directive line', () => { + const completions: Completion[] = [ + { value: '3000', description: 'Development port' }, + { value: '8080', description: 'Production port' }, + ]; + + const out = captureOutput((stream) => + emitCompletions( + completions, + ShellCompDirective.ShellCompDirectiveNoFileComp, + { stream } + ) + ); + + expect(out).toBe( + '3000\tDevelopment port\n' + + '8080\tProduction port\n' + + `:${ShellCompDirective.ShellCompDirectiveNoFileComp}\n` + ); + }); + + it('treats missing description as empty string', () => { + const out = captureOutput((stream) => + emitCompletions([{ value: 'foo' }], 0, { stream }) + ); + + expect(out).toBe('foo\t\n:0\n'); + }); + + it('emits only the directive line when there are no completions', () => { + const out = captureOutput((stream) => + emitCompletions([], ShellCompDirective.ShellCompDirectiveNoFileComp, { + stream, + }) + ); + + expect(out).toBe(`:${ShellCompDirective.ShellCompDirectiveNoFileComp}\n`); + }); + + it('defaults the directive to ShellCompDirectiveDefault (0) when omitted', () => { + const out = captureOutput((stream) => + emitCompletions([{ value: 'a', description: 'A' }], undefined, { + stream, + }) + ); + + expect(out).toBe('a\tA\n:0\n'); + }); + + it('supports composing directives with the bitwise OR operator', () => { + const directive = + ShellCompDirective.ShellCompDirectiveNoFileComp | + ShellCompDirective.ShellCompDirectiveKeepOrder; + + const out = captureOutput((stream) => + emitCompletions([{ value: 'x' }], directive, { stream }) + ); + + expect(out).toBe(`x\t\n:${directive}\n`); + }); + + it('does not filter, dedup, or reorder the input list', () => { + // Caller-supplied logic owns filtering. emitCompletions is a pure emitter: + // duplicates and out-of-prefix entries are passed through verbatim. + const out = captureOutput((stream) => + emitCompletions( + [ + { value: 'b', description: 'second' }, + { value: 'a', description: 'first' }, + { value: 'b', description: 'duplicate' }, + ], + 0, + { stream } + ) + ); + + expect(out).toBe('b\tsecond\na\tfirst\nb\tduplicate\n:0\n'); + }); +});