feat(routing): ordered backend candidates + real-probing doctor

- 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 (<channel>_backend / <CHANNEL>_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 <noreply@anthropic.com>
This commit is contained in:
Pnant
2026-06-11 15:48:33 +08:00
parent 447dc4acc4
commit 762824c590
23 changed files with 988 additions and 177 deletions
+1
View File
@@ -11,3 +11,4 @@ build/
# Claude Code personal permission settings — local only, never commit # Claude Code personal permission settings — local only, never commit
.claude/settings.local.json .claude/settings.local.json
uv.lock
+37 -3
View File
@@ -8,11 +8,22 @@ and provides:
- check(config) → is the upstream tool installed and configured? - check(config) → is the upstream tool installed and configured?
After installation, agents call upstream tools directly. 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 `<channel>_backend`
(or env var `<CHANNEL>_BACKEND`); ordered_backends() applies it.
""" """
import shutil
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import List, Tuple from typing import List, Optional, Tuple
class Channel(ABC): class Channel(ABC):
@@ -20,17 +31,40 @@ class Channel(ABC):
name: str = "" # e.g. "youtube" name: str = "" # e.g. "youtube"
description: 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 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 @abstractmethod
def can_handle(self, url: str) -> bool: def can_handle(self, url: str) -> bool:
"""Check if this channel can handle this URL.""" """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 `<channel>_backend` (env `<CHANNEL>_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]: def check(self, config=None) -> Tuple[str, str]:
""" """
Check if this channel's upstream tool is available. Check if this channel's upstream tool is available.
Returns (status, message) where status is 'ok'/'warn'/'off'/'error'. 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 '内置'}" return "ok", f"{''.join(self.backends) if self.backends else '内置'}"
+27 -8
View File
@@ -3,9 +3,10 @@
import json import json
import os import os
import shutil
import subprocess
import urllib.request import urllib.request
from agent_reach.probe import probe_command
from .base import Channel from .base import Channel
_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36" _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 return "bilibili.com" in d or "b23.tv" in d
def check(self, config=None): 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" 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") 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 = [] parts = []
@@ -51,16 +64,22 @@ class BilibiliChannel(Channel):
parts.append("视频读取:yt-dlp") parts.append("视频读取:yt-dlp")
# bili-cli 增强 # bili-cli 增强
if has_bili_cli: if bili.ok:
parts.append("搜索/热门/排行:bili-cli 可用") parts.append("搜索/热门/排行:bili-cli 可用")
status = "ok"
else: 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() api_ok = _search_api_ok()
if api_ok: if api_ok:
parts.append("搜索:B站 API 可用") parts.append("搜索:B站 API 可用")
else: else:
parts.append("搜索:B站 API 不可达") 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) return status, "".join(parts)
+19 -17
View File
@@ -1,10 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Exa Search — check if mcporter + Exa MCP is available.""" """Exa Search — check if mcporter + Exa MCP is available."""
import shutil from agent_reach.probe import probe_command
import subprocess
from .base import Channel from .base import Channel
#: mcporter 是 npm 包,断链处方与默认的 pipx/uv 不同
_MCPORTER_BROKEN_HINT = "mcporter 无法执行(node 环境损坏),重装:\n npm install -g mcporter"
class ExaSearchChannel(Channel): class ExaSearchChannel(Channel):
name = "exa_search" name = "exa_search"
@@ -16,23 +19,22 @@ class ExaSearchChannel(Channel):
return False # Search-only channel return False # Search-only channel
def check(self, config=None): def check(self, config=None):
mcporter = shutil.which("mcporter") self.active_backend = None
if not mcporter: probe = probe_command("mcporter", ["config", "list"], timeout=10, package="mcporter")
if probe.status == "missing":
return "off", ( return "off", (
"需要 mcporter + Exa MCP。安装:\n" "需要 mcporter + Exa MCP。安装:\n"
" npm install -g mcporter\n" " npm install -g mcporter\n"
" mcporter config add exa https://mcp.exa.ai/mcp" " mcporter config add exa https://mcp.exa.ai/mcp"
) )
try: if probe.status == "broken":
r = subprocess.run( return "error", _MCPORTER_BROKEN_HINT
[mcporter, "config", "list"], capture_output=True, if not probe.ok: # timeout / error
encoding="utf-8", errors="replace", timeout=5 return "error", f"mcporter 执行异常:{probe.hint or probe.output or probe.status}"
) if "exa" in probe.output.lower():
if "exa" in r.stdout.lower(): self.active_backend = self.backends[0]
return "ok", "全网语义搜索可用(免费,无需 API Key)" return "ok", "全网语义搜索可用(免费,无需 API Key)"
return "off", ( return "off", (
"mcporter 已装但 Exa 未配置。运行:\n" "mcporter 已装但 Exa 未配置。运行:\n"
" mcporter config add exa https://mcp.exa.ai/mcp" " mcporter config add exa https://mcp.exa.ai/mcp"
) )
except Exception:
return "off", "mcporter 连接异常"
+23 -13
View File
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""GitHub — check if gh CLI is available.""" """GitHub — check if gh CLI is available."""
import shutil from agent_reach.probe import probe_command
import subprocess
from .base import Channel from .base import Channel
@@ -17,16 +17,26 @@ class GitHubChannel(Channel):
return "github.com" in urlparse(url).netloc.lower() return "github.com" in urlparse(url).netloc.lower()
def check(self, config=None): def check(self, config=None):
gh = shutil.which("gh") # 真跑 gh auth status 探活。注意:未登录时 rc!=0 是正常业务态(warn),不是 error。
if not gh: 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" return "warn", "gh CLI 未安装。安装:https://cli.github.com"
try: if probe.status == "broken":
r = subprocess.run( # gh 是二进制安装(brew/官方包),不是 pip 包——处方不用 pipx/uv 文案
[gh, "auth", "status"], self.active_backend = None
capture_output=True, encoding="utf-8", errors="replace", timeout=5 return "error", (
"gh 命令存在但无法执行——安装已损坏。重装即可修复:\n"
" brew reinstall gh\n"
"或从 https://cli.github.com 重新安装 gh CLI"
) )
if r.returncode == 0: if probe.status == "timeout":
return "ok", "完整可用(读取、搜索、Fork、Issue、PR 等)" # gh 本体能启动(工具是活的),只是状态检查超时
return "warn", "gh CLI 已安装但未认证。运行 gh auth login 可解锁完整功能" self.active_backend = "gh CLI"
except Exception: return "warn", "gh CLI 状态检查超时,运行 gh auth status 查看详情"
return "warn", "gh CLI 状态检查失败,运行 gh auth status 查看详情" if probe.ok:
self.active_backend = "gh CLI"
return "ok", "完整可用(读取、搜索、Fork、Issue、PR 等)"
# rc != 0gh 活着但未认证(gh auth status 的正常业务态)
self.active_backend = "gh CLI"
return "warn", "gh CLI 已安装但未认证。运行 gh auth login 可解锁完整功能"
+14 -16
View File
@@ -1,13 +1,13 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""LinkedIn — check if linkedin-scraper-mcp is available.""" """LinkedIn — check if linkedin-scraper-mcp is available."""
import shutil from agent_reach.probe import probe_command
import subprocess
from agent_reach.utils.process import utf8_subprocess_env
from .base import Channel from .base import Channel
#: mcporter 是 npm 包,断链处方与默认的 pipx/uv 不同
_MCPORTER_BROKEN_HINT = "mcporter 无法执行(node 环境损坏),重装:\n npm install -g mcporter"
class LinkedInChannel(Channel): class LinkedInChannel(Channel):
name = "linkedin" name = "linkedin"
@@ -20,24 +20,22 @@ class LinkedInChannel(Channel):
return "linkedin.com" in urlparse(url).netloc.lower() return "linkedin.com" in urlparse(url).netloc.lower()
def check(self, config=None): def check(self, config=None):
mcporter = shutil.which("mcporter") self.active_backend = None
if not mcporter: probe = probe_command("mcporter", ["config", "list"], timeout=10, package="mcporter")
if probe.status == "missing":
return "off", ( return "off", (
"基本内容可通过 Jina Reader 读取。完整功能需要:\n" "基本内容可通过 Jina Reader 读取。完整功能需要:\n"
" pip install linkedin-scraper-mcp\n" " pip install linkedin-scraper-mcp\n"
" mcporter config add linkedin http://localhost:3000/mcp\n" " mcporter config add linkedin http://localhost:3000/mcp\n"
" 详见 https://github.com/stickerdaniel/linkedin-mcp-server" " 详见 https://github.com/stickerdaniel/linkedin-mcp-server"
) )
try: if probe.status == "broken":
r = subprocess.run( return "error", _MCPORTER_BROKEN_HINT
[mcporter, "config", "list"], capture_output=True, if not probe.ok: # timeout / error
encoding="utf-8", errors="replace", timeout=5, return "error", f"mcporter 执行异常:{probe.hint or probe.output or probe.status}"
env=utf8_subprocess_env(), if "linkedin" in probe.output.lower():
) self.active_backend = "linkedin-scraper-mcp"
if "linkedin" in r.stdout.lower(): return "ok", "完整可用(Profile、公司、职位搜索)"
return "ok", "完整可用(Profile、公司、职位搜索)"
except Exception:
pass
return "off", ( return "off", (
"mcporter 已装但 LinkedIn MCP 未配置。运行:\n" "mcporter 已装但 LinkedIn MCP 未配置。运行:\n"
" pip install linkedin-scraper-mcp\n" " pip install linkedin-scraper-mcp\n"
+66 -24
View File
@@ -10,12 +10,24 @@ import json
import shutil import shutil
import subprocess import subprocess
from agent_reach.utils.process import utf8_subprocess_env
from .base import Channel from .base import Channel
_CREDENTIAL_FILE = "~/.config/rdt-cli/credential.json" _CREDENTIAL_FILE = "~/.config/rdt-cli/credential.json"
# Pinned to the 0.4.2 state — PyPI still only has 0.4.1 (upstream issue #10). # 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" _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): class RedditChannel(Channel):
name = "reddit" name = "reddit"
@@ -30,6 +42,8 @@ class RedditChannel(Channel):
return "reddit.com" in d or "redd.it" in d return "reddit.com" in d or "redd.it" in d
def check(self, config=None): def check(self, config=None):
self.active_backend = None
rdt = shutil.which("rdt") rdt = shutil.which("rdt")
if not rdt: if not rdt:
return "off", ( return "off", (
@@ -42,6 +56,10 @@ class RedditChannel(Channel):
"安装后运行 `rdt login` 登录(需先在浏览器登录 reddit.com)" "安装后运行 `rdt login` 登录(需先在浏览器登录 reddit.com)"
) )
# 不走 probe_command:实测 `rdt status --json` 成功时(rc=0)也会向 stderr
# 打网络重试日志,probe 把 stdout+stderr 合并后 JSON 解析必炸。
# 故保留手写 subprocess(stdout 单独捕获),但异常分类对齐 probe 语义:
# exec 失败/126/127 → brokenvenv 断链处方),TimeoutExpired → 超时。
try: try:
r = subprocess.run( r = subprocess.run(
[rdt, "status", "--json"], [rdt, "status", "--json"],
@@ -49,31 +67,55 @@ class RedditChannel(Channel):
encoding="utf-8", encoding="utf-8",
errors="replace", errors="replace",
timeout=10, timeout=10,
env=utf8_subprocess_env(),
) )
data = json.loads(r.stdout or "{}") except subprocess.TimeoutExpired:
authenticated = data.get("data", {}).get("authenticated", False) return "error", "rdt 响应超时(>10s),Reddit 状态未知。稍后重试或运行 `rdt status` 查看详情"
username = data.get("data", {}).get("username") or "" except OSError:
# 含 FileNotFoundErrorwhich 命中但 exec 失败 = venv 断链(probe 的 broken
return "error", _RDT_BROKEN_HINT
if authenticated: if r.returncode in _BROKEN_EXIT_CODES:
suffix = f"(已登录:{username}" if username else "" return "error", _RDT_BROKEN_HINT
return "ok", (f"rdt-cli 可用{suffix}(搜索帖子、阅读全文、查看评论)")
return "warn", ( if r.returncode != 0:
"rdt-cli 已安装但未登录。Reddit 自 2024 年起要求认证," detail = (r.stderr or r.stdout or "").strip().splitlines()
"未登录时所有请求均返回 403。\n\n" tail = detail[-1] if detail else "无输出"
"方法一(自动):运行 `rdt login`\n" return "error", f"rdt 异常退出(exit {r.returncode}):{tail}。运行 `rdt status` 查看详情"
" 先在浏览器登录 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"
)
except (json.JSONDecodeError, FileNotFoundError, subprocess.TimeoutExpired): # 进程正常退出 → rdt 本身是活的(无论登录与否),后端即为可用
return "warn", "rdt-cli 已安装但状态检查失败,运行 `rdt status` 查看详情" 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"
)
+8 -2
View File
@@ -15,7 +15,13 @@ class RSSChannel(Channel):
def check(self, config=None): def check(self, config=None):
try: try:
import feedparser import feedparser # noqa: F401
return "ok", "可读取 RSS/Atom 源"
except ImportError: except ImportError:
self.active_backend = None
return "off", "feedparser 未安装。安装:pip install feedparser" 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 源"
+86 -46
View File
@@ -1,9 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Twitter/X — check if twitter-cli or bird CLI is available.""" """Twitter/X — check if twitter-cli or bird CLI is available."""
import shutil
import subprocess
from .base import Channel from .base import Channel
from agent_reach.probe import probe_command
class TwitterChannel(Channel): class TwitterChannel(Channel):
@@ -18,56 +17,98 @@ class TwitterChannel(Channel):
return "x.com" in d or "twitter.com" in d return "x.com" in d or "twitter.com" in d
def check(self, config=None): def check(self, config=None):
# Prefer twitter-cli, fallback to bird/birdx """按 backends 顺序真实探测,第一个活着的后端即为 active_backend。"""
twitter = shutil.which("twitter") self.active_backend = None
bird = shutil.which("bird") or shutil.which("birdx") failures = []
if twitter: for backend in self.ordered_backends(config):
return self._check_twitter_cli(twitter) if backend == "twitter-cli":
elif bird: result = self._check_twitter_cli()
return self._check_bird(bird) elif backend == "bird CLI (legacy)":
else: 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", ( return "warn", (
"Twitter CLI 未安装。安装方式:\n" "twitter-cli 已安装但未认证。设置方式:\n"
" pipx install twitter-cli\n" " export TWITTER_AUTH_TOKEN=\"xxx\"\n"
"或:\n" " export TWITTER_CT0=\"yyy\"\n"
" uv tool install twitter-cli" "或确保已在浏览器中登录 x.com"
) )
return "warn", (
"twitter-cli 已安装但认证检查失败。运行:\n"
" twitter -v status 查看详细信息"
)
def _check_twitter_cli(self, binary: str): def _check_bird(self):
try: """探测 bird/birdxlegacy 回退)。返回 None 表示均未安装,否则返回 (status, message)。"""
r = subprocess.run( last_failure = None
[binary, "status"], capture_output=True, for cmd in ("bird", "birdx"):
encoding="utf-8", errors="replace", timeout=10 probe = probe_command(
cmd, ["check"], timeout=15, retries=1, package="@steipete/bird"
) )
output = (r.stdout or "") + (r.stderr or "") if probe.status == "missing":
if r.returncode == 0 and "ok: true" in output: continue
return "ok", ( if probe.status == "broken":
"twitter-cli 完整可用(搜索、读推文、时间线、长文/Article、" last_failure = (
"用户查询、Thread" "error",
f"{cmd} 命令存在但无法执行(bird 是 npm 包,可用 "
"npm install -g @steipete/bird 重装)。\n" + probe.hint,
) )
if "not_authenticated" in output: continue # bird 坏了再试 birdx
return "warn", ( if probe.status == "timeout":
"twitter-cli 已安装但未认证。设置方式:\n" last_failure = (
" export TWITTER_AUTH_TOKEN=\"xxx\"\n" "error",
" export TWITTER_CT0=\"yyy\"\n" f"{cmd} 健康检查超时(已重试 1 次)。\n" + probe.hint,
"或确保已在浏览器中登录 x.com"
) )
return "warn", ( continue
"twitter-cli 已安装但认证检查失败。运行:\n"
" twitter -v status 查看详细信息"
)
except Exception:
return "warn", "twitter-cli 已安装但连接失败"
def _check_bird(self, binary: str): output = probe.output
try: if probe.ok:
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:
return "ok", "bird CLI 可用(读取、搜索推文,含长文/X Article)" return "ok", "bird CLI 可用(读取、搜索推文,含长文/X Article)"
if "Missing credentials" in output or "missing" in output.lower(): if "Missing credentials" in output or "missing" in output.lower():
return "warn", ( return "warn", (
@@ -78,5 +119,4 @@ class TwitterChannel(Channel):
return "warn", ( return "warn", (
"bird CLI 已安装但认证检查失败。" "bird CLI 已安装但认证检查失败。"
) )
except Exception: return last_failure
return "warn", "bird CLI 已安装但连接失败"
+2
View File
@@ -41,8 +41,10 @@ class V2EXChannel(Channel):
_get_json( _get_json(
"https://www.v2ex.com/api/topics/show.json?node_name=python&page=1" "https://www.v2ex.com/api/topics/show.json?node_name=python&page=1"
) )
self.active_backend = self.backends[0]
return "ok", "公开 API 可用(热门主题、节点浏览、主题详情、用户信息)" return "ok", "公开 API 可用(热门主题、节点浏览、主题详情、用户信息)"
except Exception as e: except Exception as e:
self.active_backend = None
return "warn", f"V2EX API 连接失败(可能需要代理):{e}" return "warn", f"V2EX API 连接失败(可能需要代理):{e}"
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
+2
View File
@@ -17,6 +17,8 @@ class WebChannel(Channel):
return True # Fallback — handles any URL return True # Fallback — handles any URL
def check(self, config=None): def check(self, config=None):
# 恒可用兜底渠道:无本地命令、不做网络探测(doctor 已有多个渠道触网),保持零开销
self.active_backend = self.backends[0]
return "ok", "通过 Jina Reader 读取任意网页(curl https://r.jina.ai/URL" return "ok", "通过 Jina Reader 读取任意网页(curl https://r.jina.ai/URL"
def read(self, url: str) -> str: def read(self, url: str) -> str:
+28 -24
View File
@@ -1,8 +1,8 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""XiaoHongShu — check if xhs-cli (xiaohongshu-cli) is available.""" """XiaoHongShu — check if xhs-cli (xiaohongshu-cli) is available."""
import shutil from agent_reach.probe import probe_command
import subprocess
from .base import Channel from .base import Channel
@@ -127,8 +127,12 @@ class XiaoHongShuChannel(Channel):
return "xiaohongshu.com" in d or "xhslink.com" in d return "xiaohongshu.com" in d or "xhslink.com" in d
def check(self, config=None): def check(self, config=None):
xhs = shutil.which("xhs") self.active_backend = None
if not xhs: probe = probe_command(
"xhs", ["status"], timeout=10, package="xiaohongshu-cli"
)
if probe.status == "missing":
return "off", ( return "off", (
"需要安装 xhs-cli\n" "需要安装 xhs-cli\n"
" pipx install xiaohongshu-cli\n" " pipx install xiaohongshu-cli\n"
@@ -136,27 +140,27 @@ class XiaoHongShuChannel(Channel):
" uv tool install xiaohongshu-cli\n" " uv tool install xiaohongshu-cli\n"
"安装后运行 `xhs login` 登录" "安装后运行 `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( if probe.ok and "ok: true" in probe.output:
[xhs, "status"], capture_output=True, self.active_backend = self.backends[0]
encoding="utf-8", errors="replace", timeout=10, return "ok", (
"完整可用(搜索、阅读、评论、发帖、热门、"
"收藏、关注、用户查询)"
) )
output = (r.stdout or "") + (r.stderr or "") if "not_authenticated" in probe.output or "expired" in probe.output:
if r.returncode == 0 and "ok: true" in output: self.active_backend = self.backends[0]
return "ok", (
"完整可用(搜索、阅读、评论、发帖、热门、"
"收藏、关注、用户查询)"
)
if "not_authenticated" in output or "expired" in output:
return "warn", (
"xhs-cli 已安装但未登录。运行:\n"
" xhs login\n"
"(自动从浏览器提取 Cookie,或扫码登录)"
)
return "warn", ( return "warn", (
"xhs-cli 已安装但状态异常。运行:\n" "xhs-cli 已安装但未登录。运行:\n"
" xhs -v status 查看详细信息" " xhs login\n"
"(自动从浏览器提取 Cookie,或扫码登录)"
) )
except Exception: self.active_backend = self.backends[0]
return "warn", "xhs-cli 已安装但连接失败" return "warn", (
"xhs-cli 已安装但状态异常。运行:\n"
" xhs -v status 查看详细信息"
)
+12 -3
View File
@@ -2,8 +2,8 @@
"""Xiaoyuzhou Podcast (小宇宙播客) — transcribe podcasts via Groq Whisper API.""" """Xiaoyuzhou Podcast (小宇宙播客) — transcribe podcasts via Groq Whisper API."""
import os import os
import shutil
from agent_reach.config import Config from agent_reach.config import Config
from agent_reach.probe import probe_command
from .base import Channel from .base import Channel
@@ -19,13 +19,21 @@ class XiaoyuzhouChannel(Channel):
return "xiaoyuzhoufm.com" in d return "xiaoyuzhoufm.com" in d
def check(self, config=None): def check(self, config=None):
# Check ffmpeg self.active_backend = None
if not shutil.which("ffmpeg"):
# 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", ( return "off", (
"需要 ffmpeg(音频转码和切片)。安装:\n" "需要 ffmpeg(音频转码和切片)。安装:\n"
" Ubuntu/Debian: apt install -y ffmpeg\n" " Ubuntu/Debian: apt install -y ffmpeg\n"
" macOS: brew install ffmpeg" " macOS: brew install ffmpeg"
) )
if not probe.ok:
return "error", (
"ffmpeg 无法执行,重装:brew install ffmpegmacOS/ apt install ffmpegLinux"
)
# Check script exists # Check script exists
script = os.path.expanduser("~/.agent-reach/tools/xiaoyuzhou/transcribe.sh") 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" " 2. 运行: agent-reach configure groq-key gsk_xxxxx"
) )
self.active_backend = "groq-whisper"
return "ok", "完整可用(播客下载 + Whisper 转录)" return "ok", "完整可用(播客下载 + Whisper 转录)"
+2
View File
@@ -162,12 +162,14 @@ class XueqiuChannel(Channel):
# ------------------------------------------------------------------ # # ------------------------------------------------------------------ #
def check(self, config=None): def check(self, config=None):
self.active_backend = None
try: try:
data = _get_json( data = _get_json(
"https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=SH000001" "https://stock.xueqiu.com/v5/stock/batch/quote.json?symbol=SH000001"
) )
items = (data.get("data") or {}).get("items") or [] items = (data.get("data") or {}).get("items") or []
if items: if items:
self.active_backend = self.backends[0]
return "ok", "公开 API 可用(行情、搜索、热帖、热股)" return "ok", "公开 API 可用(行情、搜索、热帖、热股)"
return "warn", "API 响应异常(返回数据为空)" return "warn", "API 响应异常(返回数据为空)"
except Exception as e: except Exception as e:
+14 -1
View File
@@ -3,6 +3,7 @@
import shutil 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.paths import get_ytdlp_config_path, render_ytdlp_fix_command
from agent_reach.utils.text import read_utf8_text 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 return "youtube.com" in d or "youtu.be" in d
def check(self, config=None): 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" 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 # Check JS runtime
has_js = shutil.which("deno") or shutil.which("node") has_js = shutil.which("deno") or shutil.which("node")
if not has_js: if not has_js:
+9 -3
View File
@@ -364,8 +364,11 @@ def _install_skill():
def _copy_skill_dir(target: str) -> bool: def _copy_skill_dir(target: str) -> bool:
"""Copy entire skill directory (locale-specific SKILL.md + references/).""" """Copy entire skill directory (locale-specific SKILL.md + references/)."""
try: try:
# Clear existing installation # Clear existing installation. A symlinked skill dir (dotfiles
if os.path.exists(target): # 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) shutil.rmtree(target)
os.makedirs(target, exist_ok=True) os.makedirs(target, exist_ok=True)
@@ -454,7 +457,10 @@ def _uninstall_skill():
skill_path = os.path.expanduser(skill_path_template) skill_path = os.path.expanduser(skill_path_template)
if os.path.isdir(skill_path): if os.path.isdir(skill_path):
try: 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}") print(f" Removed {platform_name} skill: {skill_path}")
removed = True removed = True
except Exception as e: except Exception as e:
+22 -7
View File
@@ -10,20 +10,37 @@ from agent_reach.channels import get_all_channels
def check_all(config: Config) -> Dict[str, dict]: 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 = {} results = {}
for ch in get_all_channels(): 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] = { results[ch.name] = {
"status": status, "status": status,
"name": ch.description, "name": ch.description,
"message": message, "message": message,
"tier": ch.tier, "tier": ch.tier,
"backends": ch.backends, "backends": ch.backends,
"active_backend": getattr(ch, "active_backend", None),
} }
return results 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: def format_report(results: Dict[str, dict]) -> str:
"""Format results as a readable text report (with Rich markup).""" """Format results as a readable text report (with Rich markup)."""
try: try:
@@ -44,7 +61,7 @@ def format_report(results: Dict[str, dict]) -> str:
lines.append("[bold]✅ 装好即用:[/bold]") lines.append("[bold]✅ 装好即用:[/bold]")
for key, r in results.items(): for key, r in results.items():
if r["tier"] == 0: 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": if r["status"] == "ok":
lines.append(f" [green]✅[/green] {name_msg}") lines.append(f" [green]✅[/green] {name_msg}")
elif r["status"] == "warn": elif r["status"] == "warn":
@@ -60,8 +77,7 @@ def format_report(results: Dict[str, dict]) -> str:
lines.append("") lines.append("")
lines.append("[bold]可选渠道(已安装):[/bold]") lines.append("[bold]可选渠道(已安装):[/bold]")
for key, r in tier1_active.items(): 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(r, escape)}")
lines.append(f" [green]✅[/green] {name_msg}")
# Tier 2 — optional complex setup # Tier 2 — optional complex setup
tier2 = {k: r for k, r in results.items() if r["tier"] == 2} 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("")
lines.append("[bold]可选渠道(已安装):[/bold]") lines.append("[bold]可选渠道(已安装):[/bold]")
for key, r in tier2_active.items(): 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(r, escape)}")
lines.append(f" [green]✅[/green] {name_msg}")
lines.append("") lines.append("")
status_color = "green" if ok_count == total else ("yellow" if ok_count > 0 else "red") status_color = "green" if ok_count == total else ("yellow" if ok_count > 0 else "red")
+99
View File
@@ -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())
+73
View File
@@ -1,10 +1,17 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Contract tests for channel adapters.""" """Contract tests for channel adapters."""
import subprocess
from agent_reach.channels import get_all_channels from agent_reach.channels import get_all_channels
from agent_reach.config import Config 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(): def test_channel_registry_contract():
channels = get_all_channels() channels = get_all_channels()
assert channels, "channel registry must not be empty" 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() 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 <channel>_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): 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.""" """YouTube should warn when only Node.js is installed but no yt-dlp config exists."""
from agent_reach.channels.youtube import YouTubeChannel 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 return None # deno not installed
monkeypatch.setattr("shutil.which", fake_which) 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 # Point to a non-existent config file
monkeypatch.setattr("os.path.expanduser", lambda p: str(tmp_path / ".config/yt-dlp/config")) 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() status, message = ch.check()
assert status == "warn" assert status == "warn"
assert "--js-runtimes" in message 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): 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 return None
monkeypatch.setattr("shutil.which", fake_which) 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.setattr("agent_reach.utils.paths.sys.platform", "win32")
monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming")) monkeypatch.setenv("APPDATA", str(tmp_path / "AppData" / "Roaming"))
@@ -84,10 +155,12 @@ def test_youtube_ok_when_deno_installed(monkeypatch):
return None return None
monkeypatch.setattr("shutil.which", fake_which) monkeypatch.setattr("shutil.which", fake_which)
monkeypatch.setattr("subprocess.run", _fake_run_ok) # yt-dlp probe really executes now
ch = YouTubeChannel() ch = YouTubeChannel()
status, _msg = ch.check() status, _msg = ch.check()
assert status == "ok" assert status == "ok"
assert ch.active_backend == "yt-dlp"
def test_channel_can_handle_contract(): def test_channel_can_handle_contract():
+300 -8
View File
@@ -670,9 +670,11 @@ class TestRedditChannel:
monkeypatch.setattr(subprocess, "run", fake_run) monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.reddit import RedditChannel from agent_reach.channels.reddit import RedditChannel
status, msg = RedditChannel().check() ch = RedditChannel()
status, msg = ch.check()
assert status == "ok" assert status == "ok"
assert "testuser" in msg assert "testuser" in msg
assert ch.active_backend == "rdt-cli"
def test_reports_warn_when_not_authenticated(self, monkeypatch): def test_reports_warn_when_not_authenticated(self, monkeypatch):
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt") monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt")
@@ -687,14 +689,18 @@ class TestRedditChannel:
monkeypatch.setattr(subprocess, "run", fake_run) monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.reddit import RedditChannel from agent_reach.channels.reddit import RedditChannel
status, msg = RedditChannel().check() ch = RedditChannel()
status, msg = ch.check()
assert status == "warn" assert status == "warn"
assert "403" in msg assert "403" in msg
assert "rdt login" in msg assert "rdt login" in msg
assert "Cookie-Editor" in msg assert "Cookie-Editor" in msg
assert "chromewebstore.google.com" 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") monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/rdt")
def fake_run(cmd, **kwargs): def fake_run(cmd, **kwargs):
@@ -702,8 +708,43 @@ class TestRedditChannel:
monkeypatch.setattr(subprocess, "run", fake_run) monkeypatch.setattr(subprocess, "run", fake_run)
from agent_reach.channels.reddit import RedditChannel from agent_reach.channels.reddit import RedditChannel
status, msg = RedditChannel().check() ch = RedditChannel()
assert status == "warn" 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 抛 FileNotFoundErrorvenv 断链)→ 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): def test_can_handle_reddit_urls(self):
from agent_reach.channels.reddit import RedditChannel from agent_reach.channels.reddit import RedditChannel
@@ -723,9 +764,11 @@ class TestXiaoHongShuChannel:
monkeypatch.setattr(subprocess, "run", fake_run) monkeypatch.setattr(subprocess, "run", fake_run)
status, msg = XiaoHongShuChannel().check() ch = XiaoHongShuChannel()
status, msg = ch.check()
assert status == "ok" assert status == "ok"
assert "完整可用" in msg assert "完整可用" in msg
assert ch.active_backend == "xhs-cli (xiaohongshu-cli)"
def test_reports_warn_when_not_authenticated(self, monkeypatch): def test_reports_warn_when_not_authenticated(self, monkeypatch):
monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs") monkeypatch.setattr(shutil, "which", lambda _: "/usr/local/bin/xhs")
@@ -735,12 +778,261 @@ class TestXiaoHongShuChannel:
monkeypatch.setattr(subprocess, "run", fake_run) monkeypatch.setattr(subprocess, "run", fake_run)
status, msg = XiaoHongShuChannel().check() ch = XiaoHongShuChannel()
status, msg = ch.check()
assert status == "warn" assert status == "warn"
assert "xhs login" in msg assert "xhs login" in msg
# 未登录是业务态:工具进程活着,后端仍可用
assert ch.active_backend == "xhs-cli (xiaohongshu-cli)"
def test_reports_off_when_not_installed(self, monkeypatch): def test_reports_off_when_not_installed(self, monkeypatch):
monkeypatch.setattr(shutil, "which", lambda _: None) monkeypatch.setattr(shutil, "which", lambda _: None)
status, msg = XiaoHongShuChannel().check() ch = XiaoHongShuChannel()
status, msg = ch.check()
assert status == "off" assert status == "off"
assert "xiaohongshu-cli" in msg assert "xiaohongshu-cli" in msg
assert ch.active_backend is None
def test_reports_error_with_reinstall_hint_when_broken(self, monkeypatch):
"""which 命中但 exec 抛 FileNotFoundErrorvenv 断链)→ 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 断链时不计为可用,降级走搜索 APIyt-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"
+8 -2
View File
@@ -8,13 +8,15 @@ from agent_reach.config import Config
class _StubChannel: 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.name = name
self.description = description self.description = description
self.tier = tier self.tier = tier
self._status = status self._status = status
self._message = message self._message = message
self.backends = backends or [] self.backends = backends or []
self.active_backend = active_backend
def check(self, config=None): def check(self, config=None):
return self._status, self._message return self._status, self._message
@@ -31,7 +33,8 @@ class TestDoctor:
doctor, doctor,
"get_all_channels", "get_all_channels",
lambda: [ lambda: [
_StubChannel("web", "网页", 0, "ok", "可抓取网页", ["requests"]), _StubChannel("web", "网页", 0, "ok", "可抓取网页", ["requests"],
active_backend="requests"),
_StubChannel("github", "GitHub", 0, "warn", "gh 未安装", ["gh"]), _StubChannel("github", "GitHub", 0, "warn", "gh 未安装", ["gh"]),
_StubChannel("exa_search", "全网语义搜索", 1, "off", "mcporter 未配置", ["Exa"]), _StubChannel("exa_search", "全网语义搜索", 1, "off", "mcporter 未配置", ["Exa"]),
], ],
@@ -46,6 +49,7 @@ class TestDoctor:
"message": "可抓取网页", "message": "可抓取网页",
"tier": 0, "tier": 0,
"backends": ["requests"], "backends": ["requests"],
"active_backend": "requests",
}, },
"github": { "github": {
"status": "warn", "status": "warn",
@@ -53,6 +57,7 @@ class TestDoctor:
"message": "gh 未安装", "message": "gh 未安装",
"tier": 0, "tier": 0,
"backends": ["gh"], "backends": ["gh"],
"active_backend": None,
}, },
"exa_search": { "exa_search": {
"status": "off", "status": "off",
@@ -60,6 +65,7 @@ class TestDoctor:
"message": "mcporter 未配置", "message": "mcporter 未配置",
"tier": 1, "tier": 1,
"backends": ["Exa"], "backends": ["Exa"],
"active_backend": None,
}, },
} }
+90
View File
@@ -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
+46
View File
@@ -26,6 +26,7 @@ def test_check_twitter_cli_found_and_auth_ok():
assert status == "ok" assert status == "ok"
assert "twitter-cli" in message assert "twitter-cli" in message
assert "完整可用" in message assert "完整可用" in message
assert channel.active_backend == "twitter-cli"
def test_check_twitter_cli_found_auth_missing(): def test_check_twitter_cli_found_auth_missing():
@@ -41,6 +42,8 @@ def test_check_twitter_cli_found_auth_missing():
status, message = channel.check() status, message = channel.check()
assert status == "warn" assert status == "warn"
assert "未认证" in message assert "未认证" in message
# 未认证是业务态:工具进程活着,后端仍可用
assert channel.active_backend == "twitter-cli"
# --- bird CLI fallback tests --- # --- bird CLI fallback tests ---
@@ -59,6 +62,7 @@ def test_check_bird_fallback_auth_ok():
status, message = channel.check() status, message = channel.check()
assert status == "ok" assert status == "ok"
assert "bird" in message assert "bird" in message
assert channel.active_backend == "bird CLI (legacy)"
def test_check_bird_fallback_auth_missing(): def test_check_bird_fallback_auth_missing():
@@ -86,6 +90,7 @@ def test_check_nothing_installed():
status, message = channel.check() status, message = channel.check()
assert status == "warn" assert status == "warn"
assert "twitter-cli" in message assert "twitter-cli" in message
assert channel.active_backend is None
# --- twitter-cli preferred over bird --- # --- twitter-cli preferred over bird ---
@@ -106,3 +111,44 @@ def test_twitter_cli_preferred_over_bird():
status, message = channel.check() status, message = channel.check()
assert status == "ok" assert status == "ok"
assert "twitter-cli" in message 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 抛 FileNotFoundErrorvenv 断链)→ 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)"