feat(plugins): discover packages and persist state
This commit is contained in:
106
app-instance/backend/beaver/plugins/manifest.py
Normal file
106
app-instance/backend/beaver/plugins/manifest.py
Normal file
@ -0,0 +1,106 @@
|
||||
"""Strict manifest parsing for declarative skill plugins."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .models import PluginManifest, PluginSkillDeclaration
|
||||
|
||||
IDENTIFIER_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
|
||||
|
||||
|
||||
def load_plugin_manifest(path: str | Path, *, workspace: str | Path | None = None) -> PluginManifest:
|
||||
manifest_path = Path(path)
|
||||
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Plugin manifest must be a JSON object")
|
||||
|
||||
schema_version = int(payload.get("schema_version", 0) or 0)
|
||||
if schema_version != 1:
|
||||
raise ValueError(f"Unsupported plugin manifest schema version: {schema_version}")
|
||||
|
||||
plugin_id = _require_identifier(payload.get("id"), field="id")
|
||||
name = _require_string(payload.get("name"), field="name")
|
||||
version = _require_string(payload.get("version"), field="version")
|
||||
root = manifest_path.parent.resolve()
|
||||
raw_skills = payload.get("skills")
|
||||
if not isinstance(raw_skills, list) or not raw_skills:
|
||||
raise ValueError("Plugin manifest must declare at least one skill")
|
||||
|
||||
skills: list[PluginSkillDeclaration] = []
|
||||
seen_names: set[str] = set()
|
||||
for item in raw_skills:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError("Plugin skill declarations must be JSON objects")
|
||||
skill_name = _require_identifier(item.get("name"), field="skill name")
|
||||
if skill_name in seen_names:
|
||||
raise ValueError(f"Plugin manifest contains duplicate skill name: {skill_name}")
|
||||
seen_names.add(skill_name)
|
||||
relative_path = _require_string(item.get("path"), field=f"{skill_name}.path")
|
||||
_reject_symlink_path(root, Path(relative_path))
|
||||
skill_root = _resolve_contained_path(root, relative_path)
|
||||
skill_file = skill_root / "SKILL.md"
|
||||
if not skill_file.is_file() or skill_file.is_symlink():
|
||||
raise ValueError(f"Plugin skill {skill_name} must contain a regular SKILL.md")
|
||||
skills.append(PluginSkillDeclaration(name=skill_name, relative_path=relative_path, root=skill_root))
|
||||
|
||||
return PluginManifest(
|
||||
schema_version=schema_version,
|
||||
plugin_id=plugin_id,
|
||||
name=name,
|
||||
version=version,
|
||||
root=root,
|
||||
manifest_path=manifest_path.resolve(),
|
||||
display_path=_display_path(manifest_path, workspace=workspace),
|
||||
skills=tuple(skills),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_contained_path(root: Path, raw_path: str) -> Path:
|
||||
relative = Path(raw_path)
|
||||
if relative.is_absolute():
|
||||
raise ValueError("Plugin skill path must be contained within the plugin root")
|
||||
resolved = (root / relative).resolve()
|
||||
if not resolved.is_relative_to(root):
|
||||
raise ValueError("Plugin skill path must be contained within the plugin root")
|
||||
return resolved
|
||||
|
||||
|
||||
def _reject_symlink_path(root: Path, relative: Path) -> None:
|
||||
current = root
|
||||
for part in relative.parts:
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
raise ValueError(f"Plugin skill path contains a symlink: {current}")
|
||||
|
||||
|
||||
def _display_path(path: Path, *, workspace: str | Path | None) -> str:
|
||||
resolved = path.resolve()
|
||||
if workspace is not None:
|
||||
workspace_root = Path(workspace).resolve()
|
||||
if resolved.is_relative_to(workspace_root):
|
||||
return resolved.relative_to(workspace_root).as_posix()
|
||||
return f"<external>/{resolved.parent.name}/{resolved.name}"
|
||||
parent = resolved.parent.parent
|
||||
if resolved.is_relative_to(parent):
|
||||
return resolved.relative_to(parent).as_posix()
|
||||
return resolved.name
|
||||
|
||||
|
||||
def _require_identifier(value: Any, *, field: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not IDENTIFIER_PATTERN.fullmatch(text):
|
||||
raise ValueError(f"Invalid plugin identifier for {field}: {text!r}")
|
||||
return text
|
||||
|
||||
|
||||
def _require_string(value: Any, *, field: str) -> str:
|
||||
if value is None:
|
||||
raise ValueError(f"Plugin manifest field is required: {field}")
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
raise ValueError(f"Plugin manifest field cannot be empty: {field}")
|
||||
return text
|
||||
Reference in New Issue
Block a user