Initial commit
This commit is contained in:
348
skills/registry.diff/SKILL.md
Normal file
348
skills/registry.diff/SKILL.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# registry.diff
|
||||
|
||||
Compare current and previous versions of skills/agents and report differences.
|
||||
|
||||
## Overview
|
||||
|
||||
The `registry.diff` skill analyzes changes between a current manifest file (skill.yaml or agent.yaml) and its existing registry entry. It detects various types of changes, determines the required action, and provides suggestions for proper version management and breaking change prevention.
|
||||
|
||||
## Purpose
|
||||
|
||||
- **Version Control**: Ensure proper semantic versioning when updating skills/agents
|
||||
- **Breaking Change Detection**: Identify changes that break backward compatibility
|
||||
- **Permission Auditing**: Track permission changes and flag unauthorized modifications
|
||||
- **Workflow Integration**: Enable automated validation in CI/CD pipelines
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Compare a skill manifest against the registry
|
||||
./skills/registry.diff/registry_diff.py path/to/skill.yaml
|
||||
|
||||
# Compare an agent manifest
|
||||
./skills/registry.diff/registry_diff.py path/to/agent.yaml
|
||||
|
||||
# Using with betty command (if configured)
|
||||
betty registry.diff <manifest_path>
|
||||
```
|
||||
|
||||
## Input
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `manifest_path` | string | Yes | Path to skill.yaml or agent.yaml file to analyze |
|
||||
|
||||
## Output
|
||||
|
||||
The skill returns a JSON response with the following structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"status": "success",
|
||||
"errors": [],
|
||||
"path": "path/to/manifest.yaml",
|
||||
"details": {
|
||||
"manifest_path": "path/to/manifest.yaml",
|
||||
"manifest_type": "skill",
|
||||
"diff_type": "version_bump",
|
||||
"required_action": "register",
|
||||
"suggestions": [
|
||||
"Version upgraded: 0.1.0 -> 0.2.0",
|
||||
"Clean version bump with no breaking changes"
|
||||
],
|
||||
"details": {
|
||||
"name": "example.skill",
|
||||
"current_version": "0.2.0",
|
||||
"previous_version": "0.1.0",
|
||||
"version_comparison": "upgrade",
|
||||
"permission_changed": false,
|
||||
"added_permissions": [],
|
||||
"removed_permissions": [],
|
||||
"removed_fields": [],
|
||||
"status_changed": false,
|
||||
"current_status": "active",
|
||||
"previous_status": "active"
|
||||
},
|
||||
"timestamp": "2025-10-23T12:00:00+00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Diff Types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `new` | Entry does not exist in registry (first-time registration) |
|
||||
| `version_bump` | Version was properly incremented |
|
||||
| `version_downgrade` | Version was decreased (breaking change) |
|
||||
| `permission_change` | Permissions were added or removed |
|
||||
| `breaking_change` | Breaking changes detected (removed fields, etc.) |
|
||||
| `status_change` | Status field changed (active, deprecated, etc.) |
|
||||
| `needs_version_bump` | Changes detected without version increment |
|
||||
| `no_change` | No significant changes detected |
|
||||
|
||||
### Required Actions
|
||||
|
||||
| Action | Description | Exit Code |
|
||||
|--------|-------------|-----------|
|
||||
| `register` | Changes are acceptable, proceed with registration | 0 |
|
||||
| `review` | Manual review recommended before registration | 0 |
|
||||
| `reject` | Breaking/unauthorized changes, registration blocked | 1 |
|
||||
| `skip` | No changes detected, no action needed | 0 |
|
||||
|
||||
## Examples
|
||||
|
||||
### Example 1: New Skill Registration
|
||||
|
||||
```bash
|
||||
$ ./skills/registry.diff/registry_diff.py skills/new.skill/skill.yaml
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"status": "success",
|
||||
"details": {
|
||||
"diff_type": "new",
|
||||
"required_action": "register",
|
||||
"suggestions": [
|
||||
"New skill 'new.skill' ready for registration"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 2: Clean Version Bump
|
||||
|
||||
```bash
|
||||
$ ./skills/registry.diff/registry_diff.py skills/example.skill/skill.yaml
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"status": "success",
|
||||
"details": {
|
||||
"diff_type": "version_bump",
|
||||
"required_action": "register",
|
||||
"suggestions": [
|
||||
"Version upgraded: 0.1.0 -> 0.2.0",
|
||||
"Clean version bump with no breaking changes"
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 3: Breaking Change Detected
|
||||
|
||||
```bash
|
||||
$ ./skills/registry.diff/registry_diff.py skills/example.skill/skill.yaml
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"status": "failed",
|
||||
"errors": [
|
||||
"Fields removed without version bump: dependencies",
|
||||
"Removing fields requires a version bump",
|
||||
"Suggested version: 0.2.0"
|
||||
],
|
||||
"details": {
|
||||
"diff_type": "breaking_change",
|
||||
"required_action": "reject",
|
||||
"details": {
|
||||
"removed_fields": ["dependencies"],
|
||||
"current_version": "0.1.0",
|
||||
"previous_version": "0.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Exit code: 1
|
||||
|
||||
### Example 4: Permission Change
|
||||
|
||||
```bash
|
||||
$ ./skills/registry.diff/registry_diff.py skills/example.skill/skill.yaml
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"status": "success",
|
||||
"details": {
|
||||
"diff_type": "permission_change",
|
||||
"required_action": "review",
|
||||
"suggestions": [
|
||||
"New permissions added: network",
|
||||
"Review: Ensure new permissions are necessary and documented"
|
||||
],
|
||||
"details": {
|
||||
"added_permissions": ["network"],
|
||||
"removed_permissions": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example 5: Version Downgrade (Rejected)
|
||||
|
||||
```bash
|
||||
$ ./skills/registry.diff/registry_diff.py skills/example.skill/skill.yaml
|
||||
```
|
||||
|
||||
Output:
|
||||
```json
|
||||
{
|
||||
"ok": false,
|
||||
"status": "failed",
|
||||
"errors": [
|
||||
"Version downgrade detected: 0.2.0 -> 0.1.0",
|
||||
"Version downgrades are not permitted",
|
||||
"Suggested action: Restore version to at least 0.2.0"
|
||||
],
|
||||
"details": {
|
||||
"diff_type": "version_downgrade",
|
||||
"required_action": "reject"
|
||||
}
|
||||
}
|
||||
```
|
||||
Exit code: 1
|
||||
|
||||
## Exit Codes
|
||||
|
||||
- **0**: Success - Changes are acceptable or no changes detected
|
||||
- **1**: Failure - Breaking or unauthorized changes detected
|
||||
|
||||
## Workflow Integration
|
||||
|
||||
### Pre-Commit Hook
|
||||
|
||||
```yaml
|
||||
# .git/hooks/pre-commit
|
||||
#!/bin/bash
|
||||
changed_files=$(git diff --cached --name-only | grep -E "skill\.yaml|agent\.yaml")
|
||||
|
||||
for file in $changed_files; do
|
||||
echo "Checking $file..."
|
||||
./skills/registry.diff/registry_diff.py "$file"
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "❌ Registry diff failed for $file"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
```
|
||||
|
||||
### CI/CD Pipeline
|
||||
|
||||
```yaml
|
||||
# .github/workflows/validate-changes.yml
|
||||
name: Validate Skill Changes
|
||||
|
||||
on: [pull_request]
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Check skill changes
|
||||
run: |
|
||||
for file in $(git diff --name-only origin/main | grep -E "skill\.yaml|agent\.yaml"); do
|
||||
python3 skills/registry.diff/registry_diff.py "$file"
|
||||
done
|
||||
```
|
||||
|
||||
## Change Detection Rules
|
||||
|
||||
### Breaking Changes (Exit 1)
|
||||
|
||||
1. **Version Downgrade**: Current version < Previous version
|
||||
2. **Removed Fields**: Fields removed without version bump
|
||||
3. **Removed Permissions**: Permissions removed without version bump
|
||||
|
||||
### Requires Review (Exit 0, but flagged)
|
||||
|
||||
1. **Permission Addition**: New permissions added
|
||||
2. **Status Change**: Status changed to deprecated/archived
|
||||
3. **Needs Version Bump**: Changes without version increment
|
||||
|
||||
### Acceptable Changes (Exit 0)
|
||||
|
||||
1. **New Entry**: First-time registration
|
||||
2. **Clean Version Bump**: Version incremented properly
|
||||
3. **No Changes**: No significant changes detected
|
||||
|
||||
## Semantic Versioning Guidelines
|
||||
|
||||
The skill follows semantic versioning principles:
|
||||
|
||||
- **Major (X.0.0)**: Breaking changes, removed functionality
|
||||
- **Minor (0.X.0)**: New features, deprecated functionality, significant changes
|
||||
- **Patch (0.0.X)**: Bug fixes, documentation updates, minor tweaks
|
||||
|
||||
### Suggested Version Bumps
|
||||
|
||||
| Change Type | Suggested Bump |
|
||||
|-------------|----------------|
|
||||
| Remove fields | Minor |
|
||||
| Remove permissions | Minor |
|
||||
| Add permissions | Patch or Minor |
|
||||
| Status to deprecated | Minor |
|
||||
| Bug fixes | Patch |
|
||||
| New features | Minor |
|
||||
| Breaking changes | Major |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- Python 3.6+
|
||||
- `packaging` library (for version comparison)
|
||||
- Betty Framework core utilities
|
||||
- PyYAML
|
||||
|
||||
## Related Skills
|
||||
|
||||
- `registry.update`: Update registry entries
|
||||
- `skill.define`: Define and validate skill manifests
|
||||
- `audit.log`: Audit trail for registry changes
|
||||
|
||||
## Notes
|
||||
|
||||
- Automatically detects whether input is a skill or agent manifest
|
||||
- Compares against appropriate registry (skills.json or agents.json)
|
||||
- Provides human-readable output to stderr for CLI usage
|
||||
- Returns structured JSON for programmatic integration
|
||||
- Thread-safe registry reading using Betty Framework utilities
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Manifest file not found"
|
||||
|
||||
Ensure the path to the manifest file is correct and the file exists.
|
||||
|
||||
### "Empty manifest file"
|
||||
|
||||
The YAML file exists but contains no data. Check for valid YAML content.
|
||||
|
||||
### "Registry file not found"
|
||||
|
||||
The registry hasn't been initialized yet. This is normal for new Betty Framework installations.
|
||||
|
||||
### "Invalid YAML in manifest"
|
||||
|
||||
The manifest file contains syntax errors. Validate with a YAML parser.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Support for dependency tree validation
|
||||
- [ ] Integration with semantic version recommendation engine
|
||||
- [ ] Diff visualization in HTML format
|
||||
- [ ] Support for comparing arbitrary versions (not just latest)
|
||||
- [ ] Batch mode for comparing multiple manifests
|
||||
1
skills/registry.diff/__init__.py
Normal file
1
skills/registry.diff/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Auto-generated package initializer for skills.
|
||||
564
skills/registry.diff/registry_diff.py
Executable file
564
skills/registry.diff/registry_diff.py
Executable file
@@ -0,0 +1,564 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
registry.diff - Compare current and previous versions of skills/agents.
|
||||
|
||||
This skill compares a skill or agent manifest against its registry entry
|
||||
to detect changes and determine appropriate actions.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import yaml
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
from datetime import datetime, timezone
|
||||
from packaging import version as version_parser
|
||||
|
||||
|
||||
# Betty framework imports
|
||||
from betty.config import BASE_DIR, REGISTRY_DIR
|
||||
from betty.file_utils import safe_read_json
|
||||
from betty.validation import validate_path
|
||||
from betty.logging_utils import setup_logger
|
||||
from betty.errors import RegistryError, format_error_response
|
||||
|
||||
logger = setup_logger(__name__)
|
||||
|
||||
SKILLS_REGISTRY = os.path.join(REGISTRY_DIR, "skills.json")
|
||||
AGENTS_REGISTRY = os.path.join(REGISTRY_DIR, "agents.json")
|
||||
|
||||
|
||||
def build_response(
|
||||
ok: bool,
|
||||
path: str,
|
||||
errors: Optional[List[str]] = None,
|
||||
details: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""Standard response format used across all skills."""
|
||||
response: Dict[str, Any] = {
|
||||
"ok": ok,
|
||||
"status": "success" if ok else "failed",
|
||||
"errors": errors or [],
|
||||
"path": path,
|
||||
}
|
||||
|
||||
if details is not None:
|
||||
response["details"] = details
|
||||
|
||||
return response
|
||||
|
||||
|
||||
def load_manifest(path: str) -> Dict[str, Any]:
|
||||
"""Load YAML manifest file with error handling."""
|
||||
try:
|
||||
with open(path) as f:
|
||||
manifest = yaml.safe_load(f)
|
||||
if not manifest:
|
||||
raise RegistryError(f"Empty manifest file: {path}")
|
||||
return manifest
|
||||
except FileNotFoundError:
|
||||
raise RegistryError(f"Manifest file not found: {path}")
|
||||
except yaml.YAMLError as e:
|
||||
raise RegistryError(f"Invalid YAML in manifest: {e}")
|
||||
|
||||
|
||||
def determine_manifest_type(manifest: Dict[str, Any]) -> str:
|
||||
"""Determine if manifest is a skill or agent based on fields."""
|
||||
if "entrypoints" in manifest or "handler" in manifest:
|
||||
return "skill"
|
||||
elif "capabilities" in manifest or "reasoning_mode" in manifest:
|
||||
return "agent"
|
||||
else:
|
||||
# Default to skill if unclear
|
||||
return "skill"
|
||||
|
||||
|
||||
def find_registry_entry(name: str, manifest_type: str) -> Optional[Dict[str, Any]]:
|
||||
"""Find existing entry in the appropriate registry."""
|
||||
registry_file = SKILLS_REGISTRY if manifest_type == "skill" else AGENTS_REGISTRY
|
||||
|
||||
if not os.path.exists(registry_file):
|
||||
logger.warning(f"Registry file not found: {registry_file}")
|
||||
return None
|
||||
|
||||
registry = safe_read_json(registry_file, default={})
|
||||
entries_key = "skills" if manifest_type == "skill" else "agents"
|
||||
entries = registry.get(entries_key, [])
|
||||
|
||||
for entry in entries:
|
||||
if entry.get("name") == name:
|
||||
return entry
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def compare_versions(current: str, previous: str) -> str:
|
||||
"""
|
||||
Compare semantic versions.
|
||||
|
||||
Returns:
|
||||
"upgrade" if current > previous
|
||||
"downgrade" if current < previous
|
||||
"same" if current == previous
|
||||
"""
|
||||
try:
|
||||
curr_ver = version_parser.parse(current)
|
||||
prev_ver = version_parser.parse(previous)
|
||||
|
||||
if curr_ver > prev_ver:
|
||||
return "upgrade"
|
||||
elif curr_ver < prev_ver:
|
||||
return "downgrade"
|
||||
else:
|
||||
return "same"
|
||||
except Exception as e:
|
||||
logger.warning(f"Error comparing versions: {e}")
|
||||
return "unknown"
|
||||
|
||||
|
||||
def get_permissions(manifest: Dict[str, Any]) -> set:
|
||||
"""Extract permissions from manifest."""
|
||||
permissions = set()
|
||||
|
||||
# For skills, permissions are in entrypoints
|
||||
if "entrypoints" in manifest:
|
||||
for ep in manifest["entrypoints"]:
|
||||
if "permissions" in ep:
|
||||
permissions.update(ep["permissions"])
|
||||
|
||||
# For agents, might have permissions at top level
|
||||
if "permissions" in manifest:
|
||||
perms = manifest["permissions"]
|
||||
if isinstance(perms, list):
|
||||
permissions.update(perms)
|
||||
|
||||
return permissions
|
||||
|
||||
|
||||
def detect_removed_fields(current: Dict[str, Any], previous: Dict[str, Any]) -> List[str]:
|
||||
"""Detect fields that were removed from the manifest."""
|
||||
removed = []
|
||||
|
||||
# Check top-level fields
|
||||
for key in previous:
|
||||
if key not in current:
|
||||
removed.append(key)
|
||||
|
||||
# Check nested structures like inputs, outputs
|
||||
for list_field in ["inputs", "outputs", "dependencies", "capabilities", "skills_available"]:
|
||||
if list_field in previous and list_field in current:
|
||||
prev_list = previous[list_field] if isinstance(previous[list_field], list) else []
|
||||
curr_list = current[list_field] if isinstance(current[list_field], list) else []
|
||||
|
||||
# For simple lists (strings), use set comparison
|
||||
if prev_list and all(isinstance(x, (str, int, float, bool)) for x in prev_list):
|
||||
prev_items = set(prev_list)
|
||||
curr_items = set(curr_list)
|
||||
removed_items = prev_items - curr_items
|
||||
if removed_items:
|
||||
removed.append(f"{list_field}: {', '.join(str(x) for x in removed_items)}")
|
||||
# For complex lists (dicts), compare by key fields like 'name'
|
||||
elif prev_list and all(isinstance(x, dict) for x in prev_list):
|
||||
prev_names = {item.get('name', item.get('command', str(item))) for item in prev_list}
|
||||
curr_names = {item.get('name', item.get('command', str(item))) for item in curr_list}
|
||||
removed_names = prev_names - curr_names
|
||||
if removed_names:
|
||||
removed.append(f"{list_field}: {', '.join(removed_names)}")
|
||||
|
||||
return removed
|
||||
|
||||
|
||||
def analyze_diff(
|
||||
manifest: Dict[str, Any],
|
||||
registry_entry: Optional[Dict[str, Any]],
|
||||
manifest_type: str
|
||||
) -> Tuple[str, str, List[str], Dict[str, Any], List[str], bool]:
|
||||
"""
|
||||
Analyze differences between current manifest and registry entry.
|
||||
|
||||
Returns:
|
||||
(diff_type, required_action, suggestions, details, changed_fields, breaking)
|
||||
"""
|
||||
name = manifest.get("name", "unknown")
|
||||
current_version = manifest.get("version", "0.0.0")
|
||||
changed_fields: List[str] = []
|
||||
breaking = False
|
||||
|
||||
# Case 1: New entry (not in registry)
|
||||
if registry_entry is None:
|
||||
return (
|
||||
"new",
|
||||
"register",
|
||||
[f"New {manifest_type} '{name}' ready for registration"],
|
||||
{
|
||||
"name": name,
|
||||
"version": current_version,
|
||||
"is_new": True
|
||||
},
|
||||
[], # No changed fields for new entries
|
||||
False # Not breaking for new entries
|
||||
)
|
||||
|
||||
# Extract previous values
|
||||
previous_version = registry_entry.get("version", "0.0.0")
|
||||
version_comparison = compare_versions(current_version, previous_version)
|
||||
|
||||
# Track version changes
|
||||
if version_comparison != "same":
|
||||
changed_fields.append("version")
|
||||
|
||||
# Detect permission changes
|
||||
current_perms = get_permissions(manifest)
|
||||
previous_perms = get_permissions(registry_entry)
|
||||
added_perms = current_perms - previous_perms
|
||||
removed_perms = previous_perms - current_perms
|
||||
permission_changed = bool(added_perms or removed_perms)
|
||||
|
||||
if permission_changed:
|
||||
changed_fields.append("permissions")
|
||||
|
||||
# Detect removed fields
|
||||
removed_fields = detect_removed_fields(manifest, registry_entry)
|
||||
|
||||
# Track field removals in changed_fields
|
||||
for field in removed_fields:
|
||||
# Extract field name (e.g., "inputs: ..." -> "inputs")
|
||||
field_name = field.split(":")[0] if ":" in field else field
|
||||
if field_name not in changed_fields:
|
||||
changed_fields.append(field_name)
|
||||
|
||||
# Detect status changes
|
||||
current_status = manifest.get("status", "draft")
|
||||
previous_status = registry_entry.get("status", "draft")
|
||||
status_changed = current_status != previous_status
|
||||
|
||||
if status_changed:
|
||||
changed_fields.append("status")
|
||||
|
||||
# Check for description changes
|
||||
if manifest.get("description") != registry_entry.get("description"):
|
||||
changed_fields.append("description")
|
||||
|
||||
# Check for other field changes based on manifest type
|
||||
if manifest_type == "skill":
|
||||
# Check inputs
|
||||
if json.dumps(manifest.get("inputs", []), sort_keys=True) != json.dumps(registry_entry.get("inputs", []), sort_keys=True):
|
||||
if "inputs" not in changed_fields:
|
||||
changed_fields.append("inputs")
|
||||
# Check outputs
|
||||
if json.dumps(manifest.get("outputs", []), sort_keys=True) != json.dumps(registry_entry.get("outputs", []), sort_keys=True):
|
||||
if "outputs" not in changed_fields:
|
||||
changed_fields.append("outputs")
|
||||
# Check entrypoints
|
||||
if json.dumps(manifest.get("entrypoints", []), sort_keys=True) != json.dumps(registry_entry.get("entrypoints", []), sort_keys=True):
|
||||
if "entrypoints" not in changed_fields:
|
||||
changed_fields.append("entrypoints")
|
||||
elif manifest_type == "agent":
|
||||
# Check capabilities
|
||||
if json.dumps(manifest.get("capabilities", []), sort_keys=True) != json.dumps(registry_entry.get("capabilities", []), sort_keys=True):
|
||||
if "capabilities" not in changed_fields:
|
||||
changed_fields.append("capabilities")
|
||||
# Check skills_available
|
||||
if json.dumps(manifest.get("skills_available", []), sort_keys=True) != json.dumps(registry_entry.get("skills_available", []), sort_keys=True):
|
||||
if "skills_available" not in changed_fields:
|
||||
changed_fields.append("skills_available")
|
||||
# Check reasoning_mode
|
||||
if manifest.get("reasoning_mode") != registry_entry.get("reasoning_mode"):
|
||||
changed_fields.append("reasoning_mode")
|
||||
|
||||
# Check dependencies
|
||||
if json.dumps(manifest.get("dependencies", []), sort_keys=True) != json.dumps(registry_entry.get("dependencies", []), sort_keys=True):
|
||||
if "dependencies" not in changed_fields:
|
||||
changed_fields.append("dependencies")
|
||||
|
||||
# Check tags
|
||||
if json.dumps(sorted(manifest.get("tags", []))) != json.dumps(sorted(registry_entry.get("tags", []))):
|
||||
if "tags" not in changed_fields:
|
||||
changed_fields.append("tags")
|
||||
|
||||
# Build details
|
||||
details = {
|
||||
"name": name,
|
||||
"current_version": current_version,
|
||||
"previous_version": previous_version,
|
||||
"version_comparison": version_comparison,
|
||||
"permission_changed": permission_changed,
|
||||
"added_permissions": list(added_perms),
|
||||
"removed_permissions": list(removed_perms),
|
||||
"removed_fields": removed_fields,
|
||||
"status_changed": status_changed,
|
||||
"current_status": current_status,
|
||||
"previous_status": previous_status
|
||||
}
|
||||
|
||||
suggestions = []
|
||||
|
||||
# Determine diff type and required action
|
||||
|
||||
# Case 2: Version downgrade (breaking change)
|
||||
if version_comparison == "downgrade":
|
||||
breaking = True
|
||||
return (
|
||||
"version_downgrade",
|
||||
"reject",
|
||||
[
|
||||
f"Version downgrade detected: {previous_version} -> {current_version}",
|
||||
"Version downgrades are not permitted",
|
||||
f"Suggested action: Restore version to at least {previous_version}"
|
||||
],
|
||||
details,
|
||||
changed_fields,
|
||||
breaking
|
||||
)
|
||||
|
||||
# Case 3: Removed fields without version bump (breaking change)
|
||||
if removed_fields and version_comparison == "same":
|
||||
breaking = True
|
||||
suggestions.extend([
|
||||
f"Fields removed without version bump: {', '.join(removed_fields)}",
|
||||
"Removing fields requires a version bump",
|
||||
f"Suggested version: {increment_version(current_version, 'minor')}"
|
||||
])
|
||||
return (
|
||||
"breaking_change",
|
||||
"reject",
|
||||
suggestions,
|
||||
details,
|
||||
changed_fields,
|
||||
breaking
|
||||
)
|
||||
|
||||
# Case 4: Permission changes
|
||||
if permission_changed:
|
||||
if removed_perms and version_comparison == "same":
|
||||
breaking = True
|
||||
suggestions.extend([
|
||||
f"Permissions removed without version bump: {', '.join(removed_perms)}",
|
||||
f"Suggested version: {increment_version(current_version, 'minor')}"
|
||||
])
|
||||
|
||||
if added_perms:
|
||||
suggestions.append(f"New permissions added: {', '.join(added_perms)}")
|
||||
suggestions.append("Review: Ensure new permissions are necessary and documented")
|
||||
|
||||
return (
|
||||
"permission_change",
|
||||
"review",
|
||||
suggestions if suggestions else [f"Permission changes detected in {name}"],
|
||||
details,
|
||||
changed_fields,
|
||||
breaking
|
||||
)
|
||||
|
||||
# Case 5: Status change to deprecated/archived without version bump
|
||||
if status_changed and current_status in ["deprecated", "archived"] and version_comparison == "same":
|
||||
suggestions.extend([
|
||||
f"Status changed to '{current_status}' without version bump",
|
||||
f"Suggested version: {increment_version(current_version, 'minor')}"
|
||||
])
|
||||
return (
|
||||
"status_change",
|
||||
"review",
|
||||
suggestions,
|
||||
details,
|
||||
changed_fields,
|
||||
breaking
|
||||
)
|
||||
|
||||
# Case 6: Version bump (normal change)
|
||||
if version_comparison == "upgrade":
|
||||
suggestions.append(f"Version upgraded: {previous_version} -> {current_version}")
|
||||
if not permission_changed and not removed_fields:
|
||||
suggestions.append("Clean version bump with no breaking changes")
|
||||
return (
|
||||
"version_bump",
|
||||
"register",
|
||||
suggestions,
|
||||
details,
|
||||
changed_fields,
|
||||
breaking
|
||||
)
|
||||
|
||||
# Case 7: No significant changes
|
||||
if version_comparison == "same" and not permission_changed and not removed_fields and not status_changed:
|
||||
return (
|
||||
"no_change",
|
||||
"skip",
|
||||
[f"No significant changes detected in {name}"],
|
||||
details,
|
||||
changed_fields,
|
||||
breaking
|
||||
)
|
||||
|
||||
# Case 8: Changes without version bump (needs review)
|
||||
suggestions.extend([
|
||||
"Changes detected without version bump",
|
||||
f"Current version: {current_version}",
|
||||
f"Suggested version: {increment_version(current_version, 'patch')}"
|
||||
])
|
||||
return (
|
||||
"needs_version_bump",
|
||||
"review",
|
||||
suggestions,
|
||||
details,
|
||||
changed_fields,
|
||||
breaking
|
||||
)
|
||||
|
||||
|
||||
def increment_version(version_str: str, bump_type: str = "patch") -> str:
|
||||
"""Increment semantic version."""
|
||||
try:
|
||||
ver = version_parser.parse(version_str)
|
||||
major, minor, patch = ver.major, ver.minor, ver.micro
|
||||
|
||||
if bump_type == "major":
|
||||
major += 1
|
||||
minor = 0
|
||||
patch = 0
|
||||
elif bump_type == "minor":
|
||||
minor += 1
|
||||
patch = 0
|
||||
else: # patch
|
||||
patch += 1
|
||||
|
||||
return f"{major}.{minor}.{patch}"
|
||||
except Exception:
|
||||
return version_str
|
||||
|
||||
|
||||
def diff_manifest(manifest_path: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Compare manifest against registry entry.
|
||||
|
||||
Args:
|
||||
manifest_path: Path to skill.yaml or agent.yaml
|
||||
|
||||
Returns:
|
||||
Dictionary with diff analysis
|
||||
"""
|
||||
# Validate input
|
||||
validate_path(manifest_path, must_exist=True)
|
||||
|
||||
# Load manifest
|
||||
logger.info(f"Loading manifest: {manifest_path}")
|
||||
manifest = load_manifest(manifest_path)
|
||||
|
||||
# Determine type
|
||||
manifest_type = determine_manifest_type(manifest)
|
||||
logger.info(f"Detected manifest type: {manifest_type}")
|
||||
|
||||
# Get name
|
||||
name = manifest.get("name")
|
||||
if not name:
|
||||
raise RegistryError("Manifest missing required 'name' field")
|
||||
|
||||
# Find registry entry
|
||||
registry_entry = find_registry_entry(name, manifest_type)
|
||||
|
||||
# Analyze differences
|
||||
diff_type, required_action, suggestions, details, changed_fields, breaking = analyze_diff(
|
||||
manifest, registry_entry, manifest_type
|
||||
)
|
||||
|
||||
# Build result
|
||||
result = {
|
||||
"manifest_path": manifest_path,
|
||||
"manifest_type": manifest_type,
|
||||
"diff_type": diff_type,
|
||||
"required_action": required_action,
|
||||
"changed_fields": changed_fields,
|
||||
"breaking": breaking,
|
||||
"suggestions": suggestions,
|
||||
"details": details,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat()
|
||||
}
|
||||
|
||||
logger.info(f"Diff analysis complete: {diff_type} -> {required_action} (breaking: {breaking}, changed: {changed_fields})")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
"""Main CLI entry point."""
|
||||
if len(sys.argv) < 2:
|
||||
message = "Usage: registry_diff.py <manifest_path>"
|
||||
response = build_response(
|
||||
False,
|
||||
path="",
|
||||
errors=[message],
|
||||
details={
|
||||
"error": {
|
||||
"error": "UsageError",
|
||||
"message": message,
|
||||
"details": {
|
||||
"usage": "registry_diff.py <path/to/skill.yaml or agent.yaml>"
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
print(json.dumps(response, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
manifest_path = sys.argv[1]
|
||||
result = diff_manifest(manifest_path)
|
||||
|
||||
# Determine exit code based on required action
|
||||
should_reject = result["required_action"] == "reject"
|
||||
exit_code = 1 if should_reject else 0
|
||||
|
||||
response = build_response(
|
||||
ok=not should_reject,
|
||||
path=manifest_path,
|
||||
errors=result["suggestions"] if should_reject else [],
|
||||
details=result
|
||||
)
|
||||
|
||||
# Print formatted output
|
||||
print(json.dumps(response, indent=2))
|
||||
|
||||
# Also print human-readable summary
|
||||
print("\n" + "="*60, file=sys.stderr)
|
||||
print(f"Registry Diff Analysis", file=sys.stderr)
|
||||
print("="*60, file=sys.stderr)
|
||||
print(f"Manifest: {manifest_path}", file=sys.stderr)
|
||||
print(f"Type: {result['manifest_type']}", file=sys.stderr)
|
||||
print(f"Diff Type: {result['diff_type']}", file=sys.stderr)
|
||||
print(f"Required Action: {result['required_action']}", file=sys.stderr)
|
||||
print(f"Breaking: {result['breaking']}", file=sys.stderr)
|
||||
if result['changed_fields']:
|
||||
print(f"Changed Fields: {', '.join(result['changed_fields'])}", file=sys.stderr)
|
||||
print("\nSuggestions:", file=sys.stderr)
|
||||
for suggestion in result["suggestions"]:
|
||||
print(f" • {suggestion}", file=sys.stderr)
|
||||
print("="*60 + "\n", file=sys.stderr)
|
||||
|
||||
sys.exit(exit_code)
|
||||
|
||||
except RegistryError as e:
|
||||
logger.error(str(e))
|
||||
error_info = format_error_response(e)
|
||||
response = build_response(
|
||||
False,
|
||||
path=manifest_path if len(sys.argv) > 1 else "",
|
||||
errors=[error_info.get("message", str(e))],
|
||||
details={"error": error_info},
|
||||
)
|
||||
print(json.dumps(response, indent=2))
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error: {e}", exc_info=True)
|
||||
error_info = format_error_response(e, include_traceback=True)
|
||||
response = build_response(
|
||||
False,
|
||||
path=manifest_path if len(sys.argv) > 1 else "",
|
||||
errors=[error_info.get("message", str(e))],
|
||||
details={"error": error_info},
|
||||
)
|
||||
print(json.dumps(response, indent=2))
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
110
skills/registry.diff/skill.yaml
Normal file
110
skills/registry.diff/skill.yaml
Normal file
@@ -0,0 +1,110 @@
|
||||
name: registry.diff
|
||||
version: 0.2.0
|
||||
description: >
|
||||
Compare current and previous versions of skills/agents and report differences.
|
||||
Detects changes, determines required actions, and provides suggestions for
|
||||
version management and breaking change prevention. Enhanced with changed_fields
|
||||
array and breaking flag for easier consumption.
|
||||
|
||||
# Input parameters
|
||||
inputs:
|
||||
- name: manifest_path
|
||||
type: string
|
||||
required: true
|
||||
description: Path to the skill.yaml or agent.yaml manifest file to compare
|
||||
|
||||
# Output files/data
|
||||
outputs:
|
||||
- name: diff_result
|
||||
type: object
|
||||
description: >
|
||||
Detailed diff analysis including diff_type, required_action, suggestions,
|
||||
and comparison details
|
||||
|
||||
# Skills this depends on
|
||||
dependencies: []
|
||||
|
||||
# Lifecycle status
|
||||
status: active
|
||||
|
||||
# Entrypoints define how the skill is invoked
|
||||
entrypoints:
|
||||
- command: /registry/diff
|
||||
handler: registry_diff.py
|
||||
runtime: python
|
||||
description: >
|
||||
Compare a manifest against its registry entry to detect changes and
|
||||
determine appropriate actions.
|
||||
parameters:
|
||||
- name: manifest_path
|
||||
type: string
|
||||
required: true
|
||||
description: >
|
||||
Path to the skill.yaml or agent.yaml manifest file to analyze.
|
||||
Can be absolute or relative path.
|
||||
permissions:
|
||||
- filesystem
|
||||
- read
|
||||
|
||||
# Return values
|
||||
returns:
|
||||
diff_type:
|
||||
type: string
|
||||
enum:
|
||||
- new
|
||||
- version_bump
|
||||
- version_downgrade
|
||||
- permission_change
|
||||
- breaking_change
|
||||
- status_change
|
||||
- needs_version_bump
|
||||
- no_change
|
||||
description: Classification of the detected changes
|
||||
|
||||
required_action:
|
||||
type: string
|
||||
enum:
|
||||
- register
|
||||
- review
|
||||
- reject
|
||||
- skip
|
||||
description: Recommended action based on the analysis
|
||||
|
||||
changed_fields:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
List of field names that changed between the manifest and registry entry
|
||||
(e.g., version, permissions, status, description, inputs, outputs, etc.)
|
||||
|
||||
breaking:
|
||||
type: boolean
|
||||
description: >
|
||||
Indicates whether the changes are breaking (e.g., version downgrade,
|
||||
removed fields without version bump, removed permissions without version bump)
|
||||
|
||||
suggestions:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: >
|
||||
List of suggestions for addressing the detected changes,
|
||||
including version bump recommendations
|
||||
|
||||
# Exit codes
|
||||
exit_codes:
|
||||
0: >
|
||||
Success - changes are acceptable (new entry, clean version bump,
|
||||
or no significant changes)
|
||||
1: >
|
||||
Failure - breaking or unauthorized changes detected (version downgrade,
|
||||
removed fields without version bump, unauthorized permission changes)
|
||||
|
||||
# Tags for categorization
|
||||
tags:
|
||||
- registry
|
||||
- validation
|
||||
- version-control
|
||||
- diff
|
||||
- comparison
|
||||
Reference in New Issue
Block a user