Files
beaver_project/app-instance/backend/beaver/plugins/manifest.py

107 lines
4.1 KiB
Python

"""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