Add host bootstrap and repair helpers
Add a dedicated pyro host surface for supported chat hosts so Claude Code, Codex, and OpenCode users can connect or repair the canonical MCP setup without hand-writing raw commands or config edits. Implement the shared host helper layer and wire it through the CLI with connect, print-config, doctor, and repair, all generated from the same canonical pyro mcp serve command shape and project-source flags. Update the docs, public contract, examples, changelog, and roadmap so the helper flow becomes the primary onramp while raw host-specific commands remain as reference material. Harden the verification path that this milestone exposed: temp git repos in tests now disable commit signing, socket-based port tests skip cleanly when the sandbox forbids those primitives, and make test still uses multiple cores by default but caps xdist workers to a stable value so make check stays fast and deterministic here. Validation: - uv lock - UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make check - UV_OFFLINE=1 UV_CACHE_DIR=.uv-cache make dist-check
This commit is contained in:
parent
535efc6919
commit
899a6760c4
25 changed files with 1658 additions and 58 deletions
|
|
@ -8,12 +8,20 @@ import shlex
|
|||
import sys
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from pyro_mcp import __version__
|
||||
from pyro_mcp.api import Pyro
|
||||
from pyro_mcp.api import McpToolProfile, Pyro
|
||||
from pyro_mcp.contract import PUBLIC_MCP_PROFILES
|
||||
from pyro_mcp.demo import run_demo
|
||||
from pyro_mcp.host_helpers import (
|
||||
HostDoctorEntry,
|
||||
HostServerConfig,
|
||||
connect_cli_host,
|
||||
doctor_hosts,
|
||||
print_or_write_opencode_config,
|
||||
repair_host,
|
||||
)
|
||||
from pyro_mcp.ollama_demo import DEFAULT_OLLAMA_BASE_URL, DEFAULT_OLLAMA_MODEL, run_ollama_tool_demo
|
||||
from pyro_mcp.runtime import DEFAULT_PLATFORM, doctor_report
|
||||
from pyro_mcp.vm_environments import DEFAULT_CATALOG_VERSION
|
||||
|
|
@ -169,6 +177,62 @@ def _print_doctor_human(payload: dict[str, Any]) -> None:
|
|||
print(f"- {issue}")
|
||||
|
||||
|
||||
def _build_host_server_config(args: argparse.Namespace) -> HostServerConfig:
|
||||
return HostServerConfig(
|
||||
installed_package=bool(getattr(args, "installed_package", False)),
|
||||
profile=cast(McpToolProfile, str(getattr(args, "profile", "workspace-core"))),
|
||||
project_path=getattr(args, "project_path", None),
|
||||
repo_url=getattr(args, "repo_url", None),
|
||||
repo_ref=getattr(args, "repo_ref", None),
|
||||
no_project_source=bool(getattr(args, "no_project_source", False)),
|
||||
)
|
||||
|
||||
|
||||
def _print_host_connect_human(payload: dict[str, Any]) -> None:
|
||||
host = str(payload.get("host", "unknown"))
|
||||
server_command = payload.get("server_command")
|
||||
verification_command = payload.get("verification_command")
|
||||
print(f"Connected pyro to {host}.")
|
||||
if isinstance(server_command, list):
|
||||
print("Server command: " + shlex.join(str(item) for item in server_command))
|
||||
if isinstance(verification_command, list):
|
||||
print("Verify with: " + shlex.join(str(item) for item in verification_command))
|
||||
|
||||
|
||||
def _print_host_print_config_human(payload: dict[str, Any]) -> None:
|
||||
rendered_config = payload.get("rendered_config")
|
||||
if isinstance(rendered_config, str):
|
||||
_write_stream(rendered_config, stream=sys.stdout)
|
||||
return
|
||||
output_path = payload.get("output_path")
|
||||
if isinstance(output_path, str):
|
||||
print(f"Wrote OpenCode config to {output_path}")
|
||||
|
||||
|
||||
def _print_host_repair_human(payload: dict[str, Any]) -> None:
|
||||
host = str(payload.get("host", "unknown"))
|
||||
if host == "opencode":
|
||||
print(f"Repaired OpenCode config at {str(payload.get('config_path', 'unknown'))}.")
|
||||
backup_path = payload.get("backup_path")
|
||||
if isinstance(backup_path, str):
|
||||
print(f"Backed up the previous config to {backup_path}.")
|
||||
return
|
||||
_print_host_connect_human(payload)
|
||||
|
||||
|
||||
def _print_host_doctor_human(entries: list[HostDoctorEntry]) -> None:
|
||||
for index, entry in enumerate(entries):
|
||||
print(
|
||||
f"{entry.host}: {entry.status} "
|
||||
f"installed={'yes' if entry.installed else 'no'} "
|
||||
f"configured={'yes' if entry.configured else 'no'}"
|
||||
)
|
||||
print(f" details: {entry.details}")
|
||||
print(f" repair: {entry.repair_command}")
|
||||
if index != len(entries) - 1:
|
||||
print()
|
||||
|
||||
|
||||
def _print_workspace_summary_human(payload: dict[str, Any], *, action: str) -> None:
|
||||
print(f"{action} ID: {str(payload.get('workspace_id', 'unknown'))}")
|
||||
name = payload.get("name")
|
||||
|
|
@ -645,6 +709,38 @@ class _HelpFormatter(
|
|||
return help_string
|
||||
|
||||
|
||||
def _add_host_server_source_args(parser: argparse.ArgumentParser) -> None:
|
||||
parser.add_argument(
|
||||
"--installed-package",
|
||||
action="store_true",
|
||||
help="Use `pyro mcp serve` instead of the default `uvx --from pyro-mcp pyro mcp serve`.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--profile",
|
||||
choices=PUBLIC_MCP_PROFILES,
|
||||
default="workspace-core",
|
||||
help="Server profile to configure for the host helper flow.",
|
||||
)
|
||||
source_group = parser.add_mutually_exclusive_group()
|
||||
source_group.add_argument(
|
||||
"--project-path",
|
||||
help="Pin the server to this local project path instead of relying on host cwd.",
|
||||
)
|
||||
source_group.add_argument(
|
||||
"--repo-url",
|
||||
help="Seed default workspaces from a clean clone of this repository URL.",
|
||||
)
|
||||
source_group.add_argument(
|
||||
"--no-project-source",
|
||||
action="store_true",
|
||||
help="Disable automatic Git checkout detection from the current working directory.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--repo-ref",
|
||||
help="Optional branch, tag, or commit to checkout after cloning --repo-url.",
|
||||
)
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
|
|
@ -658,11 +754,12 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
pyro env list
|
||||
pyro env pull debian:12
|
||||
pyro run debian:12 -- git --version
|
||||
pyro mcp serve
|
||||
pyro host connect claude-code
|
||||
|
||||
Connect a chat host after that:
|
||||
claude mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
codex mcp add pyro -- uvx --from pyro-mcp pyro mcp serve
|
||||
pyro host connect claude-code
|
||||
pyro host connect codex
|
||||
pyro host print-config opencode
|
||||
|
||||
If you want terminal-level visibility into the workspace model:
|
||||
pyro workspace create debian:12 --seed-path ./repo --id-only
|
||||
|
|
@ -767,6 +864,129 @@ def _build_parser() -> argparse.ArgumentParser:
|
|||
help="Print structured JSON instead of human-readable output.",
|
||||
)
|
||||
|
||||
host_parser = subparsers.add_parser(
|
||||
"host",
|
||||
help="Bootstrap and repair supported chat-host configs.",
|
||||
description=(
|
||||
"Connect or repair the supported Claude Code, Codex, and OpenCode "
|
||||
"host setups without hand-writing MCP commands or config."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro host connect claude-code
|
||||
pyro host connect codex --project-path /abs/path/to/repo
|
||||
pyro host print-config opencode
|
||||
pyro host repair opencode
|
||||
pyro host doctor
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
host_subparsers = host_parser.add_subparsers(dest="host_command", required=True, metavar="HOST")
|
||||
host_connect_parser = host_subparsers.add_parser(
|
||||
"connect",
|
||||
help="Connect Claude Code or Codex in one step.",
|
||||
description=(
|
||||
"Ensure the supported host has a `pyro` MCP server entry that wraps "
|
||||
"the canonical `pyro mcp serve` command."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro host connect claude-code
|
||||
pyro host connect codex --installed-package
|
||||
pyro host connect codex --project-path /abs/path/to/repo
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
host_connect_parser.add_argument(
|
||||
"host",
|
||||
choices=("claude-code", "codex"),
|
||||
help="Chat host to connect and update in place.",
|
||||
)
|
||||
_add_host_server_source_args(host_connect_parser)
|
||||
|
||||
host_print_config_parser = host_subparsers.add_parser(
|
||||
"print-config",
|
||||
help="Print or write the canonical OpenCode config snippet.",
|
||||
description=(
|
||||
"Render the canonical OpenCode `mcp.pyro` config entry so it can be "
|
||||
"copied into or written to `opencode.json`."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro host print-config opencode
|
||||
pyro host print-config opencode --output ./opencode.json
|
||||
pyro host print-config opencode --project-path /abs/path/to/repo
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
host_print_config_parser.add_argument(
|
||||
"host",
|
||||
choices=("opencode",),
|
||||
help="Host config shape to render.",
|
||||
)
|
||||
_add_host_server_source_args(host_print_config_parser)
|
||||
host_print_config_parser.add_argument(
|
||||
"--output",
|
||||
help="Write the rendered JSON to this path instead of printing it to stdout.",
|
||||
)
|
||||
|
||||
host_doctor_parser = host_subparsers.add_parser(
|
||||
"doctor",
|
||||
help="Inspect supported host setup status.",
|
||||
description=(
|
||||
"Report whether Claude Code, Codex, and OpenCode are installed, "
|
||||
"configured, missing, or drifted relative to the canonical `pyro` MCP setup."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro host doctor
|
||||
pyro host doctor --project-path /abs/path/to/repo
|
||||
pyro host doctor --installed-package
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
_add_host_server_source_args(host_doctor_parser)
|
||||
host_doctor_parser.add_argument(
|
||||
"--config-path",
|
||||
help="Override the OpenCode config path when inspecting or repairing that host.",
|
||||
)
|
||||
|
||||
host_repair_parser = host_subparsers.add_parser(
|
||||
"repair",
|
||||
help="Repair one supported host to the canonical `pyro` setup.",
|
||||
description=(
|
||||
"Repair a stale or broken host config by reapplying the canonical "
|
||||
"`pyro mcp serve` setup for that host."
|
||||
),
|
||||
epilog=dedent(
|
||||
"""
|
||||
Examples:
|
||||
pyro host repair claude-code
|
||||
pyro host repair codex --project-path /abs/path/to/repo
|
||||
pyro host repair opencode
|
||||
"""
|
||||
),
|
||||
formatter_class=_HelpFormatter,
|
||||
)
|
||||
host_repair_parser.add_argument(
|
||||
"host",
|
||||
choices=("claude-code", "codex", "opencode"),
|
||||
help="Host config to repair.",
|
||||
)
|
||||
_add_host_server_source_args(host_repair_parser)
|
||||
host_repair_parser.add_argument(
|
||||
"--config-path",
|
||||
help="Override the OpenCode config path when repairing that host.",
|
||||
)
|
||||
|
||||
mcp_parser = subparsers.add_parser(
|
||||
"mcp",
|
||||
help="Run the MCP server.",
|
||||
|
|
@ -2350,6 +2570,57 @@ def main() -> None:
|
|||
else:
|
||||
_print_prune_human(prune_payload)
|
||||
return
|
||||
if args.command == "host":
|
||||
config = _build_host_server_config(args)
|
||||
if args.host_command == "connect":
|
||||
try:
|
||||
payload = connect_cli_host(args.host, config=config)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
_print_host_connect_human(payload)
|
||||
return
|
||||
if args.host_command == "print-config":
|
||||
try:
|
||||
output_path = (
|
||||
None if args.output is None else Path(args.output).expanduser().resolve()
|
||||
)
|
||||
payload = print_or_write_opencode_config(config=config, output_path=output_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
_print_host_print_config_human(payload)
|
||||
return
|
||||
if args.host_command == "doctor":
|
||||
try:
|
||||
config_path = (
|
||||
None
|
||||
if args.config_path is None
|
||||
else Path(args.config_path).expanduser().resolve()
|
||||
)
|
||||
entries = doctor_hosts(config=config, config_path=config_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
_print_host_doctor_human(entries)
|
||||
return
|
||||
if args.host_command == "repair":
|
||||
try:
|
||||
if args.host != "opencode" and args.config_path is not None:
|
||||
raise ValueError(
|
||||
"--config-path is only supported for `pyro host repair opencode`"
|
||||
)
|
||||
config_path = (
|
||||
None
|
||||
if args.config_path is None
|
||||
else Path(args.config_path).expanduser().resolve()
|
||||
)
|
||||
payload = repair_host(args.host, config=config, config_path=config_path)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
_print_host_repair_human(payload)
|
||||
return
|
||||
if args.command == "mcp":
|
||||
pyro.create_server(
|
||||
profile=args.profile,
|
||||
|
|
@ -2497,7 +2768,8 @@ def main() -> None:
|
|||
print(f"[error] {exc}", file=sys.stderr, flush=True)
|
||||
raise SystemExit(1) from exc
|
||||
_print_workspace_exec_human(payload)
|
||||
exit_code = int(payload.get("exit_code", 1))
|
||||
exit_code_raw = payload.get("exit_code", 1)
|
||||
exit_code = exit_code_raw if isinstance(exit_code_raw, int) else 1
|
||||
if exit_code != 0:
|
||||
raise SystemExit(exit_code)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -2,9 +2,22 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "mcp", "run", "workspace")
|
||||
PUBLIC_CLI_COMMANDS = ("demo", "doctor", "env", "host", "mcp", "run", "workspace")
|
||||
PUBLIC_CLI_DEMO_SUBCOMMANDS = ("ollama",)
|
||||
PUBLIC_CLI_ENV_SUBCOMMANDS = ("inspect", "list", "pull", "prune")
|
||||
PUBLIC_CLI_HOST_SUBCOMMANDS = ("connect", "doctor", "print-config", "repair")
|
||||
PUBLIC_CLI_HOST_COMMON_FLAGS = (
|
||||
"--installed-package",
|
||||
"--profile",
|
||||
"--project-path",
|
||||
"--repo-url",
|
||||
"--repo-ref",
|
||||
"--no-project-source",
|
||||
)
|
||||
PUBLIC_CLI_HOST_CONNECT_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS
|
||||
PUBLIC_CLI_HOST_DOCTOR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
|
||||
PUBLIC_CLI_HOST_PRINT_CONFIG_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--output",)
|
||||
PUBLIC_CLI_HOST_REPAIR_FLAGS = PUBLIC_CLI_HOST_COMMON_FLAGS + ("--config-path",)
|
||||
PUBLIC_CLI_MCP_SUBCOMMANDS = ("serve",)
|
||||
PUBLIC_CLI_MCP_SERVE_FLAGS = (
|
||||
"--profile",
|
||||
|
|
|
|||
363
src/pyro_mcp/host_helpers.py
Normal file
363
src/pyro_mcp/host_helpers.py
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
"""Helpers for bootstrapping and repairing supported MCP chat hosts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pyro_mcp.api import McpToolProfile
|
||||
|
||||
SUPPORTED_HOST_CONNECT_TARGETS = ("claude-code", "codex")
|
||||
SUPPORTED_HOST_REPAIR_TARGETS = ("claude-code", "codex", "opencode")
|
||||
SUPPORTED_HOST_PRINT_CONFIG_TARGETS = ("opencode",)
|
||||
DEFAULT_HOST_SERVER_NAME = "pyro"
|
||||
DEFAULT_OPENCODE_CONFIG_PATH = Path.home() / ".config" / "opencode" / "opencode.json"
|
||||
|
||||
HostStatus = Literal["drifted", "missing", "ok", "unavailable"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostServerConfig:
|
||||
installed_package: bool = False
|
||||
profile: McpToolProfile = "workspace-core"
|
||||
project_path: str | None = None
|
||||
repo_url: str | None = None
|
||||
repo_ref: str | None = None
|
||||
no_project_source: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HostDoctorEntry:
|
||||
host: str
|
||||
installed: bool
|
||||
configured: bool
|
||||
status: HostStatus
|
||||
details: str
|
||||
repair_command: str
|
||||
|
||||
|
||||
def _run_command(command: list[str]) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run( # noqa: S603
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _host_binary(host: str) -> str:
|
||||
if host == "claude-code":
|
||||
return "claude"
|
||||
if host == "codex":
|
||||
return "codex"
|
||||
raise ValueError(f"unsupported CLI host {host!r}")
|
||||
|
||||
|
||||
def _canonical_server_command(config: HostServerConfig) -> list[str]:
|
||||
if config.project_path is not None and config.repo_url is not None:
|
||||
raise ValueError("--project-path and --repo-url are mutually exclusive")
|
||||
if config.no_project_source and (
|
||||
config.project_path is not None
|
||||
or config.repo_url is not None
|
||||
or config.repo_ref is not None
|
||||
):
|
||||
raise ValueError(
|
||||
"--no-project-source cannot be combined with --project-path, --repo-url, or --repo-ref"
|
||||
)
|
||||
if config.repo_ref is not None and config.repo_url is None:
|
||||
raise ValueError("--repo-ref requires --repo-url")
|
||||
|
||||
command = ["pyro", "mcp", "serve"]
|
||||
if not config.installed_package:
|
||||
command = ["uvx", "--from", "pyro-mcp", *command]
|
||||
if config.profile != "workspace-core":
|
||||
command.extend(["--profile", config.profile])
|
||||
if config.project_path is not None:
|
||||
command.extend(["--project-path", config.project_path])
|
||||
elif config.repo_url is not None:
|
||||
command.extend(["--repo-url", config.repo_url])
|
||||
if config.repo_ref is not None:
|
||||
command.extend(["--repo-ref", config.repo_ref])
|
||||
elif config.no_project_source:
|
||||
command.append("--no-project-source")
|
||||
return command
|
||||
|
||||
|
||||
def _render_cli_command(command: list[str]) -> str:
|
||||
return shlex.join(command)
|
||||
|
||||
|
||||
def _repair_command(host: str, config: HostServerConfig, *, config_path: Path | None = None) -> str:
|
||||
command = ["pyro", "host", "repair", host]
|
||||
if config.installed_package:
|
||||
command.append("--installed-package")
|
||||
if config.profile != "workspace-core":
|
||||
command.extend(["--profile", config.profile])
|
||||
if config.project_path is not None:
|
||||
command.extend(["--project-path", config.project_path])
|
||||
elif config.repo_url is not None:
|
||||
command.extend(["--repo-url", config.repo_url])
|
||||
if config.repo_ref is not None:
|
||||
command.extend(["--repo-ref", config.repo_ref])
|
||||
elif config.no_project_source:
|
||||
command.append("--no-project-source")
|
||||
if config_path is not None:
|
||||
command.extend(["--config-path", str(config_path)])
|
||||
return _render_cli_command(command)
|
||||
|
||||
|
||||
def _command_matches(output: str, expected: list[str]) -> bool:
|
||||
normalized_output = output.strip()
|
||||
if ":" in normalized_output:
|
||||
normalized_output = normalized_output.split(":", 1)[1].strip()
|
||||
try:
|
||||
parsed = shlex.split(normalized_output)
|
||||
except ValueError:
|
||||
parsed = normalized_output.split()
|
||||
return parsed == expected
|
||||
|
||||
|
||||
def _upsert_opencode_config(
|
||||
*,
|
||||
config_path: Path,
|
||||
config: HostServerConfig,
|
||||
) -> tuple[dict[str, object], Path | None]:
|
||||
existing_payload: dict[str, object] = {}
|
||||
backup_path: Path | None = None
|
||||
if config_path.exists():
|
||||
raw_text = config_path.read_text(encoding="utf-8")
|
||||
try:
|
||||
parsed = json.loads(raw_text)
|
||||
except json.JSONDecodeError:
|
||||
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
|
||||
backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}")
|
||||
shutil.move(str(config_path), str(backup_path))
|
||||
parsed = {}
|
||||
if isinstance(parsed, dict):
|
||||
existing_payload = parsed
|
||||
else:
|
||||
timestamp = datetime.now(UTC).strftime("%Y%m%d%H%M%S")
|
||||
backup_path = config_path.with_name(f"{config_path.name}.bak-{timestamp}")
|
||||
shutil.move(str(config_path), str(backup_path))
|
||||
payload = dict(existing_payload)
|
||||
mcp_payload = payload.get("mcp")
|
||||
if not isinstance(mcp_payload, dict):
|
||||
mcp_payload = {}
|
||||
else:
|
||||
mcp_payload = dict(mcp_payload)
|
||||
mcp_payload[DEFAULT_HOST_SERVER_NAME] = canonical_opencode_entry(config)
|
||||
payload["mcp"] = mcp_payload
|
||||
return payload, backup_path
|
||||
|
||||
|
||||
def canonical_opencode_entry(config: HostServerConfig) -> dict[str, object]:
|
||||
return {
|
||||
"type": "local",
|
||||
"enabled": True,
|
||||
"command": _canonical_server_command(config),
|
||||
}
|
||||
|
||||
|
||||
def render_opencode_config(config: HostServerConfig) -> str:
|
||||
return (
|
||||
json.dumps(
|
||||
{"mcp": {DEFAULT_HOST_SERVER_NAME: canonical_opencode_entry(config)}},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
def print_or_write_opencode_config(
|
||||
*,
|
||||
config: HostServerConfig,
|
||||
output_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
rendered = render_opencode_config(config)
|
||||
if output_path is None:
|
||||
return {
|
||||
"host": "opencode",
|
||||
"rendered_config": rendered,
|
||||
"server_command": _canonical_server_command(config),
|
||||
}
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(rendered, encoding="utf-8")
|
||||
return {
|
||||
"host": "opencode",
|
||||
"output_path": str(output_path),
|
||||
"server_command": _canonical_server_command(config),
|
||||
}
|
||||
|
||||
|
||||
def connect_cli_host(host: str, *, config: HostServerConfig) -> dict[str, object]:
|
||||
binary = _host_binary(host)
|
||||
if shutil.which(binary) is None:
|
||||
raise RuntimeError(f"{binary} CLI is not installed or not on PATH")
|
||||
server_command = _canonical_server_command(config)
|
||||
_run_command([binary, "mcp", "remove", DEFAULT_HOST_SERVER_NAME])
|
||||
result = _run_command([binary, "mcp", "add", DEFAULT_HOST_SERVER_NAME, "--", *server_command])
|
||||
if result.returncode != 0:
|
||||
details = (result.stderr or result.stdout).strip() or f"{binary} mcp add failed"
|
||||
raise RuntimeError(details)
|
||||
return {
|
||||
"host": host,
|
||||
"server_command": server_command,
|
||||
"verification_command": [binary, "mcp", "list"],
|
||||
}
|
||||
|
||||
|
||||
def repair_opencode_host(
|
||||
*,
|
||||
config: HostServerConfig,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
resolved_path = (
|
||||
DEFAULT_OPENCODE_CONFIG_PATH
|
||||
if config_path is None
|
||||
else config_path.expanduser().resolve()
|
||||
)
|
||||
resolved_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload, backup_path = _upsert_opencode_config(config_path=resolved_path, config=config)
|
||||
resolved_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
result: dict[str, object] = {
|
||||
"host": "opencode",
|
||||
"config_path": str(resolved_path),
|
||||
"server_command": _canonical_server_command(config),
|
||||
}
|
||||
if backup_path is not None:
|
||||
result["backup_path"] = str(backup_path)
|
||||
return result
|
||||
|
||||
|
||||
def repair_host(
|
||||
host: str,
|
||||
*,
|
||||
config: HostServerConfig,
|
||||
config_path: Path | None = None,
|
||||
) -> dict[str, object]:
|
||||
if host == "opencode":
|
||||
return repair_opencode_host(config=config, config_path=config_path)
|
||||
return connect_cli_host(host, config=config)
|
||||
|
||||
|
||||
def _doctor_cli_host(host: str, *, config: HostServerConfig) -> HostDoctorEntry:
|
||||
binary = _host_binary(host)
|
||||
repair_command = _repair_command(host, config)
|
||||
if shutil.which(binary) is None:
|
||||
return HostDoctorEntry(
|
||||
host=host,
|
||||
installed=False,
|
||||
configured=False,
|
||||
status="unavailable",
|
||||
details=f"{binary} CLI was not found on PATH",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
expected_command = _canonical_server_command(config)
|
||||
get_result = _run_command([binary, "mcp", "get", DEFAULT_HOST_SERVER_NAME])
|
||||
combined_get_output = (get_result.stdout + get_result.stderr).strip()
|
||||
if get_result.returncode == 0:
|
||||
status: HostStatus = (
|
||||
"ok" if _command_matches(combined_get_output, expected_command) else "drifted"
|
||||
)
|
||||
return HostDoctorEntry(
|
||||
host=host,
|
||||
installed=True,
|
||||
configured=True,
|
||||
status=status,
|
||||
details=combined_get_output or f"{binary} MCP entry exists",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
|
||||
list_result = _run_command([binary, "mcp", "list"])
|
||||
combined_list_output = (list_result.stdout + list_result.stderr).strip()
|
||||
configured = DEFAULT_HOST_SERVER_NAME in combined_list_output.split()
|
||||
return HostDoctorEntry(
|
||||
host=host,
|
||||
installed=True,
|
||||
configured=configured,
|
||||
status="drifted" if configured else "missing",
|
||||
details=combined_get_output or combined_list_output or f"{binary} MCP entry missing",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
|
||||
|
||||
def _doctor_opencode_host(
|
||||
*,
|
||||
config: HostServerConfig,
|
||||
config_path: Path | None = None,
|
||||
) -> HostDoctorEntry:
|
||||
resolved_path = (
|
||||
DEFAULT_OPENCODE_CONFIG_PATH
|
||||
if config_path is None
|
||||
else config_path.expanduser().resolve()
|
||||
)
|
||||
repair_command = _repair_command("opencode", config, config_path=config_path)
|
||||
installed = shutil.which("opencode") is not None
|
||||
if not resolved_path.exists():
|
||||
return HostDoctorEntry(
|
||||
host="opencode",
|
||||
installed=installed,
|
||||
configured=False,
|
||||
status="missing" if installed else "unavailable",
|
||||
details=f"OpenCode config missing at {resolved_path}",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
try:
|
||||
payload = json.loads(resolved_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
return HostDoctorEntry(
|
||||
host="opencode",
|
||||
installed=installed,
|
||||
configured=False,
|
||||
status="drifted" if installed else "unavailable",
|
||||
details=f"OpenCode config is invalid JSON: {exc}",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
if not isinstance(payload, dict):
|
||||
return HostDoctorEntry(
|
||||
host="opencode",
|
||||
installed=installed,
|
||||
configured=False,
|
||||
status="drifted" if installed else "unavailable",
|
||||
details="OpenCode config must be a JSON object",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
mcp_payload = payload.get("mcp")
|
||||
if not isinstance(mcp_payload, dict) or DEFAULT_HOST_SERVER_NAME not in mcp_payload:
|
||||
return HostDoctorEntry(
|
||||
host="opencode",
|
||||
installed=installed,
|
||||
configured=False,
|
||||
status="missing" if installed else "unavailable",
|
||||
details=f"OpenCode config at {resolved_path} is missing mcp.pyro",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
configured_entry = mcp_payload[DEFAULT_HOST_SERVER_NAME]
|
||||
expected_entry = canonical_opencode_entry(config)
|
||||
status: HostStatus = "ok" if configured_entry == expected_entry else "drifted"
|
||||
return HostDoctorEntry(
|
||||
host="opencode",
|
||||
installed=installed,
|
||||
configured=True,
|
||||
status=status,
|
||||
details=f"OpenCode config path: {resolved_path}",
|
||||
repair_command=repair_command,
|
||||
)
|
||||
|
||||
|
||||
def doctor_hosts(
|
||||
*,
|
||||
config: HostServerConfig,
|
||||
config_path: Path | None = None,
|
||||
) -> list[HostDoctorEntry]:
|
||||
return [
|
||||
_doctor_cli_host("claude-code", config=config),
|
||||
_doctor_cli_host("codex", config=config),
|
||||
_doctor_opencode_host(config=config, config_path=config_path),
|
||||
]
|
||||
|
|
@ -19,7 +19,7 @@ from typing import Any
|
|||
from pyro_mcp.runtime import DEFAULT_PLATFORM, RuntimePaths
|
||||
|
||||
DEFAULT_ENVIRONMENT_VERSION = "1.0.0"
|
||||
DEFAULT_CATALOG_VERSION = "4.1.0"
|
||||
DEFAULT_CATALOG_VERSION = "4.2.0"
|
||||
OCI_MANIFEST_ACCEPT = ", ".join(
|
||||
(
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
|
|
@ -48,7 +48,7 @@ class VmEnvironment:
|
|||
oci_repository: str | None = None
|
||||
oci_reference: str | None = None
|
||||
source_digest: str | None = None
|
||||
compatibility: str = ">=4.1.0,<5.0.0"
|
||||
compatibility: str = ">=4.2.0,<5.0.0"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue