From 68a0f442ef6ddde7e59745154cd73b0b6bfbf947 Mon Sep 17 00:00:00 2001 From: lukiod Date: Thu, 6 Aug 2026 19:28:08 +0800 Subject: [PATCH 01/12] fix(transcribe): decode subprocess output as UTF-8 --- agent_reach/transcribe.py | 11 +++++++++-- tests/test_transcribe.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/agent_reach/transcribe.py b/agent_reach/transcribe.py index 248a49da..e7a0c6c4 100644 --- a/agent_reach/transcribe.py +++ b/agent_reach/transcribe.py @@ -107,7 +107,8 @@ def _probe_audio_duration(path: Path) -> float: proc = subprocess.run( cmd, capture_output=True, - text=True, + encoding="utf-8", + errors="replace", timeout=FFPROBE_TIMEOUT_SECONDS, ) except subprocess.TimeoutExpired: @@ -158,7 +159,13 @@ def _run(cmd: List[str], timeout: int = 600) -> None: network read or a hung probe must not block the CLI forever. """ try: - proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + proc = subprocess.run( + cmd, + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=timeout, + ) except subprocess.TimeoutExpired: raise TranscribeError(f"{cmd[0]} timed out after {timeout}s") if proc.returncode != 0: diff --git a/tests/test_transcribe.py b/tests/test_transcribe.py index f5524d33..4c2d6ba2 100644 --- a/tests/test_transcribe.py +++ b/tests/test_transcribe.py @@ -828,6 +828,37 @@ def test_chunk_generation_rejects_segment_size_that_can_exceed_budget( ) +# --- Subprocess output decoding ---------------------------------------- # + + +class TestSubprocessDecoding: + CJK_BYTES = "中文标题".encode("utf-8") + + def _decoding_run(self, returncode: int): + def fake_run(cmd, **kwargs): + encoding = kwargs.get("encoding") or "gbk" + errors = kwargs.get("errors") or "strict" + text = self.CJK_BYTES.decode(encoding, errors) + return subprocess.CompletedProcess(cmd, returncode, text, text) + + return fake_run + + def test_run_preserves_cjk_failure_as_transcribe_error(self, monkeypatch): + monkeypatch.setattr(tr.subprocess, "run", self._decoding_run(1)) + + with pytest.raises(tr.TranscribeError, match="yt-dlp"): + tr._run(["yt-dlp", "https://example.com/video"], timeout=5) + + def test_probe_preserves_cjk_failure_as_transcribe_error( + self, monkeypatch, tmp_path + ): + monkeypatch.setattr(tr, "_require", lambda _binary: None) + monkeypatch.setattr(tr.subprocess, "run", self._decoding_run(1)) + + with pytest.raises(tr.TranscribeError, match="duration"): + tr._probe_audio_duration(tmp_path / "audio.m4a") + + # --- YouTubeChannel integration --------------------------------------- # From 7e9ff38a2483c8fc00c1e7baeed6aa77ec5f33c9 Mon Sep 17 00:00:00 2001 From: lukiod Date: Thu, 6 Aug 2026 19:29:06 +0800 Subject: [PATCH 02/12] fix(xhs): fail when container restart fails --- agent_reach/cli.py | 11 ++++++++++- tests/test_p0_cli.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/agent_reach/cli.py b/agent_reach/cli.py index dae3b3fe..7d0cf771 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -1772,14 +1772,23 @@ def _configure_xhs_cookies(value) -> bool: # Restart container so it reloads cookies from disk print(" Restarting container to reload cookies...", end=" ", flush=True) try: - subprocess.run( + restart = subprocess.run( [docker, "restart", container_name], capture_output=True, encoding="utf-8", timeout=30, ) + if restart.returncode != 0: + detail = ( + (restart.stderr or "").strip()[:200] + or f"exit {restart.returncode}" + ) + print(f"\n [!] Could not restart container: {detail}") + print(f" Restart manually: docker restart {container_name}") + return False print("done") except Exception as e: print(f"\n [!] Could not restart container: {e}") print(f" Restart manually: docker restart {container_name}") + return False except Exception as e: print(f"[X] Failed to write cookies: {e}") return False diff --git a/tests/test_p0_cli.py b/tests/test_p0_cli.py index 783ebd91..c64e1871 100644 --- a/tests/test_p0_cli.py +++ b/tests/test_p0_cli.py @@ -659,6 +659,38 @@ def fake_run(args, **_kwargs): assert "Failed to copy cookies: copy failed" in capsys.readouterr().out +def test_xhs_docker_restart_failure_returns_failure(monkeypatch, capsys): + """Cookies are not active until the container successfully restarts.""" + + def fake_which(name): + return "/usr/bin/docker" if name == "docker" else None + + def fake_run(args, **_kwargs): + if args[1] == "ps": + return _docker_result(args, stdout="xiaohongshu-mcp\n") + if args[1:3] == ["exec", "xiaohongshu-mcp"]: + return _docker_result(args, stdout="/app/data/cookies.json\n") + if args[1] == "restart": + return _docker_result( + args, + returncode=1, + stderr="no such container", + ) + return _docker_result(args) + + monkeypatch.setattr("shutil.which", fake_which) + monkeypatch.setattr(subprocess, "run", fake_run) + + result = cli._configure_xhs_cookies("web_session=xhs_secret") + + output = capsys.readouterr().out + assert result is False + assert "Could not restart container" in output + assert "no such container" in output + assert "Restart manually" in output + assert "done" not in output + + def test_system_install_uses_ytdlp_first_user_config( monkeypatch, tmp_path ): From 31b028f4fc93acdb7e415e516ab891fe6d7baf39 Mon Sep 17 00:00:00 2001 From: Osamaali313 Date: Thu, 6 Aug 2026 19:31:15 +0800 Subject: [PATCH 03/12] fix(xueqiu): honor and clamp hot-post limits --- agent_reach/channels/xueqiu.py | 7 ++++- tests/test_xueqiu_channel.py | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/agent_reach/channels/xueqiu.py b/agent_reach/channels/xueqiu.py index 0fc752d7..1ecf45b5 100644 --- a/agent_reach/channels/xueqiu.py +++ b/agent_reach/channels/xueqiu.py @@ -235,9 +235,14 @@ def get_hot_posts(self, limit: int = 20) -> list: Returns a list of dicts with keys: id, title, text, author, likes, url """ + if limit < 0: + raise ValueError("limit must be non-negative") + limit = min(limit, 50) + if limit == 0: + return [] data = _get_json( "https://xueqiu.com/v4/statuses/public_timeline_by_category.json" - "?since_id=-1&max_id=-1&count=20&category=-1" + f"?since_id=-1&max_id=-1&count={limit}&category=-1" ) items = data.get("list") or [] results = [] diff --git a/tests/test_xueqiu_channel.py b/tests/test_xueqiu_channel.py index 9b8fda66..4b1ace2b 100644 --- a/tests/test_xueqiu_channel.py +++ b/tests/test_xueqiu_channel.py @@ -13,6 +13,9 @@ import sys import types from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit + +import pytest from agent_reach.channels import xueqiu as xq from agent_reach.channels.xueqiu import XueqiuChannel, _strip_html @@ -211,6 +214,52 @@ def test_get_hot_posts_tolerates_bad_data_field(): assert p["url"] == "" +def test_get_hot_posts_requests_the_requested_count(): + ch = XueqiuChannel() + captured = {} + + def fake_get_json(url): + captured["url"] = url + return {"list": []} + + with patch.object(xq, "_get_json", side_effect=fake_get_json): + ch.get_hot_posts(limit=50) + + assert parse_qs(urlsplit(captured["url"]).query)["count"] == ["50"] + + +def test_get_hot_posts_clamps_count_to_documented_maximum(): + ch = XueqiuChannel() + captured = {} + payload = {"list": [{"data": "{}"}] * 60} + + def fake_get_json(url): + captured["url"] = url + return payload + + with patch.object(xq, "_get_json", side_effect=fake_get_json): + posts = ch.get_hot_posts(limit=500) + + assert parse_qs(urlsplit(captured["url"]).query)["count"] == ["50"] + assert len(posts) == 50 + + +def test_get_hot_posts_zero_limit_skips_network(): + ch = XueqiuChannel() + with patch.object( + xq, + "_get_json", + side_effect=AssertionError("zero limit must not make a request"), + ): + assert ch.get_hot_posts(limit=0) == [] + + +def test_get_hot_posts_rejects_negative_limit(): + ch = XueqiuChannel() + with pytest.raises(ValueError, match="non-negative"): + ch.get_hot_posts(limit=-1) + + # --- get_hot_stocks: ranking + code/symbol fallback --- def test_get_hot_stocks_ranks_and_falls_back_to_symbol(): From 544e0e76f2cc5764366a693c170ee6db8ba041e1 Mon Sep 17 00:00:00 2001 From: SEPURI-SAI-KRISHNA Date: Thu, 6 Aug 2026 19:36:45 +0800 Subject: [PATCH 04/12] fix(v2ex): encode caller values in URLs --- agent_reach/channels/v2ex.py | 39 +++++++++++++----- tests/test_v2ex_channel.py | 78 ++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 11 deletions(-) diff --git a/agent_reach/channels/v2ex.py b/agent_reach/channels/v2ex.py index 0b4c6967..d11d2455 100644 --- a/agent_reach/channels/v2ex.py +++ b/agent_reach/channels/v2ex.py @@ -7,7 +7,7 @@ import subprocess import urllib.request from typing import Any -from urllib.parse import urlsplit +from urllib.parse import quote, urlencode, urlsplit from agent_reach.utils.process import utf8_subprocess_env from agent_reach.utils.text import scrub_url_credentials @@ -17,6 +17,12 @@ _UA = "agent-reach/1.0" _TIMEOUT = 10 _MAX_RESPONSE_BYTES = 1024 * 1024 +_API_BASE = "https://www.v2ex.com" + + +def _v2ex_url(path: str, **params: Any) -> str: + """Build a V2EX URL without letting caller values alter its query.""" + return f"{_API_BASE}{path}?{urlencode(params)}" def _validate_api_url(url: str) -> None: @@ -203,9 +209,10 @@ def get_node_topics(self, node_name: str, limit: int = 20) -> list: Returns a list of dicts with keys: title, url, replies, node_name, node_title, content """ - url = ( - f"https://www.v2ex.com/api/topics/show.json" - f"?node_name={node_name}&page=1" + url = _v2ex_url( + "/api/topics/show.json", + node_name=node_name, + page=1, ) data = _get_json(url) results = [] @@ -237,7 +244,7 @@ def get_topic(self, topic_id: int) -> dict: author, created, replies (list of dicts with: author, content, created) """ topic_data = _get_json( - f"https://www.v2ex.com/api/topics/show.json?id={topic_id}" + _v2ex_url("/api/topics/show.json", id=topic_id) ) # API returns a list even for single-ID queries if isinstance(topic_data, list): @@ -251,8 +258,11 @@ def get_topic(self, topic_id: int) -> dict: # Fetch replies (first page) try: replies_raw = _get_json( - f"https://www.v2ex.com/api/replies/show.json" - f"?topic_id={topic_id}&page=1" + _v2ex_url( + "/api/replies/show.json", + topic_id=topic_id, + page=1, + ) ) except Exception: replies_raw = [] @@ -269,7 +279,10 @@ def get_topic(self, topic_id: int) -> dict: return { "id": topic.get("id", topic_id), "title": topic.get("title", ""), - "url": topic.get("url", f"https://www.v2ex.com/t/{topic_id}"), + "url": topic.get( + "url", + f"{_API_BASE}/t/{quote(str(topic_id), safe='')}", + ), "content": topic.get("content", ""), "replies_count": topic.get("replies", 0), "node_name": node.get("name", ""), @@ -290,12 +303,15 @@ def get_user(self, username: str) -> dict: location, bio, avatar, created """ data = _get_json( - f"https://www.v2ex.com/api/members/show.json?username={username}" + _v2ex_url("/api/members/show.json", username=username) ) return { "id": data.get("id", 0), "username": data.get("username", username), - "url": data.get("url", f"https://www.v2ex.com/member/{username}"), + "url": data.get( + "url", + f"{_API_BASE}/member/{quote(str(username), safe='')}", + ), "website": data.get("website", ""), "twitter": data.get("twitter", ""), "psn": data.get("psn", ""), @@ -320,11 +336,12 @@ def search(self, query: str, limit: int = 10) -> list: list of dicts with keys: title, url, snippet 如果搜索不可用,返回包含单条 {"error": str} 的列表。 """ + search_url = _v2ex_url("/", q=query) return [ { "error": ( "V2EX 公开 API 不提供搜索端点。" - f"建议改用:https://www.v2ex.com/?q={query} " + f"建议改用:{search_url} " "或通过 Exa channel 使用 site:v2ex.com 搜索。" ) } diff --git a/tests/test_v2ex_channel.py b/tests/test_v2ex_channel.py index 7964fa81..4704c09a 100644 --- a/tests/test_v2ex_channel.py +++ b/tests/test_v2ex_channel.py @@ -16,6 +16,7 @@ import subprocess from unittest.mock import patch from urllib.error import URLError +from urllib.parse import parse_qs, urlsplit import pytest @@ -186,6 +187,28 @@ def test_get_node_topics_falls_back_to_requested_node_name(): assert topics[0]["node_name"] == "jobs" +@pytest.mark.parametrize( + "node_name", + ["python&page=99", "foo#bar", "c++", "Python 开发"], +) +def test_get_node_topics_percent_encodes_node_name(node_name): + ch = V2EXChannel() + captured = {} + + def fake_get_json(url): + captured["url"] = url + return [] + + with patch.object(v2, "_get_json", side_effect=fake_get_json): + ch.get_node_topics(node_name) + + parts = urlsplit(captured["url"]) + query = parse_qs(parts.query) + assert parts.fragment == "" + assert query["node_name"] == [node_name] + assert query["page"] == ["1"] + + # --- get_topic: list-or-dict shape + replies fetch + fallbacks --- def test_get_topic_unwraps_list_and_maps_replies(): @@ -226,6 +249,26 @@ def test_get_topic_url_fallback_when_missing(): assert result["url"] == "https://www.v2ex.com/t/99" +def test_get_topic_percent_encodes_topic_id_in_both_requests(): + ch = V2EXChannel() + captured = [] + + def fake_get_json(url): + captured.append(url) + return [{"id": 1}] if len(captured) == 1 else [] + + with patch.object(v2, "_get_json", side_effect=fake_get_json): + ch.get_topic("1#&page=99") + + topic_parts = urlsplit(captured[0]) + replies_parts = urlsplit(captured[1]) + assert topic_parts.fragment == "" + assert replies_parts.fragment == "" + assert parse_qs(topic_parts.query)["id"] == ["1#&page=99"] + assert parse_qs(replies_parts.query)["topic_id"] == ["1#&page=99"] + assert parse_qs(replies_parts.query)["page"] == ["1"] + + # --- get_user: field mapping + avatar/url fallbacks --- def test_get_user_maps_fields_and_prefers_large_avatar(): @@ -250,6 +293,33 @@ def test_get_user_avatar_falls_back_to_normal(): assert user["url"] == "https://www.v2ex.com/member/neo" +def test_get_user_percent_encodes_username(): + ch = V2EXChannel() + captured = {} + + def fake_get_json(url): + captured["url"] = url + return {} + + with patch.object(v2, "_get_json", side_effect=fake_get_json): + ch.get_user("张三&admin=true") + + parts = urlsplit(captured["url"]) + assert parts.fragment == "" + assert parse_qs(parts.query)["username"] == ["张三&admin=true"] + + +def test_fallback_display_urls_percent_encode_path_segments(): + ch = V2EXChannel() + with patch.object(v2, "_get_json", return_value={}): + user_url = ch.get_user("a b/c")["url"] + with patch.object(v2, "_get_json", side_effect=[{}, []]): + topic_url = ch.get_topic("9 9")["url"] + + assert user_url == "https://www.v2ex.com/member/a%20b%2Fc" + assert topic_url == "https://www.v2ex.com/t/9%209" + + # --- search: intentionally offline (no public search endpoint) --- def test_search_returns_guidance_without_network(): @@ -259,3 +329,11 @@ def test_search_returns_guidance_without_network(): assert len(results) == 1 assert "error" in results[0] assert "python" in results[0]["error"] + + +def test_search_guidance_percent_encodes_query(): + ch = V2EXChannel() + message = ch.search("rust & go#lang")[0]["error"] + + assert "?q=rust+%26+go%23lang" in message + assert "?q=rust & go#lang" not in message From e4e00c60a3dc1ed043d90afd53bc342121058b2c Mon Sep 17 00:00:00 2001 From: realMisakaMikoto Date: Thu, 6 Aug 2026 19:38:49 +0800 Subject: [PATCH 05/12] fix(skill): use shell-safe mcporter arguments --- agent_reach/guides/setup-exa.md | 2 +- agent_reach/guides/setup-reddit.md | 2 +- agent_reach/skill/SKILL.md | 2 +- agent_reach/skill/SKILL_en.md | 2 +- agent_reach/skill/references/career.md | 8 +++--- agent_reach/skill/references/search.md | 4 +-- agent_reach/skill/references/social.md | 6 ++--- agent_reach/skill/references/web.md | 6 ++--- docs/install.md | 4 +-- docs/troubleshooting.md | 2 +- tests/test_skill_command.py | 35 +++++++++++++++++++------- 11 files changed, 45 insertions(+), 28 deletions(-) diff --git a/agent_reach/guides/setup-exa.md b/agent_reach/guides/setup-exa.md index 3abc5ad0..3dc0ab89 100644 --- a/agent_reach/guides/setup-exa.md +++ b/agent_reach/guides/setup-exa.md @@ -24,7 +24,7 @@ mcporter config add exa https://mcp.exa.ai/mcp --scope home ### 3. 验证 ```bash agent-reach doctor | grep "Search" -mcporter call 'exa.web_search_exa(query: "test", numResults: 1)' +mcporter call exa.web_search_exa query="test" numResults=1 ``` ## 需要用户手动做的步骤 diff --git a/agent_reach/guides/setup-reddit.md b/agent_reach/guides/setup-reddit.md index 5c121586..8e733dd3 100644 --- a/agent_reach/guides/setup-reddit.md +++ b/agent_reach/guides/setup-reddit.md @@ -49,7 +49,7 @@ rdt read POST_ID 如果你已经配置了 Exa(通过 mcporter),也可以通过 Exa 搜索 Reddit 内容: ```bash -mcporter call 'exa.web_search_exa(query: "python best practices", numResults: 5, includeDomains: ["reddit.com"])' +mcporter call exa.web_search_exa query="site:reddit.com python best practices" numResults=5 ``` rdt-cli 是当前推荐方案,无需额外配置即可使用。 diff --git a/agent_reach/skill/SKILL.md b/agent_reach/skill/SKILL.md index 441cebc3..447f0271 100644 --- a/agent_reach/skill/SKILL.md +++ b/agent_reach/skill/SKILL.md @@ -58,7 +58,7 @@ metadata: ```bash # Exa 网页搜索 -mcporter call 'exa.web_search_exa(query: "query", numResults: 5)' +mcporter call exa.web_search_exa query="query" numResults=5 # 通用网页阅读 curl -s "https://r.jina.ai/URL" diff --git a/agent_reach/skill/SKILL_en.md b/agent_reach/skill/SKILL_en.md index 1d4e364a..78f26f9d 100644 --- a/agent_reach/skill/SKILL_en.md +++ b/agent_reach/skill/SKILL_en.md @@ -62,7 +62,7 @@ these platforms — do not invent your own approach.** ```bash # Exa web search -mcporter call 'exa.web_search_exa(query: "query", numResults: 5)' +mcporter call exa.web_search_exa query="query" numResults=5 # Read any web page curl -s "https://r.jina.ai/URL" diff --git a/agent_reach/skill/references/career.md b/agent_reach/skill/references/career.md index d67887bc..177716dd 100644 --- a/agent_reach/skill/references/career.md +++ b/agent_reach/skill/references/career.md @@ -6,16 +6,16 @@ LinkedIn。 ```bash # 获取个人资料 -mcporter call 'linkedin.get_person_profile(linkedin_username: "username", sections: "experience,education")' +mcporter call linkedin.get_person_profile linkedin_username="username" sections="experience,education" # 搜索人才 -mcporter call 'linkedin.search_people(keywords: "AI engineer", location: "Shanghai")' +mcporter call linkedin.search_people keywords="AI engineer" location="Shanghai" # 获取公司资料 -mcporter call 'linkedin.get_company_profile(company_name: "openai", sections: "posts,jobs")' +mcporter call linkedin.get_company_profile company_name="openai" sections="posts,jobs" # 搜索职位 -mcporter call 'linkedin.search_jobs(keywords: "software engineer", location: "Remote", max_pages: 2)' +mcporter call linkedin.search_jobs keywords="software engineer" location="Remote" max_pages=2 ``` > **需要登录**: 首次使用前运行 `uvx mcp-server-linkedin@latest --login`,保存有效登录态。 diff --git a/agent_reach/skill/references/search.md b/agent_reach/skill/references/search.md index ced10db8..d571c2ef 100644 --- a/agent_reach/skill/references/search.md +++ b/agent_reach/skill/references/search.md @@ -7,8 +7,8 @@ Exa AI 搜索引擎。 高质量 AI 搜索引擎,适合查找技术文档、官方示例和相关网页。 ```bash -mcporter call 'exa.web_search_exa(query: "query", numResults: 5)' -mcporter call 'exa.web_search_exa(query: "library API code example", numResults: 5)' +mcporter call exa.web_search_exa query="query" numResults=5 +mcporter call exa.web_search_exa query="library API code example" numResults=5 ``` ### 使用场景 diff --git a/agent_reach/skill/references/social.md b/agent_reach/skill/references/social.md index 1d5b6d50..a2b6f47b 100644 --- a/agent_reach/skill/references/social.md +++ b/agent_reach/skill/references/social.md @@ -38,13 +38,13 @@ opencli xiaohongshu user USER_ID -f yaml agent-reach configure xhs-cookies # 只读检查当前状态 -mcporter call 'xiaohongshu.check_login_status()' --timeout 120000 +mcporter call xiaohongshu.check_login_status --timeout 120000 # 搜索 -mcporter call 'xiaohongshu.search_feeds(keyword: "query")' --timeout 120000 +mcporter call xiaohongshu.search_feeds keyword="query" --timeout 120000 # 笔记详情+评论(feed_id 和 xsec_token 从搜索结果取) -mcporter call 'xiaohongshu.get_feed_detail(feed_id: "...", xsec_token: "...")' --timeout 120000 +mcporter call xiaohongshu.get_feed_detail feed_id="..." xsec_token="..." --timeout 120000 ``` > 首次调用会自动下载约 150MB 无头浏览器,务必带 `--timeout 120000`。 diff --git a/agent_reach/skill/references/web.md b/agent_reach/skill/references/web.md index 5977b1c4..bbfa3c04 100644 --- a/agent_reach/skill/references/web.md +++ b/agent_reach/skill/references/web.md @@ -18,13 +18,13 @@ curl -s "https://r.jina.ai/https://example.com/article" ```bash # 读取网页内容 (Markdown 格式) -mcporter call 'web-reader.webReader(url: "https://example.com")' +mcporter call web-reader.webReader url="https://example.com" # 保留图片 -mcporter call 'web-reader.webReader(url: "https://example.com", retain_images: true)' +mcporter call web-reader.webReader url="https://example.com" retain_images=true # 纯文本格式 -mcporter call 'web-reader.webReader(url: "https://example.com", return_format: "text")' +mcporter call web-reader.webReader url="https://example.com" return_format="text" ``` **适用场景**: 需要更精确控制输出格式时使用。 diff --git a/docs/install.md b/docs/install.md index 4c15e79d..1766eb05 100644 --- a/docs/install.md +++ b/docs/install.md @@ -366,10 +366,10 @@ After installation, use upstream tools directly. See SKILL.md for the full comma | Instagram | `opencli` | `opencli instagram user nasa -f yaml` | | GitHub | `gh` | `gh search repos "query"` | | Web | `curl` + Jina | `curl -s "https://r.jina.ai/URL"` | -| Exa Search | `mcporter` | `mcporter call 'exa.web_search_exa(...)'` | +| Exa Search | `mcporter` | `mcporter call exa.web_search_exa query="..." numResults=5` | | 小红书 | `opencli`(服务器 `mcporter`) | `opencli xiaohongshu search "query" -f yaml` | | 小宇宙播客 | `transcribe.sh` | `bash ~/.agent-reach/tools/xiaoyuzhou/transcribe.sh ` | -| LinkedIn | `mcporter` | `mcporter call 'linkedin.get_person_profile(...)'` | +| LinkedIn | `mcporter` | `mcporter call linkedin.get_person_profile linkedin_username="..."` | | RSS | `feedparser` | `python3 -c "import feedparser; ..."` | > 多后端平台以 `agent-reach doctor --json` 的 `active_backend` 为准。 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index fea4c4c3..c38a9681 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -52,7 +52,7 @@ proxychains twitter search "test" -n 1 twitter-cli 不可用时,可以直接用 Exa 搜索 Twitter 内容: ```bash -mcporter call 'exa.web_search_exa(query: "site:x.com 搜索词", numResults: 5)' +mcporter call exa.web_search_exa query="site:x.com 搜索词" numResults=5 ``` ### 方案 4:检查认证 diff --git a/tests/test_skill_command.py b/tests/test_skill_command.py index 33733ae7..4659099b 100644 --- a/tests/test_skill_command.py +++ b/tests/test_skill_command.py @@ -3,6 +3,7 @@ import importlib.resources import os +import re import tempfile import unittest from argparse import Namespace @@ -37,6 +38,22 @@ def test_exa_reference_uses_default_registered_tools_only(self): self.assertNotIn("exa.get_code_context_exa", search_reference) self.assertNotIn("get_code_context_exa(", search_reference) + def test_mcporter_examples_use_shell_safe_named_arguments(self): + """Packaged commands must survive PowerShell and POSIX parsing.""" + root = Path(__file__).resolve().parents[1] + markdown_files = [ + *(root / "agent_reach" / "skill").rglob("*.md"), + *(root / "agent_reach" / "guides").rglob("*.md"), + root / "docs" / "install.md", + root / "docs" / "troubleshooting.md", + ] + function_call = re.compile(r"mcporter call\s+['\"][^'\"\r\n]+\(") + + for markdown_file in markdown_files: + with self.subTest(markdown_file=markdown_file): + content = markdown_file.read_text(encoding="utf-8") + self.assertNotRegex(content, function_call) + def test_linkedin_reference_uses_current_tool_contract(self): """LinkedIn examples should use the current server and parameters.""" career_reference = ( @@ -46,24 +63,24 @@ def test_linkedin_reference_uses_current_tool_contract(self): ) self.assertIn( - "linkedin.get_person_profile(" - 'linkedin_username: "username", ' - 'sections: "experience,education")', + "linkedin.get_person_profile " + 'linkedin_username="username" ' + 'sections="experience,education"', career_reference, ) self.assertIn( - 'linkedin.search_people(keywords: "AI engineer", ' - 'location: "Shanghai")', + 'linkedin.search_people keywords="AI engineer" ' + 'location="Shanghai"', career_reference, ) self.assertIn( - 'linkedin.get_company_profile(company_name: "openai", ' - 'sections: "posts,jobs")', + '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)', + 'linkedin.search_jobs keywords="software engineer" ' + 'location="Remote" max_pages=2', career_reference, ) self.assertNotIn("linkedin-scraper.", career_reference) From 6e92782f53f16697ce5bff98d614e388af5fdc23 Mon Sep 17 00:00:00 2001 From: nyxst4ck <289980115+nyxst4ck@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:39:25 +0800 Subject: [PATCH 06/12] docs: quote pip extras install examples --- docs/dependency-locking.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dependency-locking.md b/docs/dependency-locking.md index 380814fb..23a3fdb4 100644 --- a/docs/dependency-locking.md +++ b/docs/dependency-locking.md @@ -11,7 +11,7 @@ Agent Reach uses `constraints.txt` as a reproducible dependency baseline. ## Install with constraints ```bash -pip install -c constraints.txt -e .[dev] +pip install -c constraints.txt -e '.[dev]' ``` ## Update workflow From e0fc5b78918c61a841245badc7fd899efa910749 Mon Sep 17 00:00:00 2001 From: jiaru <16186692+hellojiaru@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:41:39 +0800 Subject: [PATCH 07/12] test(ci): add Windows suite coverage --- .github/workflows/pytest.yml | 20 ++++++++++++++++++++ tests/test_paths.py | 10 ++++++++-- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 70074661..f6724071 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -29,6 +29,26 @@ jobs: run: | pytest -q + windows-test: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install package and test deps + run: | + python -m pip install --upgrade pip + python -m pip install -c constraints.txt -e ".[dev]" + + - name: Run tests + run: | + pytest -q + # Editable installs (-e) never exercise wheel packaging, so a broken wheel # can pass tests and still fail every real `pip install` from source. # This job builds the actual wheel and installs it into a clean venv. diff --git a/tests/test_paths.py b/tests/test_paths.py index 1f3425f1..c0d0bbde 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -1,9 +1,12 @@ # -*- coding: utf-8 -*- """Behavior tests for cross-platform path and remediation helpers.""" +import shutil import subprocess from pathlib import Path +import pytest + from agent_reach.utils import paths @@ -17,8 +20,11 @@ def test_posix_ytdlp_fix_is_single_line_executable_and_idempotent( command = paths.render_ytdlp_fix_command() assert "\n" not in command - subprocess.run(["/bin/sh", "-c", command], check=True) - subprocess.run(["/bin/sh", "-c", command], check=True) + shell = shutil.which("sh") + if not shell: + pytest.skip("POSIX sh is unavailable on this platform") + subprocess.run([shell, "-c", command], check=True) + subprocess.run([shell, "-c", command], check=True) config = tmp_path / ".config" / "yt-dlp" / "config" assert config.read_text(encoding="utf-8") == "--js-runtimes node\n" From 3ab50bc024d5e126f210e00e032ce121980eac37 Mon Sep 17 00:00:00 2001 From: Pnant <73925474+Panniantong@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:51:43 +0800 Subject: [PATCH 08/12] test(ci): run Windows shell checks with Git Bash --- .github/workflows/pytest.yml | 1 + tests/test_integration_script.py | 2 +- tests/test_opencode_skill.py | 6 +++++- tests/test_p0_cli.py | 1 - tests/test_xiaoyuzhou_install.py | 9 +++++++-- 5 files changed, 14 insertions(+), 5 deletions(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index f6724071..7171f86f 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -46,6 +46,7 @@ jobs: python -m pip install -c constraints.txt -e ".[dev]" - name: Run tests + shell: bash run: | pytest -q diff --git a/tests/test_integration_script.py b/tests/test_integration_script.py index 061a450d..192d3cc1 100644 --- a/tests/test_integration_script.py +++ b/tests/test_integration_script.py @@ -6,7 +6,7 @@ def test_integration_script_has_valid_shell_syntax(): - subprocess.run(["bash", "-n", str(SCRIPT)], check=True) + subprocess.run(["bash", "-n", SCRIPT.name], check=True, cwd=ROOT) def test_integration_script_exercises_the_current_cli_contract(): diff --git a/tests/test_opencode_skill.py b/tests/test_opencode_skill.py index 34326de3..bee5d391 100644 --- a/tests/test_opencode_skill.py +++ b/tests/test_opencode_skill.py @@ -90,7 +90,11 @@ def test_full_uninstall_includes_opencode_directory( with patch( "agent_reach.cli.os.path.expanduser", - side_effect=lambda value: value.replace("~", os.fspath(tmp_path)), + side_effect=lambda value: os.fspath( + tmp_path / value.removeprefix("~/") + ) + if value.startswith("~/") + else value, ), patch("agent_reach.utils.paths.home_dir", return_value=tmp_path), patch( "shutil.which", return_value=None ): diff --git a/tests/test_p0_cli.py b/tests/test_p0_cli.py index c64e1871..12196935 100644 --- a/tests/test_p0_cli.py +++ b/tests/test_p0_cli.py @@ -699,7 +699,6 @@ def test_system_install_uses_ytdlp_first_user_config( import agent_reach.utils.paths as paths - monkeypatch.setattr(paths.sys, "platform", "darwin") monkeypatch.setattr(paths.Path, "home", classmethod(lambda cls: tmp_path)) monkeypatch.delenv("XDG_CONFIG_HOME") monkeypatch.setattr( diff --git a/tests/test_xiaoyuzhou_install.py b/tests/test_xiaoyuzhou_install.py index 7993fc23..f49f1f7e 100644 --- a/tests/test_xiaoyuzhou_install.py +++ b/tests/test_xiaoyuzhou_install.py @@ -59,12 +59,17 @@ def test_install_xiaoyuzhou_deps_replaces_stale_managed_script( assert installed.read_text(encoding="utf-8") == TRANSCRIBE_SCRIPT.read_text( encoding="utf-8" ) - assert installed.stat().st_mode & stat.S_IXUSR + if os.name != "nt": + 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) + subprocess.run( + ["bash", "-n", TRANSCRIBE_SCRIPT.relative_to(ROOT).as_posix()], + check=True, + cwd=ROOT, + ) def test_transcribe_script_handles_git_bash_python_and_size_math(): From 00e782a280c773f71c7440bbec08eb84e724dffb Mon Sep 17 00:00:00 2001 From: Pnant <73925474+Panniantong@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:54:59 +0800 Subject: [PATCH 09/12] test(ci): prefer Git Bash on Windows --- .github/workflows/pytest.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 7171f86f..4371ace8 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -48,6 +48,7 @@ jobs: - name: Run tests shell: bash run: | + export PATH="/c/Program Files/Git/bin:/c/Program Files/Git/usr/bin:$PATH" pytest -q # Editable installs (-e) never exercise wheel packaging, so a broken wheel From 8a4785ece1b508aa08d1da84b22d536f7092e3a8 Mon Sep 17 00:00:00 2001 From: Pnant <73925474+Panniantong@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:59:02 +0800 Subject: [PATCH 10/12] test(ci): locate real Bash on Windows --- tests/conftest.py | 64 ++++++++++++++++++++++++++++++++ tests/test_integration_script.py | 4 +- tests/test_xiaoyuzhou_install.py | 62 ++++++++++++++++++++++--------- 3 files changed, 111 insertions(+), 19 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 1d0f1704..6d203e96 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,11 +1,75 @@ # -*- coding: utf-8 -*- """Suite-wide containment for tests that exercise user-facing installers.""" +import os +import shutil +import subprocess +from pathlib import Path + import pytest from agent_reach.config import Config +@pytest.fixture(scope="session") +def bash_executable() -> str: + """Return a real GNU Bash, avoiding Windows' WSL launcher stub.""" + candidates: list[Path] = [] + override = os.environ.get("AGENT_REACH_TEST_BASH") + if override: + candidates.append(Path(override)) + + if os.name == "nt": + for env_name in ("PROGRAMFILES", "PROGRAMFILES(X86)"): + program_files = os.environ.get(env_name) + if program_files: + git_root = Path(program_files) / "Git" + candidates.extend( + (git_root / "bin" / "bash.exe", git_root / "usr" / "bin" / "bash.exe") + ) + + local_app_data = os.environ.get("LOCALAPPDATA") + if local_app_data: + git_root = Path(local_app_data) / "Programs" / "Git" + candidates.extend( + (git_root / "bin" / "bash.exe", git_root / "usr" / "bin" / "bash.exe") + ) + + git = shutil.which("git") + if git: + git_parent = Path(git).resolve().parent + if git_parent.name.lower() in {"bin", "cmd"}: + git_root = git_parent.parent + candidates.extend( + (git_root / "bin" / "bash.exe", git_root / "usr" / "bin" / "bash.exe") + ) + + discovered = shutil.which("bash") + if discovered: + candidates.append(Path(discovered)) + + seen: set[str] = set() + for candidate in candidates: + key = os.path.normcase(os.fspath(candidate)) + if key in seen or not candidate.is_file(): + continue + seen.add(key) + try: + result = subprocess.run( + [os.fspath(candidate), "--version"], + capture_output=True, + encoding="utf-8", + errors="replace", + timeout=5, + ) + except (OSError, subprocess.TimeoutExpired): + continue + if result.returncode == 0 and "GNU bash" in result.stdout: + return os.fspath(candidate) + + pytest.fail("GNU Bash is required for shell-script tests") + + @pytest.fixture(autouse=True) def isolated_home(tmp_path, monkeypatch): """Redirect every common home/config root before each test runs.""" diff --git a/tests/test_integration_script.py b/tests/test_integration_script.py index 192d3cc1..a3058ec4 100644 --- a/tests/test_integration_script.py +++ b/tests/test_integration_script.py @@ -5,8 +5,8 @@ SCRIPT = ROOT / "test.sh" -def test_integration_script_has_valid_shell_syntax(): - subprocess.run(["bash", "-n", SCRIPT.name], check=True, cwd=ROOT) +def test_integration_script_has_valid_shell_syntax(bash_executable): + subprocess.run([bash_executable, "-n", SCRIPT.name], check=True, cwd=ROOT) def test_integration_script_exercises_the_current_cli_contract(): diff --git a/tests/test_xiaoyuzhou_install.py b/tests/test_xiaoyuzhou_install.py index f49f1f7e..e13a1c87 100644 --- a/tests/test_xiaoyuzhou_install.py +++ b/tests/test_xiaoyuzhou_install.py @@ -64,9 +64,9 @@ def test_install_xiaoyuzhou_deps_replaces_stale_managed_script( assert "script updated" in capsys.readouterr().out -def test_transcribe_script_is_cross_platform_shell_syntax(): +def test_transcribe_script_is_cross_platform_shell_syntax(bash_executable): subprocess.run( - ["bash", "-n", TRANSCRIBE_SCRIPT.relative_to(ROOT).as_posix()], + [bash_executable, "-n", TRANSCRIBE_SCRIPT.relative_to(ROOT).as_posix()], check=True, cwd=ROOT, ) @@ -86,6 +86,14 @@ def _write_executable(path: Path, content: str) -> None: path.chmod(0o755) +def _bash_path(path: Path) -> str: + """Render a native path for a Bash process, including Git Bash on Windows.""" + rendered = path.resolve().as_posix() + if os.name == "nt" and len(rendered) >= 3 and rendered[1:3] == ":/": + return f"/{rendered[0].lower()}{rendered[2:]}" + return rendered + + def _script_env(tmp_path: Path, curl_script: str) -> tuple[dict[str, str], Path, Path]: fake_bin = tmp_path / "bin" fake_bin.mkdir() @@ -96,10 +104,10 @@ def _script_env(tmp_path: Path, curl_script: str) -> tuple[dict[str, str], Path, env = os.environ.copy() env.update({ - "CURL_LOG": str(curl_log), + "CURL_LOG": _bash_path(curl_log), "GROQ_API_KEY": "test-key", "PATH": f"{fake_bin}{os.pathsep}{env['PATH']}", - "TMPDIR": str(temp_root), + "TMPDIR": _bash_path(temp_root), }) return env, curl_log, temp_root @@ -117,17 +125,25 @@ def _assert_work_dir_cleaned(temp_root: Path) -> None: "https://evil.example/episode/123?next=xiaoyuzhoufm.com", ], ) -def test_transcribe_script_rejects_non_xiaoyuzhou_urls_before_curl(tmp_path, url): +def test_transcribe_script_rejects_non_xiaoyuzhou_urls_before_curl( + tmp_path, url, bash_executable +): 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")], + [ + bash_executable, + TRANSCRIBE_SCRIPT.relative_to(ROOT).as_posix(), + url, + _bash_path(tmp_path / "out.txt"), + ], capture_output=True, text=True, env=env, + cwd=ROOT, ) assert result.returncode != 0 @@ -143,17 +159,25 @@ def test_transcribe_script_rejects_non_xiaoyuzhou_urls_before_curl(tmp_path, url "https://www.xiaoyuzhoufm.com/episode/123", ], ) -def test_transcribe_script_accepts_http_xiaoyuzhou_hosts(tmp_path, url): +def test_transcribe_script_accepts_http_xiaoyuzhou_hosts( + tmp_path, url, bash_executable +): 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")], + [ + bash_executable, + TRANSCRIBE_SCRIPT.relative_to(ROOT).as_posix(), + url, + _bash_path(tmp_path / "out.txt"), + ], capture_output=True, text=True, env=env, + cwd=ROOT, ) assert result.returncode != 0 @@ -190,7 +214,9 @@ def test_transcribe_script_uses_secure_temp_and_bounded_curl_calls(): @pytest.mark.parametrize("ffprobe_output", ["", "not-a-number"]) -def test_transcribe_script_fails_clearly_for_invalid_duration(tmp_path, ffprobe_output): +def test_transcribe_script_fails_clearly_for_invalid_duration( + tmp_path, ffprobe_output, bash_executable +): env, _, temp_root = _script_env( tmp_path, """#!/bin/bash @@ -218,14 +244,15 @@ def test_transcribe_script_fails_clearly_for_invalid_duration(tmp_path, ffprobe_ result = subprocess.run( [ - "bash", - str(TRANSCRIBE_SCRIPT), + bash_executable, + TRANSCRIBE_SCRIPT.relative_to(ROOT).as_posix(), "https://www.xiaoyuzhoufm.com/episode/123", - str(tmp_path / "out.txt"), + _bash_path(tmp_path / "out.txt"), ], capture_output=True, text=True, env=env, + cwd=ROOT, ) assert result.returncode != 0 @@ -235,7 +262,7 @@ def test_transcribe_script_fails_clearly_for_invalid_duration(tmp_path, ffprobe_ @pytest.mark.parametrize("ffprobe_output", ["10801", "9" * 500]) def test_transcribe_script_rejects_overlong_audio_before_ffmpeg_or_groq( - tmp_path, ffprobe_output + tmp_path, ffprobe_output, bash_executable ): env, curl_log, temp_root = _script_env( tmp_path, @@ -266,19 +293,20 @@ def test_transcribe_script_rejects_overlong_audio_before_ffmpeg_or_groq( tmp_path / "bin" / "ffmpeg", "#!/bin/sh\nprintf 'called' > \"$FFMPEG_MARKER\"\n", ) - env["FFMPEG_MARKER"] = str(ffmpeg_marker) + env["FFMPEG_MARKER"] = _bash_path(ffmpeg_marker) env["FFPROBE_OUTPUT"] = ffprobe_output result = subprocess.run( [ - "bash", - str(TRANSCRIBE_SCRIPT), + bash_executable, + TRANSCRIBE_SCRIPT.relative_to(ROOT).as_posix(), "https://www.xiaoyuzhoufm.com/episode/123", - str(tmp_path / "out.txt"), + _bash_path(tmp_path / "out.txt"), ], capture_output=True, text=True, env=env, + cwd=ROOT, ) assert result.returncode != 0 From e6c066de90f46dd11a92da215d695bb7b6b23fbe Mon Sep 17 00:00:00 2001 From: Pnant <73925474+Panniantong@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:02:02 +0800 Subject: [PATCH 11/12] test(windows): decode Git Bash output as UTF-8 --- tests/test_xiaoyuzhou_install.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/test_xiaoyuzhou_install.py b/tests/test_xiaoyuzhou_install.py index e13a1c87..f459c2bd 100644 --- a/tests/test_xiaoyuzhou_install.py +++ b/tests/test_xiaoyuzhou_install.py @@ -141,7 +141,8 @@ def test_transcribe_script_rejects_non_xiaoyuzhou_urls_before_curl( _bash_path(tmp_path / "out.txt"), ], capture_output=True, - text=True, + encoding="utf-8", + errors="replace", env=env, cwd=ROOT, ) @@ -175,7 +176,8 @@ def test_transcribe_script_accepts_http_xiaoyuzhou_hosts( _bash_path(tmp_path / "out.txt"), ], capture_output=True, - text=True, + encoding="utf-8", + errors="replace", env=env, cwd=ROOT, ) @@ -250,7 +252,8 @@ def test_transcribe_script_fails_clearly_for_invalid_duration( _bash_path(tmp_path / "out.txt"), ], capture_output=True, - text=True, + encoding="utf-8", + errors="replace", env=env, cwd=ROOT, ) @@ -304,7 +307,8 @@ def test_transcribe_script_rejects_overlong_audio_before_ffmpeg_or_groq( _bash_path(tmp_path / "out.txt"), ], capture_output=True, - text=True, + encoding="utf-8", + errors="replace", env=env, cwd=ROOT, ) From 101276da85cbcc373819d2d4df35483abf3e8dd8 Mon Sep 17 00:00:00 2001 From: Pnant <73925474+Panniantong@users.noreply.github.com> Date: Thu, 6 Aug 2026 20:06:15 +0800 Subject: [PATCH 12/12] test(windows): inject shell command doubles portably --- tests/test_xiaoyuzhou_install.py | 50 ++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 21 deletions(-) diff --git a/tests/test_xiaoyuzhou_install.py b/tests/test_xiaoyuzhou_install.py index f459c2bd..15eb0157 100644 --- a/tests/test_xiaoyuzhou_install.py +++ b/tests/test_xiaoyuzhou_install.py @@ -81,11 +81,6 @@ def test_transcribe_script_handles_git_bash_python_and_size_math(): assert "| bc" not in text -def _write_executable(path: Path, content: str) -> None: - path.write_text(content, encoding="utf-8") - path.chmod(0o755) - - def _bash_path(path: Path) -> str: """Render a native path for a Bash process, including Git Bash on Windows.""" rendered = path.resolve().as_posix() @@ -94,22 +89,32 @@ def _bash_path(path: Path) -> str: return rendered -def _script_env(tmp_path: Path, curl_script: str) -> tuple[dict[str, str], Path, Path]: - fake_bin = tmp_path / "bin" - fake_bin.mkdir() +def _append_bash_function(path: Path, name: str, script: str) -> None: + lines = script.splitlines() + if lines and lines[0].startswith("#!"): + lines = lines[1:] + body = "\n".join(lines) + with path.open("a", encoding="utf-8") as handle: + handle.write(f"{name}() {{\n{body}\n}}\n") + + +def _script_env( + tmp_path: Path, curl_script: str +) -> tuple[dict[str, str], Path, Path, Path]: curl_log = tmp_path / "curl.log" temp_root = tmp_path / "tmp" temp_root.mkdir() - _write_executable(fake_bin / "curl", curl_script) + bash_env = tmp_path / "bash-env.sh" + _append_bash_function(bash_env, "curl", curl_script) env = os.environ.copy() env.update({ + "BASH_ENV": _bash_path(bash_env), "CURL_LOG": _bash_path(curl_log), "GROQ_API_KEY": "test-key", - "PATH": f"{fake_bin}{os.pathsep}{env['PATH']}", "TMPDIR": _bash_path(temp_root), }) - return env, curl_log, temp_root + return env, curl_log, temp_root, bash_env def _assert_work_dir_cleaned(temp_root: Path) -> None: @@ -128,7 +133,7 @@ def _assert_work_dir_cleaned(temp_root: Path) -> None: def test_transcribe_script_rejects_non_xiaoyuzhou_urls_before_curl( tmp_path, url, bash_executable ): - env, curl_log, temp_root = _script_env( + env, curl_log, temp_root, _ = _script_env( tmp_path, "#!/bin/sh\nprintf 'called\\n' >> \"$CURL_LOG\"\nexit 42\n", ) @@ -163,7 +168,7 @@ def test_transcribe_script_rejects_non_xiaoyuzhou_urls_before_curl( def test_transcribe_script_accepts_http_xiaoyuzhou_hosts( tmp_path, url, bash_executable ): - env, curl_log, temp_root = _script_env( + env, curl_log, temp_root, _ = _script_env( tmp_path, "#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CURL_LOG\"\nexit 42\n", ) @@ -219,7 +224,7 @@ def test_transcribe_script_uses_secure_temp_and_bounded_curl_calls(): def test_transcribe_script_fails_clearly_for_invalid_duration( tmp_path, ffprobe_output, bash_executable ): - env, _, temp_root = _script_env( + env, _, temp_root, bash_env = _script_env( tmp_path, """#!/bin/bash output="" @@ -238,8 +243,9 @@ def test_transcribe_script_fails_clearly_for_invalid_duration( fi """, ) - _write_executable( - tmp_path / "bin" / "ffprobe", + _append_bash_function( + bash_env, + "ffprobe", "#!/bin/sh\nprintf '%s' \"$FFPROBE_OUTPUT\"\n", ) env["FFPROBE_OUTPUT"] = ffprobe_output @@ -267,7 +273,7 @@ def test_transcribe_script_fails_clearly_for_invalid_duration( def test_transcribe_script_rejects_overlong_audio_before_ffmpeg_or_groq( tmp_path, ffprobe_output, bash_executable ): - env, curl_log, temp_root = _script_env( + env, curl_log, temp_root, bash_env = _script_env( tmp_path, """#!/bin/bash printf '%s\n' "$*" >> "$CURL_LOG" @@ -288,12 +294,14 @@ def test_transcribe_script_rejects_overlong_audio_before_ffmpeg_or_groq( """, ) ffmpeg_marker = tmp_path / "ffmpeg-called" - _write_executable( - tmp_path / "bin" / "ffprobe", + _append_bash_function( + bash_env, + "ffprobe", "#!/bin/sh\nprintf '%s' \"$FFPROBE_OUTPUT\"\n", ) - _write_executable( - tmp_path / "bin" / "ffmpeg", + _append_bash_function( + bash_env, + "ffmpeg", "#!/bin/sh\nprintf 'called' > \"$FFMPEG_MARKER\"\n", ) env["FFMPEG_MARKER"] = _bash_path(ffmpeg_marker)