From a5043a936f9419525b2448607d072e551866cbc0 Mon Sep 17 00:00:00 2001 From: Junhyuk Lee Date: Mon, 27 Apr 2026 16:45:28 -0500 Subject: [PATCH] Make CLI completion available without new dependencies The OpenAI CLI already builds its command tree through argparse, so the completion command reuses that parser structure to emit shell scripts for bash, zsh, fish, and PowerShell instead of adding argcomplete or maintaining hand-written command lists. Constraint: No new runtime dependency for CLI completion.\nRejected: Add argcomplete optional extra | duplicates stale PR #1603 and adds dependency/install friction.\nConfidence: medium\nScope-risk: narrow\nTested: PYTHONPATH=src pytest -q tests/test_cli_completion.py; PYTHONPATH=src python -m ruff check src/openai/cli/_cli.py src/openai/cli/_completion.py tests/test_cli_completion.py; PYTHONPATH=src python -m ruff format --check src/openai/cli/_cli.py src/openai/cli/_completion.py tests/test_cli_completion.py; PYTHONPATH=src python -m mypy src/openai/cli/_completion.py tests/test_cli_completion.py; python3 -m py_compile src/openai/cli/_cli.py src/openai/cli/_completion.py\nNot-tested: Interactive shell tab-completion in bash/zsh/fish/PowerShell. --- README.md | 20 ++++ src/openai/cli/_cli.py | 4 +- src/openai/cli/_completion.py | 187 ++++++++++++++++++++++++++++++++++ tests/test_cli_completion.py | 34 +++++++ 4 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 src/openai/cli/_completion.py create mode 100644 tests/test_cli_completion.py diff --git a/README.md b/README.md index 9450c0bc51..11a9d4f720 100644 --- a/README.md +++ b/README.md @@ -933,6 +933,26 @@ In addition to the options provided in the base `OpenAI` client, the following o An example of using the client with Microsoft Entra ID (formerly known as Azure Active Directory) can be found [here](https://github.com/openai/openai-python/blob/main/examples/azure_ad.py). +## CLI shell completion + +The `openai` CLI can generate shell completion scripts without extra dependencies: + +```sh +# Bash +eval "$(openai completion bash)" + +# Zsh +eval "$(openai completion zsh)" + +# Fish +openai completion fish > ~/.config/fish/completions/openai.fish + +# PowerShell +openai completion powershell | Out-String | Invoke-Expression +``` + +To make Bash or Zsh completion persistent, write the generated script to a file loaded by your shell startup configuration. + ## Versioning This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions: diff --git a/src/openai/cli/_cli.py b/src/openai/cli/_cli.py index d31196da50..870437ddab 100644 --- a/src/openai/cli/_cli.py +++ b/src/openai/cli/_cli.py @@ -11,7 +11,7 @@ import openai -from . import _tools +from . import _tools, _completion from .. import _ApiType, __version__ from ._api import register_commands from ._utils import can_use_http2 @@ -120,6 +120,8 @@ def help() -> None: sub_tools = subparsers.add_parser("tools", help="Client side tools for convenience") _tools.register_commands(sub_tools, subparsers) + _completion.register(subparsers, parser) + return parser diff --git a/src/openai/cli/_completion.py b/src/openai/cli/_completion.py new file mode 100644 index 0000000000..447e4d4d46 --- /dev/null +++ b/src/openai/cli/_completion.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import sys +import argparse +from typing import Literal + +from ._models import BaseModel + +Shell = Literal["bash", "zsh", "fish", "powershell"] + + +class CompletionArgs(BaseModel): + shell: Shell + + +def register(subparsers: argparse._SubParsersAction[argparse.ArgumentParser], parser: argparse.ArgumentParser) -> None: + sub = subparsers.add_parser( + "completion", + help="Generate shell completion script", + description="Generate a shell completion script for the OpenAI CLI.", + ) + sub.add_argument( + "shell", + choices=("bash", "zsh", "fish", "powershell"), + help="Shell to generate completions for.", + ) + sub.set_defaults( + func=lambda args: print_completion(parser, args.shell), + args_model=CompletionArgs, + ) + + +def print_completion(parser: argparse.ArgumentParser, shell: Shell) -> None: + sys.stdout.write(generate_completion(parser, shell)) + + +def generate_completion(parser: argparse.ArgumentParser, shell: Shell) -> str: + commands = _collect_commands(parser) + top_level = sorted(commands[()]) + api_commands = sorted(commands.get(("api",), [])) + tool_commands = sorted(commands.get(("tools",), [])) + shells = ["bash", "zsh", "fish", "powershell"] + + if shell == "bash": + return _bash(top_level, api_commands, tool_commands, shells) + if shell == "zsh": + return _zsh(top_level, api_commands, tool_commands, shells) + if shell == "fish": + return _fish(top_level, api_commands, tool_commands, shells) + return _powershell(top_level, api_commands, tool_commands, shells) + + +def _collect_commands(parser: argparse.ArgumentParser) -> dict[tuple[str, ...], list[str]]: + commands: dict[tuple[str, ...], list[str]] = {} + + def visit(current: argparse.ArgumentParser, path: tuple[str, ...]) -> None: + for action in current._actions: + choices = getattr(action, "choices", None) + if not isinstance(choices, dict) or not choices: + continue + subcommands = [name for name in choices if name != "completion"] + if path == (): + subcommands.append("completion") + commands[path] = sorted(set(subcommands)) + for name, subparser in choices.items(): + if isinstance(subparser, argparse.ArgumentParser): + visit(subparser, (*path, name)) + + visit(parser, ()) + return commands + + +def _quote_words(words: list[str]) -> str: + return " ".join(words) + + +def _bash( + top_level: list[str], + api_commands: list[str], + tool_commands: list[str], + shells: list[str], +) -> str: + return f'''# bash completion for openai +_openai_completion() {{ + local cur root + COMPREPLY=() + cur="${{COMP_WORDS[COMP_CWORD]}}" + root="${{COMP_WORDS[1]}}" + + case "$root" in + api) + if [[ $COMP_CWORD -eq 2 ]]; then + COMPREPLY=( $(compgen -W "{_quote_words(api_commands)}" -- "$cur") ) + fi + ;; + tools) + if [[ $COMP_CWORD -eq 2 ]]; then + COMPREPLY=( $(compgen -W "{_quote_words(tool_commands)}" -- "$cur") ) + fi + ;; + completion) + if [[ $COMP_CWORD -eq 2 ]]; then + COMPREPLY=( $(compgen -W "{_quote_words(shells)}" -- "$cur") ) + fi + ;; + *) + if [[ "$cur" == -* ]]; then + COMPREPLY=( $(compgen -W "-v --verbose -b --api-base -k --api-key -p --proxy -o --organization -t --api-type --api-version --azure-endpoint --azure-ad-token -V --version -h --help" -- "$cur") ) + else + COMPREPLY=( $(compgen -W "{_quote_words(top_level)}" -- "$cur") ) + fi + ;; + esac +}} +complete -F _openai_completion openai +''' + + +def _zsh( + top_level: list[str], + api_commands: list[str], + tool_commands: list[str], + shells: list[str], +) -> str: + return f"""#compdef openai +# zsh completion for openai +_openai() {{ + local -a commands api_commands tool_commands shells + commands=({_quote_words(top_level)}) + api_commands=({_quote_words(api_commands)}) + tool_commands=({_quote_words(tool_commands)}) + shells=({_quote_words(shells)}) + + case $words[2] in + api) _values 'api command' $api_commands ;; + tools) _values 'tool command' $tool_commands ;; + completion) _values 'shell' $shells ;; + *) _values 'command' $commands ;; + esac +}} +compdef _openai openai +""" + + +def _fish( + top_level: list[str], + api_commands: list[str], + tool_commands: list[str], + shells: list[str], +) -> str: + lines = ["# fish completion for openai"] + for command in top_level: + lines.append(f"complete -c openai -n '__fish_use_subcommand' -a '{command}'") + for command in api_commands: + lines.append(f"complete -c openai -n '__fish_seen_subcommand_from api' -a '{command}'") + for command in tool_commands: + lines.append(f"complete -c openai -n '__fish_seen_subcommand_from tools' -a '{command}'") + for shell in shells: + lines.append(f"complete -c openai -n '__fish_seen_subcommand_from completion' -a '{shell}'") + return "\n".join(lines) + "\n" + + +def _powershell( + top_level: list[str], + api_commands: list[str], + tool_commands: list[str], + shells: list[str], +) -> str: + top_level_values = "', '".join(top_level) + api_values = "', '".join(api_commands) + tool_values = "', '".join(tool_commands) + shell_values = "', '".join(shells) + return f"""# PowerShell completion for openai +Register-ArgumentCompleter -Native -CommandName openai -ScriptBlock {{ + param($wordToComplete, $commandAst, $cursorPosition) + $words = $commandAst.CommandElements | ForEach-Object {{ $_.Extent.Text }} + $candidates = switch ($words[1]) {{ + 'api' {{ @('{api_values}') }} + 'tools' {{ @('{tool_values}') }} + 'completion' {{ @('{shell_values}') }} + default {{ @('{top_level_values}') }} + }} + $candidates | Where-Object {{ $_ -like "$wordToComplete*" }} | ForEach-Object {{ + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) + }} +}} +""" diff --git a/tests/test_cli_completion.py b/tests/test_cli_completion.py new file mode 100644 index 0000000000..bcdb3d7625 --- /dev/null +++ b/tests/test_cli_completion.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from openai.cli._cli import _build_parser +from openai.cli._completion import generate_completion + + +def test_bash_completion_includes_top_level_commands() -> None: + output = generate_completion(_build_parser(), "bash") + + assert "complete -F _openai_completion openai" in output + assert "api completion" in output + assert "chat.completions.create" in output + assert "fine_tunes.prepare_data" in output + + +def test_zsh_completion_includes_shell_choices() -> None: + output = generate_completion(_build_parser(), "zsh") + + assert "#compdef openai" in output + assert "bash zsh fish powershell" in output + + +def test_fish_completion_includes_completion_subcommand() -> None: + output = generate_completion(_build_parser(), "fish") + + assert "complete -c openai -n '__fish_use_subcommand' -a 'completion'" in output + assert "__fish_seen_subcommand_from api" in output + + +def test_powershell_completion_registers_openai() -> None: + output = generate_completion(_build_parser(), "powershell") + + assert "Register-ArgumentCompleter -Native -CommandName openai" in output + assert "'completion' { @('bash', 'zsh', 'fish', 'powershell') }" in output