diff --git a/src/semble/installer/agents.py b/src/semble/installer/agents.py index eaca05ea..345cff53 100644 --- a/src/semble/installer/agents.py +++ b/src/semble/installer/agents.py @@ -16,6 +16,22 @@ _HOME = Path.home() + +def _exists_or_denied(path: Path) -> bool: + """Distinguish between existence and permission issues.""" + try: + path.stat() + except FileNotFoundError: + return False + # PermissionError is a subclass of OSError + # which is why this looks the way it does. + except PermissionError: + return True + except OSError: + return False + return True + + Action = Literal["created", "updated", "unchanged", "not-found", "removed", "error", "skipped"] Mode = Literal["install", "uninstall"] @@ -33,15 +49,7 @@ class IntegrationType(str, Enum): def semble_pin() -> str: - """Return the uvx --from specifier for the semble MCP server. - - Version-pinned for normal installs (rerunning `semble install` after an - upgrade rewrites this pin to match). For an editable or local-directory - install, pins to the local source path instead, so generated configs - launch the checkout being developed rather than the released package. - For a non-editable git install, pins to the exact installed commit, since - that source may not correspond to any released PyPI version at all. - """ + """Return the uvx --from specifier for the semble MCP server.""" try: raw = importlib.metadata.distribution("semble").read_text("direct_url.json") if raw: @@ -159,7 +167,7 @@ def _opencode_mcp_path() -> Path: base = Path(xdg) / "opencode" if xdg else _HOME / ".config" / "opencode" jsonc = base / "opencode.jsonc" json_ = base / "opencode.json" - return jsonc if jsonc.exists() else (json_ if json_.exists() else jsonc) + return jsonc if _exists_or_denied(jsonc) else (json_ if _exists_or_denied(json_) else jsonc) def _vscode_mcp_path() -> Path: @@ -316,4 +324,4 @@ def is_detected(agent: AgentTarget) -> bool: """Return True if the agent appears to be installed.""" if agent.binary and shutil.which(agent.binary): return True - return bool(agent.config_dir and agent.config_dir.exists()) + return bool(agent.config_dir and _exists_or_denied(agent.config_dir)) diff --git a/tests/test_installer.py b/tests/test_installer.py index 197741ce..a350ac29 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -2,6 +2,7 @@ import json import sys from dataclasses import replace +from pathlib import Path import pytest @@ -482,6 +483,31 @@ def test_is_detected(monkeypatch, tmp_path): agent_no_bin = replace(agent, binary=None, config_dir=tmp_path) assert is_detected(agent_no_bin) + agent_missing = replace(agent, binary=None, config_dir=tmp_path / "nonexistent") + assert not is_detected(agent_missing) + + +def test_is_detected_true_when_config_dir_stat_denied(monkeypatch, tmp_path): + """A config dir blocked by sandboxing (EPERM/EACCES) still counts as detected, not absent.""" + agent = replace(next(a for a in AGENTS if a.id == "claude"), binary=None, config_dir=tmp_path) + + def _denied(self): + raise PermissionError(1, "Operation not permitted") + + monkeypatch.setattr(Path, "stat", _denied) + assert is_detected(agent) + + +def test_is_detected_false_on_other_os_error(monkeypatch, tmp_path): + """A non-permission OSError (e.g. ENOTDIR) is treated as absent, not detected.""" + agent = replace(next(a for a in AGENTS if a.id == "claude"), binary=None, config_dir=tmp_path) + + def _not_a_dir(self): + raise NotADirectoryError(20, "Not a directory") + + monkeypatch.setattr(Path, "stat", _not_a_dir) + assert not is_detected(agent) + def test_checkbox(monkeypatch): """_checkbox wraps questionary.checkbox and returns the selected values."""