Skip to content

Commit 0ac233e

Browse files
committed
Add external plugin SDK
1 parent 9ac7925 commit 0ac233e

8 files changed

Lines changed: 422 additions & 1 deletion

File tree

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -262,7 +262,15 @@ automax plugins audit
262262
`plugins coverage` renders a builtin/external plugin matrix with category,
263263
remote-session, check-mode, dry-run, manual preview, diff preview and tool
264264
requirement coverage. `--strict` turns metadata and preview coverage failures
265-
into a release gate.
265+
into a release gate. External plugin SDK commands create, validate and package
266+
plugin sources before they are loaded with `--plugin-path`:
267+
268+
```bash
269+
automax plugins init company.echo --output ./plugins
270+
automax plugins check ./plugins
271+
automax plugins package ./plugins --output dist/company-plugins.zip
272+
automax plugins describe company.echo --plugin-path ./plugins
273+
```
266274

267275
Public families:
268276

docs/concepts/plugin-system.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,3 +58,5 @@ automax docs generate-plugins --output docs/plugins/generated.md
5858
The core execution engine is independent from the builtin plugin set. Future
5959
external plugins can be registered without changing job orchestration, state
6060
storage or SSH connection handling.
61+
62+
External plugin sources can be created with `automax plugins init`, validated with `automax plugins check` and packaged with `automax plugins package`.

docs/guides/creating-plugins.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,27 @@ coverage command renders a matrix for builtin and external plugins and can fail
7373
the build when metadata, preview or dry-run quality gates regress. External
7474
plugins loaded with `--plugin-path` are described and audited through the same
7575
registry contract.
76+
77+
78+
## External plugin SDK workflow
79+
80+
Use the SDK commands to create a checked plugin source file before loading it in
81+
jobs or release pipelines:
82+
83+
```bash
84+
automax plugins init company.echo --output ./plugins
85+
automax plugins check ./plugins
86+
automax plugins package ./plugins --output dist/company-plugins.zip
87+
automax plugins describe company.echo --plugin-path ./plugins
88+
```
89+
90+
`plugins init` writes a minimal plugin class with canonical metadata, parameter
91+
schema, examples and result fields. `plugins check` loads the external path
92+
without builtin plugins and fails when the metadata contract is incomplete.
93+
`plugins package` runs the same validation before producing a ZIP archive with
94+
the plugin sources and `automax-plugin.json` manifest.
95+
96+
The packaged plugin can be unpacked into a controlled plugin directory and
97+
loaded with the existing `--plugin-path` option used by `run`, `validate`,
98+
`review`, `plan`, `plugins describe`, `plugins coverage` and documentation
99+
generation commands.

docs/reference/cli.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,32 @@ automax secrets check --job job.yaml --inventory inventory.yaml --secrets secret
196196
automax secrets check --job job.yaml --inventory inventory.yaml --secrets secrets.yaml --format=json
197197
```
198198

199+
## Plugins
200+
201+
Inspect builtin and external plugins:
202+
203+
```bash
204+
automax plugins list
205+
automax plugins describe fs.file.template
206+
automax plugins coverage --strict
207+
automax plugins audit
208+
```
209+
210+
Create, validate and package an external plugin source file:
211+
212+
```bash
213+
automax plugins init company.echo --output ./plugins
214+
automax plugins check ./plugins
215+
automax plugins check ./plugins --format=json
216+
automax plugins package ./plugins --output dist/company-plugins.zip
217+
automax plugins describe company.echo --plugin-path ./plugins
218+
```
219+
220+
`plugins check` loads only the external plugin path and validates the public
221+
metadata contract: canonical name, description, category, parameter schemas,
222+
examples and result fields. `plugins package` runs the same check before writing
223+
a ZIP archive with the plugin sources and `automax-plugin.json` manifest.
224+
199225
## Manual commands
200226

201227
Render copy/pasteable commands for selected substeps when a failed operation must

src/automax/cli/cli.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@
3838
render_plugin_coverage_text,
3939
)
4040
from automax.plugins.registry import build_builtin_registry
41+
from automax.plugins.sdk import (
42+
check_external_plugin_path,
43+
create_plugin_skeleton,
44+
package_external_plugin_path,
45+
render_plugin_check_text,
46+
)
4147

4248

4349
def _parse_vars(values: Iterable[str]) -> Dict[str, str]:
@@ -1820,6 +1826,56 @@ def coverage_plugins(plugin_path: tuple[str, ...], output_format: str, output_pa
18201826
raise click.exceptions.Exit(1)
18211827

18221828

1829+
@plugins.command("init")
1830+
@click.argument("name")
1831+
@click.option("--output", "output_path", required=True, type=click.Path(), help="Plugin .py file or output directory.")
1832+
@click.option("--force", is_flag=True, help="Overwrite the generated plugin file if it already exists.")
1833+
def init_plugin(name: str, output_path: str, force: bool) -> None:
1834+
"""Create an external plugin skeleton."""
1835+
try:
1836+
output = create_plugin_skeleton(name, output_path, force=force)
1837+
except ValueError as exc:
1838+
raise click.ClickException(str(exc)) from exc
1839+
click.echo(f"Wrote {output}")
1840+
1841+
1842+
@plugins.command("check")
1843+
@click.argument("path", type=click.Path(exists=True))
1844+
@click.option(
1845+
"--format",
1846+
"output_format",
1847+
type=click.Choice(["text", "json"]),
1848+
default="text",
1849+
show_default=True,
1850+
help="Output format.",
1851+
)
1852+
def check_plugin(path: str, output_format: str) -> None:
1853+
"""Validate external plugin loading and metadata."""
1854+
try:
1855+
payload = check_external_plugin_path(path)
1856+
except ValueError as exc:
1857+
raise click.ClickException(str(exc)) from exc
1858+
if output_format == "json":
1859+
click.echo(json.dumps(payload, indent=2, sort_keys=True))
1860+
else:
1861+
click.echo(render_plugin_check_text(payload))
1862+
if not payload["ok"]:
1863+
raise click.exceptions.Exit(1)
1864+
1865+
1866+
@plugins.command("package")
1867+
@click.argument("path", type=click.Path(exists=True))
1868+
@click.option("--output", "output_path", required=True, type=click.Path(dir_okay=False), help="ZIP package path.")
1869+
@click.option("--force", is_flag=True, help="Overwrite the package if it already exists.")
1870+
def package_plugin(path: str, output_path: str, force: bool) -> None:
1871+
"""Package checked external plugin sources."""
1872+
try:
1873+
output = package_external_plugin_path(path, output_path, force=force)
1874+
except ValueError as exc:
1875+
raise click.ClickException(str(exc)) from exc
1876+
click.echo(f"Wrote {output}")
1877+
1878+
18231879
@plugins.command("describe")
18241880
@click.argument("name")
18251881
@click.option("--plugin-path", multiple=True, help="External plugin file or directory.")

src/automax/plugins/sdk.py

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
# Copyright (C) 2026 Marco Fortina
2+
# SPDX-License-Identifier: AGPL-3.0-or-later
3+
4+
"""External plugin SDK helpers."""
5+
6+
from __future__ import annotations
7+
8+
import json
9+
import re
10+
import zipfile
11+
from pathlib import Path
12+
from typing import Any, Dict
13+
14+
from automax.plugins.registry import PluginRegistry, PluginRegistryError
15+
16+
PLUGIN_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$")
17+
18+
19+
def plugin_module_name(plugin_name: str) -> str:
20+
"""Return a safe module stem for a canonical plugin name."""
21+
return plugin_name.replace(".", "_").replace("-", "_")
22+
23+
24+
def plugin_class_name(plugin_name: str) -> str:
25+
"""Return a readable plugin class name for a canonical plugin name."""
26+
parts = re.split(r"[._-]+", plugin_name)
27+
return "".join(part[:1].upper() + part[1:] for part in parts if part) + "Plugin"
28+
29+
30+
def resolve_plugin_output_path(output_path: str, plugin_name: str) -> Path:
31+
"""Resolve the file path used by the plugin skeleton generator."""
32+
output = Path(output_path).expanduser()
33+
if output.suffix == ".py":
34+
return output
35+
return output / f"{plugin_module_name(plugin_name)}.py"
36+
37+
38+
def render_plugin_skeleton(plugin_name: str) -> str:
39+
"""Render a minimal external plugin implementation."""
40+
class_name = plugin_class_name(plugin_name)
41+
return f'''# Copyright (C) 2026 Marco Fortina
42+
# SPDX-License-Identifier: AGPL-3.0-or-later
43+
44+
"""External Automax plugin: {plugin_name}."""
45+
46+
from __future__ import annotations
47+
48+
from typing import Dict
49+
50+
from automax.core.models import ExecutionContext, PluginResult
51+
from automax.plugins.base import BasePlugin
52+
53+
54+
class {class_name}(BasePlugin):
55+
"""Example external plugin implementation."""
56+
57+
name = "{plugin_name}"
58+
description = "Render a deterministic external plugin response."
59+
category = "{plugin_name.split('.', 1)[0]}"
60+
required_params = ("message",)
61+
optional_params = ("changed",)
62+
parameter_schema = {{
63+
"message": {{
64+
"type": "string",
65+
"description": "Message returned by the plugin.",
66+
"non_empty": True,
67+
}},
68+
"changed": {{
69+
"type": "boolean",
70+
"default": False,
71+
"description": "Whether the plugin should report a change.",
72+
}},
73+
}}
74+
examples = (
75+
"""- id: external\n use: {plugin_name}\n with:\n message: hello""",
76+
)
77+
result_fields = {{
78+
"message": "Rendered message.",
79+
"changed": "Whether the plugin reported a change.",
80+
}}
81+
supports_dry_run = True
82+
supports_check_mode = True
83+
84+
def execute(self, params: Dict[str, object], context: ExecutionContext) -> PluginResult:
85+
message = str(params["message"])
86+
changed = bool(params.get("changed", False))
87+
return PluginResult.success(
88+
changed=changed,
89+
message=message,
90+
data={{"message": message, "changed": changed}},
91+
)
92+
'''
93+
94+
95+
def create_plugin_skeleton(plugin_name: str, output_path: str, *, force: bool = False) -> Path:
96+
"""Create one external plugin skeleton file."""
97+
if not PLUGIN_NAME_RE.match(plugin_name):
98+
raise PluginRegistryError(
99+
"plugin name must be canonical, for example vendor.category.action"
100+
)
101+
output = resolve_plugin_output_path(output_path, plugin_name)
102+
if output.exists() and not force:
103+
raise PluginRegistryError(f"plugin file already exists: {output}")
104+
output.parent.mkdir(parents=True, exist_ok=True)
105+
output.write_text(render_plugin_skeleton(plugin_name), encoding="utf-8")
106+
return output
107+
108+
109+
def load_external_plugins(path: str) -> PluginRegistry:
110+
"""Load external plugins only from one file or directory."""
111+
registry = PluginRegistry()
112+
registry.load_from_paths([path])
113+
return registry
114+
115+
116+
def check_external_plugin_path(path: str) -> Dict[str, Any]:
117+
"""Validate external plugin loading and metadata completeness."""
118+
registry = load_external_plugins(path)
119+
plugins = registry.describe_all()
120+
failures: list[str] = []
121+
122+
for plugin in plugins:
123+
name = str(plugin["name"])
124+
if not PLUGIN_NAME_RE.match(name):
125+
failures.append(f"{name}: name must be canonical")
126+
if not plugin.get("description"):
127+
failures.append(f"{name}: description is required")
128+
if not plugin.get("category"):
129+
failures.append(f"{name}: category is required")
130+
131+
required = list(plugin.get("required_params") or [])
132+
optional = list(plugin.get("optional_params") or [])
133+
if len(set(required)) != len(required):
134+
failures.append(f"{name}: duplicate required params")
135+
if len(set(optional)) != len(optional):
136+
failures.append(f"{name}: duplicate optional params")
137+
overlap = sorted(set(required) & set(optional))
138+
if overlap:
139+
failures.append(f"{name}: params cannot be both required and optional: {', '.join(overlap)}")
140+
141+
parameters = {str(item["name"]): item for item in plugin.get("parameters", [])}
142+
for param in required + optional:
143+
details = parameters.get(param) or {}
144+
if not details.get("type") or details.get("type") == "any":
145+
failures.append(f"{name}: param {param} must declare a type")
146+
if not details.get("description"):
147+
failures.append(f"{name}: param {param} must declare a description")
148+
149+
if not plugin.get("examples"):
150+
failures.append(f"{name}: at least one example is required")
151+
if not plugin.get("result_fields"):
152+
failures.append(f"{name}: result_fields are required")
153+
154+
return {
155+
"ok": not failures,
156+
"checked": len(plugins),
157+
"failure_count": len(failures),
158+
"failures": failures,
159+
"plugins": plugins,
160+
}
161+
162+
163+
def render_plugin_check_text(payload: Dict[str, Any]) -> str:
164+
"""Render external plugin check results as text."""
165+
lines = ["External plugin check:"]
166+
lines.append(f" checked: {payload['checked']}")
167+
lines.append(f" failures: {payload['failure_count']}")
168+
if payload["plugins"]:
169+
lines.append("Plugins:")
170+
for plugin in payload["plugins"]:
171+
lines.append(f" - {plugin['name']}")
172+
if payload["failures"]:
173+
lines.append("Failures:")
174+
for failure in payload["failures"]:
175+
lines.append(f" - {failure}")
176+
lines.append(f"Result: {'OK' if payload['ok'] else 'FAILED'}")
177+
return "\n".join(lines)
178+
179+
180+
def package_external_plugin_path(path: str, output_path: str, *, force: bool = False) -> Path:
181+
"""Package external plugin sources and metadata into a ZIP archive."""
182+
source = Path(path).expanduser().resolve()
183+
output = Path(output_path).expanduser().resolve()
184+
if output.exists() and not force:
185+
raise PluginRegistryError(f"package already exists: {output}")
186+
187+
payload = check_external_plugin_path(str(source))
188+
if not payload["ok"]:
189+
failures = "; ".join(str(item) for item in payload["failures"])
190+
raise PluginRegistryError(f"external plugin check failed: {failures}")
191+
192+
if source.is_file():
193+
files = [source]
194+
root = source.parent
195+
elif source.is_dir():
196+
root = source
197+
files = sorted(
198+
item for item in source.rglob("*")
199+
if item.is_file()
200+
and "__pycache__" not in item.parts
201+
and not item.name.endswith((".pyc", ".pyo"))
202+
)
203+
else:
204+
raise PluginRegistryError(f"plugin path not found: {source}")
205+
206+
output.parent.mkdir(parents=True, exist_ok=True)
207+
manifest = {
208+
"format": "automax-external-plugin-package-v1",
209+
"source": str(source),
210+
"plugin_count": payload["checked"],
211+
"plugins": [plugin["name"] for plugin in payload["plugins"]],
212+
}
213+
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_DEFLATED) as archive:
214+
archive.writestr("automax-plugin.json", json.dumps(manifest, indent=2, sort_keys=True) + "\n")
215+
for item in files:
216+
archive.write(item, item.relative_to(root).as_posix())
217+
return output

tests/test_documentation_and_regressions.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,23 @@ def test_plugins_audit_command_reports_builtin_readiness():
460460
)
461461
assert "automax plugins audit" in docs
462462

463+
def test_external_plugin_sdk_commands_are_documented():
464+
docs = "\n".join(
465+
Path(path).read_text(encoding="utf-8")
466+
for path in [
467+
"README.md",
468+
"docs/reference/cli.md",
469+
"docs/guides/creating-plugins.md",
470+
"docs/concepts/plugin-system.md",
471+
]
472+
)
473+
474+
assert "automax plugins init" in docs
475+
assert "automax plugins check" in docs
476+
assert "automax plugins package" in docs
477+
assert "--plugin-path" in docs
478+
479+
463480
def test_plugins_coverage_command_reports_matrix_and_quality_gates(tmp_path: Path):
464481
runner = CliRunner()
465482
result = runner.invoke(cli, ["plugins", "coverage", "--strict"])

0 commit comments

Comments
 (0)