Files
gh-epieczko-betty/skills/registry.query/registry_query.py
2025-11-29 18:26:08 +08:00

736 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
"""
registry_query.py - Implementation of the registry.query Skill
Search Betty registries programmatically by filtering skills, agents, and commands.
Supports filtering by tags, domain, status, name, version, and capability.
"""
import os
import sys
import json
import re
from typing import Dict, Any, List, Optional, Set
from datetime import datetime, timezone
from pathlib import Path
from betty.config import (
REGISTRY_FILE,
AGENTS_REGISTRY_FILE,
COMMANDS_REGISTRY_FILE,
HOOKS_REGISTRY_FILE,
BASE_DIR
)
from betty.logging_utils import setup_logger
from betty.errors import BettyError
logger = setup_logger(__name__)
def build_response(
ok: bool,
errors: Optional[List[str]] = None,
details: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Build standardized response.
Args:
ok: Whether the operation was successful
errors: List of error messages
details: Additional details to include
Returns:
Standardized response dictionary
"""
response: Dict[str, Any] = {
"ok": ok,
"status": "success" if ok else "failed",
"errors": errors or [],
"timestamp": datetime.now(timezone.utc).isoformat()
}
if details is not None:
response["details"] = details
return response
def load_registry(registry_type: str) -> Dict[str, Any]:
"""
Load a registry file.
Args:
registry_type: Type of registry ('skills', 'agents', 'commands')
Returns:
Registry data dictionary
Raises:
BettyError: If registry cannot be loaded
"""
registry_paths = {
'skills': REGISTRY_FILE,
'agents': AGENTS_REGISTRY_FILE,
'commands': COMMANDS_REGISTRY_FILE,
'hooks': HOOKS_REGISTRY_FILE
}
if registry_type not in registry_paths:
raise BettyError(
f"Invalid registry type: {registry_type}",
details={
"valid_types": list(registry_paths.keys()),
"provided": registry_type
}
)
registry_path = registry_paths[registry_type]
if not os.path.exists(registry_path):
logger.warning(f"Registry not found: {registry_path}")
return {
"version": "1.0.0",
"generated_at": datetime.now(timezone.utc).isoformat(),
registry_type: []
}
try:
with open(registry_path) as f:
data = json.load(f)
logger.debug(f"Loaded {registry_type} registry: {len(data.get(registry_type, []))} entries")
return data
except json.JSONDecodeError as e:
raise BettyError(f"Invalid JSON in {registry_type} registry: {e}")
except Exception as e:
raise BettyError(f"Failed to load {registry_type} registry: {e}")
def normalize_filter_value(value: Any) -> str:
"""
Normalize a filter value for case-insensitive comparison.
Args:
value: Value to normalize
Returns:
Normalized string value
"""
if value is None:
return ""
return str(value).lower().strip()
def matches_pattern(text: str, pattern: str, fuzzy: bool = False) -> bool:
"""
Check if text matches a pattern.
Args:
text: Text to match
pattern: Pattern to match against
fuzzy: Whether to use fuzzy matching
Returns:
True if text matches pattern
"""
text = normalize_filter_value(text)
pattern = normalize_filter_value(pattern)
if not pattern:
return True
if fuzzy:
# Fuzzy match: all characters of pattern appear in order in text
pattern_idx = 0
for char in text:
if pattern_idx < len(pattern) and char == pattern[pattern_idx]:
pattern_idx += 1
return pattern_idx == len(pattern)
else:
# Exact substring match
return pattern in text
def matches_tags(entry_tags: List[str], filter_tags: List[str]) -> bool:
"""
Check if entry tags match any of the filter tags.
Args:
entry_tags: Tags from the entry
filter_tags: Tags to filter by
Returns:
True if any filter tag is in entry tags
"""
if not filter_tags:
return True
if not entry_tags:
return False
entry_tags_normalized = [normalize_filter_value(tag) for tag in entry_tags]
filter_tags_normalized = [normalize_filter_value(tag) for tag in filter_tags]
return any(filter_tag in entry_tags_normalized for filter_tag in filter_tags_normalized)
def matches_capabilities(entry_capabilities: List[str], filter_capabilities: List[str], fuzzy: bool = False) -> bool:
"""
Check if entry capabilities match any of the filter capabilities.
Args:
entry_capabilities: Capabilities from the entry
filter_capabilities: Capabilities to filter by
fuzzy: Whether to use fuzzy matching
Returns:
True if any capability matches
"""
if not filter_capabilities:
return True
if not entry_capabilities:
return False
for filter_cap in filter_capabilities:
for entry_cap in entry_capabilities:
if matches_pattern(entry_cap, filter_cap, fuzzy):
return True
return False
def extract_key_metadata(entry: Dict[str, Any], registry_type: str) -> Dict[str, Any]:
"""
Extract key metadata from an entry based on registry type.
Args:
entry: Registry entry
registry_type: Type of registry
Returns:
Dictionary with key metadata
"""
metadata = {
"name": entry.get("name"),
"version": entry.get("version"),
"description": entry.get("description"),
"status": entry.get("status"),
"tags": entry.get("tags", [])
}
# Add registry-specific metadata
if registry_type == "skills":
# Handle inputs - can be strings or objects
inputs = entry.get("inputs", [])
formatted_inputs = []
for inp in inputs:
if isinstance(inp, str):
formatted_inputs.append({"name": inp, "type": "string", "required": False})
elif isinstance(inp, dict):
formatted_inputs.append({
"name": inp.get("name"),
"type": inp.get("type"),
"required": inp.get("required", False)
})
# Handle outputs - can be strings or objects
outputs = entry.get("outputs", [])
formatted_outputs = []
for out in outputs:
if isinstance(out, str):
formatted_outputs.append({"name": out, "type": "string"})
elif isinstance(out, dict):
formatted_outputs.append({
"name": out.get("name"),
"type": out.get("type")
})
metadata.update({
"dependencies": entry.get("dependencies", []),
"entrypoints": [
{
"command": ep.get("command"),
"runtime": ep.get("runtime"),
"description": ep.get("description")
}
for ep in entry.get("entrypoints", [])
],
"inputs": formatted_inputs,
"outputs": formatted_outputs
})
elif registry_type == "agents":
metadata.update({
"capabilities": entry.get("capabilities", []),
"skills_available": entry.get("skills_available", []),
"reasoning_mode": entry.get("reasoning_mode"),
"context_requirements": entry.get("context_requirements", {})
})
elif registry_type == "commands":
metadata.update({
"execution": entry.get("execution", {}),
"parameters": entry.get("parameters", [])
})
elif registry_type == "hooks":
metadata.update({
"event": entry.get("event"),
"command": entry.get("command"),
"enabled": entry.get("enabled", True)
})
return metadata
def filter_entries(
entries: List[Dict[str, Any]],
registry_type: str,
name: Optional[str] = None,
version: Optional[str] = None,
status: Optional[str] = None,
tags: Optional[List[str]] = None,
capability: Optional[str] = None,
domain: Optional[str] = None,
fuzzy: bool = False
) -> List[Dict[str, Any]]:
"""
Filter entries based on criteria.
Args:
entries: List of registry entries
registry_type: Type of registry
name: Filter by name (substring match)
version: Filter by version (exact match)
status: Filter by status (exact match)
tags: Filter by tags (any match)
capability: Filter by capability (for agents, substring match)
domain: Filter by domain/tag (alias for tags filter)
fuzzy: Use fuzzy matching for name and capability
Returns:
List of matching entries with key metadata
"""
results = []
# Convert domain to tags if provided
if domain:
tags = tags or []
if domain not in tags:
tags.append(domain)
logger.debug(f"Filtering {len(entries)} entries with criteria:")
logger.debug(f" name={name}, version={version}, status={status}")
logger.debug(f" tags={tags}, capability={capability}, fuzzy={fuzzy}")
for entry in entries:
# Filter by name
if name and not matches_pattern(entry.get("name", ""), name, fuzzy):
continue
# Filter by version (exact match)
if version and normalize_filter_value(entry.get("version")) != normalize_filter_value(version):
continue
# Filter by status (exact match)
if status and normalize_filter_value(entry.get("status")) != normalize_filter_value(status):
continue
# Filter by tags
if tags and not matches_tags(entry.get("tags", []), tags):
continue
# Filter by capability (agents only)
if capability:
if registry_type == "agents":
capabilities = entry.get("capabilities", [])
if not matches_capabilities(capabilities, [capability], fuzzy):
continue
else:
# For non-agents, skip capability filter
logger.debug(f"Capability filter only applies to agents, skipping for {registry_type}")
# Entry matches all criteria
metadata = extract_key_metadata(entry, registry_type)
results.append(metadata)
logger.info(f"Found {len(results)} matching entries")
return results
def query_registry(
registry: str,
name: Optional[str] = None,
version: Optional[str] = None,
status: Optional[str] = None,
tag: Optional[str] = None,
tags: Optional[List[str]] = None,
capability: Optional[str] = None,
domain: Optional[str] = None,
fuzzy: bool = False,
limit: Optional[int] = None
) -> Dict[str, Any]:
"""
Query a Betty registry with filters.
Args:
registry: Registry to query ('skills', 'agents', 'commands')
name: Filter by name
version: Filter by version
status: Filter by status
tag: Single tag filter (convenience parameter)
tags: List of tags to filter by
capability: Filter by capability (agents only)
domain: Domain/tag filter (alias for tags)
fuzzy: Use fuzzy matching
limit: Maximum number of results to return
Returns:
Query result with matching entries
"""
logger.info(f"Querying {registry} registry")
# Normalize registry type
registry = registry.lower()
if registry not in ['skills', 'agents', 'commands', 'hooks']:
raise BettyError(
f"Invalid registry: {registry}",
details={
"valid_registries": ["skills", "agents", "commands", "hooks"],
"provided": registry
}
)
# Load registry
registry_data = load_registry(registry)
entries = registry_data.get(registry, [])
# Merge tag and tags parameters
if tag:
tags = tags or []
if tag not in tags:
tags.append(tag)
# Filter entries
results = filter_entries(
entries,
registry,
name=name,
version=version,
status=status,
tags=tags,
capability=capability,
domain=domain,
fuzzy=fuzzy
)
# Apply limit
if limit and limit > 0:
results = results[:limit]
# Build response
return build_response(
ok=True,
details={
"registry": registry,
"query": {
"name": name,
"version": version,
"status": status,
"tags": tags,
"capability": capability,
"domain": domain,
"fuzzy": fuzzy,
"limit": limit
},
"total_entries": len(entries),
"matching_entries": len(results),
"results": results
}
)
def format_table(results: List[Dict[str, Any]], registry_type: str) -> str:
"""
Format results as an aligned table.
Args:
results: List of matching entries
registry_type: Type of registry
Returns:
Formatted table string
"""
if not results:
return "No matching entries found."
# Define columns based on registry type
if registry_type == "skills":
columns = ["Name", "Version", "Status", "Tags", "Commands"]
elif registry_type == "agents":
columns = ["Name", "Version", "Status", "Tags", "Reasoning", "Skills"]
elif registry_type == "commands":
columns = ["Name", "Version", "Status", "Tags", "Execution Type"]
elif registry_type == "hooks":
columns = ["Name", "Version", "Status", "Event", "Command", "Enabled"]
else:
columns = ["Name", "Version", "Status", "Description"]
# Extract data for each column
rows = []
for entry in results:
if registry_type == "skills":
commands = [ep.get('command', '') for ep in entry.get('entrypoints', [])]
row = [
entry.get('name', ''),
entry.get('version', ''),
entry.get('status', ''),
', '.join(entry.get('tags', [])[:3]), # Limit to 3 tags
', '.join(commands[:2]) # Limit to 2 commands
]
elif registry_type == "agents":
row = [
entry.get('name', ''),
entry.get('version', ''),
entry.get('status', ''),
', '.join(entry.get('tags', [])[:3]),
entry.get('reasoning_mode', ''),
str(len(entry.get('skills_available', [])))
]
elif registry_type == "commands":
exec_type = entry.get('execution', {}).get('type', '')
row = [
entry.get('name', ''),
entry.get('version', ''),
entry.get('status', ''),
', '.join(entry.get('tags', [])[:3]),
exec_type
]
elif registry_type == "hooks":
row = [
entry.get('name', ''),
entry.get('version', ''),
entry.get('status', ''),
entry.get('event', ''),
entry.get('command', '')[:40], # Truncate long commands
str(entry.get('enabled', True))
]
else:
row = [
entry.get('name', ''),
entry.get('version', ''),
entry.get('status', ''),
entry.get('description', '')[:50]
]
rows.append(row)
# Calculate column widths
col_widths = [len(col) for col in columns]
for row in rows:
for i, cell in enumerate(row):
col_widths[i] = max(col_widths[i], len(str(cell)))
# Build table
lines = []
separator = "+" + "+".join("-" * (w + 2) for w in col_widths) + "+"
# Header
lines.append(separator)
header = "|" + "|".join(f" {col:<{col_widths[i]}} " for i, col in enumerate(columns)) + "|"
lines.append(header)
lines.append(separator)
# Rows
for row in rows:
row_str = "|" + "|".join(f" {str(cell):<{col_widths[i]}} " for i, cell in enumerate(row)) + "|"
lines.append(row_str)
lines.append(separator)
return "\n".join(lines)
def main():
"""Main CLI entry point."""
import argparse
parser = argparse.ArgumentParser(
description="Query Betty registries programmatically",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# List all skills (compact format)
registry_query.py skills
# Find skills with 'api' tag in table format
registry_query.py skills --tag api --format table
# Find agents with 'design' capability
registry_query.py agents --capability design
# Find active skills with name containing 'validate'
registry_query.py skills --name validate --status active
# Query hooks registry
registry_query.py hooks --status active --format table
# Fuzzy search for commands
registry_query.py commands --name test --fuzzy
# Limit results with JSON output
registry_query.py skills --tag api --limit 5 --format json
"""
)
parser.add_argument(
"registry",
choices=["skills", "agents", "commands", "hooks"],
help="Registry to query"
)
parser.add_argument(
"--name",
help="Filter by name (substring match)"
)
parser.add_argument(
"--version",
help="Filter by version (exact match)"
)
parser.add_argument(
"--status",
help="Filter by status (e.g., active, draft, deprecated)"
)
parser.add_argument(
"--tag",
help="Filter by single tag"
)
parser.add_argument(
"--tags",
nargs="+",
help="Filter by multiple tags (any match)"
)
parser.add_argument(
"--capability",
help="Filter by capability (agents only)"
)
parser.add_argument(
"--domain",
help="Filter by domain (alias for tag)"
)
parser.add_argument(
"--fuzzy",
action="store_true",
help="Use fuzzy matching for name and capability"
)
parser.add_argument(
"--limit",
type=int,
help="Maximum number of results to return"
)
parser.add_argument(
"--format",
choices=["json", "table", "compact"],
default="compact",
help="Output format (default: compact)"
)
args = parser.parse_args()
try:
result = query_registry(
registry=args.registry,
name=args.name,
version=args.version,
status=args.status,
tag=args.tag,
tags=args.tags,
capability=args.capability,
domain=args.domain,
fuzzy=args.fuzzy,
limit=args.limit
)
details = result["details"]
if args.format == "json":
# Output full JSON
print(json.dumps(result, indent=2))
elif args.format == "table":
# Table format
print(f"\n{'='*80}")
print(f"REGISTRY QUERY: {details['registry'].upper()}")
print(f"{'='*80}")
print(f"\nTotal entries: {details['total_entries']}")
print(f"Matching entries: {details['matching_entries']}\n")
if details['results']:
print(format_table(details['results'], details['registry']))
else:
print("No matching entries found.")
print(f"\n{'='*80}\n")
else:
# Compact format (original pretty print)
print(f"\n{'='*80}")
print(f"REGISTRY QUERY: {details['registry'].upper()}")
print(f"{'='*80}")
print(f"\nTotal entries: {details['total_entries']}")
print(f"Matching entries: {details['matching_entries']}")
if details['results']:
print(f"\n{'-'*80}")
print("RESULTS:")
print(f"{'-'*80}\n")
for i, entry in enumerate(details['results'], 1):
print(f"{i}. {entry['name']} (v{entry['version']})")
print(f" Status: {entry['status']}")
print(f" Description: {entry['description'][:80]}...")
if entry.get('tags'):
print(f" Tags: {', '.join(entry['tags'])}")
# Registry-specific details
if details['registry'] == 'skills':
if entry.get('entrypoints'):
commands = [ep['command'] for ep in entry['entrypoints']]
print(f" Commands: {', '.join(commands)}")
elif details['registry'] == 'agents':
if entry.get('capabilities'):
print(f" Capabilities: {len(entry['capabilities'])} capabilities")
print(f" Reasoning: {entry.get('reasoning_mode', 'unknown')}")
elif details['registry'] == 'hooks':
print(f" Event: {entry.get('event', 'unknown')}")
print(f" Command: {entry.get('command', 'unknown')}")
print(f" Enabled: {entry.get('enabled', True)}")
print()
print(f"{'-'*80}")
else:
print("\nNo matching entries found.")
print(f"\n{'='*80}\n")
sys.exit(0 if result['ok'] else 1)
except BettyError as e:
logger.error(f"Query failed: {e}")
result = build_response(
ok=False,
errors=[str(e)]
)
print(json.dumps(result, indent=2))
sys.exit(1)
except Exception as e:
logger.error(f"Unexpected error: {e}", exc_info=True)
result = build_response(
ok=False,
errors=[f"Unexpected error: {str(e)}"]
)
print(json.dumps(result, indent=2))
sys.exit(1)
if __name__ == "__main__":
main()