Initial commit

This commit is contained in:
Zhongwei Li
2025-11-29 18:26:08 +08:00
commit 8f22ddf339
295 changed files with 59710 additions and 0 deletions

323
skills/plugin.sync/plugin_sync.py Executable file
View 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()