claude-subprocess 0.1.2__tar.gz → 0.1.4__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.
Files changed (20) hide show
  1. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/.gitignore +1 -0
  2. claude_subprocess-0.1.2/README.md → claude_subprocess-0.1.4/PKG-INFO +47 -13
  3. claude_subprocess-0.1.2/PKG-INFO → claude_subprocess-0.1.4/README.md +26 -28
  4. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/pyproject.toml +16 -2
  5. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/src/claude_cli/cache.py +16 -8
  6. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/src/claude_cli/http_api.py +27 -11
  7. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/src/claude_cli/parsing.py +14 -25
  8. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/src/claude_cli/preflight.py +143 -69
  9. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/src/claude_cli/runner.py +293 -132
  10. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/tests/test_batch.py +1 -3
  11. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/tests/test_cache.py +10 -5
  12. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/tests/test_http_api.py +23 -10
  13. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/tests/test_parsing.py +27 -23
  14. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/tests/test_preflight.py +14 -12
  15. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/tests/test_runner.py +81 -10
  16. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/LICENSE +0 -0
  17. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/src/claude_cli/__init__.py +0 -0
  18. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/src/claude_cli/batch.py +0 -0
  19. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/src/claude_cli/errors.py +0 -0
  20. {claude_subprocess-0.1.2 → claude_subprocess-0.1.4}/tests/conftest.py +0 -0
@@ -1,6 +1,7 @@
1
1
  __pycache__/
2
2
  *.py[co]
3
3
  .pytest_cache/
4
+ .coverage
4
5
  .venv/
5
6
  *.egg-info/
6
7
  graphify-out/
@@ -1,5 +1,28 @@
1
+ Metadata-Version: 2.5
2
+ Name: claude-subprocess
3
+ Version: 0.1.4
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
+ Classifier: Operating System :: OS Independent
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Requires-Python: >=3.12
15
+ Provides-Extra: dev
16
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
17
+ Requires-Dist: pytest>=8; extra == 'dev'
18
+ Requires-Dist: ruff==0.17.3; extra == 'dev'
19
+ Requires-Dist: ty==0.0.95; extra == 'dev'
20
+ Description-Content-Type: text/markdown
21
+
1
22
  <p align="center">
2
23
  <picture>
24
+ <source media="(max-width: 600px) and (prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/hero-mobile-dark.svg">
25
+ <source media="(max-width: 600px)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/hero-mobile-light.svg">
3
26
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/hero-dark.svg">
4
27
  <img src="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/hero-light.svg" width="100%" alt="claude-subprocess: call claude -p from Python on your Claude Code subscription, with the risky flags locked down. Beside the title, the real argv build_args(model='haiku') produces: omniroute run claude -- -p --model haiku --effort low --output-format json --no-session-persistence --tools '' --disable-slash-commands --setting-sources user --strict-mcp-config --mcp-config mcp-empty.json. --bare is never sent because it breaks OAuth.">
5
28
  </picture>
@@ -28,17 +51,17 @@ uv add claude-subprocess # or: pip install claude-subprocess
28
51
  The PyPI name is `claude-subprocess`, the import name is `claude_cli`. At runtime you need
29
52
  `omniroute` and `claude` on `PATH`, with Claude Code logged in.
30
53
 
31
- ## A call
54
+ ## Usage
32
55
 
33
56
  ```python
34
57
  from claude_cli import run_claude, extract_json
35
58
 
36
59
  res = run_claude('Reply with {"ok": true}', model="haiku", effort="low", timeout=60)
37
- res.text # the model's answer
38
- res.cost_usd # cost the envelope reported (may be None)
60
+ res.text # the model's answer
61
+ res.cost_usd # cost the envelope reported (may be None)
39
62
  res.duration_ms
40
- res.raw # the full envelope dict
41
- extract_json(res.text) # {'ok': True}
63
+ res.raw # the full envelope dict
64
+ extract_json(res.text) # {'ok': True}
42
65
  ```
43
66
 
44
67
  `extract_json` tolerates code fences and prose around the object:
