spawnllm 0.3.1__tar.gz → 0.4.0__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 (28) hide show
  1. {spawnllm-0.3.1 → spawnllm-0.4.0}/PKG-INFO +29 -7
  2. {spawnllm-0.3.1 → spawnllm-0.4.0}/README.md +28 -6
  3. {spawnllm-0.3.1 → spawnllm-0.4.0}/pyproject.toml +1 -1
  4. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/__init__.py +18 -3
  5. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/backends/__init__.py +4 -0
  6. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/backends/base.py +124 -42
  7. spawnllm-0.4.0/spawnllm/backends/claude.py +153 -0
  8. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/backends/codex.py +49 -28
  9. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/backends/gemini.py +34 -33
  10. spawnllm-0.4.0/spawnllm/backends/mlx.py +66 -0
  11. spawnllm-0.4.0/spawnllm/call.py +97 -0
  12. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/cli.py +2 -2
  13. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/mlx/engine.py +8 -4
  14. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/proc.py +99 -1
  15. spawnllm-0.4.0/spawnllm/run.py +64 -0
  16. spawnllm-0.4.0/spawnllm/spec.py +94 -0
  17. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/structured.py +49 -0
  18. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/types.py +4 -1
  19. spawnllm-0.3.1/spawnllm/backends/claude.py +0 -194
  20. spawnllm-0.3.1/spawnllm/call.py +0 -58
  21. {spawnllm-0.3.1 → spawnllm-0.4.0}/LICENSE +0 -0
  22. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/__main__.py +0 -0
  23. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/backends/registry.py +0 -0
  24. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/mlx/__init__.py +0 -0
  25. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/mlx/codec.py +0 -0
  26. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/mlx/fuse.py +0 -0
  27. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/mlx/patches.py +0 -0
  28. {spawnllm-0.3.1 → spawnllm-0.4.0}/spawnllm/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spawnllm
3
- Version: 0.3.1
3
+ Version: 0.4.0
4
4
  Summary: Subshell + MLX LLM-calling backends (Claude/Codex CLI, local MLX) shared across tools.
5
5
  Keywords:
6
6
  Author: Yasyf Mohamedali
@@ -113,13 +113,14 @@ The `claude` backend resolves `small` to Haiku, `medium` to Sonnet, and `large`
113
113
 
114
114
  ### From Python
115
115
 
116
- `call` runs one request and returns the response. With no `backend`, it auto-selects the
117
- first installed, authenticated CLI:
116
+ `call_sync` runs one request and returns the response. With no `backend`, it auto-selects
117
+ the first installed, authenticated CLI (its async companion `call` mirrors the same
118
+ signature):
118
119
 
119
120
  ```python
120
- from spawnllm import call
121
+ from spawnllm import call_sync
121
122
 
122
- print(call("Reply with just the word: pong"))
123
+ print(call_sync("Reply with just the word: pong"))
123
124
  # pong
124
125
  ```
125
126
 
@@ -129,7 +130,7 @@ instead of text:
129
130
  ```python
130
131
  from pydantic import BaseModel
131
132
 
132
- from spawnllm import call, ClaudeCliBackend
133
+ from spawnllm import call_sync, ClaudeCliBackend
133
134
 
134
135
 
135
136
  class Capital(BaseModel):
@@ -137,7 +138,7 @@ class Capital(BaseModel):
137
138
  capital: str
138
139
 
139
140
 
140
- result = call(
141
+ result = call_sync(
141
142
  "What is the capital of France?",
142
143
  backend=ClaudeCliBackend(),
143
144
  model="large",
@@ -149,6 +150,27 @@ print(result.capital) # Paris
149
150
  When you don't pin a backend, set `specialty=` to scope auto-selection by task. The
150
151
  `debugging` and `review` specialties route to Codex, and `general` routes to Claude.
151
152
 
153
+ ### Spec-driven runs
154
+
155
+ For full control, build a `RunSpec` and execute it with `run_sync` (or its async companion
156
+ `run`). A `RunSpec` takes a literal provider model id — no tier mapping — and per-provider
157
+ flag passthrough via `provider_configs`. The call returns a `RunResult` with raw stdout,
158
+ stderr, and exit code, retrying transient `529`/overloaded/rate-limit failures with backoff:
159
+
160
+ ```python
161
+ from spawnllm import run_sync, RunSpec, ClaudeConfig, ClaudeCliBackend
162
+
163
+ result = run_sync(
164
+ RunSpec(
165
+ prompt="What is 2+2? Reply with just the number.",
166
+ model="opus",
167
+ provider_configs={"claude": ClaudeConfig(permission_mode="bypassPermissions")},
168
+ ),
169
+ backend=ClaudeCliBackend(),
170
+ )
171
+ print(result.stdout) # 4
172
+ ```
173
+
152
174
  ## What problems does this solve?
153
175
 
154
176
  Every tool that shells out to `claude` or `codex` rebuilds the same plumbing: argv
@@ -66,13 +66,14 @@ The `claude` backend resolves `small` to Haiku, `medium` to Sonnet, and `large`
66
66
 
67
67
  ### From Python
68
68
 
69
- `call` runs one request and returns the response. With no `backend`, it auto-selects the
70
- first installed, authenticated CLI:
69
+ `call_sync` runs one request and returns the response. With no `backend`, it auto-selects
70
+ the first installed, authenticated CLI (its async companion `call` mirrors the same
71
+ signature):
71
72
 
72
73
  ```python
73
- from spawnllm import call
74
+ from spawnllm import call_sync
74
75
 
75
- print(call("Reply with just the word: pong"))
76
+ print(call_sync("Reply with just the word: pong"))
76
77
  # pong
77
78
  ```
78
79
 
@@ -82,7 +83,7 @@ instead of text:
82
83
  ```python
83
84
  from pydantic import BaseModel
84
85
 
85
- from spawnllm import call, ClaudeCliBackend
86
+ from spawnllm import call_sync, ClaudeCliBackend
86
87
 
87
88
 
88
89
  class Capital(BaseModel):
@@ -90,7 +91,7 @@ class Capital(BaseModel):
90
91
  capital: str
91
92
 
92
93
 
93
- result = call(
94
+ result = call_sync(
94
95
  "What is the capital of France?",
95
96
  backend=ClaudeCliBackend(),
96
97
  model="large",
@@ -102,6 +103,27 @@ print(result.capital) # Paris
102
103
  When you don't pin a backend, set `specialty=` to scope auto-selection by task. The
103
104
  `debugging` and `review` specialties route to Codex, and `general` routes to Claude.
104
105
 
106
+ ### Spec-driven runs
107
+
108
+ For full control, build a `RunSpec` and execute it with `run_sync` (or its async companion
109
+ `run`). A `RunSpec` takes a literal provider model id — no tier mapping — and per-provider
110
+ flag passthrough via `provider_configs`. The call returns a `RunResult` with raw stdout,
111
+ stderr, and exit code, retrying transient `529`/overloaded/rate-limit failures with backoff:
112
+
113
+ ```python
114
+ from spawnllm import run_sync, RunSpec, ClaudeConfig, ClaudeCliBackend
115
+
116
+ result = run_sync(
117
+ RunSpec(
118
+ prompt="What is 2+2? Reply with just the number.",
119
+ model="opus",
120
+ provider_configs={"claude": ClaudeConfig(permission_mode="bypassPermissions")},
121
+ ),
122
+ backend=ClaudeCliBackend(),
123
+ )
124
+ print(result.stdout) # 4
125
+ ```
126
+
105
127
  ## What problems does this solve?
106
128
 
107
129
  Every tool that shells out to `claude` or `codex` rebuilds the same plumbing: argv
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "spawnllm"
3
- version = "0.3.1"
3
+ version = "0.4.0"
4
4
  description = "Subshell + MLX LLM-calling backends (Claude/Codex CLI, local MLX) shared across tools."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -15,22 +15,26 @@ from spawnllm.backends import (
15
15
  BackendStatus,
16
16
  BackendUnavailable,
17
17
  ClaudeCliBackend,
18
+ CliBackend,
18
19
  CodexCliBackend,
19
20
  GeminiCliBackend,
20
21
  Invocation,
21
22
  LlmBackend,
22
23
  LlmBackends,
24
+ MlxBackend,
23
25
  select_backend,
24
26
  )
25
- from spawnllm.call import call
26
- from spawnllm.proc import arun_cli, collect_process, map_concurrent, run_cli
27
+ from spawnllm.call import call, call_sync
28
+ from spawnllm.proc import RunResult, arun_cli, collect_process, map_concurrent, run_cli
29
+ from spawnllm.run import run, run_sync
30
+ from spawnllm.spec import ClaudeConfig, CodexConfig, GeminiConfig, RunSpec
27
31
  from spawnllm.structured import (
28
32
  extract_structured,
29
33
  parse_result_envelope,
30
34
  parse_structured_output,
31
35
  resolve_schema_path,
32
36
  )
33
- from spawnllm.types import TModel, TSpecialty
37
+ from spawnllm.types import ProviderName, TModel, TSpecialty
34
38
 
35
39
  __all__ = [
36
40
  "AntigravityCliBackend",
@@ -40,21 +44,32 @@ __all__ = [
40
44
  "BackendStatus",
41
45
  "BackendUnavailable",
42
46
  "ClaudeCliBackend",
47
+ "ClaudeConfig",
48
+ "CliBackend",
43
49
  "CodexCliBackend",
50
+ "CodexConfig",
44
51
  "GeminiCliBackend",
52
+ "GeminiConfig",
45
53
  "Invocation",
46
54
  "LlmBackend",
47
55
  "LlmBackends",
56
+ "MlxBackend",
57
+ "ProviderName",
58
+ "RunResult",
59
+ "RunSpec",
48
60
  "TModel",
49
61
  "TSpecialty",
50
62
  "arun_cli",
51
63
  "call",
64
+ "call_sync",
52
65
  "collect_process",
53
66
  "extract_structured",
54
67
  "map_concurrent",
55
68
  "parse_result_envelope",
56
69
  "parse_structured_output",
57
70
  "resolve_schema_path",
71
+ "run",
58
72
  "run_cli",
73
+ "run_sync",
59
74
  "select_backend",
60
75
  ]
@@ -8,12 +8,14 @@ from spawnllm.backends.base import (
8
8
  BackendReady,
9
9
  BackendStatus,
10
10
  BackendUnavailable,
11
+ CliBackend,
11
12
  Invocation,
12
13
  LlmBackend,
13
14
  )
14
15
  from spawnllm.backends.claude import ClaudeCliBackend
15
16
  from spawnllm.backends.codex import CodexCliBackend
16
17
  from spawnllm.backends.gemini import AntigravityCliBackend, GeminiCliBackend
18
+ from spawnllm.backends.mlx import MlxBackend
17
19
  from spawnllm.backends.registry import LlmBackends, select_backend
18
20
 
19
21
  __all__ = [
@@ -24,10 +26,12 @@ __all__ = [
24
26
  "BackendStatus",
25
27
  "BackendUnavailable",
26
28
  "ClaudeCliBackend",
29
+ "CliBackend",
27
30
  "CodexCliBackend",
28
31
  "GeminiCliBackend",
29
32
  "Invocation",
30
33
  "LlmBackend",
31
34
  "LlmBackends",
35
+ "MlxBackend",
32
36
  "select_backend",
33
37
  ]
@@ -1,17 +1,22 @@
1
- """Abstract interface for an LLM CLI backend."""
1
+ """Abstract execution contract for an LLM backend and its subprocess family."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import os
6
7
  import shutil
7
8
  from abc import ABC, abstractmethod
8
9
  from dataclasses import dataclass
10
+ from pathlib import Path
9
11
  from typing import TYPE_CHECKING, ClassVar
10
12
 
13
+ from spawnllm.proc import RunResult, acapture_cli, capture_cli
14
+
11
15
  if TYPE_CHECKING:
12
16
  from pydantic import BaseModel
13
17
 
14
- from spawnllm.types import TModel
18
+ from spawnllm.spec import RunSpec
19
+ from spawnllm.types import ProviderName, TModel
15
20
 
16
21
 
17
22
  @dataclass(frozen=True)
@@ -76,38 +81,47 @@ class Invocation:
76
81
 
77
82
 
78
83
  class LlmBackend(ABC):
79
- """Abstract interface for an LLM CLI backend.
84
+ """Abstract execution contract for an LLM backend.
80
85
 
81
86
  Concrete backends map abstract model sizes to provider-specific model names
82
- and encapsulate how to invoke the provider's CLI and parse the raw response.
87
+ and encapsulate how to execute a `RunSpec` and parse the raw response.
83
88
 
84
89
  Attributes:
85
90
  models: Mapping from abstract model size to the provider's model name.
91
+ provider: Provider identifier keying a `RunSpec`'s `provider_configs`.
86
92
  """
87
93
 
88
94
  models: ClassVar[dict[TModel, str]]
89
- binary: ClassVar[str]
90
- install_hint: ClassVar[str]
95
+ provider: ClassVar[ProviderName]
91
96
 
92
97
  @abstractmethod
93
- def build_command(self, model: str, schema_path: str | None, agent: bool) -> list[str]:
94
- """Build the CLI argv for a single invocation (prompt delivered via stdin).
98
+ async def aexecute(self, spec: RunSpec) -> RunResult:
99
+ """Execute a single run asynchronously and capture its raw outcome.
95
100
 
96
101
  Args:
97
- model: Provider-specific model name.
98
- schema_path: Schema argument for structured output, or `None`.
99
- agent: Whether the invocation may use tools / agent capabilities.
102
+ spec: The configured run to execute.
100
103
 
101
104
  Returns:
102
- The argv list to execute.
105
+ The captured stdout, stderr, and exit code.
106
+ """
107
+
108
+ @abstractmethod
109
+ def execute(self, spec: RunSpec) -> RunResult:
110
+ """Execute a single run synchronously and capture its raw outcome.
111
+
112
+ Args:
113
+ spec: The configured run to execute.
114
+
115
+ Returns:
116
+ The captured stdout, stderr, and exit code.
103
117
  """
104
118
 
105
119
  @abstractmethod
106
120
  def parse_response(self, raw: str, response_model: type[BaseModel] | None) -> str | BaseModel:
107
- """Parse raw CLI stdout into text or a validated model.
121
+ """Parse raw stdout into text or a validated model.
108
122
 
109
123
  Args:
110
- raw: Raw stdout from the backend CLI.
124
+ raw: Raw stdout from the backend.
111
125
  response_model: Model to validate against, or `None` for raw text.
112
126
 
113
127
  Returns:
@@ -116,43 +130,36 @@ class LlmBackend(ABC):
116
130
 
117
131
  @abstractmethod
118
132
  def env(self) -> dict[str, str]:
119
- """Return extra environment variables for the CLI invocation, merged over the inherited environment."""
133
+ """Return extra environment variables for the invocation, merged over the inherited environment."""
120
134
 
121
- def check_status(self, *, timeout: int = 10) -> BackendStatus:
122
- """Check whether this backend's CLI is installed and authenticated.
135
+ @abstractmethod
136
+ def is_authenticated(self, *, timeout: int) -> bool:
137
+ """Probe whether the backend holds valid credentials for its provider.
138
+
139
+ "Authenticated" means the backend reports an active login session for the
140
+ provider, not merely that an executable is present on PATH.
123
141
 
124
142
  Args:
125
- timeout: Seconds to wait for the authentication probe.
143
+ timeout: Seconds to wait for the credential probe.
126
144
 
127
145
  Returns:
128
- `BackendReady` when authenticated, `BackendNotInstalled` when the CLI
129
- is not on PATH, else `BackendNotAuthenticated`.
130
-
131
- Raises:
132
- subprocess.TimeoutExpired: If `is_authenticated` exceeds `timeout`.
146
+ `True` when the backend reports an authenticated session.
133
147
  """
134
- if not shutil.which(self.binary):
135
- return BackendNotInstalled(binary=self.binary, install_hint=self.install_hint)
136
- if self.is_authenticated(timeout=timeout):
137
- return BackendReady(binary=self.binary)
138
- return BackendNotAuthenticated(binary=self.binary)
139
148
 
140
149
  @abstractmethod
141
- def is_authenticated(self, *, timeout: int) -> bool:
142
- """Probe whether the CLI holds valid credentials for its provider.
143
-
144
- "Authenticated" means the CLI reports an active login session for the
145
- provider, not merely that the executable is present on PATH.
150
+ def check_status(self, *, timeout: int = 10) -> BackendStatus:
151
+ """Check whether this backend is installed and authenticated.
146
152
 
147
153
  Args:
148
- timeout: Seconds to wait for the credential probe.
154
+ timeout: Seconds to wait for the authentication probe.
149
155
 
150
156
  Returns:
151
- `True` when the CLI reports an authenticated session.
157
+ `BackendReady` when authenticated, `BackendNotInstalled` when the
158
+ backend is not available, else `BackendNotAuthenticated`.
152
159
  """
153
160
 
154
161
  def schema_for(self, model: type[BaseModel]) -> str:
155
- """Serialize a Pydantic model into the JSON-schema string this backend's CLI expects.
162
+ """Serialize a Pydantic model into the JSON-schema string this backend expects.
156
163
 
157
164
  The default emits the model's plain JSON schema; provider backends
158
165
  override to apply their SDK's strict-schema transform.
@@ -165,7 +172,34 @@ class LlmBackend(ABC):
165
172
  """
166
173
  return json.dumps(model.model_json_schema())
167
174
 
168
- def invocation(self, prompt: str, *, model: str, schema_path: str | None, agent: bool) -> Invocation:
175
+
176
+ class CliBackend(LlmBackend):
177
+ """Execution contract for the subprocess-backed LLM family.
178
+
179
+ Concrete CLI backends build an argv from a `RunSpec`; `aexecute`/`execute`
180
+ run it, merge environment overrides, and resolve the result from stdout or a
181
+ designated result file.
182
+
183
+ Attributes:
184
+ binary: Name of the backend's CLI executable on PATH.
185
+ install_hint: Suggested shell command to install the CLI.
186
+ """
187
+
188
+ binary: ClassVar[str]
189
+ install_hint: ClassVar[str]
190
+
191
+ @abstractmethod
192
+ def build_command(self, spec: RunSpec) -> list[str]:
193
+ """Build the CLI argv for a single invocation.
194
+
195
+ Args:
196
+ spec: The configured run to translate into argv.
197
+
198
+ Returns:
199
+ The argv list to execute.
200
+ """
201
+
202
+ def invocation(self, spec: RunSpec) -> Invocation:
169
203
  """Build the argv, stdin, and result source for a single invocation.
170
204
 
171
205
  The default delivers the prompt over stdin and reads the result from
@@ -173,12 +207,60 @@ class LlmBackend(ABC):
173
207
  result from a file.
174
208
 
175
209
  Args:
176
- prompt: The prompt text to deliver to the CLI.
177
- model: Provider-specific model name.
178
- schema_path: Schema argument for structured output, or `None`.
179
- agent: Whether the invocation may use tools / agent capabilities.
210
+ spec: The configured run to translate into an invocation.
180
211
 
181
212
  Returns:
182
213
  An `Invocation` carrying the argv, stdin text, and result source.
183
214
  """
184
- return Invocation(self.build_command(model, schema_path, agent), prompt)
215
+ return Invocation(self.build_command(spec), spec.prompt)
216
+
217
+ async def aexecute(self, spec: RunSpec) -> RunResult:
218
+ inv = self.invocation(spec)
219
+ try:
220
+ rr = await acapture_cli(
221
+ inv.argv,
222
+ input=inv.stdin,
223
+ env=os.environ | self.env() | (spec.env or {}),
224
+ cwd=spec.cwd,
225
+ timeout=spec.timeout,
226
+ )
227
+ stdout = Path(inv.result_path).read_text() if inv.result_path else rr.stdout
228
+ finally:
229
+ for path in inv.cleanup_paths:
230
+ Path(path).unlink(missing_ok=True)
231
+ return RunResult(stdout, rr.stderr, rr.returncode)
232
+
233
+ def execute(self, spec: RunSpec) -> RunResult:
234
+ inv = self.invocation(spec)
235
+ try:
236
+ rr = capture_cli(
237
+ inv.argv,
238
+ input=inv.stdin,
239
+ env=os.environ | self.env() | (spec.env or {}),
240
+ cwd=spec.cwd,
241
+ timeout=spec.timeout,
242
+ )
243
+ stdout = Path(inv.result_path).read_text() if inv.result_path else rr.stdout
244
+ finally:
245
+ for path in inv.cleanup_paths:
246
+ Path(path).unlink(missing_ok=True)
247
+ return RunResult(stdout, rr.stderr, rr.returncode)
248
+
249
+ def check_status(self, *, timeout: int = 10) -> BackendStatus:
250
+ """Check whether this backend's CLI is installed and authenticated.
251
+
252
+ Args:
253
+ timeout: Seconds to wait for the authentication probe.
254
+
255
+ Returns:
256
+ `BackendReady` when authenticated, `BackendNotInstalled` when the CLI
257
+ is not on PATH, else `BackendNotAuthenticated`.
258
+
259
+ Raises:
260
+ subprocess.TimeoutExpired: If `is_authenticated` exceeds `timeout`.
261
+ """
262
+ if not shutil.which(self.binary):
263
+ return BackendNotInstalled(binary=self.binary, install_hint=self.install_hint)
264
+ if self.is_authenticated(timeout=timeout):
265
+ return BackendReady(binary=self.binary)
266
+ return BackendNotAuthenticated(binary=self.binary)
@@ -0,0 +1,153 @@
1
+ """CliBackend for the Anthropic `claude` CLI, plus install/auth status checks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ from typing import TYPE_CHECKING, ClassVar
8
+
9
+ from spawnllm.backends.base import CliBackend
10
+ from spawnllm.spec import ClaudeConfig
11
+ from spawnllm.structured import parse_structured_output
12
+
13
+ if TYPE_CHECKING:
14
+ from pydantic import BaseModel
15
+
16
+ from spawnllm.spec import RunSpec
17
+ from spawnllm.types import ProviderName, TModel
18
+
19
+ CLAUDE_MODELS: dict[TModel, str] = {"small": "haiku", "medium": "sonnet", "large": "opus"}
20
+
21
+
22
+ class ClaudeCliBackend(CliBackend):
23
+ """`CliBackend` for the Anthropic `claude` CLI.
24
+
25
+ `build_command` translates a `RunSpec` into a `claude -p` argv with the prompt
26
+ delivered over stdin. The permission and system-prompt flags resolve through
27
+ three mutually exclusive branches: explicit `ClaudeConfig` agent fields, an
28
+ agent run, or a locked-down default. Orthogonal `ClaudeConfig` extras and the
29
+ output format are appended after.
30
+
31
+ Attributes:
32
+ models: Mapping from abstract model size to a Claude model alias
33
+ (`haiku`/`sonnet`/`opus`).
34
+
35
+ Example:
36
+ >>> from spawnllm.spec import RunSpec
37
+ >>> ClaudeCliBackend().build_command(RunSpec(prompt="hi", model="haiku"))[:5]
38
+ ['claude', '-p', '--no-session-persistence', '--model', 'haiku']
39
+ """
40
+
41
+ models: ClassVar[dict[TModel, str]] = CLAUDE_MODELS
42
+ provider: ClassVar[ProviderName] = "claude"
43
+ binary: ClassVar[str] = "claude"
44
+ install_hint: ClassVar[str] = "curl -fsSL https://claude.ai/install.sh | bash"
45
+
46
+ def build_command(self, spec: RunSpec) -> list[str]:
47
+ """Build the `claude -p` argv for one stdin-prompted invocation.
48
+
49
+ Args:
50
+ spec: The configured run to translate into argv.
51
+
52
+ Returns:
53
+ The argv list to execute; the prompt is delivered over stdin.
54
+ """
55
+ cfg = spec.config_for(ClaudeConfig) or ClaudeConfig()
56
+ explicit = (
57
+ cfg.permission_mode is not None
58
+ or cfg.mcp_config is not None
59
+ or cfg.append_system_prompt is not None
60
+ or cfg.system_prompt is not None
61
+ or cfg.settings is not None
62
+ or bool(cfg.disallowed_tools)
63
+ or cfg.strict_mcp
64
+ )
65
+ return [
66
+ "claude",
67
+ "-p",
68
+ "--no-session-persistence",
69
+ "--model",
70
+ spec.model,
71
+ *(
72
+ [
73
+ *(["--permission-mode", cfg.permission_mode] if cfg.permission_mode is not None else []),
74
+ *(["--mcp-config", cfg.mcp_config] if cfg.mcp_config is not None else []),
75
+ *(["--strict-mcp-config"] if cfg.strict_mcp else []),
76
+ *(["--disallowedTools", *cfg.disallowed_tools] if cfg.disallowed_tools else []),
77
+ *(
78
+ ["--append-system-prompt", cfg.append_system_prompt]
79
+ if cfg.append_system_prompt is not None
80
+ else []
81
+ ),
82
+ *(["--settings", cfg.settings] if cfg.settings is not None else []),
83
+ *(["--max-budget-usd", str(cfg.max_budget_usd)] if cfg.max_budget_usd is not None else []),
84
+ ]
85
+ if explicit
86
+ else ["--permission-mode", "auto", "--max-budget-usd", "1"]
87
+ if spec.agent
88
+ else ["--system-prompt", "", "--setting-sources", "", "--strict-mcp-config"]
89
+ ),
90
+ *(["--system-prompt", cfg.system_prompt] if cfg.system_prompt is not None else []),
91
+ *(["--max-turns", str(cfg.max_turns)] if cfg.max_turns is not None else []),
92
+ *(["--tools", cfg.tools] if cfg.tools is not None else []),
93
+ *(["--disable-slash-commands"] if cfg.disable_slash_commands else []),
94
+ *(
95
+ ["--json-schema", spec.schema, "--output-format", "json"]
96
+ if spec.schema
97
+ else ["--output-format", cfg.output_format]
98
+ if cfg.output_format
99
+ else []
100
+ ),
101
+ *(["--verbose"] if cfg.verbose else []),
102
+ ]
103
+
104
+ def schema_for(self, model: type[BaseModel]) -> str:
105
+ """Serialize a Pydantic model into Anthropic's structured-output JSON schema.
106
+
107
+ Uses the Anthropic SDK's `transform_schema`, which recursively sets
108
+ `additionalProperties: false` while preserving Pydantic's `required`,
109
+ producing the standard JSON Schema the `claude --json-schema` flag expects.
110
+
111
+ Args:
112
+ model: The Pydantic model describing the structured output.
113
+
114
+ Returns:
115
+ A JSON-schema string passed inline to `--json-schema`.
116
+ """
117
+ from anthropic.lib._parse._transform import transform_schema
118
+
119
+ return json.dumps(transform_schema(model))
120
+
121
+ def parse_response(self, raw: str, response_model: type[BaseModel] | None) -> str | BaseModel:
122
+ """Parse `claude` stdout into text or a validated model.
123
+
124
+ Args:
125
+ raw: Raw stdout from the `claude` CLI.
126
+ response_model: Model to validate against, or `None` for raw text.
127
+
128
+ Returns:
129
+ `raw` for text calls; otherwise the validated `structured_output` from the result event, else `raw` as JSON.
130
+ """
131
+ return parse_structured_output(raw, response_model)
132
+
133
+ def env(self) -> dict[str, str]:
134
+ """Return no extra environment variables; the `claude` CLI runs with the inherited environment."""
135
+ # CLAUDE_CODE_SIMPLE=1 breaks claude.ai keychain auth ("Not logged in")
136
+ # on current CLIs; --setting-sources ""/--strict-mcp-config already trim startup.
137
+ return {}
138
+
139
+ def is_authenticated(self, *, timeout: int) -> bool:
140
+ """Report whether `claude auth status` exits cleanly, i.e. a claude.ai login is stored.
141
+
142
+ Args:
143
+ timeout: Seconds to wait for `claude auth status`.
144
+
145
+ Returns:
146
+ `True` when the OAuth-aware probe reports a stored claude.ai login.
147
+ """
148
+ return (
149
+ subprocess.run(
150
+ ["claude", "auth", "status"], capture_output=True, text=True, timeout=timeout, check=False
151
+ ).returncode
152
+ == 0
153
+ )