Files
Agent-Reach/agent_reach/doctor.py
T
Pnant 762824c590 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>
2026-06-11 15:48:33 +08:00

125 lines
4.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""Environment health checker — powered by channels.
Each channel knows how to check itself. Doctor just collects the results.
"""
from typing import Dict
from agent_reach.config import Config
from agent_reach.channels import get_all_channels
def check_all(config: Config) -> Dict[str, 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():
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:
from rich.markup import escape
except ImportError:
escape = lambda x: x
lines = []
lines.append("[bold cyan]Agent Reach 状态[/bold cyan]")
lines.append("[cyan]" + "=" * 40 + "[/cyan]")
lines.append("图例:[green]✅[/green] 可用 [yellow][!][/yellow] 已装但需配置/登录 [red][X][/red] 未安装")
ok_count = sum(1 for r in results.values() if r["status"] == "ok")
total = len(results)
# Tier 0 — zero config
lines.append("")
lines.append("[bold]✅ 装好即用:[/bold]")
for key, r in results.items():
if r["tier"] == 0:
name_msg = _name_msg(r, escape)
if r["status"] == "ok":
lines.append(f" [green]✅[/green] {name_msg}")
elif r["status"] == "warn":
lines.append(f" [yellow][!][/yellow] {name_msg}")
elif r["status"] in ("off", "error"):
lines.append(f" [red][X][/red] {name_msg}")
# Tier 1 — needs free key / login
tier1 = {k: r for k, r in results.items() if r["tier"] == 1}
tier1_active = {k: r for k, r in tier1.items() if r["status"] == "ok"}
tier1_inactive = {k: r for k, r in tier1.items() if r["status"] != "ok"}
if tier1_active:
lines.append("")
lines.append("[bold]可选渠道(已安装):[/bold]")
for key, r in tier1_active.items():
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}
tier2_active = {k: r for k, r in tier2.items() if r["status"] == "ok"}
tier2_inactive = {k: r for k, r in tier2.items() if r["status"] != "ok"}
if tier2_active:
if not tier1_active:
lines.append("")
lines.append("[bold]可选渠道(已安装):[/bold]")
for key, r in tier2_active.items():
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")
lines.append(f"状态:[{status_color}]{ok_count}/{total}[/{status_color}] 个渠道可用")
# Summarize inactive optional channels in one line instead of listing each
all_inactive = list(tier1_inactive.values()) + list(tier2_inactive.values())
if all_inactive:
names = [r["name"] for r in all_inactive]
lines.append(
f"还有 {len(names)} 个可选渠道可以解锁({''.join(names)}),"
"告诉你的 Agent「帮我装 XXX」即可"
)
# Security check: config file permissions (Unix only)
import os
import stat
import sys
config_path = Config.CONFIG_DIR / "config.yaml"
if config_path.exists() and sys.platform != "win32":
try:
mode = config_path.stat().st_mode
if mode & (stat.S_IRGRP | stat.S_IROTH):
lines.append("")
lines.append(
"[bold red][!] 安全提示:config.yaml 权限过宽(其他用户可读)[/bold red]"
)
lines.append(" 修复:chmod 600 ~/.agent-reach/config.yaml")
except OSError:
pass
return "\n".join(lines)