Files
basicmachines-co-basic-memory/scripts/validate_codex_plugin.py
T
2026-06-04 23:14:07 -05:00

145 lines
5.3 KiB
Python
Executable File

#!/usr/bin/env python3
"""Validate the Basic Memory Codex plugin layout."""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
from typing import Any
from validate_skills import parse_frontmatter
REQUIRED_SKILLS = (
"bm-setup",
"bm-orient",
"bm-checkpoint",
"bm-decide",
"bm-remember",
"bm-share",
"bm-status",
)
REQUIRED_SCHEMAS = ("codex-session.md", "decision.md", "task.md")
REQUIRED_HOOK_EVENTS = ("SessionStart", "PreCompact")
REQUIRED_HOOK_SCRIPTS = (
"hooks/session-start.sh",
"hooks/session-start.py",
"hooks/pre-compact.sh",
"hooks/pre-compact.py",
)
REQUIRED_SKILL_AGENT_FILES = ("agents/openai.yaml", "assets/icon.svg")
REQUIRED_INTERFACE_ASSETS = {
"composerIcon": "assets/app-icon.png",
"logo": "assets/logo.png",
}
def read_json(path: Path) -> dict[str, Any]:
try:
payload = json.loads(path.read_text())
except FileNotFoundError:
raise SystemExit(f"Missing JSON file: {path}") from None
except json.JSONDecodeError as exc:
raise SystemExit(f"{path}: invalid JSON: {exc}") from None
if not isinstance(payload, dict):
raise SystemExit(f"{path}: expected a JSON object")
return payload
def require_path(path: Path, label: str) -> None:
if not path.exists():
raise SystemExit(f"Missing {label}: {path}")
def validate_plugin(plugin_dir: Path) -> None:
plugin_dir = plugin_dir.resolve()
# --- Manifest ---
manifest_path = plugin_dir / ".codex-plugin" / "plugin.json"
manifest = read_json(manifest_path)
if manifest.get("name") != "codex":
raise SystemExit(f"{manifest_path}: expected name=codex")
if manifest.get("skills") != "./skills/":
raise SystemExit(f"{manifest_path}: expected skills=./skills/")
if manifest.get("mcpServers") != "./.mcp.json":
raise SystemExit(f"{manifest_path}: expected mcpServers=./.mcp.json")
interface = manifest.get("interface")
if not isinstance(interface, dict):
raise SystemExit(f"{manifest_path}: missing interface object")
if interface.get("displayName") != "Basic Memory for Codex":
raise SystemExit(f"{manifest_path}: unexpected interface.displayName")
for field, expected_path in REQUIRED_INTERFACE_ASSETS.items():
if interface.get(field) != f"./{expected_path}":
raise SystemExit(f"{manifest_path}: expected interface.{field}=./{expected_path}")
require_path(plugin_dir / expected_path, f"interface.{field} asset")
# --- MCP ---
mcp = read_json(plugin_dir / ".mcp.json")
servers = mcp.get("mcpServers")
if not isinstance(servers, dict) or "basic-memory" not in servers:
raise SystemExit(".mcp.json: expected mcpServers.basic-memory")
basic_memory = servers["basic-memory"]
if not isinstance(basic_memory, dict):
raise SystemExit(".mcp.json: basic-memory server must be an object")
if basic_memory.get("command") not in {"uvx", "basic-memory", "bm"}:
raise SystemExit(".mcp.json: basic-memory server uses an unexpected command")
# --- Hooks ---
hooks_json = read_json(plugin_dir / "hooks" / "hooks.json")
hooks = hooks_json.get("hooks")
if not isinstance(hooks, dict):
raise SystemExit("hooks/hooks.json: expected hooks object")
for event in REQUIRED_HOOK_EVENTS:
if event not in hooks:
raise SystemExit(f"hooks/hooks.json: missing {event}")
for rel in REQUIRED_HOOK_SCRIPTS:
script = plugin_dir / rel
require_path(script, "hook script")
if not os.access(script, os.X_OK):
raise SystemExit(f"Hook script is not executable: {script}")
# --- Skills ---
skills_root = plugin_dir / "skills"
require_path(skills_root, "skills directory")
present = {path.name for path in skills_root.iterdir() if path.is_dir()}
for skill_name in REQUIRED_SKILLS:
if skill_name not in present:
raise SystemExit(f"Missing required skill: skills/{skill_name}/SKILL.md")
for skill_dir in sorted(path for path in skills_root.iterdir() if path.is_dir()):
skill_file = skill_dir / "SKILL.md"
require_path(skill_file, "skill file")
frontmatter = parse_frontmatter(skill_file)
if frontmatter.get("name") != skill_dir.name:
raise SystemExit(f"{skill_file}: name must match directory")
if not frontmatter.get("description"):
raise SystemExit(f"{skill_file}: missing description")
for rel in REQUIRED_SKILL_AGENT_FILES:
require_path(skill_dir / rel, f"skill {rel}")
# --- Schemas ---
schemas_root = plugin_dir / "schemas"
require_path(schemas_root, "schemas directory")
for schema_name in REQUIRED_SCHEMAS:
schema_file = schemas_root / schema_name
require_path(schema_file, "schema")
frontmatter = parse_frontmatter(schema_file)
if frontmatter.get("type") != "schema":
raise SystemExit(f"{schema_file}: expected type: schema")
if not frontmatter.get("entity"):
raise SystemExit(f"{schema_file}: missing entity")
print(f"validated Codex plugin in {plugin_dir}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("plugin_dir", nargs="?", default="plugins/codex")
args = parser.parse_args()
validate_plugin(Path.cwd() / args.plugin_dir)
if __name__ == "__main__":
main()