opencode-hermes-mcp 0.4.1__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_hermes_mcp/__init__.py +6 -0
- opencode_hermes_mcp/client.py +288 -0
- opencode_hermes_mcp/controller.py +896 -0
- opencode_hermes_mcp/installer.py +1012 -0
- opencode_hermes_mcp/models.py +234 -0
- opencode_hermes_mcp/pin.txt +1 -0
- opencode_hermes_mcp/server.py +259 -0
- opencode_hermes_mcp/smoke_client.py +87 -0
- opencode_hermes_mcp-0.4.1.dist-info/METADATA +289 -0
- opencode_hermes_mcp-0.4.1.dist-info/RECORD +14 -0
- opencode_hermes_mcp-0.4.1.dist-info/WHEEL +5 -0
- opencode_hermes_mcp-0.4.1.dist-info/entry_points.txt +2 -0
- opencode_hermes_mcp-0.4.1.dist-info/licenses/LICENSE +21 -0
- opencode_hermes_mcp-0.4.1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
"""Setup wizard for opencode-hermes-mcp (Python + rich rewrite of install.sh).
|
|
2
|
+
|
|
3
|
+
Entry point: python -m opencode_hermes_mcp.installer [flags]
|
|
4
|
+
|
|
5
|
+
Bootstrap (stdlib only, runs before ``rich`` is imported):
|
|
6
|
+
1. require python3 >= 3.11
|
|
7
|
+
2. locate the repo root (``OPENCODE_MCP_REPO`` env, else ``__file__``)
|
|
8
|
+
3. if not running inside ``<repo>/.venv``: create the venv, install
|
|
9
|
+
``mcp==1.12.4`` + ``rich>=13`` + ``pyyaml>=6`` + the package (editable),
|
|
10
|
+
then re-exec this module with the venv interpreter
|
|
11
|
+
4. if the venv exists but deps are missing: install them, re-exec
|
|
12
|
+
|
|
13
|
+
After the bootstrap the wizard runs with rich: banner, numbered steps,
|
|
14
|
+
styled prompts, progress, and a summary panel. The generated files
|
|
15
|
+
(opencode.json, credentials, launchers, systemd unit, Hermes config patch)
|
|
16
|
+
are byte-identical to what the former bash installer produced.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import secrets
|
|
26
|
+
import shlex
|
|
27
|
+
import shutil
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import dataclass, field
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import NoReturn
|
|
34
|
+
|
|
35
|
+
# Fallback only — the single source of truth is opencode_hermes_mcp/pin.txt
|
|
36
|
+
# (see pinned_version()). Needed for pip installs where pin.txt is not
|
|
37
|
+
# shipped next to the code.
|
|
38
|
+
OPENCODE_VERSION = "1.18.21"
|
|
39
|
+
MCP_VERSION = "1.12.4"
|
|
40
|
+
BOOTSTRAP_DEPS = ["mcp==1.12.4", "rich>=13", "pyyaml>=6"]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def pinned_version() -> str:
|
|
44
|
+
"""Return the pinned OpenCode version (single source of truth).
|
|
45
|
+
|
|
46
|
+
Reads the first line of ``opencode_hermes_mcp/pin.txt`` (stripped).
|
|
47
|
+
Falls back to ``OPENCODE_VERSION`` when the file is missing or empty.
|
|
48
|
+
"""
|
|
49
|
+
pin_file = Path(__file__).parent / "pin.txt"
|
|
50
|
+
try:
|
|
51
|
+
lines = pin_file.read_text(encoding="utf-8").splitlines()
|
|
52
|
+
except OSError:
|
|
53
|
+
return OPENCODE_VERSION
|
|
54
|
+
return (lines[0].strip() if lines else "") or OPENCODE_VERSION
|
|
55
|
+
|
|
56
|
+
DEFAULT_PROVIDER = "openai-compatible"
|
|
57
|
+
DEFAULT_BASE_URL = "http://127.0.0.1:11434/v1"
|
|
58
|
+
DEFAULT_LLM_SPEED = "fast"
|
|
59
|
+
DEFAULT_CONTEXT_LIMIT = 128000
|
|
60
|
+
DEFAULT_OUTPUT_LIMIT = 32000
|
|
61
|
+
|
|
62
|
+
PROVIDER_NPM = {
|
|
63
|
+
"openai-compatible": "@ai-sdk/openai-compatible",
|
|
64
|
+
"openai": "@ai-sdk/openai",
|
|
65
|
+
"anthropic": "@ai-sdk/anthropic",
|
|
66
|
+
}
|
|
67
|
+
PROVIDER_DISPLAY = {
|
|
68
|
+
"openai": "OpenAI",
|
|
69
|
+
"anthropic": "Anthropic",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
MCP_LAUNCHER_TEMPLATE = """\
|
|
73
|
+
#!/usr/bin/env bash
|
|
74
|
+
# Launcher for the opencode-hermes-mcp controller (MCP stdio server).
|
|
75
|
+
# Hermes spawns this as the mcp_servers.opencode.command. It reads the fixed
|
|
76
|
+
# OpenCode server credentials from the secret file and execs the controller in
|
|
77
|
+
# its dedicated venv. Keeping the secret here (not in config.yaml) means
|
|
78
|
+
# config.yaml stays secret-free.
|
|
79
|
+
set -euo pipefail
|
|
80
|
+
|
|
81
|
+
CFG="${OPENCODE_SERVER_CREDENTIALS:-$HOME/.config/hermes/opencode-server.json}"
|
|
82
|
+
VENV_PY="__REPO__/.venv/bin/python"
|
|
83
|
+
|
|
84
|
+
# Read url/username/password from the JSON secret file.
|
|
85
|
+
eval "$(python3 - "$CFG" <<'PY'
|
|
86
|
+
import json, os, shlex, sys
|
|
87
|
+
cfg = json.load(open(sys.argv[1]))
|
|
88
|
+
print(f"export OPENCODE_SERVER_URL={shlex.quote(cfg.get('base_url','http://127.0.0.1:4096'))}")
|
|
89
|
+
print(f"export OPENCODE_SERVER_USERNAME={shlex.quote(cfg.get('username','opencode'))}")
|
|
90
|
+
pw = cfg.get('password','')
|
|
91
|
+
# Build the env var name by concatenation so the literal never appears here.
|
|
92
|
+
print(f"export OPENCODE_SERVER_{'PASS'+'WORD'}={shlex.quote(pw)}")
|
|
93
|
+
PY
|
|
94
|
+
)"
|
|
95
|
+
|
|
96
|
+
exec "$VENV_PY" -m opencode_hermes_mcp.server
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
SERVER_LAUNCHER_TEMPLATE = """\
|
|
100
|
+
#!/usr/bin/env bash
|
|
101
|
+
# Launcher for the permanent OpenCode server (systemd user service).
|
|
102
|
+
# Reads fixed credentials from ~/.config/hermes/opencode-server.json and
|
|
103
|
+
# execs `opencode serve` on the configured port.
|
|
104
|
+
set -euo pipefail
|
|
105
|
+
|
|
106
|
+
CFG="${OPENCODE_SERVER_CONFIG:-$HOME/.config/hermes/opencode-server.json}"
|
|
107
|
+
BIN="${OPENCODE_BIN:-$HOME/.opencode/bin/opencode}"
|
|
108
|
+
|
|
109
|
+
eval "$(python3 - "$CFG" <<'PY'
|
|
110
|
+
import json, shlex, sys
|
|
111
|
+
cfg = json.load(open(sys.argv[1]))
|
|
112
|
+
print(f"export OPENCODE_SERVER_USERNAME={shlex.quote(cfg['username'])}")
|
|
113
|
+
print(f"export OPENCODE_SERVER_PASSWORD={shlex.quote(cfg['password'])}")
|
|
114
|
+
print(f"export OPENCODE_PORT={shlex.quote(str(cfg.get('port', 4096)))}")
|
|
115
|
+
PY
|
|
116
|
+
)"
|
|
117
|
+
|
|
118
|
+
cd "$HOME"
|
|
119
|
+
exec "$BIN" serve --hostname 127.0.0.1 --port "$OPENCODE_PORT"
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@dataclass
|
|
124
|
+
class State:
|
|
125
|
+
repo: Path
|
|
126
|
+
home: Path
|
|
127
|
+
sandbox: bool
|
|
128
|
+
dry_run: bool
|
|
129
|
+
assume_yes: bool
|
|
130
|
+
port: int
|
|
131
|
+
skip_binary: bool
|
|
132
|
+
force_config: bool
|
|
133
|
+
skip_verify: bool
|
|
134
|
+
installed: list[str] = field(default_factory=list)
|
|
135
|
+
already: list[str] = field(default_factory=list)
|
|
136
|
+
skipped: list[str] = field(default_factory=list)
|
|
137
|
+
console: object = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class Llm:
|
|
142
|
+
provider: str
|
|
143
|
+
base_url: str
|
|
144
|
+
api_key: str
|
|
145
|
+
model: str
|
|
146
|
+
speed: str
|
|
147
|
+
context_limit: int
|
|
148
|
+
output_limit: int
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _dist_version(name: str) -> str | None:
|
|
152
|
+
try:
|
|
153
|
+
from importlib.metadata import version
|
|
154
|
+
|
|
155
|
+
return version(name)
|
|
156
|
+
except Exception:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _pip_install(venv_py: str, args: list[str]) -> None:
|
|
161
|
+
sys.stdout.flush()
|
|
162
|
+
if shutil.which("uv"):
|
|
163
|
+
subprocess.run(["uv", "pip", "install", "--python", venv_py, *args], check=True)
|
|
164
|
+
else:
|
|
165
|
+
subprocess.run([venv_py, "-m", "pip", "install", *args], check=True)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _reexec(venv_py: str) -> None:
|
|
169
|
+
sys.stdout.flush()
|
|
170
|
+
sys.stderr.flush()
|
|
171
|
+
os.execv(venv_py, [venv_py, "-m", "opencode_hermes_mcp.installer", *sys.argv[1:]])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _missing_deps_current(repo: Path) -> list[str]:
|
|
175
|
+
missing: list[str] = []
|
|
176
|
+
if _dist_version("mcp") != MCP_VERSION:
|
|
177
|
+
missing.append(f"mcp=={MCP_VERSION}")
|
|
178
|
+
if _dist_version("rich") is None:
|
|
179
|
+
missing.append("rich>=13")
|
|
180
|
+
if _dist_version("pyyaml") is None:
|
|
181
|
+
missing.append("pyyaml>=6")
|
|
182
|
+
if _dist_version("opencode-hermes-mcp") is None:
|
|
183
|
+
missing.extend(["-e", str(repo)])
|
|
184
|
+
return missing
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _venv_missing(venv_py: str, repo: Path) -> list[str]:
|
|
188
|
+
probe = (
|
|
189
|
+
"import importlib.metadata as m\n"
|
|
190
|
+
"def v(n):\n"
|
|
191
|
+
" try:\n"
|
|
192
|
+
" return m.version(n)\n"
|
|
193
|
+
" except Exception:\n"
|
|
194
|
+
" return ''\n"
|
|
195
|
+
"print(' '.join(v(n) for n in ('mcp', 'rich', 'pyyaml', 'opencode-hermes-mcp')))\n"
|
|
196
|
+
)
|
|
197
|
+
try:
|
|
198
|
+
out = subprocess.run(
|
|
199
|
+
[str(venv_py), "-c", probe], capture_output=True, text=True, check=True
|
|
200
|
+
).stdout.split()
|
|
201
|
+
except (subprocess.CalledProcessError, OSError):
|
|
202
|
+
return [*BOOTSTRAP_DEPS, "-e", str(repo)]
|
|
203
|
+
mcp_v, rich_v, yaml_v, pkg_v = (out + ["", "", "", ""])[:4]
|
|
204
|
+
missing: list[str] = []
|
|
205
|
+
if mcp_v != MCP_VERSION:
|
|
206
|
+
missing.append(f"mcp=={MCP_VERSION}")
|
|
207
|
+
if not rich_v:
|
|
208
|
+
missing.append("rich>=13")
|
|
209
|
+
if not yaml_v:
|
|
210
|
+
missing.append("pyyaml>=6")
|
|
211
|
+
if not pkg_v:
|
|
212
|
+
missing.extend(["-e", str(repo)])
|
|
213
|
+
return missing
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def bootstrap() -> None:
|
|
217
|
+
"""Stdlib-only bootstrap: ensure we run inside <repo>/.venv with all deps."""
|
|
218
|
+
if sys.version_info < (3, 11):
|
|
219
|
+
print(
|
|
220
|
+
f"[bootstrap] python3 >= 3.11 required (found {sys.version.split()[0]})",
|
|
221
|
+
file=sys.stderr,
|
|
222
|
+
)
|
|
223
|
+
sys.exit(1)
|
|
224
|
+
repo_env = os.environ.get("OPENCODE_MCP_REPO")
|
|
225
|
+
repo = Path(repo_env).resolve() if repo_env else Path(__file__).resolve().parent.parent
|
|
226
|
+
venv_dir = repo / ".venv"
|
|
227
|
+
venv_py = venv_dir / "bin" / "python"
|
|
228
|
+
in_venv = os.path.realpath(sys.prefix) == os.path.realpath(str(venv_dir))
|
|
229
|
+
if not in_venv:
|
|
230
|
+
if venv_py.is_file():
|
|
231
|
+
missing = _venv_missing(str(venv_py), repo)
|
|
232
|
+
if not missing:
|
|
233
|
+
_reexec(str(venv_py))
|
|
234
|
+
print(
|
|
235
|
+
f"[bootstrap] venv at {venv_dir} incomplete — "
|
|
236
|
+
f"installing: {' '.join(missing)} ..."
|
|
237
|
+
)
|
|
238
|
+
try:
|
|
239
|
+
_pip_install(str(venv_py), missing)
|
|
240
|
+
except subprocess.CalledProcessError as e:
|
|
241
|
+
print(
|
|
242
|
+
f"[bootstrap] failed (exit {e.returncode}) — see output above",
|
|
243
|
+
file=sys.stderr,
|
|
244
|
+
)
|
|
245
|
+
sys.exit(1)
|
|
246
|
+
_reexec(str(venv_py))
|
|
247
|
+
print(
|
|
248
|
+
f"[bootstrap] creating venv at {venv_dir} "
|
|
249
|
+
f"(mcp=={MCP_VERSION}, rich, pyyaml, editable package) ..."
|
|
250
|
+
)
|
|
251
|
+
try:
|
|
252
|
+
subprocess.run([sys.executable, "-m", "venv", str(venv_dir)], check=True)
|
|
253
|
+
_pip_install(str(venv_py), [*BOOTSTRAP_DEPS, "-e", str(repo)])
|
|
254
|
+
except subprocess.CalledProcessError as e:
|
|
255
|
+
print(f"[bootstrap] failed (exit {e.returncode}) — see output above", file=sys.stderr)
|
|
256
|
+
sys.exit(1)
|
|
257
|
+
_reexec(str(venv_py))
|
|
258
|
+
missing = _missing_deps_current(repo)
|
|
259
|
+
if missing:
|
|
260
|
+
print(f"[bootstrap] venv incomplete — installing: {' '.join(missing)} ...")
|
|
261
|
+
try:
|
|
262
|
+
_pip_install(str(venv_py), missing)
|
|
263
|
+
except subprocess.CalledProcessError as e:
|
|
264
|
+
print(f"[bootstrap] failed (exit {e.returncode}) — see output above", file=sys.stderr)
|
|
265
|
+
sys.exit(1)
|
|
266
|
+
_reexec(str(venv_py))
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _port_type(value: str) -> int:
|
|
270
|
+
if not re.fullmatch(r"[0-9]+", value):
|
|
271
|
+
raise argparse.ArgumentTypeError(f"must be a number (got: {value})")
|
|
272
|
+
return int(value)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
EPILOG = """\
|
|
276
|
+
Providers (menu in interactive mode, OPENCODE_PROVIDER with --yes):
|
|
277
|
+
openai-compatible custom OpenAI-compatible endpoint (Unsloth, Ollama,
|
|
278
|
+
vLLM, llama-server, ...) — default
|
|
279
|
+
openai official OpenAI API
|
|
280
|
+
anthropic official Anthropic API
|
|
281
|
+
|
|
282
|
+
Env:
|
|
283
|
+
OPENCODE_PROVIDER provider id (default openai-compatible)
|
|
284
|
+
OPENCODE_LLM_BASE_URL LLM base URL (openai-compatible only,
|
|
285
|
+
default http://127.0.0.1:11434/v1)
|
|
286
|
+
OPENCODE_API_KEY LLM API key (UNSLOTH_API_KEY accepted as a
|
|
287
|
+
deprecated fallback)
|
|
288
|
+
OPENCODE_LLM_MODEL model id (required)
|
|
289
|
+
OPENCODE_LLM_SPEED slow (local LLM) | fast (default fast)
|
|
290
|
+
OPENCODE_CONTEXT_LIMIT model context limit (default 128000)
|
|
291
|
+
OPENCODE_OUTPUT_LIMIT model output limit (default 32000)
|
|
292
|
+
OPENCODE_MCP_HOME redirect all targets to a tmpdir (testing)
|
|
293
|
+
"""
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def parse_args(argv: list[str]) -> argparse.Namespace:
|
|
297
|
+
parser = argparse.ArgumentParser(
|
|
298
|
+
prog="opencode_hermes_mcp.installer",
|
|
299
|
+
description=(
|
|
300
|
+
"Idempotent setup wizard for opencode-hermes-mcp: pinned OpenCode binary, "
|
|
301
|
+
f"venv (mcp=={MCP_VERSION}), OpenCode provider config + secret, server "
|
|
302
|
+
"credentials, launchers, systemd user service, Hermes config patch, then "
|
|
303
|
+
"server health + smoke verification. Safe to re-run: every step skips what "
|
|
304
|
+
"is already in place."
|
|
305
|
+
),
|
|
306
|
+
epilog=EPILOG,
|
|
307
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
308
|
+
)
|
|
309
|
+
parser.add_argument(
|
|
310
|
+
"--yes", action="store_true", help="non-interactive: use env vars / defaults, no prompts"
|
|
311
|
+
)
|
|
312
|
+
parser.add_argument(
|
|
313
|
+
"--port",
|
|
314
|
+
type=_port_type,
|
|
315
|
+
default=4096,
|
|
316
|
+
metavar="N",
|
|
317
|
+
help="OpenCode server port (default 4096)",
|
|
318
|
+
)
|
|
319
|
+
parser.add_argument(
|
|
320
|
+
"--skip-binary", action="store_true", help="do not install/check the OpenCode binary"
|
|
321
|
+
)
|
|
322
|
+
parser.add_argument(
|
|
323
|
+
"--force-config", action="store_true", help="overwrite an existing OpenCode config + secret"
|
|
324
|
+
)
|
|
325
|
+
parser.add_argument("--dry-run", action="store_true", help="print actions without executing")
|
|
326
|
+
parser.add_argument(
|
|
327
|
+
"--skip-verify",
|
|
328
|
+
action="store_true",
|
|
329
|
+
help="skip the final health + smoke verification (useful for sandbox/CI)",
|
|
330
|
+
)
|
|
331
|
+
return parser.parse_args(argv)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def die(state: State, msg: str) -> NoReturn:
|
|
335
|
+
state.console.print(f"[bold red]✗ {msg}[/bold red]")
|
|
336
|
+
raise SystemExit(1)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def warn(state: State, msg: str) -> None:
|
|
340
|
+
state.console.print(f"[yellow]! {msg}[/yellow]")
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def step_header(state: State, n: int, title: str) -> None:
|
|
344
|
+
state.console.rule(f"[bold cyan]{n}/9 · {title}[/bold cyan]")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def write_file(state: State, path: Path, content: str, mode: int | None = None) -> None:
|
|
348
|
+
if state.dry_run:
|
|
349
|
+
state.console.print(f"[dim]would write {path} ({len(content.encode())} bytes)[/dim]")
|
|
350
|
+
return
|
|
351
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
352
|
+
path.write_text(content)
|
|
353
|
+
if mode is not None:
|
|
354
|
+
os.chmod(path, mode)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _run_checked(state: State, cmd: list[str]) -> None:
|
|
358
|
+
if state.dry_run:
|
|
359
|
+
state.console.print(f"[dim]would run: {shlex.join(cmd)}[/dim]")
|
|
360
|
+
return
|
|
361
|
+
try:
|
|
362
|
+
subprocess.run(cmd, check=True)
|
|
363
|
+
except subprocess.CalledProcessError as e:
|
|
364
|
+
die(state, f"command failed (exit {e.returncode}): {shlex.join(cmd)}")
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _run_streaming(state: State, cmd: list[str], description: str) -> str:
|
|
368
|
+
from rich.console import Group
|
|
369
|
+
from rich.live import Live
|
|
370
|
+
from rich.spinner import Spinner
|
|
371
|
+
from rich.text import Text
|
|
372
|
+
|
|
373
|
+
lines: list[str] = []
|
|
374
|
+
with Live(
|
|
375
|
+
Group(Spinner("dots", style="cyan"), Text(description, style="cyan")),
|
|
376
|
+
console=state.console,
|
|
377
|
+
refresh_per_second=8,
|
|
378
|
+
) as live:
|
|
379
|
+
proc = subprocess.Popen(
|
|
380
|
+
cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1
|
|
381
|
+
)
|
|
382
|
+
if proc.stdout is None:
|
|
383
|
+
die(state, "internal error: no stdout pipe")
|
|
384
|
+
for raw in proc.stdout:
|
|
385
|
+
line = raw.rstrip("\n")
|
|
386
|
+
lines.append(line)
|
|
387
|
+
tail = "\n".join(lines[-8:])
|
|
388
|
+
live.update(
|
|
389
|
+
Group(
|
|
390
|
+
Spinner("dots", style="cyan"),
|
|
391
|
+
Text(description, style="cyan"),
|
|
392
|
+
Text(tail, style="dim"),
|
|
393
|
+
)
|
|
394
|
+
)
|
|
395
|
+
proc.wait()
|
|
396
|
+
if proc.returncode != 0:
|
|
397
|
+
if lines:
|
|
398
|
+
state.console.print("\n".join(lines), style="red")
|
|
399
|
+
die(state, f"command failed (exit {proc.returncode}): {shlex.join(cmd)}")
|
|
400
|
+
if lines:
|
|
401
|
+
state.console.print("\n".join(lines))
|
|
402
|
+
return "\n".join(lines)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _resolve_api_key(state: State) -> str:
|
|
406
|
+
from rich.prompt import Prompt
|
|
407
|
+
|
|
408
|
+
env_key = os.environ.get("OPENCODE_API_KEY", "")
|
|
409
|
+
deprecated = os.environ.get("UNSLOTH_API_KEY", "")
|
|
410
|
+
if state.assume_yes:
|
|
411
|
+
api_key = env_key
|
|
412
|
+
else:
|
|
413
|
+
if env_key:
|
|
414
|
+
ans = Prompt.ask(
|
|
415
|
+
"API key [env OPENCODE_API_KEY set — Enter to use it]", password=True, default=""
|
|
416
|
+
)
|
|
417
|
+
elif deprecated:
|
|
418
|
+
ans = Prompt.ask(
|
|
419
|
+
"API key [env UNSLOTH_API_KEY set (deprecated) — Enter to use it]",
|
|
420
|
+
password=True,
|
|
421
|
+
default="",
|
|
422
|
+
)
|
|
423
|
+
else:
|
|
424
|
+
ans = Prompt.ask("API key", password=True)
|
|
425
|
+
api_key = ans or env_key
|
|
426
|
+
if not api_key and deprecated:
|
|
427
|
+
warn(state, "UNSLOTH_API_KEY is deprecated — using it as OPENCODE_API_KEY (please migrate)")
|
|
428
|
+
api_key = deprecated
|
|
429
|
+
if not api_key:
|
|
430
|
+
if state.assume_yes:
|
|
431
|
+
die(state, "--yes: no API key — set OPENCODE_API_KEY (or drop --yes to be prompted)")
|
|
432
|
+
die(state, "API key required (prompt or OPENCODE_API_KEY)")
|
|
433
|
+
return api_key
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _env_int(state: State, name: str, default: int, errfmt: str) -> int:
|
|
437
|
+
raw = os.environ.get(name) or str(default)
|
|
438
|
+
if not re.fullmatch(r"[0-9]+", raw):
|
|
439
|
+
die(state, errfmt % raw)
|
|
440
|
+
return int(raw)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _prompt_int(state: State, label: str, env_name: str, default: int) -> int:
|
|
444
|
+
from rich.prompt import Prompt
|
|
445
|
+
|
|
446
|
+
raw = Prompt.ask(label, default=os.environ.get(env_name) or str(default))
|
|
447
|
+
if not re.fullmatch(r"[0-9]+", raw):
|
|
448
|
+
die(state, f"{label.lower()} must be a number (got: {raw})")
|
|
449
|
+
return int(raw)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def step1_llm(state: State) -> Llm:
|
|
453
|
+
from rich.panel import Panel
|
|
454
|
+
from rich.prompt import Prompt
|
|
455
|
+
|
|
456
|
+
env = os.environ
|
|
457
|
+
if state.assume_yes:
|
|
458
|
+
provider = env.get("OPENCODE_PROVIDER", DEFAULT_PROVIDER)
|
|
459
|
+
if provider not in PROVIDER_NPM:
|
|
460
|
+
die(
|
|
461
|
+
state,
|
|
462
|
+
f"--yes: unknown OPENCODE_PROVIDER '{provider}' "
|
|
463
|
+
"(expected openai-compatible, openai, or anthropic)",
|
|
464
|
+
)
|
|
465
|
+
base_url = (
|
|
466
|
+
env.get("OPENCODE_LLM_BASE_URL", DEFAULT_BASE_URL)
|
|
467
|
+
if provider == "openai-compatible"
|
|
468
|
+
else ""
|
|
469
|
+
)
|
|
470
|
+
model = env.get("OPENCODE_LLM_MODEL", "")
|
|
471
|
+
if not model:
|
|
472
|
+
die(state, "--yes: no model — set OPENCODE_LLM_MODEL (or drop --yes to be prompted)")
|
|
473
|
+
api_key = _resolve_api_key(state)
|
|
474
|
+
speed = env.get("OPENCODE_LLM_SPEED", DEFAULT_LLM_SPEED)
|
|
475
|
+
if speed not in ("slow", "fast"):
|
|
476
|
+
die(state, f"--yes: invalid OPENCODE_LLM_SPEED '{speed}' (expected slow or fast)")
|
|
477
|
+
context_limit = _env_int(
|
|
478
|
+
state, "OPENCODE_CONTEXT_LIMIT", DEFAULT_CONTEXT_LIMIT,
|
|
479
|
+
"--yes: OPENCODE_CONTEXT_LIMIT must be a number (got: %s)",
|
|
480
|
+
)
|
|
481
|
+
output_limit = _env_int(
|
|
482
|
+
state, "OPENCODE_OUTPUT_LIMIT", DEFAULT_OUTPUT_LIMIT,
|
|
483
|
+
"--yes: OPENCODE_OUTPUT_LIMIT must be a number (got: %s)",
|
|
484
|
+
)
|
|
485
|
+
else:
|
|
486
|
+
env_provider = env.get("OPENCODE_PROVIDER", "")
|
|
487
|
+
if env_provider in PROVIDER_NPM:
|
|
488
|
+
default_provider = env_provider
|
|
489
|
+
elif env_provider:
|
|
490
|
+
warn(
|
|
491
|
+
state,
|
|
492
|
+
f"unknown OPENCODE_PROVIDER '{env_provider}' — ignoring "
|
|
493
|
+
"(expected openai-compatible, openai, or anthropic)",
|
|
494
|
+
)
|
|
495
|
+
default_provider = DEFAULT_PROVIDER
|
|
496
|
+
else:
|
|
497
|
+
default_provider = DEFAULT_PROVIDER
|
|
498
|
+
state.console.print(
|
|
499
|
+
Panel(
|
|
500
|
+
"1) openai-compatible custom OpenAI-compatible endpoint "
|
|
501
|
+
"(Unsloth, Ollama, vLLM, llama-server, ...)\n"
|
|
502
|
+
"2) openai official OpenAI API\n"
|
|
503
|
+
"3) anthropic official Anthropic API",
|
|
504
|
+
title="Choose an LLM provider",
|
|
505
|
+
)
|
|
506
|
+
)
|
|
507
|
+
provider = Prompt.ask(
|
|
508
|
+
"Provider", choices=list(PROVIDER_NPM), default=default_provider, case_sensitive=False
|
|
509
|
+
)
|
|
510
|
+
if provider == "openai-compatible":
|
|
511
|
+
base_url = Prompt.ask(
|
|
512
|
+
"LLM base URL", default=env.get("OPENCODE_LLM_BASE_URL") or DEFAULT_BASE_URL
|
|
513
|
+
)
|
|
514
|
+
else:
|
|
515
|
+
base_url = ""
|
|
516
|
+
api_key = _resolve_api_key(state)
|
|
517
|
+
env_model = env.get("OPENCODE_LLM_MODEL", "")
|
|
518
|
+
if env_model:
|
|
519
|
+
model = Prompt.ask(
|
|
520
|
+
f"Model id [env OPENCODE_LLM_MODEL={env_model} — Enter to use it]",
|
|
521
|
+
default=env_model,
|
|
522
|
+
)
|
|
523
|
+
else:
|
|
524
|
+
model = Prompt.ask("Model id (required, e.g. qwen3.8-27b or gpt-4o)")
|
|
525
|
+
if not model:
|
|
526
|
+
die(state, "model id required (prompt or OPENCODE_LLM_MODEL)")
|
|
527
|
+
speed = Prompt.ask(
|
|
528
|
+
"LLM speed — slow (local LLM) or fast (cloud API)?",
|
|
529
|
+
choices=["slow", "fast"],
|
|
530
|
+
default=DEFAULT_LLM_SPEED,
|
|
531
|
+
case_sensitive=False,
|
|
532
|
+
)
|
|
533
|
+
context_limit = _prompt_int(
|
|
534
|
+
state, "Context limit", "OPENCODE_CONTEXT_LIMIT", DEFAULT_CONTEXT_LIMIT
|
|
535
|
+
)
|
|
536
|
+
output_limit = _prompt_int(
|
|
537
|
+
state, "Output limit", "OPENCODE_OUTPUT_LIMIT", DEFAULT_OUTPUT_LIMIT
|
|
538
|
+
)
|
|
539
|
+
llm = Llm(
|
|
540
|
+
provider=provider,
|
|
541
|
+
base_url=base_url,
|
|
542
|
+
api_key=api_key,
|
|
543
|
+
model=model,
|
|
544
|
+
speed=speed,
|
|
545
|
+
context_limit=context_limit,
|
|
546
|
+
output_limit=output_limit,
|
|
547
|
+
)
|
|
548
|
+
base = f" base_url={base_url}" if base_url else ""
|
|
549
|
+
state.console.print(
|
|
550
|
+
f"[green]LLM:[/green] provider={provider} model={model} speed={speed}{base}"
|
|
551
|
+
)
|
|
552
|
+
return llm
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def step2_binary(state: State) -> None:
|
|
556
|
+
version = pinned_version()
|
|
557
|
+
oc_bin = Path.home() / ".opencode" / "bin" / "opencode"
|
|
558
|
+
if state.skip_binary:
|
|
559
|
+
state.console.print("[dim]binary: skipped (--skip-binary)[/dim]")
|
|
560
|
+
state.skipped.append("opencode binary (--skip-binary)")
|
|
561
|
+
return
|
|
562
|
+
if state.sandbox:
|
|
563
|
+
state.console.print("[dim]binary: skipped (sandbox mode — machine-level tool)[/dim]")
|
|
564
|
+
state.skipped.append("opencode binary (sandbox mode)")
|
|
565
|
+
return
|
|
566
|
+
if oc_bin.is_file() and os.access(oc_bin, os.X_OK):
|
|
567
|
+
try:
|
|
568
|
+
ver = subprocess.run(
|
|
569
|
+
[str(oc_bin), "--version"], capture_output=True, text=True
|
|
570
|
+
).stdout.strip()
|
|
571
|
+
except OSError:
|
|
572
|
+
ver = ""
|
|
573
|
+
if ver == version:
|
|
574
|
+
state.console.print(f"[dim]binary: already {version} — skipping[/dim]")
|
|
575
|
+
state.already.append(f"opencode binary (already {version})")
|
|
576
|
+
return
|
|
577
|
+
if shutil.which("curl") is None:
|
|
578
|
+
die(state, "curl is required to install the OpenCode binary")
|
|
579
|
+
state.console.print(
|
|
580
|
+
f"[green]binary:[/green] installing OpenCode {version} "
|
|
581
|
+
"(pinned) via official script"
|
|
582
|
+
)
|
|
583
|
+
cmd = f"curl -fsSL https://opencode.ai/install | bash -s -- --version {version}"
|
|
584
|
+
if state.dry_run:
|
|
585
|
+
state.console.print(f"[dim]would run: bash -c {shlex.quote(cmd)}[/dim]")
|
|
586
|
+
else:
|
|
587
|
+
_run_streaming(state, ["bash", "-c", cmd], f"installing OpenCode {version}")
|
|
588
|
+
state.installed.append(f"opencode binary {version} (pinned)")
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def step3_venv(state: State) -> None:
|
|
592
|
+
venv_dir = state.repo / ".venv"
|
|
593
|
+
venv_py = venv_dir / "bin" / "python"
|
|
594
|
+
if not venv_py.is_file():
|
|
595
|
+
die(state, f"venv missing at {venv_py} (the bootstrap should have created it)")
|
|
596
|
+
state.console.print(
|
|
597
|
+
f"[dim]venv: reusing {venv_dir} (wizard runs in it: {sys.executable})[/dim]"
|
|
598
|
+
)
|
|
599
|
+
state.already.append("venv (already present)")
|
|
600
|
+
mcp_ver = _dist_version("mcp")
|
|
601
|
+
if mcp_ver == MCP_VERSION:
|
|
602
|
+
state.console.print(f"[dim]deps: mcp=={MCP_VERSION} already installed[/dim]")
|
|
603
|
+
state.already.append(f"mcp dependency (already {MCP_VERSION})")
|
|
604
|
+
else:
|
|
605
|
+
die(state, f"mcp version mismatch in venv (found {mcp_ver}, expected {MCP_VERSION})")
|
|
606
|
+
pkg_ver = _dist_version("opencode-hermes-mcp")
|
|
607
|
+
if pkg_ver:
|
|
608
|
+
state.console.print(
|
|
609
|
+
f"[dim]deps: opencode-hermes-mcp {pkg_ver} already installed (editable)[/dim]"
|
|
610
|
+
)
|
|
611
|
+
state.already.append("opencode-hermes-mcp package (already installed)")
|
|
612
|
+
else:
|
|
613
|
+
die(state, "opencode-hermes-mcp not installed in the venv (bootstrap should have done it)")
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _model_ref_key(provider: str, model: str) -> tuple[str, str]:
|
|
617
|
+
prefix = provider + "/"
|
|
618
|
+
if model.startswith(prefix):
|
|
619
|
+
return model, model[len(prefix) :]
|
|
620
|
+
return f"{prefix}{model}", model
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def build_opencode_config(llm: Llm) -> str:
|
|
624
|
+
model_ref, model_key = _model_ref_key(llm.provider, llm.model)
|
|
625
|
+
display = PROVIDER_DISPLAY.get(llm.provider) or f"OpenAI-compatible — {llm.base_url}"
|
|
626
|
+
options: list[str] = []
|
|
627
|
+
if llm.provider == "openai-compatible":
|
|
628
|
+
options.append(f' "baseURL": "{llm.base_url}",')
|
|
629
|
+
if llm.speed == "slow":
|
|
630
|
+
options.append(' "apiKey": "{file:secrets/api-key}",')
|
|
631
|
+
options.append(' "timeout": false,')
|
|
632
|
+
options.append(' "headerTimeout": false,')
|
|
633
|
+
options.append(' "chunkTimeout": 120000')
|
|
634
|
+
else:
|
|
635
|
+
options.append(' "apiKey": "{file:secrets/api-key}"')
|
|
636
|
+
options_block = "\n".join(options)
|
|
637
|
+
return (
|
|
638
|
+
"{\n"
|
|
639
|
+
' "$schema": "https://opencode.ai/config.json",\n'
|
|
640
|
+
f' "model": "{model_ref}",\n'
|
|
641
|
+
' "provider": {\n'
|
|
642
|
+
f' "{llm.provider}": {{\n'
|
|
643
|
+
f' "npm": "{PROVIDER_NPM[llm.provider]}",\n'
|
|
644
|
+
f' "name": "{display}",\n'
|
|
645
|
+
' "options": {\n'
|
|
646
|
+
f"{options_block}\n"
|
|
647
|
+
' },\n'
|
|
648
|
+
' "models": {\n'
|
|
649
|
+
f' "{model_key}": {{\n'
|
|
650
|
+
f' "name": "{model_key}",\n'
|
|
651
|
+
' "reasoning": true,\n'
|
|
652
|
+
' "tool_call": true,\n'
|
|
653
|
+
' "temperature": true,\n'
|
|
654
|
+
' "limit": {\n'
|
|
655
|
+
f' "context": {llm.context_limit},\n'
|
|
656
|
+
f' "output": {llm.output_limit}\n'
|
|
657
|
+
' },\n'
|
|
658
|
+
' "options": {\n'
|
|
659
|
+
' "temperature": 0.2,\n'
|
|
660
|
+
' "topP": 0.9\n'
|
|
661
|
+
' }\n'
|
|
662
|
+
' }\n'
|
|
663
|
+
' }\n'
|
|
664
|
+
' }\n'
|
|
665
|
+
' }\n'
|
|
666
|
+
"}\n"
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def step4_opencode_config(state: State, llm: Llm) -> None:
|
|
671
|
+
oc_cfg = state.home / ".config" / "opencode" / "opencode.json"
|
|
672
|
+
oc_secret = state.home / ".config" / "opencode" / "secrets" / "api-key"
|
|
673
|
+
if oc_cfg.is_file() and oc_secret.is_file() and not state.force_config:
|
|
674
|
+
state.console.print(
|
|
675
|
+
f"[dim]opencode config: {oc_cfg} + secret already present — skipping "
|
|
676
|
+
"(use --force-config to overwrite)[/dim]"
|
|
677
|
+
)
|
|
678
|
+
state.already.append("opencode config + secret (already present)")
|
|
679
|
+
return
|
|
680
|
+
model_ref, _ = _model_ref_key(llm.provider, llm.model)
|
|
681
|
+
config = build_opencode_config(llm)
|
|
682
|
+
state.console.print(
|
|
683
|
+
f"[green]opencode config:[/green] writing {oc_cfg} "
|
|
684
|
+
f"(provider {llm.provider}, model {model_ref}, speed {llm.speed})"
|
|
685
|
+
)
|
|
686
|
+
if state.dry_run:
|
|
687
|
+
state.console.print(config)
|
|
688
|
+
write_file(state, oc_cfg, config)
|
|
689
|
+
write_file(state, oc_secret, llm.api_key + "\n", mode=0o600)
|
|
690
|
+
state.installed.append(f"opencode config + secret ({llm.provider} provider)")
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def step5_credentials(state: State) -> None:
|
|
694
|
+
cred_file = state.home / ".config" / "hermes" / "opencode-server.json"
|
|
695
|
+
if cred_file.is_file():
|
|
696
|
+
state.console.print(
|
|
697
|
+
f"[dim]server credentials: {cred_file} already present — skipping[/dim]"
|
|
698
|
+
)
|
|
699
|
+
state.already.append("server credentials (already present)")
|
|
700
|
+
return
|
|
701
|
+
state.console.print(
|
|
702
|
+
f"[green]server credentials:[/green] generating {cred_file} (port {state.port})"
|
|
703
|
+
)
|
|
704
|
+
if state.dry_run:
|
|
705
|
+
state.console.print(f"[dim]would write {cred_file} (generated token, chmod 600)[/dim]")
|
|
706
|
+
else:
|
|
707
|
+
data = {
|
|
708
|
+
"base_url": f"http://127.0.0.1:{state.port}",
|
|
709
|
+
"username": "opencode",
|
|
710
|
+
"password": secrets.token_urlsafe(16),
|
|
711
|
+
"port": state.port,
|
|
712
|
+
}
|
|
713
|
+
cred_file.parent.mkdir(parents=True, exist_ok=True)
|
|
714
|
+
fd = os.open(cred_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
715
|
+
with os.fdopen(fd, "w") as f:
|
|
716
|
+
json.dump(data, f, indent=2)
|
|
717
|
+
f.write("\n")
|
|
718
|
+
state.installed.append("server credentials (generated, chmod 600)")
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def step6_launchers(state: State) -> None:
|
|
722
|
+
mcp_launch = state.home / ".local" / "bin" / "opencode-mcp-launch.sh"
|
|
723
|
+
srv_launch = state.home / ".local" / "bin" / "opencode-server-launch.sh"
|
|
724
|
+
state.console.print(f"[green]launchers:[/green] writing {mcp_launch}")
|
|
725
|
+
write_file(
|
|
726
|
+
state,
|
|
727
|
+
mcp_launch,
|
|
728
|
+
MCP_LAUNCHER_TEMPLATE.replace("__REPO__", str(state.repo)),
|
|
729
|
+
mode=0o755,
|
|
730
|
+
)
|
|
731
|
+
state.console.print(f"[green]launchers:[/green] writing {srv_launch}")
|
|
732
|
+
write_file(state, srv_launch, SERVER_LAUNCHER_TEMPLATE, mode=0o755)
|
|
733
|
+
state.installed.append("launchers (opencode-mcp-launch.sh + opencode-server-launch.sh)")
|
|
734
|
+
helpers_src = state.repo / "scripts" / "helpers"
|
|
735
|
+
if helpers_src.is_dir():
|
|
736
|
+
for helper in ("ocattach", "oc-current"):
|
|
737
|
+
dst = state.home / ".local" / "bin" / helper
|
|
738
|
+
if dst.is_file():
|
|
739
|
+
state.already.append(f"helper {helper} (already present)")
|
|
740
|
+
else:
|
|
741
|
+
state.console.print(f"[green]helpers:[/green] writing {dst}")
|
|
742
|
+
if state.dry_run:
|
|
743
|
+
state.console.print(f"[dim]would copy {helpers_src / helper} -> {dst}[/dim]")
|
|
744
|
+
else:
|
|
745
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
746
|
+
shutil.copy2(helpers_src / helper, dst)
|
|
747
|
+
os.chmod(dst, 0o755)
|
|
748
|
+
state.installed.append(f"helper {helper}")
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def step7_systemd(state: State) -> None:
|
|
752
|
+
unit = state.home / ".config" / "systemd" / "user" / "opencode-server.service"
|
|
753
|
+
srv_launch = state.home / ".local" / "bin" / "opencode-server-launch.sh"
|
|
754
|
+
state.console.print(f"[green]systemd:[/green] writing {unit}")
|
|
755
|
+
content = (
|
|
756
|
+
"[Unit]\n"
|
|
757
|
+
f"Description=OpenCode server (permanent, loopback :{state.port})\n"
|
|
758
|
+
"After=network.target\n"
|
|
759
|
+
"\n"
|
|
760
|
+
"[Service]\n"
|
|
761
|
+
"Type=simple\n"
|
|
762
|
+
f"ExecStart={srv_launch}\n"
|
|
763
|
+
"Restart=always\n"
|
|
764
|
+
"RestartSec=3\n"
|
|
765
|
+
"TimeoutStopSec=30\n"
|
|
766
|
+
"\n"
|
|
767
|
+
"[Install]\n"
|
|
768
|
+
"WantedBy=default.target\n"
|
|
769
|
+
)
|
|
770
|
+
write_file(state, unit, content)
|
|
771
|
+
if state.sandbox:
|
|
772
|
+
state.console.print("[dim]systemd: skipping daemon-reload/enable (sandbox mode)[/dim]")
|
|
773
|
+
state.skipped.append("systemd enable/start (sandbox mode)")
|
|
774
|
+
else:
|
|
775
|
+
_run_checked(state, ["systemctl", "--user", "daemon-reload"])
|
|
776
|
+
_run_checked(state, ["systemctl", "--user", "enable", "--now", "opencode-server"])
|
|
777
|
+
state.installed.append("systemd user service opencode-server")
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def step8_hermes_config(state: State) -> None:
|
|
781
|
+
import yaml
|
|
782
|
+
|
|
783
|
+
hermes_cfg = state.home / ".hermes" / "config.yaml"
|
|
784
|
+
mcp_cmd = state.home / ".local" / "bin" / "opencode-mcp-launch.sh"
|
|
785
|
+
state.console.print(f"[green]hermes config:[/green] patching {hermes_cfg}")
|
|
786
|
+
if state.dry_run:
|
|
787
|
+
state.console.print(
|
|
788
|
+
f"[dim]would patch {hermes_cfg} "
|
|
789
|
+
"(mcp_servers.opencode + timeouts.tools, backup .bak if present)[/dim]"
|
|
790
|
+
)
|
|
791
|
+
else:
|
|
792
|
+
existed = hermes_cfg.exists()
|
|
793
|
+
if existed:
|
|
794
|
+
shutil.copy2(hermes_cfg, str(hermes_cfg) + ".bak")
|
|
795
|
+
cfg = yaml.safe_load(hermes_cfg.read_text()) or {}
|
|
796
|
+
else:
|
|
797
|
+
hermes_cfg.parent.mkdir(parents=True, exist_ok=True)
|
|
798
|
+
cfg = {}
|
|
799
|
+
mcp = cfg.setdefault("mcp_servers", {})
|
|
800
|
+
mcp["opencode"] = {
|
|
801
|
+
"command": str(mcp_cmd),
|
|
802
|
+
"enabled": True,
|
|
803
|
+
"timeout": 14400,
|
|
804
|
+
"connect_timeout": 30,
|
|
805
|
+
"supports_parallel_tool_calls": False,
|
|
806
|
+
}
|
|
807
|
+
tools = cfg.setdefault("timeouts", {}).setdefault("tools", {})
|
|
808
|
+
tools["sequential_call"] = 14400
|
|
809
|
+
tools["concurrent_batch"] = 14400
|
|
810
|
+
hermes_cfg.write_text(yaml.safe_dump(cfg, default_flow_style=False, sort_keys=False))
|
|
811
|
+
state.console.print(
|
|
812
|
+
f"hermes config: {'patched' if existed else 'wrote'} {hermes_cfg}"
|
|
813
|
+
+ (f" (backup: {hermes_cfg}.bak)" if existed else "")
|
|
814
|
+
)
|
|
815
|
+
state.installed.append("hermes config (mcp_servers.opencode + timeouts.tools)")
|
|
816
|
+
|
|
817
|
+
|
|
818
|
+
def step9_verify(state: State) -> None:
|
|
819
|
+
from rich.live import Live
|
|
820
|
+
from rich.text import Text
|
|
821
|
+
|
|
822
|
+
if state.dry_run:
|
|
823
|
+
state.console.print("[dim]verification: skipped (dry-run)[/dim]")
|
|
824
|
+
return
|
|
825
|
+
if state.skip_verify:
|
|
826
|
+
state.console.print("[dim]verification: skipped (--skip-verify)[/dim]")
|
|
827
|
+
state.skipped.append("verification (--skip-verify)")
|
|
828
|
+
return
|
|
829
|
+
cred_file = state.home / ".config" / "hermes" / "opencode-server.json"
|
|
830
|
+
cred = json.loads(cred_file.read_text())
|
|
831
|
+
url, user, pw = cred["base_url"], cred["username"], cred["password"]
|
|
832
|
+
state.console.print(
|
|
833
|
+
f"[green]verification:[/green] waiting for server health ({url}/global/health, ~30s)..."
|
|
834
|
+
)
|
|
835
|
+
healthy = False
|
|
836
|
+
last_err = ""
|
|
837
|
+
with Live(Text(), console=state.console, refresh_per_second=4) as live:
|
|
838
|
+
for attempt in range(1, 31):
|
|
839
|
+
live.update(Text(f"health check {attempt}/30 — {url}/global/health", style="cyan"))
|
|
840
|
+
try:
|
|
841
|
+
subprocess.run(
|
|
842
|
+
[
|
|
843
|
+
"curl",
|
|
844
|
+
"-sSf",
|
|
845
|
+
"--max-time",
|
|
846
|
+
"3",
|
|
847
|
+
"-u",
|
|
848
|
+
f"{user}:{pw}",
|
|
849
|
+
f"{url}/global/health",
|
|
850
|
+
],
|
|
851
|
+
stdout=subprocess.PIPE,
|
|
852
|
+
stderr=subprocess.STDOUT,
|
|
853
|
+
text=True,
|
|
854
|
+
check=True,
|
|
855
|
+
)
|
|
856
|
+
healthy = True
|
|
857
|
+
break
|
|
858
|
+
except subprocess.CalledProcessError as e:
|
|
859
|
+
out = (e.stdout or "").strip()
|
|
860
|
+
last_err = f"curl exit {e.returncode} — {out or 'no output'}"
|
|
861
|
+
time.sleep(1)
|
|
862
|
+
if not healthy:
|
|
863
|
+
if not state.sandbox:
|
|
864
|
+
state.console.print(
|
|
865
|
+
"[yellow]diagnostic — systemctl --user status opencode-server:[/yellow]"
|
|
866
|
+
)
|
|
867
|
+
subprocess.run(
|
|
868
|
+
["systemctl", "--user", "status", "opencode-server", "--no-pager"], check=False
|
|
869
|
+
)
|
|
870
|
+
die(
|
|
871
|
+
state,
|
|
872
|
+
f"OpenCode server not healthy after 30s (last error: {last_err}) — "
|
|
873
|
+
"check: systemctl --user status opencode-server",
|
|
874
|
+
)
|
|
875
|
+
state.console.print("[green]verification:[/green] server healthy — running smoke_client.py")
|
|
876
|
+
proc = subprocess.run(
|
|
877
|
+
[sys.executable, "-m", "opencode_hermes_mcp.smoke_client"], capture_output=True, text=True
|
|
878
|
+
)
|
|
879
|
+
out = (proc.stdout + proc.stderr).strip()
|
|
880
|
+
if out:
|
|
881
|
+
state.console.print(out)
|
|
882
|
+
if proc.returncode != 0:
|
|
883
|
+
die(state, "smoke test failed (non-zero exit)")
|
|
884
|
+
if "tool surface OK" not in out:
|
|
885
|
+
die(state, "smoke test failed (expected 'tool surface OK' in output)")
|
|
886
|
+
state.console.print("[green]verification:[/green] smoke test OK")
|
|
887
|
+
state.installed.append("verification (health + smoke OK)")
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def _banner(state: State) -> None:
|
|
891
|
+
from rich.panel import Panel
|
|
892
|
+
|
|
893
|
+
from opencode_hermes_mcp import __version__
|
|
894
|
+
|
|
895
|
+
modes: list[str] = []
|
|
896
|
+
if state.sandbox:
|
|
897
|
+
modes.append(f"sandbox (targets → {state.home})")
|
|
898
|
+
if state.dry_run:
|
|
899
|
+
modes.append("dry-run (no changes)")
|
|
900
|
+
mode = " + ".join(modes) if modes else "normal"
|
|
901
|
+
body = (
|
|
902
|
+
f"[bold]opencode-hermes-mcp[/bold] v{__version__}\n"
|
|
903
|
+
f"OpenCode target: [bold]{pinned_version()}[/bold] (pinned)\n"
|
|
904
|
+
f"mode: {mode}\n"
|
|
905
|
+
f"repo: {state.repo}"
|
|
906
|
+
)
|
|
907
|
+
state.console.print(Panel(body, title="setup wizard", border_style="cyan", expand=False))
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _summary(state: State, elapsed: float) -> None:
|
|
911
|
+
from rich.panel import Panel
|
|
912
|
+
from rich.text import Text
|
|
913
|
+
|
|
914
|
+
text = Text()
|
|
915
|
+
text.append("Installed:\n", style="bold")
|
|
916
|
+
if state.installed:
|
|
917
|
+
for item in state.installed:
|
|
918
|
+
text.append(f" + {item}\n", style="green")
|
|
919
|
+
else:
|
|
920
|
+
text.append(" (none)\n")
|
|
921
|
+
text.append("Already in place:\n", style="bold")
|
|
922
|
+
if state.already:
|
|
923
|
+
for item in state.already:
|
|
924
|
+
text.append(f" = {item}\n", style="dim")
|
|
925
|
+
else:
|
|
926
|
+
text.append(" (none)\n")
|
|
927
|
+
text.append("Skipped:\n", style="bold")
|
|
928
|
+
if state.skipped:
|
|
929
|
+
for item in state.skipped:
|
|
930
|
+
text.append(f" = {item}\n", style="yellow")
|
|
931
|
+
else:
|
|
932
|
+
text.append(" (none)\n")
|
|
933
|
+
text.append(f"\nDuration: {elapsed:.1f}s\n", style="bold")
|
|
934
|
+
text.append(
|
|
935
|
+
"\nNOTE: a NEW Hermes session is required to load the MCP server\n", style="bold cyan"
|
|
936
|
+
)
|
|
937
|
+
text.append("(mcp_servers.opencode is read at session start).", style="bold cyan")
|
|
938
|
+
text.append(
|
|
939
|
+
"\nNext: docs/hermes-integration.md (manual) + docs/skill.example.md\n",
|
|
940
|
+
style="bold yellow",
|
|
941
|
+
)
|
|
942
|
+
text.append(
|
|
943
|
+
"(recommended: adapt the skill template into ~/.hermes/skills/ —\n"
|
|
944
|
+
"this installer does NOT install a skill for you on purpose).",
|
|
945
|
+
style="bold yellow",
|
|
946
|
+
)
|
|
947
|
+
state.console.print(Panel(text, title="INSTALL SUMMARY", border_style="green"))
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def wizard() -> int:
|
|
951
|
+
from rich.console import Console
|
|
952
|
+
from rich.panel import Panel
|
|
953
|
+
|
|
954
|
+
args = parse_args(sys.argv[1:])
|
|
955
|
+
repo_env = os.environ.get("OPENCODE_MCP_REPO")
|
|
956
|
+
repo = Path(repo_env).resolve() if repo_env else Path(__file__).resolve().parent.parent
|
|
957
|
+
home_env = os.environ.get("OPENCODE_MCP_HOME")
|
|
958
|
+
home = Path(home_env).expanduser().resolve() if home_env else Path.home()
|
|
959
|
+
state = State(
|
|
960
|
+
repo=repo,
|
|
961
|
+
home=home,
|
|
962
|
+
sandbox=home != Path.home(),
|
|
963
|
+
dry_run=args.dry_run,
|
|
964
|
+
assume_yes=args.yes,
|
|
965
|
+
port=args.port,
|
|
966
|
+
skip_binary=args.skip_binary,
|
|
967
|
+
force_config=args.force_config,
|
|
968
|
+
skip_verify=args.skip_verify,
|
|
969
|
+
console=Console(),
|
|
970
|
+
)
|
|
971
|
+
if not (repo / "opencode_hermes_mcp" / "server.py").is_file():
|
|
972
|
+
die(state, f"repo layout unexpected (no {repo}/opencode_hermes_mcp/server.py)")
|
|
973
|
+
_banner(state)
|
|
974
|
+
if state.dry_run:
|
|
975
|
+
state.console.print(Panel("DRY RUN — no changes will be made", border_style="yellow"))
|
|
976
|
+
if state.sandbox:
|
|
977
|
+
warn(state, f"sandbox mode: targets redirected to {state.home}")
|
|
978
|
+
if not state.assume_yes and not sys.stdin.isatty():
|
|
979
|
+
die(state, "stdin is not a terminal — pass --yes for a non-interactive install")
|
|
980
|
+
started = time.monotonic()
|
|
981
|
+
step_header(state, 1, "LLM provider")
|
|
982
|
+
llm = step1_llm(state)
|
|
983
|
+
step_header(state, 2, "OpenCode binary")
|
|
984
|
+
step2_binary(state)
|
|
985
|
+
step_header(state, 3, "Venv + package")
|
|
986
|
+
step3_venv(state)
|
|
987
|
+
step_header(state, 4, "OpenCode provider config")
|
|
988
|
+
step4_opencode_config(state, llm)
|
|
989
|
+
step_header(state, 5, "Server credentials")
|
|
990
|
+
step5_credentials(state)
|
|
991
|
+
step_header(state, 6, "Launchers + helpers")
|
|
992
|
+
step6_launchers(state)
|
|
993
|
+
step_header(state, 7, "systemd user service")
|
|
994
|
+
step7_systemd(state)
|
|
995
|
+
step_header(state, 8, "Hermes config")
|
|
996
|
+
step8_hermes_config(state)
|
|
997
|
+
step_header(state, 9, "Verification")
|
|
998
|
+
step9_verify(state)
|
|
999
|
+
_summary(state, time.monotonic() - started)
|
|
1000
|
+
return 0
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def main() -> None:
|
|
1004
|
+
if any(a in ("-h", "--help") for a in sys.argv[1:]):
|
|
1005
|
+
parse_args(sys.argv[1:])
|
|
1006
|
+
return
|
|
1007
|
+
bootstrap()
|
|
1008
|
+
sys.exit(wizard())
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
if __name__ == "__main__":
|
|
1012
|
+
main()
|