avp-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,331 @@
1
+ """Install agents into the library at `~/.avp/agents/<name>/`.
2
+
3
+ Two sources, same result (a self-contained install + a generated manifest the
4
+ CLI drives):
5
+
6
+ - **Release** (`install_release`): download the prebuilt artifacts from a GitHub
7
+ release over HTTPS (no `gh`, no auth for a public repo). Binary agents (goose)
8
+ get an extracted executable; Python agents (claude-code) get a managed `venv/`
9
+ built from the release's wheels. This is the normal path.
10
+ - **Local** (`install_local_binary` / `install_local_python`): point the CLI at a
11
+ locally built binary or wheel set, no release needed. This is the contributor
12
+ loop for testing your own agent or a new version before cutting a release
13
+ (`make build-agents` produces the artifacts; see avp-cli/README.md).
14
+
15
+ Each install writes `avp-conformance.json` (command → the installed artifact,
16
+ `cwd: "."` → the install dir) and `installed.json` (provenance). `agents.py`
17
+ prefers these over the in-repo dev fallback.
18
+
19
+ Release installs fetch over plain HTTPS (the GitHub REST API + asset URLs via
20
+ stdlib urllib): a public repo needs no `gh` and no auth; a `GH_TOKEN` /
21
+ `GITHUB_TOKEN` in the environment is used only for private repos or API rate
22
+ limits. Python agents additionally need `uv` (for the venv). The unpublished
23
+ in-repo wheels (e.g. `avp`) ship on the release and are installed by file path, so
24
+ PyPI's unrelated `avp` package is never pulled; third-party deps (claude-agent-sdk)
25
+ still come from PyPI.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import platform
33
+ import shutil
34
+ import subprocess
35
+ import tarfile
36
+ import tempfile
37
+ import urllib.error
38
+ import urllib.request
39
+ from dataclasses import dataclass
40
+ from datetime import UTC, datetime
41
+ from pathlib import Path
42
+
43
+ from avp_cli import paths
44
+ from avp_cli.agents import AGENT_SOURCES, AgentSource
45
+
46
+ # Override for forks / testing; defaults to the canonical repo.
47
+ RELEASE_REPO = os.environ.get("AVP_AGENT_REPO", "portofcontext/agent-voyager-project")
48
+
49
+
50
+ class InstallError(Exception):
51
+ """An agent could not be installed (bad artifact, missing tool, no release)."""
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class InstallResult:
56
+ name: str
57
+ version: str
58
+ kind: str # "binary" | "python"
59
+ source: str # "local" | "release"
60
+ install_dir: Path
61
+ command: list[str]
62
+
63
+
64
+ def current_target() -> str:
65
+ """The Rust target triple for selecting a binary release asset (mac/linux)."""
66
+ machine = platform.machine().lower()
67
+ arch = {"x86_64": "x86_64", "amd64": "x86_64", "arm64": "aarch64", "aarch64": "aarch64"}.get(
68
+ machine
69
+ )
70
+ if arch is None:
71
+ raise InstallError(f"unsupported architecture {platform.machine()!r}")
72
+ system = platform.system()
73
+ if system == "Darwin":
74
+ return f"{arch}-apple-darwin"
75
+ if system == "Linux":
76
+ return f"{arch}-unknown-linux-gnu"
77
+ raise InstallError(f"unsupported platform {system!r} (prebuilt agents are macOS/Linux only)")
78
+
79
+
80
+ def install(
81
+ name: str,
82
+ *,
83
+ version: str | None = None,
84
+ binary: str | Path | None = None,
85
+ wheels: list[str | Path] | None = None,
86
+ force: bool = False,
87
+ ) -> InstallResult:
88
+ """Install a known agent. With `binary`/`wheels`, install locally; else from a release."""
89
+ source = AGENT_SOURCES.get(name)
90
+ if source is None:
91
+ raise InstallError(f"unknown agent {name!r}; known: {', '.join(AGENT_SOURCES)}")
92
+ if binary is not None:
93
+ if source.kind != "binary":
94
+ raise InstallError(
95
+ f"{name} is a {source.kind} agent; --binary applies to binary agents"
96
+ )
97
+ return install_local_binary(source, binary, force=force)
98
+ if wheels:
99
+ if source.kind != "python":
100
+ raise InstallError(f"{name} is a {source.kind} agent; --wheel applies to python agents")
101
+ return install_local_python(source, wheels, force=force)
102
+ return install_release(source, version=version, force=force)
103
+
104
+
105
+ def install_release(
106
+ source: AgentSource, *, version: str | None = None, force: bool = False
107
+ ) -> InstallResult:
108
+ """Download a release's artifacts over HTTPS and install them.
109
+
110
+ No `gh` and no auth for a public repo: assets are fetched from the GitHub
111
+ REST API + their download URLs via stdlib urllib. A `GH_TOKEN` / `GITHUB_TOKEN`
112
+ in the environment is used if present (private repos, API rate limits).
113
+ """
114
+ tag, resolved, assets = _resolve_release(source, version)
115
+ by_name = {a["name"]: a["browser_download_url"] for a in assets}
116
+ with tempfile.TemporaryDirectory() as tmp:
117
+ tmpdir = Path(tmp)
118
+ if source.kind == "binary":
119
+ target = current_target()
120
+ asset = f"{source.binary_name}-{target}.tar.gz"
121
+ url = by_name.get(asset)
122
+ if url is None:
123
+ have = ", ".join(by_name) or "none"
124
+ raise InstallError(f"release {tag} has no asset {asset!r} (assets: {have})")
125
+ archive = tmpdir / asset
126
+ _download(url, archive)
127
+ binary = _extract_binary(archive, source.binary_name or "", tmpdir)
128
+ return install_local_binary(
129
+ source, binary, version=resolved, force=force, origin="release", target=target
130
+ )
131
+ wheels = []
132
+ for name, url in by_name.items():
133
+ if name.endswith(".whl"):
134
+ dest = tmpdir / name
135
+ _download(url, dest)
136
+ wheels.append(dest)
137
+ if not wheels:
138
+ raise InstallError(f"release {tag} carried no .whl assets")
139
+ return install_local_python(source, wheels, version=resolved, force=force, origin="release")
140
+
141
+
142
+ def install_local_binary(
143
+ source: AgentSource,
144
+ binary: str | Path,
145
+ *,
146
+ version: str = "local",
147
+ force: bool = False,
148
+ origin: str = "local",
149
+ target: str | None = None,
150
+ ) -> InstallResult:
151
+ """Install a prebuilt binary agent from a local file."""
152
+ binary = Path(binary)
153
+ if not binary.is_file():
154
+ raise InstallError(f"binary not found: {binary}")
155
+ d = _prepare_dir(source.name, force=force)
156
+ dest = d / "bin" / (source.binary_name or binary.name)
157
+ dest.parent.mkdir(parents=True, exist_ok=True)
158
+ shutil.copy2(binary, dest)
159
+ dest.chmod(0o755)
160
+ command = [str(dest)]
161
+ _finalize(d, source, command, version=version, kind="binary", origin=origin, target=target)
162
+ return InstallResult(source.name, version, "binary", origin, d, command)
163
+
164
+
165
+ def install_local_python(
166
+ source: AgentSource,
167
+ wheels: list[str | Path],
168
+ *,
169
+ version: str = "local",
170
+ force: bool = False,
171
+ origin: str = "local",
172
+ ) -> InstallResult:
173
+ """Install a Python agent into a managed venv from a local wheel set."""
174
+ uv = shutil.which("uv")
175
+ if uv is None:
176
+ raise InstallError(
177
+ "installing a Python agent needs `uv` on PATH (https://docs.astral.sh/uv/)"
178
+ )
179
+ paths_ = [Path(w) for w in wheels]
180
+ missing = [w for w in paths_ if not w.is_file()]
181
+ if missing:
182
+ raise InstallError("wheel(s) not found: " + ", ".join(str(w) for w in missing))
183
+
184
+ d = _prepare_dir(source.name, force=force)
185
+ venv = d / "venv"
186
+ _run([uv, "venv", str(venv)], "create the venv")
187
+ py = venv / "bin" / "python"
188
+ # Install the wheels by FILE PATH (not by dist name). This pins `avp` to our
189
+ # in-repo wheel so the index is never consulted for it: there is an unrelated
190
+ # `avp` on PyPI, and resolving by name would let uv pick that (higher-version)
191
+ # package instead. The agent's third-party deps (claude-agent-sdk) still
192
+ # resolve from PyPI normally.
193
+ _run(
194
+ [uv, "pip", "install", "--python", str(py), *[str(w) for w in paths_]],
195
+ "install the agent wheels",
196
+ )
197
+ command = [str(py), "-m", source.module or ""]
198
+ _finalize(d, source, command, version=version, kind="python", origin=origin)
199
+ return InstallResult(source.name, version, "python", origin, d, command)
200
+
201
+
202
+ def uninstall(name: str) -> bool:
203
+ """Remove an installed agent. Returns False if it wasn't installed."""
204
+ d = paths.agents_dir() / name
205
+ if not d.exists():
206
+ return False
207
+ shutil.rmtree(d)
208
+ return True
209
+
210
+
211
+ def installed_info(name: str) -> dict | None:
212
+ """The provenance record for an installed agent, or None."""
213
+ p = paths.agents_dir() / name / "installed.json"
214
+ if not p.is_file():
215
+ return None
216
+ try:
217
+ return json.loads(p.read_text())
218
+ except (OSError, json.JSONDecodeError):
219
+ return None
220
+
221
+
222
+ # ── internals ─────────────────────────────────────────────────────────────────
223
+
224
+
225
+ def _prepare_dir(name: str, *, force: bool) -> Path:
226
+ d = paths.agents_dir() / name
227
+ if d.exists():
228
+ if not force:
229
+ raise InstallError(
230
+ f"agent {name!r} is already installed at {d}; pass --force to reinstall"
231
+ )
232
+ shutil.rmtree(d)
233
+ d.mkdir(parents=True, exist_ok=True)
234
+ return d
235
+
236
+
237
+ def _finalize(
238
+ d: Path,
239
+ source: AgentSource,
240
+ command: list[str],
241
+ *,
242
+ version: str,
243
+ kind: str,
244
+ origin: str,
245
+ target: str | None = None,
246
+ ) -> None:
247
+ """Write the generated manifest + provenance record."""
248
+ manifest = {
249
+ "command": command,
250
+ "cwd": ".",
251
+ "env": {},
252
+ "description": f"{source.name} ({origin} {kind} agent)",
253
+ }
254
+ (d / "avp-conformance.json").write_text(json.dumps(manifest, indent=2) + "\n")
255
+ record = {
256
+ "name": source.name,
257
+ "version": version,
258
+ "kind": kind,
259
+ "source": origin,
260
+ "installed_at": datetime.now(UTC).isoformat(timespec="seconds"),
261
+ "command": command,
262
+ }
263
+ if target:
264
+ record["target"] = target
265
+ (d / "installed.json").write_text(json.dumps(record, indent=2) + "\n")
266
+
267
+
268
+ def _resolve_release(source: AgentSource, version: str | None) -> tuple[str, str, list[dict]]:
269
+ """Return (tag, version, assets) for the requested (or newest) release."""
270
+ if version:
271
+ tag = f"{source.tag_prefix}-v{version}"
272
+ rel = _api(f"/releases/tags/{tag}")
273
+ return tag, version, rel.get("assets", [])
274
+ prefix = f"{source.tag_prefix}-v"
275
+ for rel in _api("/releases?per_page=100"): # the API returns newest first
276
+ tag = rel.get("tag_name", "")
277
+ if tag.startswith(prefix):
278
+ return tag, tag[len(prefix) :], rel.get("assets", [])
279
+ raise InstallError(
280
+ f"no releases for {source.name} (tag prefix {prefix!r}) in {RELEASE_REPO}; "
281
+ "pass --version, or build + install locally with --binary / --wheel"
282
+ )
283
+
284
+
285
+ def _auth_headers() -> dict[str, str]:
286
+ headers = {"User-Agent": "avp-cli", "Accept": "application/vnd.github+json"}
287
+ token = os.environ.get("GH_TOKEN") or os.environ.get("GITHUB_TOKEN")
288
+ if token: # only needed for private repos / API rate limits; public needs none
289
+ headers["Authorization"] = f"Bearer {token}"
290
+ return headers
291
+
292
+
293
+ def _api(path: str):
294
+ url = f"https://api.github.com/repos/{RELEASE_REPO}{path}"
295
+ req = urllib.request.Request(url, headers=_auth_headers())
296
+ try:
297
+ with urllib.request.urlopen(req, timeout=30) as resp:
298
+ return json.load(resp)
299
+ except urllib.error.HTTPError as exc:
300
+ raise InstallError(f"GitHub API {exc.code} ({exc.reason}) for {url}") from exc
301
+ except (urllib.error.URLError, TimeoutError) as exc:
302
+ raise InstallError(f"network error reaching GitHub: {exc}") from exc
303
+
304
+
305
+ def _download(url: str, dest: Path) -> None:
306
+ req = urllib.request.Request(url, headers=_auth_headers())
307
+ try:
308
+ with urllib.request.urlopen(req, timeout=120) as resp, dest.open("wb") as f:
309
+ shutil.copyfileobj(resp, f)
310
+ except (urllib.error.URLError, TimeoutError, OSError) as exc:
311
+ raise InstallError(f"failed to download {url}: {exc}") from exc
312
+
313
+
314
+ def _extract_binary(archive: Path, binary_name: str, dest: Path) -> Path:
315
+ with tarfile.open(archive) as tar:
316
+ tar.extractall(dest) # trusted: our own release artifact
317
+ for p in dest.rglob(binary_name):
318
+ if p.is_file():
319
+ return p
320
+ raise InstallError(f"{archive.name} did not contain {binary_name!r}")
321
+
322
+
323
+ def _run(cmd: list[str], what: str) -> str:
324
+ try:
325
+ result = subprocess.run(cmd, check=True, capture_output=True, text=True)
326
+ except FileNotFoundError as exc:
327
+ raise InstallError(f"could not run {cmd[0]!r}: {exc}") from exc
328
+ except subprocess.CalledProcessError as exc:
329
+ tail = "\n".join((exc.stderr or exc.stdout or "").strip().splitlines()[-5:])
330
+ raise InstallError(f"failed to {what}:\n{tail or '(no output)'}") from exc
331
+ return result.stdout
@@ -0,0 +1,73 @@
1
+ """How the CLI invokes an installed agent (the `avp-conformance.json` shape).
2
+
3
+ An agent ships an `avp-conformance.json` manifest describing how to spawn it.
4
+ The supervisor (this CLI) reads that file to run the agent, on the host for
5
+ `describe`/`list` and inside a sandbox for `eval`/`run` (the `container` block).
6
+ This is a deployment contract the supervisor owns; the conformance harness keeps
7
+ its own copy for the same file, since spawning agents is a deployment concern and
8
+ the shared `avp` wire-types package deliberately stays out of it.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+
15
+ from pydantic import BaseModel, Field
16
+
17
+ from avp.envelope import _STRICT
18
+
19
+
20
+ class ContainerSpec(BaseModel):
21
+ """How a supervisor installs and runs this agent inside a Linux sandbox.
22
+
23
+ `install` steps are Dockerfile RUN shell strings executed at image-build
24
+ time (network available); `command` is the in-sandbox argv prefix honoring
25
+ the same run contract as the host `command`.
26
+ """
27
+
28
+ model_config = _STRICT
29
+
30
+ install: list[str] = Field(
31
+ default_factory=list,
32
+ description="Image-build RUN steps that install a Linux build of the agent.",
33
+ )
34
+ command: list[str] = Field(
35
+ min_length=1,
36
+ description="Argv list used to run the agent inside the sandbox.",
37
+ )
38
+ env: dict[str, str] = Field(
39
+ default_factory=dict,
40
+ description="Environment variables the agent requires inside the sandbox.",
41
+ )
42
+
43
+
44
+ class AgentManifest(BaseModel):
45
+ """How the CLI spawns the agent subprocess.
46
+
47
+ `cwd` is resolved relative to the manifest file's location, not the CLI's
48
+ working directory. Resolution is the caller's responsibility; this model
49
+ only validates shape.
50
+ """
51
+
52
+ model_config = _STRICT
53
+
54
+ command: list[str] = Field(
55
+ min_length=1,
56
+ description="Argv list used to spawn the agent subprocess.",
57
+ )
58
+ cwd: Path = Field(
59
+ default=Path("."),
60
+ description="Working directory, relative to the manifest's location.",
61
+ )
62
+ env: dict[str, str] = Field(
63
+ default_factory=dict,
64
+ description="Environment variables passed to the subprocess.",
65
+ )
66
+ description: str | None = Field(
67
+ default=None,
68
+ description="Human-readable label for CLI output.",
69
+ )
70
+ container: ContainerSpec | None = Field(
71
+ default=None,
72
+ description="Optional: how to install and run this agent inside a sandbox.",
73
+ )
avp_cli/agents.py ADDED
@@ -0,0 +1,258 @@
1
+ """The agents an eval can run against: Goose and Claude Code.
2
+
3
+ Each is just its `avp-conformance.json` manifest, the same one the conformance
4
+ harness drives. An agent can come from one of three places, tried in order by
5
+ `resolve_agent`:
6
+
7
+ 1. **An explicit manifest path** (`--agent path/to/avp-conformance.json`) for
8
+ any third-party agent, no code change.
9
+ 2. **An installed agent** under `~/.avp/agents/<name>/` (a prebuilt artifact laid
10
+ down by `avp agent install`). This is the normal path once published.
11
+ 3. **The in-repo dev fallback** (`agents/<name>/<lang>/avp-conformance.json`),
12
+ used only when running from a source checkout. Its manifest builds the agent
13
+ from source (`cargo run` / `uv run`), so it's slow and needs the toolchain.
14
+
15
+ `AGENT_SOURCES` describes, per known agent, how it's distributed (a prebuilt
16
+ binary vs. a Python wheel set) and where its dev-fallback manifest lives; the
17
+ installer (`avp_cli.agent_install`) consumes it. `preflight` reports why an
18
+ agent can't run *on the host* (the `describe` path); actual runs happen inside
19
+ a sandbox, where `container_recipe` supplies the Linux install + run recipe
20
+ (from the manifest's `container` block, or built in here for known agents).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import importlib.util
26
+ import shutil
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+
30
+ from avp_cli import paths
31
+ from avp_cli.agent import load_manifest
32
+ from avp_cli.agent_manifest import AgentManifest
33
+ from avp_cli.images import ContainerRecipe
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class AgentSource:
38
+ """How a known agent is distributed and how the CLI installs / falls back to it.
39
+
40
+ `kind` selects the install mechanism: `"binary"` (a prebuilt executable
41
+ extracted to `bin/`) or `"python"` (a wheel set installed into a managed
42
+ `venv/`). `tag_prefix` is the GitHub release tag stem (`agent-goose` →
43
+ `agent-goose-v0.0.1`). `dev_manifest` is the in-repo manifest used only from
44
+ a source checkout.
45
+ """
46
+
47
+ name: str
48
+ kind: str # "binary" | "python"
49
+ tag_prefix: str
50
+ dev_manifest: str
51
+ # The release version whose Linux artifacts the container recipe installs.
52
+ # Pinned so the derived image is content-addressed; bump with releases.
53
+ container_version: str = ""
54
+ # binary agents
55
+ binary_name: str | None = None
56
+ # python agents: `python -m <module>`, install `dist` (pulls third-party
57
+ # deps from PyPI), with `wheel_dists` the unpublished in-repo wheels shipped
58
+ # on the release (resolved via --find-links so PyPI's unrelated `avp` isn't).
59
+ module: str | None = None
60
+ dist: str | None = None
61
+ wheel_dists: tuple[str, ...] = field(default_factory=tuple)
62
+
63
+
64
+ AGENT_SOURCES: dict[str, AgentSource] = {
65
+ "goose": AgentSource(
66
+ name="goose",
67
+ kind="binary",
68
+ tag_prefix="agent-goose",
69
+ dev_manifest="agents/avp-goose/rust/avp-conformance.json",
70
+ container_version="0.0.3",
71
+ binary_name="avp-goose-conformance",
72
+ ),
73
+ "claude-code": AgentSource(
74
+ name="claude-code",
75
+ kind="python",
76
+ tag_prefix="agent-claude-code",
77
+ dev_manifest="agents/avp-claude-agent-sdk/python/avp-conformance.json",
78
+ # 0.0.4: the wire wheel was renamed avp -> agent-voyager-project, so the
79
+ # bundled wheel filename changed; this release carries the new names.
80
+ container_version="0.0.4",
81
+ module="avp_claude_agent_sdk.conformance",
82
+ dist="avp-claude-agent-sdk",
83
+ # The conformance entrypoint also imports avp_conformance (load_commission
84
+ # / load_built_in), which pulls the wire types + typer; ship all the
85
+ # in-repo wheels. `agent-voyager-project` is the renamed `avp` dist.
86
+ wheel_dists=("agent-voyager-project", "avp-conformance", "avp-claude-agent-sdk"),
87
+ ),
88
+ }
89
+
90
+ _RELEASE_DL = "https://github.com/portofcontext/agent-voyager-project/releases/download"
91
+
92
+
93
+ def _builtin_recipe(source: AgentSource) -> ContainerRecipe:
94
+ """The container recipe for an in-tree agent, pinned to `container_version`.
95
+
96
+ Install steps run at image-build time (full network); `$(uname -m)` picks
97
+ the asset for the image's arch (aarch64 on Apple Silicon, x86_64 on amd64),
98
+ matching the release's `-unknown-linux-gnu` artifact names.
99
+ """
100
+ tag = f"{source.tag_prefix}-v{source.container_version}"
101
+ if source.kind == "binary":
102
+ asset = f"{source.binary_name}-$(uname -m)-unknown-linux-gnu.tar.gz"
103
+ return ContainerRecipe(
104
+ install=(
105
+ "apt-get update && apt-get install -y --no-install-recommends "
106
+ "curl ca-certificates && rm -rf /var/lib/apt/lists/*",
107
+ f"curl -fsSL {_RELEASE_DL}/{tag}/{asset} | tar -xz -C /usr/local/bin "
108
+ f"&& chmod +x /usr/local/bin/{source.binary_name}",
109
+ ),
110
+ command=(source.binary_name or source.name,),
111
+ )
112
+ # Python agent: the in-repo wheels come off the GitHub release (they are not
113
+ # on PyPI); third-party deps resolve from PyPI at build time. Node ships the
114
+ # `claude` CLI the agent shells out to. Assumes a python base image (the
115
+ # default env image is one); a wrong base fails loudly in the build log.
116
+ version = "0.1.0" # wheel version on the release
117
+ wheels = " ".join(
118
+ f"{_RELEASE_DL}/{tag}/{d.replace('-', '_')}-{version}-py3-none-any.whl"
119
+ for d in source.wheel_dists
120
+ )
121
+ return ContainerRecipe(
122
+ install=(
123
+ "apt-get update && apt-get install -y --no-install-recommends "
124
+ "nodejs npm ca-certificates && rm -rf /var/lib/apt/lists/*",
125
+ "npm install -g @anthropic-ai/claude-code",
126
+ f"pip install --no-cache-dir {wheels}",
127
+ ),
128
+ command=("python", "-m", source.module or ""),
129
+ # The claude CLI refuses bypassPermissions as root (the container's
130
+ # default user) unless it's told it is already inside a sandbox. It is.
131
+ env=(("IS_SANDBOX", "1"),),
132
+ )
133
+
134
+
135
+ class NoContainerRecipe(Exception):
136
+ """The agent can't run: no `container` block and no built-in recipe."""
137
+
138
+
139
+ def container_recipe(agent: ResolvedAgent) -> ContainerRecipe:
140
+ """How `agent` gets into and runs inside a sandbox image.
141
+
142
+ A manifest's `container` block wins (third-party agents describe
143
+ themselves); known agents fall back to the built-in pinned recipe. An
144
+ agent with neither cannot run (runs are always sandboxed)."""
145
+ spec = agent.manifest.container
146
+ if spec is not None:
147
+ return ContainerRecipe(
148
+ install=tuple(spec.install),
149
+ command=tuple(spec.command),
150
+ env=tuple(spec.env.items()),
151
+ )
152
+ source = AGENT_SOURCES.get(agent.name)
153
+ if source is not None and source.container_version:
154
+ return _builtin_recipe(source)
155
+ raise NoContainerRecipe(
156
+ f"agent '{agent.name}' has no container recipe: runs execute in a Linux "
157
+ "sandbox, so its manifest needs a `container` block "
158
+ '({"install": ["<RUN step>", ...], "command": ["<argv>", ...]}).'
159
+ )
160
+
161
+
162
+ DEFAULT_AGENT = "claude-code"
163
+
164
+
165
+ def _repo_root() -> Path:
166
+ # .../avp-cli/src/avp_cli/agents.py -> repo root
167
+ return Path(__file__).resolve().parents[3]
168
+
169
+
170
+ @dataclass(frozen=True)
171
+ class ResolvedAgent:
172
+ name: str
173
+ manifest: AgentManifest
174
+ manifest_cwd: Path
175
+
176
+
177
+ def known_agents() -> list[str]:
178
+ return list(AGENT_SOURCES)
179
+
180
+
181
+ def installed_manifest_path(name: str) -> Path:
182
+ """Where an installed agent's generated manifest lives (may not exist)."""
183
+ return paths.agents_dir() / name / "avp-conformance.json"
184
+
185
+
186
+ def is_installed(name: str) -> bool:
187
+ return installed_manifest_path(name).is_file()
188
+
189
+
190
+ def has_dev_fallback(name: str) -> bool:
191
+ """True if a known agent's in-repo manifest exists (running from a checkout)."""
192
+ src = AGENT_SOURCES.get(name)
193
+ return src is not None and (_repo_root() / src.dev_manifest).is_file()
194
+
195
+
196
+ def resolve_agent(spec: str) -> ResolvedAgent:
197
+ """Resolve a known agent name or a manifest path to a runnable agent."""
198
+ if spec in AGENT_SOURCES:
199
+ return _resolve_known(spec)
200
+ # Arbitrary third-party agent: spec is a path to its manifest.
201
+ path = Path(spec)
202
+ if not path.is_file():
203
+ raise SystemExit(f"agent manifest not found: {path}")
204
+ manifest, cwd = load_manifest(path)
205
+ return ResolvedAgent(name=path.parent.name or spec, manifest=manifest, manifest_cwd=cwd)
206
+
207
+
208
+ def _resolve_known(name: str) -> ResolvedAgent:
209
+ """Installed agent first, then the in-repo dev fallback."""
210
+ installed = installed_manifest_path(name)
211
+ if installed.is_file():
212
+ manifest, cwd = load_manifest(installed)
213
+ return ResolvedAgent(name=name, manifest=manifest, manifest_cwd=cwd)
214
+ dev = _repo_root() / AGENT_SOURCES[name].dev_manifest
215
+ if dev.is_file():
216
+ manifest, cwd = load_manifest(dev)
217
+ return ResolvedAgent(name=name, manifest=manifest, manifest_cwd=cwd)
218
+ raise SystemExit(
219
+ f"agent '{name}' is not installed and no in-repo copy was found.\n"
220
+ f" install it: avp agent install {name}\n"
221
+ f" or point at a manifest: --agent <path/to/avp-conformance.json>"
222
+ )
223
+
224
+
225
+ def preflight(name: str) -> str | None:
226
+ """Return why `name` can't run here, or None if its prerequisites are present.
227
+
228
+ An installed agent is a self-contained artifact, so its only prerequisites
229
+ are runtime ones (e.g. the `claude` CLI the Claude agent shells out to). The
230
+ in-repo dev fallback builds from source, so it additionally needs the build
231
+ toolchain. The API key is checked separately by the CLI; every agent needs it.
232
+ """
233
+ if is_installed(name):
234
+ return _installed_preflight(name)
235
+ if name == "goose":
236
+ if shutil.which("cargo") is None:
237
+ return (
238
+ "cargo not on PATH (the in-repo Goose agent builds via cargo; "
239
+ "or run `avp agent install goose` for the prebuilt binary)"
240
+ )
241
+ return None
242
+ if name == "claude-code":
243
+ if importlib.util.find_spec("claude_agent_sdk") is None:
244
+ return (
245
+ "claude-agent-sdk not installed (pip install claude-agent-sdk, "
246
+ "or run `avp agent install claude-code`)"
247
+ )
248
+ if shutil.which("claude") is None:
249
+ return "the `claude` CLI is not on PATH"
250
+ return None
251
+ return None # custom manifest: trust the caller
252
+
253
+
254
+ def _installed_preflight(name: str) -> str | None:
255
+ """Runtime prerequisites for an already-installed agent."""
256
+ if name == "claude-code" and shutil.which("claude") is None:
257
+ return "the `claude` CLI is not on PATH (the Claude agent shells out to it)"
258
+ return None