agentshim 0.4.0__tar.gz → 0.5.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.
Files changed (82) hide show
  1. agentshim-0.5.1/.github/workflows/ci.yml +56 -0
  2. agentshim-0.5.1/.github/workflows/docs.yml +52 -0
  3. {agentshim-0.4.0 → agentshim-0.5.1}/.gitignore +1 -0
  4. {agentshim-0.4.0 → agentshim-0.5.1}/PKG-INFO +91 -4
  5. {agentshim-0.4.0 → agentshim-0.5.1}/README.md +90 -3
  6. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/__init__.py +17 -1
  7. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/base.py +39 -16
  8. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/claude/agent.py +8 -5
  9. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/cli_agent.py +43 -123
  10. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/codex/agent.py +15 -8
  11. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/codex/events.py +0 -9
  12. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/copilot/agent.py +19 -13
  13. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/copilot/events.py +2 -1
  14. agentshim-0.5.1/agentshim/executor.py +319 -0
  15. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/gemini/agent.py +8 -5
  16. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/opencode/agent.py +8 -5
  17. agentshim-0.5.1/docs/api.md +3 -0
  18. agentshim-0.5.1/docs/development.md +27 -0
  19. agentshim-0.5.1/docs/events.md +137 -0
  20. agentshim-0.5.1/docs/executors.md +82 -0
  21. agentshim-0.5.1/docs/extending.md +51 -0
  22. agentshim-0.5.1/docs/getting-started.md +45 -0
  23. agentshim-0.5.1/docs/index.md +37 -0
  24. agentshim-0.5.1/docs/mcp.md +35 -0
  25. agentshim-0.5.1/docs/providers.md +23 -0
  26. agentshim-0.5.1/mkdocs.yml +43 -0
  27. {agentshim-0.4.0 → agentshim-0.5.1}/pyproject.toml +9 -2
  28. agentshim-0.5.1/pyrightconfig.json +10 -0
  29. agentshim-0.5.1/scripts/check_errors.sh +20 -0
  30. agentshim-0.5.1/scripts/format_code.sh +19 -0
  31. agentshim-0.5.1/scripts/type_check.sh +17 -0
  32. agentshim-0.5.1/tests/unit/cli_agent/test_command_executor.py +243 -0
  33. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/llm/test_claude_stream.py +2 -1
  34. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/test_agent_cli_event_parsing.py +2 -1
  35. agentshim-0.5.1/tests/unit/test_claude_usage_e2e.py +105 -0
  36. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/test_coding_agent_facade.py +16 -0
  37. {agentshim-0.4.0 → agentshim-0.5.1}/uv.lock +322 -1
  38. agentshim-0.4.0/.github/workflows/ci.yml +0 -18
  39. {agentshim-0.4.0 → agentshim-0.5.1}/.github/workflows/publish.yml +0 -0
  40. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/claude/__init__.py +0 -0
  41. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/claude/events.py +0 -0
  42. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/claude/hooks/__init__.py +0 -0
  43. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/claude/hooks/confine_reads.py +0 -0
  44. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/claude_events.py +0 -0
  45. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/codex/__init__.py +0 -0
  46. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/codex_events.py +0 -0
  47. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/copilot/__init__.py +0 -0
  48. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/copilot_events.py +0 -0
  49. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/events.py +0 -0
  50. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/gemini/__init__.py +0 -0
  51. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/gemini/events.py +0 -0
  52. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/gemini_events.py +0 -0
  53. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/llm_client.py +0 -0
  54. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/mcp_config.py +0 -0
  55. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/opencode/__init__.py +0 -0
  56. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/opencode/events.py +0 -0
  57. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/opencode_events.py +0 -0
  58. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/py.typed +0 -0
  59. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/sandbox.py +0 -0
  60. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/subagent.py +0 -0
  61. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/usage.py +0 -0
  62. {agentshim-0.4.0 → agentshim-0.5.1}/agentshim/utils.py +0 -0
  63. {agentshim-0.4.0 → agentshim-0.5.1}/tests/fixtures/copilot/session_turn_1.jsonl +0 -0
  64. {agentshim-0.4.0 → agentshim-0.5.1}/tests/fixtures/copilot/session_turn_2_resumed.jsonl +0 -0
  65. {agentshim-0.4.0 → agentshim-0.5.1}/tests/fixtures/copilot/streaming_dedup.jsonl +0 -0
  66. {agentshim-0.4.0 → agentshim-0.5.1}/tests/fixtures/copilot/tool_and_usage.jsonl +0 -0
  67. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/cli_agent/test_agent_cli_cleanup.py +0 -0
  68. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/cli_agent/test_check_cli.py +0 -0
  69. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/cli_agent/test_cli_prompt_passing.py +1 -1
  70. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/llm/conftest.py +0 -0
  71. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/llm/test_gemini_fixture.py +0 -0
  72. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/llm/test_gemini_stream.py +0 -0
  73. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/test_agent_cli_claude.py +0 -0
  74. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/test_agent_cli_codex.py +0 -0
  75. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/test_agent_cli_copilot.py +0 -0
  76. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/test_agent_cli_copilot_fixtures.py +0 -0
  77. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/test_agent_cli_mcp_unsupported.py +0 -0
  78. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/test_agent_cli_resume.py +2 -2
  79. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/test_agent_cli_sandbox.py +0 -0
  80. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/test_cli_agent_usage.py +2 -2
  81. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/test_event_handlers.py +0 -0
  82. {agentshim-0.4.0 → agentshim-0.5.1}/tests/unit/test_mcp_config.py +0 -0
