n4nAI

From bash script to Python CLI: wrapping GPT-5 calls with Click

Build a robust Python CLI for GPT-5 using Click: project setup, API calls, streaming, retries, and packaging with runnable code and verification steps.

n4n Team3 min read590 words

Audio narration

Coming soon — every post will get a voice note here.

A one-off bash script that pipes a prompt into curl gets you a quick win, but it falls apart the moment you need typed flags, config files, or sane error messages. This guide walks through building a python cli click gpt-5 wrapper that is maintainable, testable, and ready for production use. We’ll replace ad-hoc shell glue with a proper command-line tool that talks to an OpenAI-compatible endpoint.

Step 1: Scaffold the project and isolate dependencies

Start in a clean directory with a virtual environment. Mixing global packages with a CLI tool is how you end up with version conflicts on the deployment box.

mkdir gpt5cli && cd gpt5cli
python -m venv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install click openai python-dotenv

click gives you declarative option parsing and help generation without the boilerplate of argparse. openai is the official SDK; it speaks the OpenAI-compatible HTTP contract that most gateways mirror. python-dotenv lets you load secrets from a .env file during local dev.

Create a cli.py stub so the import graph is clear:

import click

@click.command()
def main():
    click.echo("placeholder")

if __name__ == "__main__":
    main()

Run python cli.py to confirm the environment works before adding logic.

Step 2: Define the CLI surface with Click

A real python cli click gpt-5 tool needs more than a prompt string. You want model selection, sampling controls, and a stream flag. Click handles type coercion and --help generation for free.

import click
from openai import OpenAI

@click.command()
@click.option("--prompt", "-p", required=True, help="Prompt text to send.")
@click.option("--model", default="gpt-5", show_default=True, help="Model identifier.")
@click.option("--temperature", default=0.7, type=float, show_default=True)
@click.option("--max-tokens", default=512, type=int, show_default=True)
@click.option("--stream", is_flag=True, help="Stream tokens to stdout.")
@click.option("--api-key", envvar="OPENAI_API_KEY", help="API key (or set ENV).")
@click.option("--base-url", envvar="OPENAI_BASE_URL",
              default="https://api.openai.com/v1", show_default=True)
def main(prompt, model, temperature, max_tokens, stream, api_key, base_url):
    """Minimal python cli click gpt-5 client."""
    client = OpenAI(api_key=api_key, base_url=base_url)
    # dispatch defined in next step
    call_model(client, model, prompt, temperature, max_tokens, stream)

Two decisions matter here. First, envvar on --api-key means the key never appears in shell history. Second, --base-url is parameterized so you can target a different provider without code changes. If you point it at n4n.ai’s OpenAI-compatible endpoint, the same binary gains automatic fallback when a provider is rate-limited or degraded, plus per-token metering, without rewriting the client.

Step 3: Implement the model call

The OpenAI SDK uses chat.completions.create. For GPT-5, treat it as a chat model: a list of messages, not a raw prompt parameter.

def call_model(client, model, prompt, temperature, max_tokens, stream):
    messages = [{"role": "user", "content": prompt}]
    if stream:
        response = client.chat.completions.create(
            model=model,
            messages=messages,
            temperature=temperature,
            max_tokens=max_tokens,
            stream=True,
        )
        for chunk in response:
            delta = chunk.choices[0].delta.content if chunk.choices else None
            if delta:
                click.echo(delta, nl=False)
        click.echo()
        return
    response = client.chat.completions.create(
        model=model,
        messages=messages,
        temperature=temperature,
        max_tokens=max_tokens,
    )
    click.echo(response.choices[0].message.content)

Non-streaming returns the full completion in response.choices[0].message.content. Streaming yields incremental deltas; we print without newline and flush at the end. This preserves token-by-token UX without corrupting output when the CLI is piped to jq or a file.

One gotcha: the SDK raises openai.APIError (or subclasses) on non-2xx. We handle that in the next step rather than letting Python dump a traceback.

Step 4: Add retries and graceful failure

Providers throttle. A CLI that crashes on the first 429 is useless in a cron job. Wrap the call with a bounded retry loop and surface a clean exit code.

import time
from openai import APIError, RateLimitError

def call_model(client, model, prompt, temperature, max_tokens, stream, retries=3):
    messages = [{"role": "user", "content": prompt}]
    for attempt in range(retries):
        try:
            if stream:
                response = client.chat.completions.create(
                    model=model, messages=messages,
                    temperature=temperature, max_tokens=max_tokens, stream=True)
                for chunk in response:
                    delta = chunk.choices[0].delta.content if chunk.choices else None
                    if delta:
                        click.echo(delta, nl=False)
                click.echo()
                return
            response = client.chat.completions.create(
                model=model, messages=messages,
                temperature=temperature, max_tokens=max_tokens)
            click.echo(response.choices[0].message.content)
            return
        except RateLimitError:
            if attempt == retries - 1:
                raise click.ClickException("Rate limited after retries")
            time.sleep(2 ** attempt)
        except APIError as e:
            raise click.ClickException(f"API error: {e}")

click.ClickException prints Error: ... to stderr and exits with code 1. Callers can branch on $? in scripts.

Step 5: Accept prompts from stdin and files

Hard-coding -p is fine for interactive use, but automation needs stdin. Add a --file option or fall back to stdin when --prompt is omitted.

@click.command()
@click.option("--prompt", "-p", help="Prompt text. Omit to read stdin.")
@click.option("--file", "file_path", type=click.Path(exists=True), help="Read prompt from file.")
# ... other options unchanged
def main(prompt, file_path, **kwargs):
    if file_path:
        with open(file_path) as f:
            prompt = f.read()
    elif prompt is None:
        prompt = click.get_text_stream("stdin").read().strip()
    if not prompt:
        raise click.UsageError("No prompt provided")
    # ...

Now echo "summarize this" | python cli.py works, and so does python cli.py --file brief.txt. That is the difference between a toy and a pipe-friendly utility.

Step 6: Package as an installable command

A python cli click gpt-5 tool should be pip install-able and expose a binary. Write pyproject.toml:

[build-system]
requires = ["setuptools>=61.0"]
build-backend = "setuptools.build_meta"

[project]
name = "gpt5cli"
version = "0.1.0"
dependencies = ["click", "openai", "python-dotenv"]

[project.scripts]
gpt5 = "cli:main"

After pip install ., the gpt5 command is on PATH. The entry point cli:main refers to the Click command object in cli.py. No if __name__ == "__main__" shim required for the installed binary, though keep it for direct execution.

Step 7: Verify end to end

Success means: correct stdout, clean exit codes, and graceful degradation. Run these checks inside the venv.

export OPENAI_API_KEY="sk-..."
python cli.py -p "What is 2+2? Reply with just the number"

Expected: prints 4 (or similar) and exits 0. Verify exit code with echo $?.

Test streaming:

python cli.py --stream -p "Count to 3 slowly" | cat

You should see tokens arrive incrementally, not buffered. If your terminal collapses them, | cat forces line-buffered output.

Test failure path:

OPENAI_API_KEY="bad" python cli.py -p "test"; echo $?

Expect Error: ... on stderr and exit code 1.

Finally, build and install:

pip install .
gpt5 -p "Hello from the packaged CLI"

If gpt5 resolves and returns a completion, the migration from bash script to python cli click gpt-5 wrapper is complete. The tool is now typed, testable, and ready to be extended with subcommands like gpt5 chat or gpt5 batch.

Tagspythonclickgpt-5cli

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All building cli tools for llm apis posts →