claude-subprocess 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- claude_subprocess-0.1.1/.gitignore +10 -0
- claude_subprocess-0.1.1/LICENSE +21 -0
- claude_subprocess-0.1.1/PKG-INFO +202 -0
- claude_subprocess-0.1.1/README.md +187 -0
- claude_subprocess-0.1.1/pyproject.toml +30 -0
- claude_subprocess-0.1.1/src/claude_cli/__init__.py +89 -0
- claude_subprocess-0.1.1/src/claude_cli/batch.py +88 -0
- claude_subprocess-0.1.1/src/claude_cli/cache.py +206 -0
- claude_subprocess-0.1.1/src/claude_cli/errors.py +44 -0
- claude_subprocess-0.1.1/src/claude_cli/http_api.py +102 -0
- claude_subprocess-0.1.1/src/claude_cli/parsing.py +199 -0
- claude_subprocess-0.1.1/src/claude_cli/preflight.py +261 -0
- claude_subprocess-0.1.1/src/claude_cli/runner.py +716 -0
- claude_subprocess-0.1.1/tests/conftest.py +12 -0
- claude_subprocess-0.1.1/tests/test_batch.py +52 -0
- claude_subprocess-0.1.1/tests/test_cache.py +178 -0
- claude_subprocess-0.1.1/tests/test_http_api.py +175 -0
- claude_subprocess-0.1.1/tests/test_parsing.py +147 -0
- claude_subprocess-0.1.1/tests/test_preflight.py +150 -0
- claude_subprocess-0.1.1/tests/test_runner.py +829 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Daniil Petrov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: claude-subprocess
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Hardened wrapper around the local `claude -p` CLI (subscription auth), + JSON extraction and response cache
|
|
5
|
+
Project-URL: Homepage, https://github.com/octrow/claude-cli
|
|
6
|
+
Project-URL: Issues, https://github.com/octrow/claude-cli/issues
|
|
7
|
+
Author: Daniil Petrov
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
13
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
|
|
16
|
+
# claude-subprocess
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
uv add claude-subprocess # imported as `claude_cli`
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
One hardened wrapper around the OmniRoute gateway: tool-less calls go over its HTTP API,
|
|
23
|
+
everything else (and any HTTP failure) over `omniroute run claude -- -p` (Claude Code
|
|
24
|
+
**subscription** / OAuth — there is no `ANTHROPIC_API_KEY`), extracted from six
|
|
25
|
+
independent copies of the same subprocess contract (dialogue-lens,
|
|
26
|
+
tiktok-lazy-follower, get_cool_event, finance-insights, chat-watch, buy-smarter).
|
|
27
|
+
|
|
28
|
+
stdlib only. Python ≥3.12.
|
|
29
|
+
|
|
30
|
+
## Invariants (the reason this library exists)
|
|
31
|
+
|
|
32
|
+
* **NEVER pass `--bare`** — it forces API-key-only mode and breaks subscription
|
|
33
|
+
auth. The flag is intentionally absent from `build_args`; a test guards it.
|
|
34
|
+
* **OmniRoute is mandatory** — every Claude Code subprocess is launched via
|
|
35
|
+
`omniroute run claude`, and the HTTP route talks to the same gateway, so it owns routing.
|
|
36
|
+
* **Isolated surface**: an empty temp mcp-config + `--strict-mcp-config`,
|
|
37
|
+
`--setting-sources user`, `--disable-slash-commands`,
|
|
38
|
+
`--no-session-persistence`.
|
|
39
|
+
* **Throwaway cwd** for every call, so no project `CLAUDE.md` is auto-loaded.
|
|
40
|
+
* **`--tools ""` by default.** A non-empty `tools` value goes to *both* `--tools`
|
|
41
|
+
and `--allowed-tools` — headless `-p` auto-denies anything not allow-listed.
|
|
42
|
+
* `"Not logged in"` in stdout+stderr → `NotLoggedInError` (actionable).
|
|
43
|
+
* Usage/rate/session-limit signatures → `UsageLimitError`, raised *before* the
|
|
44
|
+
exit code so callers fail fast instead of burning the rest of a batch.
|
|
45
|
+
Deliberately narrow: `context limit exceeded` is **not** a usage limit.
|
|
46
|
+
* Both output shapes supported: `--output-format json` (one envelope) and
|
|
47
|
+
`--output-format stream-json` (final `{"type":"result"}` event); both are
|
|
48
|
+
normalized into the same `ClaudeResult`.
|
|
49
|
+
|
|
50
|
+
All errors derive from `ClaudeCliError(RuntimeError)`, so `except RuntimeError`
|
|
51
|
+
graceful-degrade paths keep working.
|
|
52
|
+
|
|
53
|
+
When the failure is the gateway's rather than Claude's, the error text says so and points at
|
|
54
|
+
OmniRoute's own docs and issues. A 503 is ambiguous there — a spent quota and
|
|
55
|
+
`ALL_TARGETS_SKIPPED` (no provider matched at all) share the number, and only the first is worth
|
|
56
|
+
waiting out. [docs/OMNIROUTE.md](docs/OMNIROUTE.md) has the diagnosis order, the recognisable
|
|
57
|
+
error strings, and the checks that cost no quota.
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from claude_cli import run_claude, extract_json
|
|
63
|
+
|
|
64
|
+
res = run_claude("Reply with {\"ok\": true}", model="haiku", effort="low", timeout=60)
|
|
65
|
+
res.text # the model's answer
|
|
66
|
+
res.cost_usd # subscription cost the envelope reported (may be None)
|
|
67
|
+
res.duration_ms
|
|
68
|
+
res.raw # the full envelope dict
|
|
69
|
+
extract_json(res.text) # {'ok': True} — tolerates ```fences``` and prose
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
HTTP route: `run_claude`/`arun_claude` first POST tool-less calls to OmniRoute's
|
|
73
|
+
`/v1/chat/completions` (key from `OMNIROUTE_API_KEY` or `~/.omniroute/.env`, URL from
|
|
74
|
+
`OMNIROUTE_URL`, default `http://localhost:20128`; no key = skipped). A failure logs a
|
|
75
|
+
warning, falls back to the CLI and parks HTTP for 600 s per process. Calls with
|
|
76
|
+
`tools`/`allowed_tools`, a project `cwd` (one holding `CLAUDE.md` or `.claude/`),
|
|
77
|
+
`slash_commands`, remote overrides or `output_format="stream-json"` stay on the CLI, as does
|
|
78
|
+
`ping`. A tool-less `stream_claude` goes over HTTP too and then emits no events. Opt out with
|
|
79
|
+
`backend="cli"` (or `CLAUDE_CLI_BACKEND=cli`, which wins over the kwarg); `backend="api"` is
|
|
80
|
+
HTTP only. HTTP results have `cost_usd=0.0` and `effort` is ignored. No `temperature` is
|
|
81
|
+
sent (it would enable OmniRoute's semantic cache). Consumer test suites that fake the CLI
|
|
82
|
+
must set `CLAUDE_CLI_BACKEND=cli`, or they reach the live gateway. Details, decisions,
|
|
83
|
+
consumer migration notes and the list of what still runs on the CLI:
|
|
84
|
+
[docs/OMNIROUTE.md](docs/OMNIROUTE.md#http-route-2026-10).
|
|
85
|
+
|
|
86
|
+
Streaming envelope (long runs, tlf/gce style):
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
res = run_claude(prompt, model="sonnet", output_format="stream-json")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Live progress — `stream_claude` calls back per event *while the run is going*, instead of
|
|
93
|
+
handing you the whole stream at the end (stackpulse `apply_batch` style):
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from claude_cli import stream_claude
|
|
97
|
+
|
|
98
|
+
res = stream_claude(prompt, lambda ev: print(ev["type"]), model="opus", timeout=3600)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Project mode (opt-in, reverses the isolation invariants above) — run a project's own slash
|
|
102
|
+
command with its `CLAUDE.md` and settings loaded, and let it write files:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
res = stream_claude(
|
|
106
|
+
"/apply data/export/interested/acme.md", on_event,
|
|
107
|
+
cwd="~/dev/cv-adapter", slash_commands=True,
|
|
108
|
+
setting_sources="user,project", permission_mode="acceptEdits",
|
|
109
|
+
tools="Read,Write,Edit,Bash,WebFetch",
|
|
110
|
+
)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Async (buy-smarter style):
|
|
114
|
+
|
|
115
|
+
```python
|
|
116
|
+
from claude_cli import arun_claude
|
|
117
|
+
|
|
118
|
+
res = await arun_claude(prompt, model="opus")
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Cached (chat-watch / finance-insights style):
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from claude_cli import FileCacheStore, cached_run, run_claude
|
|
125
|
+
|
|
126
|
+
store = FileCacheStore("~/.cache/myapp/llm.db")
|
|
127
|
+
text = cached_run(run_claude, store, prompt, model="haiku",
|
|
128
|
+
purpose="classify", version="v3")
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`DictCacheStore` is the in-memory store for tests. `cache_key(model, prompt,
|
|
132
|
+
schema=..., version=..., purpose=..., tools=...)` is sha256 over the normalized
|
|
133
|
+
prompt; empty optional parts never change a key, so new dimensions don't
|
|
134
|
+
invalidate old rows.
|
|
135
|
+
|
|
136
|
+
Batch (dialogue-lens style) — one bad chunk never aborts the run:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
from claude_cli import run_chunks
|
|
140
|
+
|
|
141
|
+
results, cost, failed = run_chunks(chunks, summarize, label=str, workers=4)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
`claude_available()` reports whether the CLI is on PATH, for callers that degrade
|
|
145
|
+
to a non-LLM path. `ClaudeCliProvider` / `LLMProvider` are the one-method
|
|
146
|
+
(`ask(prompt, *, model=None) -> str`) seam for swappable backends.
|
|
147
|
+
|
|
148
|
+
`ping(model="haiku", timeout=90) -> (bool, str)` makes one real call through
|
|
149
|
+
OmniRoute ("reply with the single word pong") for "doctor"-style startup
|
|
150
|
+
checks that want proof the whole path works, not just `claude_available()`'s
|
|
151
|
+
PATH check. Never raises: `(True, "pong via omniroute, 6.2s")` on success,
|
|
152
|
+
`(False, <cleaned error, ≤200 chars>)` on any exception or timeout.
|
|
153
|
+
|
|
154
|
+
Quota-free preflight (never raises, never spends quota):
|
|
155
|
+
|
|
156
|
+
```python
|
|
157
|
+
from claude_cli import health, doctor, quota_status, simulate, dry_run_plan
|
|
158
|
+
|
|
159
|
+
health() # (True, ...) — omniroute health
|
|
160
|
+
simulate("hi", combo="free-first")
|
|
161
|
+
dry_run_plan() # (True, '{"command", "args", "env_keys"}') — key *names* only
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The combo is picked by `--model` only: `haiku`/`sonnet`/`opus` go through the
|
|
165
|
+
gateway's model→combo mappings, or pass a combo name directly. `tier=`/`profile=`
|
|
166
|
+
only set `CLAUDE_CONFIG_DIR` (a separate Claude Code settings dir), not the route —
|
|
167
|
+
see `docs/OMNIROUTE.md`, "Как выбирается комбо":
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
res = run_claude(prompt, model="sub-first") # explicit combo
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
Before classification and truncation, stderr/stdout are cleaned of ANSI codes
|
|
174
|
+
and OmniRoute's own banner/warning lines (env-file banner, "is ignored, ...
|
|
175
|
+
set it first", disabled-connectors notice) so a real error at the tail of a
|
|
176
|
+
noisy run isn't crowded out by wrapper noise; `ClaudeCliError.message` carries
|
|
177
|
+
the cleaned text.
|
|
178
|
+
|
|
179
|
+
`cached_run` never persists the prompt by default (`store_prompt=False`) — prompts
|
|
180
|
+
often embed privacy-gated payloads, so only the sha256 key touches disk. Callers
|
|
181
|
+
needing hit/miss visibility use `store.get`/`store.put` directly.
|
|
182
|
+
|
|
183
|
+
## Migration notes (for repos replacing their local wrapper)
|
|
184
|
+
|
|
185
|
+
* **Mock seam**: consumer tests that used to patch their own `subprocess.run`
|
|
186
|
+
must now patch `claude_cli.runner.subprocess.run` (sync) or
|
|
187
|
+
`claude_cli.runner.asyncio.create_subprocess_exec` (async), and
|
|
188
|
+
`claude_cli.runner.shutil.which` for `claude_available`.
|
|
189
|
+
* **`extract_json` is stricter** than copy-pasted find-first-`{`/rfind-last-`}`
|
|
190
|
+
variants: it takes the first *balanced* `{...}` object. Replies with two
|
|
191
|
+
top-level objects now yield the first instead of a decode error.
|
|
192
|
+
* Per-project defaults: subclass `ClaudeCliProvider` and override the
|
|
193
|
+
`DEFAULT_MODEL` / `DEFAULT_TIMEOUT` class attributes — no `__init__` needed.
|
|
194
|
+
|
|
195
|
+
## Tests
|
|
196
|
+
|
|
197
|
+
```bash
|
|
198
|
+
cd claude-cli && uv run --extra dev pytest -q
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The suite fakes `subprocess.run` / `asyncio.create_subprocess_exec` — it never
|
|
202
|
+
invokes the real `claude` binary.
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# claude-subprocess
|
|
2
|
+
|
|
3
|
+
```bash
|
|
4
|
+
uv add claude-subprocess # imported as `claude_cli`
|
|
5
|
+
```
|
|
6
|
+
|
|
7
|
+
One hardened wrapper around the OmniRoute gateway: tool-less calls go over its HTTP API,
|
|
8
|
+
everything else (and any HTTP failure) over `omniroute run claude -- -p` (Claude Code
|
|
9
|
+
**subscription** / OAuth — there is no `ANTHROPIC_API_KEY`), extracted from six
|
|
10
|
+
independent copies of the same subprocess contract (dialogue-lens,
|
|
11
|
+
tiktok-lazy-follower, get_cool_event, finance-insights, chat-watch, buy-smarter).
|
|
12
|
+
|
|
13
|
+
stdlib only. Python ≥3.12.
|
|
14
|
+
|
|
15
|
+
## Invariants (the reason this library exists)
|
|
16
|
+
|
|
17
|
+
* **NEVER pass `--bare`** — it forces API-key-only mode and breaks subscription
|
|
18
|
+
auth. The flag is intentionally absent from `build_args`; a test guards it.
|
|
19
|
+
* **OmniRoute is mandatory** — every Claude Code subprocess is launched via
|
|
20
|
+
`omniroute run claude`, and the HTTP route talks to the same gateway, so it owns routing.
|
|
21
|
+
* **Isolated surface**: an empty temp mcp-config + `--strict-mcp-config`,
|
|
22
|
+
`--setting-sources user`, `--disable-slash-commands`,
|
|
23
|
+
`--no-session-persistence`.
|
|
24
|
+
* **Throwaway cwd** for every call, so no project `CLAUDE.md` is auto-loaded.
|
|
25
|
+
* **`--tools ""` by default.** A non-empty `tools` value goes to *both* `--tools`
|
|
26
|
+
and `--allowed-tools` — headless `-p` auto-denies anything not allow-listed.
|
|
27
|
+
* `"Not logged in"` in stdout+stderr → `NotLoggedInError` (actionable).
|
|
28
|
+
* Usage/rate/session-limit signatures → `UsageLimitError`, raised *before* the
|
|
29
|
+
exit code so callers fail fast instead of burning the rest of a batch.
|
|
30
|
+
Deliberately narrow: `context limit exceeded` is **not** a usage limit.
|
|
31
|
+
* Both output shapes supported: `--output-format json` (one envelope) and
|
|
32
|
+
`--output-format stream-json` (final `{"type":"result"}` event); both are
|
|
33
|
+
normalized into the same `ClaudeResult`.
|
|
34
|
+
|
|
35
|
+
All errors derive from `ClaudeCliError(RuntimeError)`, so `except RuntimeError`
|
|
36
|
+
graceful-degrade paths keep working.
|
|
37
|
+
|
|
38
|
+
When the failure is the gateway's rather than Claude's, the error text says so and points at
|
|
39
|
+
OmniRoute's own docs and issues. A 503 is ambiguous there — a spent quota and
|
|
40
|
+
`ALL_TARGETS_SKIPPED` (no provider matched at all) share the number, and only the first is worth
|
|
41
|
+
waiting out. [docs/OMNIROUTE.md](docs/OMNIROUTE.md) has the diagnosis order, the recognisable
|
|
42
|
+
error strings, and the checks that cost no quota.
|
|
43
|
+
|
|
44
|
+
## Usage
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
from claude_cli import run_claude, extract_json
|
|
48
|
+
|
|
49
|
+
res = run_claude("Reply with {\"ok\": true}", model="haiku", effort="low", timeout=60)
|
|
50
|
+
res.text # the model's answer
|
|
51
|
+
res.cost_usd # subscription cost the envelope reported (may be None)
|
|
52
|
+
res.duration_ms
|
|
53
|
+
res.raw # the full envelope dict
|
|
54
|
+
extract_json(res.text) # {'ok': True} — tolerates ```fences``` and prose
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
HTTP route: `run_claude`/`arun_claude` first POST tool-less calls to OmniRoute's
|
|
58
|
+
`/v1/chat/completions` (key from `OMNIROUTE_API_KEY` or `~/.omniroute/.env`, URL from
|
|
59
|
+
`OMNIROUTE_URL`, default `http://localhost:20128`; no key = skipped). A failure logs a
|
|
60
|
+
warning, falls back to the CLI and parks HTTP for 600 s per process. Calls with
|
|
61
|
+
`tools`/`allowed_tools`, a project `cwd` (one holding `CLAUDE.md` or `.claude/`),
|
|
62
|
+
`slash_commands`, remote overrides or `output_format="stream-json"` stay on the CLI, as does
|
|
63
|
+
`ping`. A tool-less `stream_claude` goes over HTTP too and then emits no events. Opt out with
|
|
64
|
+
`backend="cli"` (or `CLAUDE_CLI_BACKEND=cli`, which wins over the kwarg); `backend="api"` is
|
|
65
|
+
HTTP only. HTTP results have `cost_usd=0.0` and `effort` is ignored. No `temperature` is
|
|
66
|
+
sent (it would enable OmniRoute's semantic cache). Consumer test suites that fake the CLI
|
|
67
|
+
must set `CLAUDE_CLI_BACKEND=cli`, or they reach the live gateway. Details, decisions,
|
|
68
|
+
consumer migration notes and the list of what still runs on the CLI:
|
|
69
|
+
[docs/OMNIROUTE.md](docs/OMNIROUTE.md#http-route-2026-10).
|
|
70
|
+
|
|
71
|
+
Streaming envelope (long runs, tlf/gce style):
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
res = run_claude(prompt, model="sonnet", output_format="stream-json")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Live progress — `stream_claude` calls back per event *while the run is going*, instead of
|
|
78
|
+
handing you the whole stream at the end (stackpulse `apply_batch` style):
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from claude_cli import stream_claude
|
|
82
|
+
|
|
83
|
+
res = stream_claude(prompt, lambda ev: print(ev["type"]), model="opus", timeout=3600)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Project mode (opt-in, reverses the isolation invariants above) — run a project's own slash
|
|
87
|
+
command with its `CLAUDE.md` and settings loaded, and let it write files:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
res = stream_claude(
|
|
91
|
+
"/apply data/export/interested/acme.md", on_event,
|
|
92
|
+
cwd="~/dev/cv-adapter", slash_commands=True,
|
|
93
|
+
setting_sources="user,project", permission_mode="acceptEdits",
|
|
94
|
+
tools="Read,Write,Edit,Bash,WebFetch",
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Async (buy-smarter style):
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
from claude_cli import arun_claude
|
|
102
|
+
|
|
103
|
+
res = await arun_claude(prompt, model="opus")
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
Cached (chat-watch / finance-insights style):
|
|
107
|
+
|
|
108
|
+
```python
|
|
109
|
+
from claude_cli import FileCacheStore, cached_run, run_claude
|
|
110
|
+
|
|
111
|
+
store = FileCacheStore("~/.cache/myapp/llm.db")
|
|
112
|
+
text = cached_run(run_claude, store, prompt, model="haiku",
|
|
113
|
+
purpose="classify", version="v3")
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
`DictCacheStore` is the in-memory store for tests. `cache_key(model, prompt,
|
|
117
|
+
schema=..., version=..., purpose=..., tools=...)` is sha256 over the normalized
|
|
118
|
+
prompt; empty optional parts never change a key, so new dimensions don't
|
|
119
|
+
invalidate old rows.
|
|
120
|
+
|
|
121
|
+
Batch (dialogue-lens style) — one bad chunk never aborts the run:
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from claude_cli import run_chunks
|
|
125
|
+
|
|
126
|
+
results, cost, failed = run_chunks(chunks, summarize, label=str, workers=4)
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`claude_available()` reports whether the CLI is on PATH, for callers that degrade
|
|
130
|
+
to a non-LLM path. `ClaudeCliProvider` / `LLMProvider` are the one-method
|
|
131
|
+
(`ask(prompt, *, model=None) -> str`) seam for swappable backends.
|
|
132
|
+
|
|
133
|
+
`ping(model="haiku", timeout=90) -> (bool, str)` makes one real call through
|
|
134
|
+
OmniRoute ("reply with the single word pong") for "doctor"-style startup
|
|
135
|
+
checks that want proof the whole path works, not just `claude_available()`'s
|
|
136
|
+
PATH check. Never raises: `(True, "pong via omniroute, 6.2s")` on success,
|
|
137
|
+
`(False, <cleaned error, ≤200 chars>)` on any exception or timeout.
|
|
138
|
+
|
|
139
|
+
Quota-free preflight (never raises, never spends quota):
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from claude_cli import health, doctor, quota_status, simulate, dry_run_plan
|
|
143
|
+
|
|
144
|
+
health() # (True, ...) — omniroute health
|
|
145
|
+
simulate("hi", combo="free-first")
|
|
146
|
+
dry_run_plan() # (True, '{"command", "args", "env_keys"}') — key *names* only
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
The combo is picked by `--model` only: `haiku`/`sonnet`/`opus` go through the
|
|
150
|
+
gateway's model→combo mappings, or pass a combo name directly. `tier=`/`profile=`
|
|
151
|
+
only set `CLAUDE_CONFIG_DIR` (a separate Claude Code settings dir), not the route —
|
|
152
|
+
see `docs/OMNIROUTE.md`, "Как выбирается комбо":
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
res = run_claude(prompt, model="sub-first") # explicit combo
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Before classification and truncation, stderr/stdout are cleaned of ANSI codes
|
|
159
|
+
and OmniRoute's own banner/warning lines (env-file banner, "is ignored, ...
|
|
160
|
+
set it first", disabled-connectors notice) so a real error at the tail of a
|
|
161
|
+
noisy run isn't crowded out by wrapper noise; `ClaudeCliError.message` carries
|
|
162
|
+
the cleaned text.
|
|
163
|
+
|
|
164
|
+
`cached_run` never persists the prompt by default (`store_prompt=False`) — prompts
|
|
165
|
+
often embed privacy-gated payloads, so only the sha256 key touches disk. Callers
|
|
166
|
+
needing hit/miss visibility use `store.get`/`store.put` directly.
|
|
167
|
+
|
|
168
|
+
## Migration notes (for repos replacing their local wrapper)
|
|
169
|
+
|
|
170
|
+
* **Mock seam**: consumer tests that used to patch their own `subprocess.run`
|
|
171
|
+
must now patch `claude_cli.runner.subprocess.run` (sync) or
|
|
172
|
+
`claude_cli.runner.asyncio.create_subprocess_exec` (async), and
|
|
173
|
+
`claude_cli.runner.shutil.which` for `claude_available`.
|
|
174
|
+
* **`extract_json` is stricter** than copy-pasted find-first-`{`/rfind-last-`}`
|
|
175
|
+
variants: it takes the first *balanced* `{...}` object. Replies with two
|
|
176
|
+
top-level objects now yield the first instead of a decode error.
|
|
177
|
+
* Per-project defaults: subclass `ClaudeCliProvider` and override the
|
|
178
|
+
`DEFAULT_MODEL` / `DEFAULT_TIMEOUT` class attributes — no `__init__` needed.
|
|
179
|
+
|
|
180
|
+
## Tests
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
cd claude-cli && uv run --extra dev pytest -q
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The suite fakes `subprocess.run` / `asyncio.create_subprocess_exec` — it never
|
|
187
|
+
invokes the real `claude` binary.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "claude-subprocess"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
license = "MIT"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Daniil Petrov" }]
|
|
7
|
+
description = "Hardened wrapper around the local `claude -p` CLI (subscription auth), + JSON extraction and response cache"
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
dependencies = [] # stdlib only, on purpose
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Homepage = "https://github.com/octrow/claude-cli"
|
|
13
|
+
Issues = "https://github.com/octrow/claude-cli/issues"
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
dev = ["pytest>=8", "pytest-asyncio>=0.24"]
|
|
17
|
+
|
|
18
|
+
[build-system]
|
|
19
|
+
requires = ["hatchling"]
|
|
20
|
+
build-backend = "hatchling.build"
|
|
21
|
+
|
|
22
|
+
[tool.hatch.build.targets.wheel]
|
|
23
|
+
packages = ["src/claude_cli"]
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.sdist]
|
|
26
|
+
include = ["src", "tests", "README.md", "LICENSE"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
testpaths = ["tests"]
|
|
30
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""claude-cli — one hardened wrapper around the local ``claude -p`` CLI.
|
|
2
|
+
|
|
3
|
+
Subscription/OAuth auth (no ``ANTHROPIC_API_KEY``), a locked-down surface, both
|
|
4
|
+
output formats, JSON extraction from prose replies, and a sha256 response cache.
|
|
5
|
+
See :mod:`claude_cli.runner` for the invariants this library exists to protect.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .batch import pack_batches, run_chunks
|
|
9
|
+
from .cache import (
|
|
10
|
+
CacheStore,
|
|
11
|
+
DictCacheStore,
|
|
12
|
+
FileCacheStore,
|
|
13
|
+
cache_key,
|
|
14
|
+
cached_run,
|
|
15
|
+
normalize_prompt,
|
|
16
|
+
)
|
|
17
|
+
from .errors import ClaudeCliError, NotLoggedInError, UsageLimitError
|
|
18
|
+
from .parsing import (
|
|
19
|
+
extract_json,
|
|
20
|
+
extract_json_or_none,
|
|
21
|
+
parse_envelope,
|
|
22
|
+
parse_stream,
|
|
23
|
+
strip_fences,
|
|
24
|
+
)
|
|
25
|
+
from .preflight import (
|
|
26
|
+
BENCHMARKS,
|
|
27
|
+
CONSTRAINTS,
|
|
28
|
+
GUIDES,
|
|
29
|
+
MATRIX,
|
|
30
|
+
QUOTA_FREE_SUBCOMMANDS,
|
|
31
|
+
TIERS,
|
|
32
|
+
doctor,
|
|
33
|
+
dry_run_plan,
|
|
34
|
+
health,
|
|
35
|
+
quota_status,
|
|
36
|
+
simulate,
|
|
37
|
+
)
|
|
38
|
+
from .runner import (
|
|
39
|
+
MODELS,
|
|
40
|
+
ClaudeCliProvider,
|
|
41
|
+
ClaudeResult,
|
|
42
|
+
LLMProvider,
|
|
43
|
+
arun_claude,
|
|
44
|
+
build_args,
|
|
45
|
+
claude_available,
|
|
46
|
+
ping,
|
|
47
|
+
run_claude,
|
|
48
|
+
stream_claude,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"BENCHMARKS",
|
|
53
|
+
"CONSTRAINTS",
|
|
54
|
+
"GUIDES",
|
|
55
|
+
"MATRIX",
|
|
56
|
+
"MODELS",
|
|
57
|
+
"QUOTA_FREE_SUBCOMMANDS",
|
|
58
|
+
"TIERS",
|
|
59
|
+
"CacheStore",
|
|
60
|
+
"ClaudeCliError",
|
|
61
|
+
"ClaudeCliProvider",
|
|
62
|
+
"ClaudeResult",
|
|
63
|
+
"DictCacheStore",
|
|
64
|
+
"FileCacheStore",
|
|
65
|
+
"LLMProvider",
|
|
66
|
+
"NotLoggedInError",
|
|
67
|
+
"UsageLimitError",
|
|
68
|
+
"arun_claude",
|
|
69
|
+
"build_args",
|
|
70
|
+
"cache_key",
|
|
71
|
+
"cached_run",
|
|
72
|
+
"claude_available",
|
|
73
|
+
"doctor",
|
|
74
|
+
"dry_run_plan",
|
|
75
|
+
"extract_json",
|
|
76
|
+
"extract_json_or_none",
|
|
77
|
+
"health",
|
|
78
|
+
"normalize_prompt",
|
|
79
|
+
"pack_batches",
|
|
80
|
+
"parse_envelope",
|
|
81
|
+
"parse_stream",
|
|
82
|
+
"ping",
|
|
83
|
+
"quota_status",
|
|
84
|
+
"run_chunks",
|
|
85
|
+
"run_claude",
|
|
86
|
+
"simulate",
|
|
87
|
+
"stream_claude",
|
|
88
|
+
"strip_fences",
|
|
89
|
+
]
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Batch helpers: pack items into prompt-sized chunks, and run a call per chunk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def pack_batches(
|
|
13
|
+
items: Iterable[Any],
|
|
14
|
+
budget: int,
|
|
15
|
+
*,
|
|
16
|
+
size_of: Callable[[Any], int],
|
|
17
|
+
max_items: int = 0,
|
|
18
|
+
) -> list[list[Any]]:
|
|
19
|
+
"""Greedily pack ``items`` into batches within a size budget, order preserved.
|
|
20
|
+
|
|
21
|
+
A batch is closed when adding the next item would push it over ``budget``
|
|
22
|
+
(chars, tokens — whatever ``size_of`` counts) or over ``max_items``. Both
|
|
23
|
+
caps are disabled when ``<= 0``. A batch always holds at least one item, so
|
|
24
|
+
an item larger than ``budget`` on its own rides alone.
|
|
25
|
+
"""
|
|
26
|
+
batches: list[list[Any]] = []
|
|
27
|
+
cur: list[Any] = []
|
|
28
|
+
cur_size = 0
|
|
29
|
+
for item in items:
|
|
30
|
+
size = size_of(item)
|
|
31
|
+
over_count = max_items > 0 and len(cur) + 1 > max_items
|
|
32
|
+
over_budget = budget > 0 and cur_size + size > budget
|
|
33
|
+
if cur and (over_count or over_budget):
|
|
34
|
+
batches.append(cur)
|
|
35
|
+
cur, cur_size = [], 0
|
|
36
|
+
cur.append(item)
|
|
37
|
+
cur_size += size
|
|
38
|
+
if cur:
|
|
39
|
+
batches.append(cur)
|
|
40
|
+
return batches
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def run_chunks(
|
|
44
|
+
chunks: Iterable[Any],
|
|
45
|
+
fn: Callable[[Any], tuple[Any, float]],
|
|
46
|
+
*,
|
|
47
|
+
label: Callable[[Any], str],
|
|
48
|
+
workers: int = 1,
|
|
49
|
+
progress: Callable[..., Iterable] | None = None,
|
|
50
|
+
) -> tuple[list, float, list[str]]:
|
|
51
|
+
"""Apply ``fn(chunk) -> (data, cost)`` over ``chunks``, optionally in parallel.
|
|
52
|
+
|
|
53
|
+
Each call shells out to a ``claude`` subprocess, so threads give real
|
|
54
|
+
concurrency (the GIL is released during ``subprocess.run``).
|
|
55
|
+
|
|
56
|
+
Returns ``(results, total_cost, failed_labels)``. A chunk that raises is
|
|
57
|
+
recorded in ``failed_labels`` and skipped — one bad chunk never aborts the
|
|
58
|
+
batch.
|
|
59
|
+
"""
|
|
60
|
+
chunks = list(chunks)
|
|
61
|
+
results, spent, failed = [], 0.0, []
|
|
62
|
+
|
|
63
|
+
def _record(produce: Callable[[], tuple[Any, float]], chunk) -> None:
|
|
64
|
+
nonlocal spent
|
|
65
|
+
try:
|
|
66
|
+
data, cost = produce()
|
|
67
|
+
except Exception as exc: # noqa: BLE001 — keep the batch alive
|
|
68
|
+
failed.append(f"{label(chunk)}: {type(exc).__name__}: {str(exc)[:120]}")
|
|
69
|
+
return
|
|
70
|
+
spent += cost or 0.0
|
|
71
|
+
results.append(data)
|
|
72
|
+
|
|
73
|
+
if workers <= 1:
|
|
74
|
+
iterator = progress(chunks) if progress else chunks
|
|
75
|
+
for chunk in iterator:
|
|
76
|
+
_record(lambda c=chunk: fn(c), chunk)
|
|
77
|
+
return results, spent, failed
|
|
78
|
+
|
|
79
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
80
|
+
|
|
81
|
+
with ThreadPoolExecutor(max_workers=workers) as ex:
|
|
82
|
+
futs = {ex.submit(fn, c): c for c in chunks}
|
|
83
|
+
stream = as_completed(futs)
|
|
84
|
+
if progress:
|
|
85
|
+
stream = progress(stream, total=len(futs))
|
|
86
|
+
for fut in stream:
|
|
87
|
+
_record(fut.result, futs[fut])
|
|
88
|
+
return results, spent, failed
|