optio-opencode 0.1.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.
- optio_opencode-0.1.0/PKG-INFO +84 -0
- optio_opencode-0.1.0/README.md +55 -0
- optio_opencode-0.1.0/pyproject.toml +50 -0
- optio_opencode-0.1.0/setup.cfg +4 -0
- optio_opencode-0.1.0/src/optio_opencode/__init__.py +38 -0
- optio_opencode-0.1.0/src/optio_opencode/host_actions.py +432 -0
- optio_opencode-0.1.0/src/optio_opencode/prompt.py +153 -0
- optio_opencode-0.1.0/src/optio_opencode/session.py +552 -0
- optio_opencode-0.1.0/src/optio_opencode/snapshots.py +101 -0
- optio_opencode-0.1.0/src/optio_opencode/types.py +55 -0
- optio_opencode-0.1.0/src/optio_opencode.egg-info/PKG-INFO +84 -0
- optio_opencode-0.1.0/src/optio_opencode.egg-info/SOURCES.txt +28 -0
- optio_opencode-0.1.0/src/optio_opencode.egg-info/dependency_links.txt +1 -0
- optio_opencode-0.1.0/src/optio_opencode.egg-info/requires.txt +7 -0
- optio_opencode-0.1.0/src/optio_opencode.egg-info/top_level.txt +1 -0
- optio_opencode-0.1.0/tests/test_host_local.py +119 -0
- optio_opencode-0.1.0/tests/test_host_primitives_local.py +198 -0
- optio_opencode-0.1.0/tests/test_host_primitives_remote.py +242 -0
- optio_opencode-0.1.0/tests/test_host_remote_resume.py +222 -0
- optio_opencode-0.1.0/tests/test_host_resume.py +86 -0
- optio_opencode-0.1.0/tests/test_prompt.py +99 -0
- optio_opencode-0.1.0/tests/test_sanity.py +38 -0
- optio_opencode-0.1.0/tests/test_session_blob_hooks.py +139 -0
- optio_opencode-0.1.0/tests/test_session_hooks.py +281 -0
- optio_opencode-0.1.0/tests/test_session_local.py +428 -0
- optio_opencode-0.1.0/tests/test_session_remote.py +147 -0
- optio_opencode-0.1.0/tests/test_session_resume.py +189 -0
- optio_opencode-0.1.0/tests/test_smart_install.py +480 -0
- optio_opencode-0.1.0/tests/test_snapshots.py +87 -0
- optio_opencode-0.1.0/tests/test_types.py +97 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: optio-opencode
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Run opencode web as an optio task; local subprocess or remote via SSH.
|
|
5
|
+
Author-email: Kristof Csillag <kristof.csillag@deai-labs.com>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/deai-network/optio
|
|
8
|
+
Project-URL: Repository, https://github.com/deai-network/optio
|
|
9
|
+
Project-URL: Issues, https://github.com/deai-network/optio/issues
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Operating System :: MacOS
|
|
17
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
19
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
20
|
+
Classifier: Framework :: AsyncIO
|
|
21
|
+
Requires-Python: >=3.11
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
Requires-Dist: optio-core<0.2,>=0.1
|
|
24
|
+
Requires-Dist: optio-host<0.2,>=0.1
|
|
25
|
+
Requires-Dist: asyncssh>=2.14
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
|
|
29
|
+
|
|
30
|
+
# optio-opencode
|
|
31
|
+
|
|
32
|
+
Run [opencode web](https://github.com/opencode-ai/opencode) as an [optio](https://github.com/deai-network/optio) task — local subprocess or remote over SSH — with opencode's UI reachable through optio's UI components.
|
|
33
|
+
|
|
34
|
+
## What it does
|
|
35
|
+
|
|
36
|
+
Given an `OpencodeTaskConfig` (workdir contents, prompt, deliverable callback), `optio-opencode`:
|
|
37
|
+
|
|
38
|
+
1. Provisions a fresh workdir on the chosen host (local or remote).
|
|
39
|
+
2. Writes `AGENTS.md` (base prompt + your instructions) and `opencode.json` (your config) into it.
|
|
40
|
+
3. Installs the opencode binary if missing (remote mode only).
|
|
41
|
+
4. Launches `opencode web` with a random auth password.
|
|
42
|
+
5. Registers the opencode UI as a widget that optio's UI components can embed via the widget proxy — SSH tunnel hidden from optio-api.
|
|
43
|
+
6. Tails a log file the LLM writes to and translates structured lines into optio events:
|
|
44
|
+
- `STATUS: …` → `ctx.report_progress(percent, message)`
|
|
45
|
+
- `DELIVERABLE: <path>` → fetches the file, invokes your `on_deliverable` callback
|
|
46
|
+
- `DONE [summary]` → clean completion
|
|
47
|
+
- `ERROR [message]` → failure
|
|
48
|
+
7. Cleans up workdir and SSH connection on teardown.
|
|
49
|
+
|
|
50
|
+
The same `OpencodeTaskConfig` works for local and remote modes; only `SSHConfig` differs.
|
|
51
|
+
|
|
52
|
+
## When to use it
|
|
53
|
+
|
|
54
|
+
You want an opencode-driven assistant session as a managed optio task — surfaced through optio's UI, with progress reporting and file deliverables — without writing the host management, log parsing, or widget plumbing yourself.
|
|
55
|
+
|
|
56
|
+
## Installation
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install optio-opencode
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Python 3.11+. Depends on `optio-core`, `optio-host`, and `asyncssh`.
|
|
63
|
+
|
|
64
|
+
## Minimal example
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from optio_opencode import create_opencode_task, OpencodeTaskConfig
|
|
68
|
+
from optio_host import SSHConfig
|
|
69
|
+
|
|
70
|
+
config = OpencodeTaskConfig(
|
|
71
|
+
workdir_files={"AGENTS.md": "Do the thing.", "opencode.json": "{...}"},
|
|
72
|
+
on_deliverable=lambda ctx, path, text: print(f"got {path}: {len(text)} bytes"),
|
|
73
|
+
ssh=SSHConfig(host="worker-1", user="optio", key_path="~/.ssh/id_optio"),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
task = create_opencode_task(config)
|
|
77
|
+
# Schedule / run via optio-core as usual.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Set `ssh=None` for local subprocess mode.
|
|
81
|
+
|
|
82
|
+
## License
|
|
83
|
+
|
|
84
|
+
Apache-2.0.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# optio-opencode
|
|
2
|
+
|
|
3
|
+
Run [opencode web](https://github.com/opencode-ai/opencode) as an [optio](https://github.com/deai-network/optio) task — local subprocess or remote over SSH — with opencode's UI reachable through optio's UI components.
|
|
4
|
+
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
Given an `OpencodeTaskConfig` (workdir contents, prompt, deliverable callback), `optio-opencode`:
|
|
8
|
+
|
|
9
|
+
1. Provisions a fresh workdir on the chosen host (local or remote).
|
|
10
|
+
2. Writes `AGENTS.md` (base prompt + your instructions) and `opencode.json` (your config) into it.
|
|
11
|
+
3. Installs the opencode binary if missing (remote mode only).
|
|
12
|
+
4. Launches `opencode web` with a random auth password.
|
|
13
|
+
5. Registers the opencode UI as a widget that optio's UI components can embed via the widget proxy — SSH tunnel hidden from optio-api.
|
|
14
|
+
6. Tails a log file the LLM writes to and translates structured lines into optio events:
|
|
15
|
+
- `STATUS: …` → `ctx.report_progress(percent, message)`
|
|
16
|
+
- `DELIVERABLE: <path>` → fetches the file, invokes your `on_deliverable` callback
|
|
17
|
+
- `DONE [summary]` → clean completion
|
|
18
|
+
- `ERROR [message]` → failure
|
|
19
|
+
7. Cleans up workdir and SSH connection on teardown.
|
|
20
|
+
|
|
21
|
+
The same `OpencodeTaskConfig` works for local and remote modes; only `SSHConfig` differs.
|
|
22
|
+
|
|
23
|
+
## When to use it
|
|
24
|
+
|
|
25
|
+
You want an opencode-driven assistant session as a managed optio task — surfaced through optio's UI, with progress reporting and file deliverables — without writing the host management, log parsing, or widget plumbing yourself.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install optio-opencode
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Python 3.11+. Depends on `optio-core`, `optio-host`, and `asyncssh`.
|
|
34
|
+
|
|
35
|
+
## Minimal example
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
from optio_opencode import create_opencode_task, OpencodeTaskConfig
|
|
39
|
+
from optio_host import SSHConfig
|
|
40
|
+
|
|
41
|
+
config = OpencodeTaskConfig(
|
|
42
|
+
workdir_files={"AGENTS.md": "Do the thing.", "opencode.json": "{...}"},
|
|
43
|
+
on_deliverable=lambda ctx, path, text: print(f"got {path}: {len(text)} bytes"),
|
|
44
|
+
ssh=SSHConfig(host="worker-1", user="optio", key_path="~/.ssh/id_optio"),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
task = create_opencode_task(config)
|
|
48
|
+
# Schedule / run via optio-core as usual.
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Set `ssh=None` for local subprocess mode.
|
|
52
|
+
|
|
53
|
+
## License
|
|
54
|
+
|
|
55
|
+
Apache-2.0.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68.0", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "optio-opencode"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Run opencode web as an optio task; local subprocess or remote via SSH."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "Apache-2.0"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Kristof Csillag", email = "kristof.csillag@deai-labs.com" },
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Operating System :: POSIX :: Linux",
|
|
22
|
+
"Operating System :: MacOS",
|
|
23
|
+
"Topic :: Software Development :: Libraries :: Python Modules",
|
|
24
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
25
|
+
"Topic :: Software Development :: Code Generators",
|
|
26
|
+
"Framework :: AsyncIO",
|
|
27
|
+
]
|
|
28
|
+
dependencies = [
|
|
29
|
+
"optio-core>=0.1,<0.2",
|
|
30
|
+
"optio-host>=0.1,<0.2",
|
|
31
|
+
"asyncssh>=2.14",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=8.0",
|
|
37
|
+
"pytest-asyncio>=0.23",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
[project.urls]
|
|
41
|
+
Homepage = "https://github.com/deai-network/optio"
|
|
42
|
+
Repository = "https://github.com/deai-network/optio"
|
|
43
|
+
Issues = "https://github.com/deai-network/optio/issues"
|
|
44
|
+
|
|
45
|
+
[tool.setuptools.packages.find]
|
|
46
|
+
where = ["src"]
|
|
47
|
+
|
|
48
|
+
[tool.pytest.ini_options]
|
|
49
|
+
asyncio_mode = "auto"
|
|
50
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""optio-opencode — run opencode web as an optio task."""
|
|
2
|
+
|
|
3
|
+
import logging as _logging
|
|
4
|
+
|
|
5
|
+
from optio_host import (
|
|
6
|
+
HookContext,
|
|
7
|
+
HookContextProtocol,
|
|
8
|
+
HostCommandError,
|
|
9
|
+
RunResult,
|
|
10
|
+
SSHConfig,
|
|
11
|
+
)
|
|
12
|
+
from optio_opencode.session import create_opencode_task, run_opencode_session
|
|
13
|
+
from optio_opencode.types import (
|
|
14
|
+
DeliverableCallback,
|
|
15
|
+
HookCallback,
|
|
16
|
+
OpencodeTaskConfig,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
# asyncssh emits per-connection / per-channel INFO lines ("Opening SSH
|
|
20
|
+
# connection...", "Received channel close", etc.) that flood the worker's
|
|
21
|
+
# stdout once an SSH-backed opencode session starts. Quiet it down by
|
|
22
|
+
# default. Consumers that want the verbose trace can override:
|
|
23
|
+
#
|
|
24
|
+
# logging.getLogger("asyncssh").setLevel(logging.INFO)
|
|
25
|
+
_logging.getLogger("asyncssh").setLevel(_logging.WARNING)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"create_opencode_task",
|
|
29
|
+
"run_opencode_session",
|
|
30
|
+
"DeliverableCallback",
|
|
31
|
+
"OpencodeTaskConfig",
|
|
32
|
+
"SSHConfig",
|
|
33
|
+
"HookContext",
|
|
34
|
+
"HookContextProtocol",
|
|
35
|
+
"HostCommandError",
|
|
36
|
+
"RunResult",
|
|
37
|
+
"HookCallback",
|
|
38
|
+
]
|
|
@@ -0,0 +1,432 @@
|
|
|
1
|
+
"""Opencode-specific actions over a generic Host.
|
|
2
|
+
|
|
3
|
+
Each function takes a ``Host`` (from optio_host) and uses only generic
|
|
4
|
+
primitives (``run_command``, ``launch_subprocess``, etc.) plus opencode-
|
|
5
|
+
shaped state-passing. Free functions, not Host methods, so optio-host's
|
|
6
|
+
Host Protocol stays generic.
|
|
7
|
+
|
|
8
|
+
Install path is uniform: ``ensure_opencode_installed`` drives
|
|
9
|
+
csillag/opencode's ``smart-install.sh --check`` and, when needed, downloads
|
|
10
|
+
the release zip as an optio child task (`HookContext.download_file`),
|
|
11
|
+
unpacks it on the host, and places the binary at
|
|
12
|
+
``<install_dir>/opencode``. ``install_dir`` defaults to
|
|
13
|
+
``~/.local/bin`` (resolved per host) and is overridable via the
|
|
14
|
+
``install_dir`` keyword argument on the public entry points; consumers
|
|
15
|
+
expose this as ``OpencodeTaskConfig.opencode_install_dir``.
|
|
16
|
+
No isinstance branches.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import shlex
|
|
25
|
+
from typing import TYPE_CHECKING, Callable
|
|
26
|
+
|
|
27
|
+
from optio_host.host import ProcessHandle
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from optio_host.host import Host
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
_READY_RE = re.compile(r"(http://[^\s]+)")
|
|
34
|
+
|
|
35
|
+
_SMART_INSTALL_URL = (
|
|
36
|
+
"https://raw.githubusercontent.com/csillag/opencode/main/smart-install.sh"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Sub-path of the host's $HOME used as the default opencode install
|
|
40
|
+
# directory when no explicit ``install_dir`` is supplied. Kept as a
|
|
41
|
+
# constant so the three places that care about it (smart-install PATH
|
|
42
|
+
# augmentation, post-ok ``command -v`` lookup, ``_install_opencode_from_zip``
|
|
43
|
+
# install target) stay in agreement.
|
|
44
|
+
DEFAULT_INSTALL_SUBDIR = ".local/bin"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def _resolve_install_dir(host: "Host", install_dir: str | None) -> str:
|
|
48
|
+
"""Return ``install_dir`` if given, else the host's default install dir.
|
|
49
|
+
|
|
50
|
+
Default: ``<host_home>/<DEFAULT_INSTALL_SUBDIR>``.
|
|
51
|
+
"""
|
|
52
|
+
if install_dir is not None:
|
|
53
|
+
return install_dir
|
|
54
|
+
host_home = await host.resolve_host_home()
|
|
55
|
+
return f"{host_home}/{DEFAULT_INSTALL_SUBDIR}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _path_augmented(cmd: str, install_dir: str) -> str:
|
|
59
|
+
"""Prefix ``cmd`` with an export that prepends ``install_dir`` to PATH.
|
|
60
|
+
|
|
61
|
+
Used so smart-install's internal ``command -v opencode`` and the
|
|
62
|
+
post-"ok" lookup find the binary at the install location even when
|
|
63
|
+
the calling shell's PATH doesn't already include it (common: the
|
|
64
|
+
python process inherits a slimmed-down PATH that doesn't add
|
|
65
|
+
``~/.local/bin``, so smart-install would falsely report "download"
|
|
66
|
+
and we'd reinstall on every task run).
|
|
67
|
+
"""
|
|
68
|
+
return f'export PATH={shlex.quote(install_dir)}:"$PATH"; {cmd}'
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _smart_install_check(
|
|
72
|
+
host: "Host", *, install_dir: str | None = None,
|
|
73
|
+
) -> tuple[str, str | None]:
|
|
74
|
+
"""Run smart-install.sh --check on ``host`` and parse the result.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
("ok", None) when opencode is already up to date.
|
|
78
|
+
("download", url) when opencode is missing or stale; ``url`` is the
|
|
79
|
+
release-archive zip to fetch.
|
|
80
|
+
|
|
81
|
+
``install_dir`` is prepended to PATH inside the remote shell so
|
|
82
|
+
smart-install's internal ``command -v opencode`` can see binaries
|
|
83
|
+
installed by a prior ``_install_opencode_from_zip``. Defaults to
|
|
84
|
+
``~/.local/bin`` on the host.
|
|
85
|
+
|
|
86
|
+
Raises RuntimeError on non-zero exit or unparseable output.
|
|
87
|
+
"""
|
|
88
|
+
resolved_install_dir = await _resolve_install_dir(host, install_dir)
|
|
89
|
+
cmd = _path_augmented(
|
|
90
|
+
f"curl -fsSL {_SMART_INSTALL_URL} | bash -s -- --check",
|
|
91
|
+
resolved_install_dir,
|
|
92
|
+
)
|
|
93
|
+
result = await host.run_command(cmd)
|
|
94
|
+
if result.exit_code != 0:
|
|
95
|
+
raise RuntimeError(
|
|
96
|
+
f"smart-install --check failed on host (exit {result.exit_code}): "
|
|
97
|
+
f"{result.stderr.strip()[:200]}"
|
|
98
|
+
)
|
|
99
|
+
line = result.stdout.strip()
|
|
100
|
+
if line == "opencode ok":
|
|
101
|
+
return ("ok", None)
|
|
102
|
+
if line.startswith("download "):
|
|
103
|
+
url = line[len("download "):].strip()
|
|
104
|
+
if not url:
|
|
105
|
+
raise RuntimeError(
|
|
106
|
+
f"smart-install --check returned empty URL: {result.stdout!r}"
|
|
107
|
+
)
|
|
108
|
+
return ("download", url)
|
|
109
|
+
raise RuntimeError(
|
|
110
|
+
f"smart-install --check returned unexpected output: {result.stdout!r}"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def _install_opencode_from_zip(
|
|
115
|
+
hook_ctx, url: str, *, install_dir: str | None = None,
|
|
116
|
+
) -> str:
|
|
117
|
+
"""Download the opencode release archive from ``url`` and install it.
|
|
118
|
+
|
|
119
|
+
Uniform for LocalHost and RemoteHost:
|
|
120
|
+
1. mktemp -d on the host.
|
|
121
|
+
2. ``hook_ctx.download_file(url, <tmpdir>/opencode.zip)`` (spawns the
|
|
122
|
+
child download task — emits its own progress on the child ctx).
|
|
123
|
+
3. unzip on the host (archive layout: ``bin/opencode`` + sidecars).
|
|
124
|
+
4. mkdir -p ``install_dir``; move binary there; chmod +x.
|
|
125
|
+
5. Remove the tempdir.
|
|
126
|
+
|
|
127
|
+
``install_dir`` defaults to ``~/.local/bin`` on the host when None.
|
|
128
|
+
|
|
129
|
+
Returns the absolute install path on the host.
|
|
130
|
+
"""
|
|
131
|
+
host = hook_ctx._host
|
|
132
|
+
resolved_install_dir = await _resolve_install_dir(host, install_dir)
|
|
133
|
+
r = await host.run_command("mktemp -d -t optio-opencode-XXXXXX")
|
|
134
|
+
if r.exit_code != 0:
|
|
135
|
+
raise RuntimeError(
|
|
136
|
+
f"mktemp -d failed (exit {r.exit_code}): {r.stderr.strip()[:200]}"
|
|
137
|
+
)
|
|
138
|
+
tmpdir = r.stdout.strip()
|
|
139
|
+
zip_path = f"{tmpdir}/opencode.zip"
|
|
140
|
+
try:
|
|
141
|
+
await hook_ctx.download_file(url, zip_path)
|
|
142
|
+
|
|
143
|
+
r = await host.run_command(
|
|
144
|
+
f"unzip -o -q {shlex.quote(zip_path)} -d {shlex.quote(tmpdir)}"
|
|
145
|
+
)
|
|
146
|
+
if r.exit_code != 0:
|
|
147
|
+
raise RuntimeError(
|
|
148
|
+
f"unzip failed (exit {r.exit_code}): {r.stderr.strip()[:200]}"
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
install_path = f"{resolved_install_dir}/opencode"
|
|
152
|
+
r = await host.run_command(f"mkdir -p {shlex.quote(resolved_install_dir)}")
|
|
153
|
+
if r.exit_code != 0:
|
|
154
|
+
raise RuntimeError(
|
|
155
|
+
f"mkdir -p {resolved_install_dir!r} failed (exit {r.exit_code}): "
|
|
156
|
+
f"{r.stderr.strip()[:200]}"
|
|
157
|
+
)
|
|
158
|
+
src = f"{tmpdir}/bin/opencode"
|
|
159
|
+
r = await host.run_command(
|
|
160
|
+
f"mv -f {shlex.quote(src)} {shlex.quote(install_path)}"
|
|
161
|
+
)
|
|
162
|
+
if r.exit_code != 0:
|
|
163
|
+
raise RuntimeError(
|
|
164
|
+
f"mv {src!r} → {install_path!r} failed (exit {r.exit_code}): "
|
|
165
|
+
f"{r.stderr.strip()[:200]}"
|
|
166
|
+
)
|
|
167
|
+
r = await host.run_command(f"chmod +x {shlex.quote(install_path)}")
|
|
168
|
+
if r.exit_code != 0:
|
|
169
|
+
raise RuntimeError(
|
|
170
|
+
f"chmod +x {install_path!r} failed (exit {r.exit_code}): "
|
|
171
|
+
f"{r.stderr.strip()[:200]}"
|
|
172
|
+
)
|
|
173
|
+
return install_path
|
|
174
|
+
finally:
|
|
175
|
+
# Best-effort cleanup. Don't mask a primary exception with cleanup errors.
|
|
176
|
+
await host.run_command(f"rm -rf {shlex.quote(tmpdir)}")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def ensure_opencode_installed(
|
|
180
|
+
hook_ctx,
|
|
181
|
+
install_if_missing: bool = True,
|
|
182
|
+
*,
|
|
183
|
+
install_dir: str | None = None,
|
|
184
|
+
) -> str:
|
|
185
|
+
"""Ensure opencode is available on the host behind ``hook_ctx``.
|
|
186
|
+
|
|
187
|
+
Uniform local + remote: runs the upstream smart-install.sh in
|
|
188
|
+
``--check`` mode via ``host.run_command``. If the host already has the
|
|
189
|
+
latest opencode, returns the absolute path that ``command -v opencode``
|
|
190
|
+
resolves to. Otherwise — when ``install_if_missing`` is True — downloads
|
|
191
|
+
the release zip (as an optio child task, so progress shows up in the
|
|
192
|
+
UI), unpacks it, and installs the binary at
|
|
193
|
+
``<install_dir>/opencode``.
|
|
194
|
+
|
|
195
|
+
``install_dir`` is the absolute path of the directory that holds (or
|
|
196
|
+
will hold) the ``opencode`` binary on the host. When None (default),
|
|
197
|
+
resolves to ``<host_home>/.local/bin``. Pass an explicit absolute path
|
|
198
|
+
to opt out of the default — the same dir is used for installation, for
|
|
199
|
+
smart-install's PATH lookup, and for the post-"ok" ``command -v``
|
|
200
|
+
resolution, so all three stay in agreement.
|
|
201
|
+
|
|
202
|
+
Returns the absolute path of the opencode binary on the host.
|
|
203
|
+
|
|
204
|
+
Raises RuntimeError when the check is unparseable, when an install is
|
|
205
|
+
needed but ``install_if_missing`` is False, or when any sub-step fails.
|
|
206
|
+
"""
|
|
207
|
+
host = hook_ctx._host
|
|
208
|
+
resolved_install_dir = await _resolve_install_dir(host, install_dir)
|
|
209
|
+
# Mark the parent task indeterminate-active before any host I/O so the
|
|
210
|
+
# dashboard shows it working rather than stuck at 0% while the install
|
|
211
|
+
# check (and any subsequent download child task) runs.
|
|
212
|
+
hook_ctx.report_progress(None, "Checking opencode installation…")
|
|
213
|
+
kind, url = await _smart_install_check(host, install_dir=resolved_install_dir)
|
|
214
|
+
if kind == "ok":
|
|
215
|
+
# Resolve the on-PATH path. Login shell so ``$HOME``-relative
|
|
216
|
+
# additions from ``~/.profile`` apply (e.g. a manual install at
|
|
217
|
+
# some other location the user has added to PATH), and *also*
|
|
218
|
+
# prepend ``resolved_install_dir`` so our install location wins
|
|
219
|
+
# even when the login profile doesn't add it.
|
|
220
|
+
lookup_inner = _path_augmented(
|
|
221
|
+
"command -v opencode", resolved_install_dir,
|
|
222
|
+
)
|
|
223
|
+
r = await host.run_command(f"bash -lc {shlex.quote(lookup_inner)}")
|
|
224
|
+
if r.exit_code != 0:
|
|
225
|
+
raise RuntimeError(
|
|
226
|
+
"smart-install reported 'opencode ok' but command -v opencode "
|
|
227
|
+
f"failed on the host (exit {r.exit_code}): "
|
|
228
|
+
f"{r.stderr.strip()[:200]}"
|
|
229
|
+
)
|
|
230
|
+
return r.stdout.strip()
|
|
231
|
+
# kind == "download"
|
|
232
|
+
if not install_if_missing:
|
|
233
|
+
raise RuntimeError(
|
|
234
|
+
"opencode is missing or stale on the host and "
|
|
235
|
+
"install_if_missing=False was requested."
|
|
236
|
+
)
|
|
237
|
+
assert url is not None # _smart_install_check guarantees
|
|
238
|
+
hook_ctx.report_progress(None, "Installing opencode…")
|
|
239
|
+
return await _install_opencode_from_zip(
|
|
240
|
+
hook_ctx, url, install_dir=resolved_install_dir,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def opencode_version(
|
|
245
|
+
host: "Host", *, opencode_executable: str = "opencode",
|
|
246
|
+
) -> str | None:
|
|
247
|
+
"""Return ``<opencode_executable> --version`` stripped stdout, or None.
|
|
248
|
+
|
|
249
|
+
Best-effort — used only for status messages. Returns None on any
|
|
250
|
+
failure (exec error, non-zero exit, empty output).
|
|
251
|
+
"""
|
|
252
|
+
try:
|
|
253
|
+
result = await host.run_command(
|
|
254
|
+
f"bash -lc {shlex.quote(opencode_executable + ' --version')}",
|
|
255
|
+
)
|
|
256
|
+
except Exception:
|
|
257
|
+
return None
|
|
258
|
+
if result.exit_code != 0:
|
|
259
|
+
return None
|
|
260
|
+
text = (result.stdout or "").strip()
|
|
261
|
+
return text or None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
async def opencode_import(
|
|
265
|
+
host: "Host",
|
|
266
|
+
opencode_db_path: str,
|
|
267
|
+
session_json: bytes,
|
|
268
|
+
*, opencode_executable: str = "opencode",
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Import ``session_json`` into ``opencode_db_path`` on ``host``.
|
|
271
|
+
|
|
272
|
+
Stages the JSON to a scratch file (workdir/snapshot.json) via
|
|
273
|
+
``put_file_to_host``, runs ``<exec> import <scratch>`` with
|
|
274
|
+
``OPENCODE_DB`` set, then removes the scratch.
|
|
275
|
+
"""
|
|
276
|
+
scratch = f"{host.workdir}/snapshot.json"
|
|
277
|
+
await host.put_file_to_host(bytes(session_json), scratch)
|
|
278
|
+
try:
|
|
279
|
+
result = await host.run_command(
|
|
280
|
+
f"bash -lc {shlex.quote(opencode_executable + ' import ' + shlex.quote(scratch))}",
|
|
281
|
+
env={"OPENCODE_DB": opencode_db_path},
|
|
282
|
+
)
|
|
283
|
+
if result.exit_code != 0:
|
|
284
|
+
raise RuntimeError(
|
|
285
|
+
f"opencode import failed (exit {result.exit_code}): "
|
|
286
|
+
f"{result.stderr}"
|
|
287
|
+
)
|
|
288
|
+
finally:
|
|
289
|
+
await host.remove_file(scratch)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
async def opencode_export(
|
|
293
|
+
host: "Host",
|
|
294
|
+
opencode_db_path: str,
|
|
295
|
+
session_id: str,
|
|
296
|
+
*, opencode_executable: str = "opencode",
|
|
297
|
+
) -> bytes:
|
|
298
|
+
"""Export session ``session_id`` from ``opencode_db_path`` on ``host``.
|
|
299
|
+
|
|
300
|
+
Redirects ``<exec> export <id>`` to a scratch file in the workdir
|
|
301
|
+
then ``fetch_bytes_from_host`` returns the contents. The redirect
|
|
302
|
+
avoids a cancellation-truncation bug seen with stdout-via-asyncssh
|
|
303
|
+
captures (see RemoteHost.opencode_export's original comment): under
|
|
304
|
+
cancellation, partial recv-buffer bytes were being committed as a
|
|
305
|
+
snapshot. With the redirect, an aborted run either leaves no file
|
|
306
|
+
(we see exit_code != 0) or a complete one.
|
|
307
|
+
"""
|
|
308
|
+
scratch = f"{host.workdir}/.opencode-export.json"
|
|
309
|
+
try:
|
|
310
|
+
result = await host.run_command(
|
|
311
|
+
f"bash -lc "
|
|
312
|
+
f"{shlex.quote(opencode_executable + ' export ' + shlex.quote(session_id) + ' > ' + shlex.quote(scratch))}",
|
|
313
|
+
env={"OPENCODE_DB": opencode_db_path},
|
|
314
|
+
)
|
|
315
|
+
if result.exit_code != 0:
|
|
316
|
+
raise RuntimeError(
|
|
317
|
+
f"opencode export failed (exit {result.exit_code}): "
|
|
318
|
+
f"{result.stderr}"
|
|
319
|
+
)
|
|
320
|
+
return await host.fetch_bytes_from_host(scratch)
|
|
321
|
+
finally:
|
|
322
|
+
await host.remove_file(scratch)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
async def launch_opencode(
|
|
326
|
+
host: "Host",
|
|
327
|
+
password: str,
|
|
328
|
+
*,
|
|
329
|
+
ready_timeout_s: float = 30.0,
|
|
330
|
+
opencode_executable: str = "opencode",
|
|
331
|
+
) -> tuple[ProcessHandle, int]:
|
|
332
|
+
"""Launch ``opencode web`` on ``host``; wait for the listening URL.
|
|
333
|
+
|
|
334
|
+
Writes the password to ``<workdir>/.opencode-password`` (mode 600)
|
|
335
|
+
and references it via ``$(cat ...)`` in the launch command so the
|
|
336
|
+
literal value never appears on the remote process's argv.
|
|
337
|
+
|
|
338
|
+
Lays down no-op browser-opener stubs (xdg-open, gio, open,
|
|
339
|
+
sensible-browser) under ``<workdir>/bin`` and prepends that
|
|
340
|
+
directory to PATH so opencode's automatic browser-launch is
|
|
341
|
+
suppressed.
|
|
342
|
+
|
|
343
|
+
Returns ``(handle, opencode_port)``. Caller is responsible for
|
|
344
|
+
eventually terminating the handle via ``host.terminate_subprocess``.
|
|
345
|
+
"""
|
|
346
|
+
pw_file = ".opencode-password"
|
|
347
|
+
await host.write_text(pw_file, password)
|
|
348
|
+
await host.run_command(f"chmod 600 {host.workdir}/{pw_file}")
|
|
349
|
+
|
|
350
|
+
# Browser-suppression bin shadow.
|
|
351
|
+
for noop in ("xdg-open", "gio", "open", "sensible-browser"):
|
|
352
|
+
await host.write_text(f"bin/{noop}", "#!/bin/sh\nexit 0\n")
|
|
353
|
+
chmod_result = await host.run_command(f"chmod +x {host.workdir}/bin/*")
|
|
354
|
+
if chmod_result.exit_code != 0:
|
|
355
|
+
# Non-fatal: the noop scripts may fail to be executable, but worst
|
|
356
|
+
# case opencode tries to open a browser and we just live with it.
|
|
357
|
+
pass
|
|
358
|
+
|
|
359
|
+
# Build cmd: read password from file via $(cat), set BROWSER=true,
|
|
360
|
+
# cd to workdir so opencode picks up opencode.json.
|
|
361
|
+
#
|
|
362
|
+
# NOTE: do NOT wrap in `bash -lc` / `bash -l`. A login shell sources
|
|
363
|
+
# the user's profile (~/.profile, ~/.bash_profile, /etc/profile),
|
|
364
|
+
# which on most Linux installs rewrites PATH from scratch and
|
|
365
|
+
# therefore wipes the workdir/bin prefix we set in `env` below. With
|
|
366
|
+
# the prefix gone, the noop xdg-open / sensible-browser / gio / open
|
|
367
|
+
# shadows below stop hiding the real ones and opencode succeeds at
|
|
368
|
+
# opening a real browser window. opencode_executable is an absolute
|
|
369
|
+
# path (resolved by ensure_opencode_installed), so login-shell PATH
|
|
370
|
+
# lookup is not needed to find the binary. Let LocalHost / RemoteHost
|
|
371
|
+
# launch_subprocess do the shell wrapping; we just need the env-var
|
|
372
|
+
# prefix and $(cat ...) substitution, which any POSIX sh handles.
|
|
373
|
+
cmd = (
|
|
374
|
+
f"exec env "
|
|
375
|
+
f"OPENCODE_SERVER_PASSWORD=\"$(cat {shlex.quote(host.workdir + '/' + pw_file)})\" "
|
|
376
|
+
f"BROWSER=true "
|
|
377
|
+
f"{opencode_executable} web --port=0 --hostname=127.0.0.1"
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# Prepend the noop-browsers bin dir to PATH via env on launch_subprocess.
|
|
381
|
+
workdir_bin = f"{host.workdir}/bin"
|
|
382
|
+
extra_path = workdir_bin + ":" + os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")
|
|
383
|
+
# OPENCODE_DB must point at the same per-task db file used by the
|
|
384
|
+
# subsequent export/import CLI calls. Without this, the server falls
|
|
385
|
+
# back to opencode's global default db while export/import target the
|
|
386
|
+
# taskdir-local file — causing snapshot capture to "Session not found"
|
|
387
|
+
# against an empty file. Convention: opencode.db is a sibling of the
|
|
388
|
+
# workdir under taskdir (session.py: opencode_db = f"{taskdir}/opencode.db").
|
|
389
|
+
env = {
|
|
390
|
+
"PATH": extra_path,
|
|
391
|
+
"OPENCODE_DB": f"{host.taskdir}/opencode.db",
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
handle = await host.launch_subprocess(cmd, env=env, cwd=host.workdir)
|
|
395
|
+
|
|
396
|
+
async def _read_url() -> int:
|
|
397
|
+
async for raw in handle.stdout:
|
|
398
|
+
if isinstance(raw, bytes):
|
|
399
|
+
line = raw.decode("utf-8", errors="replace").rstrip()
|
|
400
|
+
else:
|
|
401
|
+
line = str(raw).rstrip()
|
|
402
|
+
m = _READY_RE.search(line)
|
|
403
|
+
if m:
|
|
404
|
+
m2 = re.search(r":(\d+)", m.group(1))
|
|
405
|
+
if not m2:
|
|
406
|
+
raise RuntimeError(f"could not find port in URL: {line}")
|
|
407
|
+
return int(m2.group(1))
|
|
408
|
+
raise RuntimeError("opencode exited before printing a URL")
|
|
409
|
+
|
|
410
|
+
try:
|
|
411
|
+
port = await asyncio.wait_for(_read_url(), timeout=ready_timeout_s)
|
|
412
|
+
except asyncio.TimeoutError:
|
|
413
|
+
await host.terminate_subprocess(handle, aggressive=True)
|
|
414
|
+
raise TimeoutError(
|
|
415
|
+
f"opencode did not print a listening URL within {ready_timeout_s}s"
|
|
416
|
+
)
|
|
417
|
+
except BaseException:
|
|
418
|
+
await host.terminate_subprocess(handle, aggressive=True)
|
|
419
|
+
raise
|
|
420
|
+
|
|
421
|
+
return handle, port
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
async def terminate_opencode(
|
|
425
|
+
host: "Host",
|
|
426
|
+
handle: ProcessHandle,
|
|
427
|
+
*,
|
|
428
|
+
aggressive: bool,
|
|
429
|
+
) -> None:
|
|
430
|
+
"""Thin wrapper over ``host.terminate_subprocess`` — kept for naming
|
|
431
|
+
symmetry with ``launch_opencode``."""
|
|
432
|
+
await host.terminate_subprocess(handle, aggressive=aggressive)
|