py-opencode-wrapper 0.1.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.
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.4
2
+ Name: py-opencode-wrapper
3
+ Version: 0.1.2
4
+ Summary: Async Python wrapper for OpenCode CLI (opencode run --format json)
5
+ Project-URL: Homepage, https://github.com/idailylife/oc_py_wrapper
6
+ Project-URL: Repository, https://github.com/idailylife/oc_py_wrapper
7
+ Project-URL: Issues, https://github.com/idailylife/oc_py_wrapper/issues
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8; extra == "dev"
12
+ Requires-Dist: pytest-asyncio>=0.24; extra == "dev"
13
+
14
+ # oc-py-harness
15
+
16
+ Python **async** wrapper around the [OpenCode](https://opencode.ai/docs/) CLI (`opencode run --format json`). Intended as a subprocess-based executor for **multi-agent workflow** orchestration.
17
+
18
+ ## Requirements
19
+
20
+ - Python 3.10+
21
+ - `opencode` on `PATH` (or pass an absolute path to the binary)
22
+
23
+ ## Install (local tree)
24
+
25
+ ```bash
26
+ pip install -e ".[dev]"
27
+ ```
28
+
29
+ ## Usage
30
+
31
+ ### One-shot run with aggregated result
32
+
33
+ ```python
34
+ import asyncio
35
+ from pathlib import Path
36
+
37
+ from opencode_wrapper import AsyncOpenCodeClient, RunConfig
38
+
39
+ async def main():
40
+ client = AsyncOpenCodeClient("opencode")
41
+ cfg = RunConfig(
42
+ model="anthropic/claude-sonnet-4-5",
43
+ agent="plan",
44
+ permission={"bash": "deny", "edit": "deny"},
45
+ mcp={
46
+ "demo": {
47
+ "type": "local",
48
+ "command": ["npx", "-y", "@modelcontextprotocol/server-everything"],
49
+ "enabled": True,
50
+ }
51
+ },
52
+ )
53
+ result = await client.async_run(
54
+ "Summarize the README in one sentence.",
55
+ Path("/path/to/repo"),
56
+ run_cfg=cfg,
57
+ timeout_s=600,
58
+ )
59
+ print(result.exit_code, result.final_text)
60
+
61
+ asyncio.run(main())
62
+ ```
63
+
64
+ ### Stream structured JSON events
65
+
66
+ ```python
67
+ async def stream_example():
68
+ client = AsyncOpenCodeClient()
69
+ cfg = RunConfig(permission={"*": "allow"})
70
+ async for event in client.async_stream("List top-level files.", workspace=".", run_cfg=cfg):
71
+ print(event)
72
+ ```
73
+
74
+ ### Parallel agents (`asyncio.gather`)
75
+
76
+ ```python
77
+ async def multi():
78
+ # startup_concurrency=1 serialises SQLite initialisation to avoid a known
79
+ # WAL-pragma race in opencode when many instances start simultaneously.
80
+ # startup_delay_s controls how long each slot is held before the next
81
+ # process is allowed to start (default 0.3 s).
82
+ client = AsyncOpenCodeClient(startup_concurrency=1, startup_delay_s=0.3)
83
+ ws = Path("/path/to/monorepo")
84
+ results = await asyncio.gather(*[
85
+ client.async_run(
86
+ f"Explain services/{svc}.",
87
+ ws / "services" / svc,
88
+ run_cfg=RunConfig(agent="explore"),
89
+ timeout_s=600,
90
+ # max_retries=2 (default): retry automatically if opencode crashes
91
+ # during SQLite startup before giving up.
92
+ )
93
+ for svc in ["api", "worker", "gateway"]
94
+ ])
95
+ return results
96
+ ```
97
+
98
+ ## Configuration injection
99
+
100
+ Per-call JSON is merged and passed as `OPENCODE_CONFIG_CONTENT` (see [OpenCode config](https://opencode.ai/docs/config/)). Use `RunConfig` fields:
101
+
102
+ | Field | Purpose |
103
+ |--------|---------|
104
+ | `permission` | `permission` map (`allow` / `ask` / `deny`, patterns) |
105
+ | `mcp` | MCP server definitions |
106
+ | `tools` | Enable/disable tools (including MCP globs) |
107
+ | `config_overrides` | Any extra top-level config keys to deep-merge |
108
+
109
+ Optional env tuning: `disable_autoupdate=True` sets `OPENCODE_DISABLE_AUTOUPDATE=1`.
110
+
111
+ ## CLI arguments
112
+
113
+ `RunConfig` maps to flags such as `--agent`, `-m`, `-f`, `--attach`, `--title`, etc. Prompt text is appended as the final `opencode run` message argument.
114
+
115
+ ## Tests
116
+
117
+ Unit tests (no real OpenCode / no API calls):
118
+
119
+ ```bash
120
+ pytest -q -m "not integration"
121
+ ```
122
+
123
+ Integration tests (real `opencode run`, needs working provider auth — **slow**, may incur API usage):
124
+
125
+ ```bash
126
+ pytest -m integration -q tests/test_integration_opencode.py
127
+ ```
128
+
129
+ **Multi-agent weather workflow** (10 parallel city lookups + 1 summary — **11 API calls**, not run by default):
130
+
131
+ ```bash
132
+ OPENCODE_MULTI_AGENT_WEATHER=1 pytest -m integration -v tests/test_integration_multi_agent_weather.py
133
+ ```
134
+
135
+ Optional: `OPENCODE_WEATHER_SEQUENTIAL=1` runs the 10 city calls one-by-one (easier on rate limits).
136
+ Per-stage timeouts: `OPENCODE_WEATHER_PER_CITY_TIMEOUT_S`, `OPENCODE_WEATHER_SUMMARY_TIMEOUT_S` (default: same as `OPENCODE_INTEGRATION_TIMEOUT_S`).
137
+
138
+ | Env | Meaning |
139
+ |-----|--------|
140
+ | `OPENCODE_BINARY` | Absolute path to `opencode` if not on `PATH` |
141
+ | `OPENCODE_INTEGRATION=0` | Skip integration tests |
142
+ | `OPENCODE_INTEGRATION_TIMEOUT_S` | Per-test timeout seconds (default `300`) |
143
+ | `OPENCODE_MULTI_AGENT_WEATHER=1` | Enable 11-call weather integration test |
144
+ | `OPENCODE_ENABLE_EXA` | Passed through / defaulted to `1` in that test for web search tools |
145
+
146
+ Default `pytest -q` runs **all** tests; use `-m "not integration"` in CI without OpenCode.
147
+
148
+ ## Concurrency notes
149
+
150
+ When running many tasks with `asyncio.gather`, two mitigations are active by default:
151
+
152
+ **Startup serialisation** — `AsyncOpenCodeClient` accepts `startup_concurrency` (default `1`) and `startup_delay_s` (default `0.3`). Only one process at a time enters its SQLite initialisation window; all processes run concurrently afterwards. This avoids a known opencode bug where `PRAGMA journal_mode = WAL` races against `PRAGMA busy_timeout` during concurrent startup, causing immediate crashes.
153
+
154
+ **Automatic retry** — `async_run` accepts `max_retries` (default `2`) and `retry_delay_s` (default `1.0`). If opencode exits non-zero and stderr contains SQLite lock indicators, the call is retried with a short backoff. Non-SQLite failures are raised immediately.
155
+
156
+ Set `startup_concurrency=0` (unlimited) and `max_retries=0` to opt out of both behaviours.
157
+
158
+ ## Notes
159
+
160
+ - Event shapes from `--format json` may change between OpenCode versions; unknown fields are preserved in each parsed dict.
161
+ - For fully non-interactive automation, prefer explicit `permission` (`allow`/`deny`) over relying on interactive `ask` prompts.
@@ -0,0 +1,148 @@
1
+ # oc-py-harness
2
+
3
+ Python **async** wrapper around the [OpenCode](https://opencode.ai/docs/) CLI (`opencode run --format json`). Intended as a subprocess-based executor for **multi-agent workflow** orchestration.
4
+
5
+ ## Requirements
6
+
7
+ - Python 3.10+
8
+ - `opencode` on `PATH` (or pass an absolute path to the binary)
9
+
10
+ ## Install (local tree)
11
+
12
+ ```bash
13
+ pip install -e ".[dev]"
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ ### One-shot run with aggregated result
19
+
20
+ ```python
21
+ import asyncio
22
+ from pathlib import Path
23
+
24
+ from opencode_wrapper import AsyncOpenCodeClient, RunConfig
25
+
26
+ async def main():
27
+ client = AsyncOpenCodeClient("opencode")
28
+ cfg = RunConfig(
29
+ model="anthropic/claude-sonnet-4-5",
30
+ agent="plan",
31
+ permission={"bash": "deny", "edit": "deny"},
32
+ mcp={
33
+ "demo": {
34
+ "type": "local",
35
+ "command": ["npx", "-y", "@modelcontextprotocol/server-everything"],
36
+ "enabled": True,
37
+ }
38
+ },
39
+ )
40
+ result = await client.async_run(
41
+ "Summarize the README in one sentence.",
42
+ Path("/path/to/repo"),
43
+ run_cfg=cfg,
44
+ timeout_s=600,
45
+ )
46
+ print(result.exit_code, result.final_text)
47
+
48
+ asyncio.run(main())
49
+ ```
50
+
51
+ ### Stream structured JSON events
52
+
53
+ ```python
54
+ async def stream_example():
55
+ client = AsyncOpenCodeClient()
56
+ cfg = RunConfig(permission={"*": "allow"})
57
+ async for event in client.async_stream("List top-level files.", workspace=".", run_cfg=cfg):
58
+ print(event)
59
+ ```
60
+
61
+ ### Parallel agents (`asyncio.gather`)
62
+
63
+ ```python
64
+ async def multi():
65
+ # startup_concurrency=1 serialises SQLite initialisation to avoid a known
66
+ # WAL-pragma race in opencode when many instances start simultaneously.
67
+ # startup_delay_s controls how long each slot is held before the next
68
+ # process is allowed to start (default 0.3 s).
69
+ client = AsyncOpenCodeClient(startup_concurrency=1, startup_delay_s=0.3)
70
+ ws = Path("/path/to/monorepo")
71
+ results = await asyncio.gather(*[
72
+ client.async_run(
73
+ f"Explain services/{svc}.",
74
+ ws / "services" / svc,
75
+ run_cfg=RunConfig(agent="explore"),
76
+ timeout_s=600,
77
+ # max_retries=2 (default): retry automatically if opencode crashes
78
+ # during SQLite startup before giving up.
79
+ )
80
+ for svc in ["api", "worker", "gateway"]
81
+ ])
82
+ return results
83
+ ```
84
+
85
+ ## Configuration injection
86
+
87
+ Per-call JSON is merged and passed as `OPENCODE_CONFIG_CONTENT` (see [OpenCode config](https://opencode.ai/docs/config/)). Use `RunConfig` fields:
88
+
89
+ | Field | Purpose |
90
+ |--------|---------|
91
+ | `permission` | `permission` map (`allow` / `ask` / `deny`, patterns) |
92
+ | `mcp` | MCP server definitions |
93
+ | `tools` | Enable/disable tools (including MCP globs) |
94
+ | `config_overrides` | Any extra top-level config keys to deep-merge |
95
+
96
+ Optional env tuning: `disable_autoupdate=True` sets `OPENCODE_DISABLE_AUTOUPDATE=1`.
97
+
98
+ ## CLI arguments
99
+
100
+ `RunConfig` maps to flags such as `--agent`, `-m`, `-f`, `--attach`, `--title`, etc. Prompt text is appended as the final `opencode run` message argument.
101
+
102
+ ## Tests
103
+
104
+ Unit tests (no real OpenCode / no API calls):
105
+
106
+ ```bash
107
+ pytest -q -m "not integration"
108
+ ```
109
+
110
+ Integration tests (real `opencode run`, needs working provider auth — **slow**, may incur API usage):
111
+
112
+ ```bash
113
+ pytest -m integration -q tests/test_integration_opencode.py
114
+ ```
115
+
116
+ **Multi-agent weather workflow** (10 parallel city lookups + 1 summary — **11 API calls**, not run by default):
117
+
118
+ ```bash
119
+ OPENCODE_MULTI_AGENT_WEATHER=1 pytest -m integration -v tests/test_integration_multi_agent_weather.py
120
+ ```
121
+
122
+ Optional: `OPENCODE_WEATHER_SEQUENTIAL=1` runs the 10 city calls one-by-one (easier on rate limits).
123
+ Per-stage timeouts: `OPENCODE_WEATHER_PER_CITY_TIMEOUT_S`, `OPENCODE_WEATHER_SUMMARY_TIMEOUT_S` (default: same as `OPENCODE_INTEGRATION_TIMEOUT_S`).
124
+
125
+ | Env | Meaning |
126
+ |-----|--------|
127
+ | `OPENCODE_BINARY` | Absolute path to `opencode` if not on `PATH` |
128
+ | `OPENCODE_INTEGRATION=0` | Skip integration tests |
129
+ | `OPENCODE_INTEGRATION_TIMEOUT_S` | Per-test timeout seconds (default `300`) |
130
+ | `OPENCODE_MULTI_AGENT_WEATHER=1` | Enable 11-call weather integration test |
131
+ | `OPENCODE_ENABLE_EXA` | Passed through / defaulted to `1` in that test for web search tools |
132
+
133
+ Default `pytest -q` runs **all** tests; use `-m "not integration"` in CI without OpenCode.
134
+
135
+ ## Concurrency notes
136
+
137
+ When running many tasks with `asyncio.gather`, two mitigations are active by default:
138
+
139
+ **Startup serialisation** — `AsyncOpenCodeClient` accepts `startup_concurrency` (default `1`) and `startup_delay_s` (default `0.3`). Only one process at a time enters its SQLite initialisation window; all processes run concurrently afterwards. This avoids a known opencode bug where `PRAGMA journal_mode = WAL` races against `PRAGMA busy_timeout` during concurrent startup, causing immediate crashes.
140
+
141
+ **Automatic retry** — `async_run` accepts `max_retries` (default `2`) and `retry_delay_s` (default `1.0`). If opencode exits non-zero and stderr contains SQLite lock indicators, the call is retried with a short backoff. Non-SQLite failures are raised immediately.
142
+
143
+ Set `startup_concurrency=0` (unlimited) and `max_retries=0` to opt out of both behaviours.
144
+
145
+ ## Notes
146
+
147
+ - Event shapes from `--format json` may change between OpenCode versions; unknown fields are preserved in each parsed dict.
148
+ - For fully non-interactive automation, prefer explicit `permission` (`allow`/`deny`) over relying on interactive `ask` prompts.
@@ -0,0 +1,38 @@
1
+ """OpenCode CLI async wrapper for Python orchestration."""
2
+
3
+ from opencode_wrapper.client import AsyncOpenCodeClient, build_argv, build_env, resolve_binary
4
+ from opencode_wrapper.config import RunConfig, validate_config_for_run, validate_permission_actions
5
+ from opencode_wrapper.errors import (
6
+ OpenCodeBinaryNotFoundError,
7
+ OpenCodeCancelledError,
8
+ OpenCodeError,
9
+ OpenCodeProcessError,
10
+ OpenCodeTimeoutError,
11
+ )
12
+ from opencode_wrapper.events import (
13
+ RunResult,
14
+ TokenUsage,
15
+ aggregate_run_result,
16
+ parse_event_line,
17
+ run_result_fuzzy_text,
18
+ )
19
+
20
+ __all__ = [
21
+ "AsyncOpenCodeClient",
22
+ "RunConfig",
23
+ "RunResult",
24
+ "TokenUsage",
25
+ "aggregate_run_result",
26
+ "build_argv",
27
+ "build_env",
28
+ "parse_event_line",
29
+ "run_result_fuzzy_text",
30
+ "resolve_binary",
31
+ "validate_config_for_run",
32
+ "validate_permission_actions",
33
+ "OpenCodeError",
34
+ "OpenCodeBinaryNotFoundError",
35
+ "OpenCodeProcessError",
36
+ "OpenCodeTimeoutError",
37
+ "OpenCodeCancelledError",
38
+ ]