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