107 lines
4.1 KiB
Python
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
|