diff --git a/CHANGELOG.md b/CHANGELOG.md index 607cc883..f31807b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,12 @@ All notable changes to AgentDescent are documented here. The format follows ### Fixed +- Machine-local host installs now pin MCP and session hooks to the installing + Python, so they work when a later agent process lacks the pip scripts directory + on PATH. Stock legacy and previously pinned Codex/OpenCode entries are repaired + on reinstall, including after a Python environment moves; custom entries and + distributable plugin templates stay intact. + - Spec and dataset file failures now report a concise `SpecError` instead of a CLI traceback. Invalid JSON names the input file and line/column; JSONL uses the physical file line, including blank lines. Missing or unreadable specs, diff --git a/agentdescent/integrations/__init__.py b/agentdescent/integrations/__init__.py index d3d660da..fd6ac624 100644 --- a/agentdescent/integrations/__init__.py +++ b/agentdescent/integrations/__init__.py @@ -16,7 +16,11 @@ from __future__ import annotations import json +import ntpath import os +import re +import shlex +import sys from typing import Callable, Dict, List, Optional __all__ = ["HOSTS", "install", "render_claude_plugin", "render_dsh_plugin", @@ -38,9 +42,19 @@ def skill_text() -> str: return fh.read() -def hooks_text() -> str: +def hooks_text(launcher: Optional[List[str]] = None) -> str: with open(os.path.join(_HERE, "hooks.json"), encoding="utf-8") as fh: - return fh.read() + text = fh.read() + if launcher is None: + return text + hooks = json.loads(text) + # Hooks are shell commands, unlike the argv arrays used for MCP. + # The shipped hook uses POSIX redirection, including Git Bash on Windows. + command = " ".join(shlex.quote(arg) for arg in launcher) + for group in hooks["hooks"]["SessionStart"]: + for hook in group["hooks"]: + hook["command"] = command + hook["command"][len(MCP_COMMAND):] + return json.dumps(hooks, indent=2) + "\n" def _skill_frontmatter() -> Dict[str, str]: @@ -85,8 +99,9 @@ def _version() -> str: class _Writer: - def __init__(self, dry_run: bool) -> None: + def __init__(self, dry_run: bool, launcher: Optional[List[str]] = None) -> None: self.dry_run = dry_run + self.launcher = launcher or [MCP_COMMAND] self.lines: List[str] = [] def write(self, path: str, text: str) -> None: @@ -98,11 +113,20 @@ def write(self, path: str, text: str) -> None: fh.write(text) self.lines.append(f"{verb} {path}") - def append_unless(self, path: str, marker: str, block: str, *, what: str) -> None: + def append_unless(self, path: str, marker: str, block: str, *, what: str, + previous: Optional[Callable[[str], Optional[str]]] = None) -> None: current = "" if os.path.exists(path): with open(path, encoding="utf-8") as fh: current = fh.read() + old_block = previous(current) if previous else None + if old_block and old_block in current: + tail = current.split(old_block, 1)[1].lstrip() + # Old installs appended this block last. Only repair that exact + # shape; extra fields or nested tables may customize the launcher. + if not tail: + self.write(path, current.replace(old_block, block, 1)) + return if marker in current: self.lines.append(f"kept {path} ({what} already present)") return @@ -197,7 +221,7 @@ def _dsh_env_expression() -> str: "}).filter(([, v]) => v != null))'") -def dsh_patch_block() -> str: +def dsh_patch_block(launcher: Optional[List[str]] = None) -> str: """The two ``cordis.patch.yml`` rows: the MCP server and the hooks bridge. Wrapped in ``- insert:`` because a dsh patch file **overrides existing rows @@ -223,6 +247,7 @@ def dsh_patch_block() -> str: undefined values that are the actual problem.) Reproduced and fixed against dsh 0.1.2-rc.1 with no provider keys in the environment. """ + launcher = launcher or [MCP_COMMAND] env = " " + _dsh_env_expression() return ( "# `insert` adds rows; a bare row would be read as an override by id.\n" @@ -232,8 +257,8 @@ def dsh_patch_block() -> str: " config:\n" f" serverName: {MCP_COMMAND}\n" " transport: stdio\n" - f" command: {MCP_COMMAND}\n" - f" args: {json.dumps(MCP_ARGS)}\n" + f" command: {json.dumps(launcher[0], ensure_ascii=False)}\n" + f" args: {json.dumps(launcher[1:] + MCP_ARGS)}\n" " toolCallTimeoutMs: 120000\n" " env:\n" " # dsh scrubs KEY|PASSWORD|SECRET|TOKEN from the ambient env\n" @@ -249,9 +274,9 @@ def install_dsh(home: str, w: _Writer) -> None: dsh_home = os.environ.get("DSH_HOME") or os.path.join(home, ".dsh") skill_dir = os.path.join(dsh_home, "skills", "agentdescent") w.write(os.path.join(skill_dir, "SKILL.md"), skill_text()) - w.write(os.path.join(skill_dir, "hooks.json"), hooks_text()) + w.write(os.path.join(skill_dir, "hooks.json"), hooks_text(w.launcher)) w.sync_block(os.path.join(dsh_home, "cordis.patch.yml"), - DSH_BLOCK_START, DSH_BLOCK_END, dsh_patch_block(), + DSH_BLOCK_START, DSH_BLOCK_END, dsh_patch_block(w.launcher), what="mcp-client + hooks entries") w.note(f"forwarded into the MCP server env: {', '.join(DSH_FORWARDED_KEYS)}") w.note("verify: dsh --profile web --dump-config | grep -n agentdescent") @@ -288,8 +313,10 @@ def marketplace_manifest(source: str = "./integrations/claude-code") -> Dict[str } -def mcp_manifest() -> Dict[str, object]: - return {"mcpServers": {"agentdescent": {"command": MCP_COMMAND, "args": MCP_ARGS}}} +def mcp_manifest(launcher: Optional[List[str]] = None) -> Dict[str, object]: + launcher = launcher or [MCP_COMMAND] + return {"mcpServers": {"agentdescent": { + "command": launcher[0], "args": launcher[1:] + MCP_ARGS}}} EVOLVE_COMMAND = """--- @@ -310,8 +337,9 @@ def render_claude_plugin(dest: str, w: Optional[_Writer] = None) -> List[str]: json.dumps(plugin_manifest(), indent=2) + "\n") w.write(os.path.join(dest, "skills", "agentdescent", "SKILL.md"), skill_text()) w.write(os.path.join(dest, "commands", "evolve.md"), EVOLVE_COMMAND) - w.write(os.path.join(dest, ".mcp.json"), json.dumps(mcp_manifest(), indent=2) + "\n") - w.write(os.path.join(dest, "hooks", "hooks.json"), hooks_text()) + w.write(os.path.join(dest, ".mcp.json"), json.dumps(mcp_manifest(w.launcher), indent=2) + "\n") + w.write(os.path.join(dest, "hooks", "hooks.json"), + hooks_text(w.launcher) if w.launcher != [MCP_COMMAND] else hooks_text()) return w.lines @@ -328,18 +356,63 @@ def install_claude_code(home: str, w: _Writer) -> None: # --------------------------------------------------------------------------- -def codex_config_block() -> str: +def codex_config_block(launcher: Optional[List[str]] = None) -> str: + launcher = launcher or [MCP_COMMAND] return ("\n# --- agentdescent (written by `agentdescent install codex`) ---\n" "[mcp_servers.agentdescent]\n" - f'command = "{MCP_COMMAND}"\n' - f"args = {json.dumps(MCP_ARGS)}\n") + f"command = {json.dumps(launcher[0], ensure_ascii=False)}\n" + f"args = {json.dumps(launcher[1:] + MCP_ARGS)}\n") + + +def _stock_mcp_argv(command: object) -> bool: + """Recognize only the complete argv shape emitted by this installer. + + An absolute conventional Python interpreter with the exact module invocation + is a pinned stock launcher, even after its environment disappears. Unknown + executable names, extra arguments and relative paths remain user-owned. + """ + if command == [MCP_COMMAND, *MCP_ARGS]: + return True + return (isinstance(command, list) and len(command) == 4 + and isinstance(command[0], str) + and (os.path.isabs(command[0]) or ntpath.isabs(command[0])) + and re.fullmatch(r"(?:pythonw?|pypy)(?:[0-9]+(?:\.[0-9]+)*)?(?:\.exe)?", + ntpath.basename(command[0]), re.IGNORECASE) is not None + and command[1:] == ["-m", "agentdescent.cli", *MCP_ARGS]) + + +def _stock_codex_block(current: str) -> Optional[str]: + """Find an untouched installer-owned final block, legacy or pinned. + + Match our marker and exact serialization, not merely a TOML table name. + In particular, inline fields and nested env tables must not be overwritten. + """ + marker = "\n# --- agentdescent (written by `agentdescent install codex`) ---\n" + if current.count(marker) != 1 or current.count("[mcp_servers.agentdescent]") != 1: + return None + previous = current[current.index(marker):] + lines = previous.rstrip().splitlines() + if (len(lines) != 5 or lines[2] != "[mcp_servers.agentdescent]" + or not lines[3].startswith("command = ") + or not lines[4].startswith("args = ")): + return None + try: + command = json.loads(lines[3][len("command = "):]) + args = json.loads(lines[4][len("args = "):]) + except (ValueError, TypeError): + return None + if not isinstance(args, list) or not _stock_mcp_argv([command, *args]): + return None + launcher = [command, *args[:-len(MCP_ARGS)]] + return previous if previous.rstrip() == codex_config_block(launcher).rstrip() else None def install_codex(home: str, w: _Writer) -> None: codex_home = os.environ.get("CODEX_HOME") or os.path.join(home, ".codex") w.write(os.path.join(codex_home, "skills", "agentdescent", "SKILL.md"), skill_text()) w.append_unless(os.path.join(codex_home, "config.toml"), "[mcp_servers.agentdescent]", - codex_config_block(), what="mcp_servers entry") + codex_config_block(w.launcher), what="mcp_servers entry", + previous=_stock_codex_block) w.note("Codex has no hooks; add `run agentdescent status --brief at the start of a " "session` to AGENTS.md if you want in-progress runs surfaced.") # Codex reads the same plugin format as Claude Code, and installing the @@ -355,14 +428,14 @@ def install_codex(home: str, w: _Writer) -> None: # --------------------------------------------------------------------------- -def opencode_mcp_entry() -> Dict[str, object]: +def opencode_mcp_entry(launcher: Optional[List[str]] = None) -> Dict[str, object]: """The ``mcp`` entry, in the shape ``opencode mcp add`` itself writes. Verified by running ``opencode mcp add agentdescent -- agentdescent mcp`` against opencode 1.18 and reading the file back: ``type: "local"``, the command as one array (not command + args), and ``environment`` for env vars. """ - return {"type": "local", "command": [MCP_COMMAND, *MCP_ARGS]} + return {"type": "local", "command": [*(launcher or [MCP_COMMAND]), *MCP_ARGS]} def install_opencode(home: str, w: _Writer) -> None: @@ -384,14 +457,17 @@ def install_opencode(home: str, w: _Writer) -> None: # would corrupt the file, so say what to add instead of guessing. w.note("NOTE: could not parse opencode.jsonc (comments?); add this " "under \"mcp\" yourself: " - + json.dumps({"agentdescent": opencode_mcp_entry()})) + + json.dumps({"agentdescent": opencode_mcp_entry(w.launcher)})) return - if (current.get("mcp") or {}).get("agentdescent"): + existing = (current.get("mcp") or {}).get("agentdescent") + stock = (isinstance(existing, dict) and set(existing) == {"type", "command"} + and existing["type"] == "local" and _stock_mcp_argv(existing["command"])) + if existing and not stock: w.lines.append(f"kept {path} (mcp entry already present)") return merged = dict(current) merged.setdefault("$schema", "https://opencode.ai/config.json") - merged["mcp"] = {**(current.get("mcp") or {}), "agentdescent": opencode_mcp_entry()} + merged["mcp"] = {**(current.get("mcp") or {}), "agentdescent": opencode_mcp_entry(w.launcher)} w.write(path, json.dumps(merged, indent=2) + "\n") w.note("verify: opencode mcp list") @@ -724,7 +800,10 @@ def install(host: str, *, dry_run: bool = False, home: Optional[str] = None) -> """Wire ``host`` up. Returns the lines to print: what was written or kept.""" if host not in HOSTS: raise ValueError(f"unknown host {host!r}; choose from {sorted(HOSTS)}") - w = _Writer(dry_run) + # Bind machine-local configuration to this installation, not the host's + # future PATH. Do not resolve symlinks: a venv's Python must stay in its + # venv. Portable render_* templates deliberately keep the bare command. + w = _Writer(dry_run, [os.path.abspath(sys.executable), "-m", "agentdescent.cli"]) HOSTS[host](os.path.expanduser(home or "~"), w) # Imported here, not at module scope: `cli` reaches into this module from # `cmd_install`, and the reason for a missing SDK belongs in one place. diff --git a/scripts/smoke-installed-wheel.py b/scripts/smoke-installed-wheel.py index af636e8c..279c1137 100755 --- a/scripts/smoke-installed-wheel.py +++ b/scripts/smoke-installed-wheel.py @@ -7,13 +7,15 @@ import importlib.metadata import json import os +import re from pathlib import Path -import shutil import subprocess import sys import tempfile -from agentdescent.integrations import hooks_text, install, skill_text +from agentdescent.integrations import ( + codex_config_block, hooks_text, install, opencode_mcp_entry, skill_text, +) MCP_SESSION_TIMEOUT_SECONDS = 45 @@ -63,7 +65,8 @@ def main() -> None: assert installed_skill.read_text(encoding="utf-8") == skill_text(), ( "installed wheel did not provide the shared SKILL.md resource" ) - assert installed_hooks.read_text(encoding="utf-8") == hooks_text(), ( + launcher = [os.path.abspath(sys.executable), "-m", "agentdescent.cli"] + assert installed_hooks.read_text(encoding="utf-8") == hooks_text(launcher), ( "installed wheel did not provide the hooks.json resource" ) assert "SessionStart" in json.loads(hooks_text())["hooks"], ( @@ -74,6 +77,69 @@ def main() -> None: for name in ("PYTHONPATH", "PYTHONHOME"): env.pop(name, None) env["AGENTDESCENT_HOME"] = str(workdir / "home") + env["HOME"] = str(host_home) + env["USERPROFILE"] = str(host_home) + env["CODEX_HOME"] = str(host_home / ".codex") + env["DSH_HOME"] = str(host_home / ".dsh") + env["XDG_CONFIG_HOME"] = str(host_home / ".config") + # Model a host started after the installer exits: no pip scripts on + # PATH, no source checkout, and no ambient Python import overrides. + host_env = dict(env, PATH=str(workdir / "empty-path")) + (workdir / "empty-path").mkdir() + # Reinstall must repair an untouched launcher pinned by an earlier + # install, even when that Python environment no longer exists. Launch + # the repaired manifests below to test the real installed CLI, not just + # string replacement in the source checkout. + obsolete = [str(workdir / "removed-env" / "python"), "-m", "agentdescent.cli"] + codex_config = host_home / ".codex" / "config.toml" + codex_config.parent.mkdir(parents=True, exist_ok=True) + codex_config.write_text(codex_config_block(obsolete), encoding="utf-8") + opencode_config = host_home / ".config" / "opencode" / "opencode.jsonc" + opencode_config.parent.mkdir(parents=True, exist_ok=True) + opencode_config.write_text(json.dumps({ + "mcp": {"agentdescent": opencode_mcp_entry(obsolete)}, + }), encoding="utf-8") + manifests = {} + for host in ("claude-code", "codex", "dsh", "opencode"): + configured = subprocess.run( + [*launcher, "install", host, "--home", str(host_home)], + cwd=workdir, env=host_env, + text=True, capture_output=True, timeout=30, + ) + assert configured.returncode == 0, configured.stdout + configured.stderr + manifests["claude-code"] = json.loads( + (plugin / ".mcp.json").read_text())["mcpServers"]["agentdescent"] + oc = json.loads((host_home / ".config/opencode/opencode.jsonc").read_text()) + argv = oc["mcp"]["agentdescent"]["command"] + manifests["opencode"] = {"command": argv[0], "args": argv[1:]} + # These fields are JSON-quoted strings/arrays in our generated TOML + # and YAML. Parse without adding dependencies to the wheel smoke gate. + for host, path, separator in ( + ("codex", host_home / ".codex/config.toml", "="), + ("dsh", host_home / ".dsh/cordis.patch.yml", ":"), + ): + text = path.read_text() + manifests[host] = { + field: json.loads(re.search( + rf"^\s*{field}\s*{separator}\s*(.+)$", text, re.M).group(1)) + for field in ("command", "args") + } + for host, manifest in manifests.items(): + assert [manifest["command"], *manifest["args"]] == [*launcher, "mcp"] + launched = subprocess.run( + [manifest["command"], *manifest["args"][:-1], "--help"], + cwd=workdir, env=host_env, text=True, capture_output=True, timeout=30, + ) + assert launched.returncode == 0, (host, launched.stdout, launched.stderr) + if os.name != "nt": + for path in (installed_hooks, host_home / ".dsh/skills/agentdescent/hooks.json"): + hook = json.loads(path.read_text())["hooks"]["SessionStart"][0]["hooks"][0] + # Drop the intentional best-effort shell suffix so a missing + # executable cannot be hidden by `|| true` in this regression. + command = hook["command"].removesuffix(" 2>/dev/null || true") + status = subprocess.run(command, shell=True, cwd=workdir, env=host_env, + text=True, capture_output=True, timeout=30) + assert status.returncode == 0, status.stdout + status.stderr policy_init = subprocess.run( ["agentdescent", "init", "selection", "--kind", "policy_slot"], @@ -118,13 +184,11 @@ def main() -> None: from mcp import ClientSession, StdioServerParameters from mcp.client.stdio import stdio_client - async def check_server() -> None: - command = shutil.which("agentdescent") - assert command, "installed agentdescent console script is missing" + async def check_server(manifest) -> None: params = StdioServerParameters( - command=command, - args=["mcp"], - env=env, + command=manifest["command"], + args=manifest["args"], + env=host_env, ) async with stdio_client(params) as (reader, writer): async with ClientSession(reader, writer) as session: @@ -138,7 +202,8 @@ async def check_server() -> None: f"MCP doctor failed: {result}" ) - asyncio.run(run_mcp_check(check_server)) + for manifest in manifests.values(): + asyncio.run(run_mcp_check(lambda: check_server(manifest))) if __name__ == "__main__": diff --git a/tests/test_integrations.py b/tests/test_integrations.py index 9eef3362..99afd110 100644 --- a/tests/test_integrations.py +++ b/tests/test_integrations.py @@ -4,6 +4,7 @@ import os import shutil import subprocess +import sys import pytest @@ -59,7 +60,7 @@ def test_install_dsh_writes_skill_hooks_and_patch(tmp_path, monkeypatch): assert skill.exists() and (skill.parent / "hooks.json").exists() patch = _read(tmp_path / ".dsh" / "cordis.patch.yml") assert "name: '@deepseek-ai/dsh-mcp-client'" in patch - assert "serverName: agentdescent" in patch and "args: [\"mcp\"]" in patch + assert "serverName: agentdescent" in patch and 'args: ["-m", "agentdescent.cli", "mcp"]' in patch for key in DSH_FORWARDED_KEYS: # Each key is forwarded past dsh's scrubbing -- but as part of one # filtered !!js expression, never as its own entry: unset, it would be @@ -105,7 +106,7 @@ def test_install_claude_code_renders_a_loadable_plugin_dir(tmp_path): dest = tmp_path / ".agentdescent" / "plugins" / "claude-code" manifest = json.loads(_read(dest / ".claude-plugin" / "plugin.json")) assert manifest["name"] == "agentdescent" and manifest["version"] - assert json.loads(_read(dest / ".mcp.json"))["mcpServers"]["agentdescent"]["args"] == ["mcp"] + assert json.loads(_read(dest / ".mcp.json"))["mcpServers"]["agentdescent"]["args"] == ["-m", "agentdescent.cli", "mcp"] hooks = json.loads(_read(dest / "hooks" / "hooks.json")) assert "SessionStart" in hooks["hooks"] assert (dest / "skills" / "agentdescent" / "SKILL.md").exists() @@ -121,7 +122,8 @@ def test_install_opencode_writes_the_shape_opencode_itself_writes(tmp_path, monk assert (root / "skill" / "agentdescent" / "SKILL.md").exists() cfg = json.loads(_read(root / "opencode.jsonc")) assert cfg["mcp"]["agentdescent"] == {"type": "local", - "command": ["agentdescent", "mcp"]} + "command": [os.path.abspath(sys.executable), + "-m", "agentdescent.cli", "mcp"]} assert cfg["$schema"].startswith("https://opencode.ai") # merging keeps what was already there, and is idempotent cfg["model"] = "anthropic/claude" @@ -738,3 +740,234 @@ def test_the_skill_says_how_to_choose_an_agent(): assert "Never a CLI coding" in text # for kind: text assert "Never invent a model name" in text # it guessed gpt-4o-mini assert "Anthropic SDK" in text # `claude` is not `claude_code` + + +@pytest.mark.parametrize("host", ["codex", "opencode"]) +def test_install_repairs_only_stock_path_dependent_entry(tmp_path, monkeypatch, host): + from agentdescent.integrations import codex_config_block, opencode_mcp_entry + + monkeypatch.delenv("CODEX_HOME", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + if host == "codex": + path = tmp_path / ".codex" / "config.toml" + original = 'model = "keep"\n' + codex_config_block() + else: + path = tmp_path / ".config" / "opencode" / "opencode.jsonc" + original = json.dumps({"model": "keep", "mcp": {"agentdescent": opencode_mcp_entry()}}) + path.parent.mkdir(parents=True) + path.write_text(original) + install(host, home=str(tmp_path), dry_run=True) + assert path.read_text() == original + install(host, home=str(tmp_path)) + repaired = path.read_text() + assert "agentdescent.cli" in repaired and "keep" in repaired + install(host, home=str(tmp_path)) + assert path.read_text() == repaired + + +@pytest.mark.parametrize("host", ["codex", "opencode"]) +def test_install_preserves_custom_mcp_launcher(tmp_path, monkeypatch, host): + monkeypatch.delenv("CODEX_HOME", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + if host == "codex": + path = tmp_path / ".codex" / "config.toml" + original = '[mcp_servers.agentdescent]\ncommand = "my-wrapper"\nargs = ["mcp"]\n' + else: + path = tmp_path / ".config" / "opencode" / "opencode.jsonc" + original = json.dumps({"mcp": {"agentdescent": {"type": "local", "command": ["my-wrapper"]}}}) + path.parent.mkdir(parents=True) + path.write_text(original) + install(host, home=str(tmp_path)) + assert path.read_text() == original + + +@pytest.mark.parametrize("executable", [ + "/opt/Agent Tools/O'Brien/🐍/bin/python", + r"C:\Users\Agent Tools\python.exe", +]) +def test_launcher_paths_round_trip_in_host_formats(executable): + import re + import shlex + import yaml + from agentdescent.integrations import ( + codex_config_block, dsh_patch_block, hooks_text, mcp_manifest, opencode_mcp_entry, + ) + + launcher = [executable, "-m", "agentdescent.cli"] + expected = launcher + ["mcp"] + manifest = mcp_manifest(launcher)["mcpServers"]["agentdescent"] + assert [manifest["command"], *manifest["args"]] == expected + assert opencode_mcp_entry(launcher)["command"] == expected + doc = yaml.safe_load(dsh_patch_block(launcher).replace("!!js ", "")) + config = doc[0]["insert"][0]["config"] + assert [config["command"], *config["args"]] == expected + block = codex_config_block(launcher) + # TOML's basic strings share JSON's escaping for these path characters. + assert json.loads(re.search(r'^command = (.+)$', block, re.M).group(1)) == executable + if sys.version_info >= (3, 11): + import tomllib + config = tomllib.loads(block)["mcp_servers"]["agentdescent"] + assert [config["command"], *config["args"]] == expected + if os.name != "nt": + hook = json.loads(hooks_text(launcher))["hooks"]["SessionStart"][0]["hooks"][0] + assert shlex.split(hook["command"].removesuffix(" 2>/dev/null || true")) == ( + launcher + ["status", "--brief"] + ) + + +@pytest.mark.parametrize("custom", [ + 'env = { PATH = "/custom/wrapper/bin" }\n', + '\n[mcp_servers.agentdescent.env]\nPATH = "/custom/wrapper/bin"\n', +]) +def test_install_preserves_stock_codex_command_with_custom_fields(tmp_path, monkeypatch, custom): + from agentdescent.integrations import codex_config_block + + monkeypatch.delenv("CODEX_HOME", raising=False) + path = tmp_path / ".codex" / "config.toml" + path.parent.mkdir() + original = codex_config_block() + custom + path.write_text(original) + install("codex", home=str(tmp_path)) + assert path.read_text() == original + + +def test_windows_hook_uses_the_existing_posix_shell_contract(monkeypatch): + import shlex + from agentdescent import integrations + + launcher = [r"C:\Users\Alice\Python\python.exe", "-m", "agentdescent.cli"] + monkeypatch.setattr(integrations.os, "name", "nt") + hook = json.loads(integrations.hooks_text(launcher))["hooks"]["SessionStart"][0]["hooks"][0] + assert shlex.split(hook["command"].removesuffix(" 2>/dev/null || true")) == ( + launcher + ["status", "--brief"] + ) + + +@pytest.mark.parametrize("host", ["codex", "opencode", "claude-code", "dsh"]) +def test_reinstall_refreshes_stock_pinned_interpreter(tmp_path, monkeypatch, host): + """Replacing an environment must repair our generated launcher, not keep + an executable that disappeared. A dry-run must still leave files alone.""" + import re + import shlex + import yaml + + for name in ("CODEX_HOME", "XDG_CONFIG_HOME", "DSH_HOME"): + monkeypatch.delenv(name, raising=False) + old = tmp_path / "old env" / "python" + new = tmp_path / "new env" / "python" + for executable in (old, new): + executable.parent.mkdir() + executable.write_text("temporary interpreter placeholder") + home = tmp_path / "home" + with monkeypatch.context() as scoped: + scoped.setattr(sys, "executable", str(old)) + install(host, home=str(home)) + before = {p: p.read_bytes() for p in home.rglob("*") if p.is_file()} + monkeypatch.setattr(sys, "executable", str(new)) + install(host, home=str(home), dry_run=True) + assert {p: p.read_bytes() for p in home.rglob("*") if p.is_file()} == before + old.unlink() + install(host, home=str(home)) + if host == "codex": + text = (home / ".codex/config.toml").read_text() + argv = [json.loads(re.search(r'^command = (.+)$', text, re.M).group(1)), + *json.loads(re.search(r'^args = (.+)$', text, re.M).group(1))] + elif host == "opencode": + data = json.loads((home / ".config/opencode/opencode.jsonc").read_text()) + argv = data["mcp"]["agentdescent"]["command"] + elif host == "claude-code": + data = json.loads((home / ".agentdescent/plugins/claude-code/.mcp.json").read_text()) + entry = data["mcpServers"]["agentdescent"] + argv = [entry["command"], *entry["args"]] + else: + text = (home / ".dsh/cordis.patch.yml").read_text() + entry = yaml.safe_load(text.replace("!!js ", ""))[0]["insert"][0]["config"] + argv = [entry["command"], *entry["args"]] + assert argv == [str(new), "-m", "agentdescent.cli", "mcp"] + # DSH and Claude install session hooks too; these already refresh, and + # must continue to agree with the MCP interpreter after an environment move. + for path in home.rglob("hooks.json"): + data = json.loads(path.read_text()) + hook = data["hooks"]["SessionStart"][0]["hooks"][0]["command"] + assert shlex.split(hook.removesuffix(" 2>/dev/null || true")) == [ + str(new), "-m", "agentdescent.cli", "status", "--brief"] + repaired = {p: p.read_bytes() for p in home.rglob("*") if p.is_file()} + install(host, home=str(home)) + assert {p: p.read_bytes() for p in home.rglob("*") if p.is_file()} == repaired + + +@pytest.mark.parametrize("custom", [ + 'env = { MODE = "custom" }\n', + '\n[mcp_servers.agentdescent.env]\nMODE = "custom"\n', + 'startup_timeout_sec = 90\n', + '\n[mcp_servers.other]\ncommand = "keep"\n', +]) +def test_reinstall_preserves_customized_pinned_codex_entry(tmp_path, monkeypatch, custom): + from agentdescent.integrations import codex_config_block + + monkeypatch.delenv("CODEX_HOME", raising=False) + path = tmp_path / ".codex/config.toml" + path.parent.mkdir() + original = codex_config_block(["/old/python", "-m", "agentdescent.cli"]) + custom + path.write_text(original) + monkeypatch.setattr(sys, "executable", "/new/python") + install("codex", home=str(tmp_path)) + assert path.read_text() == original + + +@pytest.mark.parametrize("change", [ + {"environment": {"MODE": "custom"}}, + {"enabled": False}, + {"command": ["/old/python", "-m", "agentdescent.cli", "mcp", "--custom"]}, + {"command": ["/custom/wrapper"]}, + {"command": ["relative/python", "-m", "agentdescent.cli", "mcp"]}, +]) +def test_reinstall_preserves_customized_pinned_opencode_entry(tmp_path, monkeypatch, change): + from agentdescent.integrations import opencode_mcp_entry + + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + path = tmp_path / ".config/opencode/opencode.jsonc" + path.parent.mkdir(parents=True) + entry = opencode_mcp_entry(["/old/python", "-m", "agentdescent.cli"]) + entry.update(change) + original = json.dumps({"mcp": {"agentdescent": entry}, "model": "keep"}) + path.write_text(original) + monkeypatch.setattr(sys, "executable", "/new/python") + install("opencode", home=str(tmp_path)) + assert path.read_text() == original + + +@pytest.mark.parametrize("host", ["codex", "opencode"]) +@pytest.mark.parametrize("old,refresh", [ + ("/old/python", True), ("/old/python3", True), + ("/old/python3.9", True), (r"C:\old\pythonw.exe", True), + (r"C:\old\pypy3.exe", True), + ("/opt/company/mcp-wrapper", False), + (r"C:\company\mcp-wrapper.exe", False), + ("/old/renamed-interpreter", False), ("relative/python", False), +]) +def test_reinstall_distinguishes_pinned_python_from_custom_launchers( + tmp_path, monkeypatch, host, old, refresh): + from agentdescent.integrations import codex_config_block, opencode_mcp_entry + + monkeypatch.delenv("CODEX_HOME", raising=False) + monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) + launcher = [old, "-m", "agentdescent.cli"] + if host == "codex": + path = tmp_path / ".codex/config.toml" + original = codex_config_block(launcher) + expected = codex_config_block(["/new/python", "-m", "agentdescent.cli"]) + else: + path = tmp_path / ".config/opencode/opencode.jsonc" + original = json.dumps({"mcp": {"agentdescent": opencode_mcp_entry(launcher)}}) + path.parent.mkdir(parents=True) + path.write_text(original) + monkeypatch.setattr(sys, "executable", "/new/python") + install(host, home=str(tmp_path)) + if not refresh: + assert path.read_text() == original + elif host == "codex": + assert path.read_text() == expected + else: + assert json.loads(path.read_text())["mcp"]["agentdescent"]["command"] == [ + "/new/python", "-m", "agentdescent.cli", "mcp"]