Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
125 changes: 102 additions & 23 deletions agentdescent/integrations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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]:
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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"
Expand All @@ -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")
Expand Down Expand Up @@ -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 = """---
Expand All @@ -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


Expand All @@ -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
Comment on lines +379 to +380

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recognize free-threaded CPython launchers on reinstall

When installation runs under an official free-threaded CPython, sys.executable may be named python3.13t or python3.13t.exe (Python documentation), but this regex rejects the t suffix. The first install therefore writes a valid pinned launcher, while a reinstall after that environment is moved classifies the exact installer-generated Codex/OpenCode entry as custom and retains the deleted executable. Fresh evidence beyond the earlier fixed report is that the repaired recognizer still excludes this official interpreter name; allow the free-threaded suffix when recognizing stock launchers.

Useful? React with 👍 / 👎.

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
Expand All @@ -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:
Expand All @@ -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)")
Comment on lines +462 to 466

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Refresh previously pinned launchers after Python moves

When install is rerun from a different Python environment—for example after replacing or relocating a virtualenv—the existing entry generated by this version contains the old absolute interpreter and therefore differs from the bare legacy opencode_mcp_entry(). This branch misclassifies that installer-generated entry as custom and retains the now-stale executable, so OpenCode can no longer launch MCP even though reinstall succeeds; the marker check in append_unless causes the analogous Codex failure. Recognize uncustomized pinned entries as installer-owned and refresh them to w.launcher while continuing to preserve genuinely custom launchers.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0a5b611. Reinstall now refreshes untouched stock pinned Codex/OpenCode launchers after the old Python environment disappears. Recognition stays conservative: customized fields, additional arguments, relative paths and non-Python wrapper executables are preserved. Added all-four-host reinstall/dry-run/idempotence coverage plus POSIX/Windows wrapper controls, and seeded stale pinned configs into the installed-wheel smoke. Focused checks: Python 3.12 110 passed/4 skipped; Python 3.9 108 passed/6 skipped; independent final review clean. The PR is Draft while new exact-head CI runs (38039361138 tests, 38039361131 docs).

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

Expand Down Expand Up @@ -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.
Expand Down
85 changes: 75 additions & 10 deletions scripts/smoke-installed-wheel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"], (
Expand All @@ -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"],
Expand Down Expand Up @@ -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:
Expand All @@ -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__":
Expand Down
Loading
Loading