1062 lines
39 KiB
Python
1062 lines
39 KiB
Python
Total output lines: 1762
|
||
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
Agent Reach CLI — installer, doctor, and configuration tool.
|
||
|
||
Usage:
|
||
agent-reach install --env=auto
|
||
agent-reach doctor
|
||
agent-reach configure twitter-cookies "auth_token=xxx; ct0=yyy"
|
||
agent-reach setup
|
||
"""
|
||
|
||
import sys
|
||
import argparse
|
||
import json
|
||
import os
|
||
import time
|
||
|
||
from agent_reach import __version__
|
||
|
||
|
||
def _ensure_utf8_console():
|
||
"""Best-effort Windows console UTF-8 setup for CLI runtime only."""
|
||
if sys.platform != "win32":
|
||
return
|
||
# Avoid interfering with pytest/captured streams.
|
||
if os.environ.get("PYTEST_CURRENT_TEST"):
|
||
return
|
||
try:
|
||
import io
|
||
if hasattr(sys.stdout, "buffer"):
|
||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
|
||
if hasattr(sys.stderr, "buffer"):
|
||
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
|
||
except Exception:
|
||
# Do not crash CLI just because encoding patch failed.
|
||
pass
|
||
|
||
|
||
def _configure_logging(verbose: bool = False):
|
||
"""Suppress loguru output unless --verbose is set."""
|
||
from loguru import logger
|
||
logger.remove() # Remove default stderr handler
|
||
if verbose:
|
||
logger.add(sys.stderr, level="INFO")
|
||
|
||
|
||
def main():
|
||
_ensure_utf8_console()
|
||
|
||
parser = argparse.ArgumentParser(
|
||
prog="agent-reach",
|
||
description="Give your AI Agent eyes to see the entire internet",
|
||
)
|
||
parser.add_argument("-v", "--verbose", action="store_true", help="Show debug logs")
|
||
parser.add_argument("--version", action="version", version=f"Agent Reach v{__version__}")
|
||
sub = parser.add_subparsers(dest="command", help="Available commands")
|
||
|
||
# ── setup ──
|
||
sub.add_parser("setup", help="Interactive configuration wizard")
|
||
|
||
# ── install ──
|
||
p_install = sub.add_parser("install", help="One-shot installer with flags")
|
||
p_install.add_argument("--env", choices=["local", "server", "auto"], default="auto",
|
||
help="Environment: local, server, or auto-detect")
|
||
p_install.add_argument("--proxy", default="",
|
||
help="Residential proxy for Reddit/Bilibili (http://user:pass@ip:port)")
|
||
p_install.add_argument("--safe", action="store_true",
|
||
help="Safe mode: skip automatic system changes, show what's needed instead")
|
||
p_install.add_argument("--dry-run", action="store_true",
|
||
help="Show what would be done without making any changes")
|
||
p_install.add_argument("--channels", default="",
|
||
help="Comma-separated optional channels to install "
|
||
"(twitter,weibo,wechat,xiaoyuzhou,xueqiu,xiaohongshu,"
|
||
"reddit,bilibili,douyin,linkedin,all)")
|
||
|
||
# ── configure ──
|
||
p_conf = sub.add_parser("configure", help="Set a config value or auto-extract from browser")
|
||
p_conf.add_argument("key", nargs="?", default=None,
|
||
choices=["proxy", "github-token", "groq-key",
|
||
"twitter-cookies", "youtube-cookies",
|
||
"xhs-cookies"],
|
||
help="What to configure (omit if using --from-browser)")
|
||
p_conf.add_argument("value", nargs="*", help="The value(s) to set")
|
||
p_conf.add_argument("--from-browser", metavar="BROWSER",
|
||
choices=["chrome", "firefox", "edge", "brave", "opera"],
|
||
help="Auto-extract ALL platform cookies from browser (chrome/firefox/edge/brave/opera)")
|
||
|
||
# ── doctor ──
|
||
sub.add_parser("doctor", help="Check platform availability")
|
||
|
||
# ── uninstall ──
|
||
p_uninstall = sub.add_parser("uninstall", help="Remove all Agent Reach config, tokens, and skill files")
|
||
p_uninstall.add_argument("--dry-run", action="store_true",
|
||
help="Show what would be removed without making any changes")
|
||
p_uninstall.add_argument("--keep-config", action="store_true",
|
||
help="Remove skill files only, keep ~/.agent-reach/ config and tokens")
|
||
|
||
# ── skill ──
|
||
p_skill = sub.add_parser("skill", help="Manage agent skill registration")
|
||
p_skill_group = p_skill.add_mutually_exclusive_group(required=True)
|
||
p_skill_group.add_argument("--install", action="store_true",
|
||
help="Install SKILL.md to agent skill directories")
|
||
p_skill_group.add_argument("--uninstall", action="store_true",
|
||
help="Remove SKILL.md from agent skill directories")
|
||
|
||
# ── format ──
|
||
p_format = sub.add_parser("format", help="Clean and format platform API output")
|
||
p_format.add_argument("platform", choices=["xhs"], help="Platform to format (xhs)")
|
||
|
||
# ── check-update ──
|
||
sub.add_parser("check-update", help="Check for new versions and changes")
|
||
|
||
# ── watch ──
|
||
sub.add_parser("watch", help="Quick health check + update check (for scheduled tasks)")
|
||
|
||
# ── version ──
|
||
sub.add_parser("version", help="Show version")
|
||
|
||
args = parser.parse_args()
|
||
|
||
# Suppress loguru noise unless --verbose
|
||
_configure_logging(getattr(args, "verbose", False))
|
||
|
||
if not args.command:
|
||
parser.print_help()
|
||
sys.exit(0)
|
||
|
||
if args.command == "version":
|
||
print(f"Agent Reach v{__version__}")
|
||
sys.exit(0)
|
||
|
||
if args.command == "doctor":
|
||
_cmd_doctor()
|
||
elif args.command == "check-update":
|
||
_cmd_check_update()
|
||
elif args.command == "watch":
|
||
_cmd_watch()
|
||
elif args.command == "setup":
|
||
_cmd_setup()
|
||
elif args.command == "install":
|
||
_cmd_install(args)
|
||
elif args.command == "configure":
|
||
_cmd_configure(args)
|
||
elif args.command == "uninstall":
|
||
_cmd_uninstall(args)
|
||
elif args.command == "skill":
|
||
_cmd_skill(args)
|
||
elif args.command == "format":
|
||
_cmd_format(args)
|
||
|
||
|
||
# ── Command handlers ────────────────────────────────
|
||
|
||
|
||
def _cmd_install(args):
|
||
"""One-shot deterministic installer."""
|
||
import os
|
||
from agent_reach.config import Config
|
||
from agent_reach.doctor import check_all, format_report
|
||
|
||
safe_mode = args.safe
|
||
dry_run = args.dry_run
|
||
|
||
config = Config()
|
||
print()
|
||
print("Agent Reach Installer")
|
||
print("=" * 40)
|
||
|
||
# Ensure tools directory exists (for upstream tool repos)
|
||
tools_dir = os.path.expanduser("~/.agent-reach/tools")
|
||
os.makedirs(tools_dir, exist_ok=True)
|
||
|
||
if dry_run:
|
||
print("DRY RUN — showing what would be done (no changes)")
|
||
print()
|
||
if safe_mode:
|
||
print("SAFE MODE — skipping automatic system changes")
|
||
print()
|
||
|
||
# ── Parse --channels ──
|
||
CHANNEL_INSTALLERS = {
|
||
"twitter": _install_twitter_deps,
|
||
"weibo": _install_weibo_deps,
|
||
"wechat": _install_wechat_deps,
|
||
"xiaoyuzhou": _install_xiaoyuzhou_deps,
|
||
"xiaohongshu": _install_xhs_deps,
|
||
"reddit": _install_reddit_deps,
|
||
"bilibili": _install_bili_deps,
|
||
# xueqiu: cookie-only, no install step
|
||
# douyin/linkedin: manual setup, no auto-install
|
||
}
|
||
COOKIE_CHANNELS = {"twitter", "xueqiu", "bilibili"}
|
||
|
||
requested_channels = set()
|
||
if args.channels:
|
||
raw = [c.strip().lower() for c in args.channels.split(",") if c.strip()]
|
||
if "all" in raw:
|
||
requested_channels = set(CHANNEL_INSTALLERS.keys()) | {"xueqiu", "douyin", "linkedin"}
|
||
else:
|
||
requested_channels = set(raw)
|
||
|
||
# Auto-detect environment
|
||
env = args.env
|
||
if env == "auto":
|
||
env = _detect_environment()
|
||
|
||
if env == "server":
|
||
print(f"Environment: Server/VPS (auto-detected)")
|
||
else:
|
||
print(f"Environment: Local computer (auto-detected)")
|
||
|
||
# Apply explicit flags
|
||
if args.proxy:
|
||
if dry_run:
|
||
print(f"[dry-run] Would configure proxy for Bilibili")
|
||
else:
|
||
config.set("bilibili_proxy", args.proxy)
|
||
print(f"✅ Proxy configured for Bilibili")
|
||
|
||
# ── Install core system dependencies (lightweight, always) ──
|
||
print()
|
||
if dry_run:
|
||
_install_system_deps_dryrun()
|
||
elif safe_mode:
|
||
_install_system_deps_safe()
|
||
else:
|
||
_install_system_deps()
|
||
|
||
# ── mcporter (for Exa search) ──
|
||
print()
|
||
if dry_run:
|
||
print("[dry-run] Would install mcporter and configure Exa search")
|
||
elif safe_mode:
|
||
_install_mcporter_safe()
|
||
else:
|
||
_install_mcporter()
|
||
|
||
# ── Install optional channels (only if --channels specified) ──
|
||
if requested_channels and not dry_run and not safe_mode:
|
||
print()
|
||
print("Installing optional channels...")
|
||
for ch_name in sorted(requested_channels):
|
||
installer = CHANNEL_INSTALLERS.get(ch_name)
|
||
if installer:
|
||
installer()
|
||
|
||
if requested_channels and dry_run:
|
||
print()
|
||
print(f"[dry-run] Would install optional channels: {', '.join(sorted(requested_channels))}")
|
||
|
||
# ── Auto-import cookies (only if cookie-needing channels are requested) ──
|
||
needs_cookies = bool(requested_channels & COOKIE_CHANNELS)
|
||
if env == "local" and needs_cookies and not safe_mode and not dry_run:
|
||
print()
|
||
print("Importing cookies from browser...")
|
||
print(" (macOS may ask for your login password to access the Keychain — this is normal,")
|
||
print(" it only happens once during install. Enter your password or click 'Allow'.)")
|
||
try:
|
||
from agent_reach.cookie_extract import configure_from_browser
|
||
results = configure_from_browser("chrome", config)
|
||
found = False
|
||
for platform, success, message in results:
|
||
if success:
|
||
print(f" ✅ {platform}: {message}")
|
||
found = True
|
||
if not found:
|
||
results = configure_from_browser("firefox", config)
|
||
for platform, success, message in results:
|
||
if success:
|
||
print(f" ✅ {platform}: {message}")
|
||
found = True
|
||
if not found:
|
||
print(" -- No cookies found (normal if you haven't logged into these sites)")
|
||
except Exception:
|
||
print(" -- Could not read browser cookies (browser might be open or password was denied)")
|
||
elif env == "local" and needs_cookies and dry_run:
|
||
print()
|
||
print("[dry-run] Would try to import cookies from Chrome/Firefox")
|
||
|
||
# Environment-specific advice
|
||
if env == "server":
|
||
print()
|
||
print("Tip: Bilibili may block server IPs.")
|
||
print(" Reddit: rdt-cli works without proxy (pipx install rdt-cli).")
|
||
print(" For Bilibili full access: agent-reach configure proxy http://user:pass@ip:port")
|
||
print(" Cheap option: https://www.webshare.io ($1/month)")
|
||
|
||
# Test channels
|
||
if not dry_run:
|
||
print()
|
||
print("Testing channels...")
|
||
results = check_all(config)
|
||
ok = sum(1 for r in results.values() if r["status"] == "ok")
|
||
total = len(results)
|
||
|
||
# Final status
|
||
print()
|
||
print(format_report(results))
|
||
print()
|
||
|
||
# ── Install agent skill ──
|
||
_install_skill()
|
||
|
||
print(f"✅ Installation complete! {ok}/{total} channels active.")
|
||
|
||
if not requested_channels:
|
||
# First install — hint about optional channels
|
||
print()
|
||
print("More channels available! Use --channels to install:")
|
||
print(" agent-reach install --channels=twitter,weibo,xiaohongshu,...")
|
||
print(" agent-reach install --channels=all (install everything)")
|
||
|
||
# Star reminder
|
||
print()
|
||
print("如果 Agent Reach 帮到了你,给个 Star 让更多人发现它吧:")
|
||
print(" https://github.com/Panniantong/Agent-Reach")
|
||
print(" 只需一秒,对独立开发者意义很大。谢谢!")
|
||
else:
|
||
print()
|
||
print("Dry run complete. No changes were made.")
|
||
|
||
|
||
def _install_skill():
|
||
"""Install Agent Reach as an agent skill (OpenClaw / Claude Code / .agents)."""
|
||
import os
|
||
import shutil
|
||
import importlib.resources
|
||
|
||
def _is_english_locale(value: str) -> bool:
|
||
normalized = value.strip().lower()
|
||
return normalized.startswith("en") or normalized.startswith("english")
|
||
|
||
def _skill_resource_name() -> str:
|
||
locale_candidates = (
|
||
os.environ.get("AGENT_REACH_LANG", ""),
|
||
os.environ.get("LC_ALL", ""),
|
||
os.environ.get("LC_MESSAGES", ""),
|
||
os.environ.get("LANG", ""),
|
||
)
|
||
if any(_is_english_locale(candidate) for candidate in locale_candidates):
|
||
return "SKILL_en.md"
|
||
return "SKILL.md"
|
||
|
||
def _read_skill_markdown(skill_pkg):
|
||
resource_name = _skill_resource_name()
|
||
try:
|
||
return skill_pkg.joinpath(resource_name).read_text(encoding="utf-8")
|
||
except FileNotFoundError:
|
||
return skill_pkg.joinpath("SKILL.md").read_text(encoding="utf-8")
|
||
|
||
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):
|
||
shutil.rmtree(target)
|
||
os.makedirs(target, exist_ok=True)
|
||
|
||
# Get skill directory from package (with fallback for editable installs)
|
||
try:
|
||
skill_pkg = importlib.resources.files("agent_reach").joinpath("skill")
|
||
skill_md = _read_skill_markdown(skill_pkg)
|
||
except Exception:
|
||
from pathlib import Path
|
||
skill_pkg = Path(__file__).resolve().parent / "skill"
|
||
skill_md = _read_skill_markdown(skill_pkg)
|
||
|
||
# Copy SKILL.md using the selected locale file
|
||
with open(os.path.join(target, "SKILL.md"), "w", encoding="utf-8") as f:
|
||
f.write(skill_md)
|
||
|
||
# Copy references/ directory
|
||
refs_pkg = skill_pkg.joinpath("references")
|
||
refs_target = os.path.join(target, "references")
|
||
os.makedirs(refs_target, exist_ok=True)
|
||
|
||
for ref_file in refs_pkg.iterdir():
|
||
name = ref_file.name if hasattr(ref_file, 'name') else str(ref_file).split('/')[-1]
|
||
if name.endswith(".md"):
|
||
content = ref_file.read_text(encoding="utf-8") if hasattr(ref_file, 'read_text') else ref_file.read_text()
|
||
with open(os.path.join(refs_target, name), "w", encoding="utf-8") as f:
|
||
f.write(content)
|
||
|
||
return True
|
||
except Exception as e:
|
||
print(f" Warning: Could not install skill: {e}")
|
||
return False
|
||
|
||
# Determine skill install path (priority: .agents > openclaw > claude)
|
||
skill_dirs = [
|
||
os.path.expanduser("~/.agents/skills"), # Generic agents (priority)
|
||
os.path.expanduser("~/.openclaw/skills"), # OpenClaw
|
||
os.path.expanduser("~/.claude/skills"), # Claude Code (if exists)
|
||
]
|
||
|
||
# Insert OPENCLAW_HOME path at the beginning if environment variable is set
|
||
openclaw_home = os.environ.get("OPENCLAW_HOME")
|
||
if openclaw_home:
|
||
skill_dirs.insert(0, os.path.join(openclaw_home, ".openclaw", "skills"))
|
||
|
||
installed = False
|
||
for skill_dir in skill_dirs:
|
||
if os.path.isdir(skill_dir):
|
||
target = os.path.join(skill_dir, "agent-reach")
|
||
if _copy_skill_dir(target):
|
||
platform_name = "Agent" if ".agents" in skill_dir else "OpenClaw" if "openclaw" in skill_dir else "Claude Code"
|
||
print(f"Skill installed for {platform_name}: {target}")
|
||
installed = True
|
||
|
||
if not installed:
|
||
# No known skill directory found — create for .agents by default
|
||
target = os.path.expanduser("~/.agents/skills/agent-reach")
|
||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||
if _copy_skill_dir(target):
|
||
print(f"Skill installed: {target}")
|
||
else:
|
||
print(" -- Could not install agent skill (optional)")
|
||
print(" -- Tip: install OpenClaw, Claude Code, or create ~/.agents/skills/ manually")
|
||
|
||
|
||
def _uninstall_skill():
|
||
"""Remove SKILL.md from all known agent skill directories."""
|
||
import shutil
|
||
|
||
skill_dirs = [
|
||
("~/.openclaw/skills/agent-reach", "OpenClaw"),
|
||
("~/.claude/skills/agent-reach", "Claude Code"),
|
||
("~/.agents/skills/agent-reach", "Agent"),
|
||
]
|
||
|
||
# Also check OPENCLAW_HOME
|
||
openclaw_home = os.environ.get("OPENCLAW_HOME")
|
||
if openclaw_home:
|
||
skill_dirs.insert(
|
||
0,
|
||
(os.path.join(openclaw_home, ".openclaw", "skills", "agent-reach"), "OpenClaw"),
|
||
)
|
||
|
||
removed = False
|
||
for skill_path_template, platform_name in skill_dirs:
|
||
skill_path = os.path.expanduser(skill_path_template)
|
||
if os.path.isdir(skill_path):
|
||
try:
|
||
shutil.rmtree(skill_path)
|
||
print(f" Removed {platform_name} skill: {skill_path}")
|
||
removed = True
|
||
except Exception as e:
|
||
print(f" Could not remove {skill_path}: {e}")
|
||
|
||
if not removed:
|
||
print(" No skill installations found.")
|
||
|
||
|
||
def _cmd_skill(args):
|
||
"""Manage agent skill registration."""
|
||
if args.install:
|
||
_install_skill()
|
||
elif args.uninstall:
|
||
_uninstall_skill()
|
||
|
||
|
||
def _cmd_format(args):
|
||
"""Clean and format platform API output from stdin."""
|
||
import json
|
||
import sys
|
||
|
||
if args.platform == "xhs":
|
||
from agent_reach.channels.xiaohongshu import format_xhs_result
|
||
|
||
raw = sys.stdin.read().strip()
|
||
if not raw:
|
||
print("Error: no input on stdin", file=sys.stderr)
|
||
sys.exit(1)
|
||
try:
|
||
data = json.loads(raw)
|
||
except json.JSONDecodeError as e:
|
||
print(f"Error: invalid JSON: {e}", file=sys.stderr)
|
||
sys.exit(1)
|
||
|
||
cleaned = format_xhs_result(data)
|
||
print(json.dumps(cleaned, ensure_ascii=False, indent=2))
|
||
|
||
|
||
def _install_system_deps():
|
||
"""Install system-level dependencies: gh CLI, Node.js (for mcporter)."""
|
||
import shutil
|
||
import subprocess
|
||
import platform
|
||
import tempfile
|
||
|
||
print("Checking system dependencies...")
|
||
|
||
# ── gh CLI ──
|
||
if shutil.which("gh"):
|
||
print(" ✅ gh CLI already installed")
|
||
else:
|
||
print(" Installing gh CLI...")
|
||
os_type = platform.system().lower()
|
||
if os_type == "linux":
|
||
try:
|
||
# Official GitHub apt source setup without invoking a shell.
|
||
keyring_path = "/usr/share/keyrings/githubcli-archive-keyring.gpg"
|
||
list_path = "/etc/apt/sources.list.d/github-cli.list"
|
||
arch = subprocess.run(
|
||
["dpkg", "--print-architecture"],
|
||
capture_output=True, encoding="utf-8", errors="replace", timeout=10,
|
||
).stdout.strip() or "amd64"
|
||
subprocess.run(
|
||
["curl", "-fsSL", "https://cli.github.com/packages/githubcli-archive-keyring.gpg", "-o", keyring_path],
|
||
capture_output=True, timeout=60,
|
||
)
|
||
repo_line = (
|
||
f"deb [arch={arch} signed-by={keyring_path}] "
|
||
"https://cli.github.com/packages stable main\n"
|
||
)
|
||
with open(list_path, "w", encoding="utf-8") as f:
|
||
f.write(repo_line)
|
||
subprocess.run(["apt-get", "update", "-qq"], capture_output=True, timeout=60)
|
||
subproc…6779 tokens truncated… JSON array: \'[{"name":"x","value":"y","domain":".xiaohongshu.com",...}]\'')
|
||
print(' 2. Header String: "key1=val1; key2=val2; ..."')
|
||
return
|
||
|
||
# Primary path: configure xhs-cli directly.
|
||
xhs_cookie_map = {
|
||
str(c.get("name", "")).strip(): str(c.get("value", ""))
|
||
for c in cookies
|
||
if isinstance(c, dict) and str(c.get("name", "")).strip()
|
||
}
|
||
if xhs_cookie_map.get("a1"):
|
||
xhs_config_dir = os.path.expanduser("~/.xiaohongshu-cli")
|
||
os.makedirs(xhs_config_dir, exist_ok=True)
|
||
xhs_cookie_path = os.path.join(xhs_config_dir, "cookies.json")
|
||
with open(xhs_cookie_path, "w", encoding="utf-8") as f:
|
||
json.dump({**xhs_cookie_map, "saved_at": time.time()}, f, indent=2)
|
||
os.chmod(xhs_cookie_path, 0o600)
|
||
print(f"✅ xhs-cli cookies saved to {xhs_cookie_path}")
|
||
print(" Run `xhs status` or `agent-reach doctor` to verify.")
|
||
else:
|
||
print("[!] Cookie input does not include the required `a1` cookie.")
|
||
print(" xhs-cli will not treat this as a logged-in session. Export all xiaohongshu.com cookies from Cookie-Editor.")
|
||
|
||
# Keep a legacy Cookie-Editor array for users still running xiaohongshu-mcp.
|
||
legacy_cookie_path = os.path.expanduser("~/.agent-reach/xhs-cookies.json")
|
||
os.makedirs(os.path.dirname(legacy_cookie_path), exist_ok=True)
|
||
with open(legacy_cookie_path, "w") as f:
|
||
f.write(cookies_json)
|
||
os.chmod(legacy_cookie_path, 0o600)
|
||
print(f" Legacy MCP cookie array saved to {legacy_cookie_path}")
|
||
|
||
# Find the container
|
||
docker = shutil.which("docker")
|
||
if not docker:
|
||
print(" Docker not found; skipping xiaohongshu-mcp import.")
|
||
return
|
||
|
||
# Check if xiaohongshu-mcp container is running
|
||
try:
|
||
result = subprocess.run(
|
||
[docker, "ps", "--filter", "name=xiaohongshu-mcp", "--format", "{{.Names}}"],
|
||
capture_output=True, encoding="utf-8", timeout=5,
|
||
)
|
||
container_name = result.stdout.strip()
|
||
if not container_name:
|
||
print(" xiaohongshu-mcp container is not running; skipping legacy Docker import.")
|
||
return
|
||
except Exception as e:
|
||
print(f"[X] Could not check Docker: {e}")
|
||
return
|
||
|
||
# Find the cookies path inside the container
|
||
try:
|
||
result = subprocess.run(
|
||
[docker, "exec", container_name, "printenv", "COOKIES_PATH"],
|
||
capture_output=True, encoding="utf-8", timeout=5,
|
||
)
|
||
cookie_path_in_container = result.stdout.strip()
|
||
if not cookie_path_in_container:
|
||
cookie_path_in_container = "/app/cookies.json" # fallback: absolute path in workdir
|
||
except Exception:
|
||
cookie_path_in_container = "/app/cookies.json"
|
||
|
||
# Write cookies into the container
|
||
try:
|
||
# Write to temp file then docker cp
|
||
import tempfile
|
||
with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f:
|
||
f.write(cookies_json)
|
||
tmp_path = f.name
|
||
|
||
result = subprocess.run(
|
||
[docker, "cp", tmp_path, f"{container_name}:{cookie_path_in_container}"],
|
||
capture_output=True, encoding="utf-8", timeout=10,
|
||
)
|
||
os.unlink(tmp_path)
|
||
|
||
if result.returncode != 0:
|
||
print(f"[X] Failed to copy cookies: {result.stderr}")
|
||
return
|
||
|
||
print(f"✅ Cookies written to {container_name}:{cookie_path_in_container}")
|
||
# Restart container so it reloads cookies from disk
|
||
print(" Restarting container to reload cookies...", end=" ", flush=True)
|
||
try:
|
||
subprocess.run(
|
||
[docker, "restart", container_name],
|
||
capture_output=True, encoding="utf-8", timeout=30,
|
||
)
|
||
print("done")
|
||
except Exception as e:
|
||
print(f"\n [!] Could not restart container: {e}")
|
||
print(f" Restart manually: docker restart {container_name}")
|
||
except Exception as e:
|
||
print(f"[X] Failed to write cookies: {e}")
|
||
return
|
||
|
||
# Verify login status via mcporter
|
||
mcporter = shutil.which("mcporter")
|
||
if mcporter:
|
||
print(" Verifying login status...", end=" ")
|
||
try:
|
||
result = subprocess.run(
|
||
[mcporter, "call", "xiaohongshu.check_login_status()"],
|
||
capture_output=True, encoding="utf-8", errors="replace", timeout=15,
|
||
)
|
||
if "已登录" in result.stdout or "logged" in result.stdout.lower():
|
||
print("✅ Login verified!")
|
||
else:
|
||
print("[!] Login check returned unexpected result:")
|
||
print(f" {result.stdout.strip()[:200]}")
|
||
print(" Cookies were written but login might not be valid. Try fresh cookies.")
|
||
except Exception as e:
|
||
print(f"[!] Could not verify: {e}")
|
||
else:
|
||
print(" (mcporter not found, skipping verification)")
|
||
|
||
|
||
def _cmd_uninstall(args):
|
||
"""Remove all Agent Reach config, tokens, and skill files."""
|
||
import shutil
|
||
import subprocess
|
||
|
||
dry_run = args.dry_run
|
||
keep_config = args.keep_config
|
||
|
||
print()
|
||
print("Agent Reach Uninstaller")
|
||
print("=" * 40)
|
||
|
||
if dry_run:
|
||
print("DRY RUN — showing what would be removed (no changes)")
|
||
print()
|
||
|
||
removed_any = False
|
||
|
||
# ── 1. Config directory (~/.agent-reach/) ──
|
||
config_dir = os.path.expanduser("~/.agent-reach")
|
||
if not keep_config:
|
||
if os.path.isdir(config_dir):
|
||
if dry_run:
|
||
print(f"[dry-run] Would remove config directory: {config_dir}")
|
||
print(" (contains config.yaml with all tokens/cookies/API keys)")
|
||
else:
|
||
try:
|
||
shutil.rmtree(config_dir)
|
||
print(f" Removed config directory: {config_dir}")
|
||
removed_any = True
|
||
except Exception as e:
|
||
print(f" Could not remove {config_dir}: {e}")
|
||
else:
|
||
print(f" Config directory not found (already clean): {config_dir}")
|
||
else:
|
||
print(f" Skipping config directory (--keep-config): {config_dir}")
|
||
|
||
# ── 2. Skill files ──
|
||
skill_dirs = [
|
||
("~/.openclaw/skills/agent-reach", "OpenClaw"),
|
||
("~/.claude/skills/agent-reach", "Claude Code"),
|
||
("~/.agents/skills/agent-reach", "Agent"),
|
||
]
|
||
|
||
for skill_path_template, platform_name in skill_dirs:
|
||
skill_path = os.path.expanduser(skill_path_template)
|
||
if os.path.isdir(skill_path):
|
||
if dry_run:
|
||
print(f"[dry-run] Would remove {platform_name} skill: {skill_path}")
|
||
else:
|
||
try:
|
||
shutil.rmtree(skill_path)
|
||
print(f" Removed {platform_name} skill: {skill_path}")
|
||
removed_any = True
|
||
except Exception as e:
|
||
print(f" Could not remove {skill_path}: {e}")
|
||
|
||
# ── 3. mcporter MCP entries ──
|
||
if shutil.which("mcporter"):
|
||
for mcp_name in ("exa", "xiaohongshu"):
|
||
try:
|
||
r = subprocess.run(
|
||
["mcporter", "list"], capture_output=True, encoding="utf-8", errors="replace", timeout=10
|
||
)
|
||
if mcp_name in r.stdout:
|
||
if dry_run:
|
||
print(f"[dry-run] Would remove mcporter entry: {mcp_name}")
|
||
else:
|
||
subprocess.run(
|
||
["mcporter", "config", "remove", mcp_name],
|
||
capture_output=True, encoding="utf-8", errors="replace", timeout=10,
|
||
)
|
||
print(f" Removed mcporter entry: {mcp_name}")
|
||
removed_any = True
|
||
except Exception:
|
||
pass
|
||
|
||
# ── 4. Summary and optional steps ──
|
||
print()
|
||
if dry_run:
|
||
print("Dry run complete. No changes were made.")
|
||
print("Run without --dry-run to actually remove the above.")
|
||
else:
|
||
if removed_any:
|
||
print("Agent Reach data removed.")
|
||
else:
|
||
print("Nothing to remove — already clean.")
|
||
|
||
print()
|
||
print("Optional: remove the Agent Reach Python package itself:")
|
||
print(" pip uninstall agent-reach")
|
||
print()
|
||
print("Optional: remove tools installed by Agent Reach:")
|
||
print(" npm uninstall -g mcporter")
|
||
print(" pipx uninstall twitter-cli")
|
||
print(" npm uninstall -g undici")
|
||
|
||
|
||
def _cmd_doctor():
|
||
from agent_reach.config import Config
|
||
from agent_reach.doctor import check_all, format_report
|
||
try:
|
||
from rich import print as rprint
|
||
except ImportError:
|
||
rprint = print
|
||
config = Config()
|
||
results = check_all(config)
|
||
rprint(format_report(results))
|
||
|
||
# Auto-install skill if not already present (fixes #154)
|
||
_install_skill()
|
||
|
||
|
||
def _cmd_setup():
|
||
from agent_reach.config import Config
|
||
|
||
config = Config()
|
||
print()
|
||
print("Agent Reach Setup")
|
||
print("=" * 40)
|
||
print()
|
||
|
||
# Step 1: Exa (via mcporter, no API key required)
|
||
import shutil
|
||
import subprocess
|
||
|
||
print("【推荐】全网搜索 — Exa(通过 mcporter)")
|
||
print(" 免费,无需 API Key")
|
||
|
||
if not shutil.which("mcporter"):
|
||
print(" 当前状态: -- mcporter 未安装")
|
||
print(" 安装:npm install -g mcporter")
|
||
print(" 然后:mcporter config add exa https://mcp.exa.ai/mcp")
|
||
print()
|
||
else:
|
||
try:
|
||
r = subprocess.run(
|
||
["mcporter", "config", "list"], capture_output=True, encoding="utf-8", errors="replace", timeout=10
|
||
)
|
||
if "exa" in r.stdout.lower():
|
||
print(" 当前状态: ✅ 已配置")
|
||
else:
|
||
print(" 当前状态: -- 未配置")
|
||
setup_now = input(" 现在自动配置 Exa 吗?[Y/n]: ").strip().lower()
|
||
if setup_now in ("", "y", "yes"):
|
||
add_r = subprocess.run(
|
||
["mcporter", "config", "add", "exa", "https://mcp.exa.ai/mcp"],
|
||
capture_output=True, encoding="utf-8", errors="replace", timeout=10,
|
||
)
|
||
if add_r.returncode == 0:
|
||
print(" ✅ Exa 已配置")
|
||
else:
|
||
print(" [!] 自动配置失败,请手动执行:")
|
||
print(" mcporter config add exa https://mcp.exa.ai/mcp")
|
||
except Exception:
|
||
print(" [!] 无法检查 Exa 配置,请手动执行:")
|
||
print(" mcporter config add exa https://mcp.exa.ai/mcp")
|
||
print()
|
||
|
||
# Step 2: GitHub token
|
||
print("【可选】GitHub Token — 提高 API 限额")
|
||
print(" 无 token: 60 次/小时 | 有 token: 5000 次/小时")
|
||
print(" 获取: https://github.com/settings/tokens (无需任何权限)")
|
||
current = config.get("github_token")
|
||
if current:
|
||
print(f" 当前状态: ✅ 已配置")
|
||
else:
|
||
key = input(" GITHUB_TOKEN (回车跳过): ").strip()
|
||
if key:
|
||
config.set("github_token", key)
|
||
print(" ✅ GitHub API 已提升至 5000 次/小时!")
|
||
else:
|
||
print(" 跳过。公开 API 也能用")
|
||
print()
|
||
|
||
# Step 3: Reddit — rdt-cli
|
||
print("【信息】Reddit — 通过 rdt-cli 搜索和阅读,无需配置")
|
||
print(" 安装:pipx install rdt-cli")
|
||
print()
|
||
|
||
# Step 4: Groq (Whisper)
|
||
print("【可选】Groq API — 视频无字幕时的语音转文字")
|
||
print(" 免费额度,注册: https://console.groq.com")
|
||
current = config.get("groq_api_key")
|
||
if current:
|
||
print(f" 当前状态: ✅ 已配置")
|
||
else:
|
||
key = input(" GROQ_API_KEY (回车跳过): ").strip()
|
||
if key:
|
||
config.set("groq_api_key", key)
|
||
print(" ✅ 语音转文字已开启!")
|
||
else:
|
||
print(" 跳过")
|
||
print()
|
||
|
||
# Summary
|
||
print("=" * 40)
|
||
print(f"✅ 配置已保存到 {config.config_path}")
|
||
print("运行 agent-reach doctor 查看完整状态")
|
||
print()
|
||
|
||
|
||
def _classify_update_error(exc):
|
||
"""Classify update-check errors for user-friendly diagnostics."""
|
||
import requests
|
||
|
||
if isinstance(exc, requests.exceptions.Timeout):
|
||
return "timeout"
|
||
if isinstance(exc, requests.exceptions.ConnectionError):
|
||
msg = str(exc).lower()
|
||
dns_markers = [
|
||
"name or service not known",
|
||
"temporary failure in name resolution",
|
||
"nodename nor servname",
|
||
"getaddrinfo failed",
|
||
"name resolution",
|
||
"dns",
|
||
]
|
||
if any(marker in msg for marker in dns_markers):
|
||
return "dns"
|
||
return "connection"
|
||
if isinstance(exc, requests.exceptions.HTTPError):
|
||
return "http"
|
||
return "unknown"
|
||
|
||
|
||
def _update_error_text(kind):
|
||
"""Map internal error kinds to user-facing text."""
|
||
mapping = {
|
||
"timeout": "网络超时",
|
||
"dns": "DNS 解析失败",
|
||
"rate_limit": "GitHub API 速率限制",
|
||
"connection": "网络连接失败",
|
||
"server_error": "GitHub 服务暂时不可用",
|
||
"http": "HTTP 请求失败",
|
||
"unknown": "未知网络错误",
|
||
}
|
||
return mapping.get(kind, "请求失败")
|
||
|
||
|
||
def _classify_github_response_error(resp):
|
||
"""Classify non-200 GitHub responses that merit special handling."""
|
||
if resp is None:
|
||
return "unknown"
|
||
if resp.status_code == 429:
|
||
return "rate_limit"
|
||
if resp.status_code == 403:
|
||
remaining = resp.headers.get("X-RateLimit-Remaining", "")
|
||
if remaining == "0":
|
||
return "rate_limit"
|
||
try:
|
||
message = resp.json().get("message", "").lower()
|
||
if "rate limit" in message:
|
||
return "rate_limit"
|
||
except Exception:
|
||
pass
|
||
if 500 <= resp.status_code < 600:
|
||
return "server_error"
|
||
return None
|
||
|
||
|
||
def _github_get_with_retry(url, timeout=10, retries=3, sleeper=time.sleep):
|
||
"""GET GitHub API with retry/backoff and basic error classification."""
|
||
import requests
|
||
|
||
for attempt in range(1, retries + 1):
|
||
try:
|
||
resp = requests.get(url, timeout=timeout)
|
||
except requests.exceptions.RequestException as exc:
|
||
if attempt >= retries:
|
||
return None, _classify_update_error(exc), attempt
|
||
sleeper(2 ** (attempt - 1))
|
||
continue
|
||
|
||
err_kind = _classify_github_response_error(resp)
|
||
if err_kind in ("rate_limit", "server_error"):
|
||
if attempt >= retries:
|
||
return None, err_kind, attempt
|
||
delay = 2 ** (attempt - 1)
|
||
retry_after = resp.headers.get("Retry-After")
|
||
if err_kind == "rate_limit" and retry_after:
|
||
try:
|
||
delay = max(delay, float(retry_after))
|
||
except Exception:
|
||
pass
|
||
sleeper(delay)
|
||
continue
|
||
|
||
return resp, None, attempt
|
||
|
||
return None, "unknown", retries
|
||
|
||
|
||
def _cmd_check_update():
|
||
"""Check for newer versions on GitHub."""
|
||
from agent_reach import __version__
|
||
|
||
print(f"当前版本: v{__version__}")
|
||
release_url = "https://api.github.com/repos/Panniantong/Agent-Reach/releases/latest"
|
||
commit_url = "https://api.github.com/repos/Panniantong/Agent-Reach/commits/main"
|
||
|
||
# Fetch latest release with retry/backoff.
|
||
resp, err, attempts = _github_get_with_retry(release_url, timeout=10, retries=3)
|
||
if err:
|
||
print(f"[!] 无法检查更新({_update_error_text(err)},已重试 {attempts} 次)")
|
||
return "error"
|
||
|
||
if resp.status_code == 200:
|
||
data = resp.json()
|
||
latest = data.get("tag_name", "").lstrip("v")
|
||
body = data.get("body", "")
|
||
|
||
if latest and latest != __version__:
|
||
print(f"最新版本: v{latest} ← 有更新!")
|
||
if body:
|
||
print()
|
||
print("更新内容:")
|
||
# Show first 20 lines of release notes
|
||
for line in body.strip().split("\n")[:20]:
|
||
print(f" {line}")
|
||
print()
|
||
print("更新命令:")
|
||
print(" pip install --upgrade https://github.com/Panniantong/agent-reach/archive/main.zip")
|
||
return "update_available"
|
||
print(f"✅ 已是最新版本")
|
||
return "up_to_date"
|
||
|
||
release_err = _classify_github_response_error(resp)
|
||
if release_err == "rate_limit":
|
||
print("[!] 无法检查更新(GitHub API 速率限制,请稍后重试)")
|
||
return "error"
|
||
|
||
# No releases yet, fall back to latest main commit.
|
||
resp2, err2, attempts2 = _github_get_with_retry(commit_url, timeout=10, retries=2)
|
||
if err2:
|
||
print(f"[!] 无法检查更新({_update_error_text(err2)},已重试 {attempts + attempts2} 次)")
|
||
return "error"
|
||
if resp2.status_code == 200:
|
||
commit = resp2.json()
|
||
sha = commit.get("sha", "")[:7]
|
||
msg = commit.get("commit", {}).get("message", "").split("\n")[0]
|
||
date = commit.get("commit", {}).get("committer", {}).get("date", "")[:10]
|
||
print(f"最新提交: {sha} ({date}) {msg}")
|
||
print()
|
||
print("更新命令:")
|
||
print(" pip install --upgrade https://github.com/Panniantong/agent-reach/archive/main.zip")
|
||
return "unknown"
|
||
|
||
commit_err = _classify_github_response_error(resp2)
|
||
if commit_err == "rate_limit":
|
||
print("[!] 无法检查更新(GitHub API 速率限制,请稍后重试)")
|
||
return "error"
|
||
|
||
print(f"[!] 无法检查更新(GitHub 返回 {resp2.status_code})")
|
||
return "error"
|
||
|
||
|
||
def _cmd_watch():
|
||
"""Quick health check + update check, designed for scheduled tasks.
|
||
|
||
Only outputs problems. If everything is fine, outputs a single line.
|
||
"""
|
||
from agent_reach.config import Config
|
||
from agent_reach.doctor import check_all
|
||
from agent_reach import __version__
|
||
|
||
config = Config()
|
||
issues = []
|
||
|
||
# Check channels
|
||
results = check_all(config)
|
||
ok = sum(1 for r in results.values() if r["status"] == "ok")
|
||
total = len(results)
|
||
|
||
# Find broken channels (were working, now broken)
|
||
for key, r in results.items():
|
||
if r["status"] in ("off", "error"):
|
||
issues.append(f"[X] {r['name']}:{r['message']}")
|
||
elif r["status"] == "warn":
|
||
issues.append(f"[!] {r['name']}:{r['message']}")
|
||
|
||
# Check for updates
|
||
update_available = False
|
||
new_version = ""
|
||
release_body = ""
|
||
resp, err, _attempts = _github_get_with_retry(
|
||
"https://api.github.com/repos/Panniantong/Agent-Reach/releases/latest",
|
||
timeout=10,
|
||
retries=2,
|
||
)
|
||
if not err and resp and resp.status_code == 200:
|
||
data = resp.json()
|
||
latest = data.get("tag_name", "").lstrip("v")
|
||
if latest and latest != __version__:
|
||
update_available = True
|
||
new_version = latest
|
||
release_body = data.get("body", "")
|
||
|
||
# Output
|
||
if not issues and not update_available:
|
||
print(f"Agent Reach: 全部正常 ({ok}/{total} 渠道可用,v{__version__} 已是最新)")
|
||
return
|
||
|
||
print(f"Agent Reach 监控报告")
|
||
print(f"=" * 40)
|
||
print(f"版本: v{__version__} | 渠道: {ok}/{total}")
|
||
|
||
if issues:
|
||
print()
|
||
for issue in issues:
|
||
print(f" {issue}")
|
||
|
||
if update_available:
|
||
print()
|
||
print(f"新版本可用: v{new_version}")
|
||
if release_body:
|
||
for line in release_body.strip().split("\n")[:10]:
|
||
print(f" {line}")
|
||
print(f" 更新: pip install --upgrade https://github.com/Panniantong/agent-reach/archive/main.zip")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|