#!/usr/bin/env python3 """BMad manifest CRUD and validation. All manifest operations go through this script. Validation runs automatically on every write. Prompts call this instead of touching JSON directly. Usage: python3 scripts/manifest.py create [options] python3 scripts/manifest.py add-capability [options] python3 scripts/manifest.py update --set key=value [...] python3 scripts/manifest.py remove-capability --name python3 scripts/manifest.py read [--capabilities|--capability ] python3 scripts/manifest.py validate """ # /// script # requires-python = ">=3.9" # dependencies = [ # "jsonschema>=4.0.0", # ] # /// from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any try: from jsonschema import Draft7Validator except ImportError: print("Error: jsonschema required. Run with: uv run scripts/manifest.py (PEP 723 handles deps)", file=sys.stderr) sys.exit(2) MANIFEST_FILENAME = "bmad-manifest.json" SCHEMA_FILENAME = "bmad-manifest-schema.json" def get_schema_path() -> Path: """Schema is co-located with this script.""" return Path(__file__).parent / SCHEMA_FILENAME def get_manifest_path(skill_path: Path) -> Path: return skill_path / MANIFEST_FILENAME def load_schema() -> dict[str, Any]: path = get_schema_path() if not path.exists(): print(f"Error: Schema not found: {path}", file=sys.stderr) sys.exit(2) with path.open() as f: return json.load(f) def load_manifest(skill_path: Path) -> dict[str, Any]: path = get_manifest_path(skill_path) if not path.exists(): return {} with path.open() as f: try: return json.load(f) except json.JSONDecodeError as e: print(f"Error: Invalid JSON in {path}: {e}", file=sys.stderr) sys.exit(2) def save_manifest(skill_path: Path, data: dict[str, Any]) -> bool: """Save manifest after validation. Returns True if valid and saved.""" errors = validate(data) if errors: print(f"Validation failed with {len(errors)} error(s):", file=sys.stderr) for err in errors: print(f" [{err['path']}] {err['message']}", file=sys.stderr) return False path = get_manifest_path(skill_path) path.parent.mkdir(parents=True, exist_ok=True) with path.open("w") as f: json.dump(data, f, indent=2) f.write("\n") return True def validate(data: dict[str, Any]) -> list[dict[str, Any]]: """Validate manifest against schema. Returns list of errors.""" schema = load_schema() validator = Draft7Validator(schema) errors = [] for error in validator.iter_errors(data): errors.append({ "path": ".".join(str(p) for p in error.path) if error.path else "root", "message": error.message, }) return errors def validate_extras(data: dict[str, Any]) -> list[str]: """Additional checks beyond schema validation.""" warnings = [] capabilities = data.get("capabilities", []) if not capabilities: warnings.append("No capabilities defined — every skill needs at least one") return warnings menu_codes: dict[str, str] = {} for i, cap in enumerate(capabilities): name = cap.get("name", f"") # Duplicate menu-code check mc = cap.get("menu-code", "") if mc and mc in menu_codes: warnings.append(f"Duplicate menu-code '{mc}' in '{menu_codes[mc]}' and '{name}'") elif mc: menu_codes[mc] = name # Both prompt and skill-name if "prompt" in cap and "skill-name" in cap: warnings.append(f"Capability '{name}' has both 'prompt' and 'skill-name' — pick one") return warnings # --- Commands --- def cmd_create(args: argparse.Namespace) -> int: skill_path = Path(args.skill_path).resolve() existing = load_manifest(skill_path) if existing: print(f"Error: Manifest already exists at {get_manifest_path(skill_path)}", file=sys.stderr) print("Use 'update' to modify or delete the file first.", file=sys.stderr) return 1 data: dict[str, Any] = {} if args.module_code: data["module-code"] = args.module_code if args.replaces_skill: data["replaces-skill"] = args.replaces_skill if args.persona: data["persona"] = args.persona if args.has_memory: data["has-memory"] = True data["capabilities"] = [] if save_manifest(skill_path, data): print(f"Created {get_manifest_path(skill_path)}") return 0 return 1 def cmd_add_capability(args: argparse.Namespace) -> int: skill_path = Path(args.skill_path).resolve() data = load_manifest(skill_path) if not data: print("Error: No manifest found. Run 'create' first.", file=sys.stderr) return 1 capabilities = data.setdefault("capabilities", []) # Check for duplicate name for cap in capabilities: if cap.get("name") == args.name: print(f"Error: Capability '{args.name}' already exists. Use 'update' to modify.", file=sys.stderr) return 1 cap: dict[str, Any] = { "name": args.name, "menu-code": args.menu_code, "description": args.description, } if args.supports_autonomous: cap["supports-headless"] = True if args.prompt: cap["prompt"] = args.prompt if args.skill_name: cap["skill-name"] = args.skill_name if args.phase_name: cap["phase-name"] = args.phase_name if args.after: cap["after"] = args.after if args.before: cap["before"] = args.before if args.is_required: cap["is-required"] = True if args.output_location: cap["output-location"] = args.output_location capabilities.append(cap) if save_manifest(skill_path, data): print(f"Added capability '{args.name}' [{args.menu_code}]") return 0 return 1 def cmd_update(args: argparse.Namespace) -> int: skill_path = Path(args.skill_path).resolve() data = load_manifest(skill_path) if not data: print("Error: No manifest found. Run 'create' first.", file=sys.stderr) return 1 # Parse --set key=value pairs for pair in args.set: if "=" not in pair: print(f"Error: Invalid --set format '{pair}'. Use key=value.", file=sys.stderr) return 1 key, value = pair.split("=", 1) # Handle boolean values if value.lower() == "true": value = True elif value.lower() == "false": value = False # Handle capability updates: capability.name.field=value if key.startswith("capability."): parts = key.split(".", 2) if len(parts) != 3: print("Error: Capability update format: capability..=", file=sys.stderr) return 1 cap_name, field = parts[1], parts[2] found = False for cap in data.get("capabilities", []): if cap.get("name") == cap_name: cap[field] = value found = True break if not found: print(f"Error: Capability '{cap_name}' not found.", file=sys.stderr) return 1 else: # Handle removing fields with empty value if value == "": data.pop(key, None) else: data[key] = value if save_manifest(skill_path, data): print(f"Updated {get_manifest_path(skill_path)}") return 0 return 1 def cmd_remove_capability(args: argparse.Namespace) -> int: skill_path = Path(args.skill_path).resolve() data = load_manifest(skill_path) if not data: print("Error: No manifest found.", file=sys.stderr) return 1 capabilities = data.get("capabilities", []) original_len = len(capabilities) data["capabilities"] = [c for c in capabilities if c.get("name") != args.name] if len(data["capabilities"]) == original_len: print(f"Error: Capability '{args.name}' not found.", file=sys.stderr) return 1 if save_manifest(skill_path, data): print(f"Removed capability '{args.name}'") return 0 return 1 def cmd_read(args: argparse.Namespace) -> int: skill_path = Path(args.skill_path).resolve() data = load_manifest(skill_path) if not data: print("Error: No manifest found.", file=sys.stderr) return 1 if args.capabilities: caps = data.get("capabilities", []) if args.json: print(json.dumps(caps, indent=2)) else: for cap in caps: prompt_or_skill = cap.get("prompt", cap.get("skill-name", "(SKILL.md)")) auto = " [autonomous]" if cap.get("supports-headless") else "" print(f" [{cap.get('menu-code', '??')}] {cap['name']} — {cap.get('description', '')}{auto}") print(f" → {prompt_or_skill}") return 0 if args.capability: for cap in data.get("capabilities", []): if cap.get("name") == args.capability: print(json.dumps(cap, indent=2)) return 0 print(f"Error: Capability '{args.capability}' not found.", file=sys.stderr) return 1 if args.json: print(json.dumps(data, indent=2)) else: # Summary view is_agent = "persona" in data print(f"Type: {'Agent' if is_agent else 'Workflow/Skill'}") if data.get("module-code"): print(f"Module: {data['module-code']}") if is_agent: print(f"Persona: {data['persona'][:80]}...") if data.get("has-memory"): print("Memory: enabled") caps = data.get("capabilities", []) print(f"Capabilities: {len(caps)}") for cap in caps: prompt_or_skill = cap.get("prompt", cap.get("skill-name", "(SKILL.md)")) auto = " [autonomous]" if cap.get("supports-headless") else "" print(f" [{cap.get('menu-code', '??')}] {cap['name']}{auto} → {prompt_or_skill}") return 0 def cmd_validate(args: argparse.Namespace) -> int: skill_path = Path(args.skill_path).resolve() data = load_manifest(skill_path) if not data: print("Error: No manifest found.", file=sys.stderr) return 1 errors = validate(data) warnings = validate_extras(data) if args.json: print(json.dumps({ "valid": len(errors) == 0, "errors": errors, "warnings": warnings, }, indent=2)) else: if not errors: print("✓ Manifest is valid") else: print(f"✗ {len(errors)} error(s):", file=sys.stderr) for err in errors: print(f" [{err['path']}] {err['message']}", file=sys.stderr) if warnings: print(f"\n⚠ {len(warnings)} warning(s):", file=sys.stderr) for w in warnings: print(f" {w}", file=sys.stderr) return 0 if not errors else 1 def main() -> int: parser = argparse.ArgumentParser( description="BMad manifest CRUD and validation", formatter_class=argparse.RawDescriptionHelpFormatter, ) sub = parser.add_subparsers(dest="command", required=True) # create p_create = sub.add_parser("create", help="Create a new manifest") p_create.add_argument("skill_path", type=str, help="Path to skill directory") p_create.add_argument("--module-code", type=str) p_create.add_argument("--replaces-skill", type=str) p_create.add_argument("--persona", type=str) p_create.add_argument("--has-memory", action="store_true") # add-capability p_add = sub.add_parser("add-capability", help="Add a capability") p_add.add_argument("skill_path", type=str, help="Path to skill directory") p_add.add_argument("--name", required=True, type=str) p_add.add_argument("--menu-code", required=True, type=str) p_add.add_argument("--description", required=True, type=str) p_add.add_argument("--supports-autonomous", action="store_true") p_add.add_argument("--prompt", type=str, help="Relative path to prompt file") p_add.add_argument("--skill-name", type=str, help="External skill name") p_add.add_argument("--phase-name", type=str) p_add.add_argument("--after", nargs="*", help="Skill names that should run before this") p_add.add_argument("--before", nargs="*", help="Skill names this should run before") p_add.add_argument("--is-required", action="store_true") p_add.add_argument("--output-location", type=str) # update p_update = sub.add_parser("update", help="Update manifest fields") p_update.add_argument("skill_path", type=str, help="Path to skill directory") p_update.add_argument("--set", nargs="+", required=True, help="key=value pairs") # remove-capability p_remove = sub.add_parser("remove-capability", help="Remove a capability") p_remove.add_argument("skill_path", type=str, help="Path to skill directory") p_remove.add_argument("--name", required=True, type=str) # read p_read = sub.add_parser("read", help="Read manifest") p_read.add_argument("skill_path", type=str, help="Path to skill directory") p_read.add_argument("--capabilities", action="store_true", help="List capabilities only") p_read.add_argument("--capability", type=str, help="Show specific capability") p_read.add_argument("--json", action="store_true", help="JSON output") # validate p_validate = sub.add_parser("validate", help="Validate manifest") p_validate.add_argument("skill_path", type=str, help="Path to skill directory") p_validate.add_argument("--json", action="store_true", help="JSON output") args = parser.parse_args() commands = { "create": cmd_create, "add-capability": cmd_add_capability, "update": cmd_update, "remove-capability": cmd_remove_capability, "read": cmd_read, "validate": cmd_validate, } return commands[args.command](args) if __name__ == "__main__": sys.exit(main())