108 lines
3.9 KiB
Markdown
108 lines
3.9 KiB
Markdown
# claude-code-api
|
|
[](https://sladge.net)
|
|
|
|
Python wrapper around the `claude` CLI for subscription-mode (no API key)
|
|
backends. Drives one long-running interactive `claude` per conversation via
|
|
a PTY and reads events from the JSONL session file; the public surface is
|
|
Anthropic-Messages-API shaped so a gateway in front of it is a one-liner
|
|
serializer away.
|
|
|
|
Not affiliated with Anthropic. You need a working subscription, the
|
|
`claude` CLI on PATH, and to have run `claude /login` once.
|
|
|
|
## Install
|
|
|
|
As a library inside another project:
|
|
|
|
```bash
|
|
uv add "claude-code-api @ git+https://git.kotikot.com/beaver/claude-code-api"
|
|
```
|
|
|
|
The runtime needs only `ptyprocess`.
|
|
|
|
## Use
|
|
|
|
```python
|
|
import asyncio
|
|
from claude_code_api import BackendOptions, ClaudeCodeBackend
|
|
|
|
async def main() -> None:
|
|
opts = BackendOptions(cwd="/path/to/project", dangerously_skip_permissions=True)
|
|
async with ClaudeCodeBackend(opts) as backend:
|
|
async for event in backend.complete(
|
|
[{"role": "user", "content": "say hi"}]
|
|
):
|
|
print(event)
|
|
|
|
asyncio.run(main())
|
|
```
|
|
|
|
Multi-turn works by construction — append the assistant reply + a fresh
|
|
user message to the same `messages` list and call `complete()` again. The
|
|
backend fingerprints `messages[:-1]`, finds the live PTY from the previous
|
|
turn, and reuses it (so the server-side prompt cache stays warm):
|
|
|
|
```python
|
|
history = [{"role": "user", "content": "remember Beaver"}]
|
|
async for ev in backend.complete(history): ...
|
|
|
|
history += [
|
|
{"role": "assistant", "content": [{"type": "text", "text": "OK"}]},
|
|
{"role": "user", "content": "what was the codeword?"},
|
|
]
|
|
async for ev in backend.complete(history): ...
|
|
```
|
|
|
|
## Public surface
|
|
|
|
Events (Anthropic-style, vendored to keep the dep tree empty):
|
|
`AssistantMessage`, `UserMessage`, `SystemMessage`, `ResultMessage`,
|
|
`TextBlock`, `ThinkingBlock`, `ToolUseBlock`, `ToolResultBlock`.
|
|
|
|
Errors: `BackendError` (root), `AuthError`, `ProcessError`,
|
|
`CLINotFoundError`, `RateLimitError`, `SessionError`, `MessageParseError`.
|
|
|
|
Backend: `ClaudeCodeBackend(opts).complete(messages)` is an async
|
|
generator of events. `BackendOptions` exposes model / system prompt /
|
|
allowed-tools / `mcp_servers` / permission mode / history injection mode.
|
|
|
|
Lower layers (`PtyClaudeProcess`, `JsonlWatcher`, `TurnManager`,
|
|
`normalize`) are re-exported for callers that want to assemble their own
|
|
session orchestration.
|
|
|
|
## How a turn works
|
|
|
|
1. The backend looks up a live session by `hash_history(messages[:-1])`.
|
|
If one matches, the new user message goes straight into its PTY.
|
|
2. If nothing matches and `messages[:-1]` is empty, a fresh `claude` is
|
|
spawned with a brand-new `--session-id`.
|
|
3. If `messages[:-1]` is non-empty (a continuation we don't have a live
|
|
PTY for — e.g. after restart), the backend writes a hand-crafted
|
|
JSONL transcript at `~/.claude/projects/<key>/<id>.jsonl` and spawns
|
|
`claude --resume <id>`. That is the `native_jsonl` injection mode;
|
|
the fallback is `concat_message`, which folds the prior history into
|
|
one large first prompt.
|
|
4. The PTY's stdout is drained continuously by a background thread; we
|
|
never read events from there. The JSONL file is tailed at 100ms
|
|
cadence and each new record is normalized into a typed `Event`.
|
|
5. The turn closes on the first `assistant` record with `stop_reason ∈
|
|
{end_turn, max_tokens, stop_sequence, refusal}`. A `ResultMessage`
|
|
is synthesized from its `usage` and yielded last.
|
|
|
|
## Examples
|
|
|
|
- `examples/basic_usage.py` — one turn, real `claude`.
|
|
- `examples/multi_turn.py` — two turns sharing one live PTY.
|
|
- `examples/mcp_tool.py` — wire up the bundled echo MCP server and let
|
|
the model call it.
|
|
|
|
## Tests
|
|
|
|
```bash
|
|
uv run pytest # unit tests (fast, no real claude)
|
|
RUN_CLAUDE_SMOKE=1 uv run pytest tests/test_pty.py tests/test_turn.py tests/test_backend.py
|
|
```
|
|
|
|
The smoke-marked tests spawn a real `claude` process and need a logged-in
|
|
subscription on the host.
|