opencode-py 0.1.0__py3-none-any.whl
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.
- opencode/__init__.py +45 -0
- opencode/__main__.py +20 -0
- opencode/_async_client.py +538 -0
- opencode/_async_opencode.py +255 -0
- opencode/_async_session.py +155 -0
- opencode/_binary.py +135 -0
- opencode/_client.py +532 -0
- opencode/_errors.py +20 -0
- opencode/_models.py +110 -0
- opencode/_opencode.py +290 -0
- opencode/_process.py +24 -0
- opencode/_server.py +97 -0
- opencode/_session.py +160 -0
- opencode/_tools.py +156 -0
- opencode_py-0.1.0.dist-info/METADATA +201 -0
- opencode_py-0.1.0.dist-info/RECORD +17 -0
- opencode_py-0.1.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import warnings
|
|
4
|
+
from typing import Any, AsyncIterator, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from opencode._async_client import AsyncOpendcodeClient
|
|
7
|
+
from opencode._async_session import AsyncSession
|
|
8
|
+
from opencode._models import SessionMessage
|
|
9
|
+
from opencode._opencode import _extract_text, _resolve_model
|
|
10
|
+
from opencode._server import OpencodeServer, create_opencode_server
|
|
11
|
+
|
|
12
|
+
_async_opencode_state: Dict[str, Any] = {}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class AsyncOpendcode:
|
|
16
|
+
def __init__(
|
|
17
|
+
self,
|
|
18
|
+
*,
|
|
19
|
+
model: Optional[str] = None,
|
|
20
|
+
hostname: str = "127.0.0.1",
|
|
21
|
+
port: int = 4096,
|
|
22
|
+
directory: Optional[str] = None,
|
|
23
|
+
workspace: Optional[str] = None,
|
|
24
|
+
server_timeout: float = 30.0,
|
|
25
|
+
client_timeout: float = 300.0,
|
|
26
|
+
config: Optional[Dict[str, Any]] = None,
|
|
27
|
+
opencode_binary: Optional[str] = None,
|
|
28
|
+
):
|
|
29
|
+
self._model = model
|
|
30
|
+
self._hostname = hostname
|
|
31
|
+
self._port = port
|
|
32
|
+
self._directory = directory
|
|
33
|
+
self._workspace = workspace
|
|
34
|
+
self._server_timeout = server_timeout
|
|
35
|
+
self._client_timeout = client_timeout
|
|
36
|
+
self._config = config
|
|
37
|
+
self._opencode_binary = opencode_binary
|
|
38
|
+
|
|
39
|
+
self._server: Optional[OpencodeServer] = None
|
|
40
|
+
self._client: Optional[AsyncOpendcodeClient] = None
|
|
41
|
+
|
|
42
|
+
# ------------------------------------------------------------------
|
|
43
|
+
# Async context manager
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
async def __aenter__(self) -> AsyncOpendcode:
|
|
47
|
+
self.start()
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
async def __aexit__(self, *args: Any) -> None:
|
|
51
|
+
await self.close()
|
|
52
|
+
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
# Server / Client lifecycle
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
def start(self) -> None:
|
|
58
|
+
if self._client is not None:
|
|
59
|
+
return
|
|
60
|
+
server = create_opencode_server(
|
|
61
|
+
hostname=self._hostname,
|
|
62
|
+
port=self._port,
|
|
63
|
+
timeout=self._server_timeout,
|
|
64
|
+
config=self._config,
|
|
65
|
+
opencode_binary=self._opencode_binary,
|
|
66
|
+
)
|
|
67
|
+
client = AsyncOpendcodeClient(
|
|
68
|
+
base_url=server.url,
|
|
69
|
+
directory=self._directory,
|
|
70
|
+
workspace=self._workspace,
|
|
71
|
+
timeout=self._client_timeout,
|
|
72
|
+
)
|
|
73
|
+
self._server = server
|
|
74
|
+
self._client = client
|
|
75
|
+
|
|
76
|
+
async def close(self) -> None:
|
|
77
|
+
if self._client:
|
|
78
|
+
await self._client.close()
|
|
79
|
+
self._client = None
|
|
80
|
+
if self._server:
|
|
81
|
+
self._server.close()
|
|
82
|
+
self._server = None
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def client(self) -> AsyncOpendcodeClient:
|
|
86
|
+
assert self._client is not None
|
|
87
|
+
return self._client
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def server(self) -> OpencodeServer:
|
|
91
|
+
assert self._server is not None
|
|
92
|
+
return self._server
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
# High-level API
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
async def create_session(self, agent: Optional[str] = None, **kwargs) -> AsyncSession:
|
|
99
|
+
if agent:
|
|
100
|
+
kwargs["agent"] = agent
|
|
101
|
+
raw = await self.client.session_create(**kwargs)
|
|
102
|
+
sid = raw["id"]
|
|
103
|
+
return AsyncSession(self.client, sid)
|
|
104
|
+
|
|
105
|
+
async def ask(
|
|
106
|
+
self,
|
|
107
|
+
prompt: str,
|
|
108
|
+
*,
|
|
109
|
+
files: Optional[Dict[str, Any]] = None,
|
|
110
|
+
auto_tools: bool = False,
|
|
111
|
+
agent: Optional[str] = None,
|
|
112
|
+
format: Optional[Dict[str, Any]] = None,
|
|
113
|
+
wait: bool = True,
|
|
114
|
+
poll_interval: float = 0.5,
|
|
115
|
+
poll_timeout: float = 600.0,
|
|
116
|
+
) -> str:
|
|
117
|
+
session = await self.create_session(agent=agent)
|
|
118
|
+
model = _resolve_model(model=self._model, config=self._config)
|
|
119
|
+
if auto_tools:
|
|
120
|
+
from opencode._tools import ToolExecutor
|
|
121
|
+
|
|
122
|
+
msg = await session.ask(
|
|
123
|
+
prompt,
|
|
124
|
+
files=files,
|
|
125
|
+
model=model,
|
|
126
|
+
format=format,
|
|
127
|
+
tool_executor=ToolExecutor(),
|
|
128
|
+
)
|
|
129
|
+
else:
|
|
130
|
+
msg = await session.prompt(
|
|
131
|
+
prompt,
|
|
132
|
+
files=files,
|
|
133
|
+
wait=wait,
|
|
134
|
+
model=model,
|
|
135
|
+
format=format,
|
|
136
|
+
poll_interval=poll_interval,
|
|
137
|
+
poll_timeout=poll_timeout,
|
|
138
|
+
)
|
|
139
|
+
return _extract_text(msg)
|
|
140
|
+
|
|
141
|
+
async def ask_stream(
|
|
142
|
+
self,
|
|
143
|
+
prompt: str,
|
|
144
|
+
*,
|
|
145
|
+
files: Optional[Dict[str, Any]] = None,
|
|
146
|
+
session: Optional[AsyncSession] = None,
|
|
147
|
+
) -> AsyncIterator[str]:
|
|
148
|
+
import json
|
|
149
|
+
|
|
150
|
+
import httpx
|
|
151
|
+
|
|
152
|
+
if session is None:
|
|
153
|
+
session = await self.create_session()
|
|
154
|
+
# Use V1 synchronous prompt — events arrive via /event
|
|
155
|
+
body: Dict[str, Any] = {"parts": [{"type": "text", "text": prompt}]}
|
|
156
|
+
resolved = _resolve_model(model=self._model, config=self._config)
|
|
157
|
+
if resolved:
|
|
158
|
+
body["model"] = resolved
|
|
159
|
+
|
|
160
|
+
# Subscribe before sending to catch all events
|
|
161
|
+
response = await self.client.event_subscribe()
|
|
162
|
+
assert isinstance(response, httpx.Response)
|
|
163
|
+
|
|
164
|
+
# Send prompt (V1 sync)
|
|
165
|
+
await self.client.session_send(session.id, body)
|
|
166
|
+
|
|
167
|
+
seen_parts: set[str] = set()
|
|
168
|
+
|
|
169
|
+
async for line in response.aiter_lines():
|
|
170
|
+
if not line.startswith("data: "):
|
|
171
|
+
continue
|
|
172
|
+
payload = json.loads(line[6:])
|
|
173
|
+
event_type = payload.get("type")
|
|
174
|
+
props = payload.get("properties", {})
|
|
175
|
+
|
|
176
|
+
# Skip events for other sessions
|
|
177
|
+
sid = props.get("sessionID")
|
|
178
|
+
if sid is not None and sid != session.id:
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
if event_type == "message.part.delta":
|
|
182
|
+
if props.get("field") == "text":
|
|
183
|
+
part_id = props.get("partID", "")
|
|
184
|
+
delta = props.get("delta", "")
|
|
185
|
+
if delta:
|
|
186
|
+
seen_parts.add(part_id)
|
|
187
|
+
yield delta
|
|
188
|
+
|
|
189
|
+
elif event_type == "message.part.updated":
|
|
190
|
+
part = props.get("part", {})
|
|
191
|
+
part_id = part.get("id", "")
|
|
192
|
+
if part_id not in seen_parts and part.get("type") == "text":
|
|
193
|
+
text = part.get("text", "")
|
|
194
|
+
if text:
|
|
195
|
+
seen_parts.add(part_id)
|
|
196
|
+
yield text
|
|
197
|
+
|
|
198
|
+
elif event_type == "session.status":
|
|
199
|
+
status = props.get("status", {})
|
|
200
|
+
if isinstance(status, dict) and status.get("type") == "idle":
|
|
201
|
+
break
|
|
202
|
+
|
|
203
|
+
elif event_type == "session.idle":
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def async_opencode(
|
|
208
|
+
prompt: str,
|
|
209
|
+
*,
|
|
210
|
+
keep: bool = False,
|
|
211
|
+
auto_tools: bool = False,
|
|
212
|
+
agent: Optional[str] = None,
|
|
213
|
+
model: Optional[str] = None,
|
|
214
|
+
format: Optional[Dict[str, Any]] = None,
|
|
215
|
+
port: int = 4096,
|
|
216
|
+
directory: Optional[str] = None,
|
|
217
|
+
config: Optional[Dict[str, Any]] = None,
|
|
218
|
+
) -> str:
|
|
219
|
+
global _async_opencode_state
|
|
220
|
+
state = _async_opencode_state
|
|
221
|
+
|
|
222
|
+
if not state:
|
|
223
|
+
cfg = dict(config or {})
|
|
224
|
+
resolved_agent = agent or ("build" if auto_tools else None)
|
|
225
|
+
ai = AsyncOpendcode(port=port, directory=directory, config=cfg or None, model=model)
|
|
226
|
+
ai.start()
|
|
227
|
+
session = await ai.create_session(agent=resolved_agent)
|
|
228
|
+
state["ai"] = ai
|
|
229
|
+
state["session"] = session
|
|
230
|
+
state["config"] = cfg
|
|
231
|
+
state["model"] = model
|
|
232
|
+
else:
|
|
233
|
+
ai = state["ai"]
|
|
234
|
+
session = state["session"]
|
|
235
|
+
if model is not None or config is not None:
|
|
236
|
+
new_cfg = dict(config or {})
|
|
237
|
+
if model is not None and model != state.get("model"):
|
|
238
|
+
warnings.warn("Ignoring new model — using existing session")
|
|
239
|
+
elif new_cfg != state.get("config", {}):
|
|
240
|
+
warnings.warn("Ignoring new config — using existing session")
|
|
241
|
+
|
|
242
|
+
resolved = _resolve_model(model=state.get("model"), config=state.get("config", {}))
|
|
243
|
+
if auto_tools:
|
|
244
|
+
from opencode._tools import ToolExecutor
|
|
245
|
+
|
|
246
|
+
msg = await session.ask(prompt, model=resolved, format=format, tool_executor=ToolExecutor())
|
|
247
|
+
else:
|
|
248
|
+
msg = await session.prompt(prompt, model=resolved, format=format)
|
|
249
|
+
result = _extract_text(msg)
|
|
250
|
+
|
|
251
|
+
if not keep:
|
|
252
|
+
await ai.close()
|
|
253
|
+
state.clear()
|
|
254
|
+
|
|
255
|
+
return result
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Dict, List, Optional
|
|
4
|
+
|
|
5
|
+
from opencode._async_client import AsyncOpendcodeClient
|
|
6
|
+
from opencode._models import SessionMessage
|
|
7
|
+
from opencode._tools import ToolExecutor
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AsyncSession:
|
|
11
|
+
def __init__(self, client: AsyncOpendcodeClient, session_id: str):
|
|
12
|
+
self._client = client
|
|
13
|
+
self.id = session_id
|
|
14
|
+
self._auto_confirmed: bool = False
|
|
15
|
+
|
|
16
|
+
async def prompt(
|
|
17
|
+
self,
|
|
18
|
+
text: str,
|
|
19
|
+
*,
|
|
20
|
+
files: Optional[List[Dict[str, Any]]] = None,
|
|
21
|
+
agents: Optional[List[Dict[str, Any]]] = None,
|
|
22
|
+
references: Optional[List[Dict[str, Any]]] = None,
|
|
23
|
+
model: Optional[Dict[str, str]] = None,
|
|
24
|
+
format: Optional[Dict[str, Any]] = None,
|
|
25
|
+
wait: bool = True,
|
|
26
|
+
poll_interval: float = 0.5,
|
|
27
|
+
poll_timeout: float = 600.0,
|
|
28
|
+
) -> SessionMessage:
|
|
29
|
+
parts: List[Dict[str, Any]] = [{"type": "text", "text": text}]
|
|
30
|
+
body: Dict[str, Any] = {"parts": parts}
|
|
31
|
+
if model:
|
|
32
|
+
body["model"] = model
|
|
33
|
+
if format:
|
|
34
|
+
body["format"] = format
|
|
35
|
+
|
|
36
|
+
result = await self._client.session_send(self.id, body)
|
|
37
|
+
|
|
38
|
+
if isinstance(result, dict):
|
|
39
|
+
parts_list = result.get("parts", [])
|
|
40
|
+
info = result.get("info", {})
|
|
41
|
+
structured = result.get("structured") or info.get("structured")
|
|
42
|
+
text_parts: List[Dict[str, Any]] = []
|
|
43
|
+
for p in parts_list:
|
|
44
|
+
ptype = p.get("type", "")
|
|
45
|
+
if ptype in ("text", "reasoning", "tool"):
|
|
46
|
+
text_parts.append({"type": ptype, "text": p.get("text", "")})
|
|
47
|
+
msg: Dict[str, Any] = {
|
|
48
|
+
"id": info.get("id", ""),
|
|
49
|
+
"type": "assistant",
|
|
50
|
+
"content": text_parts,
|
|
51
|
+
"model": info.get("model"),
|
|
52
|
+
"time": info.get("time", {}),
|
|
53
|
+
}
|
|
54
|
+
if structured is not None:
|
|
55
|
+
msg["structured"] = structured
|
|
56
|
+
return msg
|
|
57
|
+
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
async def ask(
|
|
61
|
+
self,
|
|
62
|
+
text: str,
|
|
63
|
+
*,
|
|
64
|
+
files: Optional[List[Dict[str, Any]]] = None,
|
|
65
|
+
model: Optional[Dict[str, str]] = None,
|
|
66
|
+
format: Optional[Dict[str, Any]] = None,
|
|
67
|
+
max_tool_rounds: int = 25,
|
|
68
|
+
tool_executor: Optional[ToolExecutor] = None,
|
|
69
|
+
quiet: bool = False,
|
|
70
|
+
) -> SessionMessage:
|
|
71
|
+
if tool_executor is None:
|
|
72
|
+
tool_executor = ToolExecutor()
|
|
73
|
+
parts: List[Dict[str, Any]] = [{"type": "text", "text": text}]
|
|
74
|
+
if files:
|
|
75
|
+
parts.extend(files)
|
|
76
|
+
|
|
77
|
+
for _round in range(max_tool_rounds):
|
|
78
|
+
body: Dict[str, Any] = {"parts": parts}
|
|
79
|
+
if model:
|
|
80
|
+
body["model"] = model
|
|
81
|
+
if format:
|
|
82
|
+
body["format"] = format
|
|
83
|
+
|
|
84
|
+
result = await self._client.session_send(self.id, body)
|
|
85
|
+
if not isinstance(result, dict):
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
parts_list = result.get("parts", [])
|
|
89
|
+
info = result.get("info", {})
|
|
90
|
+
|
|
91
|
+
tool_uses = [p for p in parts_list if p.get("type") == "tool-use"]
|
|
92
|
+
if tool_uses:
|
|
93
|
+
self._auto_confirmed = True
|
|
94
|
+
results: List[Dict[str, Any]] = []
|
|
95
|
+
for tu in tool_uses:
|
|
96
|
+
tool_name = tu.get("tool", {}).get("name", "")
|
|
97
|
+
tool_input = tu.get("tool", {}).get("input", {})
|
|
98
|
+
tool_id = tu.get("toolUseID", "")
|
|
99
|
+
if not quiet:
|
|
100
|
+
print(f"\033[33m[Tool] {tool_name}\033[0m")
|
|
101
|
+
output = tool_executor.execute(tool_name, tool_input)
|
|
102
|
+
results.append({
|
|
103
|
+
"type": "tool-result",
|
|
104
|
+
"toolUseID": tool_id,
|
|
105
|
+
"tool": {"name": tool_name},
|
|
106
|
+
"output": output,
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
parts = results
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
if not self._auto_confirmed:
|
|
113
|
+
self._auto_confirmed = True
|
|
114
|
+
parts = [{"type": "text", "text": "Exit plan mode and proceed with execution now. Use tools as needed."}]
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
text_parts: List[Dict[str, Any]] = []
|
|
118
|
+
for p in parts_list:
|
|
119
|
+
ptype = p.get("type", "")
|
|
120
|
+
if ptype in ("text", "reasoning", "tool"):
|
|
121
|
+
text_parts.append({"type": ptype, "text": p.get("text", "")})
|
|
122
|
+
msg: Dict[str, Any] = {
|
|
123
|
+
"id": info.get("id", ""),
|
|
124
|
+
"type": "assistant",
|
|
125
|
+
"content": text_parts,
|
|
126
|
+
"model": info.get("model"),
|
|
127
|
+
"time": info.get("time", {}),
|
|
128
|
+
}
|
|
129
|
+
structured = result.get("structured") or info.get("structured")
|
|
130
|
+
if structured is not None:
|
|
131
|
+
msg["structured"] = structured
|
|
132
|
+
return msg
|
|
133
|
+
|
|
134
|
+
raise RuntimeError(f"Tool loop exceeded {max_tool_rounds} rounds")
|
|
135
|
+
|
|
136
|
+
async def messages(self, **kwargs) -> Any:
|
|
137
|
+
return await self._client.v2_session_messages(self.id, **kwargs)
|
|
138
|
+
|
|
139
|
+
async def context(self, **kwargs) -> List[SessionMessage]:
|
|
140
|
+
return await self._client.v2_session_context(self.id, **kwargs)
|
|
141
|
+
|
|
142
|
+
async def compact(self) -> Any:
|
|
143
|
+
return await self._client.v2_session_compact(self.id)
|
|
144
|
+
|
|
145
|
+
async def abort(self) -> Any:
|
|
146
|
+
return await self._client.session_abort(self.id)
|
|
147
|
+
|
|
148
|
+
async def fork(self, **kwargs) -> Any:
|
|
149
|
+
return await self._client.session_fork(self.id, **kwargs)
|
|
150
|
+
|
|
151
|
+
async def diff(self) -> Any:
|
|
152
|
+
return await self._client.session_diff(self.id)
|
|
153
|
+
|
|
154
|
+
async def todo(self) -> Any:
|
|
155
|
+
return await self._client.session_todo(self.id)
|
opencode/_binary.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import stat
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from opencode._errors import BinaryNotFound
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _system() -> str:
|
|
16
|
+
raw = platform.system().lower()
|
|
17
|
+
if raw == "darwin":
|
|
18
|
+
return "darwin"
|
|
19
|
+
if raw == "windows":
|
|
20
|
+
return "win32"
|
|
21
|
+
return "linux"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _arch() -> str:
|
|
25
|
+
raw = platform.machine().lower()
|
|
26
|
+
if raw in ("amd64", "x86_64"):
|
|
27
|
+
return "x64"
|
|
28
|
+
if raw in ("aarch64", "arm64"):
|
|
29
|
+
return "arm64"
|
|
30
|
+
return raw
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _platform_suffix() -> str:
|
|
34
|
+
return f"{_system()}-{_arch()}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _resolve_wrapper(path: str) -> str:
|
|
38
|
+
"""If *path* is a .cmd/.bat wrapper (npm-style), return the real .exe it launches."""
|
|
39
|
+
if not path.lower().endswith((".cmd", ".bat")):
|
|
40
|
+
return path
|
|
41
|
+
try:
|
|
42
|
+
text = Path(path).read_text(encoding="utf-8", errors="replace")
|
|
43
|
+
for line in text.splitlines():
|
|
44
|
+
m = re.search(r'"([^"]+\.exe)"', line)
|
|
45
|
+
if m:
|
|
46
|
+
rel = m.group(1)
|
|
47
|
+
full = rel.replace("%dp0%", os.path.dirname(path)).replace("/", "\\")
|
|
48
|
+
if os.path.isfile(full):
|
|
49
|
+
return os.path.realpath(full)
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
return path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def find_in_path(name: str = "opencode") -> Optional[str]:
|
|
56
|
+
resolved = shutil.which(name)
|
|
57
|
+
if resolved:
|
|
58
|
+
return _resolve_wrapper(resolved)
|
|
59
|
+
if sys.platform == "win32":
|
|
60
|
+
for ext in (".exe", ".cmd", ".bat"):
|
|
61
|
+
resolved = shutil.which(name + ext)
|
|
62
|
+
if resolved:
|
|
63
|
+
return _resolve_wrapper(resolved)
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def binary_dir() -> Path:
|
|
68
|
+
return Path.home() / ".opencode" / "bin"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def find_local(name: str = "opencode") -> Optional[str]:
|
|
72
|
+
candidates = [name]
|
|
73
|
+
if sys.platform == "win32":
|
|
74
|
+
candidates = [f"{name}.exe", name]
|
|
75
|
+
for candidate in candidates:
|
|
76
|
+
full = binary_dir() / candidate
|
|
77
|
+
if full.exists():
|
|
78
|
+
return str(full.resolve())
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def ensure_opencode(name: str = "opencode") -> str:
|
|
83
|
+
existing = find_in_path(name)
|
|
84
|
+
if existing:
|
|
85
|
+
return existing
|
|
86
|
+
existing = find_local(name)
|
|
87
|
+
if existing:
|
|
88
|
+
return existing
|
|
89
|
+
path = download_opencode(name)
|
|
90
|
+
return path
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def download_opencode(
|
|
94
|
+
name: str = "opencode",
|
|
95
|
+
version: str = "latest",
|
|
96
|
+
dest: Optional[Path] = None,
|
|
97
|
+
) -> str:
|
|
98
|
+
import io
|
|
99
|
+
import json
|
|
100
|
+
import tarfile
|
|
101
|
+
import urllib.request
|
|
102
|
+
import zipfile
|
|
103
|
+
|
|
104
|
+
dest = dest or binary_dir()
|
|
105
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
suffix = _platform_suffix()
|
|
107
|
+
|
|
108
|
+
if version == "latest":
|
|
109
|
+
url = "https://api.github.com/repos/anomalyco/opencode/releases/latest"
|
|
110
|
+
req = urllib.request.Request(url, headers={"Accept": "application/json", "User-Agent": "opencode-py"})
|
|
111
|
+
with urllib.request.urlopen(req) as resp:
|
|
112
|
+
data = json.loads(resp.read().decode())
|
|
113
|
+
version = data["tag_name"]
|
|
114
|
+
|
|
115
|
+
ext = ".zip" if sys.platform == "win32" else ".tar.gz"
|
|
116
|
+
archive_url = f"https://github.com/anomalyco/opencode/releases/download/{version}/opencode-{suffix}{ext}"
|
|
117
|
+
|
|
118
|
+
print(f"Downloading opencode {version} ({suffix})...")
|
|
119
|
+
req = urllib.request.Request(archive_url, headers={"User-Agent": "opencode-py"})
|
|
120
|
+
with urllib.request.urlopen(req) as resp:
|
|
121
|
+
body = resp.read()
|
|
122
|
+
|
|
123
|
+
if ext == ".zip":
|
|
124
|
+
zf = zipfile.ZipFile(io.BytesIO(body))
|
|
125
|
+
zf.extractall(str(dest))
|
|
126
|
+
else:
|
|
127
|
+
tf = tarfile.open(fileobj=io.BytesIO(body), mode="r:gz")
|
|
128
|
+
tf.extractall(str(dest))
|
|
129
|
+
|
|
130
|
+
final = dest / (name + (".exe" if sys.platform == "win32" else ""))
|
|
131
|
+
if final.exists():
|
|
132
|
+
final.chmod(final.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
|
|
133
|
+
|
|
134
|
+
print(f"Downloaded opencode to {final}")
|
|
135
|
+
return str(final.resolve())
|