@@ -51,6 +74,8 @@ extract_json(res.text) # {'ok': True}
51
74
  ## How a call is routed
52
75
 
53
76
  <picture>
77
+ <source media="(max-width: 600px) and (prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/flow-mobile-dark.svg">
78
+ <source media="(max-width: 600px)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/flow-mobile-light.svg">
54
79
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/flow-dark.svg">
55
80
  <img src="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/flow-light.svg" width="100%" alt="run_claude, arun_claude and stream_claude send tool-less calls to OmniRoute's HTTP API at /v1/chat/completions. Calls with tools, a project cwd or stream-json go through omniroute run claude -- -p with a throwaway cwd and an empty MCP config. An HTTP error falls back to the CLI and parks HTTP for 600 seconds. Both paths return a ClaudeResult. NotLoggedInError and UsageLimitError fail fast and derive from ClaudeCliError(RuntimeError).">
56
81
  </picture>
@@ -128,9 +153,12 @@ with its `CLAUDE.md` and settings loaded, and lets it write files:
128
153
 
129
154
  ```python
130
155
  res = stream_claude(
131
- "/apply data/export/interested/acme.md", on_event,
132
- cwd="~/projects/my-repo", slash_commands=True,
133
- setting_sources="user,project", permission_mode="acceptEdits",
156
+ "/apply data/export/interested/acme.md",
157
+ on_event,
158
+ cwd="~/projects/my-repo",
159
+ slash_commands=True,
160
+ setting_sources="user,project",
161
+ permission_mode="acceptEdits",
134
162
  tools="Read,Write,Edit,Bash,WebFetch",
135
163
  )
136
164
  ```
@@ -149,8 +177,7 @@ Cached:
149
177
  from claude_cli import FileCacheStore, cached_run, run_claude
150
178
 
151
179
  store = FileCacheStore("~/.cache/myapp/llm.db")
152
- text = cached_run(run_claude, store, prompt, model="haiku",
153
- purpose="classify", version="v3")
180
+ text = cached_run(run_claude, store, prompt, model="haiku", purpose="classify", version="v3")
154
181
  ```
155
182
 
156
183
  `cache_key(model, prompt, schema=..., version=..., purpose=..., tools=...)` is a sha256 over
@@ -175,14 +202,21 @@ the gateway's model-to-combo mappings, or you pass a combo name directly. `tier=
175
202
  res = run_claude(prompt, model="sub-first") # explicit combo
176
203
  ```
177
204
 
205
+ `direct=True` skips OmniRoute: `claude -p` runs on the account the local Claude Code is
206
+ logged in to, `model` is a plain Claude model id, and a usage limit ends the session.
207
+
208
+ ```python
209
+ res = run_claude(prompt, model="claude-sonnet-5-5", direct=True)
210
+ ```
211
+
178
212
  ## Checks before you spend quota
179
213
 
180
214
  ```python
181
215
  from claude_cli import health, doctor, quota_status, simulate, dry_run_plan
182
216
 
183
- health() # omniroute health
184
- simulate("hi", combo="free-first")
185
- dry_run_plan() # planned command, args and env key *names* only
217
+ health() # omniroute health
218
+ simulate("hi", combo="free-stack")
219
+ dry_run_plan() # planned command, args and env key *names* only
186
220
  ```
187
221
 
188
222
  None of these raise or spend quota; each returns `(ok, text)`.
@@ -1,20 +1,7 @@
1
- Metadata-Version: 2.5
2
- Name: claude-subprocess
3
- Version: 0.1.2
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
1
  <p align="center">
17
2
  <picture>
3
+ <source media="(max-width: 600px) and (prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/hero-mobile-dark.svg">
4
+ <source media="(max-width: 600px)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/hero-mobile-light.svg">
18
5
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/hero-dark.svg">
19
6
  <img src="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/hero-light.svg" width="100%" alt="claude-subprocess: call claude -p from Python on your Claude Code subscription, with the risky flags locked down. Beside the title, the real argv build_args(model='haiku') produces: omniroute run claude -- -p --model haiku --effort low --output-format json --no-session-persistence --tools '' --disable-slash-commands --setting-sources user --strict-mcp-config --mcp-config mcp-empty.json. --bare is never sent because it breaks OAuth.">
20
7
  </picture>
@@ -43,17 +30,17 @@ uv add claude-subprocess # or: pip install claude-subprocess
43
30
  The PyPI name is `claude-subprocess`, the import name is `claude_cli`. At runtime you need
44
31
  `omniroute` and `claude` on `PATH`, with Claude Code logged in.
45
32
 
46
- ## A call
33
+ ## Usage
47
34
 
48
35
  ```python
49
36
  from claude_cli import run_claude, extract_json
50
37
 
51
38
  res = run_claude('Reply with {"ok": true}', model="haiku", effort="low", timeout=60)
52
- res.text # the model's answer
53
- res.cost_usd # cost the envelope reported (may be None)
39
+ res.text # the model's answer
40
+ res.cost_usd # cost the envelope reported (may be None)
54
41
  res.duration_ms
55
- res.raw # the full envelope dict
56
- extract_json(res.text) # {'ok': True}
42
+ res.raw # the full envelope dict
43
+ extract_json(res.text) # {'ok': True}
57
44
  ```
58
45
 
59
46
  `extract_json` tolerates code fences and prose around the object:
@@ -66,6 +53,8 @@ extract_json(res.text) # {'ok': True}
66
53
  ## How a call is routed
67
54
 
68
55
  <picture>
56
+ <source media="(max-width: 600px) and (prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/flow-mobile-dark.svg">
57
+ <source media="(max-width: 600px)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/flow-mobile-light.svg">
69
58
  <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/flow-dark.svg">
70
59
  <img src="https://raw.githubusercontent.com/octrow/claude-cli/main/assets/readme/flow-light.svg" width="100%" alt="run_claude, arun_claude and stream_claude send tool-less calls to OmniRoute's HTTP API at /v1/chat/completions. Calls with tools, a project cwd or stream-json go through omniroute run claude -- -p with a throwaway cwd and an empty MCP config. An HTTP error falls back to the CLI and parks HTTP for 600 seconds. Both paths return a ClaudeResult. NotLoggedInError and UsageLimitError fail fast and derive from ClaudeCliError(RuntimeError).">
71
60
  </picture>
@@ -143,9 +132,12 @@ with its `CLAUDE.md` and settings loaded, and lets it write files:
143
132
 
144
133
  ```python
145
134
  res = stream_claude(
146
- "/apply data/export/interested/acme.md", on_event,
147
- cwd="~/projects/my-repo", slash_commands=True,
148
- setting_sources="user,project", permission_mode="acceptEdits",
135
+ "/apply data/export/interested/acme.md",
136
+ on_event,
137
+ cwd="~/projects/my-repo",
138
+ slash_commands=True,
139
+ setting_sources="user,project",
140
+ permission_mode="acceptEdits",
149
141
  tools="Read,Write,Edit,Bash,WebFetch",
150
142
  )
151
143
  ```
@@ -164,8 +156,7 @@ Cached:
164
156
  from claude_cli import FileCacheStore, cached_run, run_claude
165
157
 
166
158
  store = FileCacheStore("~/.cache/myapp/llm.db")
167
- text = cached_run(run_claude, store, prompt, model="haiku",
168
- purpose="classify", version="v3")
159
+ text = cached_run(run_claude, store, prompt, model="haiku", purpose="classify", version="v3")
169
160
  ```
170
161
 
171
162
  `cache_key(model, prompt, schema=..., version=..., purpose=..., tools=...)` is a sha256 over
@@ -190,14 +181,21 @@ the gateway's model-to-combo mappings, or you pass a combo name directly. `tier=
190
181
  res = run_claude(prompt, model="sub-first") # explicit combo
191
182
  ```
192
183
 
184
+ `direct=True` skips OmniRoute: `claude -p` runs on the account the local Claude Code is
185
+ logged in to, `model` is a plain Claude model id, and a usage limit ends the session.
186
+
187
+ ```python
188
+ res = run_claude(prompt, model="claude-sonnet-5-5", direct=True)
189
+ ```
190
+
193
191
  ## Checks before you spend quota
194
192
 
195
193
  ```python
