Initial commit
This commit is contained in:
323
skills/plugin.sync/plugin_sync.py
Executable file
323
skills/plugin.sync/plugin_sync.py
Executable file
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
plugin_sync.py – Implementation of the plugin.sync Skill
|
||||
Generates plugin.yaml from registry files (skills.json, commands.json, hooks.json).
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import yaml
|
||||
from typing import Dict, Any, List, Optional
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
from betty.config import BASE_DIR
|
||||
from betty.logging_utils import setup_logger
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
|
||||
def load_registry_file(registry_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Load a JSON registry file.
|
||||
|
||||
Args:
|
||||
registry_path: Path to the registry JSON file
|
||||
|
||||
Returns:
|
||||
Parsed registry data
|
||||
|
||||
Raises:
|
||||
FileNotFoundError: If registry file doesn't exist
|
||||
json.JSONDecodeError: If JSON is invalid
|
||||
"""
|
||||
try:
|
||||
with open(registry_path) as f:
|
||||
return json.load(f)
|
||||
except FileNotFoundError:
|
||||
logger.warning(f"Registry file not found: {registry_path}")
|
||||
return {}
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse JSON from {registry_path}: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def check_handler_exists(handler_path: str, skill_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Check if a handler file exists on disk.
|
||||
|
||||
Args:
|
||||
handler_path: Relative path to handler file
|
||||
skill_name: Name of the skill
|
||||
|
||||
Returns:
|
||||
Dictionary with exists flag and full path
|
||||
"""
|
||||
full_path = os.path.join(BASE_DIR, "skills", skill_name, handler_path)
|
||||
exists = os.path.exists(full_path)
|
||||
|
||||
return {
|
||||
"exists": exists,
|
||||
"path": full_path,
|
||||
"relative_path": f"skills/{skill_name}/{handler_path}"
|
||||
}
|
||||
|
||||
|
||||
def convert_skill_to_command(skill: Dict[str, Any], entrypoint: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert a skill registry entry with entrypoint to a plugin.yaml command format.
|
||||
|
||||
Args:
|
||||
skill: Skill entry from skills.json
|
||||
entrypoint: Entrypoint entry from the skill
|
||||
|
||||
Returns:
|
||||
Command dictionary in plugin.yaml format
|
||||
"""
|
||||
# Extract command name (remove leading slash if present)
|
||||
command_name = entrypoint.get("command", "").lstrip("/")
|
||||
|
||||
# Build handler section
|
||||
handler_path = f"skills/{skill['name']}/{entrypoint.get('handler', '')}"
|
||||
|
||||
command = {
|
||||
"name": command_name,
|
||||
"description": entrypoint.get("description") or skill.get("description", ""),
|
||||
"handler": {
|
||||
"runtime": entrypoint.get("runtime", "python"),
|
||||
"script": handler_path
|
||||
}
|
||||
}
|
||||
|
||||
# Add parameters from entrypoint or extract from skill inputs
|
||||
if "parameters" in entrypoint and entrypoint["parameters"]:
|
||||
# Use parameters from entrypoint if they exist
|
||||
command["parameters"] = entrypoint["parameters"]
|
||||
elif "inputs" in skill and skill["inputs"]:
|
||||
# Otherwise, convert skill inputs to command parameters
|
||||
parameters = []
|
||||
for inp in skill["inputs"]:
|
||||
if isinstance(inp, dict):
|
||||
# Full input specification
|
||||
param = {
|
||||
"name": inp.get("name", ""),
|
||||
"type": inp.get("type", "string"),
|
||||
"required": inp.get("required", False),
|
||||
"description": inp.get("description", "")
|
||||
}
|
||||
# Add default if present
|
||||
if "default" in inp:
|
||||
param["default"] = inp["default"]
|
||||
parameters.append(param)
|
||||
elif isinstance(inp, str):
|
||||
# Simple string input (legacy format)
|
||||
parameters.append({
|
||||
"name": inp,
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": ""
|
||||
})
|
||||
if parameters:
|
||||
command["parameters"] = parameters
|
||||
|
||||
# Add permissions if present
|
||||
if "permissions" in entrypoint:
|
||||
# Convert permissions list to proper format if needed
|
||||
permissions = entrypoint["permissions"]
|
||||
if isinstance(permissions, list):
|
||||
command["permissions"] = permissions
|
||||
|
||||
return command
|
||||
|
||||
|
||||
def generate_plugin_yaml(
|
||||
skills_data: Dict[str, Any],
|
||||
commands_data: Dict[str, Any],
|
||||
hooks_data: Dict[str, Any]
|
||||
) -> tuple[Dict[str, Any], List[str]]:
|
||||
"""
|
||||
Generate plugin.yaml content from registry data.
|
||||
|
||||
Args:
|
||||
skills_data: Parsed skills.json
|
||||
commands_data: Parsed commands.json
|
||||
hooks_data: Parsed hooks.json
|
||||
|
||||
Returns:
|
||||
Tuple of (plugin_yaml_dict, list of warnings)
|
||||
"""
|
||||
warnings = []
|
||||
commands = []
|
||||
|
||||
# Load existing plugin.yaml to preserve header content
|
||||
plugin_yaml_path = os.path.join(BASE_DIR, "plugin.yaml")
|
||||
base_config = {}
|
||||
|
||||
try:
|
||||
with open(plugin_yaml_path) as f:
|
||||
base_config = yaml.safe_load(f) or {}
|
||||
logger.info(f"Loaded existing plugin.yaml as template")
|
||||
except FileNotFoundError:
|
||||
logger.warning("No existing plugin.yaml found, creating from scratch")
|
||||
base_config = {
|
||||
"name": "betty-framework",
|
||||
"version": "1.0.0",
|
||||
"description": "Betty Framework - Structured AI-assisted engineering",
|
||||
"author": {
|
||||
"name": "RiskExec",
|
||||
"email": "platform@riskexec.com",
|
||||
"url": "https://github.com/epieczko/betty"
|
||||
},
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
# Process active skills with entrypoints
|
||||
skills = skills_data.get("skills", [])
|
||||
for skill in skills:
|
||||
if skill.get("status") != "active":
|
||||
continue
|
||||
|
||||
entrypoints = skill.get("entrypoints", [])
|
||||
if not entrypoints:
|
||||
continue
|
||||
|
||||
skill_name = skill.get("name")
|
||||
|
||||
for entrypoint in entrypoints:
|
||||
handler = entrypoint.get("handler")
|
||||
if not handler:
|
||||
warnings.append(f"Skill '{skill_name}' has entrypoint without handler")
|
||||
continue
|
||||
|
||||
# Check if handler exists on disk
|
||||
handler_check = check_handler_exists(handler, skill_name)
|
||||
if not handler_check["exists"]:
|
||||
warnings.append(
|
||||
f"Handler not found for '{skill_name}': {handler_check['relative_path']}"
|
||||
)
|
||||
|
||||
# Convert to plugin command format
|
||||
command = convert_skill_to_command(skill, entrypoint)
|
||||
commands.append(command)
|
||||
|
||||
logger.info(f"Added command: /{command['name']} from skill {skill_name}")
|
||||
|
||||
# Process commands from commands.json (if any need to be added)
|
||||
# Note: Most commands should already be represented via skills
|
||||
# This is mainly for custom commands that don't map to skills
|
||||
registry_commands = commands_data.get("commands", [])
|
||||
for cmd in registry_commands:
|
||||
if cmd.get("status") == "active":
|
||||
# Check if this command is already in our list
|
||||
cmd_name = cmd.get("name", "").lstrip("/")
|
||||
if not any(c["name"] == cmd_name for c in commands):
|
||||
logger.info(f"Command '{cmd_name}' in registry but no matching active skill found")
|
||||
|
||||
# Build final plugin.yaml structure
|
||||
plugin_config = {
|
||||
**base_config,
|
||||
"commands": commands
|
||||
}
|
||||
|
||||
# Override plugin-level permissions (remove network:none contradiction)
|
||||
plugin_config["permissions"] = [
|
||||
"filesystem:read",
|
||||
"filesystem:write",
|
||||
"process:execute"
|
||||
]
|
||||
|
||||
# Add metadata about generation
|
||||
if "metadata" not in plugin_config:
|
||||
plugin_config["metadata"] = {}
|
||||
|
||||
plugin_config["metadata"]["generated_at"] = datetime.now(timezone.utc).isoformat()
|
||||
plugin_config["metadata"]["generated_by"] = "plugin.sync skill"
|
||||
plugin_config["metadata"]["skill_count"] = len([s for s in skills if s.get("status") == "active"])
|
||||
plugin_config["metadata"]["command_count"] = len(commands)
|
||||
|
||||
return plugin_config, warnings
|
||||
|
||||
|
||||
def write_plugin_yaml(plugin_config: Dict[str, Any], output_path: str):
|
||||
"""
|
||||
Write plugin.yaml to disk with proper formatting.
|
||||
|
||||
Args:
|
||||
plugin_config: Plugin configuration dictionary
|
||||
output_path: Path where to write plugin.yaml
|
||||
"""
|
||||
# Add header comment
|
||||
header = """# Betty Framework - Claude Code Plugin
|
||||
# Auto-generated by plugin.sync skill
|
||||
# DO NOT EDIT MANUALLY - Run plugin.sync to regenerate
|
||||
|
||||
"""
|
||||
|
||||
with open(output_path, 'w') as f:
|
||||
f.write(header)
|
||||
yaml.dump(plugin_config, f, default_flow_style=False, sort_keys=False, indent=2)
|
||||
|
||||
logger.info(f"✅ Written plugin.yaml to {output_path}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main CLI entry point."""
|
||||
logger.info("Starting plugin.yaml generation from registries...")
|
||||
|
||||
# Define registry paths
|
||||
skills_path = os.path.join(BASE_DIR, "registry", "skills.json")
|
||||
commands_path = os.path.join(BASE_DIR, "registry", "commands.json")
|
||||
hooks_path = os.path.join(BASE_DIR, "registry", "hooks.json")
|
||||
|
||||
try:
|
||||
# Load registry files
|
||||
logger.info("Loading registry files...")
|
||||
skills_data = load_registry_file(skills_path)
|
||||
commands_data = load_registry_file(commands_path)
|
||||
hooks_data = load_registry_file(hooks_path)
|
||||
|
||||
# Generate plugin.yaml content
|
||||
logger.info("Generating plugin.yaml configuration...")
|
||||
plugin_config, warnings = generate_plugin_yaml(skills_data, commands_data, hooks_data)
|
||||
|
||||
# Write to file
|
||||
output_path = os.path.join(BASE_DIR, "plugin.yaml")
|
||||
write_plugin_yaml(plugin_config, output_path)
|
||||
|
||||
# Report results
|
||||
result = {
|
||||
"ok": True,
|
||||
"status": "success",
|
||||
"output_path": output_path,
|
||||
"commands_generated": len(plugin_config.get("commands", [])),
|
||||
"warnings": warnings
|
||||
}
|
||||
|
||||
# Print warnings if any
|
||||
if warnings:
|
||||
logger.warning("⚠️ Warnings during generation:")
|
||||
for warning in warnings:
|
||||
logger.warning(f" - {warning}")
|
||||
|
||||
# Print summary
|
||||
logger.info(f"✅ Generated {result['commands_generated']} commands")
|
||||
logger.info(f"📄 Output: {output_path}")
|
||||
|
||||
print(json.dumps(result, indent=2))
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate plugin.yaml: {e}")
|
||||
result = {
|
||||
"ok": False,
|
||||
"status": "failed",
|
||||
"error": str(e)
|
||||
}
|
||||
print(json.dumps(result, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user