diff --git a/agent_reach/backends/__init__.py b/agent_reach/backends/__init__.py new file mode 100644 index 0000000..fa7ff2d --- /dev/null +++ b/agent_reach/backends/__init__.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +"""Cross-channel backends. + +A backend here is an upstream runtime that serves MULTIPLE channels +(e.g. OpenCLI covers xiaohongshu/reddit/bilibili/twitter through one +browser session), as opposed to the per-platform tools probed inside +each channel file. +""" + +from .opencli import ( # noqa: F401 + OPENCLI_EXTENSION_URL, + OPENCLI_PACKAGE, + OpenCLIStatus, + opencli_status, + opencli_summary, +) diff --git a/agent_reach/backends/opencli.py b/agent_reach/backends/opencli.py new file mode 100644 index 0000000..816d33a --- /dev/null +++ b/agent_reach/backends/opencli.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +"""OpenCLI backend probing. + +OpenCLI (github.com/jackwener/opencli) drives the user's real Chrome via a +browser-bridge extension + local daemon, reusing existing login sessions — +zero per-platform configuration, desktop-only (no headless). + +Probing notes (verified live): + - `opencli doctor` AUTO-STARTS the daemon — a side effect, so health + checks must use `opencli daemon status` (pure query) instead. + - Exit codes are always 0; status must be parsed from text output. + - Extension connectivity is volatile (drops when Chrome restarts and + reconnects on demand) — report it, but treat "installed + daemon + reachable" as the stable signal. +""" + +from dataclasses import dataclass + +from agent_reach.probe import probe_command + +OPENCLI_PACKAGE = "@jackwener/opencli" +OPENCLI_EXTENSION_URL = ( + "https://chromewebstore.google.com/detail/opencli/ildkmabpimmkaediidaifkhjpohdnifk" +) + + +@dataclass +class OpenCLIStatus: + installed: bool = False + broken: bool = False + daemon_running: bool = False + extension_connected: bool = False + version: str = "" + hint: str = "" + + @property + def ready(self) -> bool: + """Fully usable right now: extension connected to the daemon.""" + return self.installed and not self.broken and self.extension_connected + + +def opencli_status(timeout: int = 10) -> OpenCLIStatus: + """Probe OpenCLI install + daemon/extension state without side effects.""" + version_probe = probe_command( + "opencli", ["--version"], timeout=timeout, package=OPENCLI_PACKAGE + ) + if version_probe.status == "missing": + return OpenCLIStatus(installed=False) + if not version_probe.ok: + return OpenCLIStatus( + installed=True, + broken=True, + hint=( + "opencli 命令存在但无法执行(node 环境损坏),重装:\n" + f" npm install -g {OPENCLI_PACKAGE}" + ), + ) + + st = OpenCLIStatus(installed=True, version=version_probe.output.strip()) + + daemon_probe = probe_command( + "opencli", ["daemon", "status"], timeout=timeout, package=OPENCLI_PACKAGE + ) + output = daemon_probe.output if daemon_probe.ok else "" + # `opencli daemon status` prints lines like: + # Daemon: running (PID 37389) / Daemon: not running + # Extension: connected / Extension: disconnected + for line in output.splitlines(): + line = line.strip().lower() + if line.startswith("daemon:"): + st.daemon_running = "not running" not in line and "running" in line + elif line.startswith("extension:"): + st.extension_connected = "disconnected" not in line and "connected" in line + + if not st.extension_connected: + st.hint = ( + "OpenCLI 已安装,但 Chrome 扩展未连接。\n" + f" 1. 安装扩展(需手动点一次):{OPENCLI_EXTENSION_URL}\n" + " 2. 保持 Chrome 打开,运行 `opencli doctor` 验证" + ) + return st + + +def opencli_summary(st: OpenCLIStatus) -> str: + """One-line state description for channel messages / install output.""" + if not st.installed: + return "OpenCLI 未安装" + if st.broken: + return "OpenCLI 无法执行(node 环境损坏)" + if st.ready: + return f"OpenCLI 可用(浏览器登录态,v{st.version})" + if st.daemon_running: + return "OpenCLI 已安装,等待 Chrome 扩展连接" + return "OpenCLI 已安装(daemon 未运行,使用时自动启动;需 Chrome 扩展)" diff --git a/agent_reach/cli.py b/agent_reach/cli.py index ccda286..99db8b8 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -199,6 +199,7 @@ def _cmd_install(args): "xiaohongshu": _install_xhs_deps, "reddit": _install_reddit_deps, "bilibili": _install_bili_deps, + "opencli": _install_opencli_deps, # cross-channel backend, desktop only # xueqiu: cookie-only, no install step # linkedin: manual setup, no auto-install } @@ -252,6 +253,10 @@ def _cmd_install(args): if requested_channels and not dry_run and not safe_mode: print() print("Installing optional channels...") + if env == "server" and "opencli" in requested_channels: + # OpenCLI rides a real desktop Chrome session — useless headless + requested_channels.discard("opencli") + print(" -- OpenCLI 需要桌面环境 + Chrome,服务器环境跳过") for ch_name in sorted(requested_channels): installer = CHANNEL_INSTALLERS.get(ch_name) if installer: @@ -716,6 +721,55 @@ def _install_xhs_deps(): print(" [!] xhs-cli install failed. Run: pipx install xiaohongshu-cli") +def _install_opencli_deps(): + """Install OpenCLI — cross-platform backend riding the user's Chrome session. + + Desktop-only. The npm package installs automatically; the Chrome + extension CANNOT be installed programmatically (Chrome security model), + so we print a one-click guide instead. + """ + import shutil + import subprocess + + from agent_reach.backends import ( + OPENCLI_EXTENSION_URL, + OPENCLI_PACKAGE, + opencli_status, + opencli_summary, + ) + + print("Setting up OpenCLI (browser-session backend, desktop only)...") + st = opencli_status() + if st.installed and not st.broken: + print(f" ✅ {opencli_summary(st)}") + if not st.ready: + print(f" {st.hint}") + return + + if not shutil.which("npm"): + print(" [!] OpenCLI requires Node.js ≥ 20. Install Node first:") + print(" https://nodejs.org (或 brew install node)") + return + + try: + subprocess.run( + ["npm", "install", "-g", OPENCLI_PACKAGE], + capture_output=True, encoding="utf-8", errors="replace", timeout=300, + ) + except Exception: + pass + + st = opencli_status() + if st.installed and not st.broken: + print(" ✅ OpenCLI installed") + print(" 最后一步(必须手动,Chrome 安全限制):安装浏览器扩展") + print(f" 1. 打开 {OPENCLI_EXTENSION_URL}") + print(" 2. 点「添加至 Chrome」") + print(" 3. 运行 `opencli doctor` 验证连接") + else: + print(f" [!] OpenCLI install failed. Run: npm install -g {OPENCLI_PACKAGE}") + + def _install_reddit_deps(): """Install rdt-cli for Reddit search + reading.""" import shutil diff --git a/tests/test_opencli_backend.py b/tests/test_opencli_backend.py new file mode 100644 index 0000000..787129f --- /dev/null +++ b/tests/test_opencli_backend.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +"""Tests for the OpenCLI cross-channel backend probing.""" + +from unittest.mock import patch + +from agent_reach.backends import opencli_status, opencli_summary +from agent_reach.probe import ProbeResult + + +def _status_with(version_probe, daemon_probe=None): + """Run opencli_status with probe_command patched per call.""" + calls = [] + + def fake_probe(cmd, args=("--version",), **kwargs): + calls.append(list(args)) + if list(args) == ["--version"]: + return version_probe + return daemon_probe or ProbeResult("missing") + + with patch("agent_reach.backends.opencli.probe_command", side_effect=fake_probe): + return opencli_status(), calls + + +def test_not_installed(): + st, _ = _status_with(ProbeResult("missing")) + assert not st.installed + assert not st.ready + assert "未安装" in opencli_summary(st) + + +def test_broken_node_env_gives_npm_hint(): + st, _ = _status_with(ProbeResult("broken", hint="x")) + assert st.installed and st.broken + assert "npm install -g @jackwener/opencli" in st.hint + assert not st.ready + + +def test_daemon_running_extension_connected_is_ready(): + daemon_out = "Daemon: running (PID 37389)\nVersion: v1.8.3\nExtension: connected\n" + st, _ = _status_with( + ProbeResult("ok", output="1.8.3"), + ProbeResult("ok", output=daemon_out), + ) + assert st.installed and st.daemon_running and st.extension_connected + assert st.ready + assert "1.8.3" in opencli_summary(st) + + +def test_extension_disconnected_not_ready_with_store_guide(): + daemon_out = "Daemon: running (PID 1)\nExtension: disconnected\n" + st, _ = _status_with( + ProbeResult("ok", output="1.8.3"), + ProbeResult("ok", output=daemon_out), + ) + assert st.daemon_running and not st.extension_connected + assert not st.ready + assert "chromewebstore.google.com" in st.hint + + +def test_daemon_not_running_parsed_correctly(): + st, _ = _status_with( + ProbeResult("ok", output="1.8.3"), + ProbeResult("ok", output="Daemon: not running\n"), + ) + assert st.installed + assert not st.daemon_running + assert not st.extension_connected + assert "自动启动" in opencli_summary(st) + + +def test_probe_uses_daemon_status_not_doctor(): + """`opencli doctor` auto-starts the daemon (side effect) — health checks + must only ever call `daemon status`.""" + _, calls = _status_with( + ProbeResult("ok", output="1.8.3"), + ProbeResult("ok", output="Daemon: not running\n"), + ) + assert ["daemon", "status"] in calls + assert ["doctor"] not in calls