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