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:
@@ -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,
|
||||
)
|
||||
@@ -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 扩展)"
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user