To build gpt-5 cli python tool that holds up outside a notebook, you need a real CLI framework, not a script with argparse bolted on. This tutorial walks through a small but production-minded client using Click and the OpenAI SDK, with streaming, env-based config, and clean exit codes.
Prerequisites
- Python 3.10 or newer
pipand a virtual environment tool- An API key from OpenAI or any OpenAI-compatible provider
- Basic familiarity with terminal and environment variables
If you plan to route through a gateway, set OPENAI_API_KEY and OPENAI_BASE_URL accordingly before running the examples.
Project setup
Create a directory and a venv:
mkdir gpt5cli && cd gpt5cli
python -m venv .venv
source .venv/bin/activate
pip install click openai
Create cli.py. We’ll build it incrementally.
A minimal Click command
Start with a single command that accepts a prompt and prints a response. Click makes argument parsing declarative.
import click
@click.command()
@click.argument("prompt")
def chat(prompt):
"""Send PROMPT to GPT-5 and print the reply."""
click.echo(f"You said: {prompt}")
if __name__ == "__main__":
chat()
Run it:
python cli.py "What is the capital of Estonia?"
Expected output:
You said: What is the capital of Estonia?
That confirms the CLI skeleton works. Now wire the model.
Wiring up the OpenAI client
We’ll use the official openai package. It speaks the OpenAI-compatible HTTP protocol, so any endpoint that mirrors /v1/chat/completions works.
import os
from openai import OpenAI
client = OpenAI(
api_key=os.environ.get("OPENAI_API_KEY"),
base_url=os.environ.get("OPENAI_BASE_URL", "https://api.openai.com/v1"),
)
Add a model option defaulting to gpt-5. (If your provider uses a different identifier, override with --model.)
@click.command()
@click.argument("prompt")
@click.option("--model", default="gpt-5", help="Model ID to use.")
def chat(prompt, model):
resp = client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": prompt}],
)
click.echo(resp.choices[0].message.content)
Run:
export OPENAI_API_KEY=sk-...
python cli.py "Explain CAP theorem in one sentence."
Expected output (truncated):
The CAP theorem states that a distributed data store cannot simultaneously guarantee consistency, availability, and partition tolerance.
Adding options: system prompt, temperature, streaming
A real client needs a system prompt and temperature control. Streaming is non-negotiable for UX—nobody wants to wait silently for a 2k-token reply.
Extend the command:
@click.command()
@click.argument("prompt")
@click.option("--model", default="gpt-5")
@click.option("--system", default="You are a concise assistant.", help="System prompt.")
@click.option("--temperature", default=0.7, type=float)
@click.option("--stream/--no-stream", default=True)
def chat(prompt, model, system, temperature, stream):
messages = [
{"role": "system", "content": system},
{"role": "user", "content": prompt},
]
if stream:
chunks = client.chat.completions.create(
model=model, messages=messages, temperature=temperature, stream=True
)
for chunk in chunks:
delta = chunk.choices[0].delta.content
if delta:
click.echo(delta, nl=False)
click.echo()
else:
resp = client.chat.completions.create(
model=model, messages=messages, temperature=temperature
)
click.echo(resp.choices[0].message.content)
Now you can run:
python cli.py "List three Rust web frameworks." --temperature 0.2 --no-stream
Expected output (non-streaming):
1. Actix Web
2. Rocket
3. Axum
With streaming (default), tokens appear incrementally as they arrive.
Handling errors and exit codes
Network failures, rate limits, and invalid keys happen. Click should exit non-zero and print a clean message.
Wrap the API call:
from openai import APIError, RateLimitError
@click.command()
# ... options as above ...
def chat(prompt, model, system, temperature, stream):
messages = [
{"role": "system", "content": system},
{"role": "user", "content": prompt},
]
try:
if stream:
chunks = client.chat.completions.create(
model=model, messages=messages, temperature=temperature, stream=True
)
for chunk in chunks:
delta = chunk.choices[0].delta.content
if delta:
click.echo(delta, nl=False)
click.echo()
else:
resp = client.chat.completions.create(
model=model, messages=messages, temperature=temperature
)
click.echo(resp.choices[0].message.content)
except RateLimitError:
raise click.ClickException("Rate limited. Check your plan or use a gateway with fallback.")
except APIError as e:
raise click.ClickException(f"API error: {e.status_code} {e.message}")
click.ClickException prints Error: <msg> and exits with code 1.
Test error path by unsetting the key:
unset OPENAI_API_KEY
python cli.py "test"
Expected output:
Error: API error: 401 Incorrect API key provided.
Making it configurable via env and a small helper
Hardcoding defaults is fine, but reading from env makes the tool portable. We already use OPENAI_API_KEY and OPENAI_BASE_URL. Add a GPT5_MODEL env override:
import os
DEFAULT_MODEL = os.environ.get("GPT5_MODEL", "gpt-5")
@click.command()
@click.argument("prompt")
@click.option("--model", default=DEFAULT_MODEL)
# ... rest ...
You can also persist config in a .env file using python-dotenv, but for a CLI, env vars are enough.
Using a gateway for resilience
If you point OPENAI_BASE_URL at an OpenAI-compatible inference gateway, you get provider redundancy without code changes. For example, n4n.ai exposes one OpenAI-compatible endpoint covering 240+ models and automatically falls back when a provider is rate-limited or degraded, while still honoring your --model routing directive and forwarding cache-control hints. That turns a single-point CLI into a resilient piece of infrastructure.
To use it:
export OPENAI_BASE_URL=https://api.n4n.ai/v1
export OPENAI_API_KEY=your-gateway-key
python cli.py "Summarize the Zen of Python." --model gpt-5
The same build gpt-5 cli python code now runs against multiple upstreams.
Supporting JSON output for scripting
Sometimes you want the raw response, including token usage, for metering. Add --json:
@click.option("--json", "as_json", is_flag=True)
# in non-streaming branch:
if as_json:
click.echo(resp.model_dump_json())
else:
click.echo(resp.choices[0].message.content)
Example:
python cli.py "ping" --no-stream --json
Outputs:
{"id":"chatcmpl-...","object":"chat.completion","model":"gpt-5","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1,"total_tokens":2}}
This makes the CLI embeddable in larger pipelines.
Why Click instead of argparse
Argparse is in the stdlib, but its error messages and option composition are clunky. Click gives you declarative options, automatic --help, and easy exit code control. For a tool you’ll maintain, that saves real time. The group/command pattern also lets you add subcommands (chat, repl) without rewriting parsing logic.
Adding a REPL for multi-turn sessions
A single-shot CLI is useful, but engineers often want a quick interactive loop. Click doesn’t ship a REPL, but a simple while loop with click.prompt is enough:
@click.command()
@click.option("--model", default=DEFAULT_MODEL)
def repl(model):
"""Start an interactive session."""
messages = [{"role": "system", "content": "You are a concise assistant."}]
while True:
try:
user_input = click.prompt("you")
except (EOFError, KeyboardInterrupt):
break
messages.append({"role": "user", "content": user_input})
resp = client.chat.completions.create(model=model, messages=messages)
reply = resp.choices[0].message.content
click.echo(f"bot: {reply}")
messages.append({"role": "assistant", "content": reply})
Wrap commands in a group:
@click.group()
def cli():
pass
@cli.command()
def repl():
...
@cli.command()
@click.argument("prompt")
# ... chat options ...
def chat(prompt, ...):
...
if __name__ == "__main__":
cli()
Exit codes and piping
Click returns 0 on success, 1 on ClickException. If you pipe to jq, check $? in shell scripts. That’s the contract your ops team will rely on. Non-zero on rate limit means a wrapper can retry or alert.
Final checks
Install your tool editable for system-wide use:
pip install -e .
Add a pyproject.toml with console script:
[project.scripts]
gpt5 = "cli:cli"
Then gpt5 chat "Hello" works from anywhere. The complete cli.py is about 90 lines. It streams, handles errors, respects env config, and can target any OpenAI-compatible endpoint. That’s a solid foundation to build gpt-5 cli python utilities for your own workflows.