pyclaudecli
A Python library that wraps the Claude Code CLI (claude) so you can drive it from Python instead of shelling out by hand: one-shot prompts, JSON/streaming output, background agents, authentication, MCP servers, plugins, and the rest of the CLI's surface.
It's a thin wrapper, not a reimplementation — every call runs the real claude binary, so it always reflects whatever version, auth, and config you have installed locally.
Contents
- Install
- Quickstart
- Setup
- Authentication
- Command-line usage
- Errors
build_flags- Security notes
- Every method, with an example
- Development
- License
Install
pip install pyclaudecli
Requires the claude CLI itself to be installed and on PATH (see the Claude Code docs).
Runs on Linux, macOS and Windows, Python 3.8+. On Windows the CLI is installed as
claude.cmd, which CreateProcess won't find from a bare "claude" — the wrapper
resolves it through PATHEXT for you, so the default ClaudeCLI() works there too.
Quickstart
from pyclaudecli import ClaudeCLI
claude = ClaudeCLI()
# One-shot prompt
print(claude.prompt("Summarize this repo's README.", model="haiku"))
Setup
from pyclaudecli import ClaudeCLI
# Defaults: binary="claude" on PATH, inherited cwd/env, no timeout
claude = ClaudeCLI()
# Pointing at a specific binary/project, with a default timeout for every call
claude = ClaudeCLI(
"claude",
cwd="/path/to/project",
env={"ANTHROPIC_API_KEY": "sk-ant-..."},
timeout=120,
)
env is merged over the current environment, so PATH, HOME and the rest still
reach the CLI — passing one API key doesn't cost you the ability to find the binary or
its credentials. Pass replace_env=True if you really want the child to start from a
clean slate:
claude = ClaudeCLI(env={"PATH": "/usr/bin", "HOME": "/tmp/sandbox"}, replace_env=True)
Authentication
claude handles auth itself, so pyclaudecli just drives it. You have two options.
API key
Pass the key through the environment — either inherited from your shell or set per client:
from pyclaudecli import ClaudeCLI
claude = ClaudeCLI(env={"ANTHROPIC_API_KEY": "sk-ant-..."})
OAuth login (paste the code)
auth_login() runs claude auth login, prints the sign-in URL, waits for the CLI's
"Paste code here" prompt, and writes your code back to it. With no arguments it reads the
code from stdin, so this is the whole interactive flow:
from pyclaudecli import ClaudeCLI
claude = ClaudeCLI()
if not claude.auth_status().get("loggedIn"):
# Prints the sign-in URL, then asks: "Paste the code from the browser here:"
exit_code = claude.auth_login()
print("login exit code:", exit_code)
print(claude.auth_status()) # {"loggedIn": True, "email": "you@example.com", ...}
To capture the URL yourself (open it in a browser, send it to a chat, log it) and paste the
code back without stdin, use on_output plus code_provider:
import re
URL_RE = re.compile(r"https://\S+")
login_url = None
def capture(chunk: str) -> None:
global login_url
print(chunk, end="", flush=True) # or log.info(chunk)
if login_url is None:
match = URL_RE.search(chunk)
if match:
login_url = match.group(0)
def supply_code(url: str) -> str:
# `url` is the sign-in URL pyclaudecli found in the CLI's output.
# Open it however you like, then return the code the browser shows.
print(f"\nOpen this URL and approve the login:\n{url}\n")
return input("Paste code here: ").strip()
claude.auth_login(
on_output=capture,
code_provider=supply_code,
console=True, # --console: force the URL/console flow instead of opening a browser
timeout=300, # how long to wait for the "Paste code here" prompt
)
Fully non-interactive — when the code already came from somewhere else (a queue, a browser-automation step, an operator pasting it into your own UI):
claude.auth_login(code="123456")
# Or fetch it programmatically from the printed sign-in URL
claude.auth_login(code_provider=lambda url: fetch_code_from_my_browser(url))
# Enterprise SSO, or pre-filling the account
claude.auth_login(sso=True)
claude.auth_login(email="you@example.com")
auth_login() returns the CLI's exit code (0 on success) and raises ClaudeTimeoutError
if the login prompt never appears within timeout seconds.
Signing out, and long-lived tokens for CI:
claude.auth_logout()
claude.setup_token() # interactive; sets up a long-lived auth token
Command-line usage
The package also installs a minimal pyclaudecli command (and python -m pyclaudecli) for quick one-off prompts — for anything beyond that, use ClaudeCLI directly.
pyclaudecli "What's 2+2?" # defaults to model "haiku"
pyclaudecli --model sonnet "Explain this diff"
pyclaudecli -m sonnet "Explain this diff"
python -m pyclaudecli "Hello, Claude!"
Errors
All CLI failures raise ClaudeCLIError (or ClaudeNotFoundError / ClaudeTimeoutError), carrying returncode, stdout, stderr, and cmd.
from pyclaudecli import ClaudeCLI, ClaudeCLIError, ClaudeNotFoundError, ClaudeTimeoutError
claude = ClaudeCLI()
try:
claude.prompt("Do something", model="haiku", timeout=30)
except ClaudeTimeoutError as exc:
print("timed out:", exc.cmd)
except ClaudeNotFoundError as exc:
print("claude binary not on PATH:", exc)
except ClaudeCLIError as exc:
print(exc.returncode, exc.stdout, exc.stderr)
build_flags
The helper ClaudeCLI uses internally to turn a {python_name: value} dict into CLI flags — handy if you're composing your own extra_flags or calling .run() directly. None/False are omitted, True becomes a bare flag, lists/tuples repeat the flag followed by each value, and anything else becomes the flag plus str(value).
from pyclaudecli import build_flags
build_flags({"model": "haiku", "verbose": True, "quiet": False, "add_dir": ["a", "b"]})
# ["--model", "haiku", "--verbose", "--add-dir", "a", "b"]
Every method, with an example
All examples assume:
from pyclaudecli import ClaudeCLI
claude = ClaudeCLI()
Raw / internal
# .run() — lowest-level call; returns a CommandResult(returncode, stdout, stderr)
result = claude.run(["--version"], check=False)
print(result.returncode, result.stdout)
Version / health
claude.version() # "claude --version" -> "1.2.3"
claude.doctor() # "claude doctor" -> health-check report
claude.update() # "claude update" -> checks for and installs updates
claude.install() # "claude install" (latest default)
claude.install("stable") # "claude install stable"
claude.install("1.2.3", force=True) # "claude install 1.2.3 --force"
Prompting
# One-shot prompt -> plain text
claude.prompt("Summarize this repo's README.", model="haiku")
# Structured result (cost, session id, etc.)
result = claude.prompt_json("What's 2+2?", model="haiku")
print(result["result"], result["total_cost_usd"])
# Live streaming events
for event in claude.prompt_stream("Write a haiku about tests.", model="haiku"):
print(event["type"])
# Full option surface
claude.prompt(
"Refactor this function for clarity.",
model="sonnet",
output_format="json",
system_prompt="You are a terse senior engineer.",
append_system_prompt="Always answer in bullet points.",
allowed_tools=["Read", "Edit"],
disallowed_tools=["Bash"],
add_dir=["../shared-lib"],
permission_mode="acceptEdits",
mcp_config=["./mcp.json"],
settings="./claude-settings.json",
resume="session-id-123",
fork_session=True,
effort="high",
fallback_model="haiku",
max_budget_usd=0.50,
json_schema='{"type": "object"}',
betas=["some-beta-flag"],
no_session_persistence=True,
dangerously_skip_permissions=False,
restricted=True,
input_text="piped stdin content",
timeout=60,
extra_flags={"worktree": True}, # anything without a named parameter
)
# continue_session=True appends --continue (keeps typing in the latest session)
claude.prompt("And now add tests for it.", continue_session=True)
Background sessions ("agents")
session_id = claude.start_background("Refactor the auth module", model="sonnet")
claude.list_agents() # all sessions, as a list of dicts
claude.list_agents(all=True, cwd="/repo") # include finished ones, scoped to a project
claude.attach(session_id) # interactive; requires a real TTY
claude.logs(session_id) # raw terminal snapshot
claude.logs(session_id, strip_ansi=True) # best-effort plain text
claude.stop(session_id) # stop, keep state
claude.respawn(session_id) # restart one stopped session
claude.respawn(all=True) # restart every stopped session
claude.rm(session_id) # delete a stopped session
Auth
claude.auth_status() # {"loggedIn": True, "email": "...", ...}
claude.auth_status(as_json=False) # human-readable text instead
# Interactive OAuth: prints the sign-in URL, then reads the pasted code from stdin
claude.auth_login()
# Non-interactive login: supply the code yourself
claude.auth_login(code="123456")
# Or fetch the code programmatically from the printed sign-in URL
claude.auth_login(code_provider=lambda url: fetch_code_from_my_browser(url))
# Route the printed sign-in URL/output somewhere other than stdout
claude.auth_login(on_output=lambda chunk: log.info(chunk), console=True, timeout=120)
claude.auth_login(sso=True) # enterprise SSO
claude.auth_login(email="you@example.com") # pre-fill the account
claude.auth_logout()
claude.setup_token() # interactive; sets up a long-lived auth token
MCP servers
claude.mcp_list()
claude.mcp_get("sentry")
claude.mcp_add("sentry", "https://mcp.sentry.dev/mcp", transport="http")
claude.mcp_add(
"local-tool", "node", "server.js",
transport="stdio", env=["API_KEY=abc"], scope="project",
)
claude.mcp_add_json("sentry", {"type": "http", "url": "https://mcp.sentry.dev/mcp"})
claude.mcp_add_from_claude_desktop(scope="user")
claude.mcp_remove("sentry", scope="project")
claude.mcp_login("sentry") # interactive OAuth
claude.mcp_logout("sentry")
claude.mcp_reset_project_choices()
Plugins
claude.plugin_list()
claude.plugin_list(as_json=True)
claude.plugin_install("some-plugin", yes=True)
claude.plugin_install("some-plugin@my-marketplace", scope="user", as_json=True)
claude.plugin_uninstall("some-plugin", yes=True, prune=True)
claude.plugin_enable("some-plugin")
claude.plugin_disable("some-plugin")
claude.plugin_disable(all=True) # disable every plugin
claude.plugin_update("some-plugin", yes=True)
claude.plugin_details("some-plugin")
claude.plugin_validate("./my-plugin", strict=True, as_json=True)
claude.plugin_prune(dry_run=True)
claude.plugin_marketplace_list()
claude.plugin_marketplace_add("https://github.com/org/marketplace-repo")
claude.plugin_marketplace_remove("marketplace-name")
claude.plugin_marketplace_update() # update all
claude.plugin_marketplace_update("marketplace-name")
Project state
claude.project_purge() # purge state for the current project
claude.project_purge("/path/to/repo") # or for a specific path
Auto mode
claude.auto_mode_config() # effective classifier config, as a dict
claude.auto_mode_defaults() # shipped default rules, as a dict
claude.auto_mode_reset() # remove custom rules from user settings
claude.auto_mode_critique() # AI feedback on your custom rules
Misc
claude.import_config("cursor", dry_run=True, yes=True) # source: codex, gemini, or cursor
claude.ultrareview() # review the current branch
claude.ultrareview("main") # review against a base branch
claude.ultrareview(482, as_json=True, post=True) # review a PR, post results, get JSON
# Long-running server: returns a Popen handle, doesn't block
gateway = claude.start_gateway(config="./gateway.json")
...
gateway.terminate()
See ClaudeCLI's docstrings for the exact CLI flags behind each method — it covers every top-level claude command (auth, mcp, plugin, project, agents/background sessions, auto-mode, doctor, update, install, import, ultrareview, gateway) plus the main prompt flags. Anything not exposed as a named parameter can still be passed through via each method's extra_flags dict.
Security notes
A few things the wrapper does on your behalf, worth knowing if you're feeding it input from anywhere but your own code:
Prompts can't smuggle in CLI flags. prompt(), prompt_stream(), start_background()
and the pyclaudecli command put -- between the options and your text, so a prompt that
starts with a dash is text, not a flag. Without it, user-supplied input like
--dangerously-skip-permissions or --settings /tmp/evil.json would be parsed by the CLI:
claude.prompt("--version") # asks Claude about "--version"; does not run the flag
Flags you actually want still go through named parameters or extra_flags. Note this
protection covers the prompt text — if you interpolate untrusted input into a flag value
(model=, settings=, add_dir=), validate it yourself.
Errors don't spill credentials. ClaudeCLIError.args/.cmd and the exception message
are redacted before they're raised, so MCP auth headers, injected --env values, tokens
inside an mcp add-json payload and anything shaped like sk-ant-…, Bearer … or
password=… come back as <redacted>. Flag names survive so the command is still
recognisable, and long values are truncated. Tracebacks and log aggregators get the
redacted form; exc.stdout/exc.stderr still hold the raw output for local debugging.
Login codes are handled as single-use secrets. auth_login() writes exactly one line
to the CLI's stdin — a code containing a newline can't inject extra input — and drops the
code and the captured output from memory once it's submitted. The child process and its
pipes are always cleaned up, including when your code_provider raises.
Two things it deliberately does not do: it never runs a shell (every call is an argv
list, so there's no shell-injection surface), and it doesn't manage credentials itself —
auth lives with the claude CLI and your environment. Remember the argv of a running
process is visible to other users on the same machine via ps, so prefer env vars over
flags for anything sensitive.
Every call is an argv list on every platform — no shell, and no shell=True fallback on
Windows, so quoting rules can't be turned into an injection.
Development
git clone https://github.com/Suriya-Ravichandran/pyclaudecli.git
cd pyclaudecli
pip install -e .
pytest # offline; does not invoke the claude binary
License
MIT
Release files for pyclaudecli 1.0.4
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| pyclaudecli-1.0.4.tar.gz | 23.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| pyclaudecli-1.0.4-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 43.7 kB
Release files / pyclaudecli-1.0.4.tar.gz
| Download URL | pyclaudecli-1.0.4.tar.gz |
|---|---|
| Size | 23.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
f836630bb451bb2769a7eec3ea54fffb36b1f841756f7970b24407ef48e8fbc3
|
|
BLAKE2b-256 checksum How to use checksums |
27528454455a069c47ce56d3af6338d139ed343654992be92c0001c83f21e695
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.14 {"installer":{"name":"uv","version":"0.12.14","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|
Release files / pyclaudecli-1.0.4-py3-none-any.whl
| Download URL | pyclaudecli-1.0.4-py3-none-any.whl |
|---|---|
| Size | 20.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
4a984e8fc73a79661fb34f1d5b72987595f4b3ac094de12ca808a1996e599f28
|
|
BLAKE2b-256 checksum How to use checksums |
734403b0156d7e3755c8e033e82cc3aefc5ba6a8d5a860caa95674eb2f8b7bd3
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.12.14 {"installer":{"name":"uv","version":"0.12.14","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
|