diff --git a/completions/README.md b/completions/README.md new file mode 100644 index 0000000000..ecef88ed64 --- /dev/null +++ b/completions/README.md @@ -0,0 +1,74 @@ +# Shell completions for the `claude` CLI + +Tab completion for [Claude Code](https://code.claude.com/docs/en/overview) in bash, zsh, and fish. + +Instead of hard-coding flag and subcommand lists (which would go stale on every release), these scripts parse the output of `claude --help` at completion time, so completions always match the installed version of Claude Code — including nested subcommands like `claude mcp add` or `claude plugin marketplace`. Parsed help output is cached per CLI version under `${XDG_CACHE_HOME:-~/.cache}/claude-code-completions/`, so after the first use completions are instant. + +What you get: + +- Subcommand completion with descriptions (`claude `, `claude mcp `, `claude plugin marketplace `, ...) +- Flag completion with descriptions (`claude --`, `claude mcp add --`, ...) +- Value completion for enum flags (`--model`, `--permission-mode`, `--output-format`, `--effort`, `--scope`, `--transport`, ...) +- Directory/file completion for path flags (`--add-dir`, `--settings`, `--mcp-config`, ...) + +## Install + +### Bash + +Source the script from your `~/.bashrc`: + +```bash +echo "source /path/to/claude-code/completions/claude.bash" >> ~/.bashrc +``` + +Or copy it into your bash-completion directory so it loads on demand: + +```bash +# Linux +cp completions/claude.bash /etc/bash_completion.d/claude +# macOS with Homebrew bash-completion +cp completions/claude.bash "$(brew --prefix)/etc/bash_completion.d/claude" +``` + +### Zsh + +Copy `_claude` into any directory on your `$fpath` (before `compinit` runs): + +```zsh +mkdir -p ~/.zsh/completions +cp completions/_claude ~/.zsh/completions/_claude +``` + +Then make sure your `~/.zshrc` contains (order matters — `fpath` must be set before `compinit`): + +```zsh +fpath=(~/.zsh/completions $fpath) +autoload -Uz compinit && compinit +``` + +### Fish + +```fish +cp completions/claude.fish ~/.config/fish/completions/claude.fish +``` + +Completions are picked up automatically in new fish sessions — no configuration needed. This also shadows the static `claude` completions bundled with recent fish releases, which go stale as the CLI evolves. + +## How it works + +1. On first completion, the script runs `claude --help` (and `claude --help` as you descend into subcommands) and caches the output under `${XDG_CACHE_HOME:-~/.cache}/claude-code-completions//`. +2. The `Commands:` and `Options:` sections are parsed with `awk` into completion candidates, including descriptions where the shell supports them (zsh and fish). +3. The cache is keyed by `claude -v`, so upgrading Claude Code automatically produces fresh completions; stale caches for old versions can be safely deleted at any time: + +```sh +rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/claude-code-completions" +``` + +The scripts only ever execute `claude --help`, `claude -v`, and `claude --help` — a word typed on the command line is only passed to `claude` if a previous help output listed it as a subcommand. + +## Compatibility + +- bash 3.2+ (works with the stock macOS bash; no bash-completion package required) +- zsh 5.x with `compinit` +- fish 3.4+ +- Requires `awk` (BSD or GNU) — present on every supported platform diff --git a/completions/_claude b/completions/_claude new file mode 100644 index 0000000000..6f7e259411 --- /dev/null +++ b/completions/_claude @@ -0,0 +1,180 @@ +#compdef claude +# Zsh completion for claude (Claude Code CLI) +# +# Instead of hard-coding the flag and subcommand lists (which would go stale +# on every release), this script parses `claude --help` output at completion +# time and caches the result per installed CLI version, so completions always +# match the version of Claude Code you are running. +# +# Install: copy this file into a directory on your $fpath as `_claude` +# (see completions/README.md). + +__claude_cache_dir() { + print -r -- "${XDG_CACHE_HOME:-$HOME/.cache}/claude-code-completions" +} + +# Print (cached) `claude --help` output. +__claude_help() { + local ver dir cache key + if [[ -z ${__CLAUDE_COMPLETION_VERSION-} ]]; then + typeset -g __CLAUDE_COMPLETION_VERSION=${$(claude -v 2>/dev/null)%% *} + fi + ver=$__CLAUDE_COMPLETION_VERSION + [[ -n $ver ]] || return 1 + key="root${*:+ $*}" + key=${key// /_} + dir="$(__claude_cache_dir)/$ver" + cache="$dir/$key" + if [[ ! -s $cache ]]; then + mkdir -p "$dir" 2>/dev/null || return 1 + claude "$@" --help >"$cache" 2>/dev/null || { rm -f "$cache"; return 1 } + fi + cat "$cache" +} + +# Parse the "Commands:" section of help output into "namedescription" +# lines (aliases like update|upgrade are split into separate entries). +__claude_parse_commands() { + awk ' + /^Commands:/ { sec = 1; next } + /^[A-Za-z]/ { sec = 0 } + sec && /^ [a-z]/ { + line = $0 + sub(/^ /, "", line) + name = line + sub(/ .*$/, "", name) + desc = "" + if (match(line, / +/)) { + desc = substr(line, RSTART + RLENGTH) + } + n = split(name, aliases, "[|]") + for (i = 1; i <= n; i++) { + if (aliases[i] != "" && aliases[i] != "help") { + print aliases[i] "\t" desc + } + } + } + ' +} + +# Parse the "Options:" section of help output into "flagdescription" +# lines. +__claude_parse_flags() { + awk ' + /^Options:/ { sec = 1; next } + /^[A-Za-z]/ { sec = 0 } + sec && /^ -/ { + line = $0 + sub(/^ +/, "", line) + desc = "" + idx = index(line, " ") + if (idx) { + desc = substr(line, idx) + sub(/^ +/, "", desc) + line = substr(line, 1, idx - 1) + } + n = split(line, parts, /,? +/) + for (i = 1; i <= n; i++) { + p = parts[i] + if (p ~ /^--?[A-Za-z]/) { + sub(/[^A-Za-z0-9-].*$/, "", p) + print p "\t" desc + } + } + } + ' +} + +_claude() { + local cur=${words[CURRENT]} + local prev=${words[CURRENT-1]} + + # Value completion for flags with a known set of values. + case $prev in + --model | --fallback-model) + _values 'model' fable opus sonnet haiku + return + ;; + --permission-mode) + _values 'permission mode' acceptEdits auto bypassPermissions manual dontAsk plan + return + ;; + --output-format) + _values 'output format' text json stream-json + return + ;; + --input-format) + _values 'input format' text stream-json + return + ;; + --effort) + _values 'effort level' low medium high xhigh max + return + ;; + --setting-sources) + _values -s , 'setting source' user project local + return + ;; + -t | --transport) + _values 'transport' stdio sse http + return + ;; + -s | --scope) + _values 'scope' local user project + return + ;; + --add-dir | --plugin-dir | --cwd) + _files -/ + return + ;; + --settings | --mcp-config | --debug-file | --config | --system-prompt-file | --append-system-prompt-file) + _files + return + ;; + esac + + # Build the subcommand path from words typed so far, descending only into + # words that the CLI's own help output lists as subcommands (this also + # keeps us from ever executing `claude --help`). + local -a cmdpath + local w + for w in "${(@)words[2,CURRENT-1]}"; do + [[ $w == -* || -z $w ]] && continue + local -a known + known=(${(f)"$(__claude_help "${(@)cmdpath}" | __claude_parse_commands | cut -f1)"}) + if (( ${known[(Ie)$w]} )); then + cmdpath+=("$w") + fi + done + + local -a entries + if [[ $cur == -* ]]; then + entries=(${(f)"$(__claude_help "${(@)cmdpath}" | __claude_parse_flags)"}) + entries=(${entries//$'\t'/:}) + _describe -o 'option' entries + return + fi + + # Positional arguments with a known set of values. + case "${(j: :)cmdpath}" in + install) + _values 'version' stable latest + return + ;; + import) + _values 'source agent' codex gemini + return + ;; + esac + + entries=(${(f)"$(__claude_help "${(@)cmdpath}" | __claude_parse_commands)"}) + entries=(${entries//$'\t'/:}) + if (( ${#entries} )); then + _describe 'claude command' entries + fi + if (( ${#cmdpath} == 0 )); then + _default + fi +} + +_claude "$@" diff --git a/completions/claude.bash b/completions/claude.bash new file mode 100644 index 0000000000..722b47e6ab --- /dev/null +++ b/completions/claude.bash @@ -0,0 +1,183 @@ +# Bash completion for claude (Claude Code CLI) +# +# Instead of hard-coding the flag and subcommand lists (which would go stale +# on every release), this script parses `claude --help` output at completion +# time and caches the result per installed CLI version, so completions always +# match the version of Claude Code you are running. +# +# Install: source this file from your ~/.bashrc, or copy it into your +# bash-completion directory (see completions/README.md). + +__claude_cache_dir() { + printf '%s/claude-code-completions' "${XDG_CACHE_HOME:-$HOME/.cache}" +} + +# Print (cached) `claude --help` output. +__claude_help() { + local ver dir cache key + if [[ -z ${__CLAUDE_COMPLETION_VERSION-} ]]; then + __CLAUDE_COMPLETION_VERSION=$(claude -v 2>/dev/null | { read -r v _ && printf '%s' "$v"; }) + fi + ver=$__CLAUDE_COMPLETION_VERSION + [[ -n $ver ]] || return 1 + key="root${*:+ $*}" + key=${key// /_} + dir="$(__claude_cache_dir)/$ver" + cache="$dir/$key" + if [[ ! -s $cache ]]; then + mkdir -p "$dir" 2>/dev/null || return 1 + claude "$@" --help >"$cache" 2>/dev/null || { rm -f "$cache"; return 1; } + fi + cat "$cache" +} + +# Parse the "Commands:" section of help output into "namedescription" +# lines (aliases like update|upgrade are split into separate entries). +__claude_parse_commands() { + awk ' + /^Commands:/ { sec = 1; next } + /^[A-Za-z]/ { sec = 0 } + sec && /^ [a-z]/ { + line = $0 + sub(/^ /, "", line) + name = line + sub(/ .*$/, "", name) + desc = "" + if (match(line, / +/)) { + desc = substr(line, RSTART + RLENGTH) + } + n = split(name, aliases, "[|]") + for (i = 1; i <= n; i++) { + if (aliases[i] != "" && aliases[i] != "help") { + print aliases[i] "\t" desc + } + } + } + ' +} + +# Parse the "Options:" section of help output into "flagdescription" +# lines. +__claude_parse_flags() { + awk ' + /^Options:/ { sec = 1; next } + /^[A-Za-z]/ { sec = 0 } + sec && /^ -/ { + line = $0 + sub(/^ +/, "", line) + desc = "" + idx = index(line, " ") + if (idx) { + desc = substr(line, idx) + sub(/^ +/, "", desc) + line = substr(line, 1, idx - 1) + } + n = split(line, parts, /,? +/) + for (i = 1; i <= n; i++) { + p = parts[i] + if (p ~ /^--?[A-Za-z]/) { + sub(/[^A-Za-z0-9-].*$/, "", p) + print p "\t" desc + } + } + } + ' +} + +__claude_subcommands() { __claude_help "$@" | __claude_parse_commands | cut -f1; } +__claude_flags() { __claude_help "$@" | __claude_parse_flags | cut -f1; } + +_claude() { + local cur prev words cword + if type _init_completion >/dev/null 2>&1; then + _init_completion || return + else + COMPREPLY=() + cur=${COMP_WORDS[COMP_CWORD]} + prev=${COMP_WORDS[COMP_CWORD - 1]} + words=("${COMP_WORDS[@]}") + cword=$COMP_CWORD + fi + + # Value completion for flags with a known set of values. + case $prev in + --model | --fallback-model) + COMPREPLY=($(compgen -W "fable opus sonnet haiku" -- "$cur")) + return + ;; + --permission-mode) + COMPREPLY=($(compgen -W "acceptEdits auto bypassPermissions manual dontAsk plan" -- "$cur")) + return + ;; + --output-format) + COMPREPLY=($(compgen -W "text json stream-json" -- "$cur")) + return + ;; + --input-format) + COMPREPLY=($(compgen -W "text stream-json" -- "$cur")) + return + ;; + --effort) + COMPREPLY=($(compgen -W "low medium high xhigh max" -- "$cur")) + return + ;; + --setting-sources) + COMPREPLY=($(compgen -W "user project local" -- "$cur")) + return + ;; + -t | --transport) + COMPREPLY=($(compgen -W "stdio sse http" -- "$cur")) + return + ;; + -s | --scope) + COMPREPLY=($(compgen -W "local user project" -- "$cur")) + return + ;; + --add-dir | --plugin-dir | --cwd) + COMPREPLY=($(compgen -d -- "$cur")) + [[ ${#COMPREPLY[@]} -gt 0 ]] && type compopt >/dev/null 2>&1 && compopt -o filenames + return + ;; + --settings | --mcp-config | --debug-file | --config | --system-prompt-file | --append-system-prompt-file) + COMPREPLY=($(compgen -f -- "$cur")) + [[ ${#COMPREPLY[@]} -gt 0 ]] && type compopt >/dev/null 2>&1 && compopt -o filenames + return + ;; + esac + + # Build the subcommand path from words typed so far, descending only into + # words that the CLI's own help output lists as subcommands (this also + # keeps us from ever executing `claude --help`). + local path=() i w s + for ((i = 1; i < cword; i++)); do + w=${words[i]} + [[ $w == -* || -z $w ]] && continue + for s in $(__claude_subcommands "${path[@]}"); do + if [[ $s == "$w" ]]; then + path[${#path[@]}]=$w + break + fi + done + done + + if [[ $cur == -* ]]; then + COMPREPLY=($(compgen -W "$(__claude_flags "${path[@]}")" -- "$cur")) + return + fi + + # Positional arguments with a known set of values. + case "${path[*]-}" in + install) + COMPREPLY=($(compgen -W "stable latest" -- "$cur")) + return + ;; + import) + COMPREPLY=($(compgen -W "codex gemini" -- "$cur")) + return + ;; + esac + + COMPREPLY=($(compgen -W "$(__claude_subcommands "${path[@]}")" -- "$cur")) +} + +complete -F _claude claude diff --git a/completions/claude.fish b/completions/claude.fish new file mode 100644 index 0000000000..0757487c62 --- /dev/null +++ b/completions/claude.fish @@ -0,0 +1,155 @@ +# Fish completion for claude (Claude Code CLI) +# +# Instead of hard-coding the flag and subcommand lists (which would go stale +# on every release), this script parses `claude --help` output at completion +# time and caches the result per installed CLI version, so completions always +# match the version of Claude Code you are running. +# +# Install: copy this file to ~/.config/fish/completions/claude.fish +# (see completions/README.md). + +function __claude_cache_dir + if set -q XDG_CACHE_HOME[1] + echo $XDG_CACHE_HOME/claude-code-completions + else + echo $HOME/.cache/claude-code-completions + end +end + +# Print (cached) `claude --help` output. +function __claude_help + if not set -q __claude_completion_version + set -g __claude_completion_version (claude -v 2>/dev/null | string split ' ')[1] + end + test -n "$__claude_completion_version"; or return 1 + set -l key (string join _ root $argv) + set -l dir (__claude_cache_dir)/$__claude_completion_version + set -l cache $dir/$key + if not test -s $cache + mkdir -p $dir 2>/dev/null; or return 1 + if not claude $argv --help >$cache 2>/dev/null + rm -f $cache + return 1 + end + end + cat $cache +end + +# Parse the "Commands:" section of help output into "namedescription" +# lines (aliases like update|upgrade are split into separate entries). +function __claude_parse_commands + awk ' + /^Commands:/ { sec = 1; next } + /^[A-Za-z]/ { sec = 0 } + sec && /^ [a-z]/ { + line = $0 + sub(/^ /, "", line) + name = line + sub(/ .*$/, "", name) + desc = "" + if (match(line, / +/)) { + desc = substr(line, RSTART + RLENGTH) + } + n = split(name, aliases, "[|]") + for (i = 1; i <= n; i++) { + if (aliases[i] != "" && aliases[i] != "help") { + print aliases[i] "\t" desc + } + } + } + ' +end + +# Parse the "Options:" section of help output into "flagdescription" +# lines. +function __claude_parse_flags + awk ' + /^Options:/ { sec = 1; next } + /^[A-Za-z]/ { sec = 0 } + sec && /^ -/ { + line = $0 + sub(/^ +/, "", line) + desc = "" + idx = index(line, " ") + if (idx) { + desc = substr(line, idx) + sub(/^ +/, "", desc) + line = substr(line, 1, idx - 1) + } + n = split(line, parts, /,? +/) + for (i = 1; i <= n; i++) { + p = parts[i] + if (p ~ /^--?[A-Za-z]/) { + sub(/[^A-Za-z0-9-].*$/, "", p) + print p "\t" desc + } + } + } + ' +end + +# The subcommand path typed so far, descending only into words that the CLI's +# own help output lists as subcommands (this also keeps us from ever executing +# `claude --help`). +function __claude_cmd_path + set -l tokens (commandline -opc) + set -e tokens[1] + set -l path + for t in $tokens + string match -q -- '-*' $t; and continue + test -n "$t"; or continue + if contains -- $t (__claude_help $path | __claude_parse_commands | cut -f1) + set -a path $t + end + end + for t in $path + echo $t + end +end + +function __claude_complete_subcommands + __claude_help (__claude_cmd_path) | __claude_parse_commands +end + +function __claude_complete_flags + __claude_help (__claude_cmd_path) | __claude_parse_flags +end + +# True when the previous token is any of the given flags. +function __claude_prev_in + set -l tokens (commandline -opc) + contains -- $tokens[-1] $argv +end + +# True when the current token position expects a subcommand or flag (i.e. the +# previous token is not a flag that takes a value we complete separately). +set -g __claude_value_flags --model --fallback-model --permission-mode \ + --output-format --input-format --effort --setting-sources \ + -t --transport -s --scope --add-dir --plugin-dir --cwd \ + --settings --mcp-config --debug-file --config \ + --system-prompt-file --append-system-prompt-file +function __claude_wants_command + not __claude_prev_in $__claude_value_flags +end + +complete -c claude -f + +# Subcommands and flags, parsed from the CLI's own help output. +complete -c claude -n __claude_wants_command -a '(__claude_complete_subcommands)' +complete -c claude -n __claude_wants_command -a '(__claude_complete_flags)' + +# Value completion for flags with a known set of values. +complete -c claude -x -n '__claude_prev_in --model --fallback-model' -a 'fable opus sonnet haiku' +complete -c claude -x -n '__claude_prev_in --permission-mode' -a 'acceptEdits auto bypassPermissions manual dontAsk plan' +complete -c claude -x -n '__claude_prev_in --output-format' -a 'text json stream-json' +complete -c claude -x -n '__claude_prev_in --input-format' -a 'text stream-json' +complete -c claude -x -n '__claude_prev_in --effort' -a 'low medium high xhigh max' +complete -c claude -x -n '__claude_prev_in --setting-sources' -a 'user project local' +complete -c claude -x -n '__claude_prev_in -t --transport' -a 'stdio sse http' +complete -c claude -x -n '__claude_prev_in -s --scope' -a 'local user project' +complete -c claude -x -n '__claude_prev_in --add-dir --plugin-dir --cwd' -a '(__fish_complete_directories)' +complete -c claude -F -n '__claude_prev_in --settings --mcp-config --debug-file --config --system-prompt-file --append-system-prompt-file' + +# Positional arguments with a known set of values. +complete -c claude -n '__claude_wants_command; and test "$(__claude_cmd_path)" = install' -a 'stable\t"Latest stable version" latest\t"Latest version"' +complete -c claude -n '__claude_wants_command; and test "$(__claude_cmd_path)" = import' -a 'codex\t"Import from Codex" gemini\t"Import from Gemini"'