@@ -0,0 +1,56 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ lint:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - uses: astral-sh/setup-uv@v6
16
+ with:
17
+ enable-cache: true
18
+ cache-dependency-glob: uv.lock
19
+ - uses: actions/setup-python@v5
20
+ with:
21
+ python-version: "3.11"
22
+ - run: uv sync --locked --dev
23
+ - run: bash scripts/format_code.sh --check
24
+ - run: bash scripts/check_errors.sh
25
+
26
+ typecheck:
27
+ runs-on: ubuntu-latest
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+ - uses: astral-sh/setup-uv@v6
31
+ with:
32
+ enable-cache: true
33
+ cache-dependency-glob: uv.lock
34
+ - uses: actions/setup-python@v5
35
+ with:
36
+ python-version: "3.11"
37
+ - run: uv sync --locked --dev
38
+ - run: bash scripts/type_check.sh
39
+
40
+ test:
41
+ runs-on: ubuntu-latest
42
+ strategy:
43
+ fail-fast: false
44
+ matrix:
45
+ python-version: ["3.10", "3.11", "3.12"]
46
+ steps:
47
+ - uses: actions/checkout@v4
48
+ - uses: astral-sh/setup-uv@v6
49
+ with:
50
+ enable-cache: true
51
+ cache-dependency-glob: uv.lock
52
+ - uses: actions/setup-python@v5
53
+ with:
54
+ python-version: ${{ matrix.python-version }}
55
+ - run: uv sync --locked --dev
56
+ - run: uv run pytest
@@ -0,0 +1,52 @@
1
+ name: Docs
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ build:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: astral-sh/setup-uv@v6
19
+ with:
20
+ enable-cache: true
21
+ cache-dependency-glob: uv.lock
22
+
23
+ - uses: actions/setup-python@v5
24
+ with:
25
+ python-version: "3.11"
26
+
27
+ - run: uv sync --locked --group docs
28
+ - run: uv run mkdocs build --strict --site-dir site
29
+
30
+ - name: Upload Pages artifact
31
+ if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
32
+ uses: actions/upload-pages-artifact@v3
33
+ with:
34
+ path: site
35
+
36
+ deploy:
37
+ if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
38
+ needs: build
39
+ runs-on: ubuntu-latest
40
+ concurrency:
41
+ group: pages
42
+ cancel-in-progress: false
43
+ permissions:
44
+ pages: write
45
+ id-token: write
46
+ environment:
47
+ name: github-pages
48
+ url: ${{ steps.deployment.outputs.page_url }}
49
+ steps:
50
+ - uses: actions/configure-pages@v5
51
+ - id: deployment
52
+ uses: actions/deploy-pages@v4
@@ -4,3 +4,4 @@ __pycache__/
4
4
  .ruff_cache/
