diff --git a/.agents/skills/switchyard-coding-agent-launchers/SKILL.md b/.agents/skills/switchyard-coding-agent-launchers/SKILL.md index 18a7d9e2d..ca7aeb3ed 100644 --- a/.agents/skills/switchyard-coding-agent-launchers/SKILL.md +++ b/.agents/skills/switchyard-coding-agent-launchers/SKILL.md @@ -1,6 +1,6 @@ --- name: switchyard-coding-agent-launchers -description: Modify or debug Switchyard's Claude Code, Codex CLI, or OpenClaw launchers. Use for changes under switchyard/cli/launchers, launch_command.py, launcher configuration, temporary agent workspaces, model catalogs, or launcher smoke tests. +description: Modify or debug Switchyard's Claude Code, Codex CLI, OpenClaw, OpenCode, or Hermes launchers. Use for changes under switchyard/cli/launchers, launch_command.py, launcher configuration, temporary agent workspaces, model catalogs, or launcher smoke tests. --- # Coding-Agent Launchers @@ -14,10 +14,18 @@ often, while the process-specific contracts below are stable. - Claude Code is configured through Anthropic environment variables. - Codex receives a temporary provider and model catalog through CLI configuration. - OpenClaw receives a temporary state directory and `openclaw.json`. +- OpenCode receives a temporary config directory (`OPENCODE_CONFIG_DIR`) with an + `@ai-sdk/openai-compatible` provider pointing at the local proxy. +- Hermes is configured through `OPENROUTER_BASE_URL` + `OPENROUTER_API_KEY` + environment overrides with `--provider custom -m `; no user config is touched. - Temporary files, environment changes, and child processes must be cleaned up on success, error, and interruption. - Secrets may be passed to child processes but must not be logged, persisted in committed files, or rendered in status output. +- Launchers must run on Windows without a PTY: POSIX-only modules (`pty`, `fcntl`, `termios`, + `tty`, `SIGWINCH`) are imported under an `os.name` guard, `stdin_is_tty()` is False off-POSIX so + the interactive footer is skipped, and `*.cmd`/`*.bat` shims are launched with + `subprocess.run(..., shell=is_windows_batch_shim(bin))`. ## Workflow @@ -45,4 +53,4 @@ authentication, or protocol difference. - Building a separate routing stack inside a launcher. - Importing server or provider dependencies at module import time. - Leaving temporary catalogs, config files, or modified environment variables behind. -- Assuming the three external agents accept the same configuration mechanism. +- Assuming the supported external agents accept the same configuration mechanism. diff --git a/AGENTS.md b/AGENTS.md index e80f0a5e8..f784f7a50 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -162,7 +162,7 @@ switchyard/ │ ├── switchyard_cli.py # `switchyard` entry point │ ├── launch_command.py # `switchyard launch` │ ├── defaults/ # packaged OpenRouter TOML deployment -│ └── launchers/ # Claude, Codex, and OpenClaw launchers +│ └── launchers/ # Claude, Codex, OpenClaw, OpenCode, and Hermes launchers └── libsy/ # typed Python wrappers for libsy algorithms switchyard_rust/ # Python facades over the PyO3 extension diff --git a/README.md b/README.md index c697f6b69..de1341c41 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,8 @@ export OPENROUTER_API_KEY="your-openrouter-key" # pragma: allowlist secret switchyard launch claude --model switchyard switchyard launch codex --model switchyard switchyard launch openclaw --model switchyard +switchyard launch opencode --model switchyard +switchyard launch hermes --model switchyard ``` To use your own native TOML deployment, pass its route ID and configuration: diff --git a/docs/getting_started.md b/docs/getting_started.md index c6428e970..739d17fc0 100644 --- a/docs/getting_started.md +++ b/docs/getting_started.md @@ -48,11 +48,13 @@ export OPENROUTER_API_KEY="your-openrouter-key" # pragma: allowlist secret switchyard launch claude --model switchyard ``` -Codex and OpenClaw use the same deployment: +Codex, OpenClaw, OpenCode, and Hermes use the same deployment: ```bash switchyard launch codex --model switchyard switchyard launch openclaw --model switchyard +switchyard launch opencode --model switchyard +switchyard launch hermes --model switchyard ``` ### Launch with a custom deployment diff --git a/switchyard/cli/launch_command.py b/switchyard/cli/launch_command.py index 94d08c190..160934f0f 100644 --- a/switchyard/cli/launch_command.py +++ b/switchyard/cli/launch_command.py @@ -58,8 +58,36 @@ def cmd_launch_openclaw(args: argparse.Namespace) -> None: ) +def cmd_launch_opencode(args: argparse.Namespace) -> None: + """Run OpenCode against a native TOML deployment.""" + from switchyard.cli.launchers.opencode_launcher import launch_opencode_config + + raise SystemExit( + launch_opencode_config( + config=_config_path(args.config), + model=args.model, + opencode_args=strip_forwarded_args(args.opencode_args), + ) + ) + + +def cmd_launch_hermes(args: argparse.Namespace) -> None: + """Run Hermes against a native TOML deployment.""" + from switchyard.cli.launchers.hermes_launcher import launch_hermes_config + + raise SystemExit( + launch_hermes_config( + config=_config_path(args.config), + model=args.model, + hermes_args=strip_forwarded_args(args.hermes_args), + ) + ) + + __all__ = [ "cmd_launch_claude", "cmd_launch_codex", "cmd_launch_openclaw", + "cmd_launch_opencode", + "cmd_launch_hermes", ] diff --git a/switchyard/cli/launchers/hermes_launcher.py b/switchyard/cli/launchers/hermes_launcher.py new file mode 100644 index 000000000..eda7f3b4a --- /dev/null +++ b/switchyard/cli/launchers/hermes_launcher.py @@ -0,0 +1,202 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Run Hermes through an in-process native Switchyard server. + +Hermes resolves its endpoint through the same environment overrides other +providers honour: ``OPENROUTER_BASE_URL`` overrides the upstream base URL and +``OPENROUTER_API_KEY`` provides the bearer token. The launcher points both at +the local Switchyard proxy, selects the route through ``--model`` and +``--provider custom``, and launches ``hermes`` against it. No change is made to +the user's Hermes config (~/.hermes/config.yaml). +""" + +import logging +import os +import shutil +import subprocess +from pathlib import Path + +from switchyard.cli.launchers.launcher_runtime import ( + banner_pause, + configure_debug_file_logging, + is_executable_file, + is_windows_batch_shim, + print_ready_banner, + print_startup_failure, + silence_launch_loggers, + stdin_is_tty, + wait_for_proxy_ready, +) +from switchyard.cli.launchers.live_stats_footer import LiveStatsFooter +from switchyard.cli.launchers.native_server import NativeServer +from switchyard.cli.launchers.proxy_health_monitor import ProxyHealthMonitor +from switchyard.cli.launchers.session_summary import print_session_summary +from switchyard.cli.launchers.shell_tui import ShellTUI + +logger = logging.getLogger(__name__) + +_READY_TIMEOUT_S = 10.0 +_EXIT_BINARY_NOT_FOUND = 127 +_EXIT_SIGINT = 130 +_API_KEY_PLACEHOLDER = "switchyard" + + +def _find_hermes_binary() -> str | None: + """Locate the ``hermes`` executable.""" + path_hit = shutil.which("hermes") + if path_hit: + return path_hit + for candidate in ( + Path.home() / ".local" / "bin" / "hermes", + Path.home() / ".hermes" / "hermes-agent" / "venv" / "bin" / "hermes", + ): + if is_executable_file(candidate): + return str(candidate) + return None + + +def _wait_ready(port: int, timeout_s: float = _READY_TIMEOUT_S) -> bool: + """Probe ``GET /health`` until HTTP 200 or timeout.""" + return wait_for_proxy_ready(port, timeout_s=timeout_s) + + +def _hermes_env(port: int) -> dict[str, str]: + """Build the env-var overrides that route Hermes through our proxy. + + * ``OPENROUTER_BASE_URL`` — our proxy URL. Hermes consults this override + when resolving the endpoint for ``--provider custom``. + * ``OPENROUTER_API_KEY`` — opaque token for the local proxy. + """ + env = os.environ.copy() + env["OPENROUTER_BASE_URL"] = f"http://127.0.0.1:{port}/v1" + env["OPENROUTER_API_KEY"] = _API_KEY_PLACEHOLDER + # Never let Hermes phone home to update channels during a proxied run. + env.setdefault("HERMES_DISABLE_AUTOUPDATE", "1") + return env + + +def _hermes_command(hermes_bin: str, hermes_args: list[str], model: str) -> list[str]: + """Build the Hermes command for the local proxy. + + ``--provider custom -m `` are global Hermes flags, so they always + lead. Forwarded arguments are Hermes' own command — an explicit subcommand + (``chat -q ...``, one-shot ``-z ...``, ``resume``, ...) plus any flags — + passed through verbatim. With nothing forwarded, default to the interactive + ``chat`` surface. + """ + routing = ["--provider", "custom", "-m", model] + if not hermes_args: + return [hermes_bin, "chat", *routing] + return [hermes_bin, *routing, *hermes_args] + + +def _supervise_hermes( + hermes_bin: str, + hermes_args: list[str], + model: str, + port: int, +) -> int: + """Run Hermes and return its exit code.""" + try: + result = subprocess.run( + _hermes_command(hermes_bin, hermes_args, model), + env=_hermes_env(port), + check=False, + shell=is_windows_batch_shim(hermes_bin), + ) + return result.returncode + except KeyboardInterrupt: + return _EXIT_SIGINT + + +def _start_native_server(config: Path) -> NativeServer: + """Start the native server; kept separate for supervision tests.""" + return NativeServer(config) + + +def _run_hermes_with_switchyard( + config: Path, + display_model: str, + hermes_args: list[str], +) -> int: + """Host a native deployment and run Hermes against it.""" + hermes_bin = _find_hermes_binary() + if hermes_bin is None: + logger.error( + "hermes binary not found. Install it with " + "`curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash`, " + "or place it on your PATH." + ) + return _EXIT_BINARY_NOT_FOUND + + silence_launch_loggers(local_logger=logger) + log_path = configure_debug_file_logging(display_model=display_model) + server = _start_native_server(config) + resolved_port = server.port + stats = server.stats + strategy_summary = f"config → {config.name}" + + try: + if not _wait_ready(resolved_port): + print_startup_failure( + port=resolved_port, + timeout_s=_READY_TIMEOUT_S, + log_path=log_path, + ) + return 1 + + logger.info("proxy ready on port %d", resolved_port) + print_ready_banner( + port=resolved_port, + display_model=display_model, + log_path=log_path, + strategy_summary=strategy_summary, + routes=[display_model], + default_route=display_model, + ) + if stdin_is_tty(): + banner_pause() + + if stdin_is_tty(): + footer = LiveStatsFooter( + stats, + display_model, + ProxyHealthMonitor(resolved_port), + strategy_label="config", + ) + return ShellTUI( + command=_hermes_command(hermes_bin, hermes_args, display_model), + footer_fn=footer.as_footer_fn(), + footer_height=lambda: footer.height, + env=_hermes_env(resolved_port), + ).run() + + return _supervise_hermes( + hermes_bin, + hermes_args, + display_model, + resolved_port, + ) + finally: + print_session_summary(stats) + server.close() + + +def launch_hermes_config( + config: Path, + model: str, + hermes_args: list[str], +) -> int: + """Run Hermes against a native server TOML deployment.""" + _quiet_launch_loggers() + return _run_hermes_with_switchyard( + config, + display_model=model, + hermes_args=hermes_args, + ) + + +def _quiet_launch_loggers() -> None: + """Keep dependency chatter out of Hermes' terminal UI.""" + silence_launch_loggers(local_logger=logger) diff --git a/switchyard/cli/launchers/launcher_runtime.py b/switchyard/cli/launchers/launcher_runtime.py index 8a5149aee..577276051 100644 --- a/switchyard/cli/launchers/launcher_runtime.py +++ b/switchyard/cli/launchers/launcher_runtime.py @@ -13,10 +13,56 @@ _debug_file_handler: logging.FileHandler | None = None log = logging.getLogger(__name__) +#: Platform flags resolved once at import; ``os.name`` never changes at +#: runtime. Read via these module constants so launcher helpers stay testable +#: without mutating the real ``os`` module (which would also flip pathlib's +#: ``WindowsPath``/``PosixPath`` selection). +_IS_WINDOWS = os.name == "nt" +_IS_POSIX = os.name == "posix" + #: Opener that ignores env proxies — loopback probes to the in-process proxy #: must never be routed through a configured HTTP_PROXY. _LOCAL_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) +#: File suffixes that mark a Windows batch shim rather than a native executable. +_WINDOWS_BATCH_SUFFIXES = (".bat", ".cmd") + + +def is_windows_batch_shim(program: str) -> bool: + """Return whether *program* is a Windows ``.bat``/``.cmd`` shim. + + Agent CLIs installed through npm on Windows are batch shims that + ``CreateProcess`` cannot exec directly; they must run through the shell. + """ + return _IS_WINDOWS and program.lower().endswith(_WINDOWS_BATCH_SUFFIXES) + + +def is_executable_file(path: Path) -> bool: + """Return whether *path* is a runnable file on this platform. + + Windows has no executable permission bit, so any existing file under a + candidate directory may be a shim; POSIX additionally requires the + executable bit via :func:`os.access`. + """ + if not path.is_file(): + return False + if _IS_WINDOWS: + return True + return os.access(path, os.X_OK) + + +def _default_state_dir() -> Path: + """Return the platform-appropriate state directory. + + On Windows, launcher diagnostics live under ``%LOCALAPPDATA%`` rather than + a POSIX dot-directory; ``Path.home()`` is the fallback when that variable + is unset. + """ + if _IS_WINDOWS: + local_app_data = os.environ.get("LOCALAPPDATA") + return Path(local_app_data) if local_app_data else Path.home() / "AppData" / "Local" + return Path.home() / ".local" / "state" + def wait_for_proxy_ready(port: int, *, timeout_s: float) -> bool: """Probe ``GET /health`` until HTTP 200 or timeout.""" @@ -41,7 +87,7 @@ def configure_debug_file_logging(*, display_model: str) -> Path: state_dir = ( Path(state_home).expanduser() if state_home - else Path.home() / ".local" / "state" + else _default_state_dir() ) log_dir = state_dir / "switchyard" / "logs" log_dir.mkdir(parents=True, exist_ok=True) @@ -92,7 +138,15 @@ def silence_launch_loggers(*, local_logger: logging.Logger) -> None: def stdin_is_tty() -> bool: - """Return whether stdin is a usable TTY.""" + """Return whether stdin is a usable TTY. + + The interactive footer (ShellTUI) and the raw-mode key-press pause both + require a POSIX pseudo-terminal, so on Windows this always returns False + and launchers fall back to a plain ``subprocess`` child that inherits the + console directly. + """ + if not _IS_POSIX: + return False try: return os.isatty(sys.stdin.fileno()) except Exception: @@ -219,8 +273,11 @@ def banner_pause(timeout: float = 10.0) -> None: """Hold the banner on screen for up to *timeout* seconds. Returns early if the user presses any key. Only call when stdin is a TTY. + The raw-mode key wait is POSIX-only; on Windows this is a no-op (the + console passes keystrokes straight to the child process anyway). """ - import os + if not _IS_POSIX: + return import select import termios import tty diff --git a/switchyard/cli/launchers/openclaw_launcher.py b/switchyard/cli/launchers/openclaw_launcher.py index a0dbcc87a..82e9327aa 100644 --- a/switchyard/cli/launchers/openclaw_launcher.py +++ b/switchyard/cli/launchers/openclaw_launcher.py @@ -16,6 +16,8 @@ from switchyard.cli.launchers.launcher_runtime import ( banner_pause, configure_debug_file_logging, + is_executable_file, + is_windows_batch_shim, print_ready_banner, print_startup_failure, silence_launch_loggers, @@ -49,13 +51,13 @@ def _find_openclaw_binary() -> str | None: Path.home() / ".npm-global" / "bin" / "openclaw", Path.home() / ".local" / "bin" / "openclaw", ): - if candidate.is_file() and os.access(candidate, os.X_OK): + if is_executable_file(candidate): return str(candidate) nvm_root = Path.home() / ".nvm" / "versions" / "node" if nvm_root.is_dir(): for node_version in sorted(nvm_root.iterdir(), reverse=True): candidate = node_version / "bin" / "openclaw" - if candidate.is_file() and os.access(candidate, os.X_OK): + if is_executable_file(candidate): return str(candidate) return None @@ -152,8 +154,15 @@ def _openclaw_env(workspace: str) -> dict[str, str]: def _openclaw_command(openclaw_bin: str, openclaw_args: list[str]) -> list[str]: - """Build the OpenClaw interactive command.""" - return [openclaw_bin, "chat", *openclaw_args] + """Build the OpenClaw command. + + The model is selected through the transient config, so the user's own + command (``run``, ``chat``, ``config``, ...) and flags are passed through + verbatim. With nothing forwarded, default to interactive ``chat``. + """ + if openclaw_args: + return [openclaw_bin, *openclaw_args] + return [openclaw_bin, "chat"] def _supervise_openclaw( @@ -167,6 +176,7 @@ def _supervise_openclaw( _openclaw_command(openclaw_bin, openclaw_args), env=_openclaw_env(workspace), check=False, + shell=is_windows_batch_shim(openclaw_bin), ) return result.returncode except KeyboardInterrupt: diff --git a/switchyard/cli/launchers/opencode_launcher.py b/switchyard/cli/launchers/opencode_launcher.py new file mode 100644 index 000000000..f1c1d2eb4 --- /dev/null +++ b/switchyard/cli/launchers/opencode_launcher.py @@ -0,0 +1,271 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Run OpenCode through an in-process native Switchyard server. + +OpenCode reads an ``opencode.json`` config from ``OPENCODE_CONFIG_DIR``. The +launcher writes a transient config that declares a custom provider +(``@ai-sdk/openai-compatible``) whose base URL points at the local Switchyard +proxy, exposes the selected route as that provider's ``switchyard/`` +model, then launches OpenCode against it. The workspace is cleaned up on +success, error, and interruption. +""" + +import json +import logging +import os +import shutil +import subprocess +import tempfile +from collections.abc import Sequence +from pathlib import Path +from typing import TypeAlias + +from switchyard.cli.launchers.launcher_runtime import ( + banner_pause, + configure_debug_file_logging, + is_executable_file, + is_windows_batch_shim, + print_ready_banner, + print_startup_failure, + silence_launch_loggers, + stdin_is_tty, + wait_for_proxy_ready, +) +from switchyard.cli.launchers.live_stats_footer import LiveStatsFooter +from switchyard.cli.launchers.native_server import NativeServer +from switchyard.cli.launchers.proxy_health_monitor import ProxyHealthMonitor +from switchyard.cli.launchers.session_summary import print_session_summary +from switchyard.cli.launchers.shell_tui import ShellTUI + +logger = logging.getLogger(__name__) + +_READY_TIMEOUT_S = 10.0 +_EXIT_BINARY_NOT_FOUND = 127 +_EXIT_SIGINT = 130 +_PROVIDER_ID = "switchyard" +_API_KEY_PLACEHOLDER = "switchyard" + +OpenCodeModelCatalogEntry: TypeAlias = tuple[str, str, str] + + +def _find_opencode_binary() -> str | None: + """Locate the ``opencode`` executable.""" + path_hit = shutil.which("opencode") + if path_hit: + return path_hit + for candidate in ( + Path.home() / ".opencode" / "bin" / "opencode", + Path.home() / ".npm-global" / "bin" / "opencode", + Path.home() / ".local" / "bin" / "opencode", + ): + if is_executable_file(candidate): + return str(candidate) + return None + + +def _wait_ready(port: int, timeout_s: float = _READY_TIMEOUT_S) -> bool: + """Probe ``GET /health`` until HTTP 200 or timeout.""" + return wait_for_proxy_ready(port, timeout_s=timeout_s) + + +def _qualified_model_id(model_id: str) -> str: + """Return the provider-qualified model ID used by OpenCode.""" + return f"{_PROVIDER_ID}/{model_id.lstrip('/')}" + + +def _opencode_model_display_name(model_id: str) -> str: + """Return a short display name for a model ID.""" + return model_id.rsplit("/", maxsplit=1)[-1] + + +def _build_opencode_config( + port: int, + entries: Sequence[OpenCodeModelCatalogEntry], + primary_model_id: str, +) -> dict[str, object]: + """Build the transient OpenCode configuration pointing at the proxy.""" + model_defs: dict[str, dict[str, str]] = {} + for model_id, display_name, _description in entries: + model_defs[model_id] = {"name": display_name, "id": model_id} + return { + "$schema": "https://opencode.ai/config.json", + "provider": { + _PROVIDER_ID: { + "npm": "@ai-sdk/openai-compatible", + "name": "Switchyard", + "options": { + "baseURL": f"http://127.0.0.1:{port}/v1", + "apiKey": _API_KEY_PLACEHOLDER, + }, + "models": model_defs, + } + }, + "model": primary_model_id, + } + + +def _write_opencode_workspace( + port: int, + entries: Sequence[OpenCodeModelCatalogEntry], + primary_model_id: str, +) -> tuple[str, Path]: + """Write a transient OpenCode workspace and return ``(dir, config_path)``.""" + workspace = tempfile.mkdtemp(prefix="switchyard-opencode-") + config_path = Path(workspace) / "opencode.json" + with config_path.open("w", encoding="utf-8") as handle: + json.dump( + _build_opencode_config(port, entries, primary_model_id), + handle, + indent=2, + ) + handle.write("\n") + return workspace, config_path + + +def _remove_opencode_workspace(workspace: str | None) -> None: + """Remove a transient OpenCode workspace.""" + if workspace is not None: + shutil.rmtree(workspace, ignore_errors=True) + + +def _opencode_env(workspace: str) -> dict[str, str]: + """Return the environment that selects the transient config directory.""" + env = os.environ.copy() + env["OPENCODE_CONFIG_DIR"] = workspace + env["OPENCODE_DISABLE_AUTOUPDATE"] = "1" + return env + + +def _opencode_command( + opencode_bin: str, + opencode_args: list[str], +) -> list[str]: + """Build the OpenCode command for the local proxy. + + OpenCode selects the model through the transient config's ``model`` field + (already set to the qualified Switchyard route), so the CLI needs no model + flag — injecting ``-m`` breaks subcommands that reject it (``serve``, + ``debug``, ``models``, ...). Forwarded arguments are OpenCode's own command + plus any flags, passed through verbatim; with nothing forwarded OpenCode + starts its interactive TUI. + """ + return [opencode_bin, *opencode_args] + + +def _supervise_opencode( + opencode_bin: str, + opencode_args: list[str], + workspace: str, +) -> int: + """Run OpenCode and return its exit code.""" + try: + result = subprocess.run( + _opencode_command(opencode_bin, opencode_args), + env=_opencode_env(workspace), + check=False, + shell=is_windows_batch_shim(opencode_bin), + ) + return result.returncode + except KeyboardInterrupt: + return _EXIT_SIGINT + + +def _start_native_server(config: Path) -> NativeServer: + """Start the native server; kept separate for supervision tests.""" + return NativeServer(config) + + +def _run_opencode_with_switchyard( + config: Path, + display_model: str, + opencode_args: list[str], + catalog_entries: Sequence[OpenCodeModelCatalogEntry], +) -> int: + """Host a native deployment and run OpenCode against it.""" + opencode_bin = _find_opencode_binary() + if opencode_bin is None: + logger.error( + "opencode binary not found. Install it with " + "`npm install -g opencode-ai@latest`, or place it on your PATH." + ) + return _EXIT_BINARY_NOT_FOUND + + silence_launch_loggers(local_logger=logger) + log_path = configure_debug_file_logging(display_model=display_model) + server = _start_native_server(config) + resolved_port = server.port + stats = server.stats + workspace_dir: str | None = None + strategy_summary = f"config → {config.name}" + + try: + workspace_dir, _workspace_config = _write_opencode_workspace( + port=resolved_port, + entries=catalog_entries, + primary_model_id=_qualified_model_id(display_model), + ) + if not _wait_ready(resolved_port): + print_startup_failure( + port=resolved_port, + timeout_s=_READY_TIMEOUT_S, + log_path=log_path, + ) + return 1 + + logger.info("proxy ready on port %d", resolved_port) + print_ready_banner( + port=resolved_port, + display_model=display_model, + log_path=log_path, + strategy_summary=strategy_summary, + routes=[display_model], + default_route=display_model, + ) + if stdin_is_tty(): + banner_pause() + + if stdin_is_tty(): + footer = LiveStatsFooter( + stats, + display_model, + ProxyHealthMonitor(resolved_port), + strategy_label="config", + ) + return ShellTUI( + command=_opencode_command(opencode_bin, opencode_args), + footer_fn=footer.as_footer_fn(), + footer_height=lambda: footer.height, + env=_opencode_env(workspace_dir), + ).run() + + return _supervise_opencode( + opencode_bin, + opencode_args, + workspace_dir, + ) + finally: + print_session_summary(stats) + server.close() + _remove_opencode_workspace(workspace_dir) + + +def launch_opencode_config( + config: Path, + model: str, + opencode_args: list[str], +) -> int: + """Run OpenCode against a native server TOML deployment.""" + catalog = [ + ( + model, + f"{_opencode_model_display_name(model)} (Switchyard)", + f"Route from {config.name}.", + ) + ] + return _run_opencode_with_switchyard( + config, + display_model=model, + opencode_args=opencode_args, + catalog_entries=catalog, + ) diff --git a/switchyard/cli/launchers/shell_tui.py b/switchyard/cli/launchers/shell_tui.py index 536902a40..78ddc13da 100644 --- a/switchyard/cli/launchers/shell_tui.py +++ b/switchyard/cli/launchers/shell_tui.py @@ -18,20 +18,25 @@ from __future__ import annotations import errno -import fcntl import os -import pty import select import signal import struct import sys -import termios import threading import time -import tty import types from collections.abc import Callable +# POSIX-only modules backing the PTY footer. Windows has no pseudo-terminal, +# so the launchers never construct ShellTUI there (`stdin_is_tty()` is False); +# these imports stay guarded so the module still imports cleanly on Windows. +if os.name != "nt": + import fcntl + import pty + import termios + import tty + ESC = b"\x1b" CSI = ESC + b"[" diff --git a/switchyard/cli/switchyard_cli.py b/switchyard/cli/switchyard_cli.py index 49ed84bdd..cdd3ebe29 100644 --- a/switchyard/cli/switchyard_cli.py +++ b/switchyard/cli/switchyard_cli.py @@ -10,7 +10,9 @@ from switchyard.cli.launch_command import ( cmd_launch_claude, cmd_launch_codex, + cmd_launch_hermes, cmd_launch_openclaw, + cmd_launch_opencode, ) @@ -26,6 +28,8 @@ def _add_launch_parser( ("claude", "Launch Claude Code", "claude_args", cmd_launch_claude), ("codex", "Launch Codex CLI", "codex_args", cmd_launch_codex), ("openclaw", "Launch OpenClaw", "openclaw_args", cmd_launch_openclaw), + ("opencode", "Launch OpenCode", "opencode_args", cmd_launch_opencode), + ("hermes", "Launch Hermes", "hermes_args", cmd_launch_hermes), ) for name, help_text, args_dest, command in launcher_parsers: agent = launch_sub.add_parser(name, help=help_text) diff --git a/tests/test_launcher_windows_compat.py b/tests/test_launcher_windows_compat.py new file mode 100644 index 000000000..023f5fda1 --- /dev/null +++ b/tests/test_launcher_windows_compat.py @@ -0,0 +1,81 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Platform-portability tests for the shared launcher runtime. + +The launchers must degrade gracefully on Windows: no POSIX-only imports at +module load time, batch-shim subprocess routing, permission-bit-free +executable detection, and a platform-appropriate state directory. These tests +flip the module's ``_IS_WINDOWS``/``_IS_POSIX`` constants directly so the real +``os`` module (and pathlib's Windows/PosixPath selection) is never mutated. +""" + +import os +from pathlib import Path + +import pytest + +from switchyard.cli.launchers import launcher_runtime + + +def test_is_windows_batch_shim_only_on_nt(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(launcher_runtime, "_IS_WINDOWS", True) + assert launcher_runtime.is_windows_batch_shim(r"C:\npm\opencode.cmd") is True + assert launcher_runtime.is_windows_batch_shim(r"C:\npm\opencode.bat") is True + assert launcher_runtime.is_windows_batch_shim(r"C:\npm\opencode.exe") is False + assert launcher_runtime.is_windows_batch_shim(r"C:\npm\opencode") is False + + +def test_is_windows_batch_shim_never_true_on_posix(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(launcher_runtime, "_IS_WINDOWS", False) + assert launcher_runtime.is_windows_batch_shim("opencode.cmd") is False + + +def test_is_executable_file_ignores_permission_bit_on_nt( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + candidate = tmp_path / "opencode" + candidate.write_text("shim", encoding="utf-8") + + monkeypatch.setattr(launcher_runtime, "_IS_WINDOWS", True) + assert launcher_runtime.is_executable_file(candidate) is True + + +def test_is_executable_file_requires_x_bit_on_posix( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, +) -> None: + candidate = tmp_path / "opencode" + candidate.write_text("#!/bin/sh\n", encoding="utf-8") + os.chmod(candidate, 0o644) + + monkeypatch.setattr(launcher_runtime, "_IS_WINDOWS", False) + assert launcher_runtime.is_executable_file(candidate) is False + + os.chmod(candidate, 0o755) + assert launcher_runtime.is_executable_file(candidate) is True + + +def test_is_executable_file_missing_file_is_false(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(launcher_runtime, "_IS_WINDOWS", True) + assert launcher_runtime.is_executable_file(Path("/nonexistent/opencode")) is False + + +def test_default_state_dir_uses_localappdata_on_nt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(launcher_runtime, "_IS_WINDOWS", True) + monkeypatch.setenv("LOCALAPPDATA", r"C:\Users\dev\AppData\Local") + + assert launcher_runtime._default_state_dir() == Path(r"C:\Users\dev\AppData\Local") + + +def test_default_state_dir_is_home_dot_local_on_posix( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(launcher_runtime, "_IS_WINDOWS", False) + assert launcher_runtime._default_state_dir() == Path.home() / ".local" / "state" + + +def test_stdin_is_tty_false_on_windows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(launcher_runtime, "_IS_POSIX", False) + assert launcher_runtime.stdin_is_tty() is False diff --git a/tests/test_launchers.py b/tests/test_launchers.py index 6873c7b75..ce71e618a 100644 --- a/tests/test_launchers.py +++ b/tests/test_launchers.py @@ -12,14 +12,13 @@ from switchyard.cli.launchers.claude_code_launcher import _claude_env from switchyard.cli.launchers.codex_cli_launcher import _codex_env, _provider_overrides from switchyard.cli.launchers.native_server import NativeServer +from switchyard.cli.launchers.openclaw_launcher import _openclaw_command from switchyard.cli.switchyard_cli import _build_parser def _subparsers(parser: argparse.ArgumentParser) -> dict[str, argparse.ArgumentParser]: action = next( - action - for action in parser._actions - if isinstance(action, argparse._SubParsersAction) + action for action in parser._actions if isinstance(action, argparse._SubParsersAction) ) return action.choices # type: ignore[return-value] @@ -28,7 +27,7 @@ def test_cli_exposes_only_launch() -> None: assert set(_subparsers(_build_parser())) == {"launch"} -@pytest.mark.parametrize("agent", ["claude", "codex", "openclaw"]) +@pytest.mark.parametrize("agent", ["claude", "codex", "openclaw", "opencode", "hermes"]) def test_launcher_surface_is_model_config_and_forwarded_args(agent: str) -> None: launch = _subparsers(_build_parser())["launch"] parser = _subparsers(launch)[agent] @@ -63,6 +62,18 @@ def test_claude_env_preserves_small_fast_model_override( assert env["ANTHROPIC_SMALL_FAST_MODEL"] == "background-route" +def test_openclaw_command_defaults_to_interactive_chat() -> None: + assert _openclaw_command("/usr/bin/openclaw", []) == ["/usr/bin/openclaw", "chat"] + + +def test_openclaw_command_forwards_agent_command() -> None: + assert _openclaw_command("/usr/bin/openclaw", ["run", "do the thing"]) == [ + "/usr/bin/openclaw", + "run", + "do the thing", + ] + + def test_forward_auth_does_not_replace_the_agent_login( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/tests/test_launchers_hermes_opencode.py b/tests/test_launchers_hermes_opencode.py new file mode 100644 index 000000000..6422923f3 --- /dev/null +++ b/tests/test_launchers_hermes_opencode.py @@ -0,0 +1,121 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Contract tests for the Hermes and OpenCode launchers.""" + +import json + +from switchyard.cli.launchers.hermes_launcher import ( + _hermes_command, + _hermes_env, +) +from switchyard.cli.launchers.opencode_launcher import ( + _build_opencode_config, + _opencode_command, + _opencode_env, + _qualified_model_id, +) + + +def test_hermes_env_points_proxy_and_placeholder_key() -> None: + env = _hermes_env(4321) + assert env["OPENROUTER_BASE_URL"] == "http://127.0.0.1:4321/v1" + assert env["OPENROUTER_API_KEY"] == "switchyard" + + +def test_hermes_command_defaults_to_interactive_chat(monkeypatch) -> None: + cmd = _hermes_command("/usr/bin/hermes", [], "my-route") + assert cmd == [ + "/usr/bin/hermes", + "chat", + "--provider", + "custom", + "-m", + "my-route", + ] + + +def test_hermes_command_keeps_forwarded_args(monkeypatch) -> None: + cmd = _hermes_command( + "/usr/bin/hermes", + ["-z", "summarize this", "--reasoning", "high"], + "my-route", + ) + # Routing flags lead; Hermes' own command + flags follow verbatim. + assert cmd == [ + "/usr/bin/hermes", + "--provider", + "custom", + "-m", + "my-route", + "-z", + "summarize this", + "--reasoning", + "high", + ] + + +def test_hermes_command_forwards_explicit_subcommand(monkeypatch) -> None: + cmd = _hermes_command( + "/usr/bin/hermes", + ["chat", "-q", "hi", "-Q"], + "my-route", + ) + assert cmd == [ + "/usr/bin/hermes", + "--provider", + "custom", + "-m", + "my-route", + "chat", + "-q", + "hi", + "-Q", + ] + + +def test_opencode_qualified_model_id() -> None: + assert _qualified_model_id("switchyard") == "switchyard/switchyard" + assert _qualified_model_id("/route") == "switchyard/route" + + +def test_opencode_config_points_proxy_and_primary_model() -> None: + entries = [("switchyard", "Switchyard (Switchyard)", "desc")] + cfg = json.loads(json.dumps(_build_opencode_config(4321, entries, "switchyard/switchyard"))) + provider = cfg["provider"]["switchyard"] + assert provider["npm"] == "@ai-sdk/openai-compatible" + assert provider["options"]["baseURL"] == "http://127.0.0.1:4321/v1" + assert provider["options"]["apiKey"] == "switchyard" + assert "switchyard" in provider["models"] + assert cfg["model"] == "switchyard/switchyard" + + +def test_opencode_config_serializes_to_valid_json(tmp_path, monkeypatch) -> None: + entries = [("switchyard", "Switchyard", "desc")] + cfg = _build_opencode_config(1234, entries, "switchyard/switchyard") + payload = json.dumps(cfg) + parsed = json.loads(payload) + assert parsed["provider"]["switchyard"]["options"]["apiKey"] == "switchyard" + + +def test_opencode_env_selects_config_dir() -> None: + env = _opencode_env("/tmp/switchyard-opencode-abc") + assert env["OPENCODE_CONFIG_DIR"] == "/tmp/switchyard-opencode-abc" + + +def test_opencode_command_forwards_run_verbatim(monkeypatch) -> None: + # Model is selected via the transient config, so the CLI carries no model + # flag; ``run`` + its own args are forwarded verbatim. + cmd = _opencode_command("/usr/bin/opencode", ["run", "--auto", "fix"]) + assert cmd == ["/usr/bin/opencode", "run", "--auto", "fix"] + + +def test_opencode_command_forwards_arbitrary_subcommand(monkeypatch) -> None: + # ``serve`` rejects ``-m``; forwarding the command verbatim keeps it valid. + cmd = _opencode_command("/usr/bin/opencode", ["serve", "--port", "4096"]) + assert cmd == ["/usr/bin/opencode", "serve", "--port", "4096"] + + +def test_opencode_command_defaults_to_tui(monkeypatch) -> None: + cmd = _opencode_command("/usr/bin/opencode", []) + assert cmd == ["/usr/bin/opencode"]