From 762824c590c7d548f6760a1e777896df2ec8c1f0 Mon Sep 17 00:00:00 2001 From: Pnant <73925474+Panniantong@users.noreply.github.com> Date: Thu, 11 Jun 2026 15:48:33 +0800 Subject: [PATCH] feat(routing): ordered backend candidates + real-probing doctor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - backends is now an ordered candidate list (first = preferred); channels report the backend actually serving via active_backend, surfaced in the doctor text report and --json - new agent_reach/probe.py really executes upstream commands and tells apart missing / broken (stale venv shebang after a system Python upgrade) / timeout, with a reinstall prescription for broken installs - all 13 channels migrated off which()-only checks: fixes bilibili false-positive "bili-cli 可用" on broken shims, misleading xiaohongshu "连接失败", rdt OSError crashing doctor, mcporter breakage masquerading as "未配置" - twitter: 15s probe + 1 retry (flaky 10s timeout), broken twitter-cli now falls back to bird instead of aborting the check - doctor survives per-channel exceptions; config supports per-channel backend override (_backend / _BACKEND env) - fix skill install/uninstall crash on symlinked skill dirs (the "[Errno None] None" warning from shutil.rmtree on a symlink) Co-Authored-By: Claude Fable 5 --- .gitignore | 1 + agent_reach/channels/base.py | 40 +++- agent_reach/channels/bilibili.py | 35 +++- agent_reach/channels/exa_search.py | 36 ++-- agent_reach/channels/github.py | 36 ++-- agent_reach/channels/linkedin.py | 30 ++- agent_reach/channels/reddit.py | 90 +++++--- agent_reach/channels/rss.py | 10 +- agent_reach/channels/twitter.py | 132 +++++++----- agent_reach/channels/v2ex.py | 2 + agent_reach/channels/web.py | 2 + agent_reach/channels/xiaohongshu.py | 52 ++--- agent_reach/channels/xiaoyuzhou.py | 15 +- agent_reach/channels/xueqiu.py | 2 + agent_reach/channels/youtube.py | 15 +- agent_reach/cli.py | 12 +- agent_reach/doctor.py | 29 ++- agent_reach/probe.py | 99 +++++++++ tests/test_channel_contracts.py | 73 +++++++ tests/test_channels.py | 308 +++++++++++++++++++++++++++- tests/test_doctor.py | 10 +- tests/test_probe.py | 90 ++++++++ tests/test_twitter_channel.py | 46 +++++ 23 files changed, 988 insertions(+), 177 deletions(-) create mode 100644 agent_reach/probe.py create mode 100644 tests/test_probe.py diff --git a/.gitignore b/.gitignore index 370ebd9..f3d65c6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ build/ # Claude Code personal permission settings — local only, never commit .claude/settings.local.json +uv.lock diff --git a/agent_reach/channels/base.py b/agent_reach/channels/base.py index 21551cf..2afec0a 100644 --- a/agent_reach/channels/base.py +++ b/agent_reach/channels/base.py @@ -8,11 +8,22 @@ and provides: - check(config) → is the upstream tool installed and configured? After installation, agents call upstream tools directly. + +Backend routing semantics: + - `backends` is an ORDERED candidate list: backends[0] is the preferred + backend, the rest are fallbacks. "Switching backends" for a platform + means reordering this list (or a user override) — not rewriting code. + - check() must set `self.active_backend` to the backend that is actually + serving the channel right now (None when nothing usable is found). + shutil.which() alone is NOT proof of health — a stale venv shim passes + which() but cannot execute (see agent_reach.probe). Channels should + really execute a lightweight command before claiming a backend active. + - Users can force a backend with config key `_backend` + (or env var `_BACKEND`); ordered_backends() applies it. """ -import shutil from abc import ABC, abstractmethod -from typing import List, Tuple +from typing import List, Optional, Tuple class Channel(ABC): @@ -20,17 +31,40 @@ class Channel(ABC): name: str = "" # e.g. "youtube" description: str = "" # e.g. "YouTube 视频和字幕" - backends: List[str] = [] # e.g. ["yt-dlp"] — what upstream tool is used + backends: List[str] = [] # ordered candidates — backends[0] = preferred tier: int = 0 # 0=zero-config, 1=needs free key, 2=needs setup + #: Backend currently serving this channel; set by check(), None = unavailable. + active_backend: Optional[str] = None + @abstractmethod def can_handle(self, url: str) -> bool: """Check if this channel can handle this URL.""" ... + def ordered_backends(self, config=None) -> List[str]: + """Candidate backends in probe order, honoring the user override. + + The config key `_backend` (env `_BACKEND`) moves the + named backend to the front of the list; unknown values are ignored so + a stale override can never hide working backends. + """ + candidates = list(self.backends) + override = config.get(f"{self.name}_backend") if config else None + if override: + for i, b in enumerate(candidates): + if b == override or b.startswith(override): + candidates.insert(0, candidates.pop(i)) + break + return candidates + def check(self, config=None) -> Tuple[str, str]: """ Check if this channel's upstream tool is available. Returns (status, message) where status is 'ok'/'warn'/'off'/'error'. + + Subclasses with external backends must really probe them (see + agent_reach.probe.probe_command) and set self.active_backend. """ + self.active_backend = self.backends[0] if self.backends else "内置" return "ok", f"{'、'.join(self.backends) if self.backends else '内置'}" diff --git a/agent_reach/channels/bilibili.py b/agent_reach/channels/bilibili.py index cda844d..18e08c4 100644 --- a/agent_reach/channels/bilibili.py +++ b/agent_reach/channels/bilibili.py @@ -3,9 +3,10 @@ import json import os -import shutil -import subprocess import urllib.request + +from agent_reach.probe import probe_command + from .base import Channel _UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" @@ -36,11 +37,23 @@ class BilibiliChannel(Channel): return "bilibili.com" in d or "b23.tv" in d def check(self, config=None): - if not shutil.which("yt-dlp"): + self.active_backend = None + + # 真跑 yt-dlp --version,区分 未装 / 断链 / 异常(which 命中不等于能用) + yt = probe_command("yt-dlp", ["--version"], timeout=10, package="yt-dlp") + if yt.status == "missing": return "off", "yt-dlp 未安装。安装:pip install yt-dlp" + if yt.status == "broken": + return "error", "yt-dlp 已安装但无法执行\n" + yt.hint + if not yt.ok: + detail = yt.hint or yt.output or yt.status + return "error", f"yt-dlp 探测失败({yt.status}):{detail}" + + self.active_backend = "yt-dlp" proxy = (config.get("bilibili_proxy") if config else None) or os.environ.get("BILIBILI_PROXY") - has_bili_cli = bool(shutil.which("bili")) + # 真跑 bili --version——断链时旧的 which 检测会误报"bili-cli 可用" + bili = probe_command("bili", ["--version"], timeout=10, package="bilibili-cli") parts = [] @@ -51,16 +64,22 @@ class BilibiliChannel(Channel): parts.append("视频读取:yt-dlp") # bili-cli 增强 - if has_bili_cli: + if bili.ok: parts.append("搜索/热门/排行:bili-cli 可用") + status = "ok" else: - # 检测搜索 API 连通性 + if bili.status == "broken": + parts.append("bili-cli 已安装但无法执行,不计为可用\n" + bili.hint) + elif bili.status in ("timeout", "error"): + parts.append(f"bili-cli 探测失败({bili.status}),不计为可用") + # 降级走搜索 API;只探测一次,message 和 status 共用结果 api_ok = _search_api_ok() if api_ok: parts.append("搜索:B站 API 可用") else: parts.append("搜索:B站 API 不可达") - parts.append("提示:安装 bili-cli 可解锁热门/排行/动态:pipx install bilibili-cli") + if bili.status == "missing": + parts.append("提示:安装 bili-cli 可解锁热门/排行/动态:pipx install bilibili-cli") + status = "ok" if api_ok else "warn" - status = "ok" if has_bili_cli or _search_api_ok() else "warn" return status, "。".join(parts) diff --git a/agent_reach/channels/exa_search.py b/agent_reach/channels/exa_search.py index a1687dd..01a2fcf 100644 --- a/agent_reach/channels/exa_search.py +++ b/agent_reach/channels/exa_search.py @@ -1,10 +1,13 @@ # -*- coding: utf-8 -*- """Exa Search — check if mcporter + Exa MCP is available.""" -import shutil -import subprocess +from agent_reach.probe import probe_command + from .base import Channel +#: mcporter 是 npm 包,断链处方与默认的 pipx/uv 不同 +_MCPORTER_BROKEN_HINT = "mcporter 无法执行(node 环境损坏),重装:\n npm install -g mcporter" + class ExaSearchChannel(Channel): name = "exa_search" @@ -16,23 +19,22 @@ class ExaSearchChannel(Channel): return False # Search-only channel def check(self, config=None): - mcporter = shutil.which("mcporter") - if not mcporter: + self.active_backend = None + probe = probe_command("mcporter", ["config", "list"], timeout=10, package="mcporter") + if probe.status == "missing": return "off", ( "需要 mcporter + Exa MCP。安装:\n" " npm install -g mcporter\n" " mcporter config add exa https://mcp.exa.ai/mcp" ) - try: - r = subprocess.run( - [mcporter, "config", "list"], capture_output=True, - encoding="utf-8", errors="replace", timeout=5 - ) - if "exa" in r.stdout.lower(): - return "ok", "全网语义搜索可用(免费,无需 API Key)" - return "off", ( - "mcporter 已装但 Exa 未配置。运行:\n" - " mcporter config add exa https://mcp.exa.ai/mcp" - ) - except Exception: - return "off", "mcporter 连接异常" + if probe.status == "broken": + return "error", _MCPORTER_BROKEN_HINT + if not probe.ok: # timeout / error + return "error", f"mcporter 执行异常:{probe.hint or probe.output or probe.status}" + if "exa" in probe.output.lower(): + self.active_backend = self.backends[0] + return "ok", "全网语义搜索可用(免费,无需 API Key)" + return "off", ( + "mcporter 已装但 Exa 未配置。运行:\n" + " mcporter config add exa https://mcp.exa.ai/mcp" + ) diff --git a/agent_reach/channels/github.py b/agent_reach/channels/github.py index 67ff75c..1908a98 100644 --- a/agent_reach/channels/github.py +++ b/agent_reach/channels/github.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- """GitHub — check if gh CLI is available.""" -import shutil -import subprocess +from agent_reach.probe import probe_command + from .base import Channel @@ -17,16 +17,26 @@ class GitHubChannel(Channel): return "github.com" in urlparse(url).netloc.lower() def check(self, config=None): - gh = shutil.which("gh") - if not gh: + # 真跑 gh auth status 探活。注意:未登录时 rc!=0 是正常业务态(warn),不是 error。 + probe = probe_command("gh", ["auth", "status"], timeout=10, package="gh") + if probe.status == "missing": + self.active_backend = None return "warn", "gh CLI 未安装。安装:https://cli.github.com" - try: - r = subprocess.run( - [gh, "auth", "status"], - capture_output=True, encoding="utf-8", errors="replace", timeout=5 + if probe.status == "broken": + # gh 是二进制安装(brew/官方包),不是 pip 包——处方不用 pipx/uv 文案 + self.active_backend = None + return "error", ( + "gh 命令存在但无法执行——安装已损坏。重装即可修复:\n" + " brew reinstall gh\n" + "或从 https://cli.github.com 重新安装 gh CLI" ) - if r.returncode == 0: - return "ok", "完整可用(读取、搜索、Fork、Issue、PR 等)" - return "warn", "gh CLI 已安装但未认证。运行 gh auth login 可解锁完整功能" - except Exception: - return "warn", "gh CLI 状态检查失败,运行 gh auth status 查看详情" + if probe.status == "timeout": + # gh 本体能启动(工具是活的),只是状态检查超时 + self.active_backend = "gh CLI" + return "warn", "gh CLI 状态检查超时,运行 gh auth status 查看详情" + if probe.ok: + self.active_backend = "gh CLI" + return "ok", "完整可用(读取、搜索、Fork、Issue、PR 等)" + # rc != 0:gh 活着但未认证(gh auth status 的正常业务态) + self.active_backend = "gh CLI" + return "warn", "gh CLI 已安装但未认证。运行 gh auth login 可解锁完整功能" diff --git a/agent_reach/channels/linkedin.py b/agent_reach/channels/linkedin.py index 1c95dc8..9b1bb5d 100644 --- a/agent_reach/channels/linkedin.py +++ b/agent_reach/channels/linkedin.py @@ -1,13 +1,13 @@ # -*- coding: utf-8 -*- """LinkedIn — check if linkedin-scraper-mcp is available.""" -import shutil -import subprocess - -from agent_reach.utils.process import utf8_subprocess_env +from agent_reach.probe import probe_command from .base import Channel +#: mcporter 是 npm 包,断链处方与默认的 pipx/uv 不同 +_MCPORTER_BROKEN_HINT = "mcporter 无法执行(node 环境损坏),重装:\n npm install -g mcporter" + class LinkedInChannel(Channel): name = "linkedin" @@ -20,24 +20,22 @@ class LinkedInChannel(Channel): return "linkedin.com" in urlparse(url).netloc.lower() def check(self, config=None): - mcporter = shutil.which("mcporter") - if not mcporter: + self.active_backend = None + probe = probe_command("mcporter", ["config", "list"], timeout=10, package="mcporter") + if probe.status == "missing": return "off", ( "基本内容可通过 Jina Reader 读取。完整功能需要:\n" " pip install linkedin-scraper-mcp\n" " mcporter config add linkedin http://localhost:3000/mcp\n" " 详见 https://github.com/stickerdaniel/linkedin-mcp-server" ) - try: - r = subprocess.run( - [mcporter, "config", "list"], capture_output=True, - encoding="utf-8", errors="replace", timeout=5, - env=utf8_subprocess_env(), - ) - if "linkedin" in r.stdout.lower(): - return "ok", "完整可用(Profile、公司、职位搜索)" - except Exception: - pass + if probe.status == "broken": + return "error", _MCPORTER_BROKEN_HINT + if not probe.ok: # timeout / error + return "error", f"mcporter 执行异常:{probe.hint or probe.output or probe.status}" + if "linkedin" in probe.output.lower(): + self.active_backend = "linkedin-scraper-mcp" + return "ok", "完整可用(Profile、公司、职位搜索)" return "off", ( "mcporter 已装但 LinkedIn MCP 未配置。运行:\n" " pip install linkedin-scraper-mcp\n" diff --git a/agent_reach/channels/reddit.py b/agent_reach/channels/reddit.py index 3b51cc3..025b339 100644 --- a/agent_reach/channels/reddit.py +++ b/agent_reach/channels/reddit.py @@ -10,12 +10,24 @@ import json import shutil import subprocess +from agent_reach.utils.process import utf8_subprocess_env + from .base import Channel _CREDENTIAL_FILE = "~/.config/rdt-cli/credential.json" # Pinned to the 0.4.2 state — PyPI still only has 0.4.1 (upstream issue #10). _RDT_GIT_SOURCE = "git+https://github.com/public-clis/rdt-cli.git@5e4fb3720d5c174e976cd425ccc3b879d52cac66" +#: shell 对"找到但不可执行/找不到"使用的退出码(对齐 agent_reach.probe) +_BROKEN_EXIT_CODES = (126, 127) + +#: rdt 应从固定 git 源安装(PyPI 落后),断链处方与 probe 默认的 pipx/uv 不同 +_RDT_BROKEN_HINT = ( + "rdt 命令存在但无法执行——通常是系统 Python 升级后 venv 解释器丢失。\n" + "PyPI 版本落后,推荐用固定 git 源强制重装:\n" + f" pipx install --force '{_RDT_GIT_SOURCE}'" +) + class RedditChannel(Channel): name = "reddit" @@ -30,6 +42,8 @@ class RedditChannel(Channel): return "reddit.com" in d or "redd.it" in d def check(self, config=None): + self.active_backend = None + rdt = shutil.which("rdt") if not rdt: return "off", ( @@ -42,6 +56,10 @@ class RedditChannel(Channel): "安装后运行 `rdt login` 登录(需先在浏览器登录 reddit.com)" ) + # 不走 probe_command:实测 `rdt status --json` 成功时(rc=0)也会向 stderr + # 打网络重试日志,probe 把 stdout+stderr 合并后 JSON 解析必炸。 + # 故保留手写 subprocess(stdout 单独捕获),但异常分类对齐 probe 语义: + # exec 失败/126/127 → broken(venv 断链处方),TimeoutExpired → 超时。 try: r = subprocess.run( [rdt, "status", "--json"], @@ -49,31 +67,55 @@ class RedditChannel(Channel): encoding="utf-8", errors="replace", timeout=10, + env=utf8_subprocess_env(), ) - data = json.loads(r.stdout or "{}") - authenticated = data.get("data", {}).get("authenticated", False) - username = data.get("data", {}).get("username") or "" + except subprocess.TimeoutExpired: + return "error", "rdt 响应超时(>10s),Reddit 状态未知。稍后重试或运行 `rdt status` 查看详情" + except OSError: + # 含 FileNotFoundError:which 命中但 exec 失败 = venv 断链(probe 的 broken) + return "error", _RDT_BROKEN_HINT - if authenticated: - suffix = f"(已登录:{username})" if username else "" - return "ok", (f"rdt-cli 可用{suffix}(搜索帖子、阅读全文、查看评论)") + if r.returncode in _BROKEN_EXIT_CODES: + return "error", _RDT_BROKEN_HINT - return "warn", ( - "rdt-cli 已安装但未登录。Reddit 自 2024 年起要求认证," - "未登录时所有请求均返回 403。\n\n" - "方法一(自动):运行 `rdt login`\n" - " 先在浏览器登录 reddit.com,再运行此命令自动提取 Cookie。\n\n" - "方法二(手动,适用于 Chrome/Edge 127+ 无法自动提取时):\n" - " 1. Chrome 应用商店安装 Cookie-Editor 扩展:\n" - " https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm\n" - " 2. 在浏览器打开 reddit.com(确保已登录)\n" - " 3. 点击 Cookie-Editor 图标,找到 `reddit_session`,复制其 Value\n" - f" 4. 将以下内容写入 {_CREDENTIAL_FILE}:\n" - ' {"cookies": {"reddit_session": "<粘贴 Value>"}, ' - '"source": "manual", "username": "<你的用户名>", ' - '"modhash": null, "saved_at": 0, "last_verified_at": null}\n\n' - "验证:`rdt status --json` 确认 authenticated: true" - ) + if r.returncode != 0: + detail = (r.stderr or r.stdout or "").strip().splitlines() + tail = detail[-1] if detail else "无输出" + return "error", f"rdt 异常退出(exit {r.returncode}):{tail}。运行 `rdt status` 查看详情" - except (json.JSONDecodeError, FileNotFoundError, subprocess.TimeoutExpired): - return "warn", "rdt-cli 已安装但状态检查失败,运行 `rdt status` 查看详情" + # 进程正常退出 → rdt 本身是活的(无论登录与否),后端即为可用 + self.active_backend = "rdt-cli" + + try: + data = json.loads(r.stdout or "") + except json.JSONDecodeError: + data = None + if not isinstance(data, dict): + return "warn", "rdt-cli 可用但状态输出无法解析,运行 `rdt status` 查看登录状态" + + info = data.get("data") + if not isinstance(info, dict): + info = {} + authenticated = info.get("authenticated", False) + username = info.get("username") or "" + + if authenticated: + suffix = f"(已登录:{username})" if username else "" + return "ok", (f"rdt-cli 可用{suffix}(搜索帖子、阅读全文、查看评论)") + + return "warn", ( + "rdt-cli 已安装但未登录。Reddit 自 2024 年起要求认证," + "未登录时所有请求均返回 403。\n\n" + "方法一(自动):运行 `rdt login`\n" + " 先在浏览器登录 reddit.com,再运行此命令自动提取 Cookie。\n\n" + "方法二(手动,适用于 Chrome/Edge 127+ 无法自动提取时):\n" + " 1. Chrome 应用商店安装 Cookie-Editor 扩展:\n" + " https://chromewebstore.google.com/detail/cookie-editor/hlkenndednhfkekhgcdicdfddnkalmdm\n" + " 2. 在浏览器打开 reddit.com(确保已登录)\n" + " 3. 点击 Cookie-Editor 图标,找到 `reddit_session`,复制其 Value\n" + f" 4. 将以下内容写入 {_CREDENTIAL_FILE}:\n" + ' {"cookies": {"reddit_session": "<粘贴 Value>"}, ' + '"source": "manual", "username": "<你的用户名>", ' + '"modhash": null, "saved_at": 0, "last_verified_at": null}\n\n' + "验证:`rdt status --json` 确认 authenticated: true" + ) diff --git a/agent_reach/channels/rss.py b/agent_reach/channels/rss.py index a61d9d9..5f63072 100644 --- a/agent_reach/channels/rss.py +++ b/agent_reach/channels/rss.py @@ -15,7 +15,13 @@ class RSSChannel(Channel): def check(self, config=None): try: - import feedparser - return "ok", "可读取 RSS/Atom 源" + import feedparser # noqa: F401 except ImportError: + self.active_backend = None return "off", "feedparser 未安装。安装:pip install feedparser" + except Exception as e: + # 已安装但导入期崩溃(半残安装/版本冲突)→ 重装处方 + self.active_backend = None + return "error", f"feedparser 导入失败:{e}\n修复:pip install --force-reinstall feedparser" + self.active_backend = self.backends[0] + return "ok", "可读取 RSS/Atom 源" diff --git a/agent_reach/channels/twitter.py b/agent_reach/channels/twitter.py index 6971edd..62cd588 100644 --- a/agent_reach/channels/twitter.py +++ b/agent_reach/channels/twitter.py @@ -1,9 +1,8 @@ # -*- coding: utf-8 -*- """Twitter/X — check if twitter-cli or bird CLI is available.""" -import shutil -import subprocess from .base import Channel +from agent_reach.probe import probe_command class TwitterChannel(Channel): @@ -18,56 +17,98 @@ class TwitterChannel(Channel): return "x.com" in d or "twitter.com" in d def check(self, config=None): - # Prefer twitter-cli, fallback to bird/birdx - twitter = shutil.which("twitter") - bird = shutil.which("bird") or shutil.which("birdx") + """按 backends 顺序真实探测,第一个活着的后端即为 active_backend。""" + self.active_backend = None + failures = [] - if twitter: - return self._check_twitter_cli(twitter) - elif bird: - return self._check_bird(bird) - else: + for backend in self.ordered_backends(config): + if backend == "twitter-cli": + result = self._check_twitter_cli() + elif backend == "bird CLI (legacy)": + result = self._check_bird() + else: + continue + + if result is None: + continue # 未安装——继续尝试下一个后端 + + status, message = result + if status in ("ok", "warn"): + # 工具本身是活的(含已装但未登录的 warn) + self.active_backend = backend + return status, message + # broken/timeout —— 记下处方,继续尝试下一个后端 + failures.append(message) + + if failures: + return "error", "\n".join(failures) + return "warn", ( + "Twitter CLI 未安装。安装方式:\n" + " pipx install twitter-cli\n" + "或:\n" + " uv tool install twitter-cli" + ) + + def _check_twitter_cli(self): + """探测 twitter-cli。返回 None 表示未安装,否则返回 (status, message)。 + + `twitter status` 才是健康信号:已登录时输出 "ok: true", + 未登录时以非零退出码输出 "not_authenticated"——工具本身是活的, + 所以 probe 的 error 状态也要看 output 内容再分类。 + """ + probe = probe_command( + "twitter", ["status"], timeout=15, retries=1, package="twitter-cli" + ) + if probe.status == "missing": + return None + if probe.status == "broken": + return "error", "twitter-cli 命令存在但无法执行。\n" + probe.hint + if probe.status == "timeout": + return "error", "twitter-cli 健康检查超时(已重试 1 次)。\n" + probe.hint + + output = probe.output + if "ok: true" in output: + return "ok", ( + "twitter-cli 完整可用(搜索、读推文、时间线、长文/Article、" + "用户查询、Thread)" + ) + if "not_authenticated" in output: return "warn", ( - "Twitter CLI 未安装。安装方式:\n" - " pipx install twitter-cli\n" - "或:\n" - " uv tool install twitter-cli" + "twitter-cli 已安装但未认证。设置方式:\n" + " export TWITTER_AUTH_TOKEN=\"xxx\"\n" + " export TWITTER_CT0=\"yyy\"\n" + "或确保已在浏览器中登录 x.com" ) + return "warn", ( + "twitter-cli 已安装但认证检查失败。运行:\n" + " twitter -v status 查看详细信息" + ) - def _check_twitter_cli(self, binary: str): - try: - r = subprocess.run( - [binary, "status"], capture_output=True, - encoding="utf-8", errors="replace", timeout=10 + def _check_bird(self): + """探测 bird/birdx(legacy 回退)。返回 None 表示均未安装,否则返回 (status, message)。""" + last_failure = None + for cmd in ("bird", "birdx"): + probe = probe_command( + cmd, ["check"], timeout=15, retries=1, package="@steipete/bird" ) - output = (r.stdout or "") + (r.stderr or "") - if r.returncode == 0 and "ok: true" in output: - return "ok", ( - "twitter-cli 完整可用(搜索、读推文、时间线、长文/Article、" - "用户查询、Thread)" + if probe.status == "missing": + continue + if probe.status == "broken": + last_failure = ( + "error", + f"{cmd} 命令存在但无法执行(bird 是 npm 包,可用 " + "npm install -g @steipete/bird 重装)。\n" + probe.hint, ) - if "not_authenticated" in output: - return "warn", ( - "twitter-cli 已安装但未认证。设置方式:\n" - " export TWITTER_AUTH_TOKEN=\"xxx\"\n" - " export TWITTER_CT0=\"yyy\"\n" - "或确保已在浏览器中登录 x.com" + continue # bird 坏了再试 birdx + if probe.status == "timeout": + last_failure = ( + "error", + f"{cmd} 健康检查超时(已重试 1 次)。\n" + probe.hint, ) - return "warn", ( - "twitter-cli 已安装但认证检查失败。运行:\n" - " twitter -v status 查看详细信息" - ) - except Exception: - return "warn", "twitter-cli 已安装但连接失败" + continue - def _check_bird(self, binary: str): - try: - r = subprocess.run( - [binary, "check"], capture_output=True, - encoding="utf-8", errors="replace", timeout=10 - ) - output = (r.stdout or "") + (r.stderr or "") - if r.returncode == 0: + output = probe.output + if probe.ok: return "ok", "bird CLI 可用(读取、搜索推文,含长文/X Article)" if "Missing credentials" in output or "missing" in output.lower(): return "warn", ( @@ -78,5 +119,4 @@ class TwitterChannel(Channel): return "warn", ( "bird CLI 已安装但认证检查失败。" ) - except Exception: - return "warn", "bird CLI 已安装但连接失败" + return last_failure diff --git a/agent_reach/channels/v2ex.py b/agent_reach/channels/v2ex.py index 700b653..1b24561 100644 --- a/agent_reach/channels/v2ex.py +++ b/agent_reach/channels/v2ex.py @@ -41,8 +41,10 @@ class V2EXChannel(Channel): _get_json( "https://www.v2ex.com/api/topics/show.json?node_name=python&page=1" ) + self.active_backend = self.backends[0] return "ok", "公开 API 可用(热门主题、节点浏览、主题详情、用户信息)" except Exception as e: + self.active_backend = None return "warn", f"V2EX API 连接失败(可能需要代理):{e}" # ------------------------------------------------------------------ # diff --git a/agent_reach/channels/web.py b/agent_reach/channels/web.py index dfdf10c..9d10dfe 100644 --- a/agent_reach/channels/web.py +++ b/agent_reach/channels/web.py @@ -17,6 +17,8 @@ class WebChannel(Channel): return True # Fallback — handles any URL def check(self, config=None): + # 恒可用兜底渠道:无本地命令、不做网络探测(doctor 已有多个渠道触网),保持零开销 + self.active_backend = self.backends[0] return "ok", "通过 Jina Reader 读取任意网页(curl https://r.jina.ai/URL)" def read(self, url: str) -> str: diff --git a/agent_reach/channels/xiaohongshu.py b/agent_reach/channels/xiaohongshu.py index 8a71df7..5549f19 100644 --- a/agent_reach/channels/xiaohongshu.py +++ b/agent_reach/channels/xiaohongshu.py @@ -1,8 +1,8 @@ # -*- coding: utf-8 -*- """XiaoHongShu — check if xhs-cli (xiaohongshu-cli) is available.""" -import shutil -import subprocess +from agent_reach.probe import probe_command + from .base import Channel @@ -127,8 +127,12 @@ class XiaoHongShuChannel(Channel): return "xiaohongshu.com" in d or "xhslink.com" in d def check(self, config=None): - xhs = shutil.which("xhs") - if not xhs: + self.active_backend = None + probe = probe_command( + "xhs", ["status"], timeout=10, package="xiaohongshu-cli" + ) + + if probe.status == "missing": return "off", ( "需要安装 xhs-cli:\n" " pipx install xiaohongshu-cli\n" @@ -136,27 +140,27 @@ class XiaoHongShuChannel(Channel): " uv tool install xiaohongshu-cli\n" "安装后运行 `xhs login` 登录" ) + if probe.status == "broken": + return "error", "xhs 命令存在但无法执行\n" + probe.hint + if probe.status == "timeout": + return "warn", "xhs-cli 已安装但状态检测超时\n" + probe.hint - try: - r = subprocess.run( - [xhs, "status"], capture_output=True, - encoding="utf-8", errors="replace", timeout=10, + # 进程是活的(执行成功或运行后非零退出)——按输出内容分类 + if probe.ok and "ok: true" in probe.output: + self.active_backend = self.backends[0] + return "ok", ( + "完整可用(搜索、阅读、评论、发帖、热门、" + "收藏、关注、用户查询)" ) - output = (r.stdout or "") + (r.stderr or "") - if r.returncode == 0 and "ok: true" in output: - return "ok", ( - "完整可用(搜索、阅读、评论、发帖、热门、" - "收藏、关注、用户查询)" - ) - if "not_authenticated" in output or "expired" in output: - return "warn", ( - "xhs-cli 已安装但未登录。运行:\n" - " xhs login\n" - "(自动从浏览器提取 Cookie,或扫码登录)" - ) + if "not_authenticated" in probe.output or "expired" in probe.output: + self.active_backend = self.backends[0] return "warn", ( - "xhs-cli 已安装但状态异常。运行:\n" - " xhs -v status 查看详细信息" + "xhs-cli 已安装但未登录。运行:\n" + " xhs login\n" + "(自动从浏览器提取 Cookie,或扫码登录)" ) - except Exception: - return "warn", "xhs-cli 已安装但连接失败" + self.active_backend = self.backends[0] + return "warn", ( + "xhs-cli 已安装但状态异常。运行:\n" + " xhs -v status 查看详细信息" + ) diff --git a/agent_reach/channels/xiaoyuzhou.py b/agent_reach/channels/xiaoyuzhou.py index 9507191..efc73b7 100644 --- a/agent_reach/channels/xiaoyuzhou.py +++ b/agent_reach/channels/xiaoyuzhou.py @@ -2,8 +2,8 @@ """Xiaoyuzhou Podcast (小宇宙播客) — transcribe podcasts via Groq Whisper API.""" import os -import shutil from agent_reach.config import Config +from agent_reach.probe import probe_command from .base import Channel @@ -19,13 +19,21 @@ class XiaoyuzhouChannel(Channel): return "xiaoyuzhoufm.com" in d def check(self, config=None): - # Check ffmpeg - if not shutil.which("ffmpeg"): + self.active_backend = None + + # Check ffmpeg — really execute it: a stale pip-installed ffmpeg shim + # passes shutil.which() but cannot run + probe = probe_command("ffmpeg", ["-version"], timeout=10, package="ffmpeg") + if probe.status == "missing": return "off", ( "需要 ffmpeg(音频转码和切片)。安装:\n" " Ubuntu/Debian: apt install -y ffmpeg\n" " macOS: brew install ffmpeg" ) + if not probe.ok: + return "error", ( + "ffmpeg 无法执行,重装:brew install ffmpeg(macOS)/ apt install ffmpeg(Linux)" + ) # Check script exists script = os.path.expanduser("~/.agent-reach/tools/xiaoyuzhou/transcribe.sh") @@ -51,4 +59,5 @@ class XiaoyuzhouChannel(Channel): " 2. 运行: agent-reach configure groq-key gsk_xxxxx" ) + self.active_backend = "groq-whisper" return "ok", "完整可用(播客下载 + Whisper 转录)" diff --git a/agent_reach/channels/xueqiu.py b/agent_reach/channels/xueqiu.py index 4bef961..f1ce444 100644 --- a/agent_reach/channels/xueqiu.py +++ b/agent_reach/channels/xueqiu.py @@ -162,12 +162,14 @@ class XueqiuChannel(Channel): # ------------------------------------------------------------------ # def check(self, config=None): + self.active_backend = None try: data = _get_json( "https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=SH000001" ) items = (data.get("data") or {}).get("items") or [] if items: + self.active_backend = self.backends[0] return "ok", "公开 API 可用(行情、搜索、热帖、热股)" return "warn", "API 响应异常(返回数据为空)" except Exception as e: diff --git a/agent_reach/channels/youtube.py b/agent_reach/channels/youtube.py index 5ee1e2b..69393ca 100644 --- a/agent_reach/channels/youtube.py +++ b/agent_reach/channels/youtube.py @@ -3,6 +3,7 @@ import shutil +from agent_reach.probe import probe_command from agent_reach.utils.paths import get_ytdlp_config_path, render_ytdlp_fix_command from agent_reach.utils.text import read_utf8_text @@ -32,8 +33,20 @@ class YouTubeChannel(Channel): return "youtube.com" in d or "youtu.be" in d def check(self, config=None): - if not shutil.which("yt-dlp"): + # 真跑 yt-dlp --version 探活,区分未装 / venv 断链 / 跑不动 + probe = probe_command("yt-dlp", ["--version"], timeout=10, package="yt-dlp") + if probe.status == "missing": + self.active_backend = None return "off", "yt-dlp 未安装。安装:pip install yt-dlp" + if probe.status == "broken": + self.active_backend = None + return "error", f"yt-dlp 已安装但无法执行\n{probe.hint}" + if not probe.ok: # timeout / error:装了但跑不动 + self.active_backend = None + detail = probe.hint or probe.output or probe.status + return "error", f"yt-dlp 无法正常运行:{detail}" + # yt-dlp 本体是活的;后面的 JS runtime/转写检查只影响 ok/warn,不影响后端归属 + self.active_backend = "yt-dlp" # Check JS runtime has_js = shutil.which("deno") or shutil.which("node") if not has_js: diff --git a/agent_reach/cli.py b/agent_reach/cli.py index d257b72..ccda286 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -364,8 +364,11 @@ def _install_skill(): def _copy_skill_dir(target: str) -> bool: """Copy entire skill directory (locale-specific SKILL.md + references/).""" try: - # Clear existing installation - if os.path.exists(target): + # Clear existing installation. A symlinked skill dir (dotfiles + # setups) breaks shutil.rmtree — unlink the link itself instead. + if os.path.islink(target): + os.unlink(target) + elif os.path.exists(target): shutil.rmtree(target) os.makedirs(target, exist_ok=True) @@ -454,7 +457,10 @@ def _uninstall_skill(): skill_path = os.path.expanduser(skill_path_template) if os.path.isdir(skill_path): try: - shutil.rmtree(skill_path) + if os.path.islink(skill_path): + os.unlink(skill_path) + else: + shutil.rmtree(skill_path) print(f" Removed {platform_name} skill: {skill_path}") removed = True except Exception as e: diff --git a/agent_reach/doctor.py b/agent_reach/doctor.py index 14e5fd1..0d56f24 100644 --- a/agent_reach/doctor.py +++ b/agent_reach/doctor.py @@ -10,20 +10,37 @@ from agent_reach.channels import get_all_channels def check_all(config: Config) -> Dict[str, dict]: - """Check all channels and return status dict.""" + """Check all channels and return status dict. + + A single misbehaving channel must never take the whole report down, + so per-channel exceptions degrade to status="error". + """ results = {} for ch in get_all_channels(): - status, message = ch.check(config) + try: + status, message = ch.check(config) + except Exception as e: # noqa: BLE001 — doctor must survive any channel + status, message = "error", f"体检异常:{e}" results[ch.name] = { "status": status, "name": ch.description, "message": message, "tier": ch.tier, "backends": ch.backends, + "active_backend": getattr(ch, "active_backend", None), } return results +def _name_msg(r: dict, escape) -> str: + """Render one channel line; show the active backend when there is a choice.""" + text = f"[bold]{escape(r['name'])}[/bold] — {escape(r['message'])}" + active = r.get("active_backend") + if active and len(r.get("backends", [])) > 1: + text += f" [dim](当前后端:{escape(active)})[/dim]" + return text + + def format_report(results: Dict[str, dict]) -> str: """Format results as a readable text report (with Rich markup).""" try: @@ -44,7 +61,7 @@ def format_report(results: Dict[str, dict]) -> str: lines.append("[bold]✅ 装好即用:[/bold]") for key, r in results.items(): if r["tier"] == 0: - name_msg = f"[bold]{escape(r['name'])}[/bold] — {escape(r['message'])}" + name_msg = _name_msg(r, escape) if r["status"] == "ok": lines.append(f" [green]✅[/green] {name_msg}") elif r["status"] == "warn": @@ -60,8 +77,7 @@ def format_report(results: Dict[str, dict]) -> str: lines.append("") lines.append("[bold]可选渠道(已安装):[/bold]") for key, r in tier1_active.items(): - name_msg = f"[bold]{escape(r['name'])}[/bold] — {escape(r['message'])}" - lines.append(f" [green]✅[/green] {name_msg}") + lines.append(f" [green]✅[/green] {_name_msg(r, escape)}") # Tier 2 — optional complex setup tier2 = {k: r for k, r in results.items() if r["tier"] == 2} @@ -72,8 +88,7 @@ def format_report(results: Dict[str, dict]) -> str: lines.append("") lines.append("[bold]可选渠道(已安装):[/bold]") for key, r in tier2_active.items(): - name_msg = f"[bold]{escape(r['name'])}[/bold] — {escape(r['message'])}" - lines.append(f" [green]✅[/green] {name_msg}") + lines.append(f" [green]✅[/green] {_name_msg(r, escape)}") lines.append("") status_color = "green" if ok_count == total else ("yellow" if ok_count > 0 else "red") diff --git a/agent_reach/probe.py b/agent_reach/probe.py new file mode 100644 index 0000000..9f20c47 --- /dev/null +++ b/agent_reach/probe.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +"""Lightweight upstream command probing. + +Distinguishes the three failure modes that look identical to shutil.which(): + - missing: command not on PATH + - broken: command exists but cannot execute — most commonly a stale venv + shebang after a system Python upgrade (pipx/uv tool installs break this + way: which() finds the shim, but exec fails with FileNotFoundError + pointing at the shim itself) + - timeout/error: command runs but misbehaves + +Channels use probe_command() inside check() so doctor reports real health, +not just file existence. +""" + +import shutil +import subprocess +from dataclasses import dataclass +from typing import Optional, Sequence + +from agent_reach.utils.process import utf8_subprocess_env + +#: Exit codes shells use for "found but not executable" / "not found". +_BROKEN_EXIT_CODES = (126, 127) + + +@dataclass +class ProbeResult: + status: str # "ok" | "missing" | "broken" | "timeout" | "error" + output: str = "" + hint: str = "" + + @property + def ok(self) -> bool: + return self.status == "ok" + + +def reinstall_hint(package: str) -> str: + """Prescription for a broken (stale-venv) CLI install.""" + return ( + f"命令存在但无法执行——通常是系统 Python 升级后 venv 解释器丢失。重装即可修复:\n" + f" uv tool install --force {package}\n" + f"或:pipx reinstall {package}" + ) + + +def probe_command( + cmd: str, + args: Sequence[str] = ("--version",), + timeout: int = 10, + retries: int = 0, + package: Optional[str] = None, +) -> ProbeResult: + """Actually execute `cmd *args` and classify the result. + + package: pip/pipx package name used in the broken-install hint + (defaults to cmd). + """ + path = shutil.which(cmd) + if not path: + return ProbeResult("missing") + + last: Optional[ProbeResult] = None + for _ in range(retries + 1): + last = _run_once(path, args, timeout, package or cmd) + if last.ok: + return last + # missing/broken won't heal between retries — only transient + # failures (timeout/error) are worth a second attempt + if last.status in ("missing", "broken"): + return last + return last + + +def _run_once(path: str, args: Sequence[str], timeout: int, package: str) -> ProbeResult: + try: + r = subprocess.run( + [path, *args], + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + env=utf8_subprocess_env(), + ) + except FileNotFoundError: + # which() found it but exec failed: the shebang interpreter is gone + return ProbeResult("broken", hint=reinstall_hint(package)) + except OSError: + return ProbeResult("broken", hint=reinstall_hint(package)) + except subprocess.TimeoutExpired: + return ProbeResult("timeout", hint=f"`{path}` 响应超时(>{timeout}s)") + + if r.returncode in _BROKEN_EXIT_CODES: + return ProbeResult("broken", hint=reinstall_hint(package)) + + output = (r.stdout or "") + (r.stderr or "") + if r.returncode != 0: + return ProbeResult("error", output=output.strip()) + return ProbeResult("ok", output=output.strip()) diff --git a/tests/test_channel_contracts.py b/tests/test_channel_contracts.py index d036c02..1733b3c 100644 --- a/tests/test_channel_contracts.py +++ b/tests/test_channel_contracts.py @@ -1,10 +1,17 @@ # -*- coding: utf-8 -*- """Contract tests for channel adapters.""" +import subprocess + from agent_reach.channels import get_all_channels from agent_reach.config import Config +def _fake_run_ok(cmd, **kwargs): + """Pretend any probed CLI executes fine and prints a version.""" + return subprocess.CompletedProcess(cmd, 0, "2026.06.09", "") + + def test_channel_registry_contract(): channels = get_all_channels() assert channels, "channel registry must not be empty" @@ -29,6 +36,67 @@ def test_channel_check_contract_with_minimal_runtime(monkeypatch, tmp_path): assert isinstance(message, str) and message.strip() +def test_channel_active_backend_attribute_contract(): + """Every channel exposes active_backend (default None, or str once set).""" + for ch in get_all_channels(): + assert hasattr(ch, "active_backend") + # Fresh instances must default to None / str (class attribute on base) + fresh = type(ch)() + assert fresh.active_backend is None or isinstance(fresh.active_backend, str) + + +def test_channel_active_backend_set_by_check(monkeypatch, tmp_path): + """After check(), active_backend is None or a str — never anything else.""" + monkeypatch.setattr("shutil.which", lambda _cmd: None) + + # Keep the network-based channels (V2EX/Xueqiu/Bilibili API) deterministic. + import urllib.request + from urllib.error import URLError + + def _no_net(*_a, **_k): + raise URLError("offline") + + monkeypatch.setattr(urllib.request, "urlopen", _no_net) + import agent_reach.channels.xueqiu as xueqiu_mod + monkeypatch.setattr(xueqiu_mod, "_cookies_initialized", True) + monkeypatch.setattr(xueqiu_mod._opener, "open", _no_net) + + config = Config(config_path=tmp_path / "config.yaml") + for ch in get_all_channels(): + ch.check(config) + assert ch.active_backend is None or isinstance(ch.active_backend, str), ( + f"{ch.name}: active_backend must be None or str after check()" + ) + + +def test_ordered_backends_contract(tmp_path): + """ordered_backends(config) is a reordering (same multiset) of backends.""" + config = Config(config_path=tmp_path / "config.yaml") + for ch in get_all_channels(): + ordered = ch.ordered_backends(config) + assert isinstance(ordered, list) + assert sorted(ordered) == sorted(ch.backends), ( + f"{ch.name}: ordered_backends must be a permutation of backends" + ) + # And without any config at all + ordered_none = ch.ordered_backends(None) + assert sorted(ordered_none) == sorted(ch.backends) + + +def test_ordered_backends_override_moves_backend_to_front(): + """Config key _backend promotes the named backend to front.""" + from agent_reach.channels.twitter import TwitterChannel + + ch = TwitterChannel() + ordered = ch.ordered_backends({"twitter_backend": "bird"}) + assert ordered[0] == "bird CLI (legacy)" + assert sorted(ordered) == sorted(ch.backends) + + # Unknown override is ignored — never hides working backends + ordered_unknown = ch.ordered_backends({"twitter_backend": "no-such-tool"}) + assert ordered_unknown == list(ch.backends) + + def test_youtube_warns_when_node_only_and_no_config(monkeypatch, tmp_path): """YouTube should warn when only Node.js is installed but no yt-dlp config exists.""" from agent_reach.channels.youtube import YouTubeChannel @@ -41,6 +109,7 @@ def test_youtube_warns_when_node_only_and_no_config(monkeypatch, tmp_path): return None # deno not installed monkeypatch.setattr("shutil.which", fake_which) + monkeypatch.setattr("subprocess.run", _fake_run_ok) # yt-dlp probe really executes now # Point to a non-existent config file monkeypatch.setattr("os.path.expanduser", lambda p: str(tmp_path / ".config/yt-dlp/config")) @@ -48,6 +117,7 @@ def test_youtube_warns_when_node_only_and_no_config(monkeypatch, tmp_path): status, message = ch.check() assert status == "warn" assert "--js-runtimes" in message + assert ch.active_backend == "yt-dlp" # 本体活着,warn 只关乎 JS runtime def test_youtube_warns_with_windows_specific_fix_command(monkeypatch, tmp_path): @@ -62,6 +132,7 @@ def test_youtube_warns_with_windows_specific_fix_command(monkeypatch, tmp_path): return None monkeypatch.setattr("shutil.which", fake_which) + monkeypatch.setattr("subprocess.run", _fake_run_ok) # yt-dlp probe really executes now monkeypatch.setattr("agent_reach.utils.paths.sys.platform", "win32") monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) @@ -84,10 +155,12 @@ def test_youtube_ok_when_deno_installed(monkeypatch): return None monkeypatch.setattr("shutil.which", fake_which) + monkeypatch.setattr("subprocess.run", _fake_run_ok) # yt-dlp probe really executes now ch = YouTubeChannel() status, _msg = ch.check() assert status == "ok" + assert ch.active_backend == "yt-dlp" def test_channel_can_handle_contract(): diff --git a/tests/test_channels.py b/tests/test_channels.py index 3ac24fa..88d79a2 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -670,9 +670,11 @@ class TestRedditChannel: monkeypatch.setattr(subprocess, "run", fake_run) from agent_reach.channels.reddit import RedditChannel - status, msg = RedditChannel().check() + ch = RedditChannel() + status, msg = ch.check() assert status == "ok" assert "testuser" in msg + assert ch.active_backend == "rdt-cli" def test_reports_warn_when_not_authenticated(self, monkeypatch): monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt") @@ -687,14 +689,18 @@ class TestRedditChannel: monkeypatch.setattr(subprocess, "run", fake_run) from agent_reach.channels.reddit import RedditChannel - status, msg = RedditChannel().check() + ch = RedditChannel() + status, msg = ch.check() assert status == "warn" assert "403" in msg assert "rdt login" in msg assert "Cookie-Editor" in msg assert "chromewebstore.google.com" in msg + # 未登录是业务态:进程活着,后端仍然算可用 + assert ch.active_backend == "rdt-cli" - def test_reports_warn_when_status_check_fails(self, monkeypatch): + def test_reports_error_when_status_check_fails(self, monkeypatch): + """rdt 非零退出且输出不可解析 → 工具异常(error),不再算 warn。""" monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt") def fake_run(cmd, **kwargs): @@ -702,8 +708,43 @@ class TestRedditChannel: monkeypatch.setattr(subprocess, "run", fake_run) from agent_reach.channels.reddit import RedditChannel - status, msg = RedditChannel().check() - assert status == "warn" + ch = RedditChannel() + status, msg = ch.check() + assert status == "error" + assert "rdt 异常退出" in msg + assert ch.active_backend is None + + def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch): + """which 命中但 exec 抛 FileNotFoundError(venv 断链)→ error + 重装处方。""" + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt") + + def fake_run(cmd, **kwargs): + raise FileNotFoundError("/usr/local/bin/rdt") + + monkeypatch.setattr(subprocess, "run", fake_run) + from agent_reach.channels.reddit import RedditChannel + ch = RedditChannel() + status, msg = ch.check() + assert status == "error" + assert "无法执行" in msg + assert "pipx install --force" in msg # rdt 专用 git 源重装处方 + assert "git+https://github.com/public-clis/rdt-cli.git" in msg + assert ch.active_backend is None + + def test_reports_error_with_reinstall_hint_on_exit_127(self, monkeypatch): + """退出码 127(找到但跑不动)同样按断链处理。""" + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt") + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 127, "", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + from agent_reach.channels.reddit import RedditChannel + ch = RedditChannel() + status, msg = ch.check() + assert status == "error" + assert "pipx install --force" in msg + assert ch.active_backend is None def test_can_handle_reddit_urls(self): from agent_reach.channels.reddit import RedditChannel @@ -723,9 +764,11 @@ class TestXiaoHongShuChannel: monkeypatch.setattr(subprocess, "run", fake_run) - status, msg = XiaoHongShuChannel().check() + ch = XiaoHongShuChannel() + status, msg = ch.check() assert status == "ok" assert "完整可用" in msg + assert ch.active_backend == "xhs-cli (xiaohongshu-cli)" def test_reports_warn_when_not_authenticated(self, monkeypatch): monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs") @@ -735,12 +778,261 @@ class TestXiaoHongShuChannel: monkeypatch.setattr(subprocess, "run", fake_run) - status, msg = XiaoHongShuChannel().check() + ch = XiaoHongShuChannel() + status, msg = ch.check() assert status == "warn" assert "xhs login" in msg + # 未登录是业务态:工具进程活着,后端仍可用 + assert ch.active_backend == "xhs-cli (xiaohongshu-cli)" def test_reports_off_when_not_installed(self, monkeypatch): monkeypatch.setattr(shutil, "which", lambda _: None) - status, msg = XiaoHongShuChannel().check() + ch = XiaoHongShuChannel() + status, msg = ch.check() assert status == "off" assert "xiaohongshu-cli" in msg + assert ch.active_backend is None + + def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch): + """which 命中但 exec 抛 FileNotFoundError(venv 断链)→ error + 重装处方。""" + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs") + + def fake_run(cmd, **kwargs): + raise FileNotFoundError("/usr/local/bin/xhs") + + monkeypatch.setattr(subprocess, "run", fake_run) + + ch = XiaoHongShuChannel() + status, msg = ch.check() + assert status == "error" + assert "无法执行" in msg + assert "uv tool install --force xiaohongshu-cli" in msg + assert "pipx reinstall xiaohongshu-cli" in msg + assert ch.active_backend is None + + +class TestBilibiliChannel: + def test_reports_error_with_reinstall_hint_when_ytdlp_broken(self, monkeypatch): + """yt-dlp which 命中但 exec 失败(venv 断链)→ error + 重装处方。""" + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/yt-dlp") + + def fake_run(cmd, **kwargs): + raise FileNotFoundError(cmd[0]) + + monkeypatch.setattr(subprocess, "run", fake_run) + from agent_reach.channels.bilibili import BilibiliChannel + ch = BilibiliChannel() + status, msg = ch.check() + assert status == "error" + assert "无法执行" in msg + assert "uv tool install --force yt-dlp" in msg + assert "pipx reinstall yt-dlp" in msg + assert ch.active_backend is None + + def test_active_backend_set_when_ytdlp_and_bili_ok(self, monkeypatch): + monkeypatch.setattr( + shutil, "which", + lambda cmd: f"/usr/local/bin/{cmd}" if cmd in ("yt-dlp", "bili") else None, + ) + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, "2026.06.09", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + from agent_reach.channels.bilibili import BilibiliChannel + ch = BilibiliChannel() + status, msg = ch.check() + assert status == "ok" + assert "bili-cli 可用" in msg + assert ch.active_backend == "yt-dlp" + + def test_bili_broken_does_not_count_as_available(self, monkeypatch): + """bili-cli 断链时不计为可用,降级走搜索 API;yt-dlp 仍是 active_backend。""" + monkeypatch.setattr( + shutil, "which", + lambda cmd: f"/usr/local/bin/{cmd}" if cmd in ("yt-dlp", "bili") else None, + ) + + def fake_run(cmd, **kwargs): + if "yt-dlp" in cmd[0]: + return subprocess.CompletedProcess(cmd, 0, "2026.06.09", "") + raise FileNotFoundError(cmd[0]) + + monkeypatch.setattr(subprocess, "run", fake_run) + import agent_reach.channels.bilibili as bilibili_mod + monkeypatch.setattr(bilibili_mod, "_search_api_ok", lambda: True) + ch = bilibili_mod.BilibiliChannel() + status, msg = ch.check() + assert status == "ok" # 搜索 API 兜底 + assert "不计为可用" in msg + assert ch.active_backend == "yt-dlp" + + +class TestYouTubeChannel: + def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch): + """yt-dlp which 命中但 exec 抛 FileNotFoundError → error + 重装处方。""" + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/yt-dlp") + + def fake_run(cmd, **kwargs): + raise FileNotFoundError(cmd[0]) + + monkeypatch.setattr(subprocess, "run", fake_run) + from agent_reach.channels.youtube import YouTubeChannel + ch = YouTubeChannel() + status, msg = ch.check() + assert status == "error" + assert "无法执行" in msg + assert "uv tool install --force yt-dlp" in msg + assert ch.active_backend is None + + +class TestGitHubChannel: + def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch): + """gh which 命中但 exec 失败 → error + brew 重装处方(gh 不是 pip 包)。""" + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/gh") + + def fake_run(cmd, **kwargs): + raise FileNotFoundError(cmd[0]) + + monkeypatch.setattr(subprocess, "run", fake_run) + from agent_reach.channels.github import GitHubChannel + ch = GitHubChannel() + status, msg = ch.check() + assert status == "error" + assert "无法执行" in msg + assert "brew reinstall gh" in msg + assert ch.active_backend is None + + def test_active_backend_set_when_authenticated(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/gh") + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, "Logged in to github.com", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + from agent_reach.channels.github import GitHubChannel + ch = GitHubChannel() + status, msg = ch.check() + assert status == "ok" + assert ch.active_backend == "gh CLI" + + def test_active_backend_set_when_unauthenticated(self, monkeypatch): + """gh auth status 非零退出是正常业务态(未登录):warn 但后端可用。""" + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/gh") + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 1, "", "You are not logged in") + + monkeypatch.setattr(subprocess, "run", fake_run) + from agent_reach.channels.github import GitHubChannel + ch = GitHubChannel() + status, msg = ch.check() + assert status == "warn" + assert "gh auth login" in msg + assert ch.active_backend == "gh CLI" + + +class TestLinkedInChannel: + def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch): + """mcporter which 命中但 exec 失败 → error + npm 重装处方。""" + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/mcporter") + + def fake_run(cmd, **kwargs): + raise FileNotFoundError(cmd[0]) + + monkeypatch.setattr(subprocess, "run", fake_run) + from agent_reach.channels.linkedin import LinkedInChannel + ch = LinkedInChannel() + status, msg = ch.check() + assert status == "error" + assert "npm install -g mcporter" in msg + assert ch.active_backend is None + + def test_active_backend_set_when_linkedin_configured(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/mcporter") + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, "linkedin http://localhost:3000/mcp", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + from agent_reach.channels.linkedin import LinkedInChannel + ch = LinkedInChannel() + status, msg = ch.check() + assert status == "ok" + assert ch.active_backend == "linkedin-scraper-mcp" + + def test_off_without_backend_when_linkedin_not_configured(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/mcporter") + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, "exa https://mcp.exa.ai/mcp", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + from agent_reach.channels.linkedin import LinkedInChannel + ch = LinkedInChannel() + status, msg = ch.check() + assert status == "off" + assert ch.active_backend is None + + +class TestExaSearchChannel: + def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch): + """mcporter which 命中但 exec 失败 → error + npm 重装处方。""" + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/mcporter") + + def fake_run(cmd, **kwargs): + raise FileNotFoundError(cmd[0]) + + monkeypatch.setattr(subprocess, "run", fake_run) + from agent_reach.channels.exa_search import ExaSearchChannel + ch = ExaSearchChannel() + status, msg = ch.check() + assert status == "error" + assert "npm install -g mcporter" in msg + assert ch.active_backend is None + + def test_active_backend_set_when_exa_configured(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/mcporter") + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, "exa https://mcp.exa.ai/mcp", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + from agent_reach.channels.exa_search import ExaSearchChannel + ch = ExaSearchChannel() + status, msg = ch.check() + assert status == "ok" + assert ch.active_backend == "Exa via mcporter" + + +class TestXiaoyuzhouChannel: + def test_reports_error_with_reinstall_hint_when_ffmpeg_broken(self, monkeypatch): + """ffmpeg which 命中但 exec 失败(pip 假 ffmpeg 断链)→ error + 重装处方。""" + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/ffmpeg") + + def fake_run(cmd, **kwargs): + raise FileNotFoundError(cmd[0]) + + monkeypatch.setattr(subprocess, "run", fake_run) + from agent_reach.channels.xiaoyuzhou import XiaoyuzhouChannel + ch = XiaoyuzhouChannel() + status, msg = ch.check() + assert status == "error" + assert "无法执行" in msg + assert "brew install ffmpeg" in msg + assert ch.active_backend is None + + def test_active_backend_set_when_fully_configured(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/ffmpeg") + + def fake_run(cmd, **kwargs): + return subprocess.CompletedProcess(cmd, 0, "ffmpeg version 7.0", "") + + monkeypatch.setattr(subprocess, "run", fake_run) + monkeypatch.setattr("os.path.isfile", lambda p: True) # transcribe.sh 已安装 + monkeypatch.setenv("GROQ_API_KEY", "gsk_test") + from agent_reach.channels.xiaoyuzhou import XiaoyuzhouChannel + ch = XiaoyuzhouChannel() + status, msg = ch.check() + assert status == "ok" + assert ch.active_backend == "groq-whisper" diff --git a/tests/test_doctor.py b/tests/test_doctor.py index f3aa77d..8ecdf57 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -8,13 +8,15 @@ from agent_reach.config import Config class _StubChannel: - def __init__(self, name, description, tier, status, message, backends=None): + def __init__(self, name, description, tier, status, message, backends=None, + active_backend=None): self.name = name self.description = description self.tier = tier self._status = status self._message = message self.backends = backends or [] + self.active_backend = active_backend def check(self, config=None): return self._status, self._message @@ -31,7 +33,8 @@ class TestDoctor: doctor, "get_all_channels", lambda: [ - _StubChannel("web", "网页", 0, "ok", "可抓取网页", ["requests"]), + _StubChannel("web", "网页", 0, "ok", "可抓取网页", ["requests"], + active_backend="requests"), _StubChannel("github", "GitHub", 0, "warn", "gh 未安装", ["gh"]), _StubChannel("exa_search", "全网语义搜索", 1, "off", "mcporter 未配置", ["Exa"]), ], @@ -46,6 +49,7 @@ class TestDoctor: "message": "可抓取网页", "tier": 0, "backends": ["requests"], + "active_backend": "requests", }, "github": { "status": "warn", @@ -53,6 +57,7 @@ class TestDoctor: "message": "gh 未安装", "tier": 0, "backends": ["gh"], + "active_backend": None, }, "exa_search": { "status": "off", @@ -60,6 +65,7 @@ class TestDoctor: "message": "mcporter 未配置", "tier": 1, "backends": ["Exa"], + "active_backend": None, }, } diff --git a/tests/test_probe.py b/tests/test_probe.py new file mode 100644 index 0000000..f1a673f --- /dev/null +++ b/tests/test_probe.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +"""Tests for agent_reach.probe — real-execution probing and failure classification.""" + +import os +import stat +import sys + +import pytest + +from agent_reach.probe import ProbeResult, probe_command, reinstall_hint + + +def _make_executable(path, content): + path.write_text(content) + path.chmod(path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + return str(path) + + +def test_missing_command(): + r = probe_command("definitely-not-a-real-command-xyz") + assert r.status == "missing" + assert not r.ok + + +@pytest.mark.skipif(sys.platform == "win32", reason="shebang semantics are POSIX-only") +def test_broken_shebang_detected_as_broken(tmp_path, monkeypatch): + """A stale venv shim: which() finds it, exec raises FileNotFoundError.""" + script = _make_executable( + tmp_path / "stale-tool", "#!/nonexistent/venv/bin/python\nprint('hi')\n" + ) + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", "")) + + r = probe_command("stale-tool", package="stale-tool-pkg") + assert r.status == "broken" + assert "uv tool install --force stale-tool-pkg" in r.hint + assert "pipx reinstall stale-tool-pkg" in r.hint + + +@pytest.mark.skipif(sys.platform == "win32", reason="shell script fixture is POSIX-only") +def test_healthy_command_returns_ok_with_output(tmp_path, monkeypatch): + script = _make_executable( + tmp_path / "healthy-tool", "#!/bin/sh\necho 'healthy-tool 1.2.3'\n" + ) + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", "")) + + r = probe_command("healthy-tool") + assert r.ok + assert "1.2.3" in r.output + + +@pytest.mark.skipif(sys.platform == "win32", reason="shell script fixture is POSIX-only") +def test_nonzero_exit_classified_as_error(tmp_path, monkeypatch): + script = _make_executable( + tmp_path / "failing-tool", "#!/bin/sh\necho 'boom' >&2\nexit 3\n" + ) + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", "")) + + r = probe_command("failing-tool") + assert r.status == "error" + assert "boom" in r.output + + +@pytest.mark.skipif(sys.platform == "win32", reason="shell script fixture is POSIX-only") +def test_exit_127_classified_as_broken(tmp_path, monkeypatch): + script = _make_executable(tmp_path / "wrapper-tool", "#!/bin/sh\nexit 127\n") + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", "")) + + r = probe_command("wrapper-tool", package="wrapper-pkg") + assert r.status == "broken" + assert "wrapper-pkg" in r.hint + + +@pytest.mark.skipif(sys.platform == "win32", reason="shell script fixture is POSIX-only") +def test_retries_help_transient_failures(tmp_path, monkeypatch): + """First call fails (exit 1), second succeeds — retries=1 should return ok.""" + marker = tmp_path / "ran-once" + script = _make_executable( + tmp_path / "flaky-tool", + f"#!/bin/sh\nif [ -f {marker} ]; then echo ok; exit 0; fi\ntouch {marker}\nexit 1\n", + ) + monkeypatch.setenv("PATH", str(tmp_path) + os.pathsep + os.environ.get("PATH", "")) + + r = probe_command("flaky-tool", retries=1) + assert r.ok + + +def test_reinstall_hint_mentions_both_installers(): + hint = reinstall_hint("some-pkg") + assert "uv tool install --force some-pkg" in hint + assert "pipx reinstall some-pkg" in hint diff --git a/tests/test_twitter_channel.py b/tests/test_twitter_channel.py index a84b044..1f8c716 100644 --- a/tests/test_twitter_channel.py +++ b/tests/test_twitter_channel.py @@ -26,6 +26,7 @@ def test_check_twitter_cli_found_and_auth_ok(): assert status == "ok" assert "twitter-cli" in message assert "完整可用" in message + assert channel.active_backend == "twitter-cli" def test_check_twitter_cli_found_auth_missing(): @@ -41,6 +42,8 @@ def test_check_twitter_cli_found_auth_missing(): status, message = channel.check() assert status == "warn" assert "未认证" in message + # 未认证是业务态:工具进程活着,后端仍可用 + assert channel.active_backend == "twitter-cli" # --- bird CLI fallback tests --- @@ -59,6 +62,7 @@ def test_check_bird_fallback_auth_ok(): status, message = channel.check() assert status == "ok" assert "bird" in message + assert channel.active_backend == "bird CLI (legacy)" def test_check_bird_fallback_auth_missing(): @@ -86,6 +90,7 @@ def test_check_nothing_installed(): status, message = channel.check() assert status == "warn" assert "twitter-cli" in message + assert channel.active_backend is None # --- twitter-cli preferred over bird --- @@ -106,3 +111,44 @@ def test_twitter_cli_preferred_over_bird(): status, message = channel.check() assert status == "ok" assert "twitter-cli" in message + assert channel.active_backend == "twitter-cli" + + +# --- broken install (stale venv shim) --- + +def test_check_twitter_cli_broken_reports_error_with_reinstall_hint(): + """which 命中但 exec 抛 FileNotFoundError(venv 断链)→ error + 重装处方。""" + channel = TwitterChannel() + with patch( + "shutil.which", + side_effect=lambda name: "/usr/local/bin/twitter" if name == "twitter" else None, + ), patch("subprocess.run", side_effect=FileNotFoundError("/usr/local/bin/twitter")): + status, message = channel.check() + assert status == "error" + assert "无法执行" in message + assert "uv tool install --force twitter-cli" in message + assert "pipx reinstall twitter-cli" in message + assert channel.active_backend is None + + +def test_check_twitter_cli_broken_falls_back_to_bird(): + """twitter-cli 断链但 bird 健康 → 回退到 bird,后端正确归属。""" + channel = TwitterChannel() + + def which_side_effect(name): + if name in ("twitter", "bird"): + return f"/usr/local/bin/{name}" + return None + + def run_side_effect(cmd, **kwargs): + if "twitter" in cmd[0]: + raise FileNotFoundError(cmd[0]) + return _cp(stdout="Authenticated as @user\n", returncode=0) + + with patch("shutil.which", side_effect=which_side_effect), patch( + "subprocess.run", side_effect=run_side_effect + ): + status, message = channel.check() + assert status == "ok" + assert "bird" in message + assert channel.active_backend == "bird CLI (legacy)"