spawnllm 0.5.1__tar.gz → 0.5.2__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.5.1 → spawnllm-0.5.2}/PKG-INFO +1 -1
  2. {spawnllm-0.5.1 → spawnllm-0.5.2}/pyproject.toml +1 -1
  3. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/backends/base.py +9 -4
  4. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/backends/claude.py +48 -7
  5. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/backends/codex.py +1 -1
  6. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/backends/gemini.py +1 -1
  7. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/backends/mlx.py +1 -1
  8. {spawnllm-0.5.1 → spawnllm-0.5.2}/LICENSE +0 -0
  9. {spawnllm-0.5.1 → spawnllm-0.5.2}/README.md +0 -0
  10. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/__init__.py +0 -0
  11. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/__main__.py +0 -0
  12. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/backends/__init__.py +0 -0
  13. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/backends/registry.py +0 -0
  14. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/call.py +0 -0
  15. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/cli.py +0 -0
  16. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/extract.py +0 -0
  17. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/mlx/__init__.py +0 -0
  18. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/mlx/codec.py +0 -0
  19. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/mlx/engine.py +0 -0
  20. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/mlx/fuse.py +0 -0
  21. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/mlx/patches.py +0 -0
  22. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/proc.py +0 -0
  23. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/py.typed +0 -0
  24. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/response.py +0 -0
  25. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/run.py +0 -0
  26. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/spec.py +0 -0
  27. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/structured.py +0 -0
  28. {spawnllm-0.5.1 → spawnllm-0.5.2}/spawnllm/types.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: spawnllm
3
- Version: 0.5.1
3
+ Version: 0.5.2
4
4
  Summary: Subshell + MLX LLM-calling backends (Claude/Codex CLI, local MLX) shared across tools.
5
5
  Keywords:
6
6
  Author: Yasyf Mohamedali
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "spawnllm"
3
3
  # Inert sentinel: the real version is set from the release tag (uv version --frozen).
4
- version = "0.5.1"
4
+ version = "0.5.2"
5
5
  description = "Subshell + MLX LLM-calling backends (Claude/Codex CLI, local MLX) shared across tools."
6
6
  readme = "README.md"
7
7
  license = "MIT"
@@ -129,8 +129,13 @@ class LlmBackend(ABC):
129
129
  """
130
130
 
131
131
  @abstractmethod
132
- def env(self) -> dict[str, str]:
133
- """Return extra environment variables for the invocation, merged over the inherited environment."""
132
+ def env(self, spec: RunSpec) -> dict[str, str]:
133
+ """Return extra environment variables for the invocation, merged over the inherited environment.
134
+
135
+ Args:
136
+ spec: The configured run, so a backend can scope env overrides to
137
+ `spec.isolated` (e.g. a fresh config home only when isolating).
138
+ """
134
139
 
135
140
  @abstractmethod
136
141
  def is_authenticated(self, *, timeout: int) -> bool:
@@ -292,7 +297,7 @@ class CliBackend(LlmBackend):
292
297
  rr = await acapture_cli(
293
298
  inv.argv,
294
299
  input=inv.stdin,
295
- env=os.environ | self.env() | (spec.env or {}),
300
+ env=os.environ | self.env(spec) | (spec.env or {}),
296
301
  cwd=spec.cwd,
297
302
  timeout=spec.timeout,
298
303
  )
@@ -311,7 +316,7 @@ class CliBackend(LlmBackend):
311
316
  rr = capture_cli(
312
317
  inv.argv,
313
318
  input=inv.stdin,
314
- env=os.environ | self.env() | (spec.env or {}),
319
+ env=os.environ | self.env(spec) | (spec.env or {}),
315
320
  cwd=spec.cwd,
316
321
  timeout=spec.timeout,
317
322
  )
@@ -2,8 +2,12 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import atexit
5
6
  import json
7
+ import shutil
6
8
  import subprocess
9
+ import tempfile
10
+ from pathlib import Path
7
11
  from typing import TYPE_CHECKING, ClassVar
8
12
 
9
13
  from spawnllm.backends.base import CliBackend
@@ -58,6 +62,8 @@ class ClaudeCliBackend(CliBackend):
58
62
  binary: ClassVar[str] = "claude"
59
63
  install_hint: ClassVar[str] = "curl -fsSL https://claude.ai/install.sh | bash"
60
64
 
65
+ _isolated_config_dir: str | None = None
66
+
61
67
  def build_command(self, spec: RunSpec) -> list[str]:
62
68
  """Build the `claude -p` argv for one stdin-prompted invocation.
63
69
 
@@ -151,15 +157,50 @@ class ClaudeCliBackend(CliBackend):
151
157
  return event["result"] if isinstance(event.get("result"), str) else "claude reported an error"
