unified-cli
One Python + CLI interface for Claude Code, OpenAI Codex, Google Antigravity, Grok, and 17 more coding-agent CLIs.
๐ฐ๐ท ํ๊ตญ์ด README ยท ๐ Detailed usage (EN) ยท ๐ ์์ธ ๊ฐ์ด๋ (ํ๊ตญ์ด)
Start here
Choose one installation path. Both install the same Core + Preview provider
code; there is no separate unified-cli-ext download.
A. Install from PyPI โ use unified-cli from any directory
pipx is recommended for a terminal application because it gives
unified-cli its own environment while placing the command on your global
PATH:
python3 -m pip install --user pipx
python3 -m pipx ensurepath
# Open a new terminal once if ensurepath asks you to.
pipx install "unified-cli[server,acp]"
unified-cli --version
unified-cli providers --include-ext
Use pipx upgrade unified-cli for later releases. If you are already inside a
Python virtual environment, ordinary pip is also fine:
python -m pip install "unified-cli[server,acp]"
The base package is enough for the REPL. server adds the browser/local HTTP
server; acp adds the optional ACP transport used by Qoder, Kilo, Hermes, and
Poolside (Python 3.10โ3.14).
B. Run a Git checkout โ develop or test the current source
git clone https://github.com/MinwooKim1990/unified_cli.git
cd unified_cli
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
python -m pip install -e ".[server,acp,dev]"
unified-cli --version # runs this checkout
unified-cli repl
The command is available while that virtual environment is active. To keep an editable checkout but expose its command globally, run this once from the repository root instead:
pipx install --force --editable ".[server,acp]"
Primary use: embed any provider in Python
The terminal and browser are optional front ends. The primary API is the same small Python wrapper for all three Core providers and all 18 extension providers:
from pathlib import Path
from unified_cli import PROVIDERS, UnifiedError, configure_extension_provider, create
provider = "grok" # or claude, codex, gemini, kimi, copilot, qwen, ...
workspace = str(Path.cwd().resolve()) # explicit is recommended; omit cwd to use Path.cwd()
# Recommended once after installing/upgrading the vendor CLI. This verifies its
# executable and stores a local launch receipt; it does not perform vendor login.
if provider not in PROVIDERS:
configure_extension_provider(provider)
client = create(provider, cwd=workspace)
try:
response = client.chat("Explain this project")
print(response.text)
except UnifiedError as error:
print(error.kind, error) # e.g. auth_expired, rate_limit, config
Streaming and async use the same object:
for event in client.stream("Review the current diff"):
if event.kind == "text":
print(event.text, end="", flush=True)
# In an async function:
# response = await client.achat("Explain this project")
# async for event in client.astream("Review the diff"): ...
unified_cli_ext is bundled in the same wheel and loaded lazily. Application
code should normally import the stable public API from unified_cli; no second
PyPI package or subprocess-sidecar is needed. See
examples/09_extensions.py for a runnable example.
Non-Python language bindings are future work; the supported embedded contract
in 0.5.4 is the Python API above.
What to run after either installation
# 1. Passive catalog: shows Core + all bundled Preview providers; runs no vendor CLI.
unified-cli providers --include-ext
# 2. Check installed binaries/login state when you explicitly ask.
unified-cli doctor
# 3. Verify and save an installed Preview CLI once (example: Grok).
unified-cli configure grok
# 4. Full-screen terminal UI: choose provider/model and type / for commands.
unified-cli repl
# 5. One command without entering the REPL.
unified-cli chat "explain this repository" --provider claude --cwd "$PWD"
unified-cli chat "explain this repository" --provider grok --cwd "$PWD"
# 6. Local browser management UI. Only the listed workspace can be used.
unified-cli serve --manage --workspace "$PWD" --open
The terminal prints the one-time local browser URL if --open cannot launch
it. Plain unified-cli serve --open shows the read-only dashboard;
--manage --workspace ... enables local provider/model/settings/chat controls.
Keep it on 127.0.0.1; the public-compatible /v1/* API retains its stricter
Core-only trust boundary.
The 18 extension providers use the same Python, CLI, and REPL paths and run when explicitly selectedโnot merely as catalog metadata. Browser chat is available only where a fixed safe read-only mapping exists; Python, CLI, and REPL support every provider. OpenCode is enabled in Python/CLI/REPL but not browser chat until inherited remote/system MCP startup can be disabled. โPreviewโ means its provider-specific end-to-end coverage is not yet verified. Install, authenticate, and configure the official vendor CLI first; if it fails, file a sanitized diagnostic/log and an issue.
Prerequisites โ this package installs and authenticates nothing.
unified-cliis a thin wrapper that shells out to the official agentic CLIs you already have. It ships no API keys and no credentials, and it stores or transmits no credentials of its own. Stable Core calls reuse the vendor login already on your machine; Preview providers may ask you to repeat the vendor's official login inside their isolated provider home.Before using a provider you must have installed the corresponding CLI and signed in with your own subscription:
- Claude โ the
claudeCLI (Claude Code), logged in with Claude Pro/Max- Codex โ the
codexCLI, logged in with ChatGPT Plus/Pro- Gemini โ the
agyCLI (Google Antigravity), logged in with your Google Antigravity accountAny subset works โ you do not need all three. The wrapper simply uses whichever of
claude/codex/agyit finds on your$PATH.
Supported CLIs at a glance
The normal unified-cli install includes Core and Extensions together. There is
no second unified-cli-ext package to install.
| Status | Supported coding CLIs (Provider ID) | What it means |
|---|---|---|
| Stable | Claude Code (claude), OpenAI Codex (codex), Google Antigravity (gemini / agy), Grok Build (grok), OpenCode (opencode) |
Grok was live-verified on macOS 2026-07-23; OpenCode on macOS 2026-07-24 |
| Preview โ executable after official CLI install/auth/configure | Kimi Code (kimi), GitHub Copilot CLI (copilot), Cursor Agent (cursor), CodeBuddy (codebuddy), Qoder (qoder), Mistral Vibe (mistral-vibe), Qwen Code (qwen), Cline (cline), Kilo Code (kilo), Factory Droid (droid), Pi (pi), Oh My Pi (oh-my-pi), Hermes Agent (hermes), Poolside Agent CLI (poolside), Amp (amp), GitLab Duo CLI (gitlab-duo) |
Executable adapters, not blocked or metadata-only; Preview denotes unverified provider-specific E2E coverage |
Preview does not mean โcatalog only.โ Every listed Preview provider has an executable adapter and is attempted when you explicitly select it:
unified-cli providers --include-ext
unified-cli configure grok
unified-cli chat "explain this project" --provider grok --cwd "$PWD"
unified-cli chat "review this change" --provider kimi --cwd "$PWD"
unified-cli chat "find the bug" --provider copilot --cwd "$PWD"
ACP-based Preview providers (qoder, kilo, hermes, poolside) require
Python 3.10+ and:
pip install "unified-cli[acp]"
Install and sign in to the corresponding official vendor CLI first. Extensions
are lazy: they are never auto-selected, never change the Core default provider,
and remain disabled on the public-compatible /v1/* routes. The loopback-only
serve --manage UI can run an Ext provider only after you explicitly select it
and register an absolute workspace. Preview processes use a private provider
home; if a vendor stores login only in its normal home, repeat that vendor's
official login in the private home described in the
extension guide.
Grok uses the guide's verified isolated state and can pass the official
owner-only auth file path without reading or copying it.
Compatibility notice: Grok and OpenCode are Stable based on the macOS live checks above. The other 16 integrations reuse tested protocol families and may need updates for a particular vendor version, account, or output schema. A failed Preview run writes a bounded, prompt-free diagnostic under
~/.unified-cli/preview-diagnostics/. Please attach that file to a GitHub issue. Reports intentionally omit prompts, environment values, auth data, and tokens.
Live verification: Grok was verified on macOS on 2026-07-23 and OpenCode on macOS on 2026-07-24. Grok's explicit tool timeline can be unavailable even when tools execute. OpenCode vendors can reject tiny or invalid images. See the OpenCode matrix.
Python uses the same installed package and registry. create() is the public
embedded API; importing unified_cli_ext directly is optional:
from pathlib import Path
from unified_cli import configure_extension_provider, create
configure_extension_provider("grok")
client = create("grok", cwd=str(Path.cwd().resolve()))
print(client.chat("Explain this project").text)
See Extensions for provider-specific install commands, Preview limitations, and protocol details.
Terms of Service & provider usage policy โ read before using
You are responsible for complying with each provider's Terms of Service. Automation may not be permitted for every account or use case, and service access may be restricted. Terms are evolving (clarified Feb 2026); this is not legal advice.
- Intended safe pattern = personal, local, individual use with your OWN
subscription. Anthropic officially supports headless
claude -p/ programmatic use, so that path is lower risk. Never expose the wrapper to other people. - Do NOT: run the OpenAI-compatible server on a public/network interface, route other people's requests through your subscription, share credentials, or resell/proxy access. These may conflict with provider policies and can result in service access being restricted.
- Antigravity (
agy/ thegeminiprovider) requires additional policy review. Google has reported access restrictions for individual accounts that automate it, including related Gemini CLI / Code Assist access. For that reason thegeminiprovider is disabled by default โ enable it only after reviewing the applicable policy by settingUNIFIED_CLI_ENABLE_GEMINI=1. - The
unified-cli serveandpython -m unified_cli.serverlaunchers bind to127.0.0.1(localhost) by default and refuse a non-loopback host unless you setUNIFIED_CLI_ALLOW_EXTERNAL_BIND=1. A rawuvicorncommand keeps Uvicorn's own host choice, but the app's ASGI guard returns HTTP 403 for a non-loopback bind, peer, or Host until that opt-in. External opt-in additionally requires a non-whitespaceUNIFIED_CLI_SERVER_AUTH_TOKENof at least 32 UTF-8 bytes and a matchingAuthorization: Bearer โฆheader on every request. This is for one trusted client behind TLS, not a way to create a public or multi-user proxy. - This package ships no credentials โ each user brings their own subscription, and nothing is stored or transmitted on your behalf.
Use all three AI coding CLIs โ each signed in with your personal subscription
(Claude Pro/Max, ChatGPT Plus/Pro, Google Antigravity) โ from a single unified
interface, both as a terminal CLI and as a Python library you can
import in your own code.
The provider key for the Google side is still
"gemini"(and-m gemini-3.5-flashetc. still route to it), but it now wraps the AntigravityagyCLI โ access to the oldgeminiCLI was restricted for individual accounts in 2026. See the migration note below.โ ๏ธ The
geminiprovider is disabled by default because automatingagycan result in Google service access restrictions. SetUNIFIED_CLI_ENABLE_GEMINI=1only after reviewing the applicable policy โ see Terms of Service & provider usage policy.
# CLI
$ unified-cli chat "hi" -m haiku
# or: unified-cli repl โ interactive mode with slash commands
# Python
from unified_cli import create, UnifiedConversation
resp = create("claude").chat("hi")
conv = UnifiedConversation()
conv.send("Hello", provider="claude")
conv.send("Continue", provider="gemini") # needs UNIFIED_CLI_ENABLE_GEMINI=1
The
geminiprovider is disabled by default (Antigravityagyautomation can result in Google service access restrictions). ExportUNIFIED_CLI_ENABLE_GEMINI=1before anygeminiexample below will work.
Why this exists
Each of the three CLIs (claude, codex, agy) ships great subscription
auth but lives in its own world. Want to route "quick query" to the fastest
model regardless of provider? Want a local OpenAI-compatible /v1/chat/completions
endpoint with a constrained Claude default (and an explicit external-sandbox
opt-in for agentic providers)? Want your Python app to switch providers
mid-conversation with automatic context handoff? That's what
this wrapper does โ as a CLI you can shell into, and as a Python package you
can import.
Features
- Dual mode: full-featured CLI (
unified-cli chat,repl,status, ...) AND clean Python API (from unified_cli import ...) โ same code, same state - Subscription-aware: uses your existing
claude/codex login/agyOAuth. Inherited vendor API keys are stripped, and authentication failures never replay a turn under a different credential - Multi-turn history: CLI via
--continue/--resume, Python viasession_id=orUnifiedConversation - Cross-provider conversation: one
UnifiedConversationcan switch providers mid-chat; the last 8 turns auto-inject as context into the new provider's prompt - Unified streaming events:
kind="text" | "tool_use" | "tool_result" | "reasoning" | "usage" | "session" | "done" | "error"โ normalized across the three native JSONL schemas - Web search by default: Claude
WebSearch, Codexweb_search. Thegeminiprovider (now the AntigravityagyCLI) is agentic and decides when to web-search on its own โ always available. - Image input (multimodal, all 3 providers): pass
images=[paths]tochat()/stream()or--image foo.pngon the CLI. Each provider uses its native vision path:- Codex โ
-i, --image <FILE>flag (codex CLI 0.129+). - Gemini (
agy) โ@<path>reference embedded in the prompt. Tool approvals stay enabled unless the caller explicitly opts into the riskyskip_permissions=Truemode. - Claude โ Routed through Claude Code's built-in
Readtool; the image path is prepended to the prompt. The wrapper does not automatically selectbypassPermissions. PNG / JPEG / GIF / WebP are supported.
- Codex โ
- Structured errors: every failure โ
UnifiedError(kind=...)from one of eight categories (auth_expired/rate_limit/model_not_allowed/not_found/network/resource_limit/config/internal) with recovery hints - OpenAI-compatible server: drop-in
/v1/chat/completions+ redesigned auto-updating dashboard at/dashboard(and/redirects there). Its safe default exposes a constrained Claude profile only. - Rich terminal UI:
doctorhealth table,status --watchlive dashboard,setupinteractive wizard, streaming spinner - Interactive REPL (
unified-cli repl): live/slash-command menu,/modeland/providersnapshot pickers (default marked โ ), snapshot-only/status, cross-provider switching โ powered byprompt_toolkit - Localized (i18n): English by default, Korean with
--lang ko(or/lang koin the REPL, orUNIFIED_CLI_LANG=ko)
Default models (lightweight, subscription-friendly)
| Provider | Default | Latest flagship (override with -m) |
|---|---|---|
| Claude | claude-haiku-4-5 |
claude-fable-5, claude-opus-4-8, claude-sonnet-5 |
| Codex | gpt-5.4-mini |
gpt-5.6-sol, gpt-5.6-terra, gpt-5.6-luna |
Gemini (agy) |
gemini-3.5-flash |
gemini-3.1-pro |
Override via -m <name>. The wrapper passes any model ID straight through to
the underlying CLI; /model in the REPL and the browser Refresh action query
the selected provider explicitly, while unified-cli models PROVIDER --refresh does the
same from a command. For the ultra-fast coding specialist currently advertised
by the installed catalog, use -m gpt-5.3-codex-spark.
Model discovery is explicit and uses a one-hour, monotonic in-process cache;
imports, server startup, management bootstrap, and REPL startup do not populate
it. Cache and flight keys retain only SHA-256 context fingerprints: normalized
Claude credentials plus proxy/TLS inputs, the canonical Codex HOME/cache-file
identity, or Gemini opt-in/PATH/override plus passive agy metadata. No binary
is executed to build a fingerprint. Same-context concurrent refreshes share one
probe. Context entries use LRU bounds of eight per provider and 24 globally;
active refreshes are bounded to four per provider and 12 globally, with a
retryable resource_limit error when full. Use
list_models(provider, force_refresh=True) or unified-cli models PROVIDER --refresh
to refresh explicitly, and invalidate_model_cache(provider) (or no argument
for all built-ins) to discard cached records. Returned ModelInfo objects are
copies, so caller mutation never changes later results.
Gemini โ Antigravity migration: As of 2026, Google restricted the old
geminiCLI for individual accounts (IneligibleTierError: ... migrate to the Antigravity suite). Thegeminiprovider now wraps the AntigravityagyCLI (~/.local/bin/agy).agyis fully agentic (web search, shell, file tools) and routes to several model families โ rununified-cli models gemini(which callsagy models) to see them, e.g.Gemini 3.5 Flash (Medium),Gemini 3.1 Pro (High),Claude Sonnet 4.6 (Thinking),GPT-OSS 120B (Medium). Both the display names and slugs likegemini-3.5-flashwork with-m. Unknown names silently fall back to the default. Note:agyheadless mode outputs plain text (no token-usage reporting).โ ๏ธ Disabled by default. Because automating
agycan lead to Google service access restrictions, thegeminiprovider only activates whenUNIFIED_CLI_ENABLE_GEMINI=1is set. Without it, directgemini/agycalls (and thegemini-*model examples above) raise a config error. The HTTP server is stricter still: it returns HTTP 403 for Gemini until its separate agentic provider opt-in is enabled inside an external sandbox. Review the applicable provider policy before enabling direct use.
Install from source (development)
Follow Start here โ B above. After activating .venv, these are useful:
unified-cli setup # optional Core onboarding
unified-cli doctor
python -m pytest
Requires Python 3.9+ (Python 3.10โ3.14 for the optional ACP extra) and at least one vendor CLI to make a real call. The setup wizard only suggests official Core install/login commands; Preview CLI installation remains explicit and is documented in the extension guide. It never stores credentials and every step can be declined.
Usage at a glance
CLI
# Single turn
unified-cli chat "explain python list reversal in one line"
# Continue the last conversation
# Restores its provider/model and a still-valid saved working directory.
# An explicit --cwd always wins.
unified-cli chat "what about in-place?" --continue
unified-cli chat "use this checkout instead" --continue --cwd ~/work/project
# Persist the provider used when no -m/--provider or saved session chooses one
unified-cli config default-provider codex
unified-cli config default-provider # inspect
unified-cli config default-provider --reset # return to Claude
# Print just the installed package version (automation-friendly)
unified-cli --version
# Resume a specific session
unified-cli chat "continue from earlier" --resume <session_id>
# Interactive REPL โ type `/` for a live menu (/model & /provider pickers, /status, /lang, ...)
unified-cli repl
unified-cli repl --provider exact-extension-id --model vendor/family/model
# Stream + web-search (both defaults)
unified-cli chat "latest Python release?" --stream
# Cheapest fast query
unified-cli chat "quick q" -m gpt-5.3-codex-spark
# Exact extension selection; --model stays literal even with slashes
unified-cli chat "hello" --provider exact-extension-id --model vendor/family/model
# Image input (works with all 3 providers โ see Features above for details)
unified-cli chat "what's in this photo?" --image cat.png -m haiku
unified-cli chat "compare these two" --image a.jpg --image b.jpg -m gpt-5.4-mini
# Status & dashboard
unified-cli doctor # one-time health check
unified-cli status --watch # live terminal dashboard (5s refresh)
uvicorn unified_cli.server:app --port 8000 # localhost-only by default โ http://localhost:8000/dashboard (/ redirects there)
Interactive REPL โ unified-cli repl
The REPL is powered by prompt_toolkit (a core dependency, so it works
straight from pip install unified-cli). In a real terminal, type / to get a
live as-you-type menu of every slash command โ you don't have to memorize
them.
[claude/haiku] > hello
[claude/haiku] > / # live dropdown of all slash commands
[claude/haiku] > /model # Core cache/fallback or loaded Ext snapshot (default โ
)
[claude/sonnet] > /provider # picker: choose a provider (context auto-injected)
[codex/gpt-5.4-mini] > /status # process-local snapshot; no provider probe
[codex/gpt-5.4-mini] > /lang ko # switch the UI to Korean (persists)
[codex/gpt-5.4-mini] > /image photo.png # attach image for the next turn
[codex/gpt-5.4-mini] > describe this
[codex/gpt-5.4-mini] > /save # current session_id + resume hint
[codex/gpt-5.4-mini] > /exit # state saved โ `chat --continue` from here
/modelwith no argument opens a picker. Core uses its in-memory cache/fallback; an explicitly loaded extension shows only its descriptor default and last successful/model --refreshsnapshot./model <literal>sets the literal model ID without probing./provider <exact-id>loads only that extension's metadata. The picker shows Core plus extension descriptors already loaded in this process./statusshows a process-local snapshot and never probes providers./doctorshows the existing Core health table when Core is selected. For a selected extension it calls only that extension's explicit doctor and renders only a Core-owned generic result./lang en//lang koswitches the UI language live and persists it.
Slash commands: /help /model /provider /status /lang /new /save
/history /tokens /doctor /image /images /clear-images /exit
(/quit alias).
When stdin/stdout isn't a TTY, the REPL falls back to a plain input() loop
with the same commands.
Language (English default, Korean optional)
The whole CLI/REPL is localized. English is the default; switch to Korean with
the global --lang flag, the UNIFIED_CLI_LANG env var, or /lang ko in the
REPL:
unified-cli --lang ko chat "์๋
" # one-off, Korean output
export UNIFIED_CLI_LANG=ko # whole shell session in Korean
Resolution order: --lang {en,ko} > ~/.unified-cli/settings.json (set by
/lang) > $UNIFIED_CLI_LANG > English.
Python
from unified_cli import create, UnifiedConversation, UnifiedError, load_last_session
# Pattern 1 โ single call
resp = create("claude").chat("hi")
# Pattern 2 โ external code manages history (typical for chatbots)
cli = create("codex")
sessions = {}
def reply(user_id: str, prompt: str) -> str:
r = cli.chat(prompt, session_id=sessions.get(user_id))
sessions[user_id] = r.session_id
return r.text
# Pattern 3 โ wrapper manages history + cross-provider
conv = UnifiedConversation()
conv.send("My name is Minwoo.", provider="claude")
conv.send("What's my name?", provider="gemini") # knows "Minwoo"
# Pattern 4 โ resume from CLI session
state = load_last_session() # reads ~/.unified-cli/state.json
if state:
resp = create(state.provider, model=state.model).chat(
"follow-up from REPL", session_id=state.session_id,
)
# Pattern 5 โ error-aware fallback
for p in ("claude", "codex", "gemini"):
try:
return create(p).chat("...")
except UnifiedError as e:
if e.kind in ("auth_expired", "rate_limit"):
continue
raise
# Pattern 6 โ image input (works on all 3 providers)
resp = create("claude").chat(
"What single color is this image?",
images=["/path/to/photo.png"],
)
print(resp.text)
# Direct Python/CLI image inputs are trusted local paths (str or pathlib.Path),
# raw bytes, or Attachment(path=...)/Attachment(bytes_=...). Remote URLs and
# data URIs are deliberately rejected by the wrapped CLIs: download or decode
# trusted data yourself before passing it to the wrapper.
images = [
"cat.png",
b"\\x89PNG...", # bytes
]
# CLI equivalent:
# unified-cli chat "describe" --image a.png --image b.jpg -m gpt-5.4-mini
See USAGE.md (English) or USAGE.ko.md (Korean) for the full cookbook โ 9 patterns including sync, async, streaming, tool events, error fallback, image input, CLIโPython state sharing, and advanced provider options.
OpenAI-compatible server
unified-cli serve --port 8000 --open # โ recommended: localhost-guarded, opens the dashboard
# Raw ASGI mode uses Uvicorn's host setting; its default is localhost and the
# app rejects non-loopback HTTP requests unless external mode is explicitly enabled.
uvicorn unified_cli.server:app --port 8000
# Browse: http://localhost:8000/dashboard (live usage / sessions)
# http://localhost:8000/ (redirects to /dashboard)
Localhost-only by default.
unified-cli serveandpython -m unified_cli.serverbind127.0.0.1and refuse a non-loopback host (e.g.0.0.0.0) unless you setUNIFIED_CLI_ALLOW_EXTERNAL_BIND=1. Rawuvicorn ... --host 0.0.0.0can still open a listener, but the app's ASGI guard returns HTTP 403 for that non-loopback bind, peer, or Host until the same opt-in is set. It also logs a personal-use warning on startup. Exposing your personal subscription to other people / over a network can violate provider terms and lead to service-access restrictions, so keep it local.
External mode is not a public-service mode. If an independently managed deployment must bind outside loopback, it needs both
UNIFIED_CLI_ALLOW_EXTERNAL_BIND=1and a non-whitespaceUNIFIED_CLI_SERVER_AUTH_TOKENof at least 32 UTF-8 bytes. Every route then requiresAuthorization: Bearer <token>, including diagnostics. Use a TLS reverse proxy and a single trusted client; a Bearer token provides neither HTTPS nor per-user isolation. The browser dashboard is intended for local use.
The opt-in management dashboard never verifies providers or loads models during
bootstrap. Those probes begin only after the corresponding explicit action.
Within that runtime, successful version/auth and non-empty model results use
separate five-minute/15-second/one-minute TTLs. Same-context model misses share
one Manage flight; explicit invalidation and shutdown fence both the Manage and
Core model generations. A forced verification queued behind an ordinary one
waits for it, after which concurrent forced callers share one new generation;
different providers remain independent. Version/auth entries are keyed by the
exact executable selected from PATH; Gemini model entries fingerprint the
effective agy selected by Core discovery, including AGY_CLI_PATH. Claude
models use the HTTP API and Codex models use ~/.codex/models_cache.json, so
those two model paths execute no CLI and have no fabricated binary identity.
Auth and model data are additionally isolated by hashed HOME/provider-
environment context. Executable identity is local invocation/canonical-target
metadata, not vendor/package provenance. The verifier API also has no provider
account identifier, so an account changed by an external process may remain
visible for at most the short auth TTL. Observed binary replacement invalidates
all of that provider's probe records.
HTTP trust boundary. By default the server accepts only Claude models, using Claude safe mode with no agent tools for text requests and a scoped read permission for supplied image bytes. Codex and Antigravity (
agy) are intentionally rejected because their agentic CLIs do not provide confidential-data isolation for arbitrary HTTP input. SetUNIFIED_CLI_SERVER_ALLOW_AGENTIC_PROVIDERS=1only in an independently sandboxed container or VM with an intentionally scoped workspace mount; it is not an authentication mechanism or a safe way to expose the server.
Claude model names are auto-routed; the user field acts as a conversation id
(preserves history across calls):
from openai import OpenAI
client = OpenAI(base_url="http://localhost:8000/v1", api_key="unused")
# Plain text turn
client.chat.completions.create(
model="haiku", # โ claude
messages=[{"role":"user","content":"hi"}],
user="session-1",
)
# Image input (OpenAI multi-content schema, Claude server profile)
client.chat.completions.create(
model="haiku", # โ claude
messages=[{"role":"user","content":[
{"type":"text","text":"describe"},
{"type":"image_url",
"image_url":{"url":"data:image/png;base64,iVBOR..."}}
]}],
)
For the intentionally restricted external mode, pass the same bearer token as the OpenAI SDK API key (and keep the endpoint behind TLS):
import os
client = OpenAI(base_url="https://trusted.example/v1",
api_key=os.environ["UNIFIED_CLI_SERVER_AUTH_TOKEN"])
For HTTP images, image_url.url must be one canonical base64 URI such as
data:image/png;base64,..., data:image/jpeg;base64,...,
data:image/gif;base64,..., or data:image/webp;base64,..., whose signature
matches its MIME type. Remote URLs and filesystem paths are rejected. Defaults
are four images per message, 4 MiB decoded per image,
and a 24 MiB request body; operators can lower or raise those explicit server
limits with UNIFIED_CLI_SERVER_MAX_IMAGES,
UNIFIED_CLI_SERVER_MAX_IMAGE_BYTES, and
UNIFIED_CLI_SERVER_MAX_BODY_BYTES.
Running under launchd / cron / a server (headless)
The wrapped CLIs are designed to run interactively. Under a background launcher (macOS launchd, cron, systemd, a long-running server process) two things bite:
1. Minimal PATH โ "binary not found". launchd/cron start with a bare
PATH (/usr/bin:/bin:/usr/sbin:/sbin), so claude/codex installed in
Homebrew, npm-global, or ~/.local/bin aren't found. unified-cli now also
probes the well-known install locations, but the robust fix is to be explicit:
export CLAUDE_CLI_PATH=/opt/homebrew/bin/claude # or ~/.local/bin/claude
export CODEX_CLI_PATH=/opt/homebrew/bin/codex
# launchd plist: set these under <key>EnvironmentVariables</key>.
2. macOS Keychain โ silent hang. On macOS, claude stores its OAuth
credentials in the login Keychain. A launchd/daemon context has no TTY to
unlock the Keychain, so the CLI blocks forever waiting on auth โ the call
appears to hang and then times out. Works in your terminal, dies only on the
server. Fix it with a long-lived token (the officially supported headless
path):
claude setup-token # run ONCE in a real terminal
# โ copy the token into your service environment:
export CLAUDE_CODE_OAUTH_TOKEN=<token> # OAuth-equivalent, NOT metered
By default the wrapper runs on your subscription OAuth and strips any inherited
ANTHROPIC_API_KEY/OPENAI_API_KEYfrom the child env, so an exported key can't silently switch you to per-token billing. SetCLAUDE_CODE_OAUTH_TOKENfor headless auth. If you intentionally want a metered call, make a new Python request and pass the key explicitly:
from unified_cli import create
metered = create(
"claude", extra_env={"ANTHROPIC_API_KEY": "<key-from-secret-store>"},
)
metered.chat("new request")
The wrapper never retries a failed OAuth turn with this credential.
Prove it before you ship. Run the preflight from the same context as your service (e.g. inside the launchd job) โ it makes a tiny real call per provider and reports whether auth actually works there instead of hanging:
unified-cli doctor --headless
# โ claude: auth OK in this context โ good to go
# โ claude: network โ ... Keychain ... โ set CLAUDE_CODE_OAUTH_TOKEN
Streaming calls also have a short first-output watchdog: if a provider
produces no output within ~60s (the classic wedged-on-Keychain case) the wrapper
kills it and returns an actionable error naming the Keychain fix, rather than
blocking indefinitely. codex needs no Keychain (~/.codex/auth.json); agy
uses browser OAuth and stays gated regardless.
Known limitations
Speed: every call spawns a fresh subprocess (claude -p / codex exec /
agy for the gemini provider) โ these CLIs don't support a long-lived
daemon. Measured latency:
| Stage | Claude | Codex | Gemini |
|---|---|---|---|
| Subprocess spawn | ~50 ms | ~60 ms | ~460 ms (Node bundle) |
| API round-trip (API round-trip) | 3โ6 s | 2โ3 s | 3โ4 s |
| Full chat turn | 5โ6 s | 2.7โ3 s | 3โ4 s |
For the absolute fastest interactive feel, use -m gpt-5.3-codex-spark. Even
then, expect 2โ3 seconds per turn. This is a structural limit of the
subprocess architecture โ not something the wrapper can fix without either
(a) losing subscription auth by calling provider APIs directly, or (b) using
experimental daemon modes (e.g. codex app-server) that aren't fully stable
yet.
Subscription ToS: each provider's terms forbid reselling/exposing your personal subscription as a third-party service. This wrapper is designed for personal local automation, not as a SaaS gateway. Don't ship a web service backed by your personal OAuth.
macOS-first: Claude's Desktop app bundle is auto-discovered on macOS. On
Linux/Windows the claude binary needs to be on $PATH. REPL's arrow-key
history needs readline (stdlib on macOS/Linux; Windows users may need
pyreadline3).
Gemini (agy) specifics: agy headless mode prints plain text (no JSON
event stream), so the wrapper can't surface per-token usage โ tokens in/out
shows as None. Session resume uses --conversation <UUID> / --continue;
the conversation id is recovered from the newest .db in
~/.gemini/antigravity-cli/conversations/. Because agy runs full agentic
loops (web/shell/file), a turn can take longer than a one-shot completion, so
this provider defaults to a larger timeout (300s).
No persistent usage tracking: UsageTracker keeps per-provider aggregates
and recent-call history in process memory only. Restart = counters reset. For
long-term usage analytics you'd need to log separately.
Comparison with similar projects
| Project | Language | CLI + Python import | 3-CLI subprocess | OpenAI server | Dashboard | REPL |
|---|---|---|---|---|---|---|
| unified-cli (this) | Python | โ | โ (direct) | โ | โ | โ |
| oauth-cli-coder | Python | โ | โ (via tmux) | โ | โ | โ |
| coding-cli-runtime | Python | library only | โ | โ | โ | โ |
| router-for-me/CLIProxyAPI | Go | โ (server only) | โ | โ | โ | โ |
| codeking-ai/cligate | TypeScript | โ (server only) | โ | โ | โ | โ |
| PleasePrompto/ductor | Python | โ (bot only) | โ | โ | โ | โ |
| simonw/llm + llm-claude-code | Python | โ | Claude only | โ | โ | โ |
| litellm | Python | โ | direct API | โ | โ | โ |
Closest neighbour: oauth-cli-coder โ same dual-mode idea, but uses tmux
sessions as the integration primitive (requires tmux on user's machine). This
project uses direct subprocess.Popen for a simpler deployment story
(stdlib-only core, no external process manager), adds the OpenAI-compatible
server + live dashboard + rich REPL + state-file sharing between CLI and
Python code.
Closest library-only alternative: coding-cli-runtime on PyPI โ pure
Python library that wraps multiple coding CLIs per its PyPI page (verify the
exact set yourself). No CLI entry point, no server, no REPL.
If your use case is just "spawn a CLI and get text back" โ coding-cli-runtime
is smaller. If you want dual-mode + richer infrastructure (state, server,
dashboard, REPL), this is the one.
Project structure
unified_cli/
โโโ src/unified_cli/
โ โโโ core.py # Message, Response, Usage, ModelInfo dataclasses
โ โโโ errors.py # UnifiedError + classify() per-provider matchers
โ โโโ discovery.py # find_{claude,codex,gemini}_bin()
โ โโโ base.py # BaseProvider ABC + side-effect-aware retry
โ โโโ providers/ # claude.py, codex.py, gemini.py
โ โโโ conversation.py # UnifiedConversation (cross-provider context)
โ โโโ state.py # ~/.unified-cli/state.json read/write
โ โโโ usage.py # UsageTracker (per-process aggregates)
โ โโโ factory.py # create() + route()
โ โโโ cli.py # doctor / setup / status / chat / repl / models
โ โโโ repl.py # interactive REPL with slash commands
โ โโโ server.py # FastAPI OpenAI-compat server + /dashboard
โ โโโ ui.py # rich helpers (tables, panels)
โโโ tests/ # pytest offline/unit and server-hardening suite
โโโ examples/ # 8 runnable scripts
License
MIT License ยท Copyright (c) 2026 Minwoo Kim โ see LICENSE.
Anyone is free to use, modify, and redistribute this software, provided the copyright notice and license text are preserved in the redistribution. Personal use of provider subscriptions (Claude Pro/Max, ChatGPT Plus/Pro, Google AI Pro) is your own responsibility under each provider's Terms of Service โ see "Known limitations" above.
Contributing
Issues and PRs welcome. Please run pytest -q before opening a PR โ the full
offline suite should stay green.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file unified_cli-0.5.4.tar.gz.
File metadata
- Download URL: unified_cli-0.5.4.tar.gz
- Upload date:
- Size: 673.6 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
78ae194e5ebbe83c0f9bb0a9224402352108e71d0cb389f4ce3f1b3c0d0bd530
|
|
| MD5 |
49eab1f968f8d48ab2ce3fc061e20629
|
|
| BLAKE2b-256 |
a4f6d85fbcbd95e1bbfe2ec86b6529ce94e4e7e4cc58ad3e39e389dbc4c515f2
|
Provenance
The following attestation bundles were made for unified_cli-0.5.4.tar.gz:
Publisher:
publish.yml on MinwooKim1990/unified_cli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
unified_cli-0.5.4.tar.gz -
Subject digest:
78ae194e5ebbe83c0f9bb0a9224402352108e71d0cb389f4ce3f1b3c0d0bd530 - Sigstore transparency entry: 2231936813
- Sigstore integration time:
-
Permalink:
MinwooKim1990/unified_cli@38024f002b87505f2d33779f141e7f16b71a7244 -
Branch / Tag:
refs/tags/v0.5.4 - Owner: https://github.com/MinwooKim1990
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@38024f002b87505f2d33779f141e7f16b71a7244 -
Trigger Event:
push
-
Statement type:
File details
Details for the file unified_cli-0.5.4-py3-none-any.whl.
File metadata
- Download URL: unified_cli-0.5.4-py3-none-any.whl
- Upload date:
- Size: 443.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/6.1.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ffdf556d8487c8a9e61995d5c7a13896013ec66dc9e95f26fa98700ff60de71c
|
|
| MD5 |
f8cd619d8f3fb68722b128365021f20b
|
|
| BLAKE2b-256 |
62266e1d4bb16aa7098ae961df75dc61dedb1f211ddf1c04e06dd6bf125c3bff
|
Provenance
The following attestation bundles were made for unified_cli-0.5.4-py3-none-any.whl:
Publisher:
publish.yml on MinwooKim1990/unified_cli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
unified_cli-0.5.4-py3-none-any.whl -
Subject digest:
ffdf556d8487c8a9e61995d5c7a13896013ec66dc9e95f26fa98700ff60de71c - Sigstore transparency entry: 2231937461
- Sigstore integration time:
-
Permalink:
MinwooKim1990/unified_cli@38024f002b87505f2d33779f141e7f16b71a7244 -
Branch / Tag:
refs/tags/v0.5.4 - Owner: https://github.com/MinwooKim1990
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@38024f002b87505f2d33779f141e7f16b71a7244 -
Trigger Event:
push
-
Statement type: