From 51d482e27d0bbbd31af302df6ec984e8b58466ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?P=C3=A9ter=20Ferenc=20Gyarmati?= Date: Sun, 20 Sep 2026 16:18:07 +0200 Subject: [PATCH] feat: add installed plugin read workflow Add a version-matched CLI briefing, split consumer and packaging skills, and centralize skill identity and inventory indexes for predictable large-plugin performance. --- .github/workflows/ci.yml | 2 + README.md | 30 +- development_docs/architecture.md | 13 +- development_docs/testing-and-release.md | 7 +- docs/guide/inspect-installed.md | 18 +- docs/guide/what-is-an-agent-plugin.md | 10 +- docs/integrations/agent-skills.md | 5 +- docs/reference/cli.md | 53 +++- docs/reference/python-api.md | 12 +- scripts/verify-dist.sh | 68 ++++- scripts/verify-pypi.sh | 28 +- skills/agent-plugins/SKILL.md | 273 ++++-------------- skills/agent-plugins/agents/openai.yaml | 6 +- skills/package-agent-plugin/SKILL.md | 108 +++++++ .../package-agent-plugin/agents/openai.yaml | 4 + .../references/build-variants.md | 77 +++++ .../references/verify-artifacts.md | 36 +++ src/agent_plugins/_cli.py | 23 +- src/agent_plugins/_discovery.py | 6 +- src/agent_plugins/_files.py | 82 ++++-- src/agent_plugins/_plugin.py | 52 +--- src/agent_plugins/_read.py | 156 ++++++++++ src/agent_plugins/_schema/errors.py | 5 +- src/agent_plugins/_skill.py | 32 +- tests/test_cli.py | 243 ++++++++++++++++ tests/test_discovery.py | 2 + tests/test_plan.py | 4 + tests/test_skill.py | 1 + 28 files changed, 1029 insertions(+), 327 deletions(-) create mode 100644 skills/package-agent-plugin/SKILL.md create mode 100644 skills/package-agent-plugin/agents/openai.yaml create mode 100644 skills/package-agent-plugin/references/build-variants.md create mode 100644 skills/package-agent-plugin/references/verify-artifacts.md create mode 100644 src/agent_plugins/_read.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47a9243..5b9eb05 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,6 +72,8 @@ jobs: uv run ruff format --check src tests uv run ruff check src tests uvx --from actionlint-py==1.7.12.24 actionlint .github/workflows/*.yml + uvx --from skills-ref==0.1.1 agentskills validate skills/agent-plugins + uvx --from skills-ref==0.1.1 agentskills validate skills/package-agent-plugin shellcheck scripts/*.sh - name: Type-check diff --git a/README.md b/README.md index c4f5a9a..76b024d 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,19 @@ The [Agent Plugins format](https://agent-plugins.org/) defines the directory: a manifest, [Agent Skills](https://agentskills.io/specification) for instructions and resources, [Model Context Protocol (MCP)](https://modelcontextprotocol.io/specification) server configuration for tools, and client extensions. This library packages that directory and makes it discoverable through Python metadata. Agent clients choose which components to activate. +## Read the bundled guidance + +Run `agent-plugins` without arguments to read its own installed plugin: + +```console +uvx agent-plugins +``` + +The output contains two version-matched skills. `agent-plugins` explains how to +read instructions from Python packages that already ship an Agent Plugin. +`package-agent-plugin` explains how to add Agent Plugin packaging to a Python +project. + ## Package your plugin Keep `plugin.json` and `skills/` beside your code. For a project using the [uv build backend](https://docs.astral.sh/uv/concepts/build-backend/), configure `pyproject.toml`: @@ -56,20 +69,27 @@ Attachment updates the wheel in place. Pass `--output-dir` to preserve the input ## Inspect an installed plugin -Install `agent-plugins` in the environment you want to inspect. The package includes its own Agent Skill: +Load the complete version-matched guidance shipped by a package: ```console -pip install agent-plugins +uvx --with my-package agent-plugins read my-package ``` +The first `my-package` tells uv which distribution to install. The second +identifies the installed Agent Plugin to read. + +Use the Python API when the package is already installed in the current +environment: + ```python import agent_plugins as ap plugin = ap.locate("agent-plugins") -skill = plugin.skill("agent-plugins") +consumer_skill = plugin.skill("agent-plugins") +packaging_skill = plugin.skill("package-agent-plugin") -print(skill.source) -print(skill.file("SKILL.md")) +print(consumer_skill.source) +print(packaging_skill.source) ``` Pass your library's distribution name to `locate()` to inspect its plugin. Use [`Plugin.from_project()`](https://peter-gy.github.io/agent-plugins/guide/inspect-project) to inspect the selected source files before building. diff --git a/development_docs/architecture.md b/development_docs/architecture.md index e8e1822..8f87053 100644 --- a/development_docs/architecture.md +++ b/development_docs/architecture.md @@ -52,7 +52,7 @@ other source → supplied BuildPlan │ │ │ | `build/hatchling.py` | Public Hatchling adapter module | | `_marker.py` | Encode and decode `agent_plugins.json` | | `_discovery.py` | Resolve markers through `importlib.metadata` | -| `_files.py` | Own resolved roots, validated relative names, subtree inventories, and selected-file lookup | +| `_files.py` | Own resolved roots, validated relative names, single-pass immediate subtree inventories, and selected-file lookup | | `_plugin.py` | Construct project-selected handles and compose named manifest, MCP, and skill access around one inventory | | `_skill.py` | Expose exact skill source, checked selected files, native joins, and tree rendering | | `_schema/manifest.py` | Dispatch and cache manifest validation | @@ -62,7 +62,8 @@ other source → supplied BuildPlan │ │ │ | `_schema/skill.py` | Split UTF-8 `SKILL.md` source at exact delimiters | | `_schema/v1/` | Validate Agent Plugins 1.0.0 documents | | `_tree.py` | Render bounded deterministic ASCII trees | -| `_cli.py` | Parse commands and render human or JSON output | +| `_read.py` | Compose installed distribution and plugin metadata into Markdown briefings | +| `_cli.py` | Parse commands, call domain operations, and write human or JSON output | ## Dependency direction @@ -72,11 +73,15 @@ Artifact operations depend on plan validation and archive writers. Archive write Versioned schema loaders produce normalized values. Stdio resolution consumes those values and the selected inventory, independently of JSON parsing. `MCPConfig` composes cached document access with uncached launch preparation. Schema loaders and launch preparation do not depend on build or discovery code. -The CLI parses input, calls public-domain functions, and renders output. Core modules do not depend on terminal state. +The CLI parses input, calls domain functions, and writes their rendered output. +Core modules do not depend on terminal state. ## Public object model -`Plugin` and `Skill` are slotted filesystem handles. Their equality and hash use the resolved root and selected relative filenames. Parsed document content does not participate. +`Plugin` and `Skill` are slotted filesystem handles. Plugin equality and hashing +use the resolved root and selected relative filenames. Skill equality and +hashing also include its structural directory name. Parsed document content +does not participate. `Manifest` and `MCPConfig` are lazy document handles. `Author`, MCP server values, `ResolvedStdioServer`, `ValidationIssue`, `BuildPlan`, `FileMapping`, and `WheelAttachment` are frozen values. diff --git a/development_docs/testing-and-release.md b/development_docs/testing-and-release.md index 1d82621..cb4fc6a 100644 --- a/development_docs/testing-and-release.md +++ b/development_docs/testing-and-release.md @@ -13,6 +13,8 @@ uv run ruff check src tests uv run ty check uv run pyrefly check uv run pytest -q +uvx --from skills-ref==0.1.1 agentskills validate skills/agent-plugins +uvx --from skills-ref==0.1.1 agentskills validate skills/package-agent-plugin ./scripts/build-dist.sh pnpm --dir docs install --frozen-lockfile pnpm --dir docs typecheck @@ -38,7 +40,8 @@ The distribution verifier: 1. Requires one wheel and one `.tar.gz` source distribution. 2. Rebuilds a wheel from the source distribution. 3. Installs the direct and rebuilt wheels in isolated targets. -4. Verifies plugin file bytes, discovery, manifest access, skill access, CLI `locate`, and CLI `list --json`. +4. Verifies plugin file bytes, discovery, manifest access, skill access, CLI + `locate`, CLI `list --json`, CLI `read`, and the no-argument shortcut. 5. Creates an editable installation and checks that discovery resolves the authored root. 6. Uses the installed Python API and CLI to attach a synthetic external wheel, installs it, and verifies discovery. @@ -48,7 +51,7 @@ The distribution verifier: | --- | --- | | Build-plan selection and CLI JSON | `tests/test_plan.py` | | Public wheel attachment, preservation, validation, signatures, and `RECORD` | `tests/test_wheel.py` | -| CLI attachment output, warnings, and exit statuses | `tests/test_cli.py` | +| CLI read and attachment output, warnings, and exit statuses | `tests/test_cli.py` | | Backend parity, sdist rebuilds, editable markers, and sdist modes | `tests/test_build_backends.py` | | Installed distribution discovery and marker failures | `tests/test_discovery.py` | | Project-selected plugin inventory, paths, display, named skills | `tests/test_plugin.py` | diff --git a/docs/guide/inspect-installed.md b/docs/guide/inspect-installed.md index 3d0e64c..0b2875a 100644 --- a/docs/guide/inspect-installed.md +++ b/docs/guide/inspect-installed.md @@ -5,12 +5,24 @@ description: Locate Agent Plugins by Python distribution name and inspect their # Inspect installed Agent Plugins -Use `locate()` to read the Agent Plugin shipped with an installed Python library. Install `agent-plugins` in the same environment as that library: +Read the guidance packaged with `agent-plugins` itself: ```console -pip install agent-plugins +uvx agent-plugins ``` +Read a package's complete primary agent instructions in one command: + +```console +uvx --with my-project agent-plugins read my-project +``` + +uv installs `my-project` and `agent-plugins` in one temporary environment. The +command prints the complete version-matched guidance carried by `my-project`. + +Use `locate()` when the Python library is already installed in the environment +where the agent will work: + ## Locate one distribution ```python @@ -45,7 +57,7 @@ for path in plugin.files: print(path) for skill in plugin.skills: - print(skill.path) + print(skill.name, skill.path) if plugin.mcp is not None: print(plugin.mcp.path) diff --git a/docs/guide/what-is-an-agent-plugin.md b/docs/guide/what-is-an-agent-plugin.md index dcf45c4..f2e01d4 100644 --- a/docs/guide/what-is-an-agent-plugin.md +++ b/docs/guide/what-is-an-agent-plugin.md @@ -9,13 +9,11 @@ An [Agent Plugin](https://agent-plugins.org/) is a directory with a `plugin.json `agent-plugins` packages that directory with a Python library. An agent that can execute Python can read the library's packaged instructions, then compose calls to its API for a task. The same workflow applies to a short script, an interactive session, or a notebook. -With `agent-plugins` [installed](/guide/inspect-installed), read its own packaged skill: +Read the package's consumer and repository-integration guidance in a temporary +environment: -```python -import agent_plugins as ap - -plugin = ap.locate("agent-plugins") -print(plugin.skill("agent-plugins").source) +```console +uvx agent-plugins ``` An **agent client** is the application hosting the model and its execution tools. It decides which instructions to load and which components to activate. The Python API supplies files, text, validated configuration, and subprocess inputs for that integration. diff --git a/docs/integrations/agent-skills.md b/docs/integrations/agent-skills.md index f93161c..b327305 100644 --- a/docs/integrations/agent-skills.md +++ b/docs/integrations/agent-skills.md @@ -47,11 +47,14 @@ plugin = ap.locate("my-project") skill = plugin.skill("review-records") print(skill.path) +print(skill.name) print(skill.source) print(skill.file("references/fields.md")) ``` -`skill.files` contains absolute paths from the selected inventory below that skill root. `skill.tree()` renders the same selection as a bounded ASCII tree. +`skill.name` is the structural directory name used by `plugin.skill(name)`. +`skill.files` contains absolute paths from the selected inventory below that +skill root. `skill.tree()` renders the same selection as a bounded ASCII tree. `plugin.skill(name)` selects an immediate skill by its structural directory name and reports sorted available names when the requested skill is absent. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 95f35e6..cab0eee 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -1,18 +1,65 @@ --- title: CLI reference -description: Reference agent-plugins plan, attach-wheel, locate, and list commands, output formats, and exit statuses. +description: Reference agent-plugins read, plan, attach-wheel, locate, and list commands, output formats, and exit statuses. --- # CLI reference -The `agent-plugins` command previews package file selection, attaches Agent Plugins to wheels, and locates plugins visible in the current Python environment. +The `agent-plugins` command reads installed plugin instructions, previews package +file selection, attaches Agent Plugins to wheels, and locates plugins visible in +the current Python environment. ```text -agent-plugins {plan,attach-wheel,locate,list} ... +agent-plugins +agent-plugins {read,plan,attach-wheel,locate,list} ... ``` `python -m agent_plugins` runs the same entry point. +With no arguments, `agent-plugins` reads the plugin installed with its own +distribution. These commands produce the same stdout: + +```console +uvx agent-plugins +uvx agent-plugins read agent-plugins +``` + +## `agent-plugins read` + +```text +agent-plugins read DISTRIBUTION [--skill NAME] +``` + +`DISTRIBUTION` is an installed Python distribution name. Human output is a +Markdown briefing containing: + +- Python distribution and plugin manifest metadata +- the bounded selected plugin file tree and installed root +- client extension names and manifest validation issues +- MCP server names and transport types, with configured commands, arguments, + environment values, URLs, and headers excluded +- the complete `SKILL.md` source for every packaged Agent Skill by default + +The generated introduction explains relative resource paths. Skill source +remains complete. The inventory uses the standard tree bounds of four levels +and 100 files, and reports omitted depth or file counts. Inspect the printed +installed root when a skill routes to a resource outside that view. + +`--skill NAME` prints one structurally named skill while retaining the package +metadata, bounded inventory, extension names, and MCP summary. An unavailable +name reports the sorted available skills on stderr. + +Use uv to read a package without adding it to the current project: + +```console +uvx --with my-package agent-plugins read my-package +``` + +The package requirement after `--with` and the final distribution argument are +separate inputs. uv installs the requirement into the temporary command +environment. `read` selects that installed distribution through Python +metadata. + ## `agent-plugins plan` ```text diff --git a/docs/reference/python-api.md b/docs/reference/python-api.md index f071b64..37c39ee 100644 --- a/docs/reference/python-api.md +++ b/docs/reference/python-api.md @@ -11,12 +11,13 @@ Import the top-level API as `agent_plugins`: import agent_plugins as ap ``` -The installed `agent-plugins` distribution includes a skill you can inspect: +The installed `agent-plugins` distribution includes separate consumer and +packaging skills: ```python plugin = ap.locate("agent-plugins") -skill = plugin.skill("agent-plugins") -print(skill.source) +print(plugin.skill("agent-plugins").source) +print(plugin.skill("package-agent-plugin").source) ``` The distribution supports Python 3.10 through 3.14 and ships a `py.typed` marker. @@ -230,6 +231,7 @@ Raises `AgentPluginError` when the root cannot be resolved, is not a directory, | Property | Type | Behavior | | --- | --- | --- | +| `name` | `str` | Structural directory name used by `plugin.skill(name)` | | `path` | `Path` | Resolved absolute skill root | | `files` | `tuple[Path, ...]` | Absolute paths in the selected skill inventory | | `frontmatter` | `str` | Raw text between the `---` delimiter lines | @@ -254,7 +256,9 @@ skill / "references" / "api.md" The `/` operator delegates to ordinary unchecked `pathlib.Path` joining. Use `skill.file()` when selection and containment are required. -`Skill.tree()`, native path conversion, display, equality, and hashing follow the `Plugin` contracts. +`Skill.tree()`, native path conversion, and display follow the `Plugin` +contracts. Two `Skill` handles compare equal and have the same hash when their +structural names, resolved roots, and selected relative filenames match. ## `Manifest` diff --git a/scripts/verify-dist.sh b/scripts/verify-dist.sh index 7c580f5..9d207fb 100755 --- a/scripts/verify-dist.sh +++ b/scripts/verify-dist.sh @@ -49,11 +49,18 @@ skill = plugin.skills[0] source_skill = source_plugin.skill("agent-plugins") assert plugin.skill("agent-plugins") is skill assert source_skill.source == skill.source +package_skill = plugin.skill("package-agent-plugin") +source_package_skill = source_plugin.skill("package-agent-plugin") +assert source_package_skill.source == package_skill.source expected_files = { "plugin.json", "skills/agent-plugins/SKILL.md", "skills/agent-plugins/agents/openai.yaml", + "skills/package-agent-plugin/SKILL.md", + "skills/package-agent-plugin/agents/openai.yaml", + "skills/package-agent-plugin/references/build-variants.md", + "skills/package-agent-plugin/references/verify-artifacts.md", } installed_files = { path.relative_to(plugin.path).as_posix() for path in plugin.files @@ -80,7 +87,41 @@ listed = subprocess.run( records = json.loads(listed.stdout) record = next(item for item in records if item["distribution"] == "agent-plugins") assert Path(record["root"]).resolve() == plugin.path -assert record["skills"] == [str(skill / "SKILL.md")] +assert record["skills"] == [ + str(skill / "SKILL.md"), + str(package_skill / "SKILL.md"), +] + +read = subprocess.run( + ["agent-plugins", "read", "agent-plugins"], + check=True, + capture_output=True, + text=True, +) +assert f"Python distribution: `agent-plugins=={expected_version}`" in read.stdout +assert f"Installed root: `{plugin.path}`" in read.stdout +assert skill.source in read.stdout +assert package_skill.source in read.stdout +assert read.stderr == "" + +shortcut = subprocess.run( + ["agent-plugins"], + check=True, + capture_output=True, + text=True, +) +assert shortcut.stdout == read.stdout +assert shortcut.stderr == "" + +selected = subprocess.run( + ["agent-plugins", "read", "agent-plugins", "--skill", "agent-plugins"], + check=True, + capture_output=True, + text=True, +) +assert skill.source in selected.stdout +assert package_skill.source not in selected.stdout +assert selected.stderr == "" PY } @@ -217,6 +258,7 @@ uv run --no-project --isolated --no-cache \ --with "$attached_wheel" \ python - "$attachment_dir" <<'PY' from pathlib import Path +import subprocess import sys import agent_plugins as ap @@ -234,6 +276,7 @@ assert { "skills/critique/SKILL.md", } core = plugin.skill("core") +critique = plugin.skill("critique") assert core.source.startswith("---\n") assert core.file("references/querying.md").read_text( encoding="utf-8" @@ -247,6 +290,29 @@ assert launch.command == str((plugin.path / "bin" / "server").resolve()) assert launch.args == ("mcp", "--data", str(data_dir.resolve())) assert launch.env["PLUGIN_ROOT"] == str(plugin.path) assert launch.env["PLUGIN_DATA"] == str(data_dir.resolve()) + +read = subprocess.run( + ["agent-plugins", "read", "attachment-smoke"], + check=True, + capture_output=True, + text=True, +) +assert "Python distribution: `attachment-smoke==1.0.0`" in read.stdout +assert "# Agent Plugin: `fixture-plugin`" in read.stdout +assert "- `fixture` (`stdio`)" in read.stdout +assert core.source in read.stdout +assert critique.source in read.stdout +assert read.stderr == "" + +selected = subprocess.run( + ["agent-plugins", "read", "attachment-smoke", "--skill", "core"], + check=True, + capture_output=True, + text=True, +) +assert core.source in selected.stdout +assert critique.source not in selected.stdout +assert selected.stderr == "" PY verify_install --with-editable "$root" diff --git a/scripts/verify-pypi.sh b/scripts/verify-pypi.sh index ea1a399..a91ac11 100755 --- a/scripts/verify-pypi.sh +++ b/scripts/verify-pypi.sh @@ -35,13 +35,13 @@ assert dist.version == os.environ["RELEASE_VERSION"] plugin = ap.locate("agent-plugins") assert plugin.manifest.name == "agent-plugins" assert plugin.manifest.issues == () -assert len(plugin.skills) == 1 +assert len(plugin.skills) == 2 -skill = plugin.skills[0] -assert skill.path.name == "agent-plugins" -assert (skill / "SKILL.md").is_file() -assert (skill / "agents/openai.yaml").is_file() -assert skill.frontmatter.startswith("name: agent-plugins\n") +for name in ("agent-plugins", "package-agent-plugin"): + skill = plugin.skill(name) + assert (skill / "SKILL.md").is_file() + assert (skill / "agents/openai.yaml").is_file() + assert skill.frontmatter.startswith(f"name: {name}\n") located = subprocess.run( ["agent-plugins", "locate", "agent-plugins"], @@ -50,6 +50,22 @@ located = subprocess.run( text=True, ) assert Path(located.stdout.strip()).resolve() == plugin.path + +read = subprocess.run( + ["agent-plugins", "read", "agent-plugins"], + check=True, + capture_output=True, + text=True, +) +shortcut = subprocess.run( + ["agent-plugins"], + check=True, + capture_output=True, + text=True, +) +assert shortcut.stdout == read.stdout +assert "## Agent Skill: `agent-plugins`" in read.stdout +assert "## Agent Skill: `package-agent-plugin`" in read.stdout PY } diff --git a/skills/agent-plugins/SKILL.md b/skills/agent-plugins/SKILL.md index 19c0937..0612da2 100644 --- a/skills/agent-plugins/SKILL.md +++ b/skills/agent-plugins/SKILL.md @@ -1,250 +1,103 @@ --- name: agent-plugins -description: Ship and inspect Agent Skills, MCP server configuration, and client extension files with a Python distribution. Use when adding an Agent Plugin to a Python project, attaching a prebuilt wheel, loading the exact project or installed selection, selecting a named skill, reading checked skill resources, resolving a stdio MCP launch, or verifying package artifacts. +description: Read and inspect the version-matched Agent Plugin carried by an installed Python distribution. Use when a package README points to agent-plugins, when loading packaged Agent Skills and resources, listing or locating installed plugins, inspecting manifest or MCP summaries, or troubleshooting discovery. For adding Agent Plugin packaging to a repository, use the package-agent-plugin skill. --- -# Agent Plugins - -Use `agent-plugins` when a Python distribution should carry the instructions and MCP -configuration that match its installed code version. - -An [Agent Plugin](https://agent-plugins.org/) is an open, vendor-neutral -portable directory format for reusable agent components. Its fixed locations -let compatible clients find [Agent Skills](https://agentskills.io/specification) -and [Model Context Protocol (MCP)](https://modelcontextprotocol.io/specification) -server configuration in the same package. Distribution, permissions, and user -experience remain with each client. - -## Build the plugin directory - -Keep one Agent Plugin directory in the codebase: - -```text -my-plugin/ -├── plugin.json -├── skills/ -│ └── use-my-package/ -│ ├── SKILL.md -│ ├── scripts/ -│ └── references/ -├── mcp.json -└── com.example.client/ - └── hooks/ -``` - -- `plugin.json` identifies the plugin and its Agent Plugins schema. -- `skills/` contains Agent Skills and their nested files. -- `mcp.json` describes stdio, Streamable HTTP, or legacy HTTP+SSE servers. -- Reverse-domain directories contain client extension files. +# Read installed Agent Plugins -Create a minimal `plugin.json` at the plugin root: +Use `agent-plugins` to load the instructions and resources shipped with the +same version of a Python package that the agent will use. -```json -{ - "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", - "name": "my-project" -} -``` +## Start from a package README -Find the Python project's `pyproject.toml` and set -`[tool.agent-plugins].root` to the authored plugin root. Prefer a path relative -to `pyproject.toml`. For uv_build: +Run the package and `agent-plugins` in one temporary environment: -```toml -[build-system] -requires = ["agent-plugins", "uv_build"] -build-backend = "agent_plugins.build.uv_build" - -[tool.agent-plugins] -root = "../.." +```console +uvx --with my-package agent-plugins read my-package ``` -For Hatchling: +The requirement after `--with` tells uv what to install. The final argument is +the installed Python distribution to inspect. Use a version constraint when +the task requires an exact release: -```toml -[build-system] -requires = ["agent-plugins", "hatchling"] -build-backend = "agent_plugins.build.hatchling" - -[tool.agent-plugins] -root = "../.." +```console +uvx --with 'my-package==1.2.3' agent-plugins read my-package ``` -The build selects `plugin.json`, the complete `skills/` tree, and `mcp.json` -when present. Select other root-relative files explicitly: +Follow each applicable skill in the output. Resolve its relative links from the +instruction file's directory. Use `--skill NAME` when one plugin carries +several skills and the task needs a single workflow. -```toml -[tool.agent-plugins] -root = "../.." -include = ["bin/**", "com.example.client/**"] -``` +`read` reports MCP metadata for discovery. It does not start servers or print +configured commands, arguments, environment values, URLs, or headers. The +agent client owns component activation, permissions, processes, and data. -Every include pattern must stay within the plugin root and match at least one -filesystem entry. A matched directory contributes its regular files. +## Choose the CLI operation -Use `attach_wheel()` when another build system already produced the wheel. It rewrites the input after the complete attached artifact succeeds. Pass `output_dir` to preserve the source. +| Task | Command | +| --- | --- | +| Read one installed plugin and all primary instructions | `agent-plugins read DISTRIBUTION` | +| Read one named skill with the plugin context | `agent-plugins read DISTRIBUTION --skill NAME` | +| List every discoverable plugin and skill path | `agent-plugins list` | +| List discoverable plugins as JSON | `agent-plugins list --json` | +| Print one installed plugin root | `agent-plugins locate DISTRIBUTION` | +| Preview files selected from a source project | `agent-plugins plan [PROJECT]` | +| Attach a configured plugin to a prebuilt wheel | `agent-plugins attach-wheel WHEEL` | -```python -import agent_plugins as ap +`plan` and `attach-wheel` are repository packaging operations. Load the +`package-agent-plugin` skill before changing a project or wheel. -result = ap.attach_wheel( - "dist/my_package-1.0.0-py3-none-any.whl", - project="packages/python", -) -print(result.output) -``` - -The CLI exposes the same operation: +Running `uvx agent-plugins` with no arguments reads the Agent Plugin carried by +`agent-plugins` itself. It is the shortcut for: ```console -agent-plugins attach-wheel dist/my_package-1.0.0-py3-none-any.whl \ - --project packages/python +uvx agent-plugins read agent-plugins ``` -Reuse a previously computed plan by passing it directly: - -```python -from pathlib import Path - -plan = ap.build_plan("packages/python") -Path("dist/attached").mkdir(parents=True, exist_ok=True) -result = ap.attach_wheel( - "dist/my_package-1.0.0-py3-none-any.whl", - plan=plan, - output_dir="dist/attached", -) -``` +Use `agent-plugins --help` or `agent-plugins COMMAND --help` for command syntax. +Successful commands write data to stdout. Expected discovery, validation, +configuration, and filesystem failures return status `1` with an +`agent-plugins: error:` diagnostic on stderr. Argument errors return status +`2`. -The output directory receives the same filename. Inspect `result.replaced_existing_plugin` and `result.removed_signatures`. The CLI writes removed-signature warnings to stderr. +## Read the current Python environment -`agent_plugins.build.BuildBackend` provides the wheel, source distribution, and -editable hooks used by the bundled adapters. A custom delegate must also expose -the three corresponding `get_requires_for_build_*` hooks. +When the target package is already installed in the active environment, run: -## Choose build-time or runtime access - -Keeping `agent-plugins` in `[build-system].requires` makes it available during -the build. Add the package to `[project].dependencies` when installed Python -code needs to locate or inspect Agent Plugins: - -```toml -[project] -dependencies = ["agent-plugins"] +```console +agent-plugins read my-package ``` -Load the exact project selection before building. After installing a build from that selection, locate it with the Python distribution name used by pip: +Use the Python API when the task needs one skill or a linked resource: ```python import agent_plugins as ap -source = ap.Plugin.from_project("packages/python") plugin = ap.locate("my-package") -skill = source.skill("use-my-package") +skill = plugin.skill("use-my-package") -print(source.path) -print(plugin.path) -print(plugin.manifest.path) -print(plugin.manifest.name) print(skill.source) -print(skill.file("SKILL.md")) - -if mcp := plugin.mcp: - for name, server in mcp.servers.items(): - print(name, server) +reference = skill.file("references/api.md") +print(reference.read_text(encoding="utf-8")) ``` -`Plugin.from_project()` uses the build plan, so its files match the source -paths selected for packaging. `plugin.path` is the absolute installed plugin -root. Each item in -`plugin.skills` is an `ap.Skill` rooted at one immediate directory under -`skills/`. Use `plugin.skill(name)` for exact structural lookup. Use -`Path(skill)` or `skill.path` for that directory. Use `skill.file()` for a -selected instruction, reference, script, or asset: +`plugin.skills` contains every immediate `skills//SKILL.md` selected by +the installed package. `skill.file()` accepts an exact selected path below that +skill and rechecks containment. `plugin.tree()` and `skill.tree()` provide a +bounded inventory before reading more files. -```python -print(skill.file("SKILL.md")) -print(skill.file("references/api.md")) -print(skill.tree(max_depth=2)) -``` - -`skill.source` returns the complete `SKILL.md` text. Use `skill.frontmatter` -for the raw source text between the `---` delimiters and `skill.body` for the -Markdown after the frontmatter. The package checks UTF-8 text and delimiter -structure. It does not parse the frontmatter as YAML. The first access to any -source property reads and splits `SKILL.md`, then caches all three strings. Path -and tree access leave the document unread so an agent can choose which files -and content to load. - -`plugin.manifest` is an `ap.Manifest`. `plugin.mcp` is an `ap.MCPConfig` when -`mcp.json` exists. Each object exposes `.path` immediately. Accessing a parsed -field such as `manifest.name` or `mcp.servers` reads, validates, and caches its -document. MCP access validates the manifest first. - -MCP servers are frozen `ap.StdioServer`, `ap.StreamableHTTPServer`, or -`ap.SSEServer` values in a read-only mapping. `manifest.issues` records -non-fatal manifest violations. `mcp.issues` records invalid server entries -skipped during loading. Document-level failures raise `ap.ValidationError` on -parsed value access. - -Resolve a validated stdio server after the client creates its plugin data directory: - -```python -from pathlib import Path -import os - -data_dir = Path(".agent-data/my-package").resolve() -data_dir.mkdir(parents=True, exist_ok=True) - -mcp = plugin.mcp -if mcp is not None: - launch = mcp.resolve_stdio( - "my-package", - data_dir=data_dir, - base_env={"PATH": os.environ.get("PATH", "")}, - ) - print(launch.command, launch.args, launch.cwd) -``` +Use `plugin.manifest` for validated plugin metadata. `plugin.mcp` is an +`MCPConfig` when the package selected `mcp.json`. Accessing parsed manifest or +MCP fields validates and caches the document. Handle `AgentPluginError` for +missing or unusable installed plugins and `ValidationError` for invalid plugin, +MCP, or skill documents. -The client owns data retention, process creation, permissions, logging, and the MCP lifecycle. `resolve_stdio()` returns immutable subprocess inputs and performs one-pass Agent Plugins placeholder expansion. +## Keep the environment explicit -Display the plugin to inspect its selected directory tree: - -```python -print(plugin) -print(plugin.tree(max_depth=2)) -print(plugin.tree(max_depth=None, max_files=None)) -``` - -`Path(plugin)`, `Path(skill)`, and `Path(plugin.manifest)` use the native path -protocol. When `plugin.mcp` is present, `Path(plugin.mcp)` does too. -`ap.installed()` returns each discovered plugin keyed by Python distribution -name. Discovery is fail-fast when a marked distribution has unusable metadata -or selected files. - -## Verify the package - -Inspect the selected paths before building: - -```console -agent-plugins plan path/to/python-project -``` +`uvx` creates a temporary environment for the command. Install the package in +the notebook, service, or project environment where its Python API will run. +The instructions printed by `read` describe the exact distribution version +resolved for that command. -Then verify the package through its installation boundaries: - -1. Build a wheel and source distribution through an adapter, or attach the Agent Plugin after an external wheel build. -2. Build a wheel from the source distribution. -3. Install the wheel in a clean environment. -4. Install the Python project as editable. -5. Compare `Plugin.from_project()` with `ap.locate()` by plugin-relative file inventory and public component values. -6. Access `plugin.manifest.name` and `plugin.mcp.servers` when MCP exists to run - the supported Agent Plugins JSON validation. -7. Confirm each `skill.path`, `skill.file("SKILL.md")`, and `skill.files` points to - the packaged skill tree. -8. Access `skill.source`, `skill.frontmatter`, and `skill.body` to verify UTF-8 text and the - packaged `SKILL.md` delimiter structure. -9. Run an Agent Skills validator to check frontmatter fields and other Agent - Skills rules. - -Handle `ap.AgentPluginError` when a requested Python distribution or usable plugin -root is absent. Handle `ap.ValidationError` when an installed plugin document -or `SKILL.md` structure is invalid. +Python distribution names and manifest plugin names are independent. Pass the +name used by pip or uv to `read`, `locate`, and `ap.locate()`. diff --git a/skills/agent-plugins/agents/openai.yaml b/skills/agent-plugins/agents/openai.yaml index eeb30ee..721612e 100644 --- a/skills/agent-plugins/agents/openai.yaml +++ b/skills/agent-plugins/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: "Agent Plugins" - short_description: "Ship agent components with Python packages" - default_prompt: "Use $agent-plugins to ship agent skills and MCP configuration with this Python package." + display_name: "Read Agent Plugins" + short_description: "Load packaged instructions from Python distributions" + default_prompt: "Use $agent-plugins to read the version-matched instructions packaged with this Python distribution." diff --git a/skills/package-agent-plugin/SKILL.md b/skills/package-agent-plugin/SKILL.md new file mode 100644 index 0000000..b6d89d9 --- /dev/null +++ b/skills/package-agent-plugin/SKILL.md @@ -0,0 +1,108 @@ +--- +name: package-agent-plugin +description: Add Agent Plugin packaging to a Python project. Use when creating plugin.json and skills, configuring uv_build or Hatchling, attaching a plugin to a prebuilt wheel, exposing runtime plugin access, or verifying wheel, source distribution, and editable artifacts. For consuming instructions from an installed package, use the agent-plugins skill. +--- + +# Package an Agent Plugin + +Package instructions beside the Python code they describe so the library and +its Agent Plugin share one release. + +## Build the smallest complete integration + +For a single-package project, keep the plugin at the project root: + +```text +my-package/ +|-- plugin.json +|-- pyproject.toml +|-- skills/ +| `-- use-my-package/ +| `-- SKILL.md +`-- src/ + `-- my_package/ +``` + +Create `plugin.json`: + +```json +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "my-package", + "description": "Use My Package from Python." +} +``` + +Create `skills/use-my-package/SKILL.md` with a discriminating description and +the shortest complete workflow an agent needs: + +```md +--- +name: use-my-package +description: Use My Package to read and transform project records from Python. +--- + +# Use My Package + +Import `my_package`, open the project input, and call `transform()`. +``` + +Configure an existing uv_build project in `pyproject.toml`: + +```toml +[build-system] +requires = ["agent-plugins", "uv_build"] +build-backend = "agent_plugins.build.uv_build" + +[tool.agent-plugins] +root = "." +``` + +Preview the exact selection, then build: + +```console +uv run --with agent-plugins agent-plugins plan . +uv build +``` + +The plan must contain `plugin.json`, every intended file under `skills/`, and +`mcp.json` when configured. Add root-relative client extension files or other +plugin resources through `[tool.agent-plugins].include`. + +## Verify the installed handoff + +Read the wheel in an isolated environment, using the Python distribution name +from `[project].name`: + +```console +uvx --with dist/my_package-0.1.0-py3-none-any.whl \ + agent-plugins read my-package +``` + +Confirm the reported distribution version, plugin metadata, bounded inventory, +and skill instructions. Use +[artifact verification](references/verify-artifacts.md) for exhaustive inventory +comparison. Put the public bootstrap in the package README: + +```console +uvx --with my-package agent-plugins read my-package +``` + +Keep `agent-plugins` in `[build-system].requires` for packaging. Add it to +`[project].dependencies` when installed Python code calls `agent_plugins` +directly at runtime. + +## Choose a different build path + +- For Hatchling, monorepo roots, include patterns, custom backends, or an + externally built wheel, read + [build variants](references/build-variants.md). +- For wheel, source distribution, editable, document, and Agent Skills checks, + read [artifact verification](references/verify-artifacts.md). +- For `mcp.json`, read the + [MCP integration guide](https://peter-gy.github.io/agent-plugins/integrations/mcp-servers). +- For reverse-domain client directories, read the + [client extension guide](https://peter-gy.github.io/agent-plugins/integrations/client-extensions). + +Use the `agent-plugins` skill when the repository work is complete and the task +becomes consuming an installed package's instructions or resources. diff --git a/skills/package-agent-plugin/agents/openai.yaml b/skills/package-agent-plugin/agents/openai.yaml new file mode 100644 index 0000000..999bfeb --- /dev/null +++ b/skills/package-agent-plugin/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Package an Agent Plugin" + short_description: "Add Agent Plugin packaging to a Python project" + default_prompt: "Use $package-agent-plugin to add version-matched agent instructions to this Python package." diff --git a/skills/package-agent-plugin/references/build-variants.md b/skills/package-agent-plugin/references/build-variants.md new file mode 100644 index 0000000..94e77a9 --- /dev/null +++ b/skills/package-agent-plugin/references/build-variants.md @@ -0,0 +1,77 @@ +# Build variants + +## Hatchling + +Wrap an existing Hatchling project with the bundled adapter: + +```toml +[build-system] +requires = ["agent-plugins", "hatchling"] +build-backend = "agent_plugins.build.hatchling" + +[tool.agent-plugins] +root = "." +``` + +The adapter preserves the delegate's wheel, source distribution, and editable +behavior while adding the selected Agent Plugin and installation marker. + +## Monorepo roots + +Resolve `root` relative to the `pyproject.toml` that owns the build. For this +layout, the Python package points back to the repository plugin root: + +```text +repository/ +|-- plugin.json +|-- skills/ +`-- packages/ + `-- python/ + `-- pyproject.toml +``` + +```toml +[tool.agent-plugins] +root = "../.." +``` + +## Additional selected files + +The build always selects `plugin.json`, the complete `skills/` tree, and +`mcp.json` when present. Select other root-relative files explicitly: + +```toml +[tool.agent-plugins] +root = "." +include = ["bin/**", "com.example.client/**"] +``` + +Every include pattern must remain inside the plugin root and match at least one +filesystem entry. A matched directory contributes its regular files. + +## Prebuilt wheels + +When another backend owns the wheel, attach the configured Agent Plugin after +that build: + +```console +agent-plugins attach-wheel dist/my_package-0.1.0-py3-none-any.whl \ + --project . +``` + +The command atomically replaces the input wheel after the complete attached +artifact succeeds. Use `--output-dir` to preserve the source wheel. The Python +API exposes the same operation: + +```python +import agent_plugins as ap + +result = ap.attach_wheel( + "dist/my_package-0.1.0-py3-none-any.whl", + project=".", +) +print(result.output) +``` + +A custom delegate must expose wheel, source distribution, and editable build +hooks together with their corresponding `get_requires_for_build_*` hooks. diff --git a/skills/package-agent-plugin/references/verify-artifacts.md b/skills/package-agent-plugin/references/verify-artifacts.md new file mode 100644 index 0000000..e339783 --- /dev/null +++ b/skills/package-agent-plugin/references/verify-artifacts.md @@ -0,0 +1,36 @@ +# Verify package artifacts + +Treat source and installed inspection as two views of the same selected plugin: + +```python +import agent_plugins as ap + +source = ap.Plugin.from_project(".") +installed = ap.locate("my-package") + +assert source.manifest.name == installed.manifest.name +assert [path.relative_to(source.path) for path in source.files] == [ + path.relative_to(installed.path) for path in installed.files +] +``` + +Verify every release boundary: + +1. Run `agent-plugins plan PROJECT` and inspect every selected path. +2. Build a wheel and source distribution through the configured adapter, or + attach the plugin to the externally built wheel. +3. Rebuild a wheel from the source distribution. +4. Install the direct and rebuilt wheels in clean environments. +5. Run `agent-plugins read my-package` against both installed wheels. +6. Install the project as editable and confirm `locate()` resolves the authored + plugin root. +7. Compare source and installed file inventories and bytes. +8. Access `manifest.name`, every `skill.source`, and `mcp.servers` when present + to execute supported document validation. +9. Confirm each `skill.file("SKILL.md")` and referenced resource is selected. +10. Run an Agent Skills validator against every authored skill directory. + +Let `AgentPluginError` fail missing configuration, unusable paths, or discovery. +Let `ValidationError` fail invalid manifest, MCP, or skill documents. Artifact +verification should exercise the CLI through the installed console script as +well as the Python API. diff --git a/src/agent_plugins/_cli.py b/src/agent_plugins/_cli.py index 0744a14..4f307a9 100644 --- a/src/agent_plugins/_cli.py +++ b/src/agent_plugins/_cli.py @@ -12,19 +12,25 @@ from ._build.wheel import WheelAttachment, attach_wheel from ._discovery import installed, locate from ._errors import AgentPluginError +from ._read import render_read def main(argv: Sequence[str] | None = None) -> int: """Run the Agent Plugins command-line interface.""" - arguments = _parser().parse_args(argv) + values = tuple(sys.argv[1:] if argv is None else argv) + arguments = _parser().parse_args(values or ("read", "agent-plugins")) try: if arguments.command == "list": _list_plugins(as_json=arguments.json) elif arguments.command == "locate": print(locate(arguments.distribution).path) + elif arguments.command == "read": + sys.stdout.write( + render_read(arguments.distribution, skill_name=arguments.skill) + ) elif arguments.command == "plan": _print_plan(build_plan(arguments.project), as_json=arguments.json) - else: + elif arguments.command == "attach-wheel": result = attach_wheel( arguments.wheel, project=arguments.project, @@ -37,6 +43,8 @@ def main(argv: Sequence[str] | None = None) -> int: f"{signature.as_posix()}", file=sys.stderr, ) + else: # pragma: no cover - argparse owns the command choices + raise AssertionError(f"Unexpected command: {arguments.command!r}") except AgentPluginError as error: print(f"agent-plugins: error: {error}", file=sys.stderr) return 1 @@ -46,7 +54,7 @@ def main(argv: Sequence[str] | None = None) -> int: def _parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="agent-plugins", - description="Package and inspect Agent Plugins in Python distributions.", + description="Read, package, and inspect Agent Plugins in Python distributions.", ) commands = parser.add_subparsers(dest="command", required=True) @@ -64,6 +72,15 @@ def _parser() -> argparse.ArgumentParser: "distribution", help="Installed Python distribution name." ) + read_parser = commands.add_parser( + "read", + help="Print one installed plugin and its Agent Skill instructions.", + ) + read_parser.add_argument("distribution", help="Installed Python distribution name.") + read_parser.add_argument( + "--skill", help="Print one named skill instead of every packaged skill." + ) + plan_parser = commands.add_parser( "plan", help="List files selected by [tool.agent-plugins]." ) diff --git a/src/agent_plugins/_discovery.py b/src/agent_plugins/_discovery.py index 72a7c01..9a11f75 100644 --- a/src/agent_plugins/_discovery.py +++ b/src/agent_plugins/_discovery.py @@ -18,6 +18,10 @@ def locate(distribution_name: str) -> Plugin: Raises: AgentPluginError: The distribution has no usable Agent Plugin marker. """ + return _locate(distribution_name)[1] + + +def _locate(distribution_name: str) -> tuple[metadata.Distribution, Plugin]: if not distribution_name: raise AgentPluginError("A distribution name is required") @@ -28,7 +32,7 @@ def locate(distribution_name: str) -> Plugin: f"Python distribution {distribution_name!r} is not installed" ) from None - return _root(distribution, distribution_name) + return distribution, _root(distribution, distribution_name) def installed() -> dict[str, Plugin]: diff --git a/src/agent_plugins/_files.py b/src/agent_plugins/_files.py index 92473a3..822b73e 100644 --- a/src/agent_plugins/_files.py +++ b/src/agent_plugins/_files.py @@ -4,7 +4,7 @@ import os from collections.abc import Iterable -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path, PurePosixPath, PureWindowsPath from ._errors import AgentPluginError @@ -16,6 +16,10 @@ class FileInventory: root: Path names: tuple[PurePosixPath, ...] + _name_set: frozenset[PurePosixPath] = field(init=False, repr=False, compare=False) + + def __post_init__(self) -> None: + object.__setattr__(self, "_name_set", frozenset(self.names)) @classmethod def discover( @@ -53,33 +57,39 @@ def select( names=_validate_names(root, names, required=required, kind=kind), ) - def subtree(self, path: str | PurePosixPath) -> FileInventory: - """Return the selected files below one relative directory.""" - relative = PurePosixPath(path) - if ( - relative.is_absolute() - or relative == PurePosixPath(".") - or ".." in relative.parts - ): - raise ValueError(f"Invalid inventory subtree: {path}") - candidate = self.root.joinpath(*relative.parts) - try: - root = candidate.resolve(strict=True) - root.relative_to(self.root) - except (OSError, RuntimeError, ValueError) as error: - raise AgentPluginError( - f"Inventory subtree cannot be resolved: {candidate}" - ) from error - if not root.is_dir(): - raise AgentPluginError(f"Inventory subtree is not a directory: {candidate}") - return type(self)( - root=root, - names=tuple( - name.relative_to(relative) - for name in self.names - if name.is_relative_to(relative) - ), - ) + def immediate_subtrees( + self, + path: str | PurePosixPath, + *, + required: str, + ) -> tuple[tuple[str, FileInventory], ...]: + """Return immediate selected subtrees containing `required`.""" + parent = PurePosixPath(path) + if parent.is_absolute() or parent == PurePosixPath(".") or ".." in parent.parts: + raise ValueError(f"Invalid inventory subtree parent: {path}") + + grouped: dict[str, list[PurePosixPath]] = {} + parent_parts = parent.parts + parent_size = len(parent.parts) + for name in self.names: + parts = name.parts + if len(parts) <= parent_size or parts[:parent_size] != parent_parts: + continue + child = parts[parent_size] + grouped.setdefault(child, []).append( + PurePosixPath(*parts[parent_size + 1 :]) + ) + + required_path = PurePosixPath(required) + entries: list[tuple[str, FileInventory]] = [] + for child in sorted(grouped, key=lambda value: (value.casefold(), value)): + names = tuple(grouped[child]) + if required_path not in names: + continue + child_root = parent / child + root = _resolve_subtree(self.root, child_root) + entries.append((child, type(self)(root=root, names=names))) + return tuple(entries) def paths(self) -> tuple[Path, ...]: """Return absolute paths for the selected files.""" @@ -99,7 +109,7 @@ def file( f"Invalid {kind} file path under {self.root}: {os.fspath(path)!r}" ) from error value = relative.as_posix() - if relative not in self.names: + if relative not in self._name_set: raise AgentPluginError( f"{kind} file is not selected under {self.root}: {value}" ) @@ -139,6 +149,20 @@ def _resolve_root( return root +def _resolve_subtree(root: Path, relative: PurePosixPath) -> Path: + candidate = root.joinpath(*relative.parts) + try: + resolved = candidate.resolve(strict=True) + resolved.relative_to(root) + except (OSError, RuntimeError, ValueError) as error: + raise AgentPluginError( + f"Inventory subtree cannot be resolved: {candidate}" + ) from error + if not resolved.is_dir(): + raise AgentPluginError(f"Inventory subtree is not a directory: {candidate}") + return resolved + + def _validate_names( root: Path, names: Iterable[str | PurePosixPath], diff --git a/src/agent_plugins/_plugin.py b/src/agent_plugins/_plugin.py index 625bfb8..2f35581 100644 --- a/src/agent_plugins/_plugin.py +++ b/src/agent_plugins/_plugin.py @@ -21,13 +21,13 @@ class Plugin: """Expose an Agent Plugin through native paths and a tree display.""" - __slots__ = ("_inventory", "_manifest", "_mcp", "_skill_names", "_skills") + __slots__ = ("_inventory", "_manifest", "_mcp", "_skills", "_skills_by_name") _inventory: FileInventory _manifest: Manifest _mcp: MCPConfig | None - _skill_names: tuple[str, ...] _skills: tuple[Skill, ...] + _skills_by_name: dict[str, Skill] def __init__(self, path: str | os.PathLike[str]) -> None: """Create a plugin from a directory containing `plugin.json`.""" @@ -90,23 +90,13 @@ def skill(self, name: str) -> Skill: ): raise AgentPluginError(f"Invalid Agent Skill directory name: {name!r}") - matches = tuple( - skill - for skill_name, skill in zip(self._skill_names, self._skills, strict=True) - if skill_name == name - ) - available = tuple( - sorted( - self._skill_names, - key=lambda value: (value.casefold(), value), - ) - ) - if not matches: - choices = ", ".join(available) if available else "none" + skill = self._skills_by_name.get(name) + if skill is None: + choices = ", ".join(self._skills_by_name) or "none" raise AgentPluginError( f"Agent Skill {name!r} is unavailable. Available skills: {choices}" ) - return matches[0] + return skill @property def mcp(self) -> MCPConfig | None: @@ -174,26 +164,16 @@ def _set_state( ) plugin._manifest = manifest plugin._mcp = mcp - entries = _skills(inventory) - plugin._skill_names = tuple(name for name, _skill in entries) - plugin._skills = tuple(skill for _name, skill in entries) - - -def _skills(inventory: FileInventory) -> tuple[tuple[str, Skill], ...]: - skill_roots = ( - relative.parent - for relative in inventory.names - if len(relative.parts) == 3 - and relative.parts[0] == "skills" - and relative.name == "SKILL.md" - ) + plugin._skills = _skills(inventory) + plugin._skills_by_name = {skill.name: skill for skill in plugin._skills} + + +def _skills(inventory: FileInventory) -> tuple[Skill, ...]: return tuple( - ( - skill_root.name, - Skill._from_inventory( - inventory.subtree(skill_root), - document=SkillDocument(inventory, (skill_root / "SKILL.md").as_posix()), - ), + Skill._from_inventory( + subtree, + name=name, + document=SkillDocument(inventory, f"skills/{name}/SKILL.md"), ) - for skill_root in skill_roots + for name, subtree in inventory.immediate_subtrees("skills", required="SKILL.md") ) diff --git a/src/agent_plugins/_read.py b/src/agent_plugins/_read.py new file mode 100644 index 0000000..2ed620a --- /dev/null +++ b/src/agent_plugins/_read.py @@ -0,0 +1,156 @@ +"""Render an installed Agent Plugin as a Markdown briefing.""" + +from __future__ import annotations + +import re +from importlib.metadata import Distribution + +from ._discovery import _locate +from ._plugin import Plugin +from ._schema.errors import ValidationIssue, format_location +from ._schema.models import Author +from ._skill import Skill + + +def render_read(distribution_name: str, *, skill_name: str | None = None) -> str: + """Return a getting-started briefing for one installed Agent Plugin.""" + distribution, plugin = _locate(distribution_name) + skills = plugin.skills if skill_name is None else (plugin.skill(skill_name),) + return _markdown(distribution, plugin, skills) + + +def _markdown( + distribution: Distribution, + plugin: Plugin, + skills: tuple[Skill, ...], +) -> str: + manifest = plugin.manifest + distribution_name = distribution.metadata["Name"] or manifest.name + lines = [ + f"# Agent Plugin: {_code(manifest.name)}", + "", + f"Python distribution: {_code(f'{distribution_name}=={distribution.version}')}", + f"Installed root: {_code(str(plugin.path))}", + ] + summary = distribution.metadata["Summary"] + if summary: + lines.append(f"Distribution summary: {_one_line(summary)}") + if manifest.version: + lines.append(f"Plugin version: {_code(manifest.version)}") + if manifest.description: + lines.append(f"Plugin description: {_one_line(manifest.description)}") + if manifest.author: + author = _author(manifest.author) + if author: + lines.append(f"Author: {_one_line(author)}") + if manifest.homepage: + lines.append(f"Homepage: {_one_line(manifest.homepage)}") + if manifest.repository: + lines.append(f"Repository: {_one_line(manifest.repository)}") + if manifest.license: + lines.append(f"License: {_one_line(manifest.license)}") + if manifest.keywords: + lines.append( + "Keywords: " + ", ".join(_code(value) for value in manifest.keywords) + ) + + lines.extend( + ( + "", + ( + "Complete installed Agent Skill instructions follow. Resolve relative " + "resource paths from each instruction file's directory." + ), + "", + "## Plugin inventory", + "", + _fenced_block(plugin.tree(), language="text"), + "", + ( + "The inventory is bounded. Inspect the installed root shown above when " + "a skill routes to a deeper resource." + ), + ) + ) + + if manifest.extensions: + lines.extend(("", "## Client extensions", "")) + lines.extend(f"- {_code(name)}" for name in manifest.extensions) + + if manifest.issues: + lines.extend(("", "## Manifest issues", "")) + lines.extend(_issue_line(issue) for issue in manifest.issues) + + mcp = plugin.mcp + if mcp is not None: + lines.extend( + ( + "", + "## MCP servers", + "", + f"Configuration: {_code(str(mcp.path))}", + ) + ) + if mcp.servers: + lines.append("") + lines.extend( + f"- {_code(name)} ({_code(server.type)})" + for name, server in mcp.servers.items() + ) + if mcp.issues: + lines.extend(("", "Configuration issues:", "")) + lines.extend(_issue_line(issue) for issue in mcp.issues) + + if skills: + for skill in skills: + lines.extend( + ( + "", + f"## Agent Skill: {_code(skill.name)}", + "", + f"Instruction file: {_code(str(skill.file('SKILL.md')))}", + "", + skill.source, + ) + ) + else: + lines.extend( + ( + "", + "## Agent Skills", + "", + "This plugin packages no Agent Skills. Use its inventory and " + "component metadata.", + ) + ) + + output = "\n".join(lines) + return output if output.endswith("\n") else output + "\n" + + +def _author(author: Author) -> str: + values = [value for value in (author.name, author.email, author.url) if value] + return ", ".join(values) + + +def _issue_line(issue: ValidationIssue) -> str: + return f"- {_code(format_location(issue.location))}: {_one_line(issue.message)}" + + +def _one_line(value: str) -> str: + return " ".join(value.split()) + + +def _code(value: str) -> str: + delimiter = "`" * (_longest_backtick_run(value) + 1) + padding = " " if value.startswith(("`", " ")) or value.endswith(("`", " ")) else "" + return f"{delimiter}{padding}{value}{padding}{delimiter}" + + +def _fenced_block(value: str, *, language: str) -> str: + delimiter = "`" * max(3, _longest_backtick_run(value) + 1) + return f"{delimiter}{language}\n{value}\n{delimiter}" + + +def _longest_backtick_run(value: str) -> int: + return max((len(match.group()) for match in re.finditer(r"`+", value)), default=0) diff --git a/src/agent_plugins/_schema/errors.py b/src/agent_plugins/_schema/errors.py index 0679b6c..f4785ed 100644 --- a/src/agent_plugins/_schema/errors.py +++ b/src/agent_plugins/_schema/errors.py @@ -25,11 +25,12 @@ def __init__(self, path: Path, issues: tuple[ValidationIssue, ...]) -> None: self.path = path self.issues = issues issue = issues[0] - location = _format_location(issue.location) + location = format_location(issue.location) super().__init__(f"{path}: {location}: {issue.message}") -def _format_location(location: tuple[str | int, ...]) -> str: +def format_location(location: tuple[str | int, ...]) -> str: + """Return a JSONPath-like validation issue location.""" if not location: return "$" return "$" + "".join( diff --git a/src/agent_plugins/_skill.py b/src/agent_plugins/_skill.py index 292b139..cc13ef8 100644 --- a/src/agent_plugins/_skill.py +++ b/src/agent_plugins/_skill.py @@ -17,10 +17,11 @@ class Skill: """Expose an Agent Skill directory through native paths and source text.""" - __slots__ = ("_document", "_inventory") + __slots__ = ("_document", "_inventory", "_name") _inventory: FileInventory _document: SkillDocument + _name: str def __init__(self, path: str | os.PathLike[str]) -> None: """Create a skill from a directory containing `SKILL.md`.""" @@ -29,16 +30,25 @@ def __init__(self, path: str | os.PathLike[str]) -> None: kind=_KIND, required=_INSTRUCTIONS, ) - _set_state(self, inventory) + _set_state(self, inventory, name=inventory.root.name) @classmethod def _from_inventory( - cls, inventory: FileInventory, *, document: SkillDocument | None = None + cls, + inventory: FileInventory, + *, + name: str, + document: SkillDocument | None = None, ) -> Skill: skill = object.__new__(cls) - _set_state(skill, inventory, document=document) + _set_state(skill, inventory, name=name, document=document) return skill + @property + def name(self) -> str: + """Return the structural skill directory name.""" + return self._name + @property def path(self) -> Path: """Return the absolute skill root.""" @@ -112,19 +122,25 @@ def _repr_html_(self) -> str: return f"
{escape(self.tree())}
" def __eq__(self, other: object) -> bool: - """Return whether two skill handles select the same files.""" - return type(other) is type(self) and other._inventory == self._inventory + """Return whether two skill handles have the same name and files.""" + return ( + type(other) is type(self) + and other._name == self._name + and other._inventory == self._inventory + ) def __hash__(self) -> int: - """Return a hash of the selected skill files.""" - return hash(self._inventory) + """Return a hash of the structural name and selected files.""" + return hash((self._name, self._inventory)) def _set_state( skill: Skill, inventory: FileInventory, *, + name: str, document: SkillDocument | None = None, ) -> None: + skill._name = name skill._inventory = inventory skill._document = document if document is not None else SkillDocument(inventory) diff --git a/tests/test_cli.py b/tests/test_cli.py index ab007eb..52fa9bb 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -10,6 +10,8 @@ DIST_INFO = "demo-1.0.0.dist-info" WHEEL_NAME = "demo-1.0.0-py3-none-any.whl" +PLUGIN_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json" +MCP_SCHEMA = "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json" def test_attach_wheel_command_prints_output_path( @@ -107,6 +109,122 @@ def test_attach_wheel_command_keeps_argument_error_status() -> None: assert error.value.code == 2 +def test_read_command_prints_generated_guidance_and_complete_skills( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + root, first_source, second_source = _installed_plugin(tmp_path, extended=True) + monkeypatch.syspath_prepend(str(tmp_path)) + + assert main(["read", "demo-provider"]) == 0 + + output = capsys.readouterr() + assert output.err == "" + assert output.out.startswith("# Agent Plugin: `demo`\n") + assert "Python distribution: `demo-provider==1.2.3`" in output.out + assert "Distribution summary: Demonstrate installed plugins" in output.out + assert "Plugin description: Agent instructions for Demo" in output.out + assert f"Installed root: `{root.resolve()}`" in output.out + assert "## Plugin inventory" in output.out + assert "file-99.md" not in output.out + assert "final.md" not in output.out + assert "more files" in output.out + assert "`-- ..." in output.out + assert "````text\n" in output.out + assert "## Agent Skill: ```tick``skill```" in output.out + assert "## Client extensions\n\n- `com.example.client`" in output.out + assert "- `demo-server` (`stdio`)" in output.out + assert "- `remote-server` (`streamable-http`)" in output.out + assert "- `legacy-server` (`sse`)" in output.out + for secret in ( + "private-command", + "private-argument", + "PRIVATE_ENV_VALUE", + "https://private.example.com/mcp", + "PRIVATE_HEADER_VALUE", + "https://private.example.com/sse", + ): + assert secret not in output.out + assert "## Agent Skill: `first`" in output.out + assert "## Agent Skill: `second`" in output.out + assert first_source in output.out + assert second_source in output.out + + +def test_read_command_can_select_one_skill( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + _root, first_source, second_source = _installed_plugin(tmp_path) + monkeypatch.syspath_prepend(str(tmp_path)) + + assert main(["read", "demo-provider", "--skill", "second"]) == 0 + + output = capsys.readouterr() + assert output.err == "" + assert "## Agent Skill: `first`" not in output.out + assert "## Agent Skill: `second`" in output.out + assert first_source not in output.out + assert second_source in output.out + + +def test_read_command_reports_an_unavailable_skill( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + _installed_plugin(tmp_path) + monkeypatch.syspath_prepend(str(tmp_path)) + + assert main(["read", "demo-provider", "--skill", "missing"]) == 1 + + output = capsys.readouterr() + assert output.out == "" + assert output.err == ( + "agent-plugins: error: Agent Skill 'missing' is unavailable. " + "Available skills: first, second\n" + ) + + +def test_read_command_reports_an_uninstalled_distribution( + capsys: pytest.CaptureFixture[str], +) -> None: + assert main(["read", "distribution-that-does-not-exist"]) == 1 + + output = capsys.readouterr() + assert output.out == "" + assert output.err == ( + "agent-plugins: error: Python distribution " + "'distribution-that-does-not-exist' is not installed\n" + ) + + +def test_no_command_reads_the_agent_plugins_distribution( + capsys: pytest.CaptureFixture[str], + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[tuple[str, str | None]] = [] + + def render(distribution: str, *, skill_name: str | None = None) -> str: + calls.append((distribution, skill_name)) + return "installed agent guidance\n" + + monkeypatch.setattr("agent_plugins._cli.render_read", render) + + assert main([]) == 0 + shortcut = capsys.readouterr() + + assert main(["read", "agent-plugins"]) == 0 + explicit = capsys.readouterr() + + assert shortcut == explicit + assert shortcut.out == "installed agent guidance\n" + assert shortcut.err == "" + assert calls == [("agent-plugins", None), ("agent-plugins", None)] + + def _project(tmp_path: Path) -> Path: project = tmp_path / "project" project.mkdir() @@ -131,3 +249,128 @@ def _wheel(path: Path, *, signatures: bool = False) -> Path: for name, value in members.items(): archive.writestr(name, value) return path + + +def _installed_plugin( + tmp_path: Path, *, extended: bool = False +) -> tuple[Path, str, str]: + dist_info = tmp_path / "demo_provider-1.2.3.dist-info" + dist_info.mkdir() + (dist_info / "METADATA").write_text( + "Metadata-Version: 2.4\n" + "Name: demo-provider\n" + "Version: 1.2.3\n" + "Summary: Demonstrate installed plugins\n", + encoding="utf-8", + ) + + root = tmp_path / "demo_provider-1.2.3.agent-plugin" + first = root / "skills/first" + second = root / "skills/second" + reference = first / "references/api.md" + extension = root / "com.example.client/config.json" + first.mkdir(parents=True) + second.mkdir(parents=True) + reference.parent.mkdir(parents=True) + extension.parent.mkdir(parents=True) + + first_source = ( + "---\n" + "name: first\n" + "description: Use the first capability.\n" + "---\n" + "# First skill\n\n" + "Read [the API reference](references/api.md).\n\n\n" + ) + second_source = ( + "---\n" + "name: second\n" + "description: Use the second capability.\n" + "---\n" + "# Second skill" + ) + (first / "SKILL.md").write_text(first_source, encoding="utf-8") + (second / "SKILL.md").write_text(second_source, encoding="utf-8") + reference.write_text("# API\n", encoding="utf-8") + extension.write_text('{"enabled":true}\n', encoding="utf-8") + (root / "plugin.json").write_text( + json.dumps( + { + "$schema": PLUGIN_SCHEMA, + "name": "demo", + "version": "4.5.6", + "description": "Agent instructions for Demo", + "author": { + "name": "Ada Lovelace", + "email": "ada@example.com", + "url": "https://example.com/ada", + }, + "homepage": "https://example.com/demo", + "repository": "https://example.com/demo.git", + "license": "MIT", + "keywords": ["demo", "agents"], + "extensions": {"com.example.client": {"enabled": True}}, + } + ), + encoding="utf-8", + ) + (root / "mcp.json").write_text( + json.dumps( + { + "$schema": MCP_SCHEMA, + "mcpServers": { + "demo-server": { + "type": "stdio", + "command": "private-command", + "args": ["private-argument"], + "env": {"PRIVATE_ENV": "PRIVATE_ENV_VALUE"}, + }, + "remote-server": { + "type": "streamable-http", + "url": "https://private.example.com/mcp", + "headers": {"X-Private": "PRIVATE_HEADER_VALUE"}, + }, + "legacy-server": { + "type": "sse", + "url": "https://private.example.com/sse", + }, + }, + } + ), + encoding="utf-8", + ) + files = [ + "plugin.json", + "mcp.json", + "skills/first/SKILL.md", + "skills/first/references/api.md", + "skills/second/SKILL.md", + "com.example.client/config.json", + ] + if extended: + deep = first / "references/deep/level/final.md" + deep.parent.mkdir(parents=True) + deep.write_text("# Final\n", encoding="utf-8") + files.append("skills/first/references/deep/level/final.md") + for index in range(101): + relative = f"skills/first/references/file-{index}.md" + (root / relative).write_text(f"# File {index}\n", encoding="utf-8") + files.append(relative) + tricky_reference = first / "references/```marker.md" + tricky_reference.write_text("# Marker\n", encoding="utf-8") + files.append("skills/first/references/```marker.md") + tricky_skill = root / "skills/tick``skill/SKILL.md" + tricky_skill.parent.mkdir(parents=True) + tricky_skill.write_text( + "---\nname: tick-skill\ndescription: Exercise Markdown delimiters.\n---\n", + encoding="utf-8", + ) + files.append("skills/tick``skill/SKILL.md") + (dist_info / "agent_plugins.json").write_text( + json.dumps({"root": root.name, "files": files}), encoding="utf-8" + ) + return ( + root, + (first / "SKILL.md").read_bytes().decode("utf-8"), + (second / "SKILL.md").read_bytes().decode("utf-8"), + ) diff --git a/tests/test_discovery.py b/tests/test_discovery.py index c7836f5..690f5f1 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -153,8 +153,10 @@ def test_locate_preserves_structural_name_for_contained_skill_alias( plugin = ap.locate("demo-provider") skill = plugin.skill("alias") + assert skill.name == "alias" assert skill.path == target.resolve() assert skill.file("SKILL.md") == instructions.resolve() + assert skill != ap.Skill(target) def test_locate_command_prints_the_absolute_root_path( diff --git a/tests/test_plan.py b/tests/test_plan.py index cb57c1e..83ea713 100644 --- a/tests/test_plan.py +++ b/tests/test_plan.py @@ -20,6 +20,10 @@ def test_project_build_plan_includes_its_agent_plugin() -> None: "plugin.json", "skills/agent-plugins/SKILL.md", "skills/agent-plugins/agents/openai.yaml", + "skills/package-agent-plugin/SKILL.md", + "skills/package-agent-plugin/agents/openai.yaml", + "skills/package-agent-plugin/references/build-variants.md", + "skills/package-agent-plugin/references/verify-artifacts.md", ] diff --git a/tests/test_skill.py b/tests/test_skill.py index be20d27..53e754c 100644 --- a/tests/test_skill.py +++ b/tests/test_skill.py @@ -67,6 +67,7 @@ def test_skill_tree_drives_text_and_notebook_display(tmp_path: Path) -> None: def test_skill_lazily_splits_and_caches_its_source_text(tmp_path: Path) -> None: root = _skill_root(tmp_path) skill = ap.Skill(root) + assert skill.name == root.name skill.tree() _write_skill(