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.
@@ -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()