Shell completion
This API is available since Optique 0.6.0.
Shell completion enhances command-line user experience by providing intelligent suggestions for commands, options, and arguments as users type. Optique provides built-in completion support for Bash, zsh, fish, PowerShell, and Nushell that integrates seamlessly with the existing parser architecture.
Unlike many CLI frameworks that require separate completion definitions, Optique's completion system leverages the same parser structure used for argument parsing. This eliminates code duplication and ensures completion suggestions stay synchronized with your CLI's actual behavior.
How completion works
Optique's completion system operates through three key components:
Parser.suggest()methods- Each parser provides completion suggestions based on the current parsing context
- Shell script generation
- Optique generates completion scripts for Bash, zsh, fish, PowerShell, and Nushell that integrate with shell completion systems
- Runtime completion
- Your application automatically handles completion requests triggered by the generated scripts
When a user presses Tab, the shell calls your application with special arguments that Optique intercepts. Your parsers provide suggestions for the current context, which are then filtered and displayed by the shell.
import { object } from "@optique/core/constructs";
import { argument, option } from "@optique/core/primitives";
import { string, choice } from "@optique/core/valueparser";
import { run } from "@optique/run";
const parser = object({
format: option("-f", "--format", choice(["json", "yaml", "xml"])),
input: argument(string({ metavar: "FILE" })),
});
// Enable completion with a single option
const config = run(parser, { completion: "both" });Users can then generate and install completion scripts:
myapp completion bash > ~/.bashrc.d/myapp.bash
source ~/.bashrc.d/myapp.bashmyapp completion zsh > ~/.zsh/completions/_myappmyapp completion fish > ~/.config/fish/completions/myapp.fishmyapp completion pwsh > $PROFILE/../myapp-completion.ps1
Add-Content $PROFILE ". $PROFILE/../myapp-completion.ps1"myapp completion nu | save myapp-completion.nu
source myapp-completion.nuThe Suggestion type
Optique uses a discriminated union to represent different types of completion suggestions:
export type Suggestion =
| {
readonly kind: "literal";
readonly text: string;
readonly description?: Message;
}
| {
readonly kind: "file";
readonly pattern?: string;
readonly type: "file" | "directory" | "any";
readonly extensions?: readonly string[];
readonly includeHidden?: boolean;
readonly description?: Message;
};Literal suggestions
Literal suggestions provide exact text completions for things like option names, subcommands, or predefined values:
// Suggests: ["--format", "--input", "--help"]
const suggestions = suggest(parser, ["--"]);File suggestions
File suggestions delegate completion to the shell's native file system integration. This provides better performance and handles platform-specific behaviors like symlinks, permissions, and hidden files:
// Uses shell's native file completion
const fileParser = argument(path({ extensions: [".json", ".yaml"] }));Parser.suggest() methods
All Optique parsers implement an optional suggest() method that provides context-aware completion suggestions. Parser combinators automatically compose suggestions from their constituent parsers.
Primitive parser suggestions
Primitive parsers provide suggestions based on their specific roles:
// Option parsers suggest their names
suggest(option("-v", "--verbose"), ["--v"]);
// Returns: [{ kind: "literal", text: "--verbose" }]
// Command parsers suggest their command names
suggest(command("build", parser), ["bu"]);
// Returns: [{ kind: "literal", text: "build" }]
// Command aliases are suggested too
suggest(command("install", parser, { aliases: ["i"] }), [""]);
// Returns: [{ kind: "literal", text: "install" },
// { kind: "literal", text: "i" }]
// Argument parsers delegate to their value parsers
suggest(argument(choice(["start", "stop"])), ["st"]);
// Returns: [{ kind: "literal", text: "start" }, { kind: "literal", text: "stop" }]Combinator composition
Parser combinators automatically combine suggestions from their constituent parsers:
const parser = object({
action: option("-a", "--action", choice(["start", "stop"])),
verbose: option("-v", "--verbose"),
});
// Suggests all available options
suggest(parser, ["--"]);
// Returns: ["--action", "--verbose", "--help"]
// Suggests values for specific options
suggest(parser, ["--action", "st"]);
// Returns: ["start", "stop"]Context-aware suggestions
The suggest() method receives the current parsing context, allowing for sophisticated completion logic:
// Suggests different options based on what's already parsed
const parser = or(
object({ command: constant("build"), target: argument(string()) }),
object({ command: constant("test"), file: argument(path()) })
);
suggest(parser, ["build", ""]);
// Suggests completions for 'target' argument
suggest(parser, ["test", ""]);
// Suggests completions for 'file' argumentValueParser.suggest() methods
Value parsers provide domain-specific completion suggestions for their respective data types. Optique includes several built-in value parsers with intelligent completion support.
Built-in value parser suggestions
// URL parser suggests protocol completions
const urlParser = url({ allowedProtocols: ["https:", "http:", "ftp:"] });
urlParser.suggest?.("ht");
// Returns: ["http://", "https://"]
// Choice parser suggests available options
const formatParser = choice(["json", "yaml", "xml"]);
formatParser.suggest?.("j");
// Returns: ["json"]
// Locale parser suggests common locale identifiers
const localeParser = locale();
localeParser.suggest?.("en");
// Returns: ["en", "en-US", "en-GB", "en-CA", ...]
// Timezone parser uses Intl.supportedValuesOf for dynamic suggestions
const timezoneParser = timeZone();
timezoneParser.suggest?.("America/");
// Returns: ["America/New_York", "America/Chicago", ...]Custom value parser suggestions
You can implement suggest() methods in custom value parsers:
function customParser(): ValueParser<"sync", string> {
return {
mode: "sync",
metavar: "CUSTOM",
placeholder: "",
parse(input) {
// Parsing logic...
},
format(value) {
return value;
},
*suggest(prefix) {
const options = ["option1", "option2", "option3"];
for (const option of options) {
if (option.startsWith(prefix.toLowerCase())) {
yield { kind: "literal", text: option };
}
}
},
};
}Async completion sources
When the candidate set for completions depends on network I/O, filesystem reads, a remote API, or any other source that requires await, implement suggest() as an async generator. The runtime consumes it with for await transparently; the sync and async completion paths stay separated at the top level.
The async suggest contract
- Only yield items whose
textstarts withprefix. Shells do not always re-filter the result, so filtering is the generator's responsibility. - Wrap the entire generator body in
try/catch. Completion is best-effort: a network failure must never propagate as an uncaught exception, because that would break the user's shell session. Log the error and return whatever has been yielded so far. - Yielding in multiple batches is fine. Do not rely on side effects from later yields—the runtime may stop consuming the generator early.
Example: GitHub issue number parser
The example below parses a GitHub issue number (such as #42) and provides async completion by fetching open issues from the GitHub API:
function issueNumber(repo: string): ValueParser<"async", number> {
return {
mode: "async",
metavar: "ISSUE",
placeholder: 0,
async parse(input: string): Promise<ValueParserResult<number>> {
if (!/^#?\d+$/.test(input)) {
return {
success: false,
error: message`Expected a GitHub issue number, got ${input}.`,
};
}
return { success: true, value: Number(input.replace(/^#/, "")) };
},
format(value: number): string {
return `#${value}`;
},
async *suggest(prefix: string): AsyncIterable<Suggestion> {
const q = prefix.replace(/^#/, "");
if (!/^\d*$/.test(q)) return;
// Normalize so that typing "42" suggests "#42" just like typing "#4" would.
const normalizedPrefix = prefix.startsWith("#") ? prefix : `#${prefix}`;
// Guard against path-traversal: require exactly owner/repo with no empty,
// ".", or ".." segments (encodeURIComponent does not encode dots).
const repoSegments = repo.split("/");
if (
repoSegments.length !== 2 ||
repoSegments.some((s) => s === "" || s === "." || s === "..")
) return;
const repoPath = repoSegments.map(encodeURIComponent).join("/");
try {
const resp = await fetch(
`https://api.github.com/repos/${repoPath}/issues?state=open&per_page=30`,
);
if (!resp.ok) return;
const issues = await resp.json() as ReadonlyArray<{
readonly number: number;
readonly title: string;
readonly pull_request?: unknown;
}>;
for (const issue of issues) {
if (issue.pull_request != null) continue; // GitHub /issues also returns PRs
const text = `#${issue.number}`;
if (!text.startsWith(normalizedPrefix)) continue;
yield {
kind: "literal",
text,
description: message`${issue.title}`,
};
}
} catch (error) {
logger.debug("GitHub issue suggestion failed.", { repo, error });
}
},
};
}Four things to notice:
- The
try/catchwraps the entire generator body. An unreachable API produces an empty completion list, not a crash. pull_request != nullskips pull requests, because the GitHub/issuesendpoint returns both issues and PRs.- The prefix is normalized to start with
#before filtering, so that a user typing42gets the same suggestions as one typing#42. - The
descriptionfield usesmessage`…`so richer shells (zsh, fish, PowerShell, Nushell) display the issue title alongside the number. Plain Bash ignores it.
Combining multiple sources
When completions span multiple backends, merge them in one generator with a dedup Set and yield in priority order. The @optique/git package's gitRef parser demonstrates this pattern—it yields branches first, then tags, then commits, deduplicating by name:
async function* suggestRef(prefix: string): AsyncIterable<Suggestion> {
const seen = new Set<string>();
for await (const name of listBranches(prefix)) {
if (seen.has(name)) continue;
seen.add(name);
yield { kind: "literal", text: name, description: message`branch` };
}
for await (const name of listTags(prefix)) {
if (seen.has(name)) continue;
seen.add(name);
yield { kind: "literal", text: name, description: message`tag` };
}
}Bounding expensive lookups
Pass a depth or page-size limit via constructor options and validate it at parser-construction time. The @optique/git package uses suggestionDepth as the canonical option name; following the same name makes your parsers consistent with the existing ecosystem:
function remoteParser(
endpoint: string,
options?: { readonly suggestionDepth?: number },
): ValueParser<"async", string> {
const depth = options?.suggestionDepth ?? 50;
if (!Number.isInteger(depth) || depth < 1) {
throw new RangeError("suggestionDepth must be a positive integer.");
}
return {
mode: "async",
metavar: "VALUE",
placeholder: "",
async parse(input: string): Promise<ValueParserResult<string>> {
return { success: true, value: input };
},
format(value: string): string {
return value;
},
async *suggest(prefix: string): AsyncIterable<Suggestion> {
try {
const resp = await fetch(
`${endpoint}?q=${encodeURIComponent(prefix)}&limit=${depth}`,
);
if (!resp.ok) return;
const items = await resp.json() as readonly string[];
for (const item of items) {
if (item.startsWith(prefix)) {
yield { kind: "literal", text: item };
}
}
} catch {
// Swallow errors — log via your logger in production.
}
},
};
}See Git integration for a complete real-world example backed by an actual Git repository.
Shell script generation
Optique generates completion scripts that integrate with each shell's native completion system. The generated scripts handle the complexity of shell integration while delegating suggestion logic to your application.
Bash completion
Bash completion scripts use the complete command to register completion functions. The generated script handles:
- Option and command name completion
- Value completion for options with
=syntax - Native file completion using
compgen - Proper handling of special characters and spaces
zsh completion
zsh completion scripts use the compdef system and _describe function for rich completion display. The generated script supports:
- Completion descriptions displayed alongside suggestions
- Native file completion using
_files - Advanced completion contexts and filtering
- Integration with zsh's completion styling system
fish completion
fish completion scripts use a function-based approach with the complete command. The generated script provides:
- Tab-separated completion descriptions (
value\tdescriptionformat) - Automatic argument parsing using
commandlineutility - Native file completion using fish globbing and string matching
- Extension filtering and hidden file handling
- Auto-loading from ~/.config/fish/completions/ directory
PowerShell completion
PowerShell completion scripts use Register-ArgumentCompleter with the -Native parameter to integrate with PowerShell's completion system. The generated script provides:
- AST-based argument extraction for robust parsing
CompletionResultobjects with descriptions and tooltips- Native file completion using
Get-ChildItem - Support for hidden files and extension filtering
- Cross-platform compatibility (Windows, Linux, macOS)
Nushell completion
Nushell completion scripts use the $env.config.completions.external.completer system to provide completions for external commands. The generated script provides:
- Custom completer registration that integrates with Nushell's completion system
- Context-aware completion using custom argument parsing
- Structured data return values with
valueanddescriptionfields - Native file completion using Nushell's
lscommand andmatchexpressions - Tab-separated encoding format for CLI communication
- Support for file type filtering and hidden file handling
- Automatic preservation of existing completers for other commands
Integration with run()
The run() function from @optique/run provides seamless completion integration. Enable completion by adding the completion option to your run() configuration.
Completion configuration
import { object } from "@optique/core/constructs";
import { argument, option } from "@optique/core/primitives";
import { string, choice } from "@optique/core/valueparser";
import { run } from "@optique/run";
const parser = object({
format: option("-f", "--format", choice(["json", "yaml"])),
input: argument(string()),
});
const config = run(parser, { completion: "both" });Completion configuration
The command and option properties control how completion is triggered:
command: true- Completion via subcommand (
myapp completion bash) option: true- Completion via option (
myapp --completion bash)
Both can be enabled simultaneously.
Command name customization
By default, the completion command is named completion and the option is --completion. You can customize the command name by passing a configuration object instead of true:
import { object } from "@optique/core/constructs";
import { run } from "@optique/run";
const parser = object({});
const config = run(parser, {
completion: {
command: { names: ["completions"] }, // Use "completions" command name
option: true,
}
});To register multiple command names (e.g., both singular and plural), pass an array. Additional names after the first are hidden from help output by default:
import { object } from "@optique/core/constructs";
import { run } from "@optique/run";
const parser = object({});
run(parser, {
completion: {
command: { names: ["completion", "completions"] },
option: true,
},
});Automatic handling
When completion is enabled, the run() function automatically:
- Detects completion requests before normal argument parsing
- Generates shell scripts when requested
- Provides runtime completion suggestions
- Handles output formatting for each shell
- Exits with appropriate status codes
Custom shell support
By default, Optique provides completion for Bash, zsh, fish, PowerShell, and Nushell. You can add custom shell completions or override the defaults using the shells option:
import type { ShellCompletion } from "@optique/core/completion";
import { object } from "@optique/core/constructs";
import { argument } from "@optique/core/primitives";
import { string } from "@optique/core/valueparser";
import { run } from "@optique/run";
const parser = object({
name: argument(string()),
});
// Create a custom shell completion
const customShell: ShellCompletion = {
name: "custom",
generateScript(programName: string, args: readonly string[] = []): string {
return `# Custom shell completion script for ${programName}`;
},
*encodeSuggestions(suggestions) {
for (const suggestion of suggestions) {
if (suggestion.kind === "literal") {
yield `${suggestion.text}\n`;
}
}
},
};
run(parser, {
completion: {
command: true,
option: true,
shells: { custom: customShell }, // Add custom shell
},
});The custom shell completion will be merged with the default shells, making all shells available. You can also override default shells by using the same name (e.g., bash, zsh, fish, pwsh, or nu).
Setup instructions
Follow these steps to add shell completion to your CLI application:
1. Enable completion in your application
import { run } from "@optique/run";
const config = run(parser, { completion: "both" });2. Generate completion scripts
Users can generate completion scripts for their preferred shell:
myapp completion bash > ~/.bashrc.d/myapp.bashmyapp completion zsh > ~/.zsh/completions/_myapp
# or
myapp completion zsh > ~/.oh-my-zsh/completions/_myappmyapp completion fish > ~/.config/fish/completions/myapp.fishmyapp completion pwsh > myapp-completion.ps1myapp completion nu | save myapp-completion.nu3. Source or install the completion script
# Bash: Source the script in your shell configuration
echo "source ~/.bashrc.d/myapp.bash" >> ~/.bashrc# zsh: Ensure completion directory is in fpath
# (usually automatic with oh-my-zsh)# fish: Completions are automatically loaded from ~/.config/fish/completions/
# No additional configuration needed - just restart fish or run:
fish_update_completions# PowerShell: Add to your profile
Add-Content $PROFILE ". $PWD/myapp-completion.ps1"
# Or load in current session
. ./myapp-completion.ps1# Nushell: Source the completion script in your config
# The script automatically registers the completer when loaded
source myapp-completion.nu
# Or add to your config file to load on startup:
# echo "source ~/myapp-completion.nu" | save --append $nu.config-path4. Test completion
# Test that completion is working
myapp <TAB>
myapp --format <TAB>
myapp --format=<TAB>Distribution considerations
For published CLI tools, consider:
- Including completion installation instructions in your README
- Providing install scripts that automatically set up completion
- Supporting common completion directories (/etc/bash_completion.d/, /usr/share/zsh/site-functions/)
- Documenting completion setup in your help text
Completion significantly improves CLI usability and is expected by users of modern command-line tools. Optique's built-in support makes adding completion straightforward while maintaining type safety and consistency with your parser definitions.