diff --git a/README.md b/README.md index a6f7bc9e..30f36b33 100644 --- a/README.md +++ b/README.md @@ -153,20 +153,20 @@ AI Agent 已经能帮你写代码、改文档、管项目——但你让它去 > 帮我更新 Agent Reach:https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md > ``` -> 🛡️ **担心安全?** 可以用安全模式——不会自动装系统包,只告诉你需要什么: +> 🛡️ **默认安全:** `agent-reach install` 默认只检查环境,不会自动装系统包或写入配置: > ``` -> 帮我安装 Agent Reach(安全模式):https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md -> 安装时使用 --safe 参数 +> 帮我安全检查并安装 Agent Reach:https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md > ``` +> 只有在你明确允许修改系统后,才使用 `agent-reach install --system`。
它会做什么?(点击展开) 1. **安装 CLI 工具** — 从本仓库安装 `agent-reach` 命令行(自带 yt-dlp、feedparser;不要从 PyPI 安装同名包,它不是本项目) -2. **安装系统基建** — 自动检测并安装 Node.js、gh CLI、mcporter -3. **配置搜索引擎** — 通过 MCP 接入 Exa(免费,无需 API Key) +2. **检查系统基建** — 检查 Node.js、gh CLI、mcporter,并给出缺失项的安装方式 +3. **按授权安装与配置** — 仅在显式传入 `--system` 时安装依赖并通过 MCP 接入 Exa 4. **检测环境** — 判断是本地电脑还是服务器,给出对应的配置建议 -5. **注册 SKILL.md** — 在 Agent 的 skills 目录安装使用指南,以后 Agent 遇到"全网调研"、"搜推特"、"看视频"这类需求,会自动知道该调哪个上游工具 +5. **按授权注册 SKILL.md** — 仅在显式 `--system` 时写入 Agent 的 skills 目录;默认检查不改文件 6. **问你要不要更多** — 默认只激活 6 个零配置渠道;小红书、Twitter、Reddit、Facebook、Instagram 这些需要登录态的,Agent 会列菜单问你要哪些,点名才装 安装完之后,`agent-reach doctor` 一条命令告诉你每个渠道的状态、当前走哪条路。 @@ -212,7 +212,7 @@ channels/ ├── facebook.py → OpenCLI(桌面浏览器登录态) ├── instagram.py → OpenCLI(桌面浏览器登录态) ├── xiaohongshu.py → OpenCLI ▸ xiaohongshu-mcp ▸ xhs-cli -├── linkedin.py → linkedin-mcp ▸ Jina Reader +├── linkedin.py → mcp-server-linkedin ▸ Jina Reader ├── rss.py → feedparser ├── exa_search.py → Exa via mcporter └── __init__.py → 渠道注册(doctor 检测用) @@ -235,7 +235,7 @@ channels/ | GitHub | [gh CLI](https://cli.github.com) | — | 官方工具,认证后完整 API 能力 | | 读 RSS | [feedparser](https://github.com/kurtmckee/feedparser) | — | Python 生态标准选择 | | 小红书 | [OpenCLI](https://github.com/jackwener/opencli)(桌面) | [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp)(服务器)▸ xhs-cli | OpenCLI 只用用户已有会话;其余后端用 Cookie-Editor 手工导出 | -| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | Jina Reader | MCP 服务,浏览器自动化 | +| LinkedIn | [mcp-server-linkedin](https://github.com/stickerdaniel/linkedin-mcp-server) | Jina Reader | MCP 服务,浏览器自动化 | > 📌 这些都是「当前选型」,基于真机实测定期复核。某条路失效了我们换下一条——`agent-reach doctor` 永远告诉你现在走的是哪条。 @@ -248,7 +248,7 @@ Agent Reach 在设计上重视安全: | 措施 | 说明 | |------|------| | 🔒 **凭据本地存储** | Cookie、Token 只存在你本机 `~/.agent-reach/config.yaml`,文件权限 600(仅所有者可读写),不上传不外传 | -| 🛡️ **安全模式** | `agent-reach install --safe` 不会自动修改系统,只列出需要什么,由你决定装不装 | +| 🛡️ **默认安全** | `agent-reach install` 默认不修改系统;只有显式 `--system` 才安装外部工具和写入配置 | | 👀 **完全开源** | 代码透明,随时可审查。所有依赖工具也是开源项目 | | 🔍 **Dry Run** | `agent-reach install --dry-run` 预览所有操作,不做任何改动 | | 🧩 **可插拔架构** | 不信任某个组件?换掉对应的 channel 文件即可,不影响其他 | @@ -265,8 +265,9 @@ Agent Reach 在设计上重视安全: | 方式 | 命令 | 适合场景 | |------|------|---------| -| 一键全自动(默认) | `agent-reach install --env=auto` | 个人电脑、开发环境 | -| 安全模式 | `agent-reach install --env=auto --safe` | 生产服务器、多人共用机器 | +| 默认安全检查 | `agent-reach install --env=auto` | 所有环境;只读检查并列出缺失项 | +| 显式安装系统依赖 | `agent-reach install --env=auto --system` | 你明确允许修改当前机器时 | +| 兼容安全参数 | `agent-reach install --env=auto --safe` | 与默认行为相同 | | 仅预览 | `agent-reach install --env=auto --dry-run` | 先看看会做什么 | ### 🗑️ 卸载 @@ -305,7 +306,7 @@ Star 一下,下次需要的时候能找到。⭐ ## 致谢 -[OpenCLI](https://github.com/jackwener/opencli) · [twitter-cli](https://github.com/public-clis/twitter-cli) · [rdt-cli](https://github.com/public-clis/rdt-cli) · [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) · [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) · [bili-cli](https://github.com/public-clis/bilibili-cli) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [Jina Reader](https://github.com/jina-ai/reader) · [Exa](https://exa.ai) · [mcporter](https://github.com/nicobailon/mcporter) · [feedparser](https://github.com/kurtmckee/feedparser) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) +[OpenCLI](https://github.com/jackwener/opencli) · [twitter-cli](https://github.com/public-clis/twitter-cli) · [rdt-cli](https://github.com/public-clis/rdt-cli) · [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) · [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) · [bili-cli](https://github.com/public-clis/bilibili-cli) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [Jina Reader](https://github.com/jina-ai/reader) · [Exa](https://exa.ai) · [mcporter](https://github.com/nicobailon/mcporter) · [feedparser](https://github.com/kurtmckee/feedparser) · [mcp-server-linkedin](https://github.com/stickerdaniel/linkedin-mcp-server) ## 联系 diff --git a/agent_reach/channels/_opencli_site.py b/agent_reach/channels/_opencli_site.py index b87ea6f2..2c7ec3da 100644 --- a/agent_reach/channels/_opencli_site.py +++ b/agent_reach/channels/_opencli_site.py @@ -32,7 +32,7 @@ def check(self, config=None): if not st.installed: return "off", ( f"未安装 {self.description} 后端。安装:\n" - " agent-reach install --channels opencli\n" + " agent-reach install --system --channels opencli\n" f"然后在 Chrome 里登录 {self.login_hint}" ) if st.broken: diff --git a/agent_reach/channels/bilibili.py b/agent_reach/channels/bilibili.py index 0d21e184..5c500585 100644 --- a/agent_reach/channels/bilibili.py +++ b/agent_reach/channels/bilibili.py @@ -76,7 +76,7 @@ def check(self, config=None): return "off", ( "没有可用的 B站后端(搜索 API 也不可达,可能是网络问题)。推荐:\n" " pipx install bilibili-cli(搜索/热门/视频详情,无需登录)\n" - " 或桌面装 OpenCLI(额外解锁字幕):agent-reach install --channels opencli" + " 或桌面装 OpenCLI(额外解锁字幕):agent-reach install --system --channels opencli" ) def _check_bili_cli(self): diff --git a/agent_reach/channels/linkedin.py b/agent_reach/channels/linkedin.py index 88184286..b3a535e2 100644 --- a/agent_reach/channels/linkedin.py +++ b/agent_reach/channels/linkedin.py @@ -1,18 +1,29 @@ # -*- coding: utf-8 -*- -"""LinkedIn — check if linkedin-scraper-mcp is available.""" +"""LinkedIn — check if mcp-server-linkedin is configured.""" import shutil from .base import Channel from .mcporter import McporterConfigError, inspect_mcporter_config -_LINKEDIN_SERVER_NAMES = {"linkedin", "linkedin-scraper", "linkedin-scraper-mcp"} +_LINKEDIN_SERVER_NAMES = { + "linkedin", + "linkedin-scraper", + "linkedin-scraper-mcp", + "mcp-server-linkedin", +} +_LOGIN_COMMAND = "uvx mcp-server-linkedin@latest --login" +_UV_INSTALL_URL = "https://docs.astral.sh/uv/getting-started/installation/" +_CONFIG_COMMAND = ( + "mcporter config add linkedin --command uvx " + "--arg mcp-server-linkedin@latest --env UV_HTTP_TIMEOUT=300 --scope home" +) class LinkedInChannel(Channel): name = "linkedin" description = "LinkedIn 职业社交" - backends = ["linkedin-scraper-mcp", "Jina Reader"] + backends = ["mcp-server-linkedin", "Jina Reader"] tier = 2 def can_handle(self, url: str) -> bool: @@ -25,9 +36,9 @@ def check(self, config=None): if not shutil.which("mcporter"): return "off", ( "基本内容可通过 Jina Reader 读取。完整功能需要:\n" - " pip install linkedin-scraper-mcp\n" - " mcporter config add linkedin http://localhost:3000/mcp " - "--scope home\n" + f" 先安装 uv/uvx:{_UV_INSTALL_URL}\n" + f" {_LOGIN_COMMAND}\n" + f" {_CONFIG_COMMAND}\n" " 详见 https://github.com/stickerdaniel/linkedin-mcp-server" ) try: @@ -35,6 +46,12 @@ def check(self, config=None): except McporterConfigError as exc: return "error", f"mcporter 配置检查失败:{exc}" if inspection.server_names & _LINKEDIN_SERVER_NAMES: + if not shutil.which("uvx"): + return "warn", ( + "LinkedIn MCP 已写入 mcporter 配置,但 uvx 未安装," + "当前无法启动服务。安装:\n" + f" {_UV_INSTALL_URL}" + ) return "warn", ( "LinkedIn MCP 已写入 mcporter 配置,但 Doctor 未启动本地" "服务做连通验证,不能仅凭配置宣称完整可用。" @@ -46,7 +63,7 @@ def check(self, config=None): ) return "off", ( "mcporter 已装但 LinkedIn MCP 未配置。运行:\n" - " pip install linkedin-scraper-mcp\n" - " mcporter config add linkedin http://localhost:3000/mcp " - "--scope home" + f" 先安装 uv/uvx:{_UV_INSTALL_URL}\n" + f" {_LOGIN_COMMAND}\n" + f" {_CONFIG_COMMAND}" ) diff --git a/agent_reach/channels/reddit.py b/agent_reach/channels/reddit.py index c214959a..83c681fb 100644 --- a/agent_reach/channels/reddit.py +++ b/agent_reach/channels/reddit.py @@ -64,7 +64,7 @@ def check(self, config=None): return "off", ( "未安装任何 Reddit 后端。注意:Reddit 没有零配置路径" "(匿名 .json 已被封,官方 API 需人工审批),必须用登录态。推荐:\n" - " 桌面:agent-reach install --channels opencli\n" + " 桌面:agent-reach install --system --channels opencli\n" " (复用 Chrome 登录态,登录过 reddit.com 即可用)\n" f" 服务器/存量:pipx install '{_RDT_GIT_SOURCE}'\n" " 然后 `rdt login` 或手动写入 Cookie(见 doctor 提示)\n" diff --git a/agent_reach/channels/twitter.py b/agent_reach/channels/twitter.py index c21e6e78..006a85b2 100644 --- a/agent_reach/channels/twitter.py +++ b/agent_reach/channels/twitter.py @@ -104,8 +104,7 @@ def _check_twitter_cli(self, config=None): return "warn", ( "twitter-cli 已安装但没有完整的显式凭据。请用 Cookie-Editor " "从 x.com 导出后运行:\n" - " agent-reach configure twitter-cookies " - "''\n" + " agent-reach configure twitter-cookies\n" "Doctor 不会自动读取浏览器 Cookie。" ) diff --git a/agent_reach/channels/v2ex.py b/agent_reach/channels/v2ex.py index b6032522..0b4c6967 100644 --- a/agent_reach/channels/v2ex.py +++ b/agent_reach/channels/v2ex.py @@ -2,22 +2,133 @@ """V2EX — public API channel for topics, nodes, users, and replies.""" import json +import shutil +import ssl +import subprocess import urllib.request from typing import Any +from urllib.parse import urlsplit +from agent_reach.utils.process import utf8_subprocess_env from agent_reach.utils.text import scrub_url_credentials from .base import Channel _UA = "agent-reach/1.0" _TIMEOUT = 10 +_MAX_RESPONSE_BYTES = 1024 * 1024 + + +def _validate_api_url(url: str) -> None: + """Allow only the public V2EX HTTPS JSON API.""" + try: + parsed = urlsplit(url) + port = parsed.port + except ValueError as exc: + raise ValueError("invalid V2EX API URL") from exc + if ( + parsed.scheme.lower() != "https" + or (parsed.hostname or "").lower() not in {"v2ex.com", "www.v2ex.com"} + or port not in {None, 443} + or parsed.username is not None + or parsed.password is not None + or not parsed.path.startswith("/api/") + ): + raise ValueError("only the V2EX HTTPS API is allowed") + + +def _get_json_with_urllib(url: str) -> Any: + """Fetch JSON with Python's standard HTTP stack.""" + _validate_api_url(url) + req = urllib.request.Request(url, headers={"User-Agent": _UA}) + with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: + raw = resp.read(_MAX_RESPONSE_BYTES + 1) + if len(raw) > _MAX_RESPONSE_BYTES: + raise ValueError("V2EX API response exceeds the 1 MiB safety limit") + return json.loads(raw.decode("utf-8")) + + +def _is_unexpected_tls_eof(error: BaseException) -> bool: + """Return whether an exception chain contains the retryable TLS EOF.""" + pending: list[BaseException] = [error] + seen: set[int] = set() + while pending: + current = pending.pop() + if id(current) in seen: + continue + seen.add(id(current)) + if isinstance(current, ssl.SSLError) and not isinstance( + current, ssl.SSLCertVerificationError + ): + text = str(current).casefold() + if ( + "unexpected_eof_while_reading" in text + or "eof occurred in violation of protocol" in text + ): + return True + for nested in ( + getattr(current, "reason", None), + current.__cause__, + current.__context__, + ): + if isinstance(nested, BaseException): + pending.append(nested) + return False + + +def _get_json_with_curl(url: str) -> Any: + """Fetch bounded JSON with the OS curl TLS stack.""" + _validate_api_url(url) + curl = shutil.which("curl") + if not curl: + raise RuntimeError("curl is unavailable for the V2EX TLS fallback") + + command = [ + curl, + "--fail", + "--silent", + "--show-error", + "--proto", + "=https", + "--connect-timeout", + "5", + "--max-time", + str(_TIMEOUT), + "--max-filesize", + str(_MAX_RESPONSE_BYTES), + "--header", + f"User-Agent: {_UA}", + "--url", + url, + ] + try: + result = subprocess.run( + command, + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=_TIMEOUT + 2, + env=utf8_subprocess_env(), + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise RuntimeError("curl could not complete the V2EX TLS fallback") from exc + if result.returncode != 0: + raise RuntimeError("curl could not complete the V2EX TLS fallback") + if len(result.stdout.encode("utf-8")) > _MAX_RESPONSE_BYTES: + raise ValueError("V2EX API response exceeds the 1 MiB safety limit") + return json.loads(result.stdout) def _get_json(url: str) -> Any: - """Fetch *url* and return parsed JSON. Raises on HTTP/network errors.""" - req = urllib.request.Request(url, headers={"User-Agent": _UA}) - with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: - return json.loads(resp.read().decode("utf-8")) + """Fetch JSON, retrying only Python's known TLS EOF via native curl.""" + try: + return _get_json_with_urllib(url) + except Exception as exc: + if isinstance(exc, ssl.SSLCertVerificationError): + raise + if not _is_unexpected_tls_eof(exc): + raise + return _get_json_with_curl(url) class V2EXChannel(Channel): diff --git a/agent_reach/channels/xiaohongshu.py b/agent_reach/channels/xiaohongshu.py index 844ce556..a72dd0bf 100644 --- a/agent_reach/channels/xiaohongshu.py +++ b/agent_reach/channels/xiaohongshu.py @@ -200,11 +200,11 @@ def check(self, config=None): return "off", ( "未安装任何小红书后端。推荐:\n" - " 桌面:agent-reach install --channels opencli\n" + " 桌面:agent-reach install --system --channels opencli\n" " (复用 Chrome 登录态,刷过小红书即零配置可用)\n" f" 服务器:xiaohongshu-mcp:{_MCP_INSTALL_URL}\n" " 登录只使用 Cookie-Editor 明确导出:\n" - " agent-reach configure xhs-cookies ''" + " agent-reach configure xhs-cookies(隐藏输入)" ) def _check_opencli(self): diff --git a/agent_reach/channels/xiaoyuzhou.py b/agent_reach/channels/xiaoyuzhou.py index 5cd50d5c..5afee0e1 100644 --- a/agent_reach/channels/xiaoyuzhou.py +++ b/agent_reach/channels/xiaoyuzhou.py @@ -42,7 +42,7 @@ def check(self, config=None): if not os.path.isfile(script): return "off", ( "转录脚本未安装。运行:\n" - " agent-reach install --env=auto\n" + " agent-reach install --env=auto --system --channels=xiaoyuzhou\n" " 或手动复制 transcribe.sh 到 ~/.agent-reach/tools/xiaoyuzhou/" ) @@ -58,7 +58,7 @@ def check(self, config=None): return "warn", ( "需要配置 Groq API Key(免费)。步骤:\n" " 1. 注册 https://console.groq.com\n" - " 2. 运行: agent-reach configure groq-key gsk_xxxxx" + " 2. 运行: agent-reach configure groq-key(隐藏输入)" ) self.active_backend = "groq-whisper" diff --git a/agent_reach/channels/youtube.py b/agent_reach/channels/youtube.py index 728a3206..2d358c29 100644 --- a/agent_reach/channels/youtube.py +++ b/agent_reach/channels/youtube.py @@ -70,7 +70,7 @@ def check(self, config=None): if not has_js: return "warn", ( "yt-dlp 已安装但缺少 JS runtime(YouTube 必须)。\n" - " 安装 Node.js 或 deno,然后运行:agent-reach install" + " 安装 Node.js 或 deno,然后运行:agent-reach install --system" ) # Check yt-dlp config for --js-runtimes # Deno works out of the box; Node.js requires explicit config @@ -114,10 +114,17 @@ def check(self, config=None): + ")" ) else: - msg += f",可转写音频({'→'.join(providers)})" + msg += f",可转写音频({'/'.join(providers)})" return "ok", msg - def transcribe(self, url: str, *, provider: str = "auto", config=None) -> str: + def transcribe( + self, + url: str, + *, + provider: str = "auto", + config=None, + allow_provider_fallback: bool = False, + ) -> str: """Download a YouTube video's audio and return its transcript. Delegates to :func:`agent_reach.transcribe.transcribe`. Imported lazily @@ -126,4 +133,9 @@ def transcribe(self, url: str, *, provider: str = "auto", config=None) -> str: """ from agent_reach.transcribe import transcribe as _transcribe - return _transcribe(url, provider=provider, config=config) + return _transcribe( + url, + provider=provider, + config=config, + allow_provider_fallback=allow_provider_fallback, + ) diff --git a/agent_reach/cli.py b/agent_reach/cli.py index 746fc923..dae3b3fe 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -5,20 +5,29 @@ Usage: agent-reach install --env=auto agent-reach doctor - agent-reach configure twitter-cookies "auth_token=xxx; ct0=yyy" + agent-reach configure twitter-cookies agent-reach setup """ -import sys import argparse import json import os +import sys import time from agent_reach import __version__ # 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" +_MAX_CONFIGURE_VALUE_CHARS = 1024 * 1024 +_SENSITIVE_CONFIG_KEYS = { + "proxy", + "github-token", + "groq-key", + "openai-key", + "twitter-cookies", + "xhs-cookies", +} def _ensure_utf8_console(): @@ -68,8 +77,17 @@ def main(): p_install.add_argument("--proxy", default="", help="Network proxy saved for agents to export as HTTP(S)_PROXY " "in restricted networks (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") + install_mode = p_install.add_mutually_exclusive_group() + install_mode.add_argument( + "--system", + action="store_true", + help="Explicitly allow system dependency, global tool, config, and skill installation", + ) + install_mode.add_argument( + "--safe", + action="store_true", + help="Safe check-only mode (default; retained for compatibility)", + ) 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="", @@ -85,6 +103,12 @@ def main(): "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( + "--stdin", + dest="read_stdin", + action="store_true", + help="Read the value from stdin instead of exposing it in process arguments", + ) p_conf.add_argument("--from-browser", metavar="BROWSER", choices=["chrome", "firefox", "edge", "brave", "opera"], help="Extract cookies for one explicitly selected platform") @@ -132,7 +156,15 @@ def main(): p_tr = sub.add_parser("transcribe", help="Transcribe a URL or local audio file (Whisper via Groq/OpenAI)") p_tr.add_argument("source", help="Audio/video URL or local file path") p_tr.add_argument("--provider", choices=["auto", "groq", "openai"], default="auto", - help="Transcription provider (default: auto = groq → openai fallback)") + help="Transcription provider (default: first configured provider)") + p_tr.add_argument( + "--allow-provider-fallback", + action="store_true", + help=( + "With --provider auto, allow sending audio to the next " + "configured provider after a failure" + ), + ) p_tr.add_argument("-o", "--output", default=None, help="Write transcript to a file instead of stdout") @@ -147,6 +179,8 @@ def main(): args = parser.parse_args() if args.command == "configure" and args.from_browser: + if args.read_stdin: + p_conf.error("--stdin cannot be combined with --from-browser") if not args.platform: p_conf.error("--platform is required with --from-browser") manual_keys = { @@ -165,11 +199,22 @@ def main(): if args.sync_legacy_twitter: p_conf.error("--sync-legacy-twitter is only valid with twitter-cookies") elif args.command == "configure": + if args.read_stdin and args.value: + p_conf.error("--stdin cannot be combined with a positional value") + if args.read_stdin and not args.key: + p_conf.error("--stdin requires a configure key") if args.profile or args.platform: p_conf.error("--platform/--profile require --from-browser") if args.sync_legacy_twitter and args.key != "twitter-cookies": p_conf.error("--sync-legacy-twitter is only valid with twitter-cookies") + if ( + args.command == "transcribe" + and args.allow_provider_fallback + and args.provider != "auto" + ): + p_tr.error("--allow-provider-fallback requires --provider auto") + # Suppress loguru noise unless --verbose _configure_logging(getattr(args, "verbose", False)) @@ -213,7 +258,7 @@ def _cmd_install(args): from agent_reach.config import Config from agent_reach.doctor import check_all, format_report - safe_mode = args.safe + safe_mode = getattr(args, "safe", False) or not getattr(args, "system", False) dry_run = args.dry_run # Validate channel names before constructing config or changing the system. @@ -277,9 +322,9 @@ def _cmd_install(args): env = _detect_environment() if env == "server": - print(f"Environment: Server/VPS (auto-detected)") + print("Environment: Server/VPS (auto-detected)") else: - print(f"Environment: Local computer (auto-detected)") + print("Environment: Local computer (auto-detected)") server_skipped_opencli_channels = set() if env == "server" and requested_channels: @@ -295,16 +340,17 @@ def _cmd_install(args): else: config.set("proxy", args.proxy) config.set("bilibili_proxy", args.proxy) # legacy key - print(f"✅ 代理已保存(Agent 访问受限网络时使用)") + print("✅ 代理已保存(Agent 访问受限网络时使用)") # ── Install core system dependencies (lightweight, always) ── print() + core_install_ok = True if dry_run: _install_system_deps_dryrun() elif safe_mode: _install_system_deps_safe() else: - _install_system_deps() + core_install_ok = _install_system_deps() is not False # ── mcporter (for Exa search) ── print() @@ -313,7 +359,7 @@ def _cmd_install(args): elif safe_mode: _install_mcporter_safe() else: - _install_mcporter() + core_install_ok = (_install_mcporter() is not False) and core_install_ok if server_skipped_opencli_channels: print() @@ -325,11 +371,16 @@ def _cmd_install(args): print() print("Installing optional channels...") ran_installers = set() + optional_install_ok = True for ch_name in sorted(requested_channels): installer = CHANNEL_INSTALLERS.get(ch_name) if installer and installer not in ran_installers: - installer() + optional_install_ok = ( + installer() is not False + ) and optional_install_ok ran_installers.add(installer) + else: + optional_install_ok = True if requested_channels and dry_run: print() @@ -343,15 +394,9 @@ def _cmd_install(args): print("Run only the platform command you intend to authorize:") for channel in sorted(requested_channels & COOKIE_CHANNELS): if channel == "twitter": - print( - " agent-reach configure twitter-cookies " - "''" - ) + print(" agent-reach configure twitter-cookies") elif channel == "xiaohongshu": - print( - " agent-reach configure xhs-cookies " - "''" - ) + print(" agent-reach configure xhs-cookies") else: print( " agent-reach configure --from-browser chrome " @@ -366,7 +411,7 @@ def _cmd_install(args): print() print("Tip: 部分平台对服务器 IP 有风控。") print(" Reddit 必须登录态(rdt-cli + Cookie,见 doctor 提示),中国大陆网络还需代理。") - print(" 保存代理供 Agent 使用:agent-reach configure proxy http://user:pass@ip:port") + print(" 保存代理供 Agent 使用:agent-reach configure proxy(隐藏输入)") print(" Cheap option: https://www.webshare.io ($1/month)") # Test channels @@ -389,22 +434,33 @@ def _cmd_install(args): ) else: # ── Install agent skill ── - _install_skill() + skill_install_ok = _install_skill() is not False + install_ok = ( + core_install_ok and optional_install_ok and skill_install_ok + ) - print(f"✅ Installation complete! {ok}/{total} channels active.") + if install_ok: + print(f"Installation complete. {ok}/{total} channels active.") + else: + print( + "Installation incomplete: one or more requested " + f"steps failed. {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,xiaohongshu,reddit,facebook,instagram,...") - print(" agent-reach install --channels=all (install everything)") + print(" agent-reach install --system --channels=twitter,xiaohongshu,reddit,facebook,instagram,...") + print(" agent-reach install --system --channels=all (install everything)") # Star reminder print() print("如果 Agent Reach 帮到了你,给个 Star 让更多人发现它吧:") print(" https://github.com/Panniantong/Agent-Reach") print(" 只需一秒,对独立开发者意义很大。谢谢!") + if not install_ok: + raise SystemExit(1) else: print() print("Dry run complete. No changes were made.") @@ -519,12 +575,14 @@ def _copy_skill_dir(target: str) -> str | None: print(f"Skill already installed, preserving existing files: {target}") elif status == "installed": print(f"Skill installed: {target}") + installed = True else: print(" -- Could not install agent skill (optional)") print( " -- Tip: install OpenCode, OpenClaw, Claude Code, " "or create ~/.agents/skills/ manually" ) + return installed def _uninstall_skill(): @@ -567,7 +625,8 @@ def _uninstall_skill(): def _cmd_skill(args): """Manage agent skill registration.""" if args.install: - _install_skill() + if not _install_skill(): + raise SystemExit(1) elif args.uninstall: _uninstall_skill() @@ -595,107 +654,168 @@ def _cmd_format(args): def _install_system_deps(): - """Install system-level dependencies: gh CLI, Node.js (for mcporter).""" + """Install system dependencies through an existing OS package manager.""" + import platform import shutil import subprocess - import platform - import tempfile print("Checking system dependencies...") - # ── gh CLI ── - if shutil.which("gh"): + gh_installed = bool(shutil.which("gh")) + node_installed = bool(shutil.which("node") and shutil.which("npm")) + + if gh_installed: print(" ✅ gh CLI already installed") - else: - print(" Installing gh CLI...") - os_type = platform.system().lower() - if os_type == "linux": + if node_installed: + print(" ✅ Node.js already installed") + + missing_labels = [] + if not gh_installed: + missing_labels.append("gh CLI") + if not node_installed: + missing_labels.append("Node.js") + system_install_ok = not missing_labels + + os_type = platform.system().lower() + if missing_labels and os_type == "linux": + apt_get = shutil.which("apt-get") + if not apt_get: + system_install_ok = False + print( + " [!] Missing system dependencies: " + f"{', '.join(missing_labels)}. apt-get is not available; " + "install them manually." + ) + else: + packages = [] + if not gh_installed: + packages.append("gh") + if not node_installed: + packages.extend(("nodejs", "npm")) + print(f" Installing {', '.join(missing_labels)} with apt-get...") 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" + update_result = subprocess.run( + [apt_get, "update", "-qq"], + capture_output=True, + timeout=120, ) - 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) - subprocess.run(["apt-get", "install", "-y", "-qq", "gh"], capture_output=True, timeout=60) - if shutil.which("gh"): - print(" ✅ gh CLI installed") + if update_result.returncode != 0: + system_install_ok = False + print( + " [!] apt-get update failed; no packages were installed." + ) else: - print(" [!] gh CLI install failed. You can try: snap install gh, or download from https://github.com/cli/cli/releases") - except Exception: - print(" [!] gh CLI install failed. You can try: snap install gh, or download from https://github.com/cli/cli/releases") - elif os_type == "darwin": - if shutil.which("brew"): - try: - subprocess.run(["brew", "install", "gh"], capture_output=True, timeout=120) - if shutil.which("gh"): - print(" ✅ gh CLI installed") + install_result = subprocess.run( + [apt_get, "install", "-y", "-qq", *packages], + capture_output=True, + timeout=180, + ) + if install_result.returncode == 0: + system_install_ok = True + print( + " ✅ Installed with apt-get: " + f"{', '.join(missing_labels)}" + ) else: - print(" [!] gh CLI install failed. Try: brew install gh") - except Exception: - print(" [!] gh CLI install failed. Try: brew install gh") - else: - print(" [!] gh CLI not found. Install: https://cli.github.com") + system_install_ok = False + print( + " [!] apt-get install failed for: " + f"{', '.join(missing_labels)}" + ) + except (OSError, subprocess.TimeoutExpired): + system_install_ok = False + print( + " [!] apt-get failed for: " + f"{', '.join(missing_labels)}" + ) + elif missing_labels and os_type == "darwin": + brew = shutil.which("brew") + if not brew: + system_install_ok = False + print( + " [!] Missing system dependencies: " + f"{', '.join(missing_labels)}. Homebrew is not available; " + "install them manually." + ) else: - print(" [!] gh CLI not found. Install: https://cli.github.com") + system_install_ok = True + brew_packages = [] + if not gh_installed: + brew_packages.append(("gh", "gh CLI")) + if not node_installed: + brew_packages.append(("node", "Node.js")) + for package, label in brew_packages: + print(f" Installing {label} with Homebrew...") + try: + undici_result = subprocess.run( + [brew, "install", package], + capture_output=True, + timeout=180, + ) + if undici_result.returncode == 0: + print(f" ✅ {label} installed") + else: + system_install_ok = False + print(f" [!] {label} install failed") + except (OSError, subprocess.TimeoutExpired): + system_install_ok = False + print(f" [!] {label} install failed") + elif missing_labels: + system_install_ok = False + print( + " [!] Missing system dependencies: " + f"{', '.join(missing_labels)}. Install them manually." + ) - # ── Node.js (needed for mcporter) ── - if shutil.which("node") and shutil.which("npm"): - print(" ✅ Node.js already installed") - else: - print(" Installing Node.js...") + # ── undici (proxy support for Node.js fetch) ── + npm_cmd = shutil.which("npm") + if npm_cmd: try: - # Use NodeSource setup script without invoking a shell pipeline. - with tempfile.NamedTemporaryFile(delete=False, suffix=".sh") as tf: - script_path = tf.name - subprocess.run( - ["curl", "-fsSL", "https://deb.nodesource.com/setup_22.x", "-o", script_path], - capture_output=True, timeout=60, + npm_root_result = subprocess.run( + [npm_cmd, "root", "-g"], + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=5, ) - subprocess.run( - ["bash", script_path], - capture_output=True, timeout=120, + except (OSError, subprocess.TimeoutExpired): + npm_root_result = None + + if npm_root_result is None or npm_root_result.returncode != 0: + print( + " -- Could not inspect global npm packages; " + "skipping optional undici install" ) - try: - os.unlink(script_path) - except Exception: - pass - subprocess.run( - ["apt-get", "install", "-y", "-qq", "nodejs"], - capture_output=True, timeout=120, + else: + npm_root = npm_root_result.stdout.strip() + undici_path = ( + os.path.join(npm_root, "undici", "index.js") + if npm_root + else "" ) - if shutil.which("node"): - print(" ✅ Node.js installed") + if os.path.exists(undici_path): + print(" ✅ undici already installed (Node.js proxy support)") else: - print(" [!] Node.js install failed. Try: apt install nodejs npm, or nvm install 22, or download from https://nodejs.org") - except Exception: - print(" [!] Node.js install failed. Try: apt install nodejs npm, or nvm install 22, or download from https://nodejs.org") - - # ── undici (proxy support for Node.js fetch) ── - npm_cmd = shutil.which("npm") - if npm_cmd: - npm_root = subprocess.run([npm_cmd, "root", "-g"], capture_output=True, encoding="utf-8", errors="replace", timeout=5).stdout.strip() - undici_path = os.path.join(npm_root, "undici", "index.js") if npm_root else "" - if os.path.exists(undici_path): - print(" ✅ undici already installed (Node.js proxy support)") - else: - try: - subprocess.run([npm_cmd, "install", "-g", "undici"], capture_output=True, encoding="utf-8", errors="replace", timeout=60) - print(" ✅ undici installed (Node.js proxy support)") - except Exception: - print(" -- undici install failed (optional — may not work behind proxies)") + try: + result = subprocess.run( + [npm_cmd, "install", "-g", "undici"], + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=60, + ) + if result.returncode == 0: + print(" ✅ undici installed (Node.js proxy support)") + else: + print( + " -- undici install failed " + "(optional — may not work behind proxies)" + ) + except (OSError, subprocess.TimeoutExpired): + print( + " -- undici install failed " + "(optional — may not work behind proxies)" + ) # ── yt-dlp JS runtime config (YouTube requires external JS runtime) ── if shutil.which("deno"): @@ -778,12 +898,15 @@ def _install_system_deps(): # NOTE: twitter-cli, xiaoyuzhou, xhs-cli etc. are optional. # They are installed via --channels flag, not here. # See CHANNEL_INSTALLERS in _cmd_install(). + return system_install_ok def _install_xiaoyuzhou_deps(): """Install Xiaoyuzhou podcast transcription script.""" import shutil + from agent_reach.config import Config + from agent_reach.utils.paths import PrivatePathError, atomic_write_private_text config = Config() print("Setting up Xiaoyuzhou podcast transcription...") @@ -791,25 +914,30 @@ def _install_xiaoyuzhou_deps(): tools_dir = os.path.expanduser("~/.agent-reach/tools/xiaoyuzhou") script_dst = os.path.join(tools_dir, "transcribe.sh") - if os.path.isfile(script_dst): - print(" ✅ Xiaoyuzhou transcription script already installed") + script_src = os.path.join( + os.path.dirname(__file__), + "scripts", + "transcribe_xiaoyuzhou.sh", + ) + script_ok = False + if os.path.isfile(script_src): + existed = os.path.isfile(script_dst) + try: + with open(script_src, encoding="utf-8") as source: + script_text = source.read() + atomic_write_private_text(script_dst, script_text) + os.chmod(script_dst, 0o700) + action = "updated" if existed else "installed" + print(f" ✅ Xiaoyuzhou transcription script {action}") + script_ok = True + except (OSError, UnicodeError, PrivatePathError) as exc: + print(f" [!] Failed to install script: {exc}") else: - # Copy script from package - script_src = os.path.join(os.path.dirname(__file__), "scripts", "transcribe_xiaoyuzhou.sh") - if os.path.isfile(script_src): - try: - os.makedirs(tools_dir, exist_ok=True) - import shutil as _shutil - _shutil.copy2(script_src, script_dst) - os.chmod(script_dst, 0o755) - print(" ✅ Xiaoyuzhou transcription script installed") - except Exception as e: - print(f" [!] Failed to install script: {e}") - else: - print(" [!] Script source not found in package") + print(" [!] Script source not found in package") # Check ffmpeg - if shutil.which("ffmpeg"): + ffmpeg_ok = bool(shutil.which("ffmpeg")) + if ffmpeg_ok: print(" ✅ ffmpeg available") else: print(" -- ffmpeg not found. Install: apt install -y ffmpeg (or brew install ffmpeg)") @@ -820,7 +948,8 @@ def _install_xiaoyuzhou_deps(): print(" ✅ Groq API key configured") else: print(" -- Groq API key not set. Get free key at https://console.groq.com") - print(" Then run: agent-reach configure groq-key gsk_xxxxx") + print(" Then run: agent-reach configure groq-key(隐藏输入)") + return script_ok and ffmpeg_ok def _install_twitter_deps(): @@ -831,19 +960,25 @@ def _install_twitter_deps(): print("Setting up Twitter (twitter-cli)...") if shutil.which("twitter"): print(" ✅ twitter-cli already installed") - return - for tool, cmd in [("pipx", ["pipx", "install", "twitter-cli"]), - ("uv", ["uv", "tool", "install", "twitter-cli"])]: - if shutil.which(tool): + return True + for tool, args in [ + ("pipx", ["install", "twitter-cli"]), + ("uv", ["tool", "install", "twitter-cli"]), + ]: + tool_cmd = shutil.which(tool) + if tool_cmd: try: - subprocess.run(cmd, capture_output=True, encoding="utf-8", - errors="replace", timeout=120) - if shutil.which("twitter"): + result = subprocess.run( + [tool_cmd, *args], capture_output=True, encoding="utf-8", + errors="replace", timeout=120, + ) + if result.returncode == 0 and shutil.which("twitter"): print(" ✅ twitter-cli installed") - return - except Exception: + return True + except (OSError, subprocess.TimeoutExpired): pass print(" [!] twitter-cli install failed. Run: pipx install twitter-cli") + return False def _install_xhs_deps(): @@ -864,14 +999,16 @@ def _install_xhs_deps(): print(" (建议放到 ~/.agent-reach/tools/ 下)") print(" 2. 启动服务(首次运行会下载约 150MB 浏览器,请等待完成)") print(" 3. 用 Cookie-Editor 从 xiaohongshu.com 明确导出 Cookie") - print(" agent-reach configure xhs-cookies ''") + print(" agent-reach configure xhs-cookies(粘贴到隐藏输入提示)") print(" 4. 接入:mcporter config add xiaohongshu http://localhost:18060/mcp --scope home") print(" 5. 验证:agent-reach doctor") - return + return False - _install_opencli_deps() - if shutil.which("xhs"): + opencli_ok = _install_opencli_deps() + xhs_ok = bool(shutil.which("xhs")) + if xhs_ok: print(" ✅ 检测到存量 xhs-cli,将作为备选后端继续可用") + return opencli_ok or xhs_ok def _install_opencli_deps(): @@ -897,30 +1034,38 @@ def _install_opencli_deps(): print(f" ✅ {opencli_summary(st)}") if not st.ready: print(f" {st.hint}") - return + return True - if not shutil.which("npm"): + npm_cmd = shutil.which("npm") + if not npm_cmd: print(" [!] OpenCLI requires Node.js ≥ 20. Install Node first:") print(" https://nodejs.org (或 brew install node)") - return + return False try: - subprocess.run( - ["npm", "install", "-g", OPENCLI_PACKAGE], + install_result = subprocess.run( + [npm_cmd, "install", "-g", OPENCLI_PACKAGE], capture_output=True, encoding="utf-8", errors="replace", timeout=300, ) - except Exception: - pass + except (OSError, subprocess.TimeoutExpired): + install_result = None st = opencli_status() - if st.installed and not st.broken: + if ( + install_result is not None + and install_result.returncode == 0 + and st.installed + and not st.broken + ): print(" ✅ OpenCLI installed") print(" 最后一步(必须手动,Chrome 安全限制):安装浏览器扩展") print(f" 1. 打开 {OPENCLI_EXTENSION_URL}") print(" 2. 点「添加至 Chrome」") print(" 3. 运行 `opencli doctor` 验证连接") + return True else: print(f" [!] OpenCLI install failed. Run: npm install -g {OPENCLI_PACKAGE}") + return False def _install_reddit_deps(): @@ -930,14 +1075,14 @@ def _install_reddit_deps(): approval-gated since 2025-11) — every backend needs a logged-in session. """ if _detect_environment() != "server": - _install_opencli_deps() + installed = _install_opencli_deps() print(" Reddit 走 OpenCLI(浏览器里登录过 reddit.com 即可用)") import shutil if shutil.which("rdt"): print(" ✅ 检测到存量 rdt-cli,将作为备选后端继续可用") - return + return installed - _install_rdt_cli() + return _install_rdt_cli() def _install_rdt_cli(): @@ -948,21 +1093,25 @@ def _install_rdt_cli(): print("Setting up Reddit (rdt-cli)...") if shutil.which("rdt"): print(" ✅ rdt-cli already installed") - return - for tool, cmd in [ - ("pipx", ["pipx", "install", _RDT_GIT_SOURCE]), - ("uv", ["uv", "tool", "install", "--from", _RDT_GIT_SOURCE, "rdt-cli"]), + return True + for tool, args in [ + ("pipx", ["install", _RDT_GIT_SOURCE]), + ("uv", ["tool", "install", "--from", _RDT_GIT_SOURCE, "rdt-cli"]), ]: - if shutil.which(tool): + tool_cmd = shutil.which(tool) + if tool_cmd: try: - subprocess.run(cmd, capture_output=True, encoding="utf-8", - errors="replace", timeout=120) - if shutil.which("rdt"): + result = subprocess.run( + [tool_cmd, *args], capture_output=True, encoding="utf-8", + errors="replace", timeout=120, + ) + if result.returncode == 0 and shutil.which("rdt"): print(" ✅ rdt-cli installed") - return - except Exception: + return True + except (OSError, subprocess.TimeoutExpired): pass print(f" [!] rdt-cli install failed. Run: pipx install '{_RDT_GIT_SOURCE}'") + return False def _install_bili_deps(): @@ -973,19 +1122,25 @@ def _install_bili_deps(): print("Setting up Bilibili (bili-cli)...") if shutil.which("bili"): print(" ✅ bili-cli already installed") - return - for tool, cmd in [("pipx", ["pipx", "install", "bilibili-cli"]), - ("uv", ["uv", "tool", "install", "bilibili-cli"])]: - if shutil.which(tool): + return True + for tool, args in [ + ("pipx", ["install", "bilibili-cli"]), + ("uv", ["tool", "install", "bilibili-cli"]), + ]: + tool_cmd = shutil.which(tool) + if tool_cmd: try: - subprocess.run(cmd, capture_output=True, encoding="utf-8", - errors="replace", timeout=120) - if shutil.which("bili"): + result = subprocess.run( + [tool_cmd, *args], capture_output=True, encoding="utf-8", + errors="replace", timeout=120, + ) + if result.returncode == 0 and shutil.which("bili"): print(" ✅ bili-cli installed") - return - except Exception: + return True + except (OSError, subprocess.TimeoutExpired): pass print(" [!] bili-cli install failed. Run: pipx install bilibili-cli") + return False def _install_system_deps_safe(): @@ -1001,7 +1156,7 @@ def _install_system_deps_safe(): missing = [] for name, binaries, label, install_hint in deps: - found = any(shutil.which(b) for b in binaries) + found = all(shutil.which(b) for b in binaries) if found: print(f" ✅ {label} already installed") else: @@ -1024,12 +1179,16 @@ def _install_system_deps_dryrun(): print("[dry-run] System dependency check:") checks = [ - ("gh CLI", ["gh"], "apt install gh / brew install gh"), - ("Node.js", ["node"], "curl NodeSource setup | bash + apt install nodejs"), + ("gh CLI", ["gh"], "apt-get install gh / brew install gh"), + ( + "Node.js", + ["node", "npm"], + "apt-get install nodejs npm / brew install node", + ), ] for label, binaries, method in checks: - found = any(shutil.which(b) for b in binaries) + found = all(shutil.which(b) for b in binaries) if found: print(f" ✅ {label}: already installed, skip") else: @@ -1044,27 +1203,29 @@ def _install_mcporter(): print("Setting up mcporter (search backend)...") - if shutil.which("mcporter"): + mcporter_cmd = shutil.which("mcporter") + if mcporter_cmd: print(" ✅ mcporter already installed") else: - # Check for npm/npx - if not shutil.which("npm") and not shutil.which("npx"): + npm_cmd = shutil.which("npm") + if not npm_cmd: print(" [!] mcporter requires Node.js. Install Node.js first:") - print(" https://nodejs.org/ or: curl -fsSL https://fnm.vercel.app/install | bash") - return + print(" https://nodejs.org/") + return False try: - subprocess.run( - ["npm", "install", "-g", "mcporter"], + install_result = subprocess.run( + [npm_cmd, "install", "-g", "mcporter"], capture_output=True, encoding="utf-8", errors="replace", timeout=120, ) - if shutil.which("mcporter"): + mcporter_cmd = shutil.which("mcporter") + if install_result.returncode == 0 and mcporter_cmd: print(" ✅ mcporter installed") else: print(" [X] mcporter install failed. Retry: npm install -g mcporter (check network/timeout), or try: npx mcporter@latest list") - return - except Exception as e: + return False + except (OSError, subprocess.TimeoutExpired) as e: print(f" [X] mcporter install failed: {e}") - return + return False # Configure Exa MCP (free, no key needed) try: @@ -1074,7 +1235,7 @@ def _install_mcporter(): ) r = subprocess.run( - ["mcporter", "config", "list", "--json"], + [mcporter_cmd, "config", "list", "--json"], capture_output=True, encoding="utf-8", errors="replace", @@ -1086,7 +1247,7 @@ def _install_mcporter(): if "exa" not in server_names: add_result = subprocess.run( [ - "mcporter", + mcporter_cmd, "config", "add", "exa", @@ -1098,15 +1259,19 @@ def _install_mcporter(): ) if add_result.returncode == 0: print(" ✅ Exa search configured (free, no API key needed)") + return True else: print( " [!] Could not configure Exa. Run manually: " "mcporter config add exa https://mcp.exa.ai/mcp --scope home" ) + return False else: print(" ✅ Exa search already configured") + return True except Exception: print(" [!] Could not configure Exa. Run manually: mcporter config add exa https://mcp.exa.ai/mcp --scope home") + return False # NOTE: xhs-cli is now optional, installed via --channels=xiaohongshu @@ -1168,6 +1333,46 @@ def _detect_environment(): return "server" if indicators >= 2 else "local" +def _read_configure_value(args) -> str: + """Read one configure value without echoing secrets by default.""" + values = getattr(args, "value", None) or [] + if getattr(args, "read_stdin", False): + try: + value = sys.stdin.read(_MAX_CONFIGURE_VALUE_CHARS + 1) + except OSError: + print("Could not read configure value from stdin", file=sys.stderr) + raise SystemExit(1) from None + if len(value) > _MAX_CONFIGURE_VALUE_CHARS: + print("Configure value exceeds the 1 MiB safety limit", file=sys.stderr) + raise SystemExit(1) + return value.rstrip("\r\n") + + if values: + if getattr(args, "key", None) in _SENSITIVE_CONFIG_KEYS: + print( + "Warning: positional secrets are deprecated because shell history " + "and process listings may expose them; omit the value for a hidden " + "prompt or use --stdin.", + file=sys.stderr, + ) + return " ".join(values) + + try: + interactive = bool(sys.stdin.isatty()) + except (AttributeError, OSError): + interactive = False + if not interactive: + return "" + + import getpass + + try: + return getpass.getpass(f"Value for {args.key}: ") + except (EOFError, KeyboardInterrupt): + print("Configure input cancelled", file=sys.stderr) + raise SystemExit(1) from None + + def _cmd_configure(args): """Set a config value and test it, or auto-extract from browser.""" import shutil @@ -1232,14 +1437,15 @@ def _cmd_configure(args): # ── Manual configure ── if not args.key: - print("Usage: agent-reach configure ") + print("Usage: agent-reach configure [--stdin]") + print(" Omit the value to enter it through a hidden prompt.") print( " or: agent-reach configure --from-browser chrome " "--platform xueqiu" ) return - value = " ".join(args.value) if args.value else "" + value = _read_configure_value(args) if not value: print(f"Missing value for {args.key}") raise SystemExit(1) @@ -1302,9 +1508,10 @@ def _cmd_configure(args): ) else: print("[X] Could not find auth_token and ct0 in your input.") - print(" Accepted formats:") - print(" 1. agent-reach configure twitter-cookies AUTH_TOKEN CT0") - print(' 2. agent-reach configure twitter-cookies "auth_token=xxx; ct0=yyy; ..."') + print(" Run `agent-reach configure twitter-cookies` and paste either:") + print(" 1. AUTH_TOKEN and CT0 separated by whitespace") + print(" 2. A Cookie-Editor Header String") + print(" For automation, pass the same value through --stdin.") raise SystemExit(1) elif args.key == "youtube-cookies": @@ -1318,26 +1525,34 @@ def _cmd_configure(args): elif args.key == "github-token": config.set("github_token", value) - print(f"✅ GitHub token configured!") + print("✅ GitHub token configured!") elif args.key == "groq-key": config.set("groq_api_key", value) - print(f"✅ Groq key configured!") + print("✅ Groq key configured!") elif args.key == "openai-key": config.set("openai_api_key", value) - print(f"✅ OpenAI key configured!") + print("✅ OpenAI key configured!") def _cmd_transcribe(args): - """Transcribe a URL or local audio file via Whisper (Groq → OpenAI fallback).""" + """Transcribe a URL or local audio file via an explicitly selected provider.""" from pathlib import Path from agent_reach.transcribe import TranscribeError, transcribe from agent_reach.utils.text import scrub_url_credentials try: - text = transcribe(args.source, provider=args.provider) + text = transcribe( + args.source, + provider=args.provider, + allow_provider_fallback=getattr( + args, + "allow_provider_fallback", + False, + ), + ) except TranscribeError as e: print(f"❌ {scrub_url_credentials(e)}") sys.exit(1) @@ -1389,7 +1604,8 @@ def _configure_xhs_cookies(value) -> bool: value = value.strip() if not value: print("[X] Missing cookie value.") - print(" Usage: agent-reach configure xhs-cookies ''") + print(" Run `agent-reach configure xhs-cookies` and paste the Cookie-Editor export.") + print(" For automation, pass the same value through --stdin.") return False # Detect format and parse @@ -1747,10 +1963,6 @@ def _cmd_uninstall(args): def _cmd_doctor(args=None): 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(read_only=True) results = check_all(config) @@ -1758,10 +1970,18 @@ def _cmd_doctor(args=None): print(json.dumps(results, ensure_ascii=False, indent=2)) return - rprint(format_report(results)) + report = format_report(results) + try: + from rich import print as rich_print + except ImportError: + print(report) + else: + rich_print(report) def _cmd_setup(): + import getpass + from agent_reach.config import Config config = Config() @@ -1832,9 +2052,9 @@ def _cmd_setup(): print(" 获取: https://github.com/settings/tokens (无需任何权限)") current = config.get("github_token") if current: - print(f" 当前状态: ✅ 已配置") + print(" 当前状态: ✅ 已配置") else: - key = input(" GITHUB_TOKEN (回车跳过): ").strip() + key = getpass.getpass(" GITHUB_TOKEN (回车跳过): ").strip() if key: config.set("github_token", key) print(" ✅ GitHub API 已提升至 5000 次/小时!") @@ -1853,9 +2073,9 @@ def _cmd_setup(): print(" 免费额度,注册: https://console.groq.com") current = config.get("groq_api_key") if current: - print(f" 当前状态: ✅ 已配置") + print(" 当前状态: ✅ 已配置") else: - key = input(" GROQ_API_KEY (回车跳过): ").strip() + key = getpass.getpass(" GROQ_API_KEY (回车跳过): ").strip() if key: config.set("groq_api_key", key) print(" ✅ 语音转文字已开启!") @@ -1984,10 +2204,10 @@ def parse(v): except ValueError: return None - r, l = parse(remote), parse(local) - if r is None or l is None: + remote_version, local_version = parse(remote), parse(local) + if remote_version is None or local_version is None: return remote != local # unparseable — fall back to old behavior - return r > l + return remote_version > local_version def _cmd_check_update(): @@ -2020,7 +2240,7 @@ def _cmd_check_update(): print() print(_UPDATE_INSTRUCTIONS) return "update_available" - print(f"✅ 已是最新版本") + print("✅ 已是最新版本") return "up_to_date" release_err = _classify_github_response_error(resp) @@ -2057,9 +2277,9 @@ def _cmd_watch(): Only outputs problems. If everything is fine, outputs a single line. """ + from agent_reach import __version__ from agent_reach.config import Config from agent_reach.doctor import check_all - from agent_reach import __version__ config = Config(read_only=True) issues = [] @@ -2098,8 +2318,8 @@ def _cmd_watch(): print(f"Agent Reach: 全部正常 ({ok}/{total} 渠道可用,v{__version__} 已是最新)") return - print(f"Agent Reach 监控报告") - print(f"=" * 40) + print("Agent Reach 监控报告") + print("=" * 40) print(f"版本: v{__version__} | 渠道: {ok}/{total}") if issues: diff --git a/agent_reach/config.py b/agent_reach/config.py index d6e51853..49b7c845 100644 --- a/agent_reach/config.py +++ b/agent_reach/config.py @@ -227,7 +227,7 @@ def to_dict(self) -> dict: masked = {} for k, v in self.data.items(): if any(s in k.lower() for s in sensitive_markers): - masked[k] = f"{str(v)[:8]}..." if v else None + masked[k] = "[REDACTED]" if v else None else: masked[k] = v return masked diff --git a/agent_reach/guides/setup-exa.md b/agent_reach/guides/setup-exa.md index 0dfdc2d5..3abc5ad0 100644 --- a/agent_reach/guides/setup-exa.md +++ b/agent_reach/guides/setup-exa.md @@ -8,7 +8,8 @@ Exa 是一个 AI 语义搜索引擎。通过 MCP 接入,**免费、无需 API ## Agent 可自动完成的步骤 -`agent-reach install --env=auto` 会自动完成以下步骤,通常不需要手动操作。 +用户明确授权后,`agent-reach install --env=auto --system` 会完成以下步骤。 +不带 `--system` 的默认命令只做只读检查。 ### 1. 安装 mcporter ```bash @@ -30,7 +31,7 @@ mcporter call 'exa.web_search_exa(query: "test", numResults: 1)' **无。** Exa 通过 MCP 接入,免费、无需注册、无需 API Key。 -如果 `agent-reach install` 因为网络问题没有自动配置 Exa,手动运行上面两条命令即可。 +如果 `agent-reach install --system` 因为网络问题没有配置 Exa,手动运行上面两条命令即可。 ## 常见问题 diff --git a/agent_reach/guides/setup-reddit.md b/agent_reach/guides/setup-reddit.md index 376350fb..5c121586 100644 --- a/agent_reach/guides/setup-reddit.md +++ b/agent_reach/guides/setup-reddit.md @@ -24,7 +24,7 @@ pipx install 'git+https://github.com/public-clis/rdt-cli.git' 或一键安装: ```bash -agent-reach install --env=auto --channels=reddit +agent-reach install --env=auto --system --channels=reddit ``` ## 使用示例 @@ -41,7 +41,8 @@ rdt read POST_ID ## 需要用户手动做的步骤 -无。rdt-cli 通过 `agent-reach install --env=auto` 自动安装。 +无。用户明确授权后,rdt-cli 通过 +`agent-reach install --env=auto --system --channels=reddit` 安装。 ## Fallback:Exa 搜索 diff --git a/agent_reach/guides/setup-twitter.md b/agent_reach/guides/setup-twitter.md index 61eb9d4c..da9d563c 100644 --- a/agent_reach/guides/setup-twitter.md +++ b/agent_reach/guides/setup-twitter.md @@ -39,7 +39,7 @@ twitter --help 4. 运行配置命令: ```bash -agent-reach configure twitter-cookies "粘贴的 Header String" +agent-reach configure twitter-cookies ``` 这会提取 `auth_token` 和 `ct0`,安全保存到 @@ -53,7 +53,7 @@ agent-reach configure twitter-cookies "粘贴的 Header String" - `~/.config/bird/credentials.env` ```bash -agent-reach configure twitter-cookies "粘贴的 Header String" --sync-legacy-twitter +agent-reach configure twitter-cookies --sync-legacy-twitter ``` `agent-reach uninstall` 只会提醒这些 legacy 副本,不会自动删除。需要清理时, diff --git a/agent_reach/guides/setup-xiaohongshu.md b/agent_reach/guides/setup-xiaohongshu.md index f952acd4..b1eb0978 100644 --- a/agent_reach/guides/setup-xiaohongshu.md +++ b/agent_reach/guides/setup-xiaohongshu.md @@ -24,7 +24,7 @@ xiaohongshu-mcp 或存量工具: 4. 把导出的字符串发给 Agent,运行: ```bash -agent-reach configure xhs-cookies "导出的 Cookie Header String" +agent-reach configure xhs-cookies agent-reach doctor ``` @@ -57,7 +57,7 @@ xhs comments NOTE_ID **Q: Cookie 过期了?** A: 重新通过 Cookie-Editor 手工导出,再运行 -`agent-reach configure xhs-cookies "新的 Cookie Header String"`。 +`agent-reach configure xhs-cookies`,并粘贴到隐藏输入提示。 **Q: 小红书提示 IP 风险?** A: 推荐使用住宅代理:`export HTTP_PROXY="http://user:pass@ip:port"`。 diff --git a/agent_reach/scripts/transcribe_xiaoyuzhou.sh b/agent_reach/scripts/transcribe_xiaoyuzhou.sh index fdb3d043..d5ab6396 100755 --- a/agent_reach/scripts/transcribe_xiaoyuzhou.sh +++ b/agent_reach/scripts/transcribe_xiaoyuzhou.sh @@ -24,8 +24,7 @@ while [ $# -gt 0 ]; do done URL="${1:?用法: bash transcribe.sh [--polish] <小宇宙链接> [输出文件路径]}" -OUTPUT="${2:-/tmp/podcast_transcript.txt}" -TMPDIR="/tmp/xiaoyuzhou_$$" +OUTPUT="${2:-}" PYTHON_CMD=() ensure_python() { @@ -44,6 +43,28 @@ ensure_python() { fi } +ensure_python || exit 1 +if ! XIAOYUZHOU_URL="$URL" "${PYTHON_CMD[@]}" <<'PY' +import os +from urllib.parse import urlsplit + +try: + parsed = urlsplit(os.environ["XIAOYUZHOU_URL"]) + hostname = (parsed.hostname or "").lower() +except ValueError: + raise SystemExit(1) + +allowed_host = ( + hostname == "xiaoyuzhoufm.com" + or hostname.endswith(".xiaoyuzhoufm.com") +) +raise SystemExit(0 if parsed.scheme.lower() in {"http", "https"} and allowed_host else 1) +PY +then + echo "❌ 仅支持 xiaoyuzhoufm.com 及其子域的 http/https 链接" >&2 + exit 1 +fi + # Try env var first, then agent-reach config.yaml if [ -z "$GROQ_API_KEY" ]; then CONFIG_FILE="$HOME/.agent-reach/config.yaml" @@ -63,20 +84,36 @@ GROQ_API_KEY="${GROQ_API_KEY:?请设置 GROQ_API_KEY 环境变量或运行 agent # Groq API 限制: 25MB per file MAX_CHUNK_SIZE_MB=20 AUDIO_BITRATE="64k" +CURL_CONNECT_TIMEOUT=15 +PAGE_TIMEOUT=60 +AUDIO_TIMEOUT=1800 +GROQ_TIMEOUT=600 +MAX_PAGE_BYTES=5242880 +MAX_AUDIO_BYTES=1073741824 +MAX_API_RESPONSE_BYTES=33554432 +MAX_DURATION_SECONDS=10800 + +TEMP_ROOT="${TMPDIR:-/tmp}" +if ! WORK_DIR=$(mktemp -d "${TEMP_ROOT%/}/agent-reach-xiaoyuzhou.XXXXXX"); then + echo "❌ 无法创建临时目录" >&2 + exit 1 +fi cleanup() { - rm -rf "$TMPDIR" + rm -rf -- "$WORK_DIR" } trap cleanup EXIT -mkdir -p "$TMPDIR" - echo "📻 小宇宙播客转文字" echo "====================" # Step 1: 提取音频 URL 和标题 echo "🔍 正在解析页面..." -PAGE=$(curl -s "$URL") +PAGE=$(curl --fail --show-error --location --silent \ + --connect-timeout "$CURL_CONNECT_TIMEOUT" \ + --max-time "$PAGE_TIMEOUT" \ + --max-filesize "$MAX_PAGE_BYTES" \ + "$URL") AUDIO_URL=$(echo "$PAGE" | perl -ne 'while (/(https:\/\/media\.xyzcdn\.net\/[^"]*\.(?:m4a|mp3))/gi) { print "$1\n" }' | head -1) TITLE=$(echo "$PAGE" | perl -ne 'if (/"title":"([^"]*)"/) { print "$1\n"; last }' | head -1) @@ -91,20 +128,56 @@ echo "🔗 音频: $AUDIO_URL" # Step 2: 下载音频 echo "⬇️ 正在下载音频..." EXT="${AUDIO_URL##*.}" -curl -sL -o "$TMPDIR/original.$EXT" "$AUDIO_URL" -FILE_SIZE=$(ls -lh "$TMPDIR/original.$EXT" | awk '{print $5}') +curl --fail --show-error --location --silent \ + --connect-timeout "$CURL_CONNECT_TIMEOUT" \ + --max-time "$AUDIO_TIMEOUT" \ + --max-filesize "$MAX_AUDIO_BYTES" \ + -o "$WORK_DIR/original.$EXT" \ + "$AUDIO_URL" +FILE_SIZE=$(ls -lh "$WORK_DIR/original.$EXT" | awk '{print $5}') echo "📦 文件大小: $FILE_SIZE" # Step 3: 获取时长 -DURATION=$(ffprobe -v quiet -show_entries format=duration -of csv=p=0 "$TMPDIR/original.$EXT" 2>/dev/null | cut -d. -f1) +if ! DURATION_RAW=$(ffprobe -v quiet -show_entries format=duration -of csv=p=0 \ + "$WORK_DIR/original.$EXT" 2>/dev/null); then + echo "❌ ffprobe 无法读取音频时长" >&2 + exit 1 +fi +if DURATION=$(DURATION_RAW="$DURATION_RAW" MAX_DURATION_SECONDS="$MAX_DURATION_SECONDS" \ + "${PYTHON_CMD[@]}" -c ' +import os +import sys +from decimal import Decimal, InvalidOperation + +raw = os.environ["DURATION_RAW"] +try: + value = Decimal(raw) +except InvalidOperation: + raise SystemExit(2) +if not value.is_finite() or value < 0: + raise SystemExit(2) +if value > Decimal(os.environ["MAX_DURATION_SECONDS"]): + raise SystemExit(3) +print(int(value)) +'); then + : +else + duration_status=$? + if [ "$duration_status" -eq 3 ]; then + echo "❌ 音频时长超过 3 小时限制" >&2 + else + echo "❌ ffprobe 返回无效音频时长: ${DURATION_RAW:-}" >&2 + fi + exit 1 +fi DURATION_MIN=$((DURATION / 60)) DURATION_SEC=$((DURATION % 60)) echo "⏱️ 时长: ${DURATION_MIN}分${DURATION_SEC}秒" # Step 4: 转为低码率单声道 MP3 echo "🔄 正在转码..." -ffmpeg -y -i "$TMPDIR/original.$EXT" -b:a "$AUDIO_BITRATE" -ac 1 "$TMPDIR/mono.mp3" 2>/dev/null -MONO_SIZE=$(stat -c%s "$TMPDIR/mono.mp3" 2>/dev/null || stat -f%z "$TMPDIR/mono.mp3") +ffmpeg -y -i "$WORK_DIR/original.$EXT" -t "$MAX_DURATION_SECONDS" -b:a "$AUDIO_BITRATE" -ac 1 "$WORK_DIR/mono.mp3" 2>/dev/null +MONO_SIZE=$(stat -c%s "$WORK_DIR/mono.mp3" 2>/dev/null || stat -f%z "$WORK_DIR/mono.mp3") MONO_SIZE_MB=$(awk -v bytes="$MONO_SIZE" 'BEGIN { printf "%.1f", bytes / 1024 / 1024 }') echo "📦 转码后: ${MONO_SIZE_MB}MB" @@ -113,7 +186,7 @@ MAX_BYTES=$((MAX_CHUNK_SIZE_MB * 1024 * 1024)) if [ "$MONO_SIZE" -le "$MAX_BYTES" ]; then # 不需要切片 - cp "$TMPDIR/mono.mp3" "$TMPDIR/chunk_0.mp3" + cp "$WORK_DIR/mono.mp3" "$WORK_DIR/chunk_0.mp3" NUM_CHUNKS=1 echo "📎 无需切片" else @@ -124,8 +197,8 @@ else for i in $(seq 0 $((NUM_CHUNKS - 1))); do START=$((i * CHUNK_DURATION)) - ffmpeg -y -i "$TMPDIR/mono.mp3" -ss "$START" -t "$CHUNK_DURATION" -c copy "$TMPDIR/chunk_${i}.mp3" 2>/dev/null - CHUNK_SIZE=$(ls -lh "$TMPDIR/chunk_${i}.mp3" | awk '{print $5}') + ffmpeg -y -i "$WORK_DIR/mono.mp3" -ss "$START" -t "$CHUNK_DURATION" -c copy "$WORK_DIR/chunk_${i}.mp3" 2>/dev/null + CHUNK_SIZE=$(ls -lh "$WORK_DIR/chunk_${i}.mp3" | awk '{print $5}') echo " 段 $((i+1))/$NUM_CHUNKS: $CHUNK_SIZE" done fi @@ -136,10 +209,14 @@ echo "🎙️ 正在转录 (Groq Whisper large-v3)..." for i in $(seq 0 $((NUM_CHUNKS - 1))); do echo -n " 段 $((i+1))/$NUM_CHUNKS... " - RESPONSE=$(curl -s -w "\n%{http_code}" \ + RESPONSE=$(curl --silent --show-error \ + --connect-timeout "$CURL_CONNECT_TIMEOUT" \ + --max-time "$GROQ_TIMEOUT" \ + --max-filesize "$MAX_API_RESPONSE_BYTES" \ + -w "\n%{http_code}" \ https://api.groq.com/openai/v1/audio/transcriptions \ -H "Authorization: Bearer $GROQ_API_KEY" \ - -F file="@$TMPDIR/chunk_${i}.mp3" \ + -F file="@$WORK_DIR/chunk_${i}.mp3" \ -F model="whisper-large-v3" \ -F language="zh" \ -F prompt="以下是一段中文普通话播客录音,请输出包含完整中文标点(,。?!:;“”‘’)的转写文本。" \ @@ -158,12 +235,19 @@ for i in $(seq 0 $((NUM_CHUNKS - 1))); do WAIT_SEC=$(echo "$BODY" | perl -ne 'if (/in (\d+)m/) { print "$1\n"; exit }') WAIT_SEC=${WAIT_SEC:-2} WAIT_SEC=$((WAIT_SEC * 60 + 30)) + if [ "$WAIT_SEC" -gt 900 ]; then + WAIT_SEC=900 + fi echo " ⏳ 速率限制,等待 ${WAIT_SEC} 秒后重试..." sleep "$WAIT_SEC" - RESPONSE=$(curl -s -w "\n%{http_code}" \ + RESPONSE=$(curl --silent --show-error \ + --connect-timeout "$CURL_CONNECT_TIMEOUT" \ + --max-time "$GROQ_TIMEOUT" \ + --max-filesize "$MAX_API_RESPONSE_BYTES" \ + -w "\n%{http_code}" \ https://api.groq.com/openai/v1/audio/transcriptions \ -H "Authorization: Bearer $GROQ_API_KEY" \ - -F file="@$TMPDIR/chunk_${i}.mp3" \ + -F file="@$WORK_DIR/chunk_${i}.mp3" \ -F model="whisper-large-v3" \ -F language="zh" \ -F prompt="以下是一段中文普通话播客录音,请输出包含完整中文标点(,。?!:;“”‘’)的转写文本。" \ @@ -185,8 +269,8 @@ for i in $(seq 0 $((NUM_CHUNKS - 1))); do fi fi - echo "$BODY" > "$TMPDIR/transcript_${i}.txt" - CHARS=$(wc -m < "$TMPDIR/transcript_${i}.txt") + echo "$BODY" > "$WORK_DIR/transcript_${i}.txt" + CHARS=$(wc -m < "$WORK_DIR/transcript_${i}.txt") echo "✅ ($CHARS 字)" done @@ -196,8 +280,8 @@ if [ "$POLISH" = "1" ]; then echo "✨ 正在润色(Llama 3.3 70B 加标点+分段)..." for i in $(seq 0 $((NUM_CHUNKS - 1))); do echo -n " 段 $((i+1))/$NUM_CHUNKS... " - IN_FILE="$TMPDIR/transcript_${i}.txt" \ - OUT_FILE="$TMPDIR/polished_${i}.txt" \ + IN_FILE="$WORK_DIR/transcript_${i}.txt" \ + OUT_FILE="$WORK_DIR/polished_${i}.txt" \ GROQ_API_KEY="$GROQ_API_KEY" \ "${PYTHON_CMD[@]}" <<'PY' import json, os, sys, urllib.request, urllib.error @@ -237,7 +321,10 @@ def call_groq(text): }, ) with urllib.request.urlopen(req, timeout=180) as r: - resp = json.load(r) + payload = r.read(32 * 1024 * 1024 + 1) + if len(payload) > 32 * 1024 * 1024: + raise ValueError("polish response exceeds 32 MiB limit") + resp = json.loads(payload) return ( resp["choices"][0]["message"]["content"].strip(), resp["choices"][0].get("finish_reason"), @@ -269,6 +356,13 @@ fi # Step 7: 合并输出 echo "📄 正在合并文字稿..." +if [ -z "$OUTPUT" ]; then + if ! OUTPUT=$(mktemp "${TEMP_ROOT%/}/agent-reach-transcript.XXXXXX"); then + echo "❌ 无法安全创建输出文件" >&2 + exit 1 + fi +fi + { echo "# $TITLE" echo "" @@ -283,10 +377,10 @@ echo "📄 正在合并文字稿..." echo "" for i in $(seq 0 $((NUM_CHUNKS - 1))); do - if [ "$POLISH" = "1" ] && [ -f "$TMPDIR/polished_${i}.txt" ]; then - cat "$TMPDIR/polished_${i}.txt" + if [ "$POLISH" = "1" ] && [ -f "$WORK_DIR/polished_${i}.txt" ]; then + cat "$WORK_DIR/polished_${i}.txt" else - cat "$TMPDIR/transcript_${i}.txt" + cat "$WORK_DIR/transcript_${i}.txt" fi echo "" done diff --git a/agent_reach/skill/references/career.md b/agent_reach/skill/references/career.md index f1b46fe7..d67887bc 100644 --- a/agent_reach/skill/references/career.md +++ b/agent_reach/skill/references/career.md @@ -6,19 +6,19 @@ LinkedIn。 ```bash # 获取个人资料 -mcporter call 'linkedin-scraper.get_person_profile(linkedin_url: "https://linkedin.com/in/username")' +mcporter call 'linkedin.get_person_profile(linkedin_username: "username", sections: "experience,education")' # 搜索人才 -mcporter call 'linkedin-scraper.search_people(keyword: "AI engineer", limit: 10)' +mcporter call 'linkedin.search_people(keywords: "AI engineer", location: "Shanghai")' # 获取公司资料 -mcporter call 'linkedin-scraper.get_company_profile(linkedin_url: "https://linkedin.com/company/xxx")' +mcporter call 'linkedin.get_company_profile(company_name: "openai", sections: "posts,jobs")' # 搜索职位 -mcporter call 'linkedin-scraper.search_jobs(keyword: "software engineer", limit: 10)' +mcporter call 'linkedin.search_jobs(keywords: "software engineer", location: "Remote", max_pages: 2)' ``` -> **需要登录**: LinkedIn scraper 需要有效的登录态。 +> **需要登录**: 首次使用前运行 `uvx mcp-server-linkedin@latest --login`,保存有效登录态。 ### Fallback 方案 diff --git a/agent_reach/skill/references/search.md b/agent_reach/skill/references/search.md index 4a0fa4c6..ced10db8 100644 --- a/agent_reach/skill/references/search.md +++ b/agent_reach/skill/references/search.md @@ -4,11 +4,11 @@ Exa AI 搜索引擎。 ## Exa AI 搜索 -高质量 AI 搜索引擎,擅长技术和代码搜索。 +高质量 AI 搜索引擎,适合查找技术文档、官方示例和相关网页。 ```bash mcporter call 'exa.web_search_exa(query: "query", numResults: 5)' -mcporter call 'exa.get_code_context_exa(query: "code question", tokensNum: 3000)' +mcporter call 'exa.web_search_exa(query: "library API code example", numResults: 5)' ``` ### 使用场景 @@ -16,12 +16,15 @@ mcporter call 'exa.get_code_context_exa(query: "code question", tokensNum: 3000) | 场景 | 参数 | |-----|------| | 网页搜索 | `web_search_exa(query: "...", numResults: 5)` | -| 代码搜索 | `get_code_context_exa(query: "...", tokensNum: 3000)` | +| 技术/代码资料 | `web_search_exa(query: "框架名 API 示例", numResults: 5)` | + +> Exa MCP 的 `get_code_context_exa` 已弃用且默认不注册。代码问题也使用 +> `web_search_exa`;需要精确搜索仓库内容时,改用 `dev.md` 中的 GitHub 搜索。 ### 特点 - 擅长英文内容和技术文档 -- 支持代码上下文搜索 +- 可通过查询词定位官方文档和代码示例 - 结果质量高 ## 与其他搜索工具对比 diff --git a/agent_reach/skill/references/social.md b/agent_reach/skill/references/social.md index fabfcfcb..1d5b6d50 100644 --- a/agent_reach/skill/references/social.md +++ b/agent_reach/skill/references/social.md @@ -35,7 +35,7 @@ opencli xiaohongshu user USER_ID -f yaml ```bash # 认证前先让用户用 Cookie-Editor 手工导出,再显式导入 -agent-reach configure xhs-cookies "导出的 Cookie Header String" +agent-reach configure xhs-cookies # 只读检查当前状态 mcporter call 'xiaohongshu.check_login_status()' --timeout 120000 @@ -80,7 +80,7 @@ xhs feed # 推荐 ### 认证前置条件 -`agent-reach configure twitter-cookies "..."` 保存的 Cookie 只供 +`agent-reach configure twitter-cookies` 通过隐藏输入保存的 Cookie 只供 `agent-reach doctor` 检查显式凭据是否齐全。`doctor` 不执行上游 `twitter status`,也不会设置当前 Shell。运行下面任何 `twitter` 命令前, 必须在同一个 Shell 或子进程环境中显式提供: diff --git a/agent_reach/skill/references/video.md b/agent_reach/skill/references/video.md index b9d8227a..54d44948 100644 --- a/agent_reach/skill/references/video.md +++ b/agent_reach/skill/references/video.md @@ -62,8 +62,11 @@ agent-reach transcribe ./local_audio.mp3 -o /tmp/transcript.txt ``` > `agent-reach transcribe` 只接收公开 http(s) URL 或本地音频文件。用 `ytsearch5:` 搜索时,先从 yt-dlp 结果里选出具体视频 URL,再转写。 -> 需要先配置 key:`agent-reach configure groq-key gsk_xxx`(免费,console.groq.com) -> 或 `agent-reach configure openai-key sk-xxx`。默认 auto 模式:groq 失败自动降级 openai。 +> 需要先配置 key:`agent-reach configure groq-key`(隐藏输入;免费,console.groq.com) +> 或 `agent-reach configure openai-key`。默认 auto 模式只使用第一个已配置服务商 +>(优先 Groq,否则 OpenAI),失败即停止,不会把音频自动发给另一家。 +> `--allow-provider-fallback` 会显式授权跨服务商降级;同一音频内容可能被 Groq 和 +> OpenAI 分别处理,并可能产生 OpenAI 费用,只应在确认内容可分享给两家后使用。 ## B站 / Bilibili(bili-cli 为主,OpenCLI 补字幕) @@ -123,8 +126,8 @@ curl -s -b /tmp/bili_ck.txt -A "$UA" -e "https://www.bilibili.com/" \ 1. **ffmpeg**: `brew install ffmpeg` 2. **Groq API Key** (免费): https://console.groq.com/keys -3. **配置 Key**: `agent-reach configure groq-key YOUR_KEY` -4. **首次运行**: `agent-reach install --env=auto` 安装工具 +3. **配置 Key**: `agent-reach configure groq-key`(隐藏输入) +4. **首次运行**: `agent-reach install --env=auto --system --channels=xiaoyuzhou`(需用户明确授权) ### 检查状态 diff --git a/agent_reach/transcribe.py b/agent_reach/transcribe.py index 1643a090..248a49da 100644 --- a/agent_reach/transcribe.py +++ b/agent_reach/transcribe.py @@ -1,12 +1,19 @@ # -*- coding: utf-8 -*- -"""Whisper audio transcription with Groq → OpenAI fallback. +"""Whisper audio transcription with explicit provider routing. Downloads audio (yt-dlp), compresses + chunks (ffmpeg), and posts to a -Whisper-compatible API. Defaults to Groq's free `whisper-large-v3` and falls -back to OpenAI's `whisper-1` on HTTP error. +Whisper-compatible API. Auto mode selects the first configured provider and +only sends audio to another provider when the caller explicitly opts in. Public entry point: - transcribe(source, *, provider="auto", out_dir=None, config=None) -> str + transcribe( + source, + *, + provider="auto", + out_dir=None, + config=None, + allow_provider_fallback=False, + ) -> str Designed to be importable from channels (e.g. YouTubeChannel.transcribe). """ @@ -16,6 +23,7 @@ import ipaddress import math import shutil +import socket import subprocess import tempfile from pathlib import Path @@ -159,10 +167,30 @@ def _run(cmd: List[str], timeout: int = 600) -> None: ) -def _is_private_ip(value: str) -> bool: +def _literal_ip(host: str): + """Return the address a literal host denotes, or None for a real hostname. + + ``ipaddress`` only accepts the canonical dotted-quad form, but the C + resolver behind yt-dlp accepts the whole ``inet_aton`` grammar: ``127.1``, + ``2130706433``, ``0x7f000001`` and ``0177.0.0.1`` all reach 127.0.0.1, and + ``0xA9FEA9FE`` reaches the cloud metadata endpoint. Parsing with the same + grammar keeps those shorthands from slipping past the private-address + check. This is literal parsing only — no name is resolved here. + """ try: - ip = ipaddress.ip_address(value) + return ipaddress.ip_address(host) except ValueError: + pass + try: + packed = socket.inet_aton(host) + except OSError: + return None + return ipaddress.IPv4Address(packed) + + +def _is_private_ip(value: str) -> bool: + ip = _literal_ip(value) + if ip is None: return False return any( ( @@ -184,15 +212,28 @@ def _assert_safe_public_url(url: str) -> None: host_part, port_part = before_slash.rsplit(":", 1) if not host_part or not port_part.isdigit(): raise TranscribeError("SSRF blocked: only public http(s) URLs are allowed") - parsed = urlparse(f"https://{url}") + normalized_url = f"https://{url}" + parsed = urlparse(normalized_url) else: + normalized_url = url parsed = urlparse(url) if parsed.scheme not in {"http", "https"}: raise TranscribeError("SSRF blocked: only public http(s) URLs are allowed") - host = (parsed.hostname or "").strip().lower().rstrip(".") - if not host: + raw_authority = normalized_url.split("://", 1)[1] + raw_authority = raw_authority.split("/", 1)[0] + raw_authority = raw_authority.split("?", 1)[0] + raw_authority = raw_authority.split("#", 1)[0] + if "\\" in raw_authority or "%" in raw_authority: + raise TranscribeError("SSRF blocked: encoded or ambiguous URL host") + + raw_host = (parsed.hostname or "").strip().rstrip(".") + if not raw_host: raise TranscribeError("SSRF blocked: URL host is missing") + try: + host = raw_host.encode("idna").decode("ascii").lower().rstrip(".") + except UnicodeError: + raise TranscribeError("SSRF blocked: URL host is invalid") from None if host in _BLOCKED_HOSTS or host.endswith(".localhost"): raise TranscribeError("SSRF blocked: internal host is not allowed") if _is_private_ip(host): @@ -360,20 +401,33 @@ def transcribe( provider: str = "auto", out_dir: Optional[Path] = None, config: Optional[Config] = None, + allow_provider_fallback: bool = False, ) -> str: """Transcribe a URL or local file path. Returns the joined transcript text. - `provider` is one of `auto` (groq → openai), `groq`, or `openai`. + `provider` is one of `auto`, `groq`, or `openai`. Auto mode selects the + first configured provider (Groq, then OpenAI). In auto mode only, set + `allow_provider_fallback=True` to permit sending failed chunks to the next + configured provider; using the flag with an explicit provider is rejected. `out_dir` defaults to a fresh temp directory; intermediate files stay there. """ + if allow_provider_fallback and provider != "auto": + raise TranscribeError( + "allow_provider_fallback requires provider='auto'" + ) cfg = config or Config() - order = _provider_order(provider) + candidates = _provider_order(provider) + configured = [p for p in candidates if _provider_key(p, cfg)] # Validate at least one provider is configured before doing expensive work. - if not any(_provider_key(p, cfg) for p in order): - names = ", ".join(PROVIDERS[p]["key_field"] for p in order) + if not configured: + names = ", ".join(PROVIDERS[p]["key_field"] for p in candidates) raise NoProviderConfigured(f"no provider key configured (need one of: {names})") + order = configured + if provider == "auto" and not allow_provider_fallback: + order = configured[:1] + if out_dir: return _transcribe_in_dir(source, order, cfg, Path(out_dir)) diff --git a/docs/README_en.md b/docs/README_en.md index 354cd230..320d1284 100644 --- a/docs/README_en.md +++ b/docs/README_en.md @@ -135,18 +135,18 @@ Copy this to your AI Agent (Claude Code, OpenClaw, Cursor, etc.): Install Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md ``` -The Agent auto-installs, detects your environment, and tells you what's ready. +The Agent installs the Python package, checks your environment, and tells you what's ready. System-level changes require an explicit `--system` flag. > 🔄 **Already installed?** Update in one command: > ``` > Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/update.md > ``` -> 🛡️ **Worried about security?** Use safe mode — it won't auto-install system packages, it only tells you what you need: +> 🛡️ **Safe by default:** `agent-reach install` checks the machine without installing system packages or writing configuration: > ``` -> Install Agent Reach (safe mode): https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md -> Use the --safe flag during install +> Safely check and install Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md > ``` +> Use `agent-reach install --system` only after explicitly approving system changes.
Manual install @@ -166,10 +166,10 @@ npx skills add Panniantong/Agent-Reach@agent-reach After the Skill is installed, the Agent will auto-detect whether `agent-reach` CLI is available and install it if needed. -> If you install via `agent-reach install`, the skill is registered automatically — no extra steps needed. +> If you explicitly install external tools with `agent-reach install --system`, the skill is registered automatically. The default read-only check leaves existing files unchanged. > > Prefer an English-only skill file? Set an English locale or export `AGENT_REACH_LANG=en` -> before running `agent-reach install --env=auto` or `agent-reach skill --install`. +> before running `agent-reach install --env=auto --system` or `agent-reach skill --install`. > The installed file is always written as `SKILL.md`, so switching languages means rerunning > the install command with the new locale and replacing the previously installed skill file.
@@ -292,7 +292,7 @@ Each channel file **actually probes** its candidate backends in order (not just | GitHub | [gh CLI](https://cli.github.com) | — | Official tool, full API after auth | | Read RSS | [feedparser](https://github.com/kurtmckee/feedparser) | — | Python ecosystem standard | | XiaoHongShu | [OpenCLI](https://github.com/jackwener/opencli) (desktop) | [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) (server) ▸ xhs-cli | OpenCLI uses only an existing user-controlled session; other backends use a manual Cookie-Editor export | -| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | Jina Reader | MCP server, browser automation | +| LinkedIn | [mcp-server-linkedin](https://github.com/stickerdaniel/linkedin-mcp-server) | Jina Reader | MCP server, browser automation | | Xiaoyuzhou Podcast | `transcribe.sh` | — | `bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh ` | > 📌 These are the *current* choices, re-verified regularly on real machines. When a path dies we switch to the next — `agent-reach doctor` always tells you which one is active. @@ -301,7 +301,7 @@ Each channel file **actually probes** its candidate backends in order (not just ## Credits -[twitter-cli](https://github.com/public-clis/twitter-cli) · [rdt-cli](https://github.com/public-clis/rdt-cli) · [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) · [bili-cli](https://github.com/public-clis/bilibili-cli) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [Jina Reader](https://github.com/jina-ai/reader) · [Exa](https://exa.ai) · [mcporter](https://github.com/nicobailon/mcporter) · [feedparser](https://github.com/kurtmckee/feedparser) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) +[twitter-cli](https://github.com/public-clis/twitter-cli) · [rdt-cli](https://github.com/public-clis/rdt-cli) · [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) · [bili-cli](https://github.com/public-clis/bilibili-cli) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [Jina Reader](https://github.com/jina-ai/reader) · [Exa](https://exa.ai) · [mcporter](https://github.com/nicobailon/mcporter) · [feedparser](https://github.com/kurtmckee/feedparser) · [mcp-server-linkedin](https://github.com/stickerdaniel/linkedin-mcp-server) ## Contact diff --git a/docs/README_ja.md b/docs/README_ja.md index 1cc7be03..1f4cab72 100644 --- a/docs/README_ja.md +++ b/docs/README_ja.md @@ -100,7 +100,8 @@ Install Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/m ```bash pip install https://github.com/Panniantong/agent-reach/archive/main.zip -agent-reach install --env=auto +agent-reach install --env=auto # 読み取り専用チェック(デフォルト) +agent-reach install --env=auto --system # システム変更を明示的に許可した場合のみ ```
@@ -113,7 +114,7 @@ npx skills add Panniantong/Agent-Reach@agent-reach Skillインストール後、エージェントは`agent-reach` CLIが利用可能かを自動検出し、必要に応じてインストールします。 -> `agent-reach install` でインストールした場合、Skillは自動的に登録されます — 追加の手順は不要です。 +> `agent-reach install --system` を明示的に許可した場合のみ、Skill は自動登録されます。デフォルトの `agent-reach install` は読み取り専用です。 --- @@ -224,7 +225,7 @@ channels/ | GitHub | [gh CLI](https://cli.github.com) | 公式ツール、認証後フルAPI | | RSS閲覧 | [feedparser](https://github.com/kurtmckee/feedparser) | Pythonエコシステムの標準、⭐2.3K | | 小紅書 | [OpenCLI](https://github.com/jackwener/opencli)(デスクトップ)▸ [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp)(サーバー)▸ xhs-cli | OpenCLI は既存のユーザー管理セッションのみ使用。その他は Cookie-Editor で手動設定 | -| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | ⭐900+、MCPサーバー、ブラウザ自動化 | +| LinkedIn | [mcp-server-linkedin](https://github.com/stickerdaniel/linkedin-mcp-server) | ⭐900+、MCPサーバー、ブラウザ自動化 | | WeChat記事 | [Exa](https://exa.ai)(検索+閲覧)+ [Camoufox](https://github.com/daijro/camoufox)(オプション) | ゼロ設定で検索+全文閲覧、Camoufoxでオプション強化 | | 小宇宙Podcast | `transcribe.sh` | `bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh ` | @@ -249,7 +250,7 @@ channels/
Twitter/X APIに課金せずにAIエージェントで検索するには? -Agent Reach は [twitter-cli](https://github.com/public-clis/twitter-cli) をCookie認証で使用します。Cookie-Editor で手動エクスポートし、`agent-reach configure twitter-cookies "your_cookies"` で Agent Reach に保存します。これは doctor の設定確認用であり、doctor は上流の認証をリアルタイム検証しません。`twitter search "query" -n 10` を直接実行するプロセスには `TWITTER_AUTH_TOKEN` と `TWITTER_CT0` を明示的に渡してください。 +Agent Reach は [twitter-cli](https://github.com/public-clis/twitter-cli) をCookie認証で使用します。Cookie-Editor で手動エクスポートし、`agent-reach configure twitter-cookies` の非表示入力で Agent Reach に保存します。これは doctor の設定確認用であり、doctor は上流の認証をリアルタイム検証しません。`twitter search "query" -n 10` を直接実行するプロセスには `TWITTER_AUTH_TOKEN` と `TWITTER_CT0` を明示的に渡してください。
@@ -267,7 +268,7 @@ Agent Reach は [rdt-cli](https://github.com/public-clis/rdt-cli) でRedditに
Agent Reach は Claude Code / Cursor / Windsurf / OpenClaw で動作する? -はい!Agent Reach はインストーラー + 設定ツールです。シェルコマンドを実行できるあらゆるAIコーディングエージェントで使用できます — Claude Code、Cursor、Windsurf、OpenClaw、Codex等。`pip install https://github.com/Panniantong/agent-reach/archive/main.zip` の後に `agent-reach install` を実行すると、エージェントはすぐに上流ツールを使い始められます。PyPI の同名パッケージは別プロジェクトです。 +はい!Agent Reach はインストーラー + 設定ツールです。シェルコマンドを実行できるあらゆるAIコーディングエージェントで使用できます — Claude Code、Cursor、Windsurf、OpenClaw、Codex等。`pip install https://github.com/Panniantong/agent-reach/archive/main.zip` の後、まず `agent-reach install` で読み取り専用チェックを行い、システム変更を明示的に許可した場合だけ `agent-reach install --system` を実行します。PyPI の同名パッケージは別プロジェクトです。
@@ -292,7 +293,7 @@ Agent Reach は小紅書へのログインを代行せず、ブラウザ Cookie ## クレジット -[twitter-cli](https://github.com/public-clis/twitter-cli) · [rdt-cli](https://github.com/public-clis/rdt-cli) · [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) · [Jina Reader](https://github.com/jina-ai/reader) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [Exa](https://exa.ai) · [feedparser](https://github.com/kurtmckee/feedparser) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) +[twitter-cli](https://github.com/public-clis/twitter-cli) · [rdt-cli](https://github.com/public-clis/rdt-cli) · [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) · [Jina Reader](https://github.com/jina-ai/reader) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [Exa](https://exa.ai) · [feedparser](https://github.com/kurtmckee/feedparser) · [mcp-server-linkedin](https://github.com/stickerdaniel/linkedin-mcp-server) ## お問い合わせ diff --git a/docs/README_ko.md b/docs/README_ko.md index d8a7c118..98b4df3b 100644 --- a/docs/README_ko.md +++ b/docs/README_ko.md @@ -100,7 +100,8 @@ Install Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/m ```bash pip install https://github.com/Panniantong/agent-reach/archive/main.zip -agent-reach install --env=auto +agent-reach install --env=auto # 읽기 전용 확인 (기본값) +agent-reach install --env=auto --system # 시스템 변경을 명시적으로 승인한 경우에만 ```
@@ -113,7 +114,7 @@ npx skills add Panniantong/Agent-Reach@agent-reach Skill이 설치된 후, 에이전트는 `agent-reach` CLI 사용 가능 여부를 자동 감지하고 필요한 경우 설치합니다. -> `agent-reach install`을 통해 설치하면 Skill이 자동으로 등록됩니다 — 추가 단계 불필요. +> `agent-reach install --system`을 명시적으로 승인한 경우에만 Skill이 자동 등록됩니다. 기본 `agent-reach install`은 읽기 전용입니다.
--- @@ -225,7 +226,7 @@ channels/ | GitHub | [gh CLI](https://cli.github.com) | 공식 도구, 인증 후 전체 API | | RSS 읽기 | [feedparser](https://github.com/kurtmckee/feedparser) | Python 생태계 표준, 2.3K stars | | XiaoHongShu | [OpenCLI](https://github.com/jackwener/opencli) (데스크톱) ▸ [xiaohongshu-mcp](https://github.com/xpzouying/xiaohongshu-mcp) (서버) ▸ xhs-cli | OpenCLI는 사용자가 관리하는 기존 세션만 사용하며, 그 외에는 Cookie-Editor로 수동 설정 | -| LinkedIn | [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) | 1.2K stars, MCP 서버, 브라우저 자동화 | +| LinkedIn | [mcp-server-linkedin](https://github.com/stickerdaniel/linkedin-mcp-server) | 1.2K stars, MCP 서버, 브라우저 자동화 | | WeChat Articles | [Exa](https://exa.ai) (검색 + 읽기) + [Camoufox](https://github.com/daijro/camoufox) (선택) | 설정 없이 검색 + 전체 글 읽기 | | Xiaoyuzhou Podcast | `transcribe.sh` | `bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh ` | @@ -250,7 +251,7 @@ channels/
AI 에이전트로 Twitter/X를 API 비용 없이 검색하는 방법? -Agent Reach는 cookie 기반 인증을 사용하는 [twitter-cli](https://github.com/public-clis/twitter-cli)를 사용합니다. Cookie-Editor로 수동 내보낸 뒤 `agent-reach configure twitter-cookies "..."`로 저장합니다. 이 값은 doctor의 설정 확인용이며 실시간 인증 성공을 뜻하지 않습니다. `twitter search "query" -n 10`을 직접 실행하는 프로세스에는 `TWITTER_AUTH_TOKEN`과 `TWITTER_CT0`를 명시적으로 전달해야 합니다. +Agent Reach는 cookie 기반 인증을 사용하는 [twitter-cli](https://github.com/public-clis/twitter-cli)를 사용합니다. Cookie-Editor로 수동 내보낸 뒤 `agent-reach configure twitter-cookies`의 숨김 입력으로 저장합니다. 이 값은 doctor의 설정 확인용이며 실시간 인증 성공을 뜻하지 않습니다. `twitter search "query" -n 10`을 직접 실행하는 프로세스에는 `TWITTER_AUTH_TOKEN`과 `TWITTER_CT0`를 명시적으로 전달해야 합니다.
@@ -268,7 +269,7 @@ Agent Reach는 Reddit을 위해 [rdt-cli](https://github.com/public-clis/rdt-cli
Agent Reach는 Claude Code / Cursor / Windsurf / OpenClaw와 호환되나요? -네! Agent Reach는 설치 + 설정 도구입니다. Shell 명령을 실행할 수 있는 모든 AI 코딩 에이전트가 사용할 수 있습니다 — Claude Code, Cursor, Windsurf, OpenClaw, Codex 등. `pip install https://github.com/Panniantong/agent-reach/archive/main.zip` 실행 후 `agent-reach install`을 실행하면, 에이전트가 즉시 업스트림 도구 사용을 시작할 수 있습니다. PyPI의 동명 패키지는 다른 프로젝트입니다. +네! Agent Reach는 설치 + 설정 도구입니다. Shell 명령을 실행할 수 있는 모든 AI 코딩 에이전트가 사용할 수 있습니다 — Claude Code, Cursor, Windsurf, OpenClaw, Codex 등. `pip install https://github.com/Panniantong/agent-reach/archive/main.zip` 실행 후 먼저 `agent-reach install`로 읽기 전용 검사를 하고, 시스템 변경을 명시적으로 승인한 경우에만 `agent-reach install --system`을 실행합니다. PyPI의 동명 패키지는 다른 프로젝트입니다.
@@ -293,7 +294,7 @@ Agent Reach는 XiaoHongShu 로그인을 대신 수행하거나 브라우저 cook ## 크레딧 -[twitter-cli](https://github.com/public-clis/twitter-cli) · [rdt-cli](https://github.com/public-clis/rdt-cli) · [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) · [bili-cli](https://github.com/public-clis/bilibili-cli) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [Jina Reader](https://github.com/jina-ai/reader) · [Exa](https://exa.ai) · [mcporter](https://github.com/nicobailon/mcporter) · [feedparser](https://github.com/kurtmckee/feedparser) · [linkedin-scraper-mcp](https://github.com/stickerdaniel/linkedin-mcp-server) +[twitter-cli](https://github.com/public-clis/twitter-cli) · [rdt-cli](https://github.com/public-clis/rdt-cli) · [xhs-cli](https://github.com/jackwener/xiaohongshu-cli) · [bili-cli](https://github.com/public-clis/bilibili-cli) · [yt-dlp](https://github.com/yt-dlp/yt-dlp) · [Jina Reader](https://github.com/jina-ai/reader) · [Exa](https://exa.ai) · [mcporter](https://github.com/nicobailon/mcporter) · [feedparser](https://github.com/kurtmckee/feedparser) · [mcp-server-linkedin](https://github.com/stickerdaniel/linkedin-mcp-server) ## 연락처 diff --git a/docs/cookie-export.md b/docs/cookie-export.md index c384aa8c..5f7fb98c 100644 --- a/docs/cookie-export.md +++ b/docs/cookie-export.md @@ -18,10 +18,14 @@ Here's how to export cookies from your local computer — **fastest method first That's it! Your Agent will run: ```bash -agent-reach configure twitter-cookies -agent-reach configure xhs-cookies +agent-reach configure twitter-cookies +agent-reach configure xhs-cookies ``` +Both commands use a hidden prompt. For non-interactive automation, send the +same exported value through stdin and add `--stdin`; never place cookies in +the process arguments. + Twitter values saved by Agent Reach are used by `agent-reach doctor` only to check whether explicit credentials are present. Doctor does not run `twitter status`. Direct `twitter` commands still require diff --git a/docs/install.md b/docs/install.md index 67de80c0..4c15e79d 100644 --- a/docs/install.md +++ b/docs/install.md @@ -8,11 +8,11 @@ Copy this to your AI Agent: 帮我安装 Agent Reach:https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md ``` -> 🛡️ **Security-conscious?** Use safe mode — it won't auto-install system packages: +> 🛡️ **Safe by default:** `agent-reach install` only checks the machine and lists missing dependencies: > ``` -> 帮我安装 Agent Reach(安全模式):https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md -> 安装时使用 --safe 参数 +> 帮我安全检查并安装 Agent Reach:https://raw.githubusercontent.com/Panniantong/agent-reach/main/docs/install.md > ``` +> Only use `--system` after the user explicitly approves system-level installs and configuration writes. --- @@ -51,14 +51,18 @@ All Agent Reach files go in dedicated directories — **never in the agent works ```bash # 推荐:pipx(最省心) pipx install https://github.com/Panniantong/agent-reach/archive/main.zip -agent-reach install --env=auto +agent-reach install --env=auto # Read-only check (default) +# After the user explicitly approves system changes: +agent-reach install --env=auto --system # 如果你的 Python 来自 Homebrew / 遇到 PEP 668(externally-managed-environment) # 用虚拟环境安装: python3 -m venv ~/.agent-reach-venv source ~/.agent-reach-venv/bin/activate pip install https://github.com/Panniantong/agent-reach/archive/main.zip -agent-reach install --env=auto +agent-reach install --env=auto # Read-only check (default) +# After the user explicitly approves system changes: +agent-reach install --env=auto --system ``` > 💡 **Windows / Microsoft Store Python alias?** @@ -74,18 +78,20 @@ agent-reach install --env=auto > agent-reach install --env=auto > ``` -This installs core infrastructure (gh CLI, Node.js, mcporter, Exa search, yt-dlp config) and activates these zero-config channels: +The default command checks core infrastructure (gh CLI, Node.js, mcporter, Exa search, yt-dlp config) without changing the host. With explicit `--system` approval it installs/configures the missing pieces and activates these zero-config channels: - Web (Jina Reader), YouTube, GitHub, RSS, Exa Search, V2EX, Bilibili (basic) > 💡 **macOS / Homebrew Python 提示 `externally-managed-environment`?** > 这是 PEP 668 保护,不是 Agent Reach 本身的问题。优先用 `pipx install ...`,或先创建 `venv` 再安装。 -**Safe mode / Dry run:** +**Install modes:** ```bash -agent-reach install --env=auto --safe # Check only, no auto-install -agent-reach install --env=auto --dry-run # Preview what would be done +agent-reach install --env=auto # Check only; safe default +agent-reach install --env=auto --safe # Same check-only behavior (compatibility) +agent-reach install --env=auto --system # Explicitly allow external/system installs +agent-reach install --env=auto --dry-run # Preview what --system would do ``` ### Step 2: Ask the user which optional channels they want @@ -112,9 +118,9 @@ After installing the basics, **ask the user** which additional channels they nee Based on the user's choice, run: ```bash -agent-reach install --env=auto --channels=opencli,xiaohongshu # Example: desktop user chose XHS (OpenCLI-backed) -agent-reach install --env=auto --channels=facebook,instagram # Example: desktop user chose Meta social channels -agent-reach install --env=auto --channels=all # User wants everything +agent-reach install --env=auto --system --channels=opencli,xiaohongshu # Desktop user chose XHS +agent-reach install --env=auto --system --channels=facebook,instagram # Desktop Meta channels +agent-reach install --env=auto --system --channels=all # User approved everything ``` Supported channel names: `opencli`, `twitter`, `xiaoyuzhou`, `xueqiu`, `xiaohongshu`, `reddit`, `facebook`, `instagram`, `bilibili`, `linkedin`, `all` @@ -149,7 +155,7 @@ Some channels need credentials only the user can provide. Based on the doctor ou > "To unlock Twitter search, I need your Twitter cookies. Install the Cookie-Editor Chrome extension, go to x.com/twitter.com, click the extension → Export → Header String, and paste it to me." ```bash -agent-reach configure twitter-cookies "PASTED_STRING" +agent-reach configure twitter-cookies ``` 这会把 `twitter_auth_token` 和 `twitter_ct0` 保存给 Agent Reach 自己的 @@ -167,7 +173,7 @@ twitter search "query" -n 10 > twitter-cli 和 rdt-cli 使用 Python,在需要代理的网络环境下可通过环境变量配置代理。 > > **你(Agent)需要做的:** -> 1. 确认用户配了代理:`agent-reach configure proxy http://user:pass@ip:port` +> 1. 确认用户配了代理:`agent-reach configure proxy`(隐藏输入) > 2. 设置环境变量:`export HTTP_PROXY="..." HTTPS_PROXY="..."` > 3. Agent Reach 会自动处理剩下的,不需要用户做额外操作 > @@ -184,7 +190,7 @@ rdt login # 自动提取浏览器 Cookie;服务器无浏览器时按 doctor > 中国大陆访问 Reddit 需要代理;服务器 IP 被风控时可配住宅代理(如 https://webshare.io,约 $1/月): > ```bash -> agent-reach configure proxy http://user:pass@ip:port +> agent-reach configure proxy > ``` **XiaoHongShu / 小红书(多后端,按环境选):** @@ -196,7 +202,7 @@ rdt login # 自动提取浏览器 Cookie;服务器无浏览器时按 doctor > xiaohongshu-mcp 或存量工具: > > ```bash -> agent-reach configure xhs-cookies "key1=val1; key2=val2; ..." +> agent-reach configure xhs-cookies > ``` > > 该显式命令会保存/导入用户提供的 xiaohongshu.com 同域 Cookie 集;请先确认 @@ -205,7 +211,7 @@ rdt login # 自动提取浏览器 Cookie;服务器无浏览器时按 doctor > **桌面电脑(推荐 OpenCLI):** ```bash -agent-reach install --channels opencli +agent-reach install --system --channels opencli ``` > 装完后引导用户做唯一一步手动操作(Chrome 安全限制,无法代劳): @@ -231,7 +237,7 @@ agent-reach install --channels opencli > 这两个平台走 OpenCLI:复用用户自己的 Chrome 登录态,不保存账号密码,不走 Meta Graph API 审批流。服务器/无桌面环境不推荐支持。 ```bash -agent-reach install --channels facebook,instagram +agent-reach install --system --channels facebook,instagram ``` > 装完后: @@ -264,7 +270,7 @@ agent-reach configure --from-browser chrome --platform xueqiu 脚本已随 Agent Reach 自动安装,用户只需提供 Key: ```bash -agent-reach configure groq-key gsk_xxxxx +agent-reach configure groq-key ``` > **获取 Groq API Key(免费、无需信用卡、30 秒搞定):** @@ -287,42 +293,24 @@ agent-reach configure groq-key gsk_xxxxx > - 转录质量高(Whisper large-v3),但不区分说话人 > - 2 小时以上的播客建议分批处理 -**LinkedIn (可选 — linkedin-scraper-mcp):** -> "LinkedIn 基本内容可通过 Jina Reader 读取。完整功能(Profile 详情、职位搜索)需要 linkedin-scraper-mcp。" +**LinkedIn (可选 — mcp-server-linkedin):** +> "LinkedIn 基本内容可通过 Jina Reader 读取。完整功能(Profile 详情、人才与职位搜索)需要 mcp-server-linkedin。" -```bash -pip install linkedin-scraper-mcp -``` - -> **登录方式(需要浏览器界面):** +> **配置方式(推荐 stdio):** +> 先按官方说明安装 `uv`(会同时提供 `uvx`): +> https://docs.astral.sh/uv/getting-started/installation/ > -> linkedin-scraper-mcp 使用 Chromium 浏览器登录,需要你能看到浏览器窗口。 -> -> - **本地电脑(有桌面):** 直接运行: -> ```bash -> linkedin-scraper-mcp --login --no-headless -> ``` -> 浏览器会弹出来,手动登录 LinkedIn 即可。 +> ```bash +> mcporter config add linkedin --command uvx --arg mcp-server-linkedin@latest --env UV_HTTP_TIMEOUT=300 --scope home +> ``` > -> - **服务器(无 UI):** 需要通过 VNC 远程桌面操作: -> ```bash -> # 1. 服务器上安装并启动 VNC(如已有可跳过) -> apt install -y tigervnc-standalone-server -> vncserver :1 -geometry 1280x720 -> -> # 2. 用 VNC 客户端连接 服务器IP:5901 -> -> # 3. 在 VNC 桌面的终端里运行: -> export DISPLAY=:1 -> linkedin-scraper-mcp --login --no-headless -> ``` -> 在 VNC 里看到浏览器后手动登录。登录成功后 session 会保存到 `~/.linkedin-mcp/profile/`。 +> `uvx` 会按需获取并启动最新版服务,无需另装 Python 包或常驻 HTTP 服务。 > -> **登录后启动 MCP 服务:** +> **首次登录(需要浏览器界面):** > ```bash -> linkedin-scraper-mcp --transport streamable-http --port 8001 -> mcporter config add linkedin http://localhost:8001/mcp --scope home +> uvx mcp-server-linkedin@latest --login > ``` +> 浏览器弹出后手动登录 LinkedIn;登录态会保存到 `~/.linkedin-mcp/profile/`。无桌面的服务器需在 VNC 等可见桌面中运行同一条登录命令。 > > 详见 https://github.com/stickerdaniel/linkedin-mcp-server @@ -353,17 +341,18 @@ If the user wants a different agent to handle it, let them choose. | Command | What it does | |---------|-------------| -| `agent-reach install --env=auto` | Install core channels (lightweight, zero-config) | -| `agent-reach install --env=auto --channels=twitter,xiaohongshu` | Install core + optional channels | -| `agent-reach install --env=auto --channels=all` | Install everything | -| `agent-reach install --env=auto --safe` | Safe setup (no auto system changes) | +| `agent-reach install --env=auto` | Read-only dependency and channel check (default) | +| `agent-reach install --env=auto --system` | Explicitly install/configure core external tools | +| `agent-reach install --env=auto --system --channels=twitter,xiaohongshu` | Install approved optional channels | +| `agent-reach install --env=auto --system --channels=all` | Install everything after explicit approval | +| `agent-reach install --env=auto --safe` | Compatibility alias for the safe default | | `agent-reach install --env=auto --dry-run` | Preview what would be done | | `agent-reach doctor` | Show channel status | | `agent-reach watch` | Quick health + update check (for scheduled tasks) | | `agent-reach check-update` | Check for new versions | -| `agent-reach configure twitter-cookies "..."` | 保存 Twitter Cookie 供 doctor 检查;直接调用仍需显式环境变量 | -| `agent-reach configure proxy URL` | 保存代理地址(Agent 访问 Reddit/Twitter 等受限网络时读取它设置 HTTP_PROXY/HTTPS_PROXY,不是自动解锁开关) | -| `agent-reach configure groq-key gsk_xxx` | Unlock Xiaoyuzhou podcast transcription | +| `agent-reach configure twitter-cookies` | 通过隐藏输入保存 Twitter Cookie;直接调用仍需显式环境变量 | +| `agent-reach configure proxy` | 通过隐藏输入保存代理地址;不是自动解锁开关 | +| `agent-reach configure groq-key` | 通过隐藏输入配置小宇宙转录 Key | After installation, use upstream tools directly. See SKILL.md for the full command reference: diff --git a/docs/update.md b/docs/update.md index 17836a6f..2ef00bb9 100644 --- a/docs/update.md +++ b/docs/update.md @@ -75,7 +75,7 @@ by the user. The update must never log the user in or read browser cookies: > 『添加扩展』。小红书 OpenCLI 只使用你已有的 Chrome 会话;如果没有现成 > 会话,我不会自动登录,会改用 Cookie-Editor 配置 MCP / 存量工具。" -If yes: `agent-reach install --channels opencli` and guide them through the +If yes: `agent-reach install --system --channels opencli` and guide them through the extension click. If no, everything keeps working on existing backends. ### Step 4: Coexistence (DO NOT uninstall old tools) diff --git a/test.sh b/test.sh index ccbc6613..6910dc07 100644 --- a/test.sh +++ b/test.sh @@ -1,92 +1,70 @@ #!/bin/bash -# Agent Reach 一键完整测试 -# 用法: bash test-agent-reach.sh -# 在任何有 Python 3.10+ 的机器上跑就行 +# Agent Reach clean-environment integration test. +# Creates an isolated venv, installs this checkout, runs a read-only doctor, +# then executes the repository test suite. -set -e +set -euo pipefail -echo "╔════════════════════════════════════════════╗" -echo "║ 👁️ Agent Reach 完整测试 ║" -echo "╚════════════════════════════════════════════╝" -echo "" +REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +TEST_DIR=$(mktemp -d "${TMPDIR:-/tmp}/agent-reach-test.XXXXXX") +TEST_DIR=$(cd "$TEST_DIR" && pwd -P) +export HOME="$TEST_DIR/home" +export XDG_CONFIG_HOME="$HOME/.config" +export PYTHONDONTWRITEBYTECODE=1 +mkdir -p "$HOME" "$XDG_CONFIG_HOME" -# ── 1. 准备干净环境 ── -echo "📦 创建测试环境..." -TEST_DIR=$(mktemp -d) -python3 -m venv "$TEST_DIR/venv" -source "$TEST_DIR/venv/bin/activate" - -# ── 2. 安装 ── -echo "📥 从 GitHub 安装..." -pip install -q https://github.com/Panniantong/agent-reach/archive/main.zip 2>&1 | tail -1 -echo "" - -# ── 3. 自动配置 ── -echo "⚙️ 运行 install..." -agent-reach install --env=auto 2>&1 -echo "" - -# ── 4. 诊断 ── -echo "🩺 运行 doctor..." -agent-reach doctor 2>&1 -echo "" +cleanup() { + rm -rf -- "$TEST_DIR" +} +trap cleanup EXIT INT TERM -# ── 5. 逐个测试 ── -PASS=0 -FAIL=0 -SKIP=0 +PYTHON_CMD=() +if command -v python3 >/dev/null 2>&1 && \ + python3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)'; then + PYTHON_CMD=(python3) +elif command -v python >/dev/null 2>&1 && \ + python -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)'; then + PYTHON_CMD=(python) +elif command -v py >/dev/null 2>&1 && \ + py -3 -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)'; then + PYTHON_CMD=(py -3) +elif [ -x "$REPO_ROOT/.venv/bin/python" ] && \ + "$REPO_ROOT/.venv/bin/python" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)'; then + PYTHON_CMD=("$REPO_ROOT/.venv/bin/python") +elif [ -x "$REPO_ROOT/.venv/Scripts/python.exe" ] && \ + "$REPO_ROOT/.venv/Scripts/python.exe" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3, 10) else 1)'; then + PYTHON_CMD=("$REPO_ROOT/.venv/Scripts/python.exe") +else + echo "Python 3.10+ is required" >&2 + exit 1 +fi -test_it() { - local name="$1" - shift - echo -n " $name ... " - output=$(eval "$@" 2>&1) || true - if echo "$output" | grep -q "📖\|🔗\|http"; then - echo "✅" - PASS=$((PASS+1)) - elif echo "$output" | grep -q "⚠️\|not installed\|not configured"; then - echo "⏭️ (跳过 — 缺依赖)" - SKIP=$((SKIP+1)) - else - echo "❌" - echo " $(echo "$output" | head -2)" - FAIL=$((FAIL+1)) - fi -} +echo "[1/5] Creating isolated environment" +"${PYTHON_CMD[@]}" -m venv "$TEST_DIR/venv" +# shellcheck disable=SC1091 +if [ -f "$TEST_DIR/venv/bin/activate" ]; then + source "$TEST_DIR/venv/bin/activate" +elif [ -f "$TEST_DIR/venv/Scripts/activate" ]; then + source "$TEST_DIR/venv/Scripts/activate" +else + echo "Could not find the virtual environment activation script" >&2 + exit 1 +fi -echo "📖 阅读测试" -test_it "网页" "agent-reach read 'https://example.com'" -test_it "GitHub" "agent-reach read 'https://github.com/Panniantong/agent-reach'" -test_it "YouTube" "agent-reach read 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'" -test_it "B站" "agent-reach read 'https://www.bilibili.com/video/BV1d4411N7zD'" -test_it "RSS" "agent-reach read 'https://hnrss.org/frontpage'" -test_it "Twitter" "agent-reach read 'https://x.com/elonmusk/status/1893797839927353448'" -test_it "Reddit" "agent-reach read 'https://www.reddit.com/r/LocalLLaMA/hot'" +echo "[2/5] Installing this checkout and test dependencies" +python -m pip install --quiet --upgrade pip +python -m pip install --quiet -c "$REPO_ROOT/constraints.txt" -e "$REPO_ROOT[dev]" -echo "" -echo "🔍 搜索测试" -test_it "全网搜索" "agent-reach search 'best AI agent framework' -n 2" -test_it "GitHub搜索" "agent-reach search-github 'yt-dlp' -n 2" -test_it "Twitter搜索" "agent-reach search-twitter 'AI agent' -n 2" -test_it "Reddit搜索" "agent-reach search-reddit 'machine learning' -n 2" -test_it "YouTube搜索" "agent-reach search-youtube 'AI tutorial' -n 2" -test_it "B站搜索" "agent-reach search-bilibili 'AI' -n 2" -test_it "小红书搜索" "agent-reach search-xhs 'AI' -n 2" +echo "[3/5] Verifying the installed CLI" +agent-reach version -echo "" -echo "════════════════════════════════════════════" -echo " ✅ 通过: $PASS ❌ 失败: $FAIL ⏭️ 跳过: $SKIP" -echo "════════════════════════════════════════════" +echo "[4/5] Running read-only install check and doctor" +agent-reach install --env=auto --safe +agent-reach install --env=auto --system --dry-run +agent-reach doctor --json > "$TEST_DIR/doctor.json" +python -c 'import json,sys; data=json.load(open(sys.argv[1], encoding="utf-8")); assert isinstance(data, dict) and data, "doctor returned no channels"; print(f"doctor OK: {len(data)} channels")' "$TEST_DIR/doctor.json" -# ── 6. 清理 ── -deactivate 2>/dev/null || true -rm -rf "$TEST_DIR" +echo "[5/5] Running repository tests" +pytest "$REPO_ROOT/tests" -q -if [ $FAIL -eq 0 ]; then - echo "" - echo "🎉 全部通过!" -else - echo "" - echo "⚠️ 有 $FAIL 个测试失败,请检查上面的输出" - exit 1 -fi +echo "Agent Reach integration test passed" diff --git a/tests/test_auth_guidance_policy.py b/tests/test_auth_guidance_policy.py index 2a2dd8ca..f30c3e75 100644 --- a/tests/test_auth_guidance_policy.py +++ b/tests/test_auth_guidance_policy.py @@ -174,6 +174,28 @@ def test_public_guidance_never_installs_the_unrelated_pypi_package(): assert not violations, "\n".join(violations) +def test_public_guidance_never_puts_secrets_in_process_arguments(): + """Operational docs should use hidden prompts or stdin for credentials.""" + forbidden = ( + 'agent-reach configure twitter-cookies "', + "agent-reach configure twitter-cookies '", + 'agent-reach configure xhs-cookies "', + "agent-reach configure xhs-cookies '", + "agent-reach configure groq-key gsk_", + "agent-reach configure openai-key sk-", + "agent-reach configure github-token gh", + "agent-reach configure proxy http", + ) + violations = [] + for path in _policy_documents(): + text = path.read_text(encoding="utf-8") + for marker in forbidden: + if marker in text: + violations.append(f"{path.relative_to(ROOT)}: {marker}") + + assert not violations, "\n".join(violations) + + def test_skill_explains_unverified_backend_state(): """A null backend is an explicit safety state, not a routing instruction.""" skills = ( diff --git a/tests/test_channels.py b/tests/test_channels.py index 0897559c..a7cc31ea 100644 --- a/tests/test_channels.py +++ b/tests/test_channels.py @@ -89,7 +89,7 @@ def test_opencli_missing_reports_off(self, monkeypatch): status, msg = ch.check() assert status == "off" assert ch.active_backend is None - assert "agent-reach install --channels opencli" in msg + assert "agent-reach install --system --channels opencli" in msg assert "instagram.com" in msg def test_opencli_installed_without_extension_reports_warn(self, monkeypatch): @@ -127,7 +127,7 @@ def __enter__(self): def __exit__(self, *args): pass - def read(self): + def read(self, _size=-1): return b"[]" monkeypatch.setattr( @@ -227,7 +227,7 @@ def __enter__(self): def __exit__(self, *_): pass - def read(self): + def read(self, _size=-1): return json.dumps(fake_data).encode() monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=None: FakeResponse()) @@ -252,7 +252,7 @@ def test_get_hot_topics_respects_limit(self, monkeypatch): class FakeResponse: def __enter__(self): return self def __exit__(self, *_): pass - def read(self): return json.dumps(fake_data).encode() + def read(self, _size=-1): return json.dumps(fake_data).encode() monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=None: FakeResponse()) topics = V2EXChannel().get_hot_topics(limit=3) @@ -270,7 +270,7 @@ def test_get_hot_topics_truncates_content(self, monkeypatch): class FakeResponse: def __enter__(self): return self def __exit__(self, *_): pass - def read(self): return json.dumps(fake_data).encode() + def read(self, _size=-1): return json.dumps(fake_data).encode() monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=None: FakeResponse()) topics = V2EXChannel().get_hot_topics(limit=1) @@ -298,7 +298,7 @@ def test_get_node_topics(self, monkeypatch): class FakeResponse: def __enter__(self): return self def __exit__(self, *_): pass - def read(self): return json.dumps(fake_data).encode() + def read(self, _size=-1): return json.dumps(fake_data).encode() monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=None: FakeResponse()) topics = V2EXChannel().get_node_topics("python") @@ -346,7 +346,7 @@ def __init__(self, payload): def __enter__(self): return self def __exit__(self, *_): pass - def read(self): return json.dumps(self._payload).encode() + def read(self, _size=-1): return json.dumps(self._payload).encode() def fake_urlopen(req, timeout=None): url = req.full_url @@ -385,7 +385,7 @@ class FakeResponse: def __init__(self, payload): self._payload = payload def __enter__(self): return self def __exit__(self, *_): pass - def read(self): return json.dumps(self._payload).encode() + def read(self, _size=-1): return json.dumps(self._payload).encode() def fake_urlopen(req, timeout=None): if "replies" in req.full_url: @@ -421,7 +421,7 @@ def test_get_user_returns_profile(self, monkeypatch): class FakeResponse: def __enter__(self): return self def __exit__(self, *_): pass - def read(self): return json.dumps(fake_user).encode() + def read(self, _size=-1): return json.dumps(fake_user).encode() monkeypatch.setattr(urllib.request, "urlopen", lambda req, timeout=None: FakeResponse()) user = V2EXChannel().get_user("alice") @@ -1450,6 +1450,62 @@ def test_hosts_metadata_refuses_ancestor_symlink( class TestLinkedInChannel: + def test_setup_hint_uses_current_stdio_contract(self, monkeypatch): + monkeypatch.setattr(shutil, "which", lambda _: None) + + from agent_reach.channels.linkedin import LinkedInChannel + + channel = LinkedInChannel() + status, message = channel.check() + + assert status == "off" + assert channel.backends[0] == "mcp-server-linkedin" + assert "docs.astral.sh/uv/getting-started/installation" in message + assert "uvx mcp-server-linkedin@latest --login" in message + assert ( + "mcporter config add linkedin --command uvx " + "--arg mcp-server-linkedin@latest --env UV_HTTP_TIMEOUT=300 " + "--scope home" + ) in message + assert "pip install linkedin-scraper-mcp" not in message + assert "localhost:3000/mcp" not in message + + def test_configured_linkedin_warns_when_uvx_is_missing( + self, monkeypatch, tmp_path + ): + monkeypatch.chdir(tmp_path) + config_path = tmp_path / "config" / "mcporter.json" + config_path.parent.mkdir() + config_path.write_text( + json.dumps( + { + "mcpServers": { + "linkedin": { + "command": "uvx", + "args": ["mcp-server-linkedin@latest"], + } + }, + "imports": [], + } + ), + encoding="utf-8", + ) + monkeypatch.setattr( + shutil, + "which", + lambda name: "/usr/local/bin/mcporter" + if name == "mcporter" + else None, + ) + + from agent_reach.channels.linkedin import LinkedInChannel + + status, message = LinkedInChannel().check() + + assert status == "warn" + assert "uvx 未安装" in message + assert "docs.astral.sh/uv/getting-started/installation" in message + def test_mcporter_is_never_executed( self, monkeypatch, tmp_path ): @@ -1468,8 +1524,17 @@ def test_mcporter_is_never_executed( assert status == "off" assert ch.active_backend is None - def test_configured_linkedin_is_not_false_positive_active( - self, monkeypatch, tmp_path + @pytest.mark.parametrize( + "server_name", + [ + "linkedin", + "linkedin-scraper", + "linkedin-scraper-mcp", + "mcp-server-linkedin", + ], + ) + def test_configured_linkedin_name_is_not_false_positive_active( + self, monkeypatch, tmp_path, server_name ): monkeypatch.chdir(tmp_path) config_path = tmp_path / "config" / "mcporter.json" @@ -1478,7 +1543,7 @@ def test_configured_linkedin_is_not_false_positive_active( json.dumps( { "mcpServers": { - "linkedin-scraper": {"command": "linkedin-mcp"} + server_name: {"command": "linkedin-mcp"} }, "imports": [], } diff --git a/tests/test_cli.py b/tests/test_cli.py index 6981957c..d5d3d80d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -103,6 +103,48 @@ def test_transcribe_command_writes_output_file(self, capsys, tmp_path): assert out_file.read_text(encoding="utf-8").strip() == "saved text" assert "Transcript written" in capsys.readouterr().out + def test_transcribe_provider_fallback_requires_explicit_flag(self): + with patch( + "agent_reach.transcribe.transcribe", + return_value="hello transcript", + ) as mock_transcribe: + with patch( + "sys.argv", + [ + "agent-reach", + "transcribe", + "audio.mp3", + "--allow-provider-fallback", + ], + ): + main() + + mock_transcribe.assert_called_once_with( + "audio.mp3", + provider="auto", + allow_provider_fallback=True, + ) + + def test_transcribe_provider_fallback_rejects_explicit_provider(self, capsys): + with patch("agent_reach.transcribe.transcribe") as mock_transcribe: + with patch( + "sys.argv", + [ + "agent-reach", + "transcribe", + "audio.mp3", + "--provider", + "groq", + "--allow-provider-fallback", + ], + ): + with pytest.raises(SystemExit) as exc_info: + main() + + assert exc_info.value.code == 2 + assert "requires --provider auto" in capsys.readouterr().err + mock_transcribe.assert_not_called() + def test_parse_twitter_cookie_input_separate_values(self): auth_token, ct0 = cli._parse_twitter_cookie_input("token123 ct0abc") assert auth_token == "token123" @@ -166,7 +208,7 @@ def fake_run(cmd, **kwargs): cli._install_rdt_cli() out = capsys.readouterr().out - assert commands == [["pipx", "install", cli._RDT_GIT_SOURCE]] + assert commands == [["/usr/local/bin/pipx", "install", cli._RDT_GIT_SOURCE]] assert "✅ rdt-cli installed" in out def test_install_reddit_deps_routes_by_environment(self, monkeypatch): @@ -185,6 +227,35 @@ def test_install_reddit_deps_routes_by_environment(self, monkeypatch): cli._install_reddit_deps() assert calls == ["rdt"] + def test_install_opencli_uses_resolved_windows_npm_path(self, monkeypatch): + import agent_reach.backends as backends + from agent_reach.backends import OpenCLIStatus + + statuses = iter( + [ + OpenCLIStatus(installed=False), + OpenCLIStatus(installed=True, extension_connected=False), + ] + ) + calls = [] + monkeypatch.setattr(backends, "opencli_status", lambda: next(statuses)) + monkeypatch.setattr( + shutil, + "which", + lambda name: "C:/Tools/npm.CMD" if name == "npm" else None, + ) + monkeypatch.setattr( + subprocess, + "run", + lambda args, **_kwargs: calls.append(args) + or subprocess.CompletedProcess(args, 0, "", ""), + ) + + assert cli._install_opencli_deps() is True + assert calls == [ + ["C:/Tools/npm.CMD", "install", "-g", backends.OPENCLI_PACKAGE] + ] + def test_install_facebook_instagram_routes_to_opencli_once(self, monkeypatch, capsys): calls = [] @@ -212,6 +283,7 @@ def test_install_facebook_instagram_routes_to_opencli_once(self, monkeypatch, ca Namespace( env="auto", proxy="", + system=True, safe=False, dry_run=False, channels="facebook,instagram,opencli", @@ -228,6 +300,7 @@ def test_install_server_dry_run_skips_opencli_only_channels(self, monkeypatch, c Namespace( env="server", proxy="", + system=True, safe=False, dry_run=True, channels="facebook,instagram,opencli,bilibili", diff --git a/tests/test_config.py b/tests/test_config.py index 6ae4697c..51408a38 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -72,17 +72,53 @@ def test_get_configured_features(self, tmp_config): assert "exa_search" in features assert all(v is False for v in features.values()) - def test_to_dict_masks_sensitive(self, tmp_config): - tmp_config.set("exa_api_key", "super-secret-key-12345") + def test_to_dict_redacts_long_secret_without_leaking_prefix(self, tmp_config): + secret = "super-secret-key-12345" + tmp_config.set("exa_api_key", secret) tmp_config.set("normal_setting", "visible") masked = tmp_config.to_dict() - assert masked["exa_api_key"] == "super-se..." + + assert masked["exa_api_key"] == "[REDACTED]" + assert secret not in str(masked) assert masked["normal_setting"] == "visible" - def test_to_dict_masks_cookie_and_session_credentials(self, tmp_config): + @pytest.mark.parametrize( + "secret", + ( + "!", + "!@", + "!@#", + "!@#$", + "!@#$%", + "!@#$%^", + "!@#$%^&", + "!@#$%^&*", + ), + ) + def test_to_dict_redacts_short_secrets_without_leaking_value( + self, tmp_config, secret + ): + tmp_config.set("api_key", secret) + + masked = tmp_config.to_dict() + + assert masked["api_key"] == "[REDACTED]" + assert secret not in str(masked) + + @pytest.mark.parametrize("empty_value", (None, "")) + def test_to_dict_keeps_empty_sensitive_values_as_none( + self, tmp_config, empty_value + ): + tmp_config.set("api_key", empty_value) + + assert tmp_config.to_dict()["api_key"] is None + + def test_to_dict_redacts_sensitive_credential_markers(self, tmp_config): secrets = { "twitter_ct0": "csrf-secret-value", "xhs_cookie": "web_session=xhs-secret", + "browser_session": "browser-session-secret", + "https_proxy": "socks5://proxy.example:1080", "xueqiu_cookie": "xq_a_token=xueqiu-secret", "bilibili_sessdata": "bili-session-secret", "bilibili_csrf": "bili-csrf-secret", @@ -95,7 +131,8 @@ def test_to_dict_masks_cookie_and_session_credentials(self, tmp_config): masked = tmp_config.to_dict() dumped = str(masked) - for value in secrets.values(): + for key, value in secrets.items(): + assert masked[key] == "[REDACTED]" assert value not in dumped assert masked["normal_setting"] == "visible" diff --git a/tests/test_integration_script.py b/tests/test_integration_script.py new file mode 100644 index 00000000..061a450d --- /dev/null +++ b/tests/test_integration_script.py @@ -0,0 +1,36 @@ +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPT = ROOT / "test.sh" + + +def test_integration_script_has_valid_shell_syntax(): + subprocess.run(["bash", "-n", str(SCRIPT)], check=True) + + +def test_integration_script_exercises_the_current_cli_contract(): + text = SCRIPT.read_text(encoding="utf-8") + + assert 'pip install --quiet -c "$REPO_ROOT/constraints.txt"' in text + assert 'TEST_DIR=$(cd "$TEST_DIR" && pwd -P)' in text + assert 'export HOME="$TEST_DIR/home"' in text + assert "sys.version_info >= (3, 10)" in text + assert 'PYTHON_CMD=("$REPO_ROOT/.venv/bin/python")' in text + assert 'venv/Scripts/activate' in text + assert "agent-reach install --env=auto --safe" in text + assert "agent-reach install --env=auto --system --dry-run" in text + assert "agent-reach doctor --json" in text + assert 'pytest "$REPO_ROOT/tests" -q' in text + + nonexistent_commands = ( + "agent-reach read ", + "agent-reach search ", + "agent-reach search-github ", + "agent-reach search-twitter ", + "agent-reach search-reddit ", + "agent-reach search-youtube ", + "agent-reach search-bilibili ", + "agent-reach search-xhs ", + ) + assert not any(command in text for command in nonexistent_commands) diff --git a/tests/test_p0_cli.py b/tests/test_p0_cli.py index 1003b0e9..783ebd91 100644 --- a/tests/test_p0_cli.py +++ b/tests/test_p0_cli.py @@ -3,6 +3,7 @@ from __future__ import annotations +import io import json import subprocess import sys @@ -26,6 +27,147 @@ def set(self, key, value): self.data[key] = value +def test_configure_reads_secret_from_stdin_without_echoing_it( + monkeypatch, capsys +): + import agent_reach.config as config_module + + secret = "gsk-secret-from-stdin" + config = _MemoryConfig() + monkeypatch.setattr(config_module, "Config", lambda: config) + monkeypatch.setattr(cli, "_configure_logging", lambda _verbose=False: None) + monkeypatch.setattr(sys, "stdin", io.StringIO(secret + "\n")) + monkeypatch.setattr( + sys, + "argv", + ["agent-reach", "configure", "groq-key", "--stdin"], + ) + + cli.main() + + assert config.data["groq_api_key"] == secret + output = capsys.readouterr() + assert secret not in output.out + assert secret not in output.err + + +def test_configure_uses_hidden_prompt_when_no_value_is_given( + monkeypatch, capsys +): + import getpass + + import agent_reach.config as config_module + + class TtyInput(io.StringIO): + def isatty(self): + return True + + secret = "sk-secret-from-prompt" + config = _MemoryConfig() + monkeypatch.setattr(config_module, "Config", lambda: config) + monkeypatch.setattr(cli, "_configure_logging", lambda _verbose=False: None) + monkeypatch.setattr(sys, "stdin", TtyInput()) + monkeypatch.setattr(getpass, "getpass", lambda _prompt: secret) + monkeypatch.setattr( + sys, + "argv", + ["agent-reach", "configure", "openai-key"], + ) + + cli.main() + + assert config.data["openai_api_key"] == secret + output = capsys.readouterr() + assert secret not in output.out + assert secret not in output.err + + +def test_setup_uses_hidden_prompts_for_secrets(monkeypatch, capsys): + import getpass + import shutil + + import agent_reach.config as config_module + + github_secret = "ghp-secret-from-setup" + groq_secret = "gsk-secret-from-setup" + secrets = iter([github_secret, groq_secret]) + prompts = [] + config = _MemoryConfig() + config.config_path = Path("/tmp/agent-reach-test-config.yaml") + + monkeypatch.setattr(config_module, "Config", lambda: config) + monkeypatch.setattr(shutil, "which", lambda _name: None) + monkeypatch.setattr( + getpass, + "getpass", + lambda prompt: prompts.append(prompt) or next(secrets), + ) + + cli._cmd_setup() + + assert config.data["github_token"] == github_secret + assert config.data["groq_api_key"] == groq_secret + assert prompts == [ + " GITHUB_TOKEN (回车跳过): ", + " GROQ_API_KEY (回车跳过): ", + ] + output = capsys.readouterr() + assert github_secret not in output.out + assert github_secret not in output.err + assert groq_secret not in output.out + assert groq_secret not in output.err + + +def test_configure_positional_secret_warns_to_use_safe_input( + monkeypatch, capsys +): + import agent_reach.config as config_module + + config = _MemoryConfig() + monkeypatch.setattr(config_module, "Config", lambda: config) + monkeypatch.setattr(cli, "_configure_logging", lambda _verbose=False: None) + monkeypatch.setattr( + sys, + "argv", + ["agent-reach", "configure", "groq-key", "legacy-secret"], + ) + + cli.main() + + assert config.data["groq_api_key"] == "legacy-secret" + error = capsys.readouterr().err + assert "deprecated" in error.lower() + assert "--stdin" in error + assert "legacy-secret" not in error + + +def test_configure_rejects_stdin_combined_with_positional_value( + monkeypatch, capsys +): + monkeypatch.setattr( + cli, + "_cmd_configure", + lambda _args: pytest.fail("invalid input sources must not configure"), + ) + monkeypatch.setattr( + sys, + "argv", + [ + "agent-reach", + "configure", + "groq-key", + "legacy-secret", + "--stdin", + ], + ) + + with pytest.raises(SystemExit) as exc: + cli.main() + + assert exc.value.code == 2 + assert "--stdin cannot be combined" in capsys.readouterr().err + + def test_browser_cookie_import_requires_explicit_platform(monkeypatch): """A bare --from-browser must fail before any browser credential access.""" import agent_reach.config as config_module @@ -192,6 +334,7 @@ def test_install_does_not_implicitly_read_browser_cookies(monkeypatch, tmp_path, Namespace( env="local", proxy="", + system=True, safe=False, dry_run=False, channels="twitter", @@ -203,6 +346,25 @@ def test_install_does_not_implicitly_read_browser_cookies(monkeypatch, tmp_path, assert "Importing cookies from browser" not in output +def test_install_rejects_safe_and_system_together(monkeypatch, capsys): + monkeypatch.setattr( + cli, + "_cmd_install", + lambda _args: pytest.fail("conflicting install modes must not run"), + ) + monkeypatch.setattr( + sys, + "argv", + ["agent-reach", "install", "--safe", "--system"], + ) + + with pytest.raises(SystemExit) as exc: + cli.main() + + assert exc.value.code == 2 + assert "not allowed with argument" in capsys.readouterr().err + + def test_install_rejects_unknown_channel_before_side_effects( monkeypatch, capsys ): @@ -542,6 +704,230 @@ def fake_run(args, **_kwargs): assert not (tmp_path / ".legacy-config" / "config").exists() +def test_system_install_uses_existing_apt_without_remote_bootstrap( + monkeypatch, +): + """Linux installs only from an already-configured apt package manager.""" + import builtins + import io + import platform + import shutil + + commands = [] + system_source_writes = [] + real_open = builtins.open + + def fake_which(name): + if name == "apt-get": + return "/usr/bin/apt-get" + return None + + def fake_open(path, mode="r", *args, **kwargs): + if str(path).startswith(("/etc/apt/", "/usr/share/keyrings/")): + system_source_writes.append((str(path), mode)) + return io.StringIO() + return real_open(path, mode, *args, **kwargs) + + def fake_run(args, **_kwargs): + commands.append(args) + return subprocess.CompletedProcess(args, 0, stdout="", stderr="") + + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.setattr(shutil, "which", fake_which) + monkeypatch.setattr(builtins, "open", fake_open) + monkeypatch.setattr(subprocess, "run", fake_run) + + cli._install_system_deps() + + assert commands == [ + ["/usr/bin/apt-get", "update", "-qq"], + [ + "/usr/bin/apt-get", + "install", + "-y", + "-qq", + "gh", + "nodejs", + "npm", + ], + ] + assert system_source_writes == [] + + +def test_system_install_stops_after_failed_apt_update(monkeypatch, capsys): + """A failed apt index refresh blocks the package installation step.""" + import platform + import shutil + + commands = [] + monkeypatch.setattr(platform, "system", lambda: "Linux") + monkeypatch.setattr( + shutil, + "which", + lambda name: "/usr/bin/apt-get" if name == "apt-get" else None, + ) + + def fake_run(args, **_kwargs): + commands.append(args) + return subprocess.CompletedProcess(args, 1, stdout="", stderr="failed") + + monkeypatch.setattr(subprocess, "run", fake_run) + + cli._install_system_deps() + + assert commands == [["/usr/bin/apt-get", "update", "-qq"]] + output = capsys.readouterr().out + assert "apt-get update failed" in output + assert "Installed with apt-get" not in output + + +def test_system_install_uses_brew_and_checks_each_result(monkeypatch, capsys): + """Homebrew failures are reported per dependency and never as success.""" + import platform + import shutil + + commands = [] + monkeypatch.setattr(platform, "system", lambda: "Darwin") + monkeypatch.setattr( + shutil, + "which", + lambda name: "/opt/homebrew/bin/brew" if name == "brew" else None, + ) + + def fake_run(args, **_kwargs): + commands.append(args) + returncode = 0 if args[-1] == "gh" else 1 + return subprocess.CompletedProcess( + args, + returncode, + stdout="", + stderr="failed" if returncode else "", + ) + + monkeypatch.setattr(subprocess, "run", fake_run) + + cli._install_system_deps() + + assert commands == [ + ["/opt/homebrew/bin/brew", "install", "gh"], + ["/opt/homebrew/bin/brew", "install", "node"], + ] + output = capsys.readouterr().out + assert "✅ gh CLI installed" in output + assert "[!] Node.js install failed" in output + assert "✅ Node.js installed" not in output + + +def test_system_install_stops_undici_setup_when_npm_root_fails( + monkeypatch, capsys +): + """A failed discovery step must not trigger a global npm write.""" + import shutil + + commands = [] + monkeypatch.setattr( + shutil, + "which", + lambda name: f"/usr/bin/{name}" + if name in {"gh", "node", "npm", "deno"} + else None, + ) + + def fake_run(args, **_kwargs): + commands.append(args) + return subprocess.CompletedProcess( + args, + 1, + stdout="/untrusted/npm-root\n", + stderr="npm root failed", + ) + + monkeypatch.setattr(subprocess, "run", fake_run) + + cli._install_system_deps() + + assert commands == [["/usr/bin/npm", "root", "-g"]] + output = capsys.readouterr().out + assert "undici installed" not in output + assert "Could not inspect global npm packages" in output + + +def test_system_install_does_not_report_failed_undici_as_success( + monkeypatch, capsys +): + import shutil + + monkeypatch.setattr( + shutil, + "which", + lambda name: f"/usr/bin/{name}" + if name in {"gh", "node", "npm", "deno"} + else None, + ) + + def fake_run(args, **_kwargs): + if args[1:3] == ["root", "-g"]: + return subprocess.CompletedProcess( + args, + 0, + stdout="/missing/npm-root\n", + stderr="", + ) + return subprocess.CompletedProcess( + args, + 1, + stdout="", + stderr="install failed", + ) + + monkeypatch.setattr(subprocess, "run", fake_run) + + cli._install_system_deps() + + output = capsys.readouterr().out + assert "✅ undici installed" not in output + assert "undici install failed" in output + + +def test_install_dry_run_never_suggests_remote_setup_scripts( + monkeypatch, capsys +): + import shutil + + monkeypatch.setattr(shutil, "which", lambda _name: None) + + cli._install_system_deps_dryrun() + + output = capsys.readouterr().out + assert "curl" not in output + assert "NodeSource" not in output + assert "apt-get" in output + assert "brew" in output + + +@pytest.mark.parametrize( + "helper", + [cli._install_system_deps_safe, cli._install_system_deps_dryrun], +) +def test_system_dependency_checks_require_both_node_and_npm( + monkeypatch, capsys, helper +): + import shutil + + monkeypatch.setattr( + shutil, + "which", + lambda name: f"/usr/bin/{name}" if name in {"gh", "node"} else None, + ) + + helper() + + output = capsys.readouterr().out + assert "Node.js" in output + assert "All system dependencies are installed" not in output + assert "Node.js: already installed" not in output + + @pytest.mark.parametrize( "yt_dlp_version", [None, "2025.10.22", "not-a-stable-version"], @@ -611,7 +997,7 @@ def test_mcporter_install_adds_exa_to_home_scope(monkeypatch): def fake_run(args, **_kwargs): calls.append(args) - if args == ["mcporter", "config", "list", "--json"]: + if args == ["/usr/bin/mcporter", "config", "list", "--json"]: return _docker_result(args, stdout='{"servers": []}') return _docker_result(args) @@ -620,7 +1006,7 @@ def fake_run(args, **_kwargs): cli._install_mcporter() assert [ - "mcporter", + "/usr/bin/mcporter", "config", "add", "exa", @@ -628,7 +1014,7 @@ def fake_run(args, **_kwargs): "--scope", "home", ] in calls - assert ["mcporter", "config", "list", "--json"] in calls + assert ["/usr/bin/mcporter", "config", "list", "--json"] in calls def test_mcporter_install_does_not_treat_metadata_as_exa_server( @@ -657,7 +1043,7 @@ def test_mcporter_install_does_not_treat_metadata_as_exa_server( def fake_run(args, **_kwargs): calls.append(args) - if args == ["mcporter", "config", "list", "--json"]: + if args == ["/usr/bin/mcporter", "config", "list", "--json"]: return _docker_result(args, stdout=json.dumps(payload)) return _docker_result(args) @@ -666,7 +1052,7 @@ def fake_run(args, **_kwargs): cli._install_mcporter() assert [ - "mcporter", + "/usr/bin/mcporter", "config", "add", "exa", @@ -676,6 +1062,44 @@ def fake_run(args, **_kwargs): ] in calls +def test_mcporter_install_uses_resolved_windows_command_paths(monkeypatch): + """Windows .CMD shims must not be replaced with unresolved bare names.""" + import shutil + + npm_cmd = "C:/Tools/npm.CMD" + mcporter_cmd = "C:/Tools/mcporter.CMD" + state = {"installed": False} + calls = [] + + def fake_which(name): + if name == "npm": + return npm_cmd + if name == "mcporter" and state["installed"]: + return mcporter_cmd + return None + + def fake_run(args, **_kwargs): + calls.append(args) + if args[:4] == [npm_cmd, "install", "-g", "mcporter"]: + state["installed"] = True + return _docker_result(args) + if args == [mcporter_cmd, "config", "list", "--json"]: + return _docker_result( + args, + stdout='{"servers": [{"name": "exa"}]}', + ) + return _docker_result(args, returncode=1) + + monkeypatch.setattr(shutil, "which", fake_which) + monkeypatch.setattr(subprocess, "run", fake_run) + + assert cli._install_mcporter() is True + assert calls == [ + [npm_cmd, "install", "-g", "mcporter"], + [mcporter_cmd, "config", "list", "--json"], + ] + + def test_server_xhs_install_never_recommends_qr_or_browser_extraction( monkeypatch, capsys ): @@ -830,6 +1254,7 @@ def test_install_dry_run_does_not_create_agent_reach_directory( Namespace( env="local", proxy="", + system=True, safe=False, dry_run=True, channels="", diff --git a/tests/test_private_file_writes.py b/tests/test_private_file_writes.py index ed0c46bc..2f8466c4 100644 --- a/tests/test_private_file_writes.py +++ b/tests/test_private_file_writes.py @@ -458,6 +458,7 @@ def test_safe_install_with_proxy_makes_no_persistent_writes( Namespace( env="local", proxy="http://user:pass@proxy.example:8080", + system=False, safe=True, dry_run=False, channels="twitter", @@ -475,3 +476,165 @@ def test_safe_install_with_proxy_makes_no_persistent_writes( output = capsys.readouterr().out assert "SAFE MODE" in output assert "Would save network proxy" in output + + +def test_install_is_safe_by_default(isolated_home, monkeypatch, capsys): + """Plain install checks readiness without modifying the host.""" + monkeypatch.setattr(cli, "_configure_logging", lambda _verbose=False: None) + monkeypatch.setattr( + cli, + "_install_system_deps", + lambda: pytest.fail("default install must not modify system dependencies"), + ) + monkeypatch.setattr( + cli, + "_install_mcporter", + lambda: pytest.fail("default install must not install global tools"), + ) + monkeypatch.setattr( + cli, + "_install_skill", + lambda: pytest.fail("default install must not register agent skills"), + ) + monkeypatch.setattr("agent_reach.doctor.check_all", lambda _config: {}) + monkeypatch.setattr( + "agent_reach.doctor.format_report", + lambda _results: "report", + ) + monkeypatch.setattr( + cli.sys, + "argv", + ["agent-reach", "install", "--env", "local"], + ) + + cli.main() + + assert not (isolated_home / ".agent-reach").exists() + output = capsys.readouterr().out + assert "SAFE MODE" in output + assert "No changes were made" in output + + +def test_install_system_flag_explicitly_enables_writes( + isolated_home, monkeypatch, capsys +): + """The legacy write path remains available only through --system.""" + calls = [] + monkeypatch.setattr(cli, "_configure_logging", lambda _verbose=False: None) + monkeypatch.setattr( + cli, + "_install_system_deps", + lambda: calls.append("system-deps"), + ) + monkeypatch.setattr( + cli, + "_install_mcporter", + lambda: calls.append("mcporter"), + ) + monkeypatch.setattr( + cli, + "_install_skill", + lambda: calls.append("skill"), + ) + monkeypatch.setattr("agent_reach.doctor.check_all", lambda _config: {}) + monkeypatch.setattr( + "agent_reach.doctor.format_report", + lambda _results: "report", + ) + monkeypatch.setattr( + cli.sys, + "argv", + ["agent-reach", "install", "--env", "local", "--system"], + ) + + cli.main() + + assert calls == ["system-deps", "mcporter", "skill"] + assert (isolated_home / ".agent-reach" / "tools").is_dir() + assert "Installation complete" in capsys.readouterr().out + + +def test_install_system_exits_nonzero_when_core_steps_fail( + isolated_home, monkeypatch, capsys +): + """Automation must not receive exit zero after failed core installation.""" + monkeypatch.setattr(cli, "_configure_logging", lambda _verbose=False: None) + monkeypatch.setattr(cli, "_install_system_deps", lambda: False) + monkeypatch.setattr(cli, "_install_mcporter", lambda: False) + monkeypatch.setattr(cli, "_install_skill", lambda: None) + monkeypatch.setattr("agent_reach.doctor.check_all", lambda _config: {}) + monkeypatch.setattr( + "agent_reach.doctor.format_report", + lambda _results: "report", + ) + monkeypatch.setattr( + cli.sys, + "argv", + ["agent-reach", "install", "--env", "local", "--system"], + ) + + with pytest.raises(SystemExit) as exc: + cli.main() + + assert exc.value.code == 1 + output = capsys.readouterr().out + assert "Installation incomplete" in output + assert "Installation complete" not in output + + +def test_install_system_exits_nonzero_when_requested_channel_fails( + isolated_home, monkeypatch, capsys +): + monkeypatch.setattr(cli, "_configure_logging", lambda _verbose=False: None) + monkeypatch.setattr(cli, "_install_system_deps", lambda: True) + monkeypatch.setattr(cli, "_install_mcporter", lambda: True) + monkeypatch.setattr(cli, "_install_opencli_deps", lambda: False) + monkeypatch.setattr(cli, "_install_skill", lambda: True) + monkeypatch.setattr("agent_reach.doctor.check_all", lambda _config: {}) + monkeypatch.setattr( + "agent_reach.doctor.format_report", + lambda _results: "report", + ) + monkeypatch.setattr( + cli.sys, + "argv", + [ + "agent-reach", + "install", + "--env", + "local", + "--system", + "--channels=opencli", + ], + ) + + with pytest.raises(SystemExit) as exc: + cli.main() + + assert exc.value.code == 1 + assert "Installation incomplete" in capsys.readouterr().out + + +def test_install_system_exits_nonzero_when_skill_install_fails( + isolated_home, monkeypatch, capsys +): + monkeypatch.setattr(cli, "_configure_logging", lambda _verbose=False: None) + monkeypatch.setattr(cli, "_install_system_deps", lambda: True) + monkeypatch.setattr(cli, "_install_mcporter", lambda: True) + monkeypatch.setattr(cli, "_install_skill", lambda: False) + monkeypatch.setattr("agent_reach.doctor.check_all", lambda _config: {}) + monkeypatch.setattr( + "agent_reach.doctor.format_report", + lambda _results: "report", + ) + monkeypatch.setattr( + cli.sys, + "argv", + ["agent-reach", "install", "--env", "local", "--system"], + ) + + with pytest.raises(SystemExit) as exc: + cli.main() + + assert exc.value.code == 1 + assert "Installation incomplete" in capsys.readouterr().out diff --git a/tests/test_skill_command.py b/tests/test_skill_command.py index 15f1edd2..33733ae7 100644 --- a/tests/test_skill_command.py +++ b/tests/test_skill_command.py @@ -5,9 +5,11 @@ import os import tempfile import unittest +from argparse import Namespace +from pathlib import Path from unittest.mock import patch -from agent_reach.cli import _install_skill, _uninstall_skill +from agent_reach.cli import _cmd_skill, _install_skill, _uninstall_skill class TestSkillCommand(unittest.TestCase): @@ -23,6 +25,87 @@ def test_skill_resources_include_both_locales(self): self.assertTrue(default_skill.strip()) self.assertTrue(english_skill.strip()) + def test_exa_reference_uses_default_registered_tools_only(self): + """Agent instructions must not call Exa tools disabled by default.""" + search_reference = ( + importlib.resources.files("agent_reach") + .joinpath("skill", "references", "search.md") + .read_text(encoding="utf-8") + ) + + self.assertIn("web_search_exa", search_reference) + self.assertNotIn("exa.get_code_context_exa", search_reference) + self.assertNotIn("get_code_context_exa(", search_reference) + + def test_linkedin_reference_uses_current_tool_contract(self): + """LinkedIn examples should use the current server and parameters.""" + career_reference = ( + importlib.resources.files("agent_reach") + .joinpath("skill", "references", "career.md") + .read_text(encoding="utf-8") + ) + + self.assertIn( + "linkedin.get_person_profile(" + 'linkedin_username: "username", ' + 'sections: "experience,education")', + career_reference, + ) + self.assertIn( + 'linkedin.search_people(keywords: "AI engineer", ' + 'location: "Shanghai")', + career_reference, + ) + self.assertIn( + 'linkedin.get_company_profile(company_name: "openai", ' + 'sections: "posts,jobs")', + career_reference, + ) + self.assertIn( + 'linkedin.search_jobs(keywords: "software engineer", ' + 'location: "Remote", max_pages: 2)', + career_reference, + ) + self.assertNotIn("linkedin-scraper.", career_reference) + + def test_linkedin_install_docs_use_current_stdio_contract(self): + """LinkedIn install guidance should use uvx over stdio.""" + install_doc = ( + Path(__file__).resolve().parents[1] / "docs" / "install.md" + ).read_text(encoding="utf-8") + linkedin_section = install_doc.split( + "**LinkedIn (", maxsplit=1 + )[1].split("### Step 4:", maxsplit=1)[0] + + self.assertIn( + "uvx mcp-server-linkedin@latest --login", + linkedin_section, + ) + self.assertIn( + "mcporter config add linkedin --command uvx " + "--arg mcp-server-linkedin@latest --env UV_HTTP_TIMEOUT=300 " + "--scope home", + linkedin_section, + ) + self.assertNotIn("linkedin-scraper-mcp", linkedin_section) + self.assertNotIn("localhost:3000/mcp", linkedin_section) + self.assertNotIn("linkedin-scraper.", linkedin_section) + self.assertNotIn("--transport streamable-http", linkedin_section) + + def test_localized_readmes_use_current_linkedin_server_name(self): + root = Path(__file__).resolve().parents[1] + for name in ("README_ja.md", "README_ko.md"): + content = (root / "docs" / name).read_text(encoding="utf-8") + self.assertIn("mcp-server-linkedin", content) + self.assertNotIn("linkedin-scraper-mcp", content) + + def test_skill_install_command_exits_nonzero_when_install_fails(self): + with patch("agent_reach.cli._install_skill", return_value=False): + with self.assertRaises(SystemExit) as raised: + _cmd_skill(Namespace(install=True, uninstall=False)) + + self.assertEqual(raised.exception.code, 1) + def test_install_skill_creates_skill_md(self): """_install_skill should create SKILL.md in the first available skill dir.""" with tempfile.TemporaryDirectory() as tmpdir: diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index 863c688a..f5524d33 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -168,6 +168,112 @@ def test_all_fail_raises_with_last_error(self, monkeypatch, fake_config, chunk_f class TestOrchestrator: + def test_provider_fallback_consent_requires_auto(self, fake_config, chunk_file): + with pytest.raises(tr.TranscribeError, match="requires provider='auto'"): + tr.transcribe( + str(chunk_file), + provider="groq", + config=fake_config, + allow_provider_fallback=True, + ) + + def test_auto_does_not_send_audio_to_second_provider_without_consent( + self, + monkeypatch, + fake_config, + tmp_path, + chunk_file, + bounded_audio_duration, + ): + fake_config.set("groq_api_key", "gsk_test") + fake_config.set("openai_api_key", "sk-test") + compressed = tmp_path / "compressed.m4a" + compressed.write_bytes(b"compressed") + monkeypatch.setattr(tr, "compress_audio", lambda *_args: compressed) + calls: List[str] = [] + + def fake_post(url, **_kwargs): + calls.append(url) + if url == tr.PROVIDERS["groq"]["endpoint"]: + return FakeResponse(429, "rate limited") + return FakeResponse(200, "from-openai") + + monkeypatch.setattr(tr.requests, "post", fake_post) + + with pytest.raises(tr.TranscribeError, match="groq.*HTTP 429"): + tr.transcribe( + str(chunk_file), + out_dir=tmp_path / "work", + config=fake_config, + ) + + assert calls == [tr.PROVIDERS["groq"]["endpoint"]] + + def test_auto_falls_back_only_with_explicit_consent( + self, + monkeypatch, + fake_config, + tmp_path, + chunk_file, + bounded_audio_duration, + ): + fake_config.set("groq_api_key", "gsk_test") + fake_config.set("openai_api_key", "sk-test") + compressed = tmp_path / "compressed.m4a" + compressed.write_bytes(b"compressed") + monkeypatch.setattr(tr, "compress_audio", lambda *_args: compressed) + calls: List[str] = [] + + def fake_post(url, **_kwargs): + calls.append(url) + if url == tr.PROVIDERS["groq"]["endpoint"]: + return FakeResponse(429, "rate limited") + return FakeResponse(200, "from-openai") + + monkeypatch.setattr(tr.requests, "post", fake_post) + + text = tr.transcribe( + str(chunk_file), + out_dir=tmp_path / "work", + config=fake_config, + allow_provider_fallback=True, + ) + + assert text == "from-openai" + assert calls == [ + tr.PROVIDERS["groq"]["endpoint"], + tr.PROVIDERS["openai"]["endpoint"], + ] + + def test_auto_uses_openai_when_it_is_the_only_configured_provider( + self, + monkeypatch, + fake_config, + tmp_path, + chunk_file, + bounded_audio_duration, + ): + fake_config.set("openai_api_key", "sk-test") + compressed = tmp_path / "compressed.m4a" + compressed.write_bytes(b"compressed") + monkeypatch.setattr(tr, "compress_audio", lambda *_args: compressed) + calls: List[str] = [] + + def fake_post(url, **_kwargs): + calls.append(url) + return FakeResponse(200, "from-openai") + + monkeypatch.setattr(tr.requests, "post", fake_post) + + text = tr.transcribe( + str(chunk_file), + out_dir=tmp_path / "work", + config=fake_config, + ) + + assert text == "from-openai" + assert calls == [tr.PROVIDERS["openai"]["endpoint"]] + def test_rejects_overlong_audio_before_compression( self, monkeypatch, fake_config, chunk_file ): @@ -583,6 +689,89 @@ def fake_run(cmd, timeout=600): assert captured["cmd"][-1] == "https://youtu.be/abc123" + # The C resolver behind yt-dlp accepts the full inet_aton grammar, so a + # canonical dotted-quad check alone lets loopback and the cloud metadata + # endpoint through under a different spelling. + @pytest.mark.parametrize( + ("url", "reaches"), + [ + ("http://127.1/a.mp3", "127.0.0.1"), + ("http://127.0.1/a.mp3", "127.0.0.1"), + ("http://2130706433/a.mp3", "127.0.0.1"), + ("http://0x7f000001/a.mp3", "127.0.0.1"), + ("http://0177.0.0.1/a.mp3", "127.0.0.1"), + ("http://017700000001/a.mp3", "127.0.0.1"), + ("http://0/a.mp3", "0.0.0.0"), + ("http://192.168.1/a.mp3", "192.168.0.1"), + ("http://2852039166/a.mp3", "169.254.169.254"), + ("http://0xA9FEA9FE/a.mp3", "169.254.169.254"), + ("http://127.0.0.1/a.mp3", "127.0.0.1"), + ("http://2130706433/a.mp3", "127.0.0.1"), + ("http://0x7f000001/a.mp3", "127.0.0.1"), + ("http://ⓛⓞⓒⓐⓛⓗⓞⓢⓣ/a.mp3", "localhost"), + ("http://ℓocalhost/a.mp3", "localhost"), + ("http://%31%32%37.0.0.1/a.mp3", "127.0.0.1"), + ("http://127%2e0%2e0%2e1/a.mp3", "127.0.0.1"), + ("http://local%68ost/a.mp3", "localhost"), + ("http://127.0.0.1\\@example.com/a.mp3", "127.0.0.1"), + ], + ) + def test_rejects_shorthand_ipv4_spellings_of_internal_hosts( + self, monkeypatch, tmp_path, url, reaches + ): + monkeypatch.setattr(tr, "_require", lambda binary: None) + + def should_not_run(*args, **kwargs): + raise AssertionError(f"yt-dlp must not run for a URL reaching {reaches}") + + monkeypatch.setattr(tr, "_run", should_not_run) + + with pytest.raises(tr.TranscribeError, match="private|internal|SSRF"): + tr.download_audio(url, tmp_path) + + def test_shorthand_ipv4_check_stays_dns_free(self, monkeypatch, tmp_path): + import socket as socket_module + + monkeypatch.setattr(tr, "_require", lambda binary: None) + monkeypatch.setattr( + socket_module, + "getaddrinfo", + lambda *args, **kwargs: (_ for _ in ()).throw( + AssertionError("literal IP parsing must not resolve names") + ), + ) + + def should_not_run(*args, **kwargs): + raise AssertionError("yt-dlp must not run for private/internal URLs") + + monkeypatch.setattr(tr, "_run", should_not_run) + + with pytest.raises(tr.TranscribeError, match="private|internal|SSRF"): + tr.download_audio("http://2130706433/a.mp3", tmp_path) + + @pytest.mark.parametrize( + "url", + [ + "https://1.1.1.1/a.mp3", + "https://8.8.8.8/a.mp3", + # Octal dotted-quad that denotes a public address, not loopback. + "http://010.010.010.010/a.mp3", + ], + ) + def test_allows_public_literal_addresses(self, monkeypatch, tmp_path, url): + monkeypatch.setattr(tr, "_require", lambda binary: None) + captured = {} + + def fake_run(cmd, timeout=600): + captured["cmd"] = cmd + (tmp_path / "source.m4a").write_bytes(b"audio") + + monkeypatch.setattr(tr, "_run", fake_run) + + tr.download_audio(url, tmp_path) + + assert captured["cmd"][-1] == url + class TestMediaGenerationBudget: def test_compression_has_hard_duration_cap( @@ -648,20 +837,32 @@ def test_delegates_to_transcribe(self, monkeypatch, fake_config): captured = {} - def fake_transcribe(source, *, provider="auto", out_dir=None, config=None): + def fake_transcribe( + source, + *, + provider="auto", + out_dir=None, + config=None, + allow_provider_fallback=False, + ): captured["source"] = source captured["provider"] = provider captured["config"] = config + captured["allow_provider_fallback"] = allow_provider_fallback return "delegated text" monkeypatch.setattr(tr, "transcribe", fake_transcribe) out = YouTubeChannel().transcribe( - "https://youtu.be/abc", provider="groq", config=fake_config + "https://youtu.be/abc", + provider="groq", + config=fake_config, + allow_provider_fallback=True, ) assert out == "delegated text" assert captured["source"] == "https://youtu.be/abc" assert captured["provider"] == "groq" assert captured["config"] is fake_config + assert captured["allow_provider_fallback"] is True # --- Config feature requirement --------------------------------------- # diff --git a/tests/test_v2ex_channel.py b/tests/test_v2ex_channel.py index 2379ed62..7964fa81 100644 --- a/tests/test_v2ex_channel.py +++ b/tests/test_v2ex_channel.py @@ -11,12 +11,17 @@ reddit (#364) and xueqiu (#365). """ +import json +import ssl +import subprocess from unittest.mock import patch +from urllib.error import URLError + +import pytest from agent_reach.channels import v2ex as v2 from agent_reach.channels.v2ex import V2EXChannel - # --- can_handle --- def test_can_handle_matches_v2ex_hosts(): @@ -47,6 +52,107 @@ def test_check_warn_on_exception_clears_backend(): assert ch.active_backend is None +def test_get_json_retries_unexpected_tls_eof_with_bounded_curl(): + payload = [{"id": 1}] + tls_error = URLError( + ssl.SSLError( + "[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol" + ) + ) + + with patch.object(v2, "_get_json_with_urllib", side_effect=tls_error), patch.object( + v2.shutil, "which", return_value="C:/Windows/System32/curl.exe" + ), patch.object( + v2.subprocess, + "run", + return_value=subprocess.CompletedProcess( + ["curl"], 0, json.dumps(payload), "" + ), + ) as run: + assert v2._get_json("https://www.v2ex.com/api/topics/hot.json") == payload + + command = run.call_args.args[0] + assert command[0] == "C:/Windows/System32/curl.exe" + assert "--fail" in command + assert command[command.index("--proto") + 1] == "=https" + assert "--location" not in command + assert "--max-time" in command + assert "--max-filesize" in command + assert command[-2:] == [ + "--url", + "https://www.v2ex.com/api/topics/hot.json", + ] + assert run.call_args.kwargs["timeout"] == v2._TIMEOUT + 2 + + +def test_get_json_does_not_hide_certificate_verification_failures(): + certificate_error = ssl.SSLCertVerificationError( + "certificate verify failed" + ) + + with patch.object( + v2, "_get_json_with_urllib", side_effect=certificate_error + ), patch.object(v2.subprocess, "run") as run: + with pytest.raises(ssl.SSLCertVerificationError): + v2._get_json("https://www.v2ex.com/api/topics/hot.json") + + run.assert_not_called() + + +def test_get_json_does_not_fallback_for_plain_error_text(): + fake_error = RuntimeError( + "UNEXPECTED_EOF_WHILE_READING appeared in an unrelated message" + ) + + with patch.object( + v2, "_get_json_with_urllib", side_effect=fake_error + ), patch.object(v2.subprocess, "run") as run: + with pytest.raises(RuntimeError, match="unrelated"): + v2._get_json("https://www.v2ex.com/api/topics/hot.json") + + run.assert_not_called() + + +@pytest.mark.parametrize( + "url", + [ + "http://www.v2ex.com/api/topics/hot.json", + "https://v2ex.com.evil.test/api/topics/hot.json", + "https://user:pass@www.v2ex.com/api/topics/hot.json", + "https://www.v2ex.com:8443/api/topics/hot.json", + "https://www.v2ex.com/about", + ], +) +def test_get_json_rejects_non_api_targets_before_network(url): + with patch.object(v2.urllib.request, "urlopen") as urlopen, patch.object( + v2.subprocess, "run" + ) as run: + with pytest.raises(ValueError, match="V2EX HTTPS API"): + v2._get_json(url) + + urlopen.assert_not_called() + run.assert_not_called() + + +def test_check_is_healthy_when_native_curl_recovers_tls_eof(): + ch = V2EXChannel() + tls_error = ssl.SSLError( + "[SSL: UNEXPECTED_EOF_WHILE_READING] EOF occurred in violation of protocol" + ) + + with patch.object(v2, "_get_json_with_urllib", side_effect=tls_error), patch.object( + v2.shutil, "which", return_value="/usr/bin/curl" + ), patch.object( + v2.subprocess, + "run", + return_value=subprocess.CompletedProcess(["curl"], 0, "[]", ""), + ): + status, _message = ch.check() + + assert status == "ok" + assert ch.active_backend == ch.backends[0] + + # --- get_hot_topics / get_node_topics --- def test_get_hot_topics_maps_node_and_truncates_content(): diff --git a/tests/test_xiaoyuzhou_install.py b/tests/test_xiaoyuzhou_install.py index 5abf169e..7993fc23 100644 --- a/tests/test_xiaoyuzhou_install.py +++ b/tests/test_xiaoyuzhou_install.py @@ -1,9 +1,13 @@ # -*- coding: utf-8 -*- +import os +import re import subprocess from pathlib import Path from unittest.mock import patch +import pytest + import agent_reach.cli as cli ROOT = Path(__file__).resolve().parents[1] @@ -15,10 +19,17 @@ def get(self, _key): return None -def test_install_xiaoyuzhou_deps_does_not_raise_when_no_groq_key(capsys): - with patch("agent_reach.config.Config", return_value=_DummyConfig()), \ - patch("os.path.isfile", side_effect=lambda p: True if str(p).endswith("transcribe.sh") else False), \ - patch("shutil.which", return_value=None): +def test_install_xiaoyuzhou_deps_does_not_raise_when_no_groq_key( + monkeypatch, tmp_path, capsys +): + monkeypatch.setattr( + cli.os.path, + "expanduser", + lambda value: value.replace("~", str(tmp_path)), + ) + with patch("agent_reach.config.Config", return_value=_DummyConfig()), patch( + "shutil.which", return_value=None + ): cli._install_xiaoyuzhou_deps() out = capsys.readouterr().out @@ -26,6 +37,32 @@ def test_install_xiaoyuzhou_deps_does_not_raise_when_no_groq_key(capsys): assert "Groq API key not set" in out +def test_install_xiaoyuzhou_deps_replaces_stale_managed_script( + monkeypatch, tmp_path, capsys +): + import stat + + installed = tmp_path / ".agent-reach" / "tools" / "xiaoyuzhou" / "transcribe.sh" + installed.parent.mkdir(parents=True) + installed.write_text("#!/bin/sh\necho stale\n", encoding="utf-8") + + monkeypatch.setattr( + cli.os.path, + "expanduser", + lambda value: value.replace("~", str(tmp_path)), + ) + monkeypatch.setattr("agent_reach.config.Config", lambda: _DummyConfig()) + monkeypatch.setattr("shutil.which", lambda _name: None) + + cli._install_xiaoyuzhou_deps() + + assert installed.read_text(encoding="utf-8") == TRANSCRIBE_SCRIPT.read_text( + encoding="utf-8" + ) + assert installed.stat().st_mode & stat.S_IXUSR + assert "script updated" in capsys.readouterr().out + + def test_transcribe_script_is_cross_platform_shell_syntax(): subprocess.run(["bash", "-n", str(TRANSCRIBE_SCRIPT)], check=True) @@ -37,3 +74,212 @@ def test_transcribe_script_handles_git_bash_python_and_size_math(): assert "command -v py" in text assert "cygpath -w" in text assert "| bc" not in text + + +def _write_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def _script_env(tmp_path: Path, curl_script: str) -> tuple[dict[str, str], Path, Path]: + fake_bin = tmp_path / "bin" + fake_bin.mkdir() + curl_log = tmp_path / "curl.log" + temp_root = tmp_path / "tmp" + temp_root.mkdir() + _write_executable(fake_bin / "curl", curl_script) + + env = os.environ.copy() + env.update({ + "CURL_LOG": str(curl_log), + "GROQ_API_KEY": "test-key", + "PATH": f"{fake_bin}{os.pathsep}{env['PATH']}", + "TMPDIR": str(temp_root), + }) + return env, curl_log, temp_root + + +def _assert_work_dir_cleaned(temp_root: Path) -> None: + assert list(temp_root.glob("agent-reach-xiaoyuzhou.*")) == [] + + +@pytest.mark.parametrize( + "url", + [ + "ftp://xiaoyuzhoufm.com/episode/123", + "https://notxiaoyuzhoufm.com/episode/123", + "https://xiaoyuzhoufm.com.evil.example/episode/123", + "https://evil.example/episode/123?next=xiaoyuzhoufm.com", + ], +) +def test_transcribe_script_rejects_non_xiaoyuzhou_urls_before_curl(tmp_path, url): + env, curl_log, temp_root = _script_env( + tmp_path, + "#!/bin/sh\nprintf 'called\\n' >> \"$CURL_LOG\"\nexit 42\n", + ) + + result = subprocess.run( + ["bash", str(TRANSCRIBE_SCRIPT), url, str(tmp_path / "out.txt")], + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode != 0 + assert "仅支持 xiaoyuzhoufm.com" in result.stderr + assert not curl_log.exists() + _assert_work_dir_cleaned(temp_root) + + +@pytest.mark.parametrize( + "url", + [ + "http://xiaoyuzhoufm.com/episode/123", + "https://www.xiaoyuzhoufm.com/episode/123", + ], +) +def test_transcribe_script_accepts_http_xiaoyuzhou_hosts(tmp_path, url): + env, curl_log, temp_root = _script_env( + tmp_path, + "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CURL_LOG\"\nexit 42\n", + ) + + result = subprocess.run( + ["bash", str(TRANSCRIBE_SCRIPT), url, str(tmp_path / "out.txt")], + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode != 0 + assert curl_log.exists() + _assert_work_dir_cleaned(temp_root) + + +def test_transcribe_script_uses_secure_temp_and_bounded_curl_calls(): + text = TRANSCRIBE_SCRIPT.read_text(encoding="utf-8") + + assert "mktemp -d" in text + assert "xiaoyuzhou_$$" not in text + assert "/tmp/podcast_transcript.txt" not in text + assert 'mktemp "${TEMP_ROOT%/}/agent-reach-transcript.XXXXXX"' in text + assert "trap cleanup EXIT" in text + assert text.count('--connect-timeout "$CURL_CONNECT_TIMEOUT"') == 4 + assert text.count('--max-time "$GROQ_TIMEOUT"') == 2 + assert text.count("--fail --show-error --location") == 2 + assert text.count("--max-filesize") == 4 + assert text.count('--max-filesize "$MAX_API_RESPONSE_BYTES"') == 2 + assert "MAX_DURATION_SECONDS=10800" in text + assert '-t "$MAX_DURATION_SECONDS"' in text + assert 'if [ "$WAIT_SEC" -gt 900 ]' in text + assert "r.read(32 * 1024 * 1024 + 1)" in text + + page_limit = int(re.search(r"^MAX_PAGE_BYTES=(\d+)$", text, re.MULTILINE).group(1)) + audio_limit = int(re.search(r"^MAX_AUDIO_BYTES=(\d+)$", text, re.MULTILINE).group(1)) + api_response_limit = int( + re.search(r"^MAX_API_RESPONSE_BYTES=(\d+)$", text, re.MULTILINE).group(1) + ) + assert page_limit <= 10 * 1024 * 1024 + assert 25 * 1024 * 1024 <= audio_limit <= 2 * 1024 * 1024 * 1024 + assert api_response_limit <= 32 * 1024 * 1024 + + +@pytest.mark.parametrize("ffprobe_output", ["", "not-a-number"]) +def test_transcribe_script_fails_clearly_for_invalid_duration(tmp_path, ffprobe_output): + env, _, temp_root = _script_env( + tmp_path, + """#!/bin/bash +output="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then + output="$2" + shift 2 + else + shift + fi +done +if [ -n "$output" ]; then + printf 'fake audio' > "$output" +else + printf '%s' 'https://media.xyzcdn.net/test.mp3' +fi +""", + ) + _write_executable( + tmp_path / "bin" / "ffprobe", + "#!/bin/sh\nprintf '%s' \"$FFPROBE_OUTPUT\"\n", + ) + env["FFPROBE_OUTPUT"] = ffprobe_output + + result = subprocess.run( + [ + "bash", + str(TRANSCRIBE_SCRIPT), + "https://www.xiaoyuzhoufm.com/episode/123", + str(tmp_path / "out.txt"), + ], + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode != 0 + assert "ffprobe 返回无效音频时长" in result.stderr + _assert_work_dir_cleaned(temp_root) + + +@pytest.mark.parametrize("ffprobe_output", ["10801", "9" * 500]) +def test_transcribe_script_rejects_overlong_audio_before_ffmpeg_or_groq( + tmp_path, ffprobe_output +): + env, curl_log, temp_root = _script_env( + tmp_path, + """#!/bin/bash +printf '%s\n' "$*" >> "$CURL_LOG" +output="" +while [ "$#" -gt 0 ]; do + if [ "$1" = "-o" ]; then + output="$2" + shift 2 + else + shift + fi +done +if [ -n "$output" ]; then + printf 'fake audio' > "$output" +else + printf '%s' 'https://media.xyzcdn.net/test.mp3' +fi +""", + ) + ffmpeg_marker = tmp_path / "ffmpeg-called" + _write_executable( + tmp_path / "bin" / "ffprobe", + "#!/bin/sh\nprintf '%s' \"$FFPROBE_OUTPUT\"\n", + ) + _write_executable( + tmp_path / "bin" / "ffmpeg", + "#!/bin/sh\nprintf 'called' > \"$FFMPEG_MARKER\"\n", + ) + env["FFMPEG_MARKER"] = str(ffmpeg_marker) + env["FFPROBE_OUTPUT"] = ffprobe_output + + result = subprocess.run( + [ + "bash", + str(TRANSCRIBE_SCRIPT), + "https://www.xiaoyuzhoufm.com/episode/123", + str(tmp_path / "out.txt"), + ], + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode != 0 + assert "音频时长超过 3 小时限制" in result.stderr + assert not ffmpeg_marker.exists() + curl_calls = curl_log.read_text(encoding="utf-8").splitlines() + assert len(curl_calls) == 2 + assert all("api.groq.com" not in call for call in curl_calls) + _assert_work_dir_cleaned(temp_root) diff --git a/tests/test_youtube_channel.py b/tests/test_youtube_channel.py index e48dac8b..1281e882 100644 --- a/tests/test_youtube_channel.py +++ b/tests/test_youtube_channel.py @@ -105,6 +105,7 @@ def test_check_warn_when_no_js_runtime_but_backend_active(): status, message = ch.check() assert status == "warn" assert "JS runtime" in message + assert "agent-reach install --system" in message assert ch.active_backend == "yt-dlp" # probe was ok → backend attributed @@ -205,6 +206,19 @@ def test_check_ok_reports_transcription_when_provider_and_ffmpeg_present(): assert "可转写音频" in message +def test_check_lists_multiple_transcription_providers_without_implying_fallback(): + ch = YouTubeChannel() + cfg = Mock() + cfg.is_configured = lambda key: key in {"groq_whisper", "openai_whisper"} + with patch.object(yt, "probe_command", return_value=ProbeResult("ok")), \ + patch("shutil.which", side_effect=_which("deno", "ffmpeg", "ffprobe")): + status, message = ch.check(config=cfg) + + assert status == "ok" + assert "groq/openai" in message + assert "groq→openai" not in message + + def test_check_ok_flags_missing_ffmpeg_for_transcription(): ch = YouTubeChannel() cfg = Mock()