5
5
  dist/
6
6
  build/
7
+ site/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: agentshim
3
- Version: 0.4.0
3
+ Version: 0.5.1
4
4
  Summary: Provider-agnostic coding-agent CLI shims
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: litellm>=1.0.0
@@ -22,6 +22,7 @@ prompting, session resumption, event parsing, or MCP configuration.
22
22
  - a shared CLI agent abstraction with a provider registry
23
23
  - adapters for Claude Code, Codex, Gemini, and Opencode
24
24
  - stateful chat sessions that automatically resume provider-native threads
25
+ - injectable command executors for custom process launching or sandboxing
25
26
  - MCP server config models for providers that support MCP
26
27
  - sandbox settings helpers for Claude Code
27
28
  - a lightweight LiteLLM client and subagent helper
@@ -152,7 +153,90 @@ agent = CodingAgent(provider="claude")
152
153
  reply = agent.generate("Return only the answer.", silent=True)
153
154
  ```
154
155
 
155
- ### 3. Instantiate a Specific Provider Directly
156
+ ### 3. Run Commands Through a Custom Executor
157
+
158
+ Provider classes accept an optional `executor=`. The executor controls binary
159
+ lookup, CLI validation, and streaming process execution while `agentshim` keeps
160
+ owning provider command construction, stdout/stderr parsing, session state, and
161
+ event emission.
162
+
163
+ This is useful when a caller needs to run the CLI somewhere other than the
164
+ current host process, for example through an existing container, remote shell,
165
+ or custom sandbox.
166
+
167
+ ```python
168
+ from agentshim import (
169
+ CodexCodingAgent,
170
+ CommandHandle,
171
+ CommandRequest,
172
+ CommandResult,
173
+ CommandStreamSink,
174
+ )
175
+
176
+
177
+ class MyCommandHandle:
178
+ def terminate(self) -> None:
179
+ ...
180
+
181
+ def kill(self) -> None:
182
+ ...
183
+
184
+
185
+ class MyExecutor:
186
+ def find_binary(self, binary_name: str, env: dict[str, str]) -> str:
187
+ # This value becomes request.argv[0]. Container or remote executors can
188
+ # return the binary name if lookup happens in the target runtime.
189
+ return binary_name
190
+
191
+ def check_binary(self, binary_path: str, env: dict[str, str], *, timeout: int) -> None:
192
+ # Raise RuntimeError if the target CLI is unavailable. No-op is fine
193
+ # when validation is not cheap or is handled by the runtime.
194
+ return None
195
+
196
+ def run(self, request: CommandRequest, sink: CommandStreamSink) -> CommandResult:
197
+ handle = MyCommandHandle()
198
+ sink.started(handle)
199
+
200
+ stdout = ""
201
+ stderr = ""
202
+
203
+ # Run request.argv in your target runtime, with request.stdin,
204
+ # request.cwd, request.env, and request.timeout. Stream each complete
205
+ # line as it arrives, preserving trailing newlines when present.
206
+ line = "streamed output\n"
207
+ stdout += line
208
+ sink.stdout(line)
209
+
210
+ return CommandResult(returncode=0, stdout=stdout, stderr=stderr)
211
+
212
+
213
+ agent = CodexCodingAgent(executor=MyExecutor())
214
+ ```
215
+
216
+ The default `HostCommandExecutor` preserves the normal local `subprocess`
217
+ behavior. `CodingAgent(provider=..., executor=...)` forwards the same executor
218
+ to the selected provider.
219
+
220
+ Executor contract:
221
+
222
+ - `CommandRequest.argv` is the complete provider CLI command. `argv[0]` is the
223
+ value returned by `find_binary`.
224
+ - `CommandRequest.stdin` is the prompt text to write to the command's standard
225
+ input, then stdin should be closed.
226
+ - `CommandRequest.cwd`, `env`, and `timeout` should be honored by the executor.
227
+ - Call `sink.started(handle)` once after the command starts. The handle only
228
+ needs `terminate()` and `kill()`.
229
+ - Call `sink.stdout(line)` and `sink.stderr(line)` as output is produced. Lines
230
+ should include trailing newlines when the underlying stream provided them.
231
+ - Return `CommandResult(returncode, stdout, stderr)` after the command exits.
232
+ The returned text should match what was streamed through the sink.
233
+
234
+ If you were using the pre-0.5 executor preview, replace
235
+ `run_streaming(cmd, ..., on_stdout, on_stderr, on_process_started)` with
236
+ `run(request, sink)`. The parser/event APIs remain internal; custom executors
237
+ only provide a stable command runtime.
238
+
239
+ ### 4. Instantiate a Specific Provider Directly
156
240
 
157
241
  If you already know which backend you want, construct the provider class
158
242
  yourself.
@@ -174,7 +258,7 @@ The bundled provider classes are:
174
258
  - `GeminiCodingAgent`
175
259
  - `OpencodeCodingAgent`
176
260
 
177
- ### 4. Configure MCP Servers
261
+ ### 5. Configure MCP Servers
178
262
 
179
263
  Claude Code and Codex can be configured with MCP servers by passing
180
264
  `HttpMcpServer` and `StdioMcpServer` objects at construction time.
@@ -251,13 +335,16 @@ Notes:
251
335
  - Registration is import-driven. Your provider is available only after the module defining it has been imported in the current Python process.
252
336
  - `list_providers()` returns canonical provider names only. Aliases resolve via `get_provider_class(...)` and `CodingAgent(provider=...)`.
253
337
  - `register_provider(...)` rejects invalid names, abstract classes, and accidental name collisions unless you pass `overwrite=True`.
254
- - If you want `CodingAgent(...)` to instantiate your provider, its constructor should accept the shared kwargs `model`, `event_handler`, `event_handlers`, `mcp_servers`, and `sandbox` as needed.
338
+ - If you want `CodingAgent(...)` to instantiate your provider, its constructor should accept the shared kwargs `model`, `event_handler`, `event_handlers`, `mcp_servers`, `sandbox`, and `executor` as needed.
255
339
  - If your provider needs extra constructor arguments beyond the shared portable set, pass them via `backend_kwargs={...}` when constructing `CodingAgent(...)`.
256
340
 
257
341
  ## Development
258
342
 
259
343
  ```bash
