From 92d50c97e4878eedb4bab812397a4e6e1aa6336b Mon Sep 17 00:00:00 2001 From: Scott Raisbeck Date: Sun, 6 Sep 2026 13:54:20 +0100 Subject: [PATCH 1/3] feat: add Pi package and local extension management --- AGENTS.md | 32 +- README.md | 4 +- docs/examples.md | 69 ++++ .../adapters/agent_adapter_protocol.py | 16 +- .../adapters/claude_code_adapter.py | 10 + src/agent_shell/adapters/codex_adapter.py | 10 + .../adapters/copilot_cli_adapter.py | 10 + src/agent_shell/adapters/cursor_adapter.py | 10 + src/agent_shell/adapters/grok_adapter.py | 10 + src/agent_shell/adapters/opencode_adapter.py | 10 + src/agent_shell/adapters/pi_adapter.py | 112 +++++++ src/agent_shell/models/agent.py | 12 + src/agent_shell/shell.py | 13 + tests/e2e/test_pi_packages_e2e.py | 92 +++++ .../integration/test_packages_unsupported.py | 20 ++ tests/integration/test_pi_packages.py | 315 ++++++++++++++++++ tests/unit/test_package_spec.py | 10 + 17 files changed, 751 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/test_pi_packages_e2e.py create mode 100644 tests/integration/test_packages_unsupported.py create mode 100644 tests/integration/test_pi_packages.py create mode 100644 tests/unit/test_package_spec.py diff --git a/AGENTS.md b/AGENTS.md index 9134f96..7116f8d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,6 +17,9 @@ classDiagram +add_mcp_server(spec) None +remove_mcp_server(name) None +list_mcp_servers() list~MCPServerSpec~ + +add_package(spec, timeout) None + +list_packages() list~PackageSpec~ + +remove_package(source, timeout) None } class AgentAdapter { @@ -29,6 +32,9 @@ classDiagram +add_mcp_server(spec) None +remove_mcp_server(name) None +list_mcp_servers() list~MCPServerSpec~ + +add_package(spec, timeout) None + +list_packages() list~PackageSpec~ + +remove_package(source, timeout) None } class ClaudeCodeAdapter { @@ -264,8 +270,30 @@ entries from `~/.claude.json` directly, avoiding the health checks and human-rea `claude mcp list`. Cursor manages user-scope MCP entries directly in `~/.cursor/mcp.json` because its `mcp` subcommands have no add/remove commands. Grok listing reads user-scope `mcp_servers` entries from `~/.grok/config.toml` directly for the same reason. Pi's MCP add/remove/list methods -raise `NotImplementedError`. Pi manages capability via `pi install` extensions, which needs -investigation before wiring up. +raise `NotImplementedError`. Pi packages and local extensions use the separate package API below. + +## Package Management + +`AgentShell` and `AgentAdapter` expose `add_package(PackageSpec, timeout=120.0)`, `list_packages()`, +and `remove_package(source, timeout=120.0)`. `PackageSpec` is a frozen model with a single `source` +string. Pi implements the lifecycle; other adapters raise `NotImplementedError`. + +Pi uses its native install/remove commands with user scope and reads configured packages directly +from `settings.json`. Configuration follows `PI_CODING_AGENT_DIR`, falling back to `~/.pi/agent`. +No new runtime directory or evaluation isolation is introduced: callers own container mappings and +per-run isolation. Like MCP management, these operations run locally and inherit the environment, +independently of the selected execution host/isolation policy. + +Installs accept exact npm versions, Git sources with an explicit ref, and existing local files or +package directories. Relative input paths resolve against the Python process cwd. Listing returns +configured package sources with local paths normalized to absolute paths. It does not prove that +resources loaded. +Standalone `extensions` entries and project-local packages are outside this initial API. + +Pi can exit zero even when saving settings fails. Package operations reject malformed settings +before modification and verify persistence after the command exits. Timeouts and cancellation clean +up the command's process group. Local-only Pi E2Es verify repeat registration, removal, loading by a +fresh shell, and read-only settings failure without downloads or model requests. ## Test Philosophy diff --git a/README.md b/README.md index 07c5653..88c2bd5 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ and returning the output that can be used programatically as a unified contract (`bash, edit, read, web_search, web_fetch`) translated to each CLI's own tool names. - **Unified MCP management** — register, remove, and list MCP servers across agents through a single API. +- **Package management** — install/register, list, and remove harness packages through a shared + API, with Pi as the first supported agent. - **Async & dependency-free** — pure `asyncio`, zero runtime dependencies, Python 3.12+. ## Installation @@ -106,4 +108,4 @@ follow_up = await shell.execute( > tokens** (they are billed at the output rate). It is reported consistently across all adapters. See [more examples](docs/examples.md) for isolation and execution hosts, failure handling, -streaming, model discovery, health checks, tool restrictions, MCP servers, and logging. +streaming, model discovery, health checks, tool restrictions, MCP servers, packages, and logging. diff --git a/docs/examples.md b/docs/examples.md index 6706f3e..6165a6e 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -432,6 +432,75 @@ entries directly from `~/.claude.json`, Cursor from `~/.cursor/mcp.json`, and Gr `~/.grok/config.toml`, so listing does not launch configured servers for health checks. MCP is not supported for Pi; all three MCP methods raise `NotImplementedError`. +## Packages and local extensions + +`add_package`, `list_packages`, and `remove_package` provide a shared package API. Pi implements +these operations first; other adapters raise `NotImplementedError`. + +```python +from agent_shell.models.agent import AgentType, PackageSpec +from agent_shell.shell import AgentShell + +# arrange(): install once using the container's existing Pi configuration location. +shell = AgentShell(AgentType.PI) +await shell.add_package(PackageSpec(source="npm:@example/pi-tools@1.2.3")) + +# Local extension files and package directories can be registered too. +await shell.add_package(PackageSpec(source="./extensions/my-tools.ts")) + +# act(): a fresh shell inherits the same Pi configuration and loads its packages. +shell = AgentShell(AgentType.PI) +response = await shell.execute(cwd="/workspace", prompt="Use the installed tools.") + +for package in await shell.list_packages(): + print(package.source) + +await shell.remove_package("npm:@example/pi-tools") +``` + +The npm name above is illustrative; substitute a real package. Pi packages may contain extensions, +skills, prompts, and themes. Installing a package enables its resources according to Pi's settings +and package manifest. Local sources are registered in place, not copied; keep them available for +later runs. Removing a local source removes its registration and preserves the original files. + +Scope follows Pi's existing user configuration: `PI_CODING_AGENT_DIR` when set, otherwise +`~/.pi/agent`. These methods inherit the caller's environment and run locally, independently of +the selected execution host and isolation policy. They do not modify project `.pi/settings.json`. +The caller owns container mappings and separation between evaluation runs. A fresh AgentShell +uses the same configuration as long as its process inherits the same mapping/environment. + +Supported installation sources: + +- npm sources with an exact version, such as `npm:tools@1.2.3`; implicit latest and version ranges + are rejected. +- Git sources with an explicit `@ref`, such as `git:github.com/example/tools@v1` or + `ssh://git@github.com/example/tools@COMMIT`. Prefer commit IDs for reproducible runs. +- Absolute paths or paths starting with `./`, `../`, or `~/`, pointing to an existing extension + file or package directory. Relative input paths resolve against the Python process's current + working directory. + +`list_packages()` reads user settings and returns `list[PackageSpec]`, including packages configured +outside AgentShell. Local sources are returned as absolute paths, so they can be passed to removal +from another working directory. Listing reports configured packages, not successful extension +loading; it excludes project packages and standalone entries in Pi's `extensions` setting. +Package resource filters are left to Pi and are not represented by `PackageSpec`. + +Adding the same source again follows Pi's package identity rules: npm package name, Git repository, +or resolved local path. A new version replaces the configured source for that identity. Removal +accepts an npm name or Git source without a version/ref, or a local path. Missing packages and CLI +failures raise `RuntimeError` with Pi's diagnostic. Invalid sources raise `ValueError`. + +Install and remove accept `timeout=120.0` (seconds). A timeout raises `RuntimeError`; task +cancellation propagates `asyncio.CancelledError`. Both clean up the command's process group. +Pi owns installation and persistence; failed or cancelled operations may leave downloaded files. +Per-run resource selection and individual enable/disable controls are outside this initial API. +Pi extensions execute with the process's permissions; this API does not sandbox their code. + +See [Pi packages][pi-packages] for native package behaviour. + +[pi-packages]: + https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/packages.md + ## Logging Agent Shell uses Python's standard `logging` module. Configure the `agent_shell` logger to capture diff --git a/src/agent_shell/adapters/agent_adapter_protocol.py b/src/agent_shell/adapters/agent_adapter_protocol.py index edcd855..7c6eea3 100644 --- a/src/agent_shell/adapters/agent_adapter_protocol.py +++ b/src/agent_shell/adapters/agent_adapter_protocol.py @@ -1,5 +1,7 @@ from typing import Protocol, AsyncIterator -from agent_shell.models.agent import AgentResponse, StreamEvent, MCPServerSpec, HealthCheckResult +from agent_shell.models.agent import ( + AgentResponse, StreamEvent, MCPServerSpec, HealthCheckResult, PackageSpec, +) class AgentAdapter(Protocol): async def execute( @@ -64,3 +66,15 @@ async def remove_mcp_server(self, mcp_server_name: str) -> None: async def list_mcp_servers(self) -> list[MCPServerSpec]: ... + + async def list_packages(self) -> list[PackageSpec]: + """List configured user-scope packages; unsupported adapters raise NotImplementedError.""" + ... + + async def add_package(self, package: PackageSpec, *, timeout: float = 120.0) -> None: + """Install/register a user-scope package; unsupported adapters raise NotImplementedError.""" + ... + + async def remove_package(self, source: str, *, timeout: float = 120.0) -> None: + """Remove a user-scope package; unsupported adapters raise NotImplementedError.""" + ... diff --git a/src/agent_shell/adapters/claude_code_adapter.py b/src/agent_shell/adapters/claude_code_adapter.py index 56ef06e..a294486 100644 --- a/src/agent_shell/adapters/claude_code_adapter.py +++ b/src/agent_shell/adapters/claude_code_adapter.py @@ -24,6 +24,7 @@ AgentResponse, HealthCheckResult, MCPServerSpec, + PackageSpec, MCPServerType, StreamEvent, ) @@ -384,6 +385,15 @@ async def list_models( raise RuntimeError("Claude model discovery returned no initialization response") + async def add_package(self, package: PackageSpec, *, timeout: float = 120.0) -> None: + raise NotImplementedError("add_package is not supported for Claude Code") + + async def list_packages(self) -> list[PackageSpec]: + raise NotImplementedError("list_packages is not supported for Claude Code") + + async def remove_package(self, source: str, *, timeout: float = 120.0) -> None: + raise NotImplementedError("remove_package is not supported for Claude Code") + async def add_mcp_server(self, mcp_server: MCPServerSpec) -> None: # Pre-remove for overwrite semantics; ignore failure (server may not exist). await self._run_mcp_command( diff --git a/src/agent_shell/adapters/codex_adapter.py b/src/agent_shell/adapters/codex_adapter.py index 47bc51e..9a4aa9a 100644 --- a/src/agent_shell/adapters/codex_adapter.py +++ b/src/agent_shell/adapters/codex_adapter.py @@ -22,6 +22,7 @@ AgentResponse, HealthCheckResult, MCPServerSpec, + PackageSpec, MCPServerType, StreamEvent, ) @@ -390,6 +391,15 @@ async def list_models( visible_models.append(slug) return visible_models + async def add_package(self, package: PackageSpec, *, timeout: float = 120.0) -> None: + raise NotImplementedError("add_package is not supported for Codex") + + async def list_packages(self) -> list[PackageSpec]: + raise NotImplementedError("list_packages is not supported for Codex") + + async def remove_package(self, source: str, *, timeout: float = 120.0) -> None: + raise NotImplementedError("remove_package is not supported for Codex") + async def add_mcp_server(self, mcp_server: MCPServerSpec) -> None: if mcp_server.type == MCPServerType.STDIO: cmd = ["codex", "mcp", "add", mcp_server.name] diff --git a/src/agent_shell/adapters/copilot_cli_adapter.py b/src/agent_shell/adapters/copilot_cli_adapter.py index 04b7573..93e42f5 100644 --- a/src/agent_shell/adapters/copilot_cli_adapter.py +++ b/src/agent_shell/adapters/copilot_cli_adapter.py @@ -25,6 +25,7 @@ AgentResponse, HealthCheckResult, MCPServerSpec, + PackageSpec, MCPServerType, StreamEvent, ) @@ -523,6 +524,15 @@ def _write_config(self, config: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(config, indent=2)) + async def add_package(self, package: PackageSpec, *, timeout: float = 120.0) -> None: + raise NotImplementedError("add_package is not supported for Copilot CLI") + + async def list_packages(self) -> list[PackageSpec]: + raise NotImplementedError("list_packages is not supported for Copilot CLI") + + async def remove_package(self, source: str, *, timeout: float = 120.0) -> None: + raise NotImplementedError("remove_package is not supported for Copilot CLI") + async def add_mcp_server(self, mcp_server: MCPServerSpec) -> None: config = self._read_config() config.setdefault("mcpServers", {}) diff --git a/src/agent_shell/adapters/cursor_adapter.py b/src/agent_shell/adapters/cursor_adapter.py index 4b851a2..17172c0 100644 --- a/src/agent_shell/adapters/cursor_adapter.py +++ b/src/agent_shell/adapters/cursor_adapter.py @@ -24,6 +24,7 @@ AgentResponse, HealthCheckResult, MCPServerSpec, + PackageSpec, MCPServerType, StreamEvent, ) @@ -471,6 +472,15 @@ def _string_mapping(value: object, field_name: str) -> dict[str, str]: raise TypeError(f"{field_name} must be an object with string values") return dict(value) + async def add_package(self, package: PackageSpec, *, timeout: float = 120.0) -> None: + raise NotImplementedError("add_package is not supported for Cursor") + + async def list_packages(self) -> list[PackageSpec]: + raise NotImplementedError("list_packages is not supported for Cursor") + + async def remove_package(self, source: str, *, timeout: float = 120.0) -> None: + raise NotImplementedError("remove_package is not supported for Cursor") + async def add_mcp_server(self, mcp_server: MCPServerSpec) -> None: config = self._read_mcp_config() servers = self._mcp_servers(config) diff --git a/src/agent_shell/adapters/grok_adapter.py b/src/agent_shell/adapters/grok_adapter.py index 0eab533..7029929 100644 --- a/src/agent_shell/adapters/grok_adapter.py +++ b/src/agent_shell/adapters/grok_adapter.py @@ -25,6 +25,7 @@ AgentResponse, HealthCheckResult, MCPServerSpec, + PackageSpec, MCPServerType, StreamEvent, ) @@ -427,6 +428,15 @@ def _parse_models_output(self, output: str) -> list[str]: raise RuntimeError("Unexpected `grok models` output") return models + async def add_package(self, package: PackageSpec, *, timeout: float = 120.0) -> None: + raise NotImplementedError("add_package is not supported for Grok") + + async def list_packages(self) -> list[PackageSpec]: + raise NotImplementedError("list_packages is not supported for Grok") + + async def remove_package(self, source: str, *, timeout: float = 120.0) -> None: + raise NotImplementedError("remove_package is not supported for Grok") + async def add_mcp_server(self, mcp_server: MCPServerSpec) -> None: # `grok mcp add` is already add-or-update for the chosen scope. Do NOT pre-remove: # unscoped remove searches user AND project and can delete a project-owned server diff --git a/src/agent_shell/adapters/opencode_adapter.py b/src/agent_shell/adapters/opencode_adapter.py index 423930e..830afdc 100644 --- a/src/agent_shell/adapters/opencode_adapter.py +++ b/src/agent_shell/adapters/opencode_adapter.py @@ -23,6 +23,7 @@ AgentResponse, HealthCheckResult, MCPServerSpec, + PackageSpec, MCPServerType, StreamEvent, ) @@ -476,6 +477,15 @@ def _write_config(self, config: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(config, indent=2)) + async def add_package(self, package: PackageSpec, *, timeout: float = 120.0) -> None: + raise NotImplementedError("add_package is not supported for OpenCode") + + async def list_packages(self) -> list[PackageSpec]: + raise NotImplementedError("list_packages is not supported for OpenCode") + + async def remove_package(self, source: str, *, timeout: float = 120.0) -> None: + raise NotImplementedError("remove_package is not supported for OpenCode") + async def add_mcp_server(self, mcp_server: MCPServerSpec) -> None: config = self._read_config() config.setdefault("mcp", {}) diff --git a/src/agent_shell/adapters/pi_adapter.py b/src/agent_shell/adapters/pi_adapter.py index 3f5db31..82f5eb7 100644 --- a/src/agent_shell/adapters/pi_adapter.py +++ b/src/agent_shell/adapters/pi_adapter.py @@ -2,11 +2,13 @@ import codecs import json import logging +import math import os import re import warnings from pathlib import Path from typing import AsyncIterator +from urllib.parse import urlsplit from agent_shell.adapters.health import run_health_probe from agent_shell.adapters.model_discovery import decode_model_output, run_model_command @@ -24,6 +26,7 @@ AgentResponse, HealthCheckResult, MCPServerSpec, + PackageSpec, StreamEvent, ) from agent_shell.process_cleanup import ( @@ -48,6 +51,51 @@ # aborted turn produced a completed answer, so both must report status "error". _FAILURE_STOP_REASONS = ("error", "aborted") +_PACKAGE_REMOTE_PREFIXES = ("npm:", "git:", "https://", "http://", "ssh://") + + +def _package_source(source: str, *, installing: bool) -> str: + """Validate Pi's explicit source forms and resolve local paths against the caller's cwd.""" + PackageSpec(source) + if source.startswith("npm:"): + match = re.fullmatch(r"npm:(?:@[^/@\s]+/)?[^/@\s]+(?:@([^\s]+))?", source) + if not match: + raise ValueError("Invalid Pi npm package source") + version = match.group(1) or "" + if installing and not re.fullmatch( + r"(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)" + r"(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?", version, + ): + raise ValueError("Pi npm installs require a pinned version, e.g. npm:tools@1.2.3") + return source + if source.startswith(_PACKAGE_REMOTE_PREFIXES): + repository = source[4:] if source.startswith("git:") else source + if "://" in repository: + parsed = urlsplit(repository) + if parsed.scheme not in {"https", "http", "ssh", "git"} or not parsed.hostname: + raise ValueError("Invalid Pi Git package source") + if parsed.query or parsed.fragment: + raise ValueError("Pi Git sources use @ref, not query strings or fragments") + path = parsed.path.lstrip("/") + else: + # Both git@host:owner/repo and host/owner/repo are native Pi sources. + match = re.fullmatch(r"(?:git@[^: /]+:|[^: /]+/)(.+)", repository) + if not match: + raise ValueError("Invalid Pi Git package source") + path = match.group(1) + repo_path, separator, ref = path.partition("@") + if len(repo_path.split("/")) < 2 or any(char.isspace() for char in path): + raise ValueError("Invalid Pi Git package source") + if installing and (not separator or not ref): + raise ValueError("Pi Git installs require an explicit @ref (tag or commit)") + return source + if not (Path(source).is_absolute() or source.startswith(("./", "../", "~/"))): + raise ValueError("Unsupported Pi package source; use npm:, git:, or an explicit local path") + path = Path(source).expanduser().resolve() + if installing and not path.exists(): + raise ValueError(f"Local Pi package source does not exist: {path}") + return str(path) + def _failure_reason(message: dict) -> str: """Best available explanation for a failed assistant message. @@ -462,3 +510,67 @@ async def remove_mcp_server(self, mcp_server_name: str) -> None: async def list_mcp_servers(self) -> list[MCPServerSpec]: raise NotImplementedError("list_mcp_servers is not yet implemented for Pi") + + async def list_packages(self) -> list[PackageSpec]: + override = os.environ.get("PI_CODING_AGENT_DIR") + directory = Path(override).expanduser() if override else Path.home() / ".pi" / "agent" + settings_path = directory / "settings.json" + try: + settings = json.loads(settings_path.read_text(encoding="utf-8")) + except FileNotFoundError: + return [] + except (OSError, ValueError) as error: + raise RuntimeError( + f"Cannot read Pi package settings at {settings_path}: {error}" + ) from error + try: + if not isinstance(settings, dict) or not isinstance(settings.get("packages", []), list): + raise ValueError("expected a settings object with a packages array") + packages = [] + for entry in settings.get("packages", []): + source = entry.get("source") if isinstance(entry, dict) else entry + PackageSpec(source) # Validate before interpreting a source as a local path. + if not source.startswith(_PACKAGE_REMOTE_PREFIXES): + path = Path(source).expanduser() + source = str((directory / path).resolve()) + packages.append(PackageSpec(source=source)) + return packages + except ValueError as error: + raise RuntimeError( + f"Invalid Pi package settings at {settings_path}: {error}" + ) from error + + async def add_package(self, package: PackageSpec, *, timeout: float = 120.0) -> None: + source = _package_source(package.source, installing=True) + await self._run_package_command("install", source, timeout) + + async def remove_package(self, source: str, *, timeout: float = 120.0) -> None: + source = _package_source(source, installing=False) + await self._run_package_command("remove", source, timeout) + + async def _run_package_command(self, operation: str, source: str, timeout: float) -> None: + if not math.isfinite(timeout) or timeout <= 0: + raise ValueError("Package timeout must be a positive finite number") + before = await self.list_packages() # Reject malformed settings before Pi can modify them. + # Configuration management stays local, independent of the selected execution host. + label = f"pi {operation}" + try: + process = await NativeExecutionHost().launch( + ["pi", operation, source, "--no-approve"], cwd=os.getcwd(), + ) + except OSError as error: + raise RuntimeError(f"Could not start `{label}`: {error}") from error + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout) + if process.returncode != 0: + detail = format_stderr(stderr or stdout) or f"exit code {process.returncode}" + raise RuntimeError(f"`{label}` failed: {detail}") + except TimeoutError as error: + raise RuntimeError(f"`{label}` timed out after {timeout:g} seconds") from error + finally: + await process.cancel() + # Pi can exit zero when its queued settings write failed. The next run needs disk state. + after = await self.list_packages() + persisted = PackageSpec(source) in after if operation == "install" else before != after + if not persisted: + raise RuntimeError(f"`{label}` did not persist the package change in Pi settings") diff --git a/src/agent_shell/models/agent.py b/src/agent_shell/models/agent.py index 487291e..ba6b163 100644 --- a/src/agent_shell/models/agent.py +++ b/src/agent_shell/models/agent.py @@ -79,6 +79,18 @@ class HealthCheckResult: healthy: bool exception: str | None = None + +@dataclass(frozen=True) +class PackageSpec: + """A harness-native package source; supported formats depend on the adapter.""" + + source: str + + def __post_init__(self): + if not isinstance(self.source, str) or not self.source.strip() or "\x00" in self.source: + raise ValueError("Package source must be nonempty text without NUL characters") + + @dataclass class MCPServerSpec: name: str diff --git a/src/agent_shell/shell.py b/src/agent_shell/shell.py index 4a12e87..2773b9d 100644 --- a/src/agent_shell/shell.py +++ b/src/agent_shell/shell.py @@ -23,6 +23,7 @@ AgentType, HealthCheckResult, MCPServerSpec, + PackageSpec, StreamEvent, ) @@ -206,3 +207,15 @@ async def remove_mcp_server(self, mcp_server_name: str) -> None: async def list_mcp_servers(self) -> list[MCPServerSpec]: return await self._adapter.list_mcp_servers() + + async def list_packages(self) -> list[PackageSpec]: + """List configured user-scope packages, not individual loaded resources.""" + return await self._adapter.list_packages() + + async def add_package(self, package: PackageSpec, *, timeout: float = 120.0) -> None: + """Install/register a user-scope package for subsequent agent runs.""" + await self._adapter.add_package(package, timeout=timeout) + + async def remove_package(self, source: str, *, timeout: float = 120.0) -> None: + """Remove a user-scope package using its harness-native source or identity.""" + await self._adapter.remove_package(source, timeout=timeout) diff --git a/tests/e2e/test_pi_packages_e2e.py b/tests/e2e/test_pi_packages_e2e.py new file mode 100644 index 0000000..32586d2 --- /dev/null +++ b/tests/e2e/test_pi_packages_e2e.py @@ -0,0 +1,92 @@ +"""Real Pi package lifecycle using local sources only: no downloads or model requests.""" +import asyncio +import json +import shutil + +import pytest + +from agent_shell.models.agent import AgentType, PackageSpec +from agent_shell.shell import AgentShell + + +pytestmark = [pytest.mark.e2e, pytest.mark.skipif(not shutil.which("pi"), reason="Pi CLI required")] + + +@pytest.fixture +def pi_workspace(tmp_path, monkeypatch): + config = tmp_path / "agent" + config.mkdir() + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("PI_CODING_AGENT_DIR", str(config)) + monkeypatch.setenv("PI_OFFLINE", "1") + monkeypatch.setenv("PI_TELEMETRY", "0") + return tmp_path + + +@pytest.mark.parametrize("directory_package", [False, True]) +async def test_local_package_lifecycle_survives_fresh_shells(pi_workspace, directory_package): + # Arrange + source = pi_workspace / "local-package" + if directory_package: + (source / "extensions").mkdir(parents=True) + extension = source / "extensions" / "test.js" + else: + source = source.with_suffix(".js") + extension = source + extension.write_text("export default function (pi) {}") + spec = PackageSpec(str(source)) + shell = AgentShell(AgentType.PI) + + # Act + await shell.add_package(spec) + await shell.add_package(spec) + installed = await AgentShell(AgentType.PI).list_packages() + await AgentShell(AgentType.PI).remove_package(installed[0].source) + remaining = await AgentShell(AgentType.PI).list_packages() + + # Assert + assert installed == [spec] + assert remaining == [] + assert extension.is_file() # Removing a local registration must preserve the caller's files. + + +async def test_fresh_shell_loads_registered_extension_on_stream(pi_workspace): + # Arrange — handle input in the extension so this test never sends a model request. + marker = pi_workspace / "extension-ran.txt" + extension = pi_workspace / "capture.js" + extension.write_text( + 'import { writeFileSync } from "node:fs";\n' + 'export default function (pi) {\n' + ' pi.on("input", (event) => {\n' + f' writeFileSync({json.dumps(str(marker))}, event.text);\n' + ' return { action: "handled" };\n' + ' });\n' + '}\n' + ) + await AgentShell(AgentType.PI).add_package(PackageSpec(str(extension))) + + # Act + async with asyncio.timeout(30): + events = [event async for event in AgentShell(AgentType.PI).stream( + cwd=str(pi_workspace), prompt="package lifecycle smoke test", + )] + + # Assert — the file is the extension's observable output, not adapter internals. + assert marker.read_text() == "package lifecycle smoke test" + assert not [event for event in events if event.type == "error"] + + +async def test_readonly_settings_do_not_report_successful_install(pi_workspace): + # Arrange — Pi can exit zero even when persisting settings fails. + settings = pi_workspace / "agent" / "settings.json" + settings.write_text("{}") + settings.chmod(0o400) + extension = pi_workspace / "readonly-test.js" + extension.write_text("export default function (pi) {}") + + # Act / Assert + try: + with pytest.raises(RuntimeError, match="persist|settings"): + await AgentShell(AgentType.PI).add_package(PackageSpec(str(extension))) + finally: + settings.chmod(0o600) diff --git a/tests/integration/test_packages_unsupported.py b/tests/integration/test_packages_unsupported.py new file mode 100644 index 0000000..dbdda87 --- /dev/null +++ b/tests/integration/test_packages_unsupported.py @@ -0,0 +1,20 @@ +import pytest + +from agent_shell.models.agent import AgentType, PackageSpec +from agent_shell.shell import AgentShell + + +@pytest.mark.parametrize("agent_type", [agent for agent in AgentType if agent != AgentType.PI]) +@pytest.mark.parametrize("operation", ["add_package", "list_packages", "remove_package"]) +async def test_unsupported_harness_reports_package_operation(agent_type, operation): + # Arrange + shell = AgentShell(agent_type) + arguments = { + "add_package": [PackageSpec("npm:tools@1.2.3")], + "list_packages": [], + "remove_package": ["npm:tools"], + } + + # Act / Assert + with pytest.raises(NotImplementedError, match=operation): + await getattr(shell, operation)(*arguments[operation]) diff --git a/tests/integration/test_pi_packages.py b/tests/integration/test_pi_packages.py new file mode 100644 index 0000000..a257de8 --- /dev/null +++ b/tests/integration/test_pi_packages.py @@ -0,0 +1,315 @@ +import asyncio +import json +from unittest.mock import AsyncMock, patch + +import pytest + +from agent_shell.models.agent import AgentType, PackageSpec +from agent_shell.shell import AgentShell + + +@pytest.fixture +def pi_config(tmp_path, monkeypatch): + directory = tmp_path / "pi-agent" + directory.mkdir() + monkeypatch.setenv("PI_CODING_AGENT_DIR", str(directory)) + monkeypatch.chdir(tmp_path) + return directory + + +def _successful_process(config, sources): + """Model Pi's external effect: persist the sources before the command exits zero.""" + process = AsyncMock(returncode=0) + + async def communicate(*args): + (config / "settings.json").write_text(json.dumps({"packages": sources})) + return b"Package command completed\n", b"" + + process.communicate.side_effect = communicate + return process + + +async def test_fresh_shell_lists_configured_packages(pi_config): + # Arrange — Pi supports strings and objects with resource filters in user settings. + (pi_config / "settings.json").write_text(json.dumps({ + "packages": [ + "npm:@example/tools@1.2.3", + {"source": "git:github.com/example/tools@v2", "skills": []}, + "../my-extension.ts", + ], + "extensions": ["/unmanaged/extension.ts"], + })) + + # Act + packages = await AgentShell(AgentType.PI).list_packages() + + # Assert — local sources are usable from any subsequent working directory. + assert packages == [ + PackageSpec(source="npm:@example/tools@1.2.3"), + PackageSpec(source="git:github.com/example/tools@v2"), + PackageSpec(source=str(pi_config.parent / "my-extension.ts")), + ] + + +async def test_missing_settings_means_no_configured_packages(pi_config): + # Arrange + shell = AgentShell(AgentType.PI) + + # Act + packages = await shell.list_packages() + + # Assert + assert packages == [] + + +@pytest.mark.parametrize("source", [ + "npm:@example/tools@1.2.3", + "git:github.com/example/tools@v2", +]) +async def test_installs_pinned_package_through_pi_cli(pi_config, source): + # Arrange + process = _successful_process(pi_config, [source]) + shell = AgentShell(AgentType.PI) + + # Act + with patch("asyncio.create_subprocess_exec", return_value=process) as launch: + await shell.add_package(PackageSpec(source=source)) + + # Assert — argv and inherited environment are the external CLI contract. + assert launch.call_args.args == ("pi", "install", source, "--no-approve") + assert launch.call_args.kwargs["cwd"] == str(pi_config.parent) + assert launch.call_args.kwargs["env"] is None + + +async def test_registers_local_extension_with_literal_absolute_path(pi_config): + # Arrange — spaces and shell syntax are valid filename characters, not executable code. + extension = pi_config.parent / "tools $(touch unwanted).ts" + extension.write_text("export default function (pi) {}") + process = _successful_process(pi_config, [str(extension)]) + + # Act + with patch("asyncio.create_subprocess_exec", return_value=process) as launch: + await AgentShell(AgentType.PI).add_package(PackageSpec(source=f"./{extension.name}")) + + # Assert + assert launch.call_args.args == ("pi", "install", str(extension), "--no-approve") + assert not (pi_config.parent / "unwanted").exists() + + +@pytest.mark.parametrize("source", ["npm:@example/tools", "git:github.com/example/tools"]) +async def test_removes_package_by_unversioned_identity(pi_config, source): + # Arrange + (pi_config / "settings.json").write_text(json.dumps({"packages": [source]})) + process = _successful_process(pi_config, []) + + # Act + with patch("asyncio.create_subprocess_exec", return_value=process) as launch: + await AgentShell(AgentType.PI).remove_package(source) + + # Assert + assert launch.call_args.args == ("pi", "remove", source, "--no-approve") + + +@pytest.mark.parametrize("operation", ["add_package", "remove_package"]) +async def test_package_failure_reports_cli_diagnostic(pi_config, operation): + # Arrange + process = AsyncMock(returncode=1) + process.communicate.return_value = (b"", b"Registry unavailable") + argument = PackageSpec("npm:tools@1.2.3") if operation == "add_package" else "npm:tools" + + # Act / Assert + with patch("asyncio.create_subprocess_exec", return_value=process): + with pytest.raises(RuntimeError, match="Registry unavailable"): + await getattr(AgentShell(AgentType.PI), operation)(argument) + + +async def test_install_timeout_is_clear_and_reaps_process(pi_config): + # Arrange + process = AsyncMock(returncode=None) + + async def hang(*args): + await asyncio.Event().wait() + + process.communicate.side_effect = hang + + # Act / Assert + with patch("asyncio.create_subprocess_exec", return_value=process): + with pytest.raises(RuntimeError, match="pi install.*timed out"): + await AgentShell(AgentType.PI).add_package(PackageSpec("npm:tools@1.2.3"), timeout=.01) + process.wait.assert_awaited() + + +async def test_missing_pi_has_clear_error(pi_config): + # Arrange + shell = AgentShell(AgentType.PI) + + # Act / Assert + with patch("asyncio.create_subprocess_exec", side_effect=FileNotFoundError("pi")): + with pytest.raises(RuntimeError, match="Could not start.*pi install"): + await shell.add_package(PackageSpec("npm:tools@1.2.3")) + + +@pytest.mark.parametrize("source", [ + "npm:tools", "npm:tools@latest", "npm:tools@^1.2.3", "npm:tools@1", + "git:github.com/example/tools", "git:git@github.com:example/tools", + "ssh://git@github.com/example/tools", "https://github.com/example/tools", +]) +async def test_install_requires_explicit_remote_version(pi_config, source): + # Arrange + shell = AgentShell(AgentType.PI) + + # Act / Assert + with patch("asyncio.create_subprocess_exec") as launch: + with pytest.raises(ValueError, match="pin|version|ref"): + await shell.add_package(PackageSpec(source)) + launch.assert_not_called() + + +@pytest.mark.parametrize("source", ["--help", "pip:tools", "ftp://example.com/tools", "git:"]) +async def test_unsupported_sources_are_rejected_before_launch(pi_config, source): + # Arrange + shell = AgentShell(AgentType.PI) + + # Act / Assert + with patch("asyncio.create_subprocess_exec") as launch: + with pytest.raises(ValueError, match="source"): + await shell.add_package(PackageSpec(source)) + launch.assert_not_called() + + +@pytest.mark.parametrize("source", [ + "npm:tools@2.0.0-beta.1", + "git:git@github.com:example/tools@v1", + "ssh://git@github.com/example/tools@abc123", + "https://github.com/example/tools@v1", +]) +async def test_supported_remote_sources_are_passed_unchanged(pi_config, source): + # Arrange + process = _successful_process(pi_config, [source]) + + # Act + with patch("asyncio.create_subprocess_exec", return_value=process) as launch: + await AgentShell(AgentType.PI).add_package(PackageSpec(source)) + + # Assert + assert launch.call_args.args[2] == source + + +async def test_missing_local_extension_is_rejected(pi_config): + # Arrange + shell = AgentShell(AgentType.PI) + + # Act / Assert + with patch("asyncio.create_subprocess_exec") as launch: + with pytest.raises(ValueError, match="does not exist"): + await shell.add_package(PackageSpec("./missing.ts")) + launch.assert_not_called() + + +@pytest.mark.parametrize("timeout", [0, -1, float("nan"), float("inf")]) +async def test_invalid_package_timeout_is_rejected_before_launch(pi_config, timeout): + # Arrange + shell = AgentShell(AgentType.PI) + + # Act / Assert + with patch("asyncio.create_subprocess_exec") as launch: + with pytest.raises(ValueError, match="timeout"): + await shell.add_package(PackageSpec("npm:tools@1.2.3"), timeout=timeout) + launch.assert_not_called() + + +@pytest.mark.parametrize("settings", [ + "not json", "[]", '{"packages": {}}', '{"packages": [null]}', + '{"packages": [{"skills": []}]}', '{"packages": [""]}', +]) +async def test_malformed_package_settings_fail_clearly(pi_config, settings): + # Arrange + (pi_config / "settings.json").write_text(settings) + + # Act / Assert + with pytest.raises(RuntimeError, match="Pi package settings"): + await AgentShell(AgentType.PI).list_packages() + + +async def test_install_cancellation_propagates_and_reaps_process(pi_config): + # Arrange + process = AsyncMock(returncode=None) + started = asyncio.Event() + + async def hang(*args): + started.set() + await asyncio.Event().wait() + + process.communicate.side_effect = hang + + # Act + with patch("asyncio.create_subprocess_exec", return_value=process): + task = asyncio.create_task( + AgentShell(AgentType.PI).add_package(PackageSpec("npm:tools@1.2.3")) + ) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + # Assert + process.wait.assert_awaited() + + +@pytest.mark.parametrize("operation", ["add_package", "remove_package"]) +async def test_broken_settings_are_rejected_before_modifying_packages(pi_config, operation): + # Arrange — Pi may warn about malformed settings and continue; do not risk replacing them. + (pi_config / "settings.json").write_text("{broken") + argument = PackageSpec("npm:tools@1.2.3") if operation == "add_package" else "npm:tools" + + # Act / Assert + with patch("asyncio.create_subprocess_exec") as launch: + with pytest.raises(RuntimeError, match="Pi package settings"): + await getattr(AgentShell(AgentType.PI), operation)(argument) + launch.assert_not_called() + + +async def test_lists_default_pi_directory_when_no_override(tmp_path, monkeypatch): + # Arrange — patch the filesystem home lookup without changing the process's HOME. + from pathlib import Path + + monkeypatch.delenv("PI_CODING_AGENT_DIR", raising=False) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + config = tmp_path / ".pi" / "agent" + config.mkdir(parents=True) + (config / "settings.json").write_text('{"packages": ["npm:tools@1.2.3"]}') + + # Act + packages = await AgentShell(AgentType.PI).list_packages() + + # Assert + assert packages == [PackageSpec("npm:tools@1.2.3")] + + +@pytest.mark.parametrize("operation", ["add_package", "remove_package"]) +async def test_zero_exit_without_persisted_change_is_failure(pi_config, operation): + # Arrange — captured Pi behaviour: a settings write error need not change its exit code. + initial = [] if operation == "add_package" else ["npm:tools@1.2.3"] + (pi_config / "settings.json").write_text(json.dumps({"packages": initial})) + argument = PackageSpec("npm:tools@1.2.3") if operation == "add_package" else "npm:tools" + process = AsyncMock(returncode=0) + process.communicate.return_value = (b"Package command completed\n", b"") + + # Act / Assert + with patch("asyncio.create_subprocess_exec", return_value=process): + with pytest.raises(RuntimeError, match="persist.*settings"): + await getattr(AgentShell(AgentType.PI), operation)(argument) + + +async def test_version_replacement_is_visible_to_a_fresh_shell(pi_config): + # Arrange — Pi replaces a configured npm source by package name. + (pi_config / "settings.json").write_text('{"packages": ["npm:tools@1.2.3"]}') + process = _successful_process(pi_config, ["npm:tools@2.0.0"]) + + # Act + with patch("asyncio.create_subprocess_exec", return_value=process): + await AgentShell(AgentType.PI).add_package(PackageSpec("npm:tools@2.0.0")) + packages = await AgentShell(AgentType.PI).list_packages() + + # Assert + assert packages == [PackageSpec("npm:tools@2.0.0")] diff --git a/tests/unit/test_package_spec.py b/tests/unit/test_package_spec.py new file mode 100644 index 0000000..4248049 --- /dev/null +++ b/tests/unit/test_package_spec.py @@ -0,0 +1,10 @@ +import pytest + +from agent_shell.models.agent import PackageSpec + + +@pytest.mark.parametrize("source", ["", " ", "npm:tools\x00", None, 123]) +def test_package_source_must_be_nonempty_text(source): + # Arrange / Act / Assert + with pytest.raises(ValueError, match="source"): + PackageSpec(source=source) From 2c299f01a7a1e8135a3eee1f75212e71abdb6693 Mon Sep 17 00:00:00 2001 From: Scott Raisbeck Date: Sun, 6 Sep 2026 14:04:31 +0100 Subject: [PATCH 2/3] fix: preserve Pi package identities through symlinks --- docs/examples.md | 2 +- src/agent_shell/adapters/pi_adapter.py | 5 +++-- tests/e2e/test_pi_packages_e2e.py | 22 ++++++++++++++++++++++ tests/integration/test_pi_packages.py | 26 ++++++++++++++++++++++++-- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/docs/examples.md b/docs/examples.md index 6165a6e..8c3dd8c 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -477,7 +477,7 @@ Supported installation sources: `ssh://git@github.com/example/tools@COMMIT`. Prefer commit IDs for reproducible runs. - Absolute paths or paths starting with `./`, `../`, or `~/`, pointing to an existing extension file or package directory. Relative input paths resolve against the Python process's current - working directory. + working directory. Symbolic links retain their link paths, matching Pi's package identities. `list_packages()` reads user settings and returns `list[PackageSpec]`, including packages configured outside AgentShell. Local sources are returned as absolute paths, so they can be passed to removal diff --git a/src/agent_shell/adapters/pi_adapter.py b/src/agent_shell/adapters/pi_adapter.py index 82f5eb7..f52d051 100644 --- a/src/agent_shell/adapters/pi_adapter.py +++ b/src/agent_shell/adapters/pi_adapter.py @@ -91,7 +91,8 @@ def _package_source(source: str, *, installing: bool) -> str: return source if not (Path(source).is_absolute() or source.startswith(("./", "../", "~/"))): raise ValueError("Unsupported Pi package source; use npm:, git:, or an explicit local path") - path = Path(source).expanduser().resolve() + # Match Pi's node:path.resolve: preserve symlinks as distinct package identities. + path = Path(os.path.abspath(Path(source).expanduser())) if installing and not path.exists(): raise ValueError(f"Local Pi package source does not exist: {path}") return str(path) @@ -532,7 +533,7 @@ async def list_packages(self) -> list[PackageSpec]: PackageSpec(source) # Validate before interpreting a source as a local path. if not source.startswith(_PACKAGE_REMOTE_PREFIXES): path = Path(source).expanduser() - source = str((directory / path).resolve()) + source = os.path.abspath(directory / path) packages.append(PackageSpec(source=source)) return packages except ValueError as error: diff --git a/tests/e2e/test_pi_packages_e2e.py b/tests/e2e/test_pi_packages_e2e.py index 32586d2..52f654a 100644 --- a/tests/e2e/test_pi_packages_e2e.py +++ b/tests/e2e/test_pi_packages_e2e.py @@ -50,6 +50,28 @@ async def test_local_package_lifecycle_survives_fresh_shells(pi_workspace, direc assert extension.is_file() # Removing a local registration must preserve the caller's files. +async def test_symlinked_extension_keeps_its_registered_path(pi_workspace): + # Arrange — Pi identifies local packages by the link path, not its current target. + target = pi_workspace / "target.js" + target.write_text("export default function (pi) {}") + extension = pi_workspace / "linked.js" + extension.symlink_to(target) + spec = PackageSpec(str(extension)) + shell = AgentShell(AgentType.PI) + + # Act + await shell.add_package(spec) + installed = await AgentShell(AgentType.PI).list_packages() + await shell.remove_package(spec.source) + remaining = await shell.list_packages() + + # Assert + assert installed == [spec] + assert remaining == [] + assert extension.is_symlink() + assert target.is_file() + + async def test_fresh_shell_loads_registered_extension_on_stream(pi_workspace): # Arrange — handle input in the extension so this test never sends a model request. marker = pi_workspace / "extension-ran.txt" diff --git a/tests/integration/test_pi_packages.py b/tests/integration/test_pi_packages.py index a257de8..3fe9608 100644 --- a/tests/integration/test_pi_packages.py +++ b/tests/integration/test_pi_packages.py @@ -62,6 +62,22 @@ async def test_missing_settings_means_no_configured_packages(pi_config): assert packages == [] +async def test_symlinked_config_preserves_pi_local_package_identity(pi_config, monkeypatch): + # Arrange — Pi resolves relative sources lexically, without following directory symlinks. + (pi_config / "settings.json").write_text('{"packages": ["../tool.js"]}') + mapped = pi_config.parent / "mapped" + mapped.mkdir() + config_link = mapped / "agent" + config_link.symlink_to(pi_config, target_is_directory=True) + monkeypatch.setenv("PI_CODING_AGENT_DIR", str(config_link)) + + # Act + packages = await AgentShell(AgentType.PI).list_packages() + + # Assert — this is the path Pi will use, not the target directory's sibling. + assert packages == [PackageSpec(str(mapped / "tool.js"))] + + @pytest.mark.parametrize("source", [ "npm:@example/tools@1.2.3", "git:github.com/example/tools@v2", @@ -81,10 +97,16 @@ async def test_installs_pinned_package_through_pi_cli(pi_config, source): assert launch.call_args.kwargs["env"] is None -async def test_registers_local_extension_with_literal_absolute_path(pi_config): +@pytest.mark.parametrize("symlink", [False, True]) +async def test_registers_local_extension_with_literal_absolute_path(pi_config, symlink): # Arrange — spaces and shell syntax are valid filename characters, not executable code. extension = pi_config.parent / "tools $(touch unwanted).ts" - extension.write_text("export default function (pi) {}") + if symlink: + target = pi_config.parent / "target.ts" + target.write_text("export default function (pi) {}") + extension.symlink_to(target) + else: + extension.write_text("export default function (pi) {}") process = _successful_process(pi_config, [str(extension)]) # Act From 93762c38d8513251e8f96e40a65387804989f723 Mon Sep 17 00:00:00 2001 From: Scott Raisbeck Date: Sun, 6 Sep 2026 20:46:27 +0100 Subject: [PATCH 3/3] docs: move package management details out of AGENTS.md --- AGENTS.md | 24 +++--------------------- docs/development/package_management.md | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 21 deletions(-) create mode 100644 docs/development/package_management.md diff --git a/AGENTS.md b/AGENTS.md index 7116f8d..192cba6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -270,30 +270,12 @@ entries from `~/.claude.json` directly, avoiding the health checks and human-rea `claude mcp list`. Cursor manages user-scope MCP entries directly in `~/.cursor/mcp.json` because its `mcp` subcommands have no add/remove commands. Grok listing reads user-scope `mcp_servers` entries from `~/.grok/config.toml` directly for the same reason. Pi's MCP add/remove/list methods -raise `NotImplementedError`. Pi packages and local extensions use the separate package API below. +raise `NotImplementedError`. ## Package Management -`AgentShell` and `AgentAdapter` expose `add_package(PackageSpec, timeout=120.0)`, `list_packages()`, -and `remove_package(source, timeout=120.0)`. `PackageSpec` is a frozen model with a single `source` -string. Pi implements the lifecycle; other adapters raise `NotImplementedError`. - -Pi uses its native install/remove commands with user scope and reads configured packages directly -from `settings.json`. Configuration follows `PI_CODING_AGENT_DIR`, falling back to `~/.pi/agent`. -No new runtime directory or evaluation isolation is introduced: callers own container mappings and -per-run isolation. Like MCP management, these operations run locally and inherit the environment, -independently of the selected execution host/isolation policy. - -Installs accept exact npm versions, Git sources with an explicit ref, and existing local files or -package directories. Relative input paths resolve against the Python process cwd. Listing returns -configured package sources with local paths normalized to absolute paths. It does not prove that -resources loaded. -Standalone `extensions` entries and project-local packages are outside this initial API. - -Pi can exit zero even when saving settings fails. Package operations reject malformed settings -before modification and verify persistence after the command exits. Timeouts and cancellation clean -up the command's process group. Local-only Pi E2Es verify repeat registration, removal, loading by a -fresh shell, and read-only settings failure without downloads or model requests. +See [package management](docs/development/package_management.md) for the API, Pi behaviour, +scope, and validation details. ## Test Philosophy diff --git a/docs/development/package_management.md b/docs/development/package_management.md new file mode 100644 index 0000000..4ec3ce8 --- /dev/null +++ b/docs/development/package_management.md @@ -0,0 +1,24 @@ +# Package Management + +`AgentShell` and `AgentAdapter` expose `add_package(PackageSpec, timeout=120.0)`, `list_packages()`, +and `remove_package(source, timeout=120.0)`. `PackageSpec` is a frozen model with a single `source` +string. Pi implements the lifecycle; other adapters raise `NotImplementedError`. + +Pi uses its native install/remove commands with user scope and reads configured packages directly +from `settings.json`. Configuration follows `PI_CODING_AGENT_DIR`, falling back to `~/.pi/agent`. +No new runtime directory or evaluation isolation is introduced: callers own container mappings and +per-run isolation. Like MCP management, these operations run locally and inherit the environment, +independently of the selected execution host/isolation policy. + +Installs accept exact npm versions, Git sources with an explicit ref, and existing local files or +package directories. Relative input paths resolve against the Python process cwd. Listing returns +configured package sources with local paths normalized to absolute paths. It does not prove that +resources loaded. +Standalone `extensions` entries and project-local packages are outside this initial API. + +Pi can exit zero even when saving settings fails. Package operations reject malformed settings +before modification and verify persistence after the command exits. Timeouts and cancellation clean +up the command's process group. Local-only Pi E2Es verify repeat registration, removal, loading by a +fresh shell, and read-only settings failure without downloads or model requests. + +See [usage examples](../examples.md#packages-and-local-extensions).