diff --git a/docs/en/docs/how-to/install-and-run.md b/docs/en/docs/how-to/install-and-run.md index 2f63ca3da..beae2f8e5 100644 --- a/docs/en/docs/how-to/install-and-run.md +++ b/docs/en/docs/how-to/install-and-run.md @@ -25,6 +25,19 @@ powercontext setup dsh --source oceanbase/powercontext --ref powercontext setup pi --source oceanbase/powercontext --ref ``` +Per-host commands remain the explicit path. The first-class catalog contains `codex`, `claude-code`, `dsh`, +`openclaw`, `opencode`, `pi`, and `hermes`. To install more than one in one run, pass `--host` repeatedly, or omit +`--host` on a TTY to choose from the catalog. `powercontext setup` with no subcommand still prints help: + +```bash +powercontext setup select --host codex --host dsh --source oceanbase/powercontext --ref +``` + +Without `--server-url`, Claude Code and OpenClaw keep their `http://127.0.0.1:8000` defaults. An explicit +`--server-url` overrides both selected hosts. OpenClaw's `--scope-mode` defaults to `agent`. Selected Codex, DSH, +OpenCode, Pi, and Hermes integrations pass their existing post-install diagnostics before they are reported as +installed. After installing Hermes, run `hermes memory setup` and select PowerContext before starting Hermes. + For host-specific options, see [Configure Codex](configure-codex.md) and [Configure DeepSeek Harness](configure-dsh.md). diff --git a/docs/en/docs/how-to/troubleshoot.md b/docs/en/docs/how-to/troubleshoot.md index 2b25f8034..7b82b265b 100644 --- a/docs/en/docs/how-to/troubleshoot.md +++ b/docs/en/docs/how-to/troubleshoot.md @@ -33,7 +33,7 @@ git ls-remote https://github.com/oceanbase/powercontext.git HEAD If this fails, configure the credential helper or SSH key used by Git, then rerun `uv tool install`. `uv` uses Git's credential configuration; PowerContext does not accept or store repository credentials. -## `powercontext`, `codex`, `claude`, `dsh`, or `pi` is not found +## A PowerContext or host CLI is not found Run: @@ -43,12 +43,17 @@ command -v powercontext command -v codex command -v claude command -v dsh +command -v openclaw +command -v opencode command -v pi +command -v hermes ``` Add the uv tool bin directory to `PATH` if needed. `powercontext setup codex`, `powercontext setup claude-code`, -`powercontext setup dsh`, and `powercontext setup pi` report an error rather than attempting installation when the host -CLI is unavailable. +`powercontext setup dsh`, `powercontext setup openclaw`, `powercontext setup opencode`, `powercontext setup pi`, and +`powercontext setup hermes` report an error rather than attempting installation when the host CLI is unavailable. +`powercontext setup select` installs only the hosts you choose. A selected host that is missing still fails that row +and does not block the other selected hosts. An unselected host is skipped even if its CLI is on `PATH`. ## The plugin is missing or stale diff --git a/docs/en/docs/reference/interfaces.md b/docs/en/docs/reference/interfaces.md index b88b4faf5..6e29b2b99 100644 --- a/docs/en/docs/reference/interfaces.md +++ b/docs/en/docs/reference/interfaces.md @@ -100,12 +100,20 @@ boundary flushing fail open; explicit durable writes require interactive confirm ```text powercontext setup codex +powercontext setup claude-code powercontext setup dsh +powercontext setup openclaw +powercontext setup opencode powercontext setup pi +powercontext setup hermes +powercontext setup select powercontext doctor powercontext doctor codex +powercontext doctor claude-code powercontext doctor dsh +powercontext doctor opencode powercontext doctor pi +powercontext doctor hermes powercontext server run powercontext ready powercontext capabilities diff --git a/docs/zh/docs/how-to/install-and-run.md b/docs/zh/docs/how-to/install-and-run.md index 260fa5d60..3b075d2dc 100644 --- a/docs/zh/docs/how-to/install-and-run.md +++ b/docs/zh/docs/how-to/install-and-run.md @@ -24,6 +24,19 @@ powercontext setup dsh --source oceanbase/powercontext --ref powercontext setup pi --source oceanbase/powercontext --ref ``` +单宿主命令仍是显式路径。一级宿主目录包含 `codex`、`claude-code`、`dsh`、`openclaw`、`opencode`、`pi` +和 `hermes`。若要一次安装多个宿主,可重复传入 `--host`;在 TTY 上省略 `--host` 则从目录中选择。不带子命令的 +`powercontext setup` 仍然只打印帮助: + +```bash +powercontext setup select --host codex --host dsh --source oceanbase/powercontext --ref +``` + +未传入 `--server-url` 时,Claude Code 和 OpenClaw 保留 `http://127.0.0.1:8000` 默认值;显式传入该选项时会覆盖 +两个被选中宿主的地址。OpenClaw 的 `--scope-mode` 默认值为 `agent`。Codex、DSH、OpenCode、Pi 和 Hermes 只有在 +通过现有安装后诊断后才会报告为 installed。安装 Hermes 后,还需运行 `hermes memory setup` 并选择 PowerContext, +然后再启动 Hermes。 + 宿主专有选项见[配置 Codex](configure-codex.md)和[配置 DeepSeek Harness](configure-dsh.md)。 ## 运行本地 Server diff --git a/docs/zh/docs/how-to/troubleshoot.md b/docs/zh/docs/how-to/troubleshoot.md index d8e111cd5..920d92b56 100644 --- a/docs/zh/docs/how-to/troubleshoot.md +++ b/docs/zh/docs/how-to/troubleshoot.md @@ -33,7 +33,7 @@ git ls-remote https://github.com/oceanbase/powercontext.git HEAD 如果失败,请配置 Git 使用的 credential helper 或 SSH key,再重新运行 `uv tool install`。`uv` 使用 Git 凭据配置;PowerContext 不接收或保存仓库凭据。 -## 找不到 `powercontext`、`codex`、`claude`、`dsh` 或 `pi` +## 找不到 PowerContext 或宿主 CLI 执行: @@ -43,11 +43,17 @@ command -v powercontext command -v codex command -v claude command -v dsh +command -v openclaw +command -v opencode command -v pi +command -v hermes ``` 必要时把 uv tool bin 目录加入 `PATH`。宿主 CLI 不可用时,`powercontext setup codex`、 -`powercontext setup claude-code`、`powercontext setup dsh` 和 `powercontext setup pi` 都会报告错误,而不会尝试安装。 +`powercontext setup claude-code`、`powercontext setup dsh`、`powercontext setup openclaw`、 +`powercontext setup opencode`、`powercontext setup pi` 和 `powercontext setup hermes` 都会报告错误,而不会尝试安装。 +`powercontext setup select` 只安装你选中的宿主。某个选中宿主未安装时,该行失败,但不会阻塞其余选中项; +未选中的宿主即使已在 `PATH` 上也不会安装。 ## 插件缺失或版本不一致 diff --git a/docs/zh/docs/reference/interfaces.md b/docs/zh/docs/reference/interfaces.md index b2cf9530b..771de05f6 100644 --- a/docs/zh/docs/reference/interfaces.md +++ b/docs/zh/docs/reference/interfaces.md @@ -88,12 +88,20 @@ Pi transcript。召回、采集和边界 flush 都会正常降级;显式持久 ```text powercontext setup codex +powercontext setup claude-code powercontext setup dsh +powercontext setup openclaw +powercontext setup opencode powercontext setup pi +powercontext setup hermes +powercontext setup select powercontext doctor powercontext doctor codex +powercontext doctor claude-code powercontext doctor dsh +powercontext doctor opencode powercontext doctor pi +powercontext doctor hermes powercontext server run powercontext ready powercontext capabilities diff --git a/src/powercontext/cli/hosts.py b/src/powercontext/cli/hosts.py new file mode 100644 index 000000000..461b9a76b --- /dev/null +++ b/src/powercontext/cli/hosts.py @@ -0,0 +1,323 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""First-class host catalog and opt-in multi-host setup orchestration.""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Sequence +from dataclasses import dataclass + +import typer + + +@dataclass(frozen=True, slots=True) +class HostSpec: + """One first-class setup target. PATH is not used to build this catalog.""" + + name: str + label: str + + +FIRST_CLASS_HOSTS: tuple[HostSpec, ...] = ( + HostSpec("codex", "Codex"), + HostSpec("claude-code", "Claude Code"), + HostSpec("dsh", "DeepSeek Harness"), + HostSpec("openclaw", "OpenClaw"), + HostSpec("opencode", "OpenCode"), + HostSpec("pi", "Pi"), + HostSpec("hermes", "Hermes"), +) +HOST_NAMES: tuple[str, ...] = tuple(host.name for host in FIRST_CLASS_HOSTS) +_HOST_INDEX: dict[str, str] = {str(index): host.name for index, host in enumerate(FIRST_CLASS_HOSTS, start=1)} + + +class SetupSelectError(RuntimeError): + """Invalid setup select usage or host selection.""" + + @classmethod + def json_requires_host(cls) -> SetupSelectError: + return cls("setup select --json requires --host.") + + @classmethod + def tty_requires_host(cls) -> SetupSelectError: + return cls("setup select requires --host when stdin is not a TTY.") + + @classmethod + def unknown_host(cls, token: str) -> SetupSelectError: + return cls(f"unknown host: {token}. Choose from: {', '.join(HOST_NAMES)}.") + + +@dataclass(frozen=True, slots=True) +class HostSetupRow: + """One catalog row in a setup select report.""" + + host: str + status: str + error: str | None = None + + def as_json(self) -> dict[str, str]: + payload = {"host": self.host, "status": self.status} + if self.error is not None: + payload["error"] = self.error + return payload + + +@dataclass(frozen=True, slots=True) +class SetupSelectReport: + """Per-host matrix for one setup select run.""" + + hosts: tuple[HostSetupRow, ...] + + @property + def has_failure(self) -> bool: + return any(row.status == "failed" for row in self.hosts) + + @property + def has_installed(self) -> bool: + return any(row.status == "installed" for row in self.hosts) + + def is_installed(self, host: str) -> bool: + """Return whether one host completed installation and verification.""" + + return any(row.host == host and row.status == "installed" for row in self.hosts) + + +def stdin_is_tty() -> bool: + """Return True when the current stdin can prompt for a host selection.""" + + return sys.stdin.isatty() + + +def parse_host_selection(text: str) -> tuple[str, ...] | None: + """Parse a comma-separated catalog selection. Empty input cancels.""" + + stripped = text.strip() + if not stripped: + return None + selected: set[str] = set() + for token in (part.strip() for part in stripped.split(",")): + if not token: + continue + selected.add(_resolve_host_token(token)) + return tuple(host.name for host in FIRST_CLASS_HOSTS if host.name in selected) + + +def normalize_requested_hosts(hosts: Sequence[str]) -> tuple[str, ...]: + """Validate --host values, drop duplicates, and keep catalog order.""" + + selected = {_resolve_host_token(name) for name in hosts} + return tuple(host.name for host in FIRST_CLASS_HOSTS if host.name in selected) + + +def run_setup_select( + *, + hosts: Sequence[str] | None, + source: str, + ref: str, + server_url: str | None, + scope_mode: str, + capture_prompts: bool, + json_output: bool, +) -> None: + """Resolve a selection, install those hosts, and print the matrix.""" + + try: + selected = resolve_selected_hosts(requested=hosts, json_output=json_output) + except SetupSelectError as error: + typer.echo(str(error), err=True) + raise typer.Exit(code=1) from error + if selected is None: + return + report = setup_selected_hosts( + selected=selected, + source=source, + ref=ref, + server_url=server_url, + scope_mode=scope_mode, + capture_prompts=capture_prompts, + ) + write_setup_select_report(report, json_output=json_output) + if report.has_failure: + raise typer.Exit(code=1) + + +def resolve_selected_hosts(*, requested: Sequence[str] | None, json_output: bool) -> tuple[str, ...] | None: + """Return catalog names to install, or None when the user cancels.""" + + if requested: + return normalize_requested_hosts(requested) + if json_output: + raise SetupSelectError.json_requires_host() + if not stdin_is_tty(): + raise SetupSelectError.tty_requires_host() + _write_host_catalog() + return parse_host_selection(sys.stdin.readline()) + + +def setup_selected_hosts( + *, + selected: Sequence[str], + source: str, + ref: str, + server_url: str | None, + scope_mode: str, + capture_prompts: bool, +) -> SetupSelectReport: + """Install selected hosts and isolate failures from sibling hosts.""" + + from powercontext.cli.system import SetupError + + selected_names = set(selected) + rows: list[HostSetupRow] = [] + for host in FIRST_CLASS_HOSTS: + if host.name not in selected_names: + rows.append(HostSetupRow(host=host.name, status="skipped")) + continue + try: + install_host( + host.name, + source=source, + ref=ref, + server_url=server_url, + scope_mode=scope_mode, + capture_prompts=capture_prompts, + ) + verify_host(host.name) + except SetupError as error: + rows.append(HostSetupRow(host=host.name, status="failed", error=str(error))) + continue + rows.append(HostSetupRow(host=host.name, status="installed")) + return SetupSelectReport(hosts=tuple(rows)) + + +def install_host( + name: str, + *, + source: str, + ref: str, + server_url: str | None, + scope_mode: str, + capture_prompts: bool, +) -> object: + """Call the existing installer for one first-class host.""" + + if name == "codex": + from powercontext.cli.system import install_codex_plugin + + return install_codex_plugin(source=source, ref=ref) + if name == "claude-code": + from powercontext.cli.system import DEFAULT_CLAUDE_CODE_SERVER_URL, install_claude_code_plugin + + return install_claude_code_plugin( + source=source, + ref=ref, + server_url=server_url if server_url is not None else DEFAULT_CLAUDE_CODE_SERVER_URL, + capture_prompts=capture_prompts, + ) + if name == "dsh": + from powercontext.cli.dsh import install_dsh_plugin + + return install_dsh_plugin(source=source, ref=ref) + if name == "openclaw": + from powercontext.cli.openclaw import install_openclaw_plugin + from powercontext.cli.system import DEFAULT_OPENCLAW_SERVER_URL + + return install_openclaw_plugin( + source=source, + ref=ref, + server_url=server_url if server_url is not None else DEFAULT_OPENCLAW_SERVER_URL, + scope_mode=scope_mode, + ) + if name == "opencode": + from powercontext.cli.opencode import install_opencode_plugin + + return install_opencode_plugin(source=source, ref=ref) + if name == "pi": + from powercontext.cli.pi import install_pi_plugin + + return install_pi_plugin(source=source, ref=ref) + if name == "hermes": + from powercontext.cli.hermes import install_hermes_plugin + + return install_hermes_plugin(source=source, ref=ref) + raise SetupSelectError.unknown_host(name) + + +def verify_host(name: str) -> None: + """Run the post-install diagnostics used by the matching single-host setup command.""" + + from powercontext.cli.system import SetupError + + if name == "codex": + from powercontext.cli.system import run_codex_diagnostics + + diagnostics = run_codex_diagnostics() + elif name == "dsh": + from powercontext.cli.dsh import run_dsh_diagnostics + + diagnostics = run_dsh_diagnostics() + elif name == "pi": + from powercontext.cli.pi import run_pi_diagnostics + + diagnostics = run_pi_diagnostics() + elif name == "hermes": + from powercontext.cli.hermes import run_hermes_diagnostics + + diagnostics = run_hermes_diagnostics() + elif name == "opencode": + from powercontext.cli.opencode import run_opencode_diagnostics + + diagnostics = run_opencode_diagnostics() + elif name in {"claude-code", "openclaw"}: + return + else: + raise SetupSelectError.unknown_host(name) + + failures = [f"{check}: {diagnostic.detail}" for check, diagnostic in diagnostics.items() if not diagnostic.ok] + if failures: + raise SetupError.post_install_verification(failures) + + +def write_setup_select_report(report: SetupSelectReport, *, json_output: bool) -> None: + """Write the per-host matrix as JSON or human text.""" + + if json_output: + typer.echo(json.dumps({"hosts": [row.as_json() for row in report.hosts]}, indent=2)) + return + for row in report.hosts: + if row.status == "failed" and row.error is not None: + typer.echo(f"{row.host}: failed - {row.error}") + continue + typer.echo(f"{row.host}: {row.status}") + if report.has_installed: + typer.echo("Next: run `powercontext server run`, then start a new host session.") + if report.is_installed("hermes"): + typer.echo("Hermes: run `hermes memory setup` and select PowerContext before starting Hermes.") + + +def _resolve_host_token(token: str) -> str: + name = _HOST_INDEX.get(token, token) + if name not in HOST_NAMES: + raise SetupSelectError.unknown_host(token) + return name + + +def _write_host_catalog() -> None: + typer.echo("Official first-class integrations:") + for index, host in enumerate(FIRST_CLASS_HOSTS, start=1): + typer.echo(f" {index}) {host.label} ({host.name})") + typer.echo("Select hosts by number or name (comma-separated), or press Enter to cancel:") diff --git a/src/powercontext/cli/system.py b/src/powercontext/cli/system.py index 0f8b37047..f19d76509 100644 --- a/src/powercontext/cli/system.py +++ b/src/powercontext/cli/system.py @@ -40,6 +40,9 @@ HELP_OPTION_NAMES = ("-h", "--help") DEFAULT_MARKETPLACE_SOURCE = "oceanbase/powercontext" DEFAULT_MARKETPLACE_REF = "master" +DEFAULT_CLAUDE_CODE_SERVER_URL = "http://127.0.0.1:8000" +DEFAULT_OPENCLAW_SERVER_URL = "http://127.0.0.1:8000" +DEFAULT_OPENCLAW_SCOPE_MODE = "agent" PLUGIN_NAME = "powercontext" CLAUDE_MARKETPLACE_NAME = "powercontext" _GITHUB_REPOSITORY = re.compile(r"^[^/\s]+/[^/\s]+$") @@ -232,6 +235,10 @@ def invalid_command_output(cls, command: list[str], detail: str) -> SetupError: def missing_result(cls, name: str) -> SetupError: return cls(f"Integration CLI did not return {name}") + @classmethod + def post_install_verification(cls, failures: list[str]) -> SetupError: + return cls(f"post-install verification failed: {'; '.join(failures)}") + @classmethod def claude_plugin_not_enabled(cls) -> SetupError: return cls("Claude Code did not report an enabled PowerContext plugin after installation.") @@ -380,7 +387,7 @@ def setup_claude_code( server_url: Annotated[ str, typer.Option(help="PowerContext Server base URL configured for the plugin."), - ] = "http://127.0.0.1:8000", + ] = DEFAULT_CLAUDE_CODE_SERVER_URL, capture_prompts: Annotated[ bool, typer.Option(help="Capture Claude Code user prompts as ordinary Source evidence."), @@ -466,11 +473,11 @@ def setup_openclaw( server_url: Annotated[ str, typer.Option(help="PowerContext Server base URL configured for the plugin."), - ] = "http://127.0.0.1:8000", + ] = DEFAULT_OPENCLAW_SERVER_URL, scope_mode: Annotated[ str, typer.Option("--scope-mode", help="Memory scope mode: agent or project."), - ] = "agent", + ] = DEFAULT_OPENCLAW_SCOPE_MODE, json_output: Annotated[ bool, typer.Option("--json", help="Write the result as JSON."), @@ -622,6 +629,52 @@ def setup_hermes( typer.echo("Next: run `hermes memory setup`, select PowerContext, then start Hermes.") +@setup_app.command("select") +def setup_select( + host: Annotated[ + list[str] | None, + typer.Option(help="First-class host to install. Repeatable. Required with --json or a non-TTY."), + ] = None, + source: Annotated[ + str, + typer.Option(help="Git source or local path passed to each selected installer."), + ] = DEFAULT_MARKETPLACE_SOURCE, + ref: Annotated[ + str, + typer.Option(help="Git ref used for a remote source."), + ] = DEFAULT_MARKETPLACE_REF, + server_url: Annotated[ + str | None, + typer.Option(help="PowerContext Server base URL override for Claude Code and OpenClaw."), + ] = None, + scope_mode: Annotated[ + str, + typer.Option("--scope-mode", help="OpenClaw memory scope mode: agent or project."), + ] = DEFAULT_OPENCLAW_SCOPE_MODE, + capture_prompts: Annotated[ + bool, + typer.Option(help="Capture Claude Code user prompts as ordinary Source evidence."), + ] = True, + json_output: Annotated[ + bool, + typer.Option("--json", help="Write the result as JSON."), + ] = False, +) -> None: + """Install selected first-class host plugins without scanning PATH.""" + + from powercontext.cli.hosts import run_setup_select + + run_setup_select( + hosts=host, + source=source, + ref=ref, + server_url=server_url, + scope_mode=scope_mode, + capture_prompts=capture_prompts, + json_output=json_output, + ) + + @doctor_app.callback() def doctor( context: typer.Context, diff --git a/tests/test_setup_select.py b/tests/test_setup_select.py new file mode 100644 index 000000000..b40ebdb0b --- /dev/null +++ b/tests/test_setup_select.py @@ -0,0 +1,418 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +from unittest.mock import Mock + +import pytest +from typer.testing import CliRunner + +import powercontext.cli.dsh as dsh_cli +import powercontext.cli.hermes as hermes_cli +import powercontext.cli.hosts as hosts_cli +import powercontext.cli.openclaw as openclaw_cli +import powercontext.cli.opencode as opencode_cli +import powercontext.cli.pi as pi_cli +import powercontext.cli.system as system_cli +from powercontext.cli.app import create_cli +from powercontext.cli.hosts import parse_host_selection +from powercontext.cli.system import Diagnostic, DiagnosticStatus, SetupError, setup_app + +FIRST_CLASS_HOSTS = ("codex", "claude-code", "dsh", "openclaw", "opencode", "pi", "hermes") + + +def _cli(): + return create_cli([setup_app]) + + +def _invoke(arguments: list[str], *, stdin_text: str | None = None): + return CliRunner().invoke(_cli(), arguments, input=stdin_text) + + +def _patch_installers(monkeypatch, **replacements: Mock) -> dict[str, Mock]: + _patch_diagnostics(monkeypatch) + installers = { + "codex": Mock(name="install_codex_plugin", return_value=object()), + "claude-code": Mock(name="install_claude_code_plugin", return_value=object()), + "dsh": Mock(name="install_dsh_plugin", return_value=object()), + "openclaw": Mock(name="install_openclaw_plugin", return_value=object()), + "opencode": Mock(name="install_opencode_plugin", return_value=object()), + "pi": Mock(name="install_pi_plugin", return_value=object()), + "hermes": Mock(name="install_hermes_plugin", return_value=object()), + } + installers.update(replacements) + monkeypatch.setattr(system_cli, "install_codex_plugin", installers["codex"]) + monkeypatch.setattr(system_cli, "install_claude_code_plugin", installers["claude-code"]) + monkeypatch.setattr(dsh_cli, "install_dsh_plugin", installers["dsh"]) + monkeypatch.setattr(openclaw_cli, "install_openclaw_plugin", installers["openclaw"]) + monkeypatch.setattr(opencode_cli, "install_opencode_plugin", installers["opencode"]) + monkeypatch.setattr(pi_cli, "install_pi_plugin", installers["pi"]) + monkeypatch.setattr(hermes_cli, "install_hermes_plugin", installers["hermes"]) + return installers + + +def _patch_diagnostics(monkeypatch, **replacements: Mock) -> dict[str, Mock]: + diagnostics = { + "codex": Mock(return_value={"plugin": Diagnostic(DiagnosticStatus.OK, "installed")}), + "dsh": Mock(return_value={"plugin": Diagnostic(DiagnosticStatus.OK, "installed")}), + "opencode": Mock(return_value={"plugin": Diagnostic(DiagnosticStatus.OK, "installed")}), + "pi": Mock(return_value={"package": Diagnostic(DiagnosticStatus.OK, "installed")}), + "hermes": Mock(return_value={"plugin": Diagnostic(DiagnosticStatus.OK, "installed")}), + } + diagnostics.update(replacements) + monkeypatch.setattr(system_cli, "run_codex_diagnostics", diagnostics["codex"]) + monkeypatch.setattr(dsh_cli, "run_dsh_diagnostics", diagnostics["dsh"]) + monkeypatch.setattr(opencode_cli, "run_opencode_diagnostics", diagnostics["opencode"]) + monkeypatch.setattr(pi_cli, "run_pi_diagnostics", diagnostics["pi"]) + monkeypatch.setattr(hermes_cli, "run_hermes_diagnostics", diagnostics["hermes"]) + return diagnostics + + +def _assert_not_called(*installers: Mock) -> None: + for installer in installers: + installer.assert_not_called() + + +def test_setup_without_subcommand_prints_help_and_installs_nothing(monkeypatch) -> None: + installers = _patch_installers(monkeypatch) + + result = _invoke(["setup"]) + + assert result.exit_code == 2 + assert "Usage" in result.output + assert "Install and configure PowerContext integrations." in result.output + _assert_not_called(*installers.values()) + + +def test_setup_select_json_without_host_does_not_install(monkeypatch) -> None: + installers = _patch_installers(monkeypatch) + + result = _invoke(["setup", "select", "--json"]) + + assert result.exit_code == 1 + assert "--host" in result.output + _assert_not_called(*installers.values()) + + +def test_setup_select_without_host_requires_host_on_a_non_tty(monkeypatch) -> None: + installers = _patch_installers(monkeypatch) + monkeypatch.setattr(hosts_cli, "stdin_is_tty", lambda: False) + + result = _invoke(["setup", "select"]) + + assert result.exit_code == 1 + assert "--host" in result.output + _assert_not_called(*installers.values()) + + +def test_setup_select_rejects_an_unknown_host_before_installing(monkeypatch) -> None: + installers = _patch_installers(monkeypatch) + + result = _invoke(["setup", "select", "--host", "unknown"]) + + assert result.exit_code == 1 + assert "unknown host: unknown" in result.output + assert "codex" in result.output + _assert_not_called(*installers.values()) + + +def test_setup_select_installs_only_the_requested_hosts(monkeypatch) -> None: + installers = _patch_installers(monkeypatch) + + result = _invoke(["setup", "select", "--host", "codex", "--host", "dsh", "--json"]) + + assert result.exit_code == 0 + assert json.loads(result.output) == { + "hosts": [ + {"host": "codex", "status": "installed"}, + {"host": "claude-code", "status": "skipped"}, + {"host": "dsh", "status": "installed"}, + {"host": "openclaw", "status": "skipped"}, + {"host": "opencode", "status": "skipped"}, + {"host": "pi", "status": "skipped"}, + {"host": "hermes", "status": "skipped"}, + ] + } + installers["codex"].assert_called_once() + installers["dsh"].assert_called_once() + _assert_not_called( + installers["claude-code"], + installers["openclaw"], + installers["opencode"], + installers["pi"], + installers["hermes"], + ) + + +def test_setup_select_continues_after_a_selected_host_fails(monkeypatch) -> None: + installers = _patch_installers( + monkeypatch, + dsh=Mock(side_effect=SetupError.dsh_unavailable()), + ) + + result = _invoke(["setup", "select", "--host", "dsh", "--host", "hermes", "--json"]) + + assert result.exit_code == 1 + assert json.loads(result.output) == { + "hosts": [ + {"host": "codex", "status": "skipped"}, + {"host": "claude-code", "status": "skipped"}, + { + "host": "dsh", + "status": "failed", + "error": "DeepSeek Harness CLI is not installed or is not on PATH.", + }, + {"host": "openclaw", "status": "skipped"}, + {"host": "opencode", "status": "skipped"}, + {"host": "pi", "status": "skipped"}, + {"host": "hermes", "status": "installed"}, + ] + } + installers["dsh"].assert_called_once() + installers["hermes"].assert_called_once() + + +def test_setup_select_reports_an_unavailable_selected_host_and_continues(monkeypatch) -> None: + installers = _patch_installers( + monkeypatch, + codex=Mock(side_effect=SetupError.codex_unavailable()), + ) + + result = _invoke(["setup", "select", "--host", "codex", "--host", "pi"]) + + assert result.exit_code == 1 + assert "codex: failed - Codex CLI is not installed or is not on PATH." in result.output + assert "pi: installed" in result.output + installers["codex"].assert_called_once() + installers["pi"].assert_called_once() + + +def test_setup_select_reports_a_successful_rerun_as_installed(monkeypatch) -> None: + installers = _patch_installers(monkeypatch) + + first = _invoke(["setup", "select", "--host", "codex", "--json"]) + second = _invoke(["setup", "select", "--host", "codex", "--json"]) + + assert first.exit_code == 0 + assert second.exit_code == 0 + assert json.loads(second.output)["hosts"][0] == {"host": "codex", "status": "installed"} + assert installers["codex"].call_count == 2 + + +def test_setup_select_reads_a_tty_selection_by_number(monkeypatch) -> None: + installers = _patch_installers(monkeypatch) + monkeypatch.setattr(hosts_cli, "stdin_is_tty", lambda: True) + + result = _invoke(["setup", "select"], stdin_text="1,3\n") + + assert result.exit_code == 0 + assert "codex: installed" in result.output + assert "dsh: installed" in result.output + assert "claude-code: skipped" in result.output + installers["codex"].assert_called_once() + installers["dsh"].assert_called_once() + _assert_not_called( + installers["claude-code"], + installers["openclaw"], + installers["opencode"], + installers["pi"], + installers["hermes"], + ) + + +def test_setup_select_cancels_an_empty_tty_selection(monkeypatch) -> None: + installers = _patch_installers(monkeypatch) + monkeypatch.setattr(hosts_cli, "stdin_is_tty", lambda: True) + + result = _invoke(["setup", "select"], stdin_text="\n") + + assert result.exit_code == 0 + _assert_not_called(*installers.values()) + + +def test_setup_select_rejects_an_invalid_tty_token_without_installing(monkeypatch) -> None: + installers = _patch_installers(monkeypatch) + monkeypatch.setattr(hosts_cli, "stdin_is_tty", lambda: True) + + result = _invoke(["setup", "select"], stdin_text="nope\n") + + assert result.exit_code == 1 + assert "unknown host: nope" in result.output + _assert_not_called(*installers.values()) + + +def test_setup_select_deduplicates_repeated_host_flags(monkeypatch) -> None: + installers = _patch_installers(monkeypatch) + + result = _invoke(["setup", "select", "--host", "codex", "--host", "codex", "--json"]) + + assert result.exit_code == 0 + installers["codex"].assert_called_once() + + +def test_setup_select_json_writes_the_matrix_without_a_prompt(monkeypatch) -> None: + installers = _patch_installers(monkeypatch) + monkeypatch.setattr(hosts_cli, "stdin_is_tty", lambda: True) + + result = _invoke(["setup", "select", "--host", "codex", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["hosts"][0] == {"host": "codex", "status": "installed"} + assert "Select hosts" not in result.output + assert "Next:" not in result.output + installers["codex"].assert_called_once() + + +def test_setup_select_passes_source_ref_and_host_specific_defaults(monkeypatch) -> None: + installers = _patch_installers(monkeypatch) + + result = _invoke([ + "setup", + "select", + "--host", + "codex", + "--host", + "claude-code", + "--host", + "openclaw", + "--host", + "opencode", + "--source", + "oceanbase/powercontext", + "--ref", + "tested-ref", + ]) + + assert result.exit_code == 0 + installers["codex"].assert_called_once_with(source="oceanbase/powercontext", ref="tested-ref") + installers["claude-code"].assert_called_once_with( + source="oceanbase/powercontext", + ref="tested-ref", + server_url="http://127.0.0.1:8000", + capture_prompts=True, + ) + installers["openclaw"].assert_called_once_with( + source="oceanbase/powercontext", + ref="tested-ref", + server_url="http://127.0.0.1:8000", + scope_mode="agent", + ) + installers["opencode"].assert_called_once_with(source="oceanbase/powercontext", ref="tested-ref") + + +def test_setup_select_passes_server_and_scope_overrides_to_openclaw(monkeypatch) -> None: + installers = _patch_installers(monkeypatch) + + result = _invoke([ + "setup", + "select", + "--host", + "openclaw", + "--server-url", + "https://memory.example", + "--scope-mode", + "project", + ]) + + assert result.exit_code == 0 + installers["openclaw"].assert_called_once_with( + source="oceanbase/powercontext", + ref="master", + server_url="https://memory.example", + scope_mode="project", + ) + + +@pytest.mark.parametrize( + ("host", "module", "attribute"), + [ + ("codex", system_cli, "run_codex_diagnostics"), + ("dsh", dsh_cli, "run_dsh_diagnostics"), + ("opencode", opencode_cli, "run_opencode_diagnostics"), + ("pi", pi_cli, "run_pi_diagnostics"), + ("hermes", hermes_cli, "run_hermes_diagnostics"), + ], +) +def test_setup_select_fails_a_row_when_post_install_verification_fails( + monkeypatch, + host: str, + module: object, + attribute: str, +) -> None: + installers = _patch_installers(monkeypatch) + verification = Mock( + return_value={"plugin": Diagnostic(DiagnosticStatus.FAILED, "PowerContext plugin is not loaded")} + ) + monkeypatch.setattr(module, attribute, verification) + + result = _invoke(["setup", "select", "--host", host, "--json"]) + + assert result.exit_code == 1 + row = next(row for row in json.loads(result.output)["hosts"] if row["host"] == host) + assert row == { + "host": host, + "status": "failed", + "error": "post-install verification failed: plugin: PowerContext plugin is not loaded", + } + installers[host].assert_called_once() + verification.assert_called_once_with() + + +def test_setup_select_continues_after_post_install_verification_fails(monkeypatch) -> None: + installers = _patch_installers(monkeypatch) + monkeypatch.setattr( + system_cli, + "run_codex_diagnostics", + Mock(return_value={"plugin": Diagnostic(DiagnosticStatus.FAILED, "plugin is not loaded")}), + ) + + result = _invoke(["setup", "select", "--host", "codex", "--host", "openclaw", "--json"]) + + assert result.exit_code == 1 + rows = {row["host"]: row for row in json.loads(result.output)["hosts"]} + assert rows["codex"]["status"] == "failed" + assert rows["openclaw"] == {"host": "openclaw", "status": "installed"} + installers["openclaw"].assert_called_once() + + +def test_setup_select_prints_hermes_specific_next_step_only_when_installed(monkeypatch) -> None: + _patch_installers(monkeypatch) + + installed = _invoke(["setup", "select", "--host", "hermes"]) + skipped = _invoke(["setup", "select", "--host", "openclaw"]) + + assert installed.exit_code == 0 + assert "`hermes memory setup`" in installed.output + assert "`hermes memory setup`" not in skipped.output + + +def test_setup_dsh_still_fails_closed_when_the_cli_is_missing(monkeypatch) -> None: + monkeypatch.setattr(dsh_cli, "which", lambda _name: None) + + result = _invoke(["setup", "dsh"]) + + assert result.exit_code == 1 + assert "DeepSeek Harness CLI is not installed" in result.output + + +def test_parse_host_selection_accepts_names_and_reorders_to_the_catalog() -> None: + assert parse_host_selection("dsh,codex") == ("codex", "dsh") + assert parse_host_selection("4") == ("openclaw",) + assert parse_host_selection("5") == ("opencode",) + assert parse_host_selection("7") == ("hermes",) + assert parse_host_selection("") is None + assert parse_host_selection(" ") is None