152
158
  return None
153
159
 
154
- def env(self) -> dict[str, str]:
155
- """Return no extra environment variables; the `claude` CLI runs with the inherited environment.
160
+ def env(self, spec: RunSpec) -> dict[str, str]:
161
+ """Point an isolated run at a fresh, host-free `CLAUDE_CONFIG_DIR`; otherwise add nothing.
162
+
163
+ Defense in depth behind the argv flags: a config home seeded with nothing
164
+ but the account pointer and OAuth token means plugin and
165
+ `~/.claude.json`-driven loading finds no host settings, plugins, or hooks
166
+ even if a flag is ever dropped.
167
+
168
+ Args:
169
+ spec: The configured run; `spec.isolated` gates the override.
156
170
 
157
- Isolation is flag-only (`--setting-sources ""`/`--strict-mcp-config`). A fresh
158
- `CLAUDE_CONFIG_DIR` would log the CLI out: the keychain token is keyed to the
159
- `oauthAccount` recorded in `~/.claude.json`, absent from a relocated dir.
160
- (`CLAUDE_CODE_SIMPLE=1` likewise breaks claude.ai keychain auth.)
171
+ Returns:
172
+ `{"CLAUDE_CONFIG_DIR": <isolated dir>}` for an isolated run, else `{}`.
173
+ """
174
+ if not spec.isolated:
175
+ return {}
176
+ return {"CLAUDE_CONFIG_DIR": self._isolated_dir()}
177
+
178
+ def _isolated_dir(self) -> str:
179
+ """Return the process-lifetime isolated config home, creating and seeding it once.
180
+
181
+ The home is a fresh temp dir seeded with only the two auth-bearing files:
182
+ `~/.claude.json` minus its `mcpServers` block (the active-account pointer,
183
+ with no host MCP servers leaking even absent `--strict-mcp-config`) and
184
+ `~/.claude/.credentials.json` (the claude.ai OAuth token, which lives in
185
+ the config home — relocating it without this copy logs the run out). Host
186
+ `settings.json`, plugins, and hooks are never copied. The dir is cached on
187
+ the backend and removed at interpreter exit.
161
188
  """
162
- return {}
189
+ if self._isolated_config_dir is not None:
190
+ return self._isolated_config_dir
191
+ config_dir = Path(tempfile.mkdtemp(prefix="spawnllm-claude-config-"))
192
+ home = Path.home()
193
+ account_path = home / ".claude.json"
194
+ if account_path.exists():
195
+ account = json.loads(account_path.read_text())
196
+ account.pop("mcpServers", None)
197
+ (config_dir / ".claude.json").write_text(json.dumps(account))
198
+ credentials_path = home / ".claude" / ".credentials.json"
199
+ if credentials_path.exists():
200
+ shutil.copyfile(credentials_path, config_dir / ".credentials.json")
201
+ atexit.register(shutil.rmtree, config_dir, ignore_errors=True)
202
+ self._isolated_config_dir = str(config_dir)
203
+ return self._isolated_config_dir
163
204
 
164
205
  def is_authenticated(self, *, timeout: int) -> bool:
165
206
  """Report whether `claude auth status` exits cleanly, i.e. a claude.ai login is stored.
@@ -128,7 +128,7 @@ class CodexCliBackend(CliBackend):
128
128
 
129
129
  return json.dumps(to_strict_json_schema(model))
130
130
 
131
- def env(self) -> dict[str, str]:
131
+ def env(self, _spec: RunSpec) -> dict[str, str]:
132
132
  """Return no extra environment variables; `--ignore-user-config` isolates config while `CODEX_HOME` keeps auth.
133
133
 
134
134
  `codex` keeps `auth.json` in `CODEX_HOME`, so relocating it would strand a
@@ -30,7 +30,7 @@ class GeminiFamilyBackend(CliBackend, ABC):
30
30
 
31
31
  api_key_envs: ClassVar[tuple[str, ...]]
32
32
 
33
- def env(self) -> dict[str, str]:
33
+ def env(self, _spec: RunSpec) -> dict[str, str]:
34
34
  """Return no extra environment variables.
35
35
 
36
36
  Gemini-family CLIs read settings and OAuth from the same config home (`~/.gemini`,
@@ -52,7 +52,7 @@ class MlxBackend(LlmBackend):
52
52
  """Return the `structured_output` from a stream-json result event, else `raw` parsed as JSON."""
53
53
  return structured_value(raw)
54
54
 
55
- def env(self) -> dict[str, str]:
55
+ def env(self, _spec: RunSpec) -> dict[str, str]:
56
56
  """Return no extra environment variables; MLX runs in-process with nothing to isolate."""
57
57
  return {}
58
58
 
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes