diff --git a/docs/en/docs/how-to/install-and-run.md b/docs/en/docs/how-to/install-and-run.md index 2f63ca3da..49084a9ee 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). @@ -86,9 +99,14 @@ powercontext capabilities ```bash powercontext doctor +powercontext doctor integrations powercontext doctor codex +powercontext doctor claude-code powercontext doctor dsh +powercontext doctor openclaw +powercontext doctor opencode powercontext doctor pi +powercontext doctor hermes powercontext ready powercontext capabilities ``` @@ -96,9 +114,11 @@ powercontext capabilities `doctor` checks the installed package, Server liveness, and Server readiness without requiring an integration. Server readiness covers the database and each configured inference provider. Runtime or database failures return `not_ready`; an inference failure returns `degraded` without removing database-backed operations from traffic. -`doctor codex`, `doctor dsh`, and `doctor pi` separately check their optional host CLI and PowerContext integration. The content commands exercise the -public HTTP SDK path. `ready` and `capabilities` show the readiness and enabled capabilities of the running service. For complete status definitions and -recovery steps, see [Troubleshoot](troubleshoot.md). +`doctor integrations` is an optional read-only overview of every first-class host; a missing CLI does not fail that +command. The individual `doctor ` commands check one optional host CLI and all of its PowerContext integration +items. The content commands exercise the public HTTP SDK path. `ready` and `capabilities` show the readiness and +enabled capabilities of the running service. For complete status definitions and recovery steps, see +[Troubleshoot](troubleshoot.md). ## Update or replace an installation diff --git a/docs/en/docs/how-to/troubleshoot.md b/docs/en/docs/how-to/troubleshoot.md index 2b25f8034..e52f90c6e 100644 --- a/docs/en/docs/how-to/troubleshoot.md +++ b/docs/en/docs/how-to/troubleshoot.md @@ -16,12 +16,20 @@ The command checks the package, Server liveness, and Server readiness. It exits the top-level result and every check include `ok` and `status`. Check optional host integrations separately: ```bash +powercontext doctor integrations powercontext doctor codex powercontext doctor claude-code powercontext doctor dsh +powercontext doctor openclaw +powercontext doctor opencode powercontext doctor pi +powercontext doctor hermes ``` +`doctor integrations` prints every first-class host. A host whose CLI is not on PATH is `missing` and does not fail +the command. A present host that is broken still exits 1. Single-host commands such as `doctor codex` stay fail-closed +when that CLI is missing. + ## Installation cannot read the Git URL Confirm that Git can read the repository: @@ -33,7 +41,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 +51,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 d16498d05..b03e53280 100644 --- a/docs/en/docs/reference/interfaces.md +++ b/docs/en/docs/reference/interfaces.md @@ -118,12 +118,22 @@ 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 integrations powercontext doctor codex +powercontext doctor claude-code powercontext doctor dsh +powercontext doctor openclaw +powercontext doctor opencode powercontext doctor pi +powercontext doctor hermes powercontext server run powercontext ready powercontext capabilities @@ -143,10 +153,11 @@ powercontext external-skill import --scope-id project:example --fingerprint SHA2 All content commands call the configured Server. The optional `server` role adds `powercontext server run`; it does not create a second content profile inside the CLI. -`powercontext doctor` checks the package and Server without requiring an integration. `powercontext doctor codex` -checks the Codex CLI and PowerContext plugin explicitly. `powercontext doctor dsh` checks the DeepSeek Harness CLI -and that dump-config lists the plugin id `powercontext-dsh`. `powercontext doctor pi` checks the Pi executable and -that Pi lists the PowerContext package. +`powercontext doctor` checks the package and Server without requiring an integration. `powercontext doctor integrations` +prints a read-only matrix for every first-class host; a missing CLI is `missing` and does not fail the command. +Each `powercontext doctor ` command still fails when that host CLI is missing. The matrix preserves every +host-specific integration check, including OpenCode's separate `plugin` and `skill` results. DSH checks that +`dump-config` lists `powercontext-dsh`; Pi checks that the CLI lists the PowerContext package. The `candidate` command group exposes the human Review Inbox. See [Review Candidates](../how-to/review-candidates.md) for the ordered workflow to list, inspect, revise, approve, or reject Candidates. diff --git a/docs/zh/docs/how-to/install-and-run.md b/docs/zh/docs/how-to/install-and-run.md index 260fa5d60..6b816c800 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 @@ -82,17 +95,24 @@ powercontext capabilities ```bash powercontext doctor +powercontext doctor integrations powercontext doctor codex +powercontext doctor claude-code powercontext doctor dsh +powercontext doctor openclaw +powercontext doctor opencode powercontext doctor pi +powercontext doctor hermes powercontext ready powercontext capabilities ``` `doctor` 检查已安装的包、Server 存活状态和 Server 就绪状态,不要求安装集成。Server 就绪检查涵盖数据库和 每个已配置的推理服务。Runtime 或数据库故障返回 `not_ready`;推理服务故障返回 `degraded`,不会使数据库 -操作退出流量。`doctor codex`、`doctor dsh` 和 `doctor pi` 分别检查对应的可选宿主 CLI 与 PowerContext 集成。内容命令会经过公开 HTTP SDK -路径。`ready` 和 `capabilities` 用于查看运行中服务的就绪状态和已启用能力。完整的状态解释和恢复步骤见[排查问题](troubleshoot.md)。 +操作退出流量。`doctor integrations` 是可选的一级宿主只读总览,缺失 CLI 不会让该命令失败。 +各个 `doctor ` 命令分别检查一个可选宿主 CLI 及其全部 PowerContext 集成项。内容命令会经过公开 HTTP SDK +路径。`ready` 和 `capabilities` 用于查看运行中服务的就绪状态和已启用能力。完整的状态解释和恢复步骤见 +[排查问题](troubleshoot.md)。 ## 更新或替换安装 diff --git a/docs/zh/docs/how-to/troubleshoot.md b/docs/zh/docs/how-to/troubleshoot.md index d8e111cd5..30d6529c5 100644 --- a/docs/zh/docs/how-to/troubleshoot.md +++ b/docs/zh/docs/how-to/troubleshoot.md @@ -16,12 +16,19 @@ powercontext doctor `ok` 与 `status`。可单独检查可选的宿主集成: ```bash +powercontext doctor integrations powercontext doctor codex powercontext doctor claude-code powercontext doctor dsh +powercontext doctor openclaw +powercontext doctor opencode powercontext doctor pi +powercontext doctor hermes ``` +`doctor integrations` 会打印全部一级宿主。CLI 不在 PATH 上时该行是 `missing`,不会让整条命令失败;已安装但异常的 +宿主仍会以状态码 1 退出。单宿主命令(如 `doctor codex`)在该 CLI 缺失时仍然失败。 + ## 安装时无法读取 Git 地址 确认 Git 能够读取仓库: @@ -33,7 +40,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 +50,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 eaa08b4f3..72f23b3f9 100644 --- a/docs/zh/docs/reference/interfaces.md +++ b/docs/zh/docs/reference/interfaces.md @@ -104,12 +104,22 @@ 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 integrations powercontext doctor codex +powercontext doctor claude-code powercontext doctor dsh +powercontext doctor openclaw +powercontext doctor opencode powercontext doctor pi +powercontext doctor hermes powercontext server run powercontext ready powercontext capabilities @@ -129,9 +139,10 @@ powercontext external-skill import --scope-id project:example --fingerprint SHA2 所有内容命令都调用已配置的 Server。可选的 `server` role 会增加 `powercontext server run`,但不会在 CLI 中创建第二套内容 profile。 -`powercontext doctor` 检查安装包和 Server,不要求任何集成;`powercontext doctor codex` 显式检查 Codex CLI -和 PowerContext 插件;`powercontext doctor dsh` 检查 DeepSeek Harness CLI,以及 dump-config 是否列出插件 id -`powercontext-dsh`;`powercontext doctor pi` 检查 Pi 可执行文件,以及 Pi 是否列出了 PowerContext package。 +`powercontext doctor` 检查安装包和 Server,不要求任何集成。`powercontext doctor integrations` 打印全部一级宿主的只读矩阵; +CLI 不在 PATH 上时该行是 `missing`,不会让整条命令失败。各个 `powercontext doctor ` 命令在对应 CLI +缺失时仍会失败。矩阵保留每个宿主专有的全部集成检查,包括 OpenCode 独立的 `plugin` 与 `skill` 结果。 +DSH 检查 `dump-config` 是否列出 `powercontext-dsh`;Pi 检查 CLI 是否列出 PowerContext package。 `candidate` 命令组提供面向人工的 Review Inbox。列出、检查、修订、批准和拒绝的操作步骤见 [审核 Candidate](../how-to/review-candidates.md)。 diff --git a/src/powercontext/cli/hosts.py b/src/powercontext/cli/hosts.py new file mode 100644 index 000000000..6e7cc0d4c --- /dev/null +++ b/src/powercontext/cli/hosts.py @@ -0,0 +1,477 @@ +# 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, opt-in multi-host setup, and read-only integration diagnostics.""" + +from __future__ import annotations + +import json +import sys +from collections.abc import Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import typer + +if TYPE_CHECKING: + from powercontext.cli.system import Diagnostic + + +@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)} +_INTEGRATION_KEYS = frozenset({"plugin", "package", "skill"}) +_PATH_MISSING = "is not installed or is not on PATH" + + +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) + + +@dataclass(frozen=True, slots=True) +class IntegrationRow: + """One first-class host in a doctor integrations report.""" + + host: str + presence: str + cli_key: str + cli: Diagnostic + integrations: tuple[tuple[str, Diagnostic], ...] + + @property + def failed(self) -> bool: + return self.presence == "present" and not ( + self.cli.ok and all(diagnostic.ok for _, diagnostic in self.integrations) + ) + + def as_json(self) -> dict[str, object]: + payload: dict[str, object] = { + "presence": self.presence, + self.cli_key: self.cli.as_json(), + } + payload.update({key: diagnostic.as_json() for key, diagnostic in self.integrations}) + return payload + + +@dataclass(frozen=True, slots=True) +class IntegrationReport: + """Read-only matrix of first-class host CLI and integration status.""" + + hosts: tuple[IntegrationRow, ...] + + @property + def has_present_failure(self) -> bool: + return any(row.failed for row in self.hosts) + + @property + def ok(self) -> bool: + return not self.has_present_failure + + @property + def status(self) -> str: + return "failed" if self.has_present_failure else "ok" + + def as_json(self) -> dict[str, object]: + return { + "ok": self.ok, + "status": self.status, + "hosts": {row.host: row.as_json() 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:") + + +def diagnose_host(name: str) -> dict[str, Diagnostic]: + """Lazily collect diagnostics for one first-class host.""" + + if name == "codex": + from powercontext.cli.system import run_codex_diagnostics + + return run_codex_diagnostics() + if name == "claude-code": + from powercontext.cli.system import run_claude_code_diagnostics + + return run_claude_code_diagnostics() + if name == "dsh": + from powercontext.cli.dsh import run_dsh_diagnostics + + return run_dsh_diagnostics() + if name == "openclaw": + from powercontext.cli.openclaw import run_openclaw_diagnostics + + return run_openclaw_diagnostics() + if name == "opencode": + from powercontext.cli.opencode import run_opencode_diagnostics + + return run_opencode_diagnostics() + if name == "pi": + from powercontext.cli.pi import run_pi_diagnostics + + return run_pi_diagnostics() + if name == "hermes": + from powercontext.cli.hermes import run_hermes_diagnostics + + return run_hermes_diagnostics() + raise SetupSelectError.unknown_host(name) + + +def split_host_diagnostics( + diagnostics: dict[str, Diagnostic], +) -> tuple[str, Diagnostic, tuple[tuple[str, Diagnostic], ...]]: + """Split one host probe into its CLI check and integration checks.""" + + cli_key = next(key for key in diagnostics if key not in _INTEGRATION_KEYS) + integrations = tuple((key, diagnostic) for key, diagnostic in diagnostics.items() if key in _INTEGRATION_KEYS) + return cli_key, diagnostics[cli_key], integrations + + +def classify_host_presence(cli: Diagnostic, integrations: tuple[tuple[str, Diagnostic], ...]) -> str: + """Mark a host missing only when PATH lookup failed and the integration was skipped.""" + + if _PATH_MISSING in cli.detail and all(diagnostic.status.value == "skipped" for _, diagnostic in integrations): + return "missing" + return "present" + + +def build_integration_row(name: str, diagnostics: dict[str, Diagnostic]) -> IntegrationRow: + """Classify one host diagnostic pair without deciding the command exit code.""" + + cli_key, cli, integrations = split_host_diagnostics(diagnostics) + return IntegrationRow( + host=name, + presence=classify_host_presence(cli, integrations), + cli_key=cli_key, + cli=cli, + integrations=integrations, + ) + + +def collect_integration_diagnostics() -> IntegrationReport: + """Walk the shared catalog and collect a row for every first-class host.""" + + return IntegrationReport( + hosts=tuple(build_integration_row(host.name, diagnose_host(host.name)) for host in FIRST_CLASS_HOSTS) + ) + + +def format_integration_row(row: IntegrationRow) -> str: + integrations = " ".join(f"{key}={diagnostic.status.value}" for key, diagnostic in row.integrations) + return f"{row.host}: {row.presence} - cli={row.cli.status.value} {integrations}" + + +def write_integration_report(report: IntegrationReport, *, json_output: bool) -> None: + """Write the host matrix as JSON or one human line per host.""" + + if json_output: + typer.echo(json.dumps(report.as_json(), indent=2)) + return + for row in report.hosts: + typer.echo(format_integration_row(row)) + + +def run_doctor_integrations(*, json_output: bool) -> None: + """Print the first-class host matrix and fail only when a present host is broken.""" + + report = collect_integration_diagnostics() + write_integration_report(report, json_output=json_output) + if report.has_present_failure: + raise typer.Exit(code=1) diff --git a/src/powercontext/cli/system.py b/src/powercontext/cli/system.py index 58a9c9bfe..25b0eda70 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]+$") @@ -280,6 +283,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.") @@ -428,7 +435,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."), @@ -514,11 +521,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."), @@ -670,6 +677,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, + ) + + @setup_app.command("workbuddy") def setup_workbuddy( source: Annotated[ @@ -866,6 +919,20 @@ def doctor_openclaw( raise typer.Exit(code=1) +@doctor_app.command("integrations") +def doctor_integrations( + json_output: Annotated[ + bool, + typer.Option("--json", help="Write the result as JSON."), + ] = False, +) -> None: + """Report first-class host CLI and integration status without failing on missing CLIs.""" + + from powercontext.cli.hosts import run_doctor_integrations + + run_doctor_integrations(json_output=json_output) + + def install_codex_plugin(*, source: str, ref: str) -> CodexSetupResult: """Install the plugin from one local or Git marketplace source.""" diff --git a/tests/test_doctor_integrations.py b/tests/test_doctor_integrations.py new file mode 100644 index 000000000..9348033d6 --- /dev/null +++ b/tests/test_doctor_integrations.py @@ -0,0 +1,249 @@ +# 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 + +from typer.testing import CliRunner + +import powercontext.cli.dsh as dsh_cli +import powercontext.cli.hermes as hermes_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.system import Diagnostic, DiagnosticStatus, doctor_app + +FIRST_CLASS_HOSTS = ("codex", "claude-code", "dsh", "openclaw", "opencode", "pi", "hermes") +CLI_KEYS = { + "codex": "codex", + "claude-code": "claude_code", + "dsh": "dsh", + "openclaw": "openclaw", + "opencode": "opencode", + "pi": "pi", + "hermes": "hermes", +} +INTEGRATION_KEYS = { + "codex": ("plugin",), + "claude-code": ("plugin",), + "dsh": ("plugin",), + "openclaw": ("plugin",), + "opencode": ("plugin", "skill"), + "pi": ("package",), + "hermes": ("plugin",), +} +PATH_MISSING = { + "codex": "Codex CLI is not installed or is not on PATH", + "claude-code": "Claude Code CLI is not installed or is not on PATH", + "dsh": "DeepSeek Harness CLI is not installed or is not on PATH", + "openclaw": "OpenClaw CLI is not installed or is not on PATH", + "opencode": "OpenCode CLI is not installed or is not on PATH", + "pi": "Pi CLI is not installed or is not on PATH", + "hermes": "Hermes CLI is not installed or is not on PATH", +} + + +def _cli(): + return create_cli([doctor_app]) + + +def _invoke(arguments: list[str]): + return CliRunner().invoke(_cli(), arguments) + + +def _missing(host: str) -> dict[str, Diagnostic]: + diagnostics = { + CLI_KEYS[host]: Diagnostic(status=DiagnosticStatus.FAILED, detail=PATH_MISSING[host]), + } + diagnostics.update({ + key: Diagnostic( + status=DiagnosticStatus.SKIPPED, + detail="not checked because the host CLI is unavailable", + ) + for key in INTEGRATION_KEYS[host] + }) + return diagnostics + + +def _ok_codex() -> dict[str, Diagnostic]: + return { + "codex": Diagnostic(status=DiagnosticStatus.OK, detail="/usr/bin/codex"), + "plugin": Diagnostic(status=DiagnosticStatus.OK, detail="powercontext@powercontext enabled=True"), + } + + +def _failed_plugin_codex() -> dict[str, Diagnostic]: + return { + "codex": Diagnostic(status=DiagnosticStatus.OK, detail="/usr/bin/codex"), + "plugin": Diagnostic(status=DiagnosticStatus.FAILED, detail="PowerContext plugin is not installed"), + } + + +def _list_failed_codex() -> dict[str, Diagnostic]: + return { + "codex": Diagnostic(status=DiagnosticStatus.FAILED, detail="codex plugin list failed: timeout"), + "plugin": Diagnostic(status=DiagnosticStatus.SKIPPED, detail="plugin list is unavailable"), + } + + +def _failed_skill_opencode() -> dict[str, Diagnostic]: + return { + "opencode": Diagnostic(status=DiagnosticStatus.OK, detail="/usr/bin/opencode (1.18.21)"), + "plugin": Diagnostic(status=DiagnosticStatus.OK, detail="powercontext is configured and active"), + "skill": Diagnostic(status=DiagnosticStatus.FAILED, detail="PowerContext OpenCode Skill is not installed"), + } + + +def _patch_diagnostics(monkeypatch, **replacements: Mock) -> dict[str, Mock]: + probes = {host: Mock(name=f"run_{host}_diagnostics", return_value=_missing(host)) for host in FIRST_CLASS_HOSTS} + probes.update(replacements) + monkeypatch.setattr(system_cli, "run_codex_diagnostics", probes["codex"]) + monkeypatch.setattr(system_cli, "run_claude_code_diagnostics", probes["claude-code"]) + monkeypatch.setattr(dsh_cli, "run_dsh_diagnostics", probes["dsh"]) + monkeypatch.setattr(openclaw_cli, "run_openclaw_diagnostics", probes["openclaw"]) + monkeypatch.setattr(opencode_cli, "run_opencode_diagnostics", probes["opencode"]) + monkeypatch.setattr(pi_cli, "run_pi_diagnostics", probes["pi"]) + monkeypatch.setattr(hermes_cli, "run_hermes_diagnostics", probes["hermes"]) + return probes + + +def test_doctor_integrations_json_includes_every_first_class_host(monkeypatch) -> None: + _patch_diagnostics(monkeypatch) + + result = _invoke(["doctor", "integrations", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert list(payload["hosts"]) == list(FIRST_CLASS_HOSTS) + for host in FIRST_CLASS_HOSTS: + assert payload["hosts"][host]["presence"] == "missing" + assert CLI_KEYS[host] in payload["hosts"][host] + for integration_key in INTEGRATION_KEYS[host]: + assert integration_key in payload["hosts"][host] + + +def test_doctor_integrations_treats_missing_clis_as_success(monkeypatch) -> None: + _patch_diagnostics(monkeypatch, codex=Mock(return_value=_ok_codex())) + + result = _invoke(["doctor", "integrations", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert payload["ok"] is True + assert payload["status"] == "ok" + assert payload["hosts"]["codex"]["presence"] == "present" + assert payload["hosts"]["codex"]["plugin"]["ok"] is True + assert payload["hosts"]["pi"]["presence"] == "missing" + assert payload["hosts"]["pi"]["package"]["status"] == "skipped" + + +def test_doctor_integrations_prints_a_human_matrix(monkeypatch) -> None: + _patch_diagnostics(monkeypatch, codex=Mock(return_value=_ok_codex())) + + result = _invoke(["doctor", "integrations"]) + + assert result.exit_code == 0 + assert "codex: present - cli=ok plugin=ok" in result.output + assert "claude-code: missing - cli=failed plugin=skipped" in result.output + assert "opencode: missing - cli=failed plugin=skipped skill=skipped" in result.output + assert "pi: missing - cli=failed package=skipped" in result.output + + +def test_doctor_integrations_fails_when_a_present_plugin_is_broken(monkeypatch) -> None: + _patch_diagnostics(monkeypatch, codex=Mock(return_value=_failed_plugin_codex())) + + result = _invoke(["doctor", "integrations", "--json"]) + + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["ok"] is False + assert payload["status"] == "failed" + assert payload["hosts"]["codex"]["presence"] == "present" + assert payload["hosts"]["codex"]["plugin"]["status"] == "failed" + assert payload["hosts"]["dsh"]["presence"] == "missing" + + +def test_doctor_integrations_fails_when_a_present_cli_cannot_list_plugins(monkeypatch) -> None: + _patch_diagnostics(monkeypatch, codex=Mock(return_value=_list_failed_codex())) + + result = _invoke(["doctor", "integrations", "--json"]) + + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["hosts"]["codex"]["presence"] == "present" + assert payload["hosts"]["codex"]["codex"]["status"] == "failed" + assert payload["hosts"]["codex"]["plugin"]["status"] == "skipped" + assert "is not installed or is not on PATH" not in payload["hosts"]["codex"]["codex"]["detail"] + + +def test_doctor_integrations_fails_when_a_present_opencode_skill_is_broken(monkeypatch) -> None: + _patch_diagnostics(monkeypatch, opencode=Mock(return_value=_failed_skill_opencode())) + + result = _invoke(["doctor", "integrations", "--json"]) + + assert result.exit_code == 1 + payload = json.loads(result.output) + assert payload["hosts"]["opencode"]["presence"] == "present" + assert payload["hosts"]["opencode"]["plugin"]["status"] == "ok" + assert payload["hosts"]["opencode"]["skill"]["status"] == "failed" + + +def test_doctor_integrations_succeeds_when_every_host_is_missing(monkeypatch) -> None: + _patch_diagnostics(monkeypatch) + + result = _invoke(["doctor", "integrations"]) + + assert result.exit_code == 0 + assert "codex: missing - cli=failed plugin=skipped" in result.output + assert "hermes: missing - cli=failed plugin=skipped" in result.output + + +def test_default_doctor_does_not_scan_first_class_hosts(monkeypatch) -> None: + probes = _patch_diagnostics( + monkeypatch, + **{ + host: Mock(side_effect=AssertionError(f"default doctor must not inspect {host}")) + for host in FIRST_CLASS_HOSTS + }, + ) + monkeypatch.setattr( + system_cli, + "run_diagnostics", + lambda **_kwargs: { + "package": Diagnostic(status=DiagnosticStatus.OK, detail="powercontext 0.0.1"), + "server_liveness": Diagnostic(status=DiagnosticStatus.OK, detail="http://127.0.0.1:8000 status=ok"), + "server_readiness": Diagnostic(status=DiagnosticStatus.OK, detail="http://127.0.0.1:8000 status=ready"), + }, + ) + + result = _invoke(["doctor", "--json"]) + + assert result.exit_code == 0 + payload = json.loads(result.output) + assert list(payload["checks"]) == ["package", "server_liveness", "server_readiness"] + for probe in probes.values(): + probe.assert_not_called() + + +def test_doctor_codex_still_fails_when_the_cli_is_missing(monkeypatch) -> None: + monkeypatch.setattr(system_cli, "which", lambda _name: None) + + result = _invoke(["doctor", "codex"]) + + assert result.exit_code == 1 + assert "Codex CLI is not installed or is not on PATH" in result.output 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