mirror of
https://github.com/basicmachines-co/basic-memory
synced 2026-06-21 13:47:35 +00:00
5973f9b787
Signed-off-by: phernandez <paul@basicmachines.co>
145 lines
5.3 KiB
Python
Executable File
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()
|