feat(backends): onboard OpenCLI as cross-channel desktop backend

- new agent_reach/backends/opencli.py: probes install + daemon/extension
  state via `opencli daemon status` (pure query — `opencli doctor`
  auto-starts the daemon, a side effect health checks must avoid)
- `agent-reach install --channels opencli`: npm install + Chrome Web
  Store guide (extension install cannot be automated — Chrome security
  model — so we print the one-click path)
- server env skips OpenCLI (rides a real desktop Chrome session)
- channels will adopt it as a backend candidate in follow-up PRs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Pnant
2026-06-11 15:53:51 +08:00
parent 762824c590
commit 0e8dd3f412
4 changed files with 243 additions and 0 deletions
+16
View File
@@ -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,
)
+94
View File
@@ -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 扩展)"
+54
View File
@@ -199,6 +199,7 @@ def _cmd_install(args):
"xiaohongshu": _install_xhs_deps, "xiaohongshu": _install_xhs_deps,
"reddit": _install_reddit_deps, "reddit": _install_reddit_deps,
"bilibili": _install_bili_deps, "bilibili": _install_bili_deps,
"opencli": _install_opencli_deps, # cross-channel backend, desktop only
# xueqiu: cookie-only, no install step # xueqiu: cookie-only, no install step
# linkedin: manual setup, no auto-install # 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: if requested_channels and not dry_run and not safe_mode:
print() print()
print("Installing optional channels...") 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): for ch_name in sorted(requested_channels):
installer = CHANNEL_INSTALLERS.get(ch_name) installer = CHANNEL_INSTALLERS.get(ch_name)
if installer: if installer:
@@ -716,6 +721,55 @@ def _install_xhs_deps():
print(" [!] xhs-cli install failed. Run: pipx install xiaohongshu-cli") 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(): def _install_reddit_deps():
"""Install rdt-cli for Reddit search + reading.""" """Install rdt-cli for Reddit search + reading."""
import shutil import shutil
+79
View File
@@ -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