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
14 changes: 12 additions & 2 deletions AGENTS.md

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.

can you please move the package management specific documentation to ./docs/development/package_management.md and add a pointer to it in the AGENTS.md - this is way too much bloat in an agents.md file

Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -264,8 +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 manages capability via `pi install` extensions, which needs
investigation before wiring up.
raise `NotImplementedError`.

## Package Management

See [package management](docs/development/package_management.md) for the API, Pi behaviour,
scope, and validation details.

## Test Philosophy

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
24 changes: 24 additions & 0 deletions docs/development/package_management.md
Original file line number Diff line number Diff line change
@@ -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).
69 changes: 69 additions & 0 deletions docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 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
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
Expand Down
16 changes: 15 additions & 1 deletion src/agent_shell/adapters/agent_adapter_protocol.py
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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."""
...
10 changes: 10 additions & 0 deletions src/agent_shell/adapters/claude_code_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
AgentResponse,
HealthCheckResult,
MCPServerSpec,
PackageSpec,
MCPServerType,
StreamEvent,
)
Expand Down Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions src/agent_shell/adapters/codex_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
AgentResponse,
HealthCheckResult,
MCPServerSpec,
PackageSpec,
MCPServerType,
StreamEvent,
)
Expand Down Expand Up @@ -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]
Expand Down
10 changes: 10 additions & 0 deletions src/agent_shell/adapters/copilot_cli_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
AgentResponse,
HealthCheckResult,
MCPServerSpec,
PackageSpec,
MCPServerType,
StreamEvent,
)
Expand Down Expand Up @@ -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", {})
Expand Down
10 changes: 10 additions & 0 deletions src/agent_shell/adapters/cursor_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
AgentResponse,
HealthCheckResult,
MCPServerSpec,
PackageSpec,
MCPServerType,
StreamEvent,
)
Expand Down Expand Up @@ -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)
Expand Down
10 changes: 10 additions & 0 deletions src/agent_shell/adapters/grok_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
AgentResponse,
HealthCheckResult,
MCPServerSpec,
PackageSpec,
MCPServerType,
StreamEvent,
)
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions src/agent_shell/adapters/opencode_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
AgentResponse,
HealthCheckResult,
MCPServerSpec,
PackageSpec,
MCPServerType,
StreamEvent,
)
Expand Down Expand Up @@ -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", {})
Expand Down
Loading