196
194
  from claude_cli import health, doctor, quota_status, simulate, dry_run_plan
197
195
 
198
- health() # omniroute health
199
- simulate("hi", combo="free-first")
200
- dry_run_plan() # planned command, args and env key *names* only
196
+ health() # omniroute health
197
+ simulate("hi", combo="free-stack")
198
+ dry_run_plan() # planned command, args and env key *names* only
201
199
  ```
202
200
 
203
201
  None of these raise or spend quota; each returns `(ok, text)`.
@@ -1,11 +1,17 @@
1
1
  [project]
2
2
  name = "claude-subprocess"
3
- version = "0.1.2"
3
+ version = "0.1.4"
4
4
  license = "MIT"
5
5
  readme = "README.md"
6
6
  authors = [{ name = "Daniil Petrov" }]
7
7
  description = "Hardened wrapper around the local `claude -p` CLI (subscription auth), + JSON extraction and response cache"
8
8
  requires-python = ">=3.12"
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "Programming Language :: Python :: 3.12",
12
+ "Programming Language :: Python :: 3.13",
13
+ "Operating System :: OS Independent",
14
+ ]
9
15
  dependencies = [] # stdlib only, on purpose
10
16
 
11
17
  [project.urls]
@@ -13,7 +19,7 @@ Homepage = "https://github.com/octrow/claude-cli"
13
19
  Issues = "https://github.com/octrow/claude-cli/issues"
14
20
 
15
21
  [project.optional-dependencies]
16
- dev = ["pytest>=8", "pytest-asyncio>=0.24"]
22
+ dev = ["pytest>=8", "pytest-asyncio>=0.24", "ruff==0.17.3", "ty==0.0.95"]
17
23
 
18
24
  [build-system]
19
25
  requires = ["hatchling"]
@@ -28,3 +34,11 @@ include = ["src", "tests", "README.md", "LICENSE"]
28
34
  [tool.pytest.ini_options]
29
35
  testpaths = ["tests"]
30
36
  asyncio_mode = "auto"
37
+
38
+ [tool.ruff]
39
+ line-length = 100 # the code was already written to ~100; only 6 lines were longer
40
+
41
+ [tool.vulture]
42
+ paths = ["src", "tests"]
43
+ min_confidence = 80
44
+ ignore_names = ["auto"] # pytest fixture requested for its env side effect, never read
@@ -12,7 +12,7 @@ import hashlib
12
12
  import logging
13
13
  import sqlite3
14
14
  from contextlib import closing
15
- from datetime import datetime, timedelta, timezone
15
+ from datetime import UTC, datetime, timedelta
16
16
  from pathlib import Path
17
17
  from typing import Protocol
18
18
 
@@ -116,8 +116,8 @@ class FileCacheStore:
116
116
  except ValueError: # unparseable timestamp: treat as expired, re-ask rather than trust
117
117
  return False
118
118
  if written.tzinfo is None: # naive row (hand-written/legacy): assume UTC, never TypeError
119
- written = written.replace(tzinfo=timezone.utc)
120
- age = datetime.now(timezone.utc) - written
119
+ written = written.replace(tzinfo=UTC)
120
+ age = datetime.now(UTC) - written
121
121
  return age <= timedelta(days=self.max_age_days)
122
122
 
123
123
  def put(self, key: str, text: str, **meta) -> None:
@@ -134,7 +134,7 @@ class FileCacheStore:
134
134
  text,
135
135
  meta.get("cost_usd"),
136
136
  meta.get("duration_ms"),
137
- datetime.now(timezone.utc).isoformat(),
137
+ datetime.now(UTC).isoformat(),
138
138
  ),
139
139
  )
140
140
  conn.commit()
@@ -186,8 +186,12 @@ def cached_run(
186
186
  # folded into the model part only when set, so existing rows stay valid.
187
187
  routing = ",".join(f"{k}={kwargs[k]}" for k in _ROUTING_KWARGS if kwargs.get(k))
188
188
  key = cache_key(
189
- f"{model}|{routing}" if routing else model, prompt, schema=schema,
190
- version=version, purpose=purpose, tools=kwargs.get("tools", ""),
189
+ f"{model}|{routing}" if routing else model,
190
+ prompt,
191
+ schema=schema,
192
+ version=version,
193
+ purpose=purpose,
194
+ tools=kwargs.get("tools", ""),
191
195
  )
192
196
  if not refresh:
193
197
  hit = store.get(key)
@@ -197,9 +201,13 @@ def cached_run(
197
201
  result = runner(prompt, model=model, **kwargs)
198
202
  try:
199
203
  store.put(
200
- key, result.text, model=model, purpose=purpose,
204
+ key,
205
+ result.text,
206
+ model=model,
207
+ purpose=purpose,
201
208
  prompt=prompt if store_prompt else None,
202
- cost_usd=result.cost_usd, duration_ms=result.duration_ms,
209
+ cost_usd=result.cost_usd,
210
+ duration_ms=result.duration_ms,
203
211
  )
204
212
  except (sqlite3.Error, OSError) as exc:
205
213
  log.warning("llm cache write failed: %s", exc)
@@ -26,7 +26,9 @@ COMBOS = {"haiku": "free-stack", "sonnet": "free-stack", "opus": "hard"}
26
26
  COOLDOWN_SECS = 600
27
27
  ENV_FILE = Path.home() / ".omniroute" / ".env"
28
28
 
29
- _down_until = 0.0 # ponytail: per process, not per model; a dead gateway costs one timeout per window
29
+ _down_until = (
30
+ 0.0 # ponytail: per process, not per model; a dead gateway costs one timeout per window
31
+ )
30
32
 
31
33
 
32
34
  class HttpLlmError(ClaudeCliError):
@@ -47,8 +49,14 @@ def api_key() -> str | None:
47
49
  return None
48
50
 
49
51
 
50
- def omniroute_chat(prompt: str, *, model: str = "sonnet", timeout: float = 300,
51
- system_prompt: str | None = None, **_ignored) -> ClaudeResult:
52
+ def omniroute_chat(
53
+ prompt: str,
54
+ *,
55
+ model: str = "sonnet",
56
+ timeout: float = 300,
57
+ system_prompt: str | None = None,
58
+ **_ignored,
59
+ ) -> ClaudeResult:
52
60
  """One chat completion, ClaudeResult-shaped. CLI-only knobs (effort, cwd...) are ignored."""
53
61
  key = api_key()
54
62
  if not key:
@@ -58,8 +66,9 @@ def omniroute_chat(prompt: str, *, model: str = "sonnet", timeout: float = 300,
58
66
  if system_prompt:
59
67
  messages.insert(0, {"role": "system", "content": system_prompt})
60
68
  body = json.dumps({"model": COMBOS.get(model, model), "messages": messages}).encode()
61
- req = urllib.request.Request(url, body, {
62
- "Authorization": f"Bearer {key}", "Content-Type": "application/json"})
69
+ req = urllib.request.Request(
70
+ url, body, {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
71
+ )
63
72
  t0 = time.monotonic()
64
73
  try:
65
74
  with urllib.request.urlopen(req, timeout=timeout) as resp:
@@ -73,10 +82,16 @@ def omniroute_chat(prompt: str, *, model: str = "sonnet", timeout: float = 300,
73
82
  raise HttpLlmError("empty completion")
74
83
  # raw keeps the OpenAI body, but `usage` in the CLI envelope's keys so meters read it
75
84
  usage = data.get("usage") or {}
76
- raw = {**data, "usage": {"input_tokens": usage.get("prompt_tokens"),
77
- "output_tokens": usage.get("completion_tokens")}}
78
- return ClaudeResult(text=text, cost_usd=0.0, raw=raw,
79
- duration_ms=int((time.monotonic() - t0) * 1000))
85
+ raw = {
86
+ **data,
87
+ "usage": {
88
+ "input_tokens": usage.get("prompt_tokens"),
89
+ "output_tokens": usage.get("completion_tokens"),
90
+ },
91
+ }
92
+ return ClaudeResult(
93
+ text=text, cost_usd=0.0, raw=raw, duration_ms=int((time.monotonic() - t0) * 1000)
94
+ )
80
95
 
81
96
 
82
97
  def try_http(prompt: str, *, backend: str, cli_only: bool, **kw) -> ClaudeResult | None:
@@ -97,6 +112,7 @@ def try_http(prompt: str, *, backend: str, cli_only: bool, **kw) -> ClaudeResult
97
112
  return omniroute_chat(prompt, **kw)
98
113
  except HttpLlmError as exc:
99
114
  _down_until = time.monotonic() + COOLDOWN_SECS
100
- log.warning("omniroute http failed (%s); CLI fallback, http off for %ds",
101
- exc, COOLDOWN_SECS)
115
+ log.warning(
116
+ "omniroute http failed (%s); CLI fallback, http off for %ds", exc, COOLDOWN_SECS
117
+ )
102
118
  return None
@@ -13,8 +13,15 @@ from typing import Any
13
13
 
14
14
  #: Metadata keys lifted from a stream-json ``result`` event (tlf/digest.py).
15
15
  _META_KEYS = (
16
- "usage", "total_cost_usd", "duration_ms", "duration_api_ms",
17
- "num_turns", "session_id", "model", "modelUsage", "is_error",
16
+ "usage",
17
+ "total_cost_usd",
18
+ "duration_ms",
19
+ "duration_api_ms",
20
+ "num_turns",
21
+ "session_id",
22
+ "model",
23
+ "modelUsage",
24
+ "is_error",
18
25
  )
19
26
 
20
27
  _FENCED_JSON_RE = re.compile(r"```(?:json)?\s*(\{.*\})\s*```", re.DOTALL)
@@ -58,25 +65,8 @@ def extract_json(text: str) -> Any:
58
65
  start = text.find("{")
59
66
  if start == -1:
60
67
  return json.loads(text)
61
- depth, in_str, esc = 0, False, False
62
- for i in range(start, len(text)):
63
- ch = text[i]
64
- if in_str:
65
- if esc:
66
- esc = False
67
- elif ch == "\\":
68
- esc = True
69
- elif ch == '"':
70
- in_str = False
71
- elif ch == '"':
72
- in_str = True
73
- elif ch == "{":
74
- depth += 1
75
- elif ch == "}":
76
- depth -= 1
77
- if depth == 0:
78
- return json.loads(text[start : i + 1])
79
- return json.loads(text[start:]) # unbalanced — let json raise a clear error
68
+ # raw_decode parses the first object at `start` and ignores what follows it
69
+ return json.JSONDecoder().raw_decode(text, start)[0]
80
70
 
81
71
 
82
72
  def extract_json_or_none(text: str) -> Any | None:
@@ -110,9 +100,7 @@ def _is_omniroute_banner_line(line: str) -> bool:
110
100
  return True
111
101
  if " is ignored, " in line and "set it first" in line:
112
102
  return True
113
- if "connectors are disabled because ANTHROPIC_API_KEY" in line:
114
- return True
115
- return False
103
+ return "connectors are disabled because ANTHROPIC_API_KEY" in line
116
104
 
117
105
 
118
106
  def strip_omniroute_noise(text: str) -> str:
@@ -151,7 +139,8 @@ def parse_envelope(stdout: str) -> dict:
151
139
  if env is None:
152
140
  raise ValueError(f"claude returned non-JSON: {stdout[-300:]}") from exc
153
141
  if not isinstance(env, dict):
154
- raise ValueError(f"unexpected claude envelope: {type(env).__name__}")
142
+ # ValueError, not TypeError: runner._finish catches ValueError for every bad envelope
143
+ raise ValueError(f"unexpected claude envelope: {type(env).__name__}") # noqa: TRY004
155
144
  return env
156
145
 
157
146