260
344
  uv sync --dev
345
+ bash scripts/format_code.sh --check
346
+ bash scripts/check_errors.sh
347
+ bash scripts/type_check.sh
261
348
  uv run pytest
262
349
  ```
263
350
 
@@ -11,6 +11,7 @@ prompting, session resumption, event parsing, or MCP configuration.
11
11
  - a shared CLI agent abstraction with a provider registry
12
12
  - adapters for Claude Code, Codex, Gemini, and Opencode
13
13
  - stateful chat sessions that automatically resume provider-native threads
14
+ - injectable command executors for custom process launching or sandboxing
14
15
  - MCP server config models for providers that support MCP
15
16
  - sandbox settings helpers for Claude Code
16
17
  - a lightweight LiteLLM client and subagent helper
@@ -141,7 +142,90 @@ agent = CodingAgent(provider="claude")
141
142
  reply = agent.generate("Return only the answer.", silent=True)
142
143
  ```
143
144
 
144
- ### 3. Instantiate a Specific Provider Directly
145
+ ### 3. Run Commands Through a Custom Executor
146
+
147
+ Provider classes accept an optional `executor=`. The executor controls binary
148
+ lookup, CLI validation, and streaming process execution while `agentshim` keeps
149
+ owning provider command construction, stdout/stderr parsing, session state, and
150
+ event emission.
151
+
152
+ This is useful when a caller needs to run the CLI somewhere other than the
153
+ current host process, for example through an existing container, remote shell,
154
+ or custom sandbox.
155
+
156
+ ```python
157
+ from agentshim import (
158
+ CodexCodingAgent,
159
+ CommandHandle,
160
+ CommandRequest,
161
+ CommandResult,
162
+ CommandStreamSink,
163
+ )
164
+
165
+
166
+ class MyCommandHandle:
167
+ def terminate(self) -> None:
168
+ ...
169
+
170
+ def kill(self) -> None:
171
+ ...
172
+
173
+
174
+ class MyExecutor:
175
+ def find_binary(self, binary_name: str, env: dict[str, str]) -> str:
176
+ # This value becomes request.argv[0]. Container or remote executors can
177
+ # return the binary name if lookup happens in the target runtime.
178
+ return binary_name
179
+
180
+ def check_binary(self, binary_path: str, env: dict[str, str], *, timeout: int) -> None:
181
+ # Raise RuntimeError if the target CLI is unavailable. No-op is fine
182
+ # when validation is not cheap or is handled by the runtime.
183
+ return None
184
+
185
+ def run(self, request: CommandRequest, sink: CommandStreamSink) -> CommandResult:
186
+ handle = MyCommandHandle()
187
+ sink.started(handle)
188
+
189
+ stdout = ""
190
+ stderr = ""
191
+
192
+ # Run request.argv in your target runtime, with request.stdin,
193
+ # request.cwd, request.env, and request.timeout. Stream each complete
194
+ # line as it arrives, preserving trailing newlines when present.
195
+ line = "streamed output\n"
196
+ stdout += line
197
+ sink.stdout(line)
198
+
199
+ return CommandResult(returncode=0, stdout=stdout, stderr=stderr)
200
+
201
+
202
+ agent = CodexCodingAgent(executor=MyExecutor())
203
+ ```
204
+
205
+ The default `HostCommandExecutor` preserves the normal local `subprocess`
206
+ behavior. `CodingAgent(provider=..., executor=...)` forwards the same executor
207
+ to the selected provider.
208
+
209
+ Executor contract:
210
+
211
+ - `CommandRequest.argv` is the complete provider CLI command. `argv[0]` is the
212
+ value returned by `find_binary`.
213
+ - `CommandRequest.stdin` is the prompt text to write to the command's standard
214
+ input, then stdin should be closed.
215
+ - `CommandRequest.cwd`, `env`, and `timeout` should be honored by the executor.
216
+ - Call `sink.started(handle)` once after the command starts. The handle only
217
+ needs `terminate()` and `kill()`.
218
+ - Call `sink.stdout(line)` and `sink.stderr(line)` as output is produced. Lines
219
+ should include trailing newlines when the underlying stream provided them.
220
+ - Return `CommandResult(returncode, stdout, stderr)` after the command exits.
221
+ The returned text should match what was streamed through the sink.
222
+
223
+ If you were using the pre-0.5 executor preview, replace
224
+ `run_streaming(cmd, ..., on_stdout, on_stderr, on_process_started)` with
225
+ `run(request, sink)`. The parser/event APIs remain internal; custom executors
226
+ only provide a stable command runtime.
227
+
228
+ ### 4. Instantiate a Specific Provider Directly
145
229
 
146
230
  If you already know which backend you want, construct the provider class
147
231
  yourself.
@@ -163,7 +247,7 @@ The bundled provider classes are:
163
247
  - `GeminiCodingAgent`
164
248
  - `OpencodeCodingAgent`
165
249
 
166
- ### 4. Configure MCP Servers
250
+ ### 5. Configure MCP Servers
167
251
 
168
252
  Claude Code and Codex can be configured with MCP servers by passing
169
253
  `HttpMcpServer` and `StdioMcpServer` objects at construction time.
@@ -240,13 +324,16 @@ Notes:
240
324
  - Registration is import-driven. Your provider is available only after the module defining it has been imported in the current Python process.
241
325
  - `list_providers()` returns canonical provider names only. Aliases resolve via `get_provider_class(...)` and `CodingAgent(provider=...)`.
242
326
  - `register_provider(...)` rejects invalid names, abstract classes, and accidental name collisions unless you pass `overwrite=True`.
243
- - If you want `CodingAgent(...)` to instantiate your provider, its constructor should accept the shared kwargs `model`, `event_handler`, `event_handlers`, `mcp_servers`, and `sandbox` as needed.
327
+ - If you want `CodingAgent(...)` to instantiate your provider, its constructor should accept the shared kwargs `model`, `event_handler`, `event_handlers`, `mcp_servers`, `sandbox`, and `executor` as needed.
244
328
  - If your provider needs extra constructor arguments beyond the shared portable set, pass them via `backend_kwargs={...}` when constructing `CodingAgent(...)`.
245
329
 
246
330
  ## Development
247
331
 
248
332
  ```bash
249
333
  uv sync --dev
334
+ bash scripts/format_code.sh --check
335
+ bash scripts/check_errors.sh
336
+ bash scripts/type_check.sh
250
337
  uv run pytest
251
338
  ```
252
339
 
@@ -1,8 +1,17 @@
1
1
  from .base import BaseAgentSession, BaseCodingAgent, CodingAgent, get_provider_class, list_providers, register_provider
2
2
  from .claude import ClaudeCodeCodingAgent
3
- from .copilot import CopilotCodingAgent
4
3
  from .codex import CodexCodingAgent
4
+ from .copilot import CopilotCodingAgent
5
5
  from .events import CompositeEventHandler, ConsoleEventHandler, NullEventHandler
6
+ from .executor import (
7
+ CallbackCommandStreamSink,
8
+ CommandExecutor,
9
+ CommandHandle,
10
+ CommandRequest,
11
+ CommandResult,
12
+ CommandStreamSink,
13
+ HostCommandExecutor,
14
+ )
6
15
  from .gemini import GeminiCodingAgent
7
16
  from .mcp_config import HttpMcpServer, McpServerConfig, StdioMcpServer
8
17
  from .opencode import OpencodeCodingAgent
@@ -18,6 +27,13 @@ __all__ = [
18
27
  "CompositeEventHandler",
19
28
  "ConsoleEventHandler",
20
29
  "NullEventHandler",
30
+ "CallbackCommandStreamSink",
31
+ "CommandExecutor",
32
+ "CommandHandle",
33
+ "CommandRequest",
34
+ "CommandResult",
35
+ "CommandStreamSink",
36
+ "HostCommandExecutor",
21
37
  "CopilotCodingAgent",
22
38
  "CodexCodingAgent",
23
39
  "GeminiCodingAgent",
@@ -3,12 +3,15 @@ from __future__ import annotations
3
3
  import inspect
4
4
  import re
5
5
  from abc import ABC, abstractmethod
6
- from collections.abc import Callable, Sequence
7
- from typing import Any, TypeVar
6
+ from typing import TYPE_CHECKING, Any, TypeVar, cast
8
7
 
9
- from agentshim.events import AgentEventHandler
10
- from agentshim.mcp_config import McpServerConfig
11
- from agentshim.sandbox import SandboxConfig
8
+ if TYPE_CHECKING:
9
+ from collections.abc import Callable, Sequence
10
+
11
+ from agentshim.events import AgentEventHandler
12
+ from agentshim.executor import CommandExecutor, CommandHandle
13
+ from agentshim.mcp_config import McpServerConfig
14
+ from agentshim.sandbox import SandboxConfig
12
15
 
13
16
  _T = TypeVar("_T")
14
17
  _READABLE_NAME_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])")
@@ -83,7 +86,7 @@ class BaseAgentSession(ABC):
83
86
  cwd: str | None = None,
84
87
  timeout: int | None = None,
85
88
  silent: bool | None = None,
86
- on_process_started: Callable[[Any], None] | None = None,
89
+ on_process_started: Callable[[CommandHandle], None] | None = None,
87
90
  ) -> str:
88
91
  """Send ``prompt`` within an existing chat session."""
89
92
 
@@ -104,7 +107,7 @@ class ProviderRegistry:
104
107
  self._providers: dict[str, ProviderClass] = {}
105
108
  self._canonical_names: dict[str, str] = {}
106
109
 
107
- def _normalize_name(self, name: str) -> str:
110
+ def _normalize_name(self, name: object) -> str:
108
111
  if not isinstance(name, str):
109
112
  raise TypeError(f"provider name must be a string, got {type(name).__name__}")
110
113
  normalized = name.strip().lower()
@@ -121,6 +124,9 @@ class ProviderRegistry:
121
124
  )
122
125
  return canonical, normalized_aliases
123
126
 
127
+ def validate_names(self, canonical_name: str, aliases: tuple[str, ...]) -> None:
128
+ self._normalize_names(canonical_name, aliases)
129
+
124
130
  def _validate_provider_class(self, cls: type[Any]) -> ProviderClass:
125
131
  if not inspect.isclass(cls):
126
132
  raise TypeError(f"registered provider must be a class, got {type(cls).__name__}")
@@ -185,7 +191,7 @@ def register_provider(
185
191
  *extra_names: str,
186
192
  aliases: tuple[str, ...] = (),
187
193
  overwrite: bool = False,
188
- ) -> Callable[[type[_T]], _T]:
194
+ ) -> Callable[[type[_T]], type[_T]]:
189
195
  """Decorator to register a coding agent provider.
190
196
 
191
197
  Registration is import-driven: the decorated class becomes available only
@@ -199,14 +205,17 @@ def register_provider(
199
205
  """
200
206
 
201
207
  all_aliases = (*extra_names, *aliases)
202
- _PROVIDER_REGISTRY._normalize_names(canonical_name, all_aliases)
203
-
204
- def decorator(cls: type[_T]) -> _T:
205
- return _PROVIDER_REGISTRY.register(
206
- cls,
207
- canonical_name=canonical_name,
208
- aliases=all_aliases,
209
- overwrite=overwrite,
208
+ _PROVIDER_REGISTRY.validate_names(canonical_name, all_aliases)
209
+
210
+ def decorator(cls: type[_T]) -> type[_T]:
211
+ return cast(
212
+ "type[_T]",
213
+ _PROVIDER_REGISTRY.register(
214
+ cls,
215
+ canonical_name=canonical_name,
216
+ aliases=all_aliases,
217
+ overwrite=overwrite,
218
+ ),
210
219
  )
211
220
 
212
221
  return decorator
@@ -237,6 +246,7 @@ class CodingAgent(BaseCodingAgent):
237
246
  event_handlers: Sequence[AgentEventHandler] | None = None,
238
247
  mcp_servers: Sequence[McpServerConfig] | None = None,
239
248
  sandbox: bool | SandboxConfig | None = False,
249
+ executor: CommandExecutor | None = None,
240
250
  backend_kwargs: dict[str, Any] | None = None,
241
251
  ) -> None:
242
252
  requested_provider = _PROVIDER_REGISTRY.get_canonical_name(provider)
@@ -254,6 +264,8 @@ class CodingAgent(BaseCodingAgent):
254
264
  portable_kwargs["mcp_servers"] = list(mcp_servers)
255
265
  if sandbox is not None and sandbox is not False:
256
266
  portable_kwargs["sandbox"] = sandbox
267
+ if executor is not None:
268
+ portable_kwargs["executor"] = executor
257
269
 
258
270
  advanced_kwargs = dict(backend_kwargs or {})
259
271
  overlapping_keys = sorted(portable_kwargs.keys() & advanced_kwargs.keys())
@@ -293,6 +305,17 @@ class CodingAgent(BaseCodingAgent):
293
305
  def event_handler(self, value: Any | None) -> None:
294
306
  self._backend.event_handler = value
295
307
 
308
+ @property
309
+ def last_usage(self) -> Any:
310
+ """Token/turn usage from the backend's most recent ``generate``.
311
+
312
+ ``generate`` writes usage onto the concrete backend, so the
313
+ portable wrapper must surface it here — otherwise callers holding a
314
+ ``CodingAgent`` (rather than the raw provider class) read a missing
315
+ attribute and lose all usage accounting.
316
+ """
317
+ return getattr(self._backend, "last_usage", None)
318
+
296
319
  def start_session(
297
320
  self,
298
321
  cwd: str | None = None,
@@ -1,12 +1,12 @@
1
1
  import json
2
- import subprocess
3
2
  import time
4
- from collections.abc import Callable, Iterable
3
+ from collections.abc import Callable, Iterable, Sequence
5
4
  from typing import Any
6
5
 
7
6
  from ..base import register_provider
8
7
  from ..cli_agent import CLICodingAgent, CLIGenerationSession
9
8
  from ..events import AgentEventHandler
9
+ from ..executor import CommandExecutor, CommandHandle
10
10
  from ..mcp_config import HttpMcpServer, McpServerConfig
11
11
  from ..sandbox import SandboxConfig, build_claude_sandbox_settings, resolve_sandbox
12
12
  from ..usage import ProviderUsage, TokenUsage
@@ -130,8 +130,9 @@ class ClaudeCodeCodingAgent(CLICodingAgent):
130
130
  model: str | None = None,
131
131
  event_handler: AgentEventHandler | None = None,
132
132
  event_handlers: Iterable[AgentEventHandler] | None = None,
133
- mcp_servers: list[McpServerConfig] | None = None,
133
+ mcp_servers: Sequence[McpServerConfig] | None = None,
134
134
  sandbox: bool | SandboxConfig = False,
135
+ executor: CommandExecutor | None = None,
135
136
  ):
136
137
  """Initialize the Claude Code coding agent.
137
138
 
@@ -145,8 +146,9 @@ class ClaudeCodeCodingAgent(CLICodingAgent):
145
146
  ``--settings``. Only bash subprocess commands are
146
147
  sandboxed; the Claude process itself is not wrapped.
147
148
  Defaults to False (no sandbox).
149
+ executor: Optional command executor for binary lookup and process execution.
148
150
  """
149
- super().__init__("claude", model, event_handler, event_handlers, mcp_servers)
151
+ super().__init__("claude", model, event_handler, event_handlers, mcp_servers, executor=executor)
150
152
  self.sandbox = resolve_sandbox(sandbox)
151
153
  if self.sandbox is not None:
152
154
  # Without this, Claude Code cd's into a per-invocation scratch dir
@@ -223,7 +225,7 @@ class ClaudeCodeCodingAgent(CLICodingAgent):
223
225
  cwd: str | None = None,
224
226
  timeout: int = 300,
225
227
  silent: bool = False,
226
- on_process_started: Callable[[subprocess.Popen[str]], None] | None = None,
228
+ on_process_started: Callable[[CommandHandle], None] | None = None,
227
229
  ) -> ClaudeGenerationSession:
228
230
  return ClaudeGenerationSession(
229
231
  binary_name=self.binary_name,
@@ -235,5 +237,6 @@ class ClaudeCodeCodingAgent(CLICodingAgent):
235
237
  timeout=timeout,
236
238
  silent=silent,
237
239
  event_handler=self.event_handler,
240
+ executor=self.executor,
238
241
  on_process_started=on_process_started,
239
242
  )