From 1ff27218672cbd0d4e5634f58521635275b15c81 Mon Sep 17 00:00:00 2001 From: Juemuel <52780296+juemuel@users.noreply.github.com> Date: Mon, 22 Jun 2026 01:59:41 +0800 Subject: [PATCH 1/9] feat(paths): centralize managed data paths --- agent_reach/paths.py | 37 +++++++++++++++++++++++++++++++++++++ tests/test_paths.py | 39 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 agent_reach/paths.py create mode 100644 tests/test_paths.py diff --git a/agent_reach/paths.py b/agent_reach/paths.py new file mode 100644 index 00000000..9e79e5c0 --- /dev/null +++ b/agent_reach/paths.py @@ -0,0 +1,37 @@ +"""Runtime path resolution for Agent Reach-owned data.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from pathlib import Path + +AGENT_REACH_HOME_ENV = "AGENT_REACH_HOME" + + +def agent_reach_home(env: Mapping[str, str] | None = None) -> Path: + values = os.environ if env is None else env + configured = values.get(AGENT_REACH_HOME_ENV, "").strip() + if not configured: + return Path.home() / ".agent-reach" + + path = Path(configured).expanduser() + if not path.is_absolute(): + raise ValueError("AGENT_REACH_HOME must be an absolute path") + return path + + +def config_file(env: Mapping[str, str] | None = None) -> Path: + return agent_reach_home(env) / "config.yaml" + + +def tools_dir(env: Mapping[str, str] | None = None) -> Path: + return agent_reach_home(env) / "tools" + + +def xiaoyuzhou_tools_dir(env: Mapping[str, str] | None = None) -> Path: + return tools_dir(env) / "xiaoyuzhou" + + +def xhs_cookie_file(env: Mapping[str, str] | None = None) -> Path: + return agent_reach_home(env) / "xhs-cookies.json" diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 00000000..dd936ba5 --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,39 @@ +"""Tests for agent_reach.paths — runtime path resolution.""" + +from pathlib import Path + +import pytest + +from agent_reach.paths import ( + agent_reach_home, + config_file, + tools_dir, + xhs_cookie_file, + xiaoyuzhou_tools_dir, +) + + +def test_default_paths_derive_from_user_home(monkeypatch, tmp_path): + monkeypatch.delenv("AGENT_REACH_HOME", raising=False) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + + assert agent_reach_home() == tmp_path / ".agent-reach" + assert config_file() == tmp_path / ".agent-reach" / "config.yaml" + assert tools_dir() == tmp_path / ".agent-reach" / "tools" + assert xiaoyuzhou_tools_dir() == tmp_path / ".agent-reach" / "tools" / "xiaoyuzhou" + assert xhs_cookie_file() == tmp_path / ".agent-reach" / "xhs-cookies.json" + + +def test_custom_home_overrides_default(monkeypatch, tmp_path): + custom = tmp_path / "central" / "agent-reach" + monkeypatch.setenv("AGENT_REACH_HOME", str(custom)) + + assert agent_reach_home() == custom + assert config_file() == custom / "config.yaml" + + +def test_relative_custom_home_is_rejected(monkeypatch): + monkeypatch.setenv("AGENT_REACH_HOME", "relative/agent-reach") + + with pytest.raises(ValueError, match="AGENT_REACH_HOME must be an absolute path"): + agent_reach_home() From 72485167ade44921320cc8ca29488ab26ba3a6f4 Mon Sep 17 00:00:00 2001 From: Juemuel <52780296+juemuel@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:05:37 +0800 Subject: [PATCH 2/9] refactor(paths): use managed root consistently --- agent_reach/channels/xiaoyuzhou.py | 3 +- agent_reach/cli.py | 71 +++++++++++++----------------- agent_reach/config.py | 10 ++++- agent_reach/doctor.py | 3 +- tests/test_cli.py | 38 ++++++++++++++++ tests/test_paths.py | 25 +++++++++++ 6 files changed, 107 insertions(+), 43 deletions(-) diff --git a/agent_reach/channels/xiaoyuzhou.py b/agent_reach/channels/xiaoyuzhou.py index efc73b78..49e28eb2 100644 --- a/agent_reach/channels/xiaoyuzhou.py +++ b/agent_reach/channels/xiaoyuzhou.py @@ -4,6 +4,7 @@ import os from agent_reach.config import Config from agent_reach.probe import probe_command +from agent_reach.paths import xiaoyuzhou_tools_dir from .base import Channel @@ -36,7 +37,7 @@ def check(self, config=None): ) # Check script exists - script = os.path.expanduser("~/.agent-reach/tools/xiaoyuzhou/transcribe.sh") + script = str(xiaoyuzhou_tools_dir() / "transcribe.sh") if not os.path.isfile(script): return "off", ( "转录脚本未安装。运行:\n" diff --git a/agent_reach/cli.py b/agent_reach/cli.py index 00ecf0f5..014a54d8 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -16,6 +16,7 @@ import time from agent_reach import __version__ +from agent_reach.paths import agent_reach_home, tools_dir, xhs_cookie_file, xiaoyuzhou_tools_dir # 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" @@ -183,8 +184,7 @@ def _cmd_install(args): print("=" * 40) # Ensure tools directory exists (for upstream tool repos) - tools_dir = os.path.expanduser("~/.agent-reach/tools") - os.makedirs(tools_dir, exist_ok=True) + tools_dir().mkdir(parents=True, exist_ok=True) if dry_run: print("DRY RUN — showing what would be done (no changes)") @@ -441,27 +441,29 @@ def _copy_skill_dir(target: str) -> bool: print(" -- Tip: install OpenClaw, Claude Code, or create ~/.agents/skills/ manually") -def _uninstall_skill(): - """Remove SKILL.md from all known agent skill directories.""" - import shutil - +def _skill_install_targets() -> list: + """Return list of (path, platform_name) tuples for all skill directories.""" skill_dirs = [ - ("~/.openclaw/skills/agent-reach", "OpenClaw"), - ("~/.claude/skills/agent-reach", "Claude Code"), - ("~/.agents/skills/agent-reach", "Agent"), + (os.path.expanduser("~/.openclaw/skills/agent-reach"), "OpenClaw"), + (os.path.expanduser("~/.claude/skills/agent-reach"), "Claude Code"), + (os.path.expanduser("~/.agents/skills/agent-reach"), "Agent"), ] - # Also check OPENCLAW_HOME openclaw_home = os.environ.get("OPENCLAW_HOME") if openclaw_home: skill_dirs.insert( 0, (os.path.join(openclaw_home, ".openclaw", "skills", "agent-reach"), "OpenClaw"), ) + return skill_dirs + + +def _uninstall_skill(): + """Remove SKILL.md from all known agent skill directories.""" + import shutil removed = False - for skill_path_template, platform_name in skill_dirs: - skill_path = os.path.expanduser(skill_path_template) + for skill_path, platform_name in _skill_install_targets(): if os.path.isdir(skill_path): try: if os.path.islink(skill_path): @@ -642,20 +644,20 @@ def _install_xiaoyuzhou_deps(): config = Config() print("Setting up Xiaoyuzhou podcast transcription...") - tools_dir = os.path.expanduser("~/.agent-reach/tools/xiaoyuzhou") - script_dst = os.path.join(tools_dir, "transcribe.sh") + tools_path = xiaoyuzhou_tools_dir() + script_dst = tools_path / "transcribe.sh" - if os.path.isfile(script_dst): + if script_dst.is_file(): print(" ✅ Xiaoyuzhou transcription script already installed") 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) + tools_path.mkdir(parents=True, exist_ok=True) import shutil as _shutil - _shutil.copy2(script_src, script_dst) - os.chmod(script_dst, 0o755) + _shutil.copy2(script_src, str(script_dst)) + os.chmod(str(script_dst), 0o755) print(" ✅ Xiaoyuzhou transcription script installed") except Exception as e: print(f" [!] Failed to install script: {e}") @@ -1238,7 +1240,7 @@ def _configure_xhs_cookies(value): # between open() and a follow-up chmod() (same pattern Config.save() # uses in config.py). import stat - cookie_path = os.path.expanduser("~/.agent-reach/xhs-cookies.json") + cookie_path = str(xhs_cookie_file()) try: fd = os.open( cookie_path, @@ -1362,7 +1364,7 @@ def _cmd_uninstall(args): removed_any = False # ── 1. Config directory (~/.agent-reach/) ── - config_dir = os.path.expanduser("~/.agent-reach") + config_dir = str(agent_reach_home()) if not keep_config: if os.path.isdir(config_dir): if dry_run: @@ -1381,27 +1383,16 @@ def _cmd_uninstall(args): print(f" Skipping config directory (--keep-config): {config_dir}") # ── 2. Skill files ── - skill_dirs = [ - ("~/.openclaw/skills/agent-reach", "OpenClaw"), - ("~/.claude/skills/agent-reach", "Claude Code"), - ("~/.agents/skills/agent-reach", "Agent"), - ] - - for skill_path_template, platform_name in skill_dirs: - skill_path = os.path.expanduser(skill_path_template) - if os.path.isdir(skill_path): - if dry_run: + if dry_run: + for skill_path, platform_name in _skill_install_targets(): + if os.path.isdir(skill_path): print(f"[dry-run] Would remove {platform_name} skill: {skill_path}") - else: - try: - if os.path.islink(skill_path): - os.unlink(skill_path) - else: - shutil.rmtree(skill_path) - print(f" Removed {platform_name} skill: {skill_path}") - removed_any = True - except Exception as e: - print(f" Could not remove {skill_path}: {e}") + else: + _uninstall_skill() + # Track removal for summary — _uninstall_skill prints its own messages + if any(os.path.isdir(p) for p, _ in _skill_install_targets()): + pass # _uninstall_skill already handled printing + removed_any = True # ── 3. mcporter MCP entries ── if shutil.which("mcporter"): diff --git a/agent_reach/config.py b/agent_reach/config.py index 4386bb47..7a12b6ec 100644 --- a/agent_reach/config.py +++ b/agent_reach/config.py @@ -11,6 +11,8 @@ import yaml +from agent_reach.paths import AGENT_REACH_HOME_ENV, config_file + class Config: """Manages Agent Reach configuration.""" @@ -28,7 +30,13 @@ class Config: } def __init__(self, config_path: Optional[Path] = None): - self.config_path = Path(config_path) if config_path else self.CONFIG_FILE + if config_path is not None: + selected_path = Path(config_path) + elif os.environ.get(AGENT_REACH_HOME_ENV, "").strip(): + selected_path = config_file() + else: + selected_path = self.CONFIG_FILE + self.config_path = selected_path self.config_dir = self.config_path.parent self.data: dict = {} self._ensure_dir() diff --git a/agent_reach/doctor.py b/agent_reach/doctor.py index 6060f877..e37b9f7e 100644 --- a/agent_reach/doctor.py +++ b/agent_reach/doctor.py @@ -111,7 +111,8 @@ def format_report(results: Dict[str, dict]) -> str: import stat import sys - config_path = Config.CONFIG_DIR / "config.yaml" + from agent_reach.paths import config_file + config_path = config_file() if config_path.exists() and sys.platform != "win32": try: mode = config_path.stat().st_mode diff --git a/tests/test_cli.py b/tests/test_cli.py index cc420fbd..f674f8ae 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -222,3 +222,41 @@ def json(): out = capsys.readouterr().out assert "新版本可用" not in out assert "全部正常" in out + + +class TestManagedPathsIntegration: + """Integration tests verifying CLI call sites use managed paths.""" + + def test_install_creates_tools_under_custom_home(self, monkeypatch, tmp_path): + custom = tmp_path / "custom" + monkeypatch.setenv("AGENT_REACH_HOME", str(custom)) + monkeypatch.setattr(cli, "_detect_environment", lambda: "local") + monkeypatch.setattr(cli, "_install_system_deps_safe", lambda: None) + monkeypatch.setattr(cli, "_install_mcporter_safe", lambda: None) + 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: "") + + args = type("Args", (), { + "safe": True, + "system": False, + "dry_run": False, + "channels": "", + "env": "local", + "proxy": "", + })() + cli._cmd_install(args) + + assert (custom / "tools").is_dir() + + def test_uninstall_targets_custom_home(self, monkeypatch, tmp_path): + custom = tmp_path / "custom" + custom.mkdir() + (custom / "config.yaml").write_text("key: value", encoding="utf-8") + monkeypatch.setenv("AGENT_REACH_HOME", str(custom)) + monkeypatch.setattr(cli, "_uninstall_skill", lambda: None) + + args = type("Args", (), {"dry_run": False, "keep_config": False})() + cli._cmd_uninstall(args) + + assert not custom.exists() diff --git a/tests/test_paths.py b/tests/test_paths.py index dd936ba5..3c9087dc 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -37,3 +37,28 @@ def test_relative_custom_home_is_rejected(monkeypatch): with pytest.raises(ValueError, match="AGENT_REACH_HOME must be an absolute path"): agent_reach_home() + + +# ── Config precedence tests ───────────────────────── + + +def test_config_uses_custom_home(monkeypatch, tmp_path): + from agent_reach.config import Config + + custom = tmp_path / "custom" + monkeypatch.setenv("AGENT_REACH_HOME", str(custom)) + + config = Config() + + assert config.config_path == custom / "config.yaml" + + +def test_explicit_config_path_beats_custom_home(monkeypatch, tmp_path): + from agent_reach.config import Config + + monkeypatch.setenv("AGENT_REACH_HOME", str(tmp_path / "custom")) + explicit = tmp_path / "explicit.yaml" + + config = Config(config_path=explicit) + + assert config.config_path == explicit From 7b6cf793f5b2b3da8e9e2aa787c7cc9a57e6d8b1 Mon Sep 17 00:00:00 2001 From: Juemuel <52780296+juemuel@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:08:19 +0800 Subject: [PATCH 3/9] feat(cli): report path ownership --- agent_reach/cli.py | 27 +++++++++++++++++++ agent_reach/paths.py | 51 +++++++++++++++++++++++++++++++++++ tests/test_cli.py | 24 +++++++++++++++++ tests/test_paths.py | 63 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 165 insertions(+) diff --git a/agent_reach/cli.py b/agent_reach/cli.py index 014a54d8..115a4330 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -128,6 +128,10 @@ def main(): # ── watch ── sub.add_parser("watch", help="Quick health check + update check (for scheduled tasks)") + # ── paths ── + p_paths = sub.add_parser("paths", help="Show managed, registration, and upstream paths") + p_paths.add_argument("--json", action="store_true", help="Output machine-readable JSON") + # ── version ── sub.add_parser("version", help="Show version") @@ -146,6 +150,8 @@ def main(): if args.command == "doctor": _cmd_doctor(args) + elif args.command == "paths": + _cmd_paths(args) elif args.command == "check-update": _cmd_check_update() elif args.command == "watch": @@ -1455,6 +1461,27 @@ def _cmd_doctor(args=None): _install_skill() +def _cmd_paths(args): + from agent_reach.paths import path_report + + report = path_report() + if args.json: + print(json.dumps(report, ensure_ascii=False, indent=2)) + return + + headings = ( + ("managed", "Agent Reach managed"), + ("registration", "Agent platform registration"), + ("external", "Upstream managed"), + ) + for key, heading in headings: + print(f"\n{heading}:") + for item in report[key]: + path = item["path"] or "(not installed)" + print(f" {item['key']}: {path}") + print("\nUpstream-managed paths are not moved or removed by Agent Reach.") + + def _cmd_setup(): from agent_reach.config import Config diff --git a/agent_reach/paths.py b/agent_reach/paths.py index 9e79e5c0..4118839b 100644 --- a/agent_reach/paths.py +++ b/agent_reach/paths.py @@ -35,3 +35,54 @@ def xiaoyuzhou_tools_dir(env: Mapping[str, str] | None = None) -> Path: def xhs_cookie_file(env: Mapping[str, str] | None = None) -> Path: return agent_reach_home(env) / "xhs-cookies.json" + + +# ── Path report ────────────────────────────────── + +import shutil + +EXTERNAL_COMMANDS = ("gh", "node", "npm", "mcporter", "twitter", "rdt", "bili", "opencli") + + +def skill_registration_dirs(env: Mapping[str, str] | None = None) -> list[Path]: + values = os.environ if env is None else env + targets = [ + Path.home() / ".agents" / "skills", + Path.home() / ".openclaw" / "skills", + Path.home() / ".claude" / "skills", + ] + openclaw_home = values.get("OPENCLAW_HOME", "").strip() + if openclaw_home: + targets.insert(0, Path(openclaw_home).expanduser() / ".openclaw" / "skills") + return targets + + +def path_report() -> dict[str, list[dict[str, object]]]: + managed = [ + {"key": "home", "path": str(agent_reach_home()), "owner": "agent-reach"}, + {"key": "config", "path": str(config_file()), "owner": "agent-reach"}, + {"key": "tools", "path": str(tools_dir()), "owner": "agent-reach"}, + {"key": "xhs_cookies", "path": str(xhs_cookie_file()), "owner": "agent-reach"}, + ] + registration = [ + { + "key": f"skill_{index}", + "path": str(parent / "agent-reach"), + "owner": "agent-platform", + "exists": (parent / "agent-reach").exists(), + } + for index, parent in enumerate(skill_registration_dirs(), start=1) + ] + external = [ + { + "key": command, + "path": shutil.which(command), + "owner": "upstream", + } + for command in EXTERNAL_COMMANDS + ] + return { + "managed": managed, + "registration": registration, + "external": external, + } diff --git a/tests/test_cli.py b/tests/test_cli.py index f674f8ae..ea15ad67 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Tests for Agent Reach CLI.""" +import json import shutil import subprocess from unittest.mock import patch @@ -260,3 +261,26 @@ def test_uninstall_targets_custom_home(self, monkeypatch, tmp_path): cli._cmd_uninstall(args) assert not custom.exists() + + +class TestPathsCommand: + """Tests for 'agent-reach paths' and 'agent-reach paths --json'.""" + + def test_paths_json_reports_categories(self, monkeypatch, capsys, tmp_path): + monkeypatch.setenv("AGENT_REACH_HOME", str(tmp_path / "managed")) + with patch("sys.argv", ["agent-reach", "paths", "--json"]): + main() + + payload = json.loads(capsys.readouterr().out) + assert set(payload) == {"managed", "registration", "external"} + + def test_paths_text_explains_external_ownership(self, monkeypatch, capsys, tmp_path): + monkeypatch.setenv("AGENT_REACH_HOME", str(tmp_path / "managed")) + with patch("sys.argv", ["agent-reach", "paths"]): + main() + + output = capsys.readouterr().out + assert "Agent Reach managed" in output + assert "Agent platform registration" in output + assert "Upstream managed" in output + assert "not moved or removed by Agent Reach" in output diff --git a/tests/test_paths.py b/tests/test_paths.py index 3c9087dc..f39e1a5f 100644 --- a/tests/test_paths.py +++ b/tests/test_paths.py @@ -62,3 +62,66 @@ def test_explicit_config_path_beats_custom_home(monkeypatch, tmp_path): config = Config(config_path=explicit) assert config.config_path == explicit + + +# ── Path report tests ──────────────────────────── + + +def test_path_report_separates_ownership(monkeypatch, tmp_path): + from agent_reach.paths import path_report + + monkeypatch.setenv("AGENT_REACH_HOME", str(tmp_path / "managed")) + monkeypatch.setattr("agent_reach.paths.shutil.which", lambda name: f"/bin/{name}") + + report = path_report() + + assert [item["key"] for item in report["managed"]] == [ + "home", "config", "tools", "xhs_cookies", + ] + assert all(item["owner"] == "agent-reach" for item in report["managed"]) + assert all(item["owner"] == "agent-platform" for item in report["registration"]) + assert all(item["owner"] == "upstream" for item in report["external"]) + assert next(item for item in report["external"] if item["key"] == "gh")["path"] == "/bin/gh" + + +def test_path_report_external_not_found_is_null(monkeypatch, tmp_path): + from agent_reach.paths import path_report + + monkeypatch.setenv("AGENT_REACH_HOME", str(tmp_path / "managed")) + monkeypatch.setattr("agent_reach.paths.shutil.which", lambda name: None) + + report = path_report() + assert next(item for item in report["external"] if item["key"] == "gh")["path"] is None + + +def test_skill_registration_dirs_includes_known_platforms(monkeypatch, tmp_path): + from agent_reach.paths import skill_registration_dirs + + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.delenv("OPENCLAW_HOME", raising=False) + + dirs = skill_registration_dirs() + dir_names = [str(d) for d in dirs] + + assert str(tmp_path / ".agents" / "skills" / "agent-reach") not in dir_names + assert str(tmp_path / ".openclaw" / "skills" / "agent-reach") not in dir_names + + # skill_registration_dirs returns parent directories (without /agent-reach) + expected_parents = [ + tmp_path / ".agents" / "skills", + tmp_path / ".openclaw" / "skills", + tmp_path / ".claude" / "skills", + ] + for parent in expected_parents: + assert parent in dirs + + +def test_skill_registration_dirs_respects_openclaw_home(monkeypatch, tmp_path): + from agent_reach.paths import skill_registration_dirs + + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + custom_openclaw = tmp_path / "custom-openclaw" + monkeypatch.setenv("OPENCLAW_HOME", str(custom_openclaw)) + + dirs = skill_registration_dirs() + assert dirs[0] == custom_openclaw / ".openclaw" / "skills" From ec0035e23cc9b8a3cab4924d7c77c148125422f8 Mon Sep 17 00:00:00 2001 From: Juemuel <52780296+juemuel@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:10:35 +0800 Subject: [PATCH 4/9] fix(install): require opt-in for system changes --- agent_reach/cli.py | 53 +++++++++++++++++++---------- tests/test_cli.py | 83 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 119 insertions(+), 17 deletions(-) diff --git a/agent_reach/cli.py b/agent_reach/cli.py index 115a4330..b551282c 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -69,8 +69,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") + system_mode = p_install.add_mutually_exclusive_group() + system_mode.add_argument( + "--system", + action="store_true", + help="Allow system package-manager and core global npm changes", + ) + system_mode.add_argument( + "--safe", + action="store_true", + help="Skip automatic system, core, and optional channel tool installation", + ) 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="", @@ -182,6 +191,7 @@ def _cmd_install(args): from agent_reach.doctor import check_all, format_report safe_mode = args.safe + system_mode = bool(getattr(args, "system", False)) dry_run = args.dry_run config = Config() @@ -196,7 +206,11 @@ def _cmd_install(args): print("DRY RUN — showing what would be done (no changes)") print() if safe_mode: - print("SAFE MODE — skipping automatic system changes") + print("SAFE MODE — skipping automatic system, core, and optional channel tool installation") + print() + elif not system_mode: + print("Non-mutating mode — checking core dependencies without system/global installs") + print(" Use --system to enable apt/npm global installs, --safe to skip everything") print() # ── Parse --channels ── @@ -239,26 +253,31 @@ def _cmd_install(args): config.set("bilibili_proxy", args.proxy) # legacy key print(f"✅ 代理已保存(Agent 访问受限网络时使用)") - # ── Install core system dependencies (lightweight, always) ── + # ── Install core system dependencies ── print() if dry_run: - _install_system_deps_dryrun() - elif safe_mode: - _install_system_deps_safe() - else: + if system_mode: + _install_system_deps_dryrun() + print() + print("[dry-run] Would install mcporter globally and configure Exa search") + else: + _install_system_deps_dryrun() + print() + print("[dry-run] Would check mcporter and suggest Exa configuration") + elif system_mode: _install_system_deps() - - # ── mcporter (for Exa search) ── - print() - if dry_run: - print("[dry-run] Would install mcporter and configure Exa search") - elif safe_mode: - _install_mcporter_safe() - else: + print() _install_mcporter() + else: + _install_system_deps_safe() + print() + _install_mcporter_safe() # ── Install optional channels (only if --channels specified) ── - if requested_channels and not dry_run and not safe_mode: + # --safe skips all tool installation, including explicitly listed channels. + if args.safe: + requested_channels.clear() + if requested_channels and not dry_run: print() print("Installing optional channels...") if env == "server" and "opencli" in requested_channels: diff --git a/tests/test_cli.py b/tests/test_cli.py index ea15ad67..44df1452 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -284,3 +284,86 @@ def test_paths_text_explains_external_ownership(self, monkeypatch, capsys, tmp_p assert "Agent platform registration" in output assert "Upstream managed" in output assert "not moved or removed by Agent Reach" in output + + +class TestInstallSystemFlag: + """Tests for --system and --safe mutual exclusivity and install routing.""" + + def test_install_rejects_safe_and_system_together(self): + with patch("sys.argv", ["agent-reach", "install", "--safe", "--system"]): + with pytest.raises(SystemExit) as exc_info: + main() + assert exc_info.value.code == 2 + + +def _install_args(*, system=False, safe=False, dry_run=False): + return type("Args", (), { + "safe": safe, + "system": system, + "dry_run": dry_run, + "channels": "", + "env": "local", + "proxy": "", + })() + + +class TestInstallRouting: + """Tests that default install is non-mutating and --system enables mutation.""" + + def test_install_default_only_checks_core_dependencies(self, monkeypatch, tmp_path): + calls = [] + monkeypatch.setenv("AGENT_REACH_HOME", str(tmp_path / "managed")) + monkeypatch.setattr(cli, "_install_system_deps_safe", lambda: calls.append("system-check")) + monkeypatch.setattr(cli, "_install_system_deps", lambda: calls.append("system-install")) + monkeypatch.setattr(cli, "_install_mcporter_safe", lambda: calls.append("mcporter-check")) + monkeypatch.setattr(cli, "_install_mcporter", lambda: calls.append("mcporter-install")) + 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: "") + + cli._cmd_install(_install_args()) + + assert calls == ["system-check", "mcporter-check"] + + def test_install_system_performs_core_installation(self, monkeypatch, tmp_path): + calls = [] + monkeypatch.setenv("AGENT_REACH_HOME", str(tmp_path / "managed")) + monkeypatch.setattr(cli, "_install_system_deps_safe", lambda: calls.append("system-check")) + monkeypatch.setattr(cli, "_install_system_deps", lambda: calls.append("system-install")) + monkeypatch.setattr(cli, "_install_mcporter_safe", lambda: calls.append("mcporter-check")) + monkeypatch.setattr(cli, "_install_mcporter", lambda: calls.append("mcporter-install")) + 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: "") + + cli._cmd_install(_install_args(system=True)) + + assert calls == ["system-install", "mcporter-install"] + + def test_install_safe_skips_explicit_channel_installers(self, monkeypatch, tmp_path): + calls = [] + monkeypatch.setenv("AGENT_REACH_HOME", str(tmp_path / "managed")) + monkeypatch.setattr(cli, "_install_system_deps_safe", lambda: None) + monkeypatch.setattr(cli, "_install_mcporter_safe", lambda: None) + monkeypatch.setattr(cli, "_install_twitter_deps", lambda: calls.append("twitter")) + 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: "") + args = _install_args(safe=True) + args.channels = "twitter" + + cli._cmd_install(args) + + assert calls == [] + + def test_default_dry_run_does_not_claim_system_install(self, capsys, monkeypatch, tmp_path): + monkeypatch.setenv("AGENT_REACH_HOME", str(tmp_path / "managed")) + cli._cmd_install(_install_args(dry_run=True)) + output = capsys.readouterr().out + assert "Would install mcporter globally" not in output + + def test_system_dry_run_lists_global_install(self, capsys, monkeypatch, tmp_path): + monkeypatch.setenv("AGENT_REACH_HOME", str(tmp_path / "managed")) + cli._cmd_install(_install_args(system=True, dry_run=True)) + output = capsys.readouterr().out + assert "Would install mcporter globally" in output From e1ed3fd7bc73e0c0a6e687b873bc4881a2f5537f Mon Sep 17 00:00:00 2001 From: Juemuel <52780296+juemuel@users.noreply.github.com> Date: Mon, 22 Jun 2026 02:11:39 +0800 Subject: [PATCH 5/9] docs(install): explain path ownership --- README.md | 29 ++++++++++++++++++++++++++--- docs/install.md | 21 +++++++++++++++++---- docs/update.md | 2 +- 3 files changed, 44 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 3322da43..5beb7595 100644 --- a/README.md +++ b/README.md @@ -216,10 +216,30 @@ 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 文件即可,不影响其他 | +| 🗺️ **路径透明** | `agent-reach paths` 显示所有托管、注册和外部路径及其所有者 | + +### 📁 路径所有权 + +Agent Reach 区分三类路径,各自有明确的所有者: + +| 类别 | 示例 | 所有者 | 随 `AGENT_REACH_HOME` 移动 | +|---|---|---|---| +| Agent Reach 数据 | `~/.agent-reach/config.yaml` | Agent Reach | 是 | +| Agent Reach 工具 | `~/.agent-reach/tools/` | Agent Reach | 是 | +| Skill 注册 | `~/.agents/skills/agent-reach/` | Agent 平台 | 否 | +| CLI 可执行文件 | `which gh` / `which twitter` 的输出 | 包管理器 | 否 | +| 上游缓存/状态 | 上游 CLI 创建的目录 | 上游项目 | 否 | + +自定义根路径: +```bash +export AGENT_REACH_HOME="$HOME/.local/share/agent-reach" +agent-reach paths +``` +变量必须解析为绝对路径,不会自动迁移已有数据,卸载只影响当前选中的托管根。 ### 🍪 Cookie 安全建议 @@ -233,10 +253,13 @@ Agent Reach 在设计上重视安全: | 方式 | 命令 | 适合场景 | |------|------|---------| -| 一键全自动(默认) | `agent-reach install --env=auto` | 个人电脑、开发环境 | -| 安全模式 | `agent-reach install --env=auto --safe` | 生产服务器、多人共用机器 | +| 非侵入式检查(默认) | `agent-reach install --env=auto` | 检查依赖,不自动装系统包或全局 npm 包 | +| 系统安装 | `agent-reach install --env=auto --system` | 明确允许 apt/Homebrew/全局 npm 变更 | +| 安全模式 | `agent-reach install --env=auto --safe` | 跳过所有自动安装(包括可选渠道) | | 仅预览 | `agent-reach install --env=auto --dry-run` | 先看看会做什么 | +`--channels=twitter,...` 显式授权安装指定的可选渠道工具。 + ### 🗑️ 卸载 ```bash diff --git a/docs/install.md b/docs/install.md index 75416cbb..dda15614 100644 --- a/docs/install.md +++ b/docs/install.md @@ -27,7 +27,7 @@ Install Agent Reach and all upstream tools so your user's agent has full interne When fixing issues, stay within these limits: - **DO NOT** run commands with `sudo` unless the user explicitly approved -- **DO NOT** modify system files outside `~/.agent-reach/` +- **DO NOT** modify system files or install global packages without explicit user authorization - **DO NOT** install packages not listed in this guide - **DO NOT** disable firewalls, security settings, or system protections - **DO NOT** clone repos, create files, or run commands inside the agent workspace / working directory @@ -51,8 +51,12 @@ 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(不自动安装系统包或全局 npm 包) agent-reach install --env=auto +# 明确允许 apt/Homebrew/NodeSource/全局 npm 变更 +agent-reach install --env=auto --system + # 如果你的 Python 来自 Homebrew / 遇到 PEP 668(externally-managed-environment) # 用虚拟环境安装: python3 -m venv ~/.agent-reach-venv @@ -74,8 +78,15 @@ 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: +By default, `install` checks core dependencies and writes managed data plus +agent-platform skill registrations. `--system` explicitly permits system +package-manager and core global npm changes. `--channels` explicitly permits +installation of the named optional upstream tools. + +`gh` uses the official apt repository or Homebrew when `--system` is selected; +it is not installed inside the Agent Reach project or managed-data directory. +Core channels activated (zero-config): - Web (Jina Reader), YouTube, GitHub, RSS, Exa Search, V2EX, Bilibili (basic) > 💡 **macOS / Homebrew Python 提示 `externally-managed-environment`?** @@ -304,12 +315,14 @@ 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` | Check deps, write managed data (non-mutating) | +| `agent-reach install --env=auto --system` | Full install with system/global package changes | | `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 --safe` | Check only — skip ALL automatic installation | | `agent-reach install --env=auto --dry-run` | Preview what would be done | | `agent-reach doctor` | Show channel status | +| `agent-reach paths` | Show managed, registration, and upstream paths | | `agent-reach watch` | Quick health + update check (for scheduled tasks) | | `agent-reach check-update` | Check for new versions | | `agent-reach configure twitter-cookies "..."` | Unlock Twitter search + posting | diff --git a/docs/update.md b/docs/update.md index 79a9bfa6..4cdad3b3 100644 --- a/docs/update.md +++ b/docs/update.md @@ -20,7 +20,7 @@ Update Agent Reach: https://raw.githubusercontent.com/Panniantong/agent-reach/ma ### ⚠️ Workspace Rules -**Never create files, clone repos, or run commands in the agent workspace.** Use `/tmp/` for temporary work and `~/.agent-reach/` for persistent data. +**Never create files, clone repos, or run commands in the agent workspace.** Use `/tmp/` for temporary work and `${AGENT_REACH_HOME:-$HOME/.agent-reach}` for persistent data. Run `agent-reach paths` to see current managed root and all tool locations. ### Goal From e100d5e2cc5cb32a32d5556525e76e4da942157e Mon Sep 17 00:00:00 2001 From: Juemuel <52780296+juemuel@users.noreply.github.com> Date: Mon, 22 Jun 2026 11:47:57 +0800 Subject: [PATCH 6/9] fix: dry-run zero-write, uninstall accuracy, doctor config path - [P1] Guard tools_dir().mkdir() behind 'not dry_run' so --dry-run creates zero files. Route default dry-run through safe-mode check output instead of install-language output. - [P1] _uninstall_skill() now returns bool; _cmd_uninstall uses it instead of unconditionally setting removed_any=True. - [P2] format_report() accepts optional config_path; all callers (_cmd_install, _cmd_doctor, AgentReach.doctor_report) pass the active Config instance path so the permission check inspects the actual config file, not a separately-resolved default. --- agent_reach/cli.py | 27 ++++++++-------- agent_reach/core.py | 2 +- agent_reach/doctor.py | 16 +++++++--- tests/test_cli.py | 71 ++++++++++++++++++++++++++++++++++++++++--- tests/test_doctor.py | 49 +++++++++++++++++++++++++++++ 5 files changed, 144 insertions(+), 21 deletions(-) diff --git a/agent_reach/cli.py b/agent_reach/cli.py index b551282c..4280a9fb 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -199,9 +199,6 @@ def _cmd_install(args): print("Agent Reach Installer") print("=" * 40) - # Ensure tools directory exists (for upstream tool repos) - tools_dir().mkdir(parents=True, exist_ok=True) - if dry_run: print("DRY RUN — showing what would be done (no changes)") print() @@ -213,6 +210,10 @@ def _cmd_install(args): print(" Use --system to enable apt/npm global installs, --safe to skip everything") print() + # Ensure tools directory exists (for upstream tool repos) — only when writing + if not dry_run: + tools_dir().mkdir(parents=True, exist_ok=True) + # ── Parse --channels ── CHANNEL_INSTALLERS = { "twitter": _install_twitter_deps, @@ -261,7 +262,7 @@ def _cmd_install(args): print() print("[dry-run] Would install mcporter globally and configure Exa search") else: - _install_system_deps_dryrun() + _install_system_deps_safe() print() print("[dry-run] Would check mcporter and suggest Exa configuration") elif system_mode: @@ -340,7 +341,7 @@ def _cmd_install(args): # Final status print() - print(format_report(results)) + print(format_report(results, config_path=config.config_path)) print() # ── Install agent skill ── @@ -484,7 +485,10 @@ def _skill_install_targets() -> list: def _uninstall_skill(): - """Remove SKILL.md from all known agent skill directories.""" + """Remove SKILL.md from all known agent skill directories. + + Returns True if at least one skill directory was removed. + """ import shutil removed = False @@ -502,6 +506,7 @@ def _uninstall_skill(): if not removed: print(" No skill installations found.") + return removed def _cmd_skill(args): @@ -1413,11 +1418,9 @@ def _cmd_uninstall(args): if os.path.isdir(skill_path): print(f"[dry-run] Would remove {platform_name} skill: {skill_path}") else: - _uninstall_skill() - # Track removal for summary — _uninstall_skill prints its own messages - if any(os.path.isdir(p) for p, _ in _skill_install_targets()): - pass # _uninstall_skill already handled printing - removed_any = True + skill_removed = _uninstall_skill() + if skill_removed: + removed_any = True # ── 3. mcporter MCP entries ── if shutil.which("mcporter"): @@ -1474,7 +1477,7 @@ def _cmd_doctor(args=None): print(json.dumps(results, ensure_ascii=False, indent=2)) return - rprint(format_report(results)) + rprint(format_report(results, config_path=config.config_path)) # Auto-install skill if not already present (fixes #154) _install_skill() diff --git a/agent_reach/core.py b/agent_reach/core.py index 075d9de1..7f7ed9d0 100644 --- a/agent_reach/core.py +++ b/agent_reach/core.py @@ -39,4 +39,4 @@ def doctor(self) -> Dict[str, dict]: def doctor_report(self) -> str: """Get formatted health report.""" from agent_reach.doctor import check_all, format_report - return format_report(check_all(self.config)) + return format_report(check_all(self.config), config_path=self.config.config_path) diff --git a/agent_reach/doctor.py b/agent_reach/doctor.py index e37b9f7e..08c222b4 100644 --- a/agent_reach/doctor.py +++ b/agent_reach/doctor.py @@ -44,8 +44,15 @@ def _name_msg(r: dict, escape) -> str: return text -def format_report(results: Dict[str, dict]) -> str: - """Format results as a readable text report (with Rich markup).""" +def format_report(results: Dict[str, dict], config_path=None) -> str: + """Format results as a readable text report (with Rich markup). + + Args: + results: Channel check results from ``check_all``. + config_path: Optional ``Path`` to the active config file. When + provided the permission check uses this path; otherwise + ``agent_reach.paths.config_file()`` is used as a fallback. + """ try: from rich.markup import escape except ImportError: @@ -111,8 +118,9 @@ def format_report(results: Dict[str, dict]) -> str: import stat import sys - from agent_reach.paths import config_file - config_path = config_file() + if config_path is None: + from agent_reach.paths import config_file as _default_config_file + config_path = _default_config_file() if config_path.exists() and sys.platform != "win32": try: mode = config_path.stat().st_mode diff --git a/tests/test_cli.py b/tests/test_cli.py index 44df1452..da551f72 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -236,7 +236,7 @@ def test_install_creates_tools_under_custom_home(self, monkeypatch, tmp_path): monkeypatch.setattr(cli, "_install_mcporter_safe", lambda: None) 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: "") + monkeypatch.setattr("agent_reach.doctor.format_report", lambda *a, **kw: "") args = type("Args", (), { "safe": True, @@ -319,7 +319,7 @@ def test_install_default_only_checks_core_dependencies(self, monkeypatch, tmp_pa monkeypatch.setattr(cli, "_install_mcporter", lambda: calls.append("mcporter-install")) 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: "") + monkeypatch.setattr("agent_reach.doctor.format_report", lambda *a, **kw: "") cli._cmd_install(_install_args()) @@ -334,7 +334,7 @@ def test_install_system_performs_core_installation(self, monkeypatch, tmp_path): monkeypatch.setattr(cli, "_install_mcporter", lambda: calls.append("mcporter-install")) 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: "") + monkeypatch.setattr("agent_reach.doctor.format_report", lambda *a, **kw: "") cli._cmd_install(_install_args(system=True)) @@ -348,7 +348,7 @@ def test_install_safe_skips_explicit_channel_installers(self, monkeypatch, tmp_p monkeypatch.setattr(cli, "_install_twitter_deps", lambda: calls.append("twitter")) 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: "") + monkeypatch.setattr("agent_reach.doctor.format_report", lambda *a, **kw: "") args = _install_args(safe=True) args.channels = "twitter" @@ -367,3 +367,66 @@ def test_system_dry_run_lists_global_install(self, capsys, monkeypatch, tmp_path cli._cmd_install(_install_args(system=True, dry_run=True)) output = capsys.readouterr().out assert "Would install mcporter globally" in output + + def test_dry_run_does_not_write_tools_dir(self, tmp_path, monkeypatch): + """--dry-run must be zero-write: no tools/ directory created.""" + custom = tmp_path / "managed" + monkeypatch.setenv("AGENT_REACH_HOME", str(custom)) + monkeypatch.setattr(cli, "_detect_environment", lambda: "local") + monkeypatch.setattr(cli, "_install_system_deps_safe", lambda: None) + monkeypatch.setattr(cli, "_install_mcporter_safe", lambda: None) + monkeypatch.setattr(cli, "_install_skill", lambda: None) + monkeypatch.setattr("agent_reach.doctor.check_all", lambda config: {}) + monkeypatch.setattr("agent_reach.doctor.format_report", lambda *a, **kw: "") + + cli._cmd_install(_install_args(dry_run=True)) + + assert not (custom / "tools").exists() + + def test_default_dry_run_does_not_claim_gh_install(self, capsys, monkeypatch, tmp_path): + """Default dry-run checks deps, should not claim 'would install via apt'. """ + monkeypatch.setenv("AGENT_REACH_HOME", str(tmp_path / "managed")) + cli._cmd_install(_install_args(dry_run=True)) + output = capsys.readouterr().out + assert "would install via" not in output.lower() + + def test_system_dry_run_shows_install_language(self, capsys, monkeypatch, tmp_path): + """--system --dry-run should show system-install language.""" + monkeypatch.setenv("AGENT_REACH_HOME", str(tmp_path / "managed")) + cli._cmd_install(_install_args(system=True, dry_run=True)) + output = capsys.readouterr().out + assert "would install via" in output.lower() + + +class TestUninstallSkillResult: + """Tests for _uninstall_skill return value and _cmd_uninstall reporting.""" + + def test_uninstall_skill_returns_true_when_dir_removed(self, tmp_path): + skill_path = tmp_path / ".openclaw" / "skills" / "agent-reach" + skill_path.mkdir(parents=True) + + import os as _os + with patch("agent_reach.cli._skill_install_targets", return_value=[(str(skill_path), "Test")]): + result = cli._uninstall_skill() + assert result is True + assert not _os.path.exists(skill_path) + + def test_uninstall_skill_returns_false_when_nothing_found(self): + with patch("agent_reach.cli._skill_install_targets", return_value=[]): + result = cli._uninstall_skill() + assert result is False + + def test_uninstall_does_not_report_removed_when_nothing_removed(self, monkeypatch, capsys, tmp_path): + """When no config dir and no skill dirs exist, summary must not say removed.""" + custom = tmp_path / "nonexistent" + monkeypatch.setenv("AGENT_REACH_HOME", str(custom)) + monkeypatch.setattr(cli, "_skill_install_targets", lambda: []) + monkeypatch.setattr(cli, "_uninstall_skill", lambda: False) + monkeypatch.setattr(shutil, "which", lambda name: None) + + args = type("Args", (), {"dry_run": False, "keep_config": False})() + cli._cmd_uninstall(args) + + output = capsys.readouterr().out + assert "Agent Reach data removed" not in output + assert "Nothing to remove" in output diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 7ce7013a..a27931bb 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -124,3 +124,52 @@ def check(self, config=None): results = doctor.check_all(config=None) assert results["boom"]["status"] == "error" assert results["boom"]["active_backend"] is None + + +class TestFormatReportConfigPath: + """format_report should use the active Config's config_path for permission checks.""" + + def test_format_report_accepts_and_uses_config_path(self, tmp_path, monkeypatch): + """When config_path is passed, format_report checks that path, not defaults.""" + explicit = tmp_path / "explicit" / "config.yaml" + explicit.parent.mkdir(parents=True) + explicit.write_text("key: value", encoding="utf-8") + + report = doctor.format_report( + { + "web": { + "status": "ok", + "name": "网页", + "message": "ok", + "tier": 0, + "backends": ["requests"], + }, + }, + config_path=explicit, + ) + # Must not crash; should reference the explicit path + assert "Agent Reach" in report + + def test_doctor_report_uses_active_config_path(self, tmp_path, monkeypatch): + """AgentReach.doctor_report() must pass its Config's config_path to format_report.""" + from agent_reach.core import AgentReach + + explicit = tmp_path / "my-config.yaml" + cfg = Config(config_path=explicit) + + ar = AgentReach(config=cfg) + # Patch check_all to short-circuit + monkeypatch.setattr( + doctor, + "get_all_channels", + lambda: [ + _StubChannel("web", "网页", 0, "ok", "ok", ["requests"], + active_backend="requests"), + ], + ) + report = ar.doctor_report() + assert "Agent Reach" in report + # The key validation: function must not crash and must use + # cfg.config_path, not resolve AGENT_REACH_HOME from env. + # We verify by checking the report is produced successfully + # with an explicit path outside the default location. From 68b3be7d90f8a374eaf4e01dc5120eae9d924ea9 Mon Sep 17 00:00:00 2001 From: Juemuel <52780296+juemuel@users.noreply.github.com> Date: Mon, 22 Jun 2026 14:00:12 +0800 Subject: [PATCH 7/9] fix(install): defer Config() so --dry-run creates zero files Previously Config() called _ensure_dir() unconditionally at the top of _cmd_install, creating / before the dry_run guard. Now Config is lazily initialised only when the code path actually needs to write (proxy save or channel testing), keeping --dry-run and --system --dry-run entirely side-effect-free. --- agent_reach/cli.py | 11 +++++++++-- tests/test_cli.py | 18 +++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/agent_reach/cli.py b/agent_reach/cli.py index 4280a9fb..501fd2c1 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -187,14 +187,15 @@ def main(): def _cmd_install(args): """One-shot deterministic installer.""" import os - from agent_reach.config import Config from agent_reach.doctor import check_all, format_report safe_mode = args.safe system_mode = bool(getattr(args, "system", False)) dry_run = args.dry_run - config = Config() + # Defer Config creation so --dry-run never creates directories or files. + config = None + print() print("Agent Reach Installer") print("=" * 40) @@ -250,6 +251,9 @@ def _cmd_install(args): if dry_run: print(f"[dry-run] Would save network proxy") else: + if config is None: + from agent_reach.config import Config + config = Config() config.set("proxy", args.proxy) config.set("bilibili_proxy", args.proxy) # legacy key print(f"✅ 代理已保存(Agent 访问受限网络时使用)") @@ -333,6 +337,9 @@ def _cmd_install(args): # Test channels if not dry_run: + if config is None: + from agent_reach.config import Config + config = Config() print() print("Testing channels...") results = check_all(config) diff --git a/tests/test_cli.py b/tests/test_cli.py index da551f72..837016f6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -368,8 +368,8 @@ def test_system_dry_run_lists_global_install(self, capsys, monkeypatch, tmp_path output = capsys.readouterr().out assert "Would install mcporter globally" in output - def test_dry_run_does_not_write_tools_dir(self, tmp_path, monkeypatch): - """--dry-run must be zero-write: no tools/ directory created.""" + def test_dry_run_does_not_write_managed_root(self, tmp_path, monkeypatch): + """--dry-run must be zero-write: no / directory created at all.""" custom = tmp_path / "managed" monkeypatch.setenv("AGENT_REACH_HOME", str(custom)) monkeypatch.setattr(cli, "_detect_environment", lambda: "local") @@ -381,7 +381,19 @@ def test_dry_run_does_not_write_tools_dir(self, tmp_path, monkeypatch): cli._cmd_install(_install_args(dry_run=True)) - assert not (custom / "tools").exists() + assert not custom.exists(), f"dry-run must not create {custom}" + + def test_system_dry_run_does_not_write_managed_root(self, tmp_path, monkeypatch): + """--system --dry-run must also be zero-write: no root directory created.""" + custom = tmp_path / "managed" + monkeypatch.setenv("AGENT_REACH_HOME", str(custom)) + monkeypatch.setattr(cli, "_detect_environment", lambda: "local") + monkeypatch.setattr("agent_reach.doctor.check_all", lambda config: {}) + monkeypatch.setattr("agent_reach.doctor.format_report", lambda *a, **kw: "") + + cli._cmd_install(_install_args(system=True, dry_run=True)) + + assert not custom.exists(), f"--system --dry-run must not create {custom}" def test_default_dry_run_does_not_claim_gh_install(self, capsys, monkeypatch, tmp_path): """Default dry-run checks deps, should not claim 'would install via apt'. """ From 5aa2ff9d2f0a5e159ce593816977d21e51413126 Mon Sep 17 00:00:00 2001 From: Juemuel <52780296+juemuel@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:01:30 +0800 Subject: [PATCH 8/9] fix(install): use platform-aware dependency hints --- agent_reach/cli.py | 26 ++++++++++++++++++++++---- tests/test_cli.py | 27 +++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 4 deletions(-) diff --git a/agent_reach/cli.py b/agent_reach/cli.py index 501fd2c1..1955dc59 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -879,6 +879,24 @@ def _install_bili_deps(): print(" [!] bili-cli install failed. Run: pipx install bilibili-cli") +def _gh_install_hint() -> str: + """Return a platform-appropriate manual install hint for GitHub CLI.""" + if sys.platform == "win32": + return "https://cli.github.com — or: winget install --id GitHub.cli" + if sys.platform == "darwin": + return "https://cli.github.com — or: brew install gh" + return "https://cli.github.com — or: apt install gh" + + +def _node_install_hint() -> str: + """Return a platform-appropriate manual install hint for Node.js.""" + if sys.platform == "win32": + return "https://nodejs.org — or: winget install OpenJS.NodeJS.LTS" + if sys.platform == "darwin": + return "https://nodejs.org — or: brew install node" + return "https://nodejs.org — or: apt install nodejs npm" + + def _install_system_deps_safe(): """Safe mode: check what's installed, print instructions for what's missing.""" import shutil @@ -886,8 +904,8 @@ def _install_system_deps_safe(): print("Checking system dependencies (safe mode — no auto-install)...") deps = [ - ("gh", ["gh"], "GitHub CLI", "https://cli.github.com — or: apt install gh / brew install gh"), - ("node", ["node", "npm"], "Node.js", "https://nodejs.org — or: apt install nodejs npm"), + ("gh", ["gh"], "GitHub CLI", _gh_install_hint()), + ("node", ["node", "npm"], "Node.js", _node_install_hint()), ] missing = [] @@ -915,8 +933,8 @@ 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"], _gh_install_hint()), + ("Node.js", ["node"], _node_install_hint()), ] for label, binaries, method in checks: diff --git a/tests/test_cli.py b/tests/test_cli.py index 837016f6..dc7b349d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -410,6 +410,33 @@ def test_system_dry_run_shows_install_language(self, capsys, monkeypatch, tmp_pa assert "would install via" in output.lower() +class TestPlatformHints: + """Platform-aware install hints (Windows winget, macOS brew, Linux apt).""" + + def test_safe_mode_uses_windows_gh_hint(self, capsys, monkeypatch): + monkeypatch.setattr(cli.sys, "platform", "win32") + monkeypatch.setattr(shutil, "which", lambda name: "node.exe" if name in {"node", "npm"} else None) + + cli._install_system_deps_safe() + + output = capsys.readouterr().out + assert "GitHub CLI" in output + assert "https://cli.github.com" in output + assert "winget install --id GitHub.cli" in output + assert "apt install gh / brew install gh" not in output + + def test_system_dry_run_uses_windows_gh_hint(self, capsys, monkeypatch): + monkeypatch.setattr(cli.sys, "platform", "win32") + monkeypatch.setattr(shutil, "which", lambda name: "node.exe" if name == "node" else None) + + cli._install_system_deps_dryrun() + + output = capsys.readouterr().out + assert "gh CLI" in output + assert "winget install --id GitHub.cli" in output + assert "apt install gh / brew install gh" not in output + + class TestUninstallSkillResult: """Tests for _uninstall_skill return value and _cmd_uninstall reporting.""" From 96226c3d49782c1ff623986ecbda3dc57e499299 Mon Sep 17 00:00:00 2001 From: Juemuel <52780296+juemuel@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:03:15 +0800 Subject: [PATCH 9/9] fix(uninstall): prune empty skill parent directories --- agent_reach/cli.py | 23 +++++++++++++++++++++++ tests/test_cli.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/agent_reach/cli.py b/agent_reach/cli.py index 1955dc59..db20effd 100644 --- a/agent_reach/cli.py +++ b/agent_reach/cli.py @@ -491,6 +491,27 @@ def _skill_install_targets() -> list: return skill_dirs +def _prune_empty_skill_parents(skill_path: str) -> list[str]: + """Remove empty skill registration parent dirs after deleting agent-reach. + + Only removes the immediate ``skills`` directory and its platform root when + they are empty. Never removes HOME or any non-empty directory. + """ + removed: list[str] = [] + skills_dir = os.path.dirname(skill_path) + platform_dir = os.path.dirname(skills_dir) + + for path in (skills_dir, platform_dir): + try: + if os.path.isdir(path) and not os.listdir(path): + os.rmdir(path) + removed.append(path) + except OSError: + pass + + return removed + + def _uninstall_skill(): """Remove SKILL.md from all known agent skill directories. @@ -508,6 +529,8 @@ def _uninstall_skill(): shutil.rmtree(skill_path) print(f" Removed {platform_name} skill: {skill_path}") removed = True + for parent in _prune_empty_skill_parents(skill_path): + print(f" Removed empty skill parent directory: {parent}") except Exception as e: print(f" Could not remove {skill_path}: {e}") diff --git a/tests/test_cli.py b/tests/test_cli.py index dc7b349d..b87a9a0e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -469,3 +469,31 @@ def test_uninstall_does_not_report_removed_when_nothing_removed(self, monkeypatc output = capsys.readouterr().out assert "Agent Reach data removed" not in output assert "Nothing to remove" in output + + def test_uninstall_skill_prunes_empty_registration_parents(self, tmp_path): + skill_path = tmp_path / ".agents" / "skills" / "agent-reach" + skill_path.mkdir(parents=True) + + with patch("agent_reach.cli._skill_install_targets", return_value=[(str(skill_path), "Agent")]): + result = cli._uninstall_skill() + + assert result is True + assert not skill_path.exists() + assert not (tmp_path / ".agents" / "skills").exists() + assert not (tmp_path / ".agents").exists() + assert tmp_path.exists() + + def test_uninstall_skill_keeps_non_empty_registration_parents(self, tmp_path): + skill_path = tmp_path / ".agents" / "skills" / "agent-reach" + skill_path.mkdir(parents=True) + sibling = tmp_path / ".agents" / "skills" / "other-skill" + sibling.mkdir() + + with patch("agent_reach.cli._skill_install_targets", return_value=[(str(skill_path), "Agent")]): + result = cli._uninstall_skill() + + assert result is True + assert not skill_path.exists() + assert sibling.exists() + assert (tmp_path / ".agents" / "skills").exists() + assert (tmp_path / ".agents").exists()