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()