agent-plugins 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. agent_plugins-0.2.2/PKG-INFO +124 -0
  2. agent_plugins-0.2.2/README.md +103 -0
  3. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/pyproject.toml +3 -3
  4. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/pyproject.toml.orig +3 -3
  5. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_build/plan.py +66 -8
  6. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_build/sdist.py +2 -2
  7. agent_plugins-0.2.2/src/agent_plugins/_build/wheel.py +239 -0
  8. agent_plugins-0.2.2/src/agent_plugins/_build/wheel_archive.py +292 -0
  9. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_discovery.py +9 -1
  10. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_files.py +1 -1
  11. agent_plugins-0.2.2/src/agent_plugins/_mcp.py +198 -0
  12. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_plugin.py +9 -2
  13. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_schema/json.py +14 -1
  14. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_schema/lazy.py +2 -1
  15. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_schema/manifest.py +10 -1
  16. agent_plugins-0.2.2/src/agent_plugins/_schema/mcp.py +144 -0
  17. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_schema/skill.py +5 -3
  18. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_skill.py +7 -3
  19. agent_plugins-0.2.0/PKG-INFO +0 -206
  20. agent_plugins-0.2.0/README.md +0 -185
  21. agent_plugins-0.2.0/src/agent_plugins/_build/wheel.py +0 -541
  22. agent_plugins-0.2.0/src/agent_plugins/_schema/mcp.py +0 -283
  23. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/.agent-plugin/plugin.json +0 -0
  24. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/.agent-plugin/skills/agent-plugins/SKILL.md +0 -0
  25. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/.agent-plugin/skills/agent-plugins/agents/openai.yaml +0 -0
  26. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/LICENSE +0 -0
  27. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/__init__.py +0 -0
  28. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/__main__.py +0 -0
  29. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_build/__init__.py +0 -0
  30. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_build/backend.py +0 -0
  31. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_cli.py +0 -0
  32. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_errors.py +0 -0
  33. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_marker.py +0 -0
  34. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_schema/__init__.py +0 -0
  35. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_schema/errors.py +0 -0
  36. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_schema/models.py +0 -0
  37. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_schema/v1/__init__.py +0 -0
  38. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_schema/v1/manifest.py +0 -0
  39. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_schema/v1/mcp.py +0 -0
  40. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/_tree.py +0 -0
  41. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/build/__init__.py +0 -0
  42. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/build/hatchling.py +0 -0
  43. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/build/uv_build.py +0 -0
  44. {agent_plugins-0.2.0 → agent_plugins-0.2.2}/src/agent_plugins/py.typed +0 -0
@@ -0,0 +1,124 @@
1
+ Metadata-Version: 2.4
2
+ Name: agent-plugins
3
+ Version: 0.2.2
4
+ Summary: Ship Agent Plugins with Python packages and inspect their installed files
5
+ Author: Péter Ferenc Gyarmati
6
+ Author-email: Péter Ferenc Gyarmati <dev.petergy@gmail.com>
7
+ License-Expression: Apache-2.0
8
+ License-File: LICENSE
9
+ Classifier: Programming Language :: Python :: 3.10
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: Programming Language :: Python :: 3.13
13
+ Classifier: Programming Language :: Python :: 3.14
14
+ Classifier: Typing :: Typed
15
+ Requires-Dist: tomli>=1.0.3 ; python_full_version < '3.11'
16
+ Requires-Python: >=3.10, <3.15
17
+ Project-URL: Documentation, https://peter-gy.github.io/agent-plugins/
18
+ Project-URL: Issues, https://github.com/peter-gy/agent-plugins/issues
19
+ Project-URL: Source, https://github.com/peter-gy/agent-plugins
20
+ Description-Content-Type: text/markdown
21
+
22
+ <p align="center">
23
+ <picture>
24
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/peter-gy/agent-plugins/main/docs/public/brand/agent-plugins-lockup-horizontal-dark.svg">
25
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/peter-gy/agent-plugins/main/docs/public/brand/agent-plugins-lockup-horizontal-light.svg">
26
+ <img alt="agent-plugins" src="https://raw.githubusercontent.com/peter-gy/agent-plugins/main/docs/public/brand/agent-plugins-lockup-horizontal-light.svg" width="430">
27
+ </picture>
28
+ </p>
29
+
30
+ <p align="center">
31
+ Ship Agent Plugins with Python packages.
32
+ </p>
33
+
34
+ <p align="center">
35
+ <a href="https://peter-gy.github.io/agent-plugins/"><strong>Documentation</strong></a> ·
36
+ <a href="https://pypi.org/project/agent-plugins/"><strong>PyPI</strong></a> ·
37
+ <a href="https://agent-plugins.org/"><strong>Agent Plugins format</strong></a>
38
+ </p>
39
+
40
+ <p align="center">
41
+ <a href="https://pypi.org/project/agent-plugins/"><img alt="PyPI version" src="https://img.shields.io/pypi/v/agent-plugins"></a>
42
+ <a href="https://pypi.org/project/agent-plugins/"><img alt="Supported Python versions" src="https://img.shields.io/pypi/pyversions/agent-plugins"></a>
43
+ <a href="https://github.com/peter-gy/agent-plugins/actions/workflows/ci.yml"><img alt="CI status" src="https://github.com/peter-gy/agent-plugins/actions/workflows/ci.yml/badge.svg"></a>
44
+ <a href="https://github.com/peter-gy/agent-plugins/blob/main/LICENSE"><img alt="Apache-2.0 license" src="https://img.shields.io/pypi/l/agent-plugins"></a>
45
+ </p>
46
+
47
+ `agent-plugins` ships a Python library and its agent integrations in one package. An agent with Python execution can read its packaged instructions, then use the library in the same environment. Library code, skills, tool configuration, and resources share one release.
48
+
49
+ 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.
50
+
51
+ ## Package your plugin
52
+
53
+ 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`:
54
+
55
+ ```toml
56
+ [build-system]
57
+ requires = ["agent-plugins", "uv_build"]
58
+ build-backend = "agent_plugins.build.uv_build"
59
+
60
+ [tool.agent-plugins]
61
+ root = "."
62
+ ```
63
+
64
+ Build with the [uv package manager](https://docs.astral.sh/uv/):
65
+
66
+ ```console
67
+ uv build
68
+ ```
69
+
70
+ The [quickstart](https://peter-gy.github.io/agent-plugins/guide/getting-started) creates a complete project, builds it, and locates the installed plugin. Use the [Hatchling adapter](https://peter-gy.github.io/agent-plugins/guide/build-backends#hatchling) for Hatchling projects, or [attach a prebuilt wheel](https://peter-gy.github.io/agent-plugins/guide/attach-wheel) when another tool owns the build:
71
+
72
+ ```console
73
+ agent-plugins attach-wheel dist/my_project-0.1.0-py3-none-any.whl --project .
74
+ ```
75
+
76
+ Attachment updates the wheel in place. Pass `--output-dir` to preserve the input.
77
+
78
+ ## Inspect an installed plugin
79
+
80
+ Install `agent-plugins` in the environment you want to inspect. The package includes its own Agent Skill:
81
+
82
+ ```console
83
+ pip install agent-plugins
84
+ ```
85
+
86
+ ```python
87
+ import agent_plugins as ap
88
+
89
+ plugin = ap.locate("agent-plugins")
90
+ skill = plugin.skill("agent-plugins")
91
+
92
+ print(skill.source)
93
+ print(skill.file("SKILL.md"))
94
+ ```
95
+
96
+ 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.
97
+
98
+ ## Documentation
99
+
100
+ - [Get started](https://peter-gy.github.io/agent-plugins/guide/getting-started): package, install, and locate a plugin.
101
+ - [How packaging works](https://peter-gy.github.io/agent-plugins/guide/artifact-lifecycle): wheels, source distributions, and editable installs.
102
+ - [Integrate](https://peter-gy.github.io/agent-plugins/guide/inspect-installed): read skills, inspect files, and resolve MCP configuration.
103
+ - [Python API](https://peter-gy.github.io/agent-plugins/reference/python-api) · [CLI](https://peter-gy.github.io/agent-plugins/reference/cli) · [Configuration](https://peter-gy.github.io/agent-plugins/reference/pyproject)
104
+
105
+ <details>
106
+ <summary>Packaging lifecycle</summary>
107
+
108
+ <p align="center">
109
+ <picture>
110
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/peter-gy/agent-plugins/main/docs/public/brand/agent-plugins-core-model-dark.svg">
111
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/peter-gy/agent-plugins/main/docs/public/brand/agent-plugins-core-model-light.svg">
112
+ <img alt="Core model: an authored Python project builds into one wheel that installs the library beside its version-matched Agent Plugin, which locate() returns as a plugin handle" src="https://raw.githubusercontent.com/peter-gy/agent-plugins/main/docs/public/brand/agent-plugins-core-model-light.svg" width="300">
113
+ </picture>
114
+ </p>
115
+
116
+ </details>
117
+
118
+ ## Development
119
+
120
+ See [development_docs/](https://github.com/peter-gy/agent-plugins/tree/main/development_docs) for setup, architecture, checks, and releases. Serve the documentation locally with `pnpm --dir docs dev`.
121
+
122
+ ## License
123
+
124
+ [Apache-2.0](https://github.com/peter-gy/agent-plugins/blob/main/LICENSE).
@@ -0,0 +1,103 @@
1
+ <p align="center">
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/peter-gy/agent-plugins/main/docs/public/brand/agent-plugins-lockup-horizontal-dark.svg">
4
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/peter-gy/agent-plugins/main/docs/public/brand/agent-plugins-lockup-horizontal-light.svg">
5
+ <img alt="agent-plugins" src="https://raw.githubusercontent.com/peter-gy/agent-plugins/main/docs/public/brand/agent-plugins-lockup-horizontal-light.svg" width="430">
6
+ </picture>
7
+ </p>
8
+
9
+ <p align="center">
10
+ Ship Agent Plugins with Python packages.
11
+ </p>
12
+
13
+ <p align="center">
14
+ <a href="https://peter-gy.github.io/agent-plugins/"><strong>Documentation</strong></a> ·
15
+ <a href="https://pypi.org/project/agent-plugins/"><strong>PyPI</strong></a> ·
16
+ <a href="https://agent-plugins.org/"><strong>Agent Plugins format</strong></a>
17
+ </p>
18
+
19
+ <p align="center">
20
+ <a href="https://pypi.org/project/agent-plugins/"><img alt="PyPI version" src="https://img.shields.io/pypi/v/agent-plugins"></a>
21
+ <a href="https://pypi.org/project/agent-plugins/"><img alt="Supported Python versions" src="https://img.shields.io/pypi/pyversions/agent-plugins"></a>
22
+ <a href="https://github.com/peter-gy/agent-plugins/actions/workflows/ci.yml"><img alt="CI status" src="https://github.com/peter-gy/agent-plugins/actions/workflows/ci.yml/badge.svg"></a>
23
+ <a href="https://github.com/peter-gy/agent-plugins/blob/main/LICENSE"><img alt="Apache-2.0 license" src="https://img.shields.io/pypi/l/agent-plugins"></a>
24
+ </p>
25
+
26
+ `agent-plugins` ships a Python library and its agent integrations in one package. An agent with Python execution can read its packaged instructions, then use the library in the same environment. Library code, skills, tool configuration, and resources share one release.
27
+
28
+ 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.
29
+
30
+ ## Package your plugin
31
+
32
+ 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`:
33
+
34
+ ```toml
35
+ [build-system]
36
+ requires = ["agent-plugins", "uv_build"]
37
+ build-backend = "agent_plugins.build.uv_build"
38
+
39
+ [tool.agent-plugins]
40
+ root = "."
41
+ ```
42
+
43
+ Build with the [uv package manager](https://docs.astral.sh/uv/):
44
+
45
+ ```console
46
+ uv build
47
+ ```
48
+
49
+ The [quickstart](https://peter-gy.github.io/agent-plugins/guide/getting-started) creates a complete project, builds it, and locates the installed plugin. Use the [Hatchling adapter](https://peter-gy.github.io/agent-plugins/guide/build-backends#hatchling) for Hatchling projects, or [attach a prebuilt wheel](https://peter-gy.github.io/agent-plugins/guide/attach-wheel) when another tool owns the build:
50
+
51
+ ```console
52
+ agent-plugins attach-wheel dist/my_project-0.1.0-py3-none-any.whl --project .
53
+ ```
54
+
55
+ Attachment updates the wheel in place. Pass `--output-dir` to preserve the input.
56
+
57
+ ## Inspect an installed plugin
58
+
59
+ Install `agent-plugins` in the environment you want to inspect. The package includes its own Agent Skill:
60
+
61
+ ```console
62
+ pip install agent-plugins
63
+ ```
64
+
65
+ ```python
66
+ import agent_plugins as ap
67
+
68
+ plugin = ap.locate("agent-plugins")
69
+ skill = plugin.skill("agent-plugins")
70
+
71
+ print(skill.source)
72
+ print(skill.file("SKILL.md"))
73
+ ```
74
+
75
+ 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.
76
+
77
+ ## Documentation
78
+
79
+ - [Get started](https://peter-gy.github.io/agent-plugins/guide/getting-started): package, install, and locate a plugin.
80
+ - [How packaging works](https://peter-gy.github.io/agent-plugins/guide/artifact-lifecycle): wheels, source distributions, and editable installs.
81
+ - [Integrate](https://peter-gy.github.io/agent-plugins/guide/inspect-installed): read skills, inspect files, and resolve MCP configuration.
82
+ - [Python API](https://peter-gy.github.io/agent-plugins/reference/python-api) · [CLI](https://peter-gy.github.io/agent-plugins/reference/cli) · [Configuration](https://peter-gy.github.io/agent-plugins/reference/pyproject)
83
+
84
+ <details>
85
+ <summary>Packaging lifecycle</summary>
86
+
87
+ <p align="center">
88
+ <picture>
89
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/peter-gy/agent-plugins/main/docs/public/brand/agent-plugins-core-model-dark.svg">
90
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/peter-gy/agent-plugins/main/docs/public/brand/agent-plugins-core-model-light.svg">
91
+ <img alt="Core model: an authored Python project builds into one wheel that installs the library beside its version-matched Agent Plugin, which locate() returns as a plugin handle" src="https://raw.githubusercontent.com/peter-gy/agent-plugins/main/docs/public/brand/agent-plugins-core-model-light.svg" width="300">
92
+ </picture>
93
+ </p>
94
+
95
+ </details>
96
+
97
+ ## Development
98
+
99
+ See [development_docs/](https://github.com/peter-gy/agent-plugins/tree/main/development_docs) for setup, architecture, checks, and releases. Serve the documentation locally with `pnpm --dir docs dev`.
100
+
101
+ ## License
102
+
103
+ [Apache-2.0](https://github.com/peter-gy/agent-plugins/blob/main/LICENSE).
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "agent-plugins"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Ship Agent Plugins with Python packages and inspect their installed files"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
7
7
  license-files = ["LICENSE"]
8
8
  requires-python = ">=3.10,<3.15"
9
- dependencies = ["tomli==2.3.1; python_version < '3.11'"]
9
+ dependencies = ["tomli>=1.0.3; python_version < '3.11'"]
10
10
  classifiers = [
11
11
  "Programming Language :: Python :: 3.10",
12
12
  "Programming Language :: Python :: 3.11",
@@ -30,7 +30,7 @@ agent-plugins = "agent_plugins._cli:main"
30
30
 
31
31
  [build-system]
32
32
  requires = [
33
- "tomli==2.3.1; python_version < '3.11'",
33
+ "tomli>=1.0.3; python_version < '3.11'",
34
34
  "uv_build==0.12.2",
35
35
  ]
36
36
  build-backend = "agent_plugins.build.uv_build"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agent-plugins"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "Ship Agent Plugins with Python packages and inspect their installed files"
5
5
  readme = "README.md"
6
6
  license = "Apache-2.0"
@@ -10,7 +10,7 @@ authors = [
10
10
  ]
11
11
  requires-python = ">=3.10,<3.15"
12
12
  dependencies = [
13
- "tomli==2.3.1; python_version < '3.11'",
13
+ "tomli>=1.0.3; python_version < '3.11'",
14
14
  ]
15
15
  classifiers = [
16
16
  "Programming Language :: Python :: 3.10",
@@ -31,7 +31,7 @@ agent-plugins = "agent_plugins._cli:main"
31
31
 
32
32
  [build-system]
33
33
  requires = [
34
- "tomli==2.3.1; python_version < '3.11'",
34
+ "tomli>=1.0.3; python_version < '3.11'",
35
35
  "uv_build==0.12.2",
36
36
  ]
37
37
  build-backend = "agent_plugins.build.uv_build"
@@ -4,7 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import sys
6
6
  from dataclasses import dataclass
7
- from pathlib import Path, PurePosixPath
7
+ from pathlib import Path, PurePosixPath, PureWindowsPath
8
8
  from typing import cast
9
9
 
10
10
  if sys.version_info >= (3, 11):
@@ -38,7 +38,7 @@ def build_plan(project: str | Path = ".") -> BuildPlan:
38
38
  """Load project configuration and return its complete plugin file plan."""
39
39
  try:
40
40
  project_path = Path(project).resolve()
41
- except (OSError, RuntimeError) as error:
41
+ except (OSError, RuntimeError, ValueError) as error:
42
42
  raise AgentPluginError(
43
43
  f"Project path cannot be resolved: {project}. Check the path and retry."
44
44
  ) from error
@@ -63,10 +63,11 @@ def build_plan(project: str | Path = ".") -> BuildPlan:
63
63
 
64
64
  staged = project_path / STAGED_ROOT
65
65
  configured = project_path / root_value
66
- source_root = staged if (staged / "plugin.json").is_file() else configured
66
+ is_staged = (staged / "plugin.json").is_file()
67
+ source_root = staged if is_staged else configured
67
68
  try:
68
69
  root = source_root.resolve(strict=True)
69
- except (OSError, RuntimeError) as error:
70
+ except (OSError, RuntimeError, ValueError) as error:
70
71
  raise AgentPluginError(
71
72
  f"Agent Plugin root cannot be resolved: {source_root}"
72
73
  ) from error
@@ -75,10 +76,13 @@ def build_plan(project: str | Path = ".") -> BuildPlan:
75
76
 
76
77
  files: dict[PurePosixPath, Path] = {}
77
78
  _add_file(files, root, root / "plugin.json", required=True)
78
- _add_tree(files, root, root / "skills")
79
- _add_file(files, root, root / "mcp.json", required=False)
80
- for pattern in include:
81
- _add_pattern(files, root, pattern)
79
+ if is_staged:
80
+ _add_tree(files, root, root)
81
+ else:
82
+ _add_tree(files, root, root / "skills")
83
+ _add_file(files, root, root / "mcp.json", required=False)
84
+ for pattern in include:
85
+ _add_pattern(files, root, pattern)
82
86
 
83
87
  mappings = tuple(
84
88
  FileMapping(source=source, target=target)
@@ -87,6 +91,58 @@ def build_plan(project: str | Path = ".") -> BuildPlan:
87
91
  return BuildPlan(project=project_path, root=root, files=mappings)
88
92
 
89
93
 
94
+ def validate_plan(plan: BuildPlan) -> tuple[PurePosixPath, ...]:
95
+ """Check that a plan can produce a discoverable plugin payload."""
96
+ files: list[PurePosixPath] = []
97
+ seen: set[PurePosixPath] = set()
98
+ for mapping in plan.files:
99
+ target = mapping.target
100
+ value = target.as_posix()
101
+ if (
102
+ target.is_absolute()
103
+ or PureWindowsPath(value).drive
104
+ or not target.parts
105
+ or ".." in target.parts
106
+ or "\\" in value
107
+ or "\x00" in value
108
+ ):
109
+ raise AgentPluginError(
110
+ f"Plugin target must stay within the plugin root: {value!r}"
111
+ )
112
+ if target in seen:
113
+ raise AgentPluginError(f"Plugin plan contains a duplicate target: {value}")
114
+ collision = next(
115
+ (
116
+ existing
117
+ for existing in seen
118
+ if existing in target.parents or target in existing.parents
119
+ ),
120
+ None,
121
+ )
122
+ if collision is not None:
123
+ raise AgentPluginError(
124
+ "Plugin plan contains file-directory target collisions: "
125
+ f"{collision.as_posix()} and {value}"
126
+ )
127
+ seen.add(target)
128
+ try:
129
+ source = mapping.source.resolve(strict=True)
130
+ except (OSError, RuntimeError, ValueError) as error:
131
+ raise AgentPluginError(
132
+ f"Plugin file cannot be read: {mapping.source}. "
133
+ "Restore the file and retry."
134
+ ) from error
135
+ if not source.is_file():
136
+ raise AgentPluginError(
137
+ f"Plugin source is not a file: {source}. "
138
+ "Select a regular file and retry."
139
+ )
140
+ files.append(target)
141
+ if PurePosixPath("plugin.json") not in seen:
142
+ raise AgentPluginError("Plugin plan must include plugin.json")
143
+ return tuple(files)
144
+
145
+
90
146
  def _config(document: dict[str, object], pyproject: Path) -> dict[str, object]:
91
147
  tool = document.get("tool")
92
148
  if not isinstance(tool, dict):
@@ -133,6 +189,8 @@ def _add_tree(files: dict[PurePosixPath, Path], root: Path, directory: Path) ->
133
189
  return
134
190
  if not directory.is_dir():
135
191
  raise AgentPluginError(f"Expected a directory: {directory}")
192
+ if directory.is_symlink():
193
+ raise AgentPluginError(f"Directory symlinks cannot be packaged: {directory}")
136
194
  for candidate in directory.rglob("*"):
137
195
  if candidate.is_symlink() and candidate.is_dir():
138
196
  raise AgentPluginError(
@@ -35,7 +35,7 @@ def _rewrite(source_path: Path, target_path: Path, plan: BuildPlan) -> None:
35
35
  with tarfile.open(source_path, "r:gz") as source:
36
36
  members = source.getmembers()
37
37
  root = _archive_root(members)
38
- stage = f"{root}/{STAGED_ROOT}"
38
+ stage = PurePosixPath(root, STAGED_ROOT)
39
39
 
40
40
  with (
41
41
  target_path.open("wb") as raw_target,
@@ -47,7 +47,7 @@ def _rewrite(source_path: Path, target_path: Path, plan: BuildPlan) -> None:
47
47
  ) as target,
48
48
  ):
49
49
  for member in members:
50
- if member.name == stage or member.name.startswith(f"{stage}/"):
50
+ if PurePosixPath(member.name).is_relative_to(stage):
51
51
  continue
52
52
  file_object = source.extractfile(member) if member.isfile() else None
53
53
  try:
@@ -0,0 +1,239 @@
1
+ """Attach Agent Plugin payloads to wheel artifacts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shutil
7
+ import stat
8
+ import tempfile
9
+ import zipfile
10
+ from contextlib import suppress
11
+ from dataclasses import dataclass
12
+ from pathlib import Path, PurePosixPath
13
+
14
+ from .._errors import AgentPluginError
15
+ from .plan import BuildPlan, build_plan, validate_plan
16
+ from .wheel_archive import rewrite_wheel
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class WheelAttachment:
21
+ """Describe an Agent Plugin attached to a wheel."""
22
+
23
+ source: Path
24
+ output: Path
25
+ dist_info: PurePosixPath
26
+ plugin_root: PurePosixPath
27
+ files: tuple[PurePosixPath, ...]
28
+ replaced_existing_plugin: bool
29
+ removed_signatures: tuple[PurePosixPath, ...]
30
+
31
+
32
+ def attach_wheel(
33
+ wheel: str | Path,
34
+ *,
35
+ project: str | Path | None = None,
36
+ plan: BuildPlan | None = None,
37
+ output_dir: str | Path | None = None,
38
+ ) -> WheelAttachment:
39
+ """Attach a planned Agent Plugin to one existing wheel."""
40
+ if project is not None and plan is not None:
41
+ raise AgentPluginError(
42
+ "Pass either project or plan to attach_wheel(), not both"
43
+ )
44
+
45
+ source, source_mode = _resolve_wheel(wheel)
46
+ output = _resolve_output(source, output_dir)
47
+ selected_plan = plan if plan is not None else build_plan(project or Path.cwd())
48
+ files = validate_plan(selected_plan)
49
+
50
+ temporary = _temporary_wheel(output)
51
+ try:
52
+ try:
53
+ layout = rewrite_wheel(
54
+ source,
55
+ temporary,
56
+ plan=selected_plan,
57
+ editable_root=None,
58
+ )
59
+ temporary.chmod(source_mode)
60
+ _publish(temporary, output, in_place=output == source)
61
+ except AgentPluginError:
62
+ raise
63
+ except zipfile.BadZipFile as error:
64
+ raise AgentPluginError(
65
+ f"Wheel is corrupt: {source}. Rebuild the wheel and retry."
66
+ ) from error
67
+ except zipfile.LargeZipFile as error:
68
+ raise AgentPluginError(
69
+ f"Wheel exceeds the supported ZIP limits: {source}. "
70
+ "Rebuild the wheel with ZIP64 support and retry."
71
+ ) from error
72
+ except NotImplementedError as error:
73
+ raise AgentPluginError(
74
+ f"Wheel uses unsupported ZIP compression: {source}. "
75
+ "Rebuild the wheel with a supported compression method and retry."
76
+ ) from error
77
+ except OSError as error:
78
+ raise AgentPluginError(
79
+ f"Cannot write attached wheel: {output}. "
80
+ "Check file access and available space, then retry."
81
+ ) from error
82
+ finally:
83
+ with suppress(OSError):
84
+ temporary.unlink(missing_ok=True)
85
+
86
+ return WheelAttachment(
87
+ source=source,
88
+ output=output,
89
+ dist_info=layout.dist_info,
90
+ plugin_root=layout.plugin_root,
91
+ files=files,
92
+ replaced_existing_plugin=layout.replaced_existing_plugin,
93
+ removed_signatures=layout.removed_signatures,
94
+ )
95
+
96
+
97
+ def _attach_editable_wheel(wheel: Path, plan: BuildPlan) -> None:
98
+ """Attach an authored-root marker to an editable wheel."""
99
+ source, source_mode = _resolve_wheel(wheel)
100
+ validate_plan(plan)
101
+ temporary = _temporary_wheel(source)
102
+ try:
103
+ try:
104
+ rewrite_wheel(source, temporary, plan=plan, editable_root=plan.root)
105
+ temporary.chmod(source_mode)
106
+ temporary.replace(source)
107
+ except AgentPluginError:
108
+ raise
109
+ except zipfile.BadZipFile as error:
110
+ raise AgentPluginError(
111
+ f"Wheel is corrupt: {source}. Rebuild the wheel and retry."
112
+ ) from error
113
+ except (zipfile.LargeZipFile, NotImplementedError) as error:
114
+ raise AgentPluginError(
115
+ f"Wheel uses an unsupported ZIP format: {source}. "
116
+ "Rebuild the wheel and retry."
117
+ ) from error
118
+ except OSError as error:
119
+ raise AgentPluginError(
120
+ f"Cannot write editable wheel: {source}. "
121
+ "Check file access and available space, then retry."
122
+ ) from error
123
+ finally:
124
+ with suppress(OSError):
125
+ temporary.unlink(missing_ok=True)
126
+
127
+
128
+ def _resolve_wheel(wheel: str | Path) -> tuple[Path, int]:
129
+ candidate = Path(wheel).expanduser()
130
+ if candidate.suffix != ".whl":
131
+ raise AgentPluginError(
132
+ f"Wheel path must end in .whl: {candidate}. Select a built wheel and retry."
133
+ )
134
+ try:
135
+ path = candidate.resolve(strict=True)
136
+ metadata = path.stat()
137
+ except (OSError, RuntimeError, ValueError) as error:
138
+ raise AgentPluginError(
139
+ f"Wheel cannot be read: {candidate}. "
140
+ "Check the path and file access, then retry."
141
+ ) from error
142
+ if not path.is_file():
143
+ raise AgentPluginError(
144
+ f"Wheel path is not a file: {path}. Select a built wheel and retry."
145
+ )
146
+ return path, stat.S_IMODE(metadata.st_mode)
147
+
148
+
149
+ def _resolve_output(source: Path, output_dir: str | Path | None) -> Path:
150
+ if output_dir is None:
151
+ return source
152
+ candidate = Path(output_dir).expanduser()
153
+ try:
154
+ directory = candidate.resolve(strict=True)
155
+ except (OSError, RuntimeError, ValueError) as error:
156
+ raise AgentPluginError(
157
+ f"Output directory cannot be used: {candidate}. "
158
+ "Create the directory and check its access, then retry."
159
+ ) from error
160
+ if not directory.is_dir():
161
+ raise AgentPluginError(
162
+ f"Output directory is not a directory: {directory}. "
163
+ "Select an existing directory and retry."
164
+ )
165
+ output = directory / source.name
166
+ if output.exists() or output.is_symlink():
167
+ raise AgentPluginError(
168
+ f"Output wheel already exists: {output}. "
169
+ "Remove it or select another output directory."
170
+ )
171
+ return output
172
+
173
+
174
+ def _temporary_wheel(output: Path) -> Path:
175
+ try:
176
+ with tempfile.NamedTemporaryFile(
177
+ dir=output.parent,
178
+ prefix=f".{output.name}.",
179
+ suffix=".tmp",
180
+ delete=False,
181
+ ) as temporary:
182
+ return Path(temporary.name)
183
+ except OSError as error:
184
+ raise AgentPluginError(
185
+ f"Cannot create a temporary wheel in {output.parent}. "
186
+ "Check directory access and available space, then retry."
187
+ ) from error
188
+
189
+
190
+ def _publish(temporary: Path, output: Path, *, in_place: bool) -> None:
191
+ if in_place:
192
+ temporary.replace(output)
193
+ return
194
+ try:
195
+ os.link(temporary, output)
196
+ except FileExistsError as error:
197
+ raise AgentPluginError(
198
+ f"Output wheel already exists: {output}. "
199
+ "Remove it or select another output directory."
200
+ ) from error
201
+ except OSError:
202
+ _publish_reserved(temporary, output)
203
+
204
+
205
+ def _publish_reserved(temporary: Path, output: Path) -> None:
206
+ descriptor: int | None = None
207
+ try:
208
+ mode = stat.S_IMODE(temporary.stat().st_mode)
209
+ descriptor = os.open(output, os.O_CREAT | os.O_EXCL | os.O_WRONLY, mode)
210
+ if os.name != "nt":
211
+ os.fchmod(descriptor, mode)
212
+ with (
213
+ temporary.open("rb") as source,
214
+ os.fdopen(descriptor, "wb", closefd=False) as target,
215
+ ):
216
+ shutil.copyfileobj(source, target, length=1024 * 1024)
217
+ target.flush()
218
+ os.fsync(descriptor)
219
+ if not os.path.samestat(os.fstat(descriptor), output.lstat()):
220
+ raise AgentPluginError(
221
+ f"Output wheel changed during publication: {output}. "
222
+ "Inspect the destination and retry."
223
+ )
224
+ except FileExistsError as error:
225
+ raise AgentPluginError(
226
+ f"Output wheel already exists: {output}. "
227
+ "Remove it or select another output directory."
228
+ ) from error
229
+ except AgentPluginError:
230
+ raise
231
+ except OSError as error:
232
+ raise AgentPluginError(
233
+ f"Cannot publish attached wheel: {output}. "
234
+ "Remove an incomplete destination, check file access, and retry."
235
+ ) from error
236
+ finally:
237
+ if descriptor is not None:
238
+ with suppress(OSError):
239
+ os.close(descriptor)