541 lines
14 KiB
Python
Executable File
541 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
Generate or update SKILL.md documentation from skill.yaml manifest files.
|
|
|
|
This skill automatically creates comprehensive documentation for Betty skills
|
|
based on their manifest definitions, ensuring consistency and completeness.
|
|
"""
|
|
|
|
import sys
|
|
import json
|
|
import argparse
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Dict, Any, List, Optional
|
|
|
|
# Add betty module to path
|
|
|
|
from betty.logging_utils import setup_logger
|
|
from betty.errors import format_error_response, BettyError
|
|
from betty.validation import validate_path
|
|
|
|
logger = setup_logger(__name__)
|
|
|
|
|
|
def load_skill_manifest(manifest_path: str) -> Dict[str, Any]:
|
|
"""
|
|
Load and parse a skill.yaml manifest file.
|
|
|
|
Args:
|
|
manifest_path: Path to skill.yaml file
|
|
|
|
Returns:
|
|
Parsed manifest data
|
|
|
|
Raises:
|
|
BettyError: If manifest file is invalid or not found
|
|
"""
|
|
manifest_file = Path(manifest_path)
|
|
|
|
if not manifest_file.exists():
|
|
raise BettyError(f"Manifest file not found: {manifest_path}")
|
|
|
|
if not manifest_file.is_file():
|
|
raise BettyError(f"Manifest path is not a file: {manifest_path}")
|
|
|
|
try:
|
|
import yaml
|
|
with open(manifest_file, 'r') as f:
|
|
manifest = yaml.safe_load(f)
|
|
|
|
if not isinstance(manifest, dict):
|
|
raise BettyError("Manifest must be a YAML object/dictionary")
|
|
|
|
logger.info(f"Loaded skill manifest from {manifest_path}")
|
|
return manifest
|
|
except yaml.YAMLError as e:
|
|
raise BettyError(f"Failed to parse YAML manifest: {e}")
|
|
except Exception as e:
|
|
raise BettyError(f"Failed to load manifest: {e}")
|
|
|
|
|
|
def normalize_input(inp: Any) -> Dict[str, Any]:
|
|
"""
|
|
Normalize input definition to standard format.
|
|
|
|
Args:
|
|
inp: Input definition (string or dict)
|
|
|
|
Returns:
|
|
Normalized input dictionary
|
|
"""
|
|
if isinstance(inp, str):
|
|
# Simple string format: "workflow_path"
|
|
return {
|
|
'name': inp,
|
|
'type': 'any',
|
|
'required': True,
|
|
'description': 'No description'
|
|
}
|
|
elif isinstance(inp, dict):
|
|
# Already in object format
|
|
return inp
|
|
else:
|
|
return {
|
|
'name': 'unknown',
|
|
'type': 'any',
|
|
'required': False,
|
|
'description': 'Invalid input format'
|
|
}
|
|
|
|
|
|
def format_inputs_section(inputs: List[Any]) -> str:
|
|
"""
|
|
Format the inputs section for the documentation.
|
|
|
|
Args:
|
|
inputs: List of input definitions from manifest (strings or dicts)
|
|
|
|
Returns:
|
|
Formatted markdown table
|
|
"""
|
|
if not inputs:
|
|
return "_No inputs defined_\n"
|
|
|
|
lines = [
|
|
"| Parameter | Type | Required | Default | Description |",
|
|
"|-----------|------|----------|---------|-------------|"
|
|
]
|
|
|
|
for inp in inputs:
|
|
normalized = normalize_input(inp)
|
|
name = normalized.get('name', 'unknown')
|
|
type_val = normalized.get('type', 'any')
|
|
required = 'Yes' if normalized.get('required', False) else 'No'
|
|
default = normalized.get('default', '-')
|
|
if default is True:
|
|
default = 'true'
|
|
elif default is False:
|
|
default = 'false'
|
|
elif default != '-':
|
|
default = f'`{default}`'
|
|
description = normalized.get('description', 'No description')
|
|
|
|
lines.append(f"| `{name}` | {type_val} | {required} | {default} | {description} |")
|
|
|
|
return '\n'.join(lines) + '\n'
|
|
|
|
|
|
def normalize_output(out: Any) -> Dict[str, Any]:
|
|
"""
|
|
Normalize output definition to standard format.
|
|
|
|
Args:
|
|
out: Output definition (string or dict)
|
|
|
|
Returns:
|
|
Normalized output dictionary
|
|
"""
|
|
if isinstance(out, str):
|
|
# Simple string format: "validation_result.json"
|
|
return {
|
|
'name': out,
|
|
'type': 'any',
|
|
'description': 'No description'
|
|
}
|
|
elif isinstance(out, dict):
|
|
# Already in object format
|
|
return out
|
|
else:
|
|
return {
|
|
'name': 'unknown',
|
|
'type': 'any',
|
|
'description': 'Invalid output format'
|
|
}
|
|
|
|
|
|
def format_outputs_section(outputs: List[Any]) -> str:
|
|
"""
|
|
Format the outputs section for the documentation.
|
|
|
|
Args:
|
|
outputs: List of output definitions from manifest (strings or dicts)
|
|
|
|
Returns:
|
|
Formatted markdown table
|
|
"""
|
|
if not outputs:
|
|
return "_No outputs defined_\n"
|
|
|
|
lines = [
|
|
"| Output | Type | Description |",
|
|
"|--------|------|-------------|"
|
|
]
|
|
|
|
for out in outputs:
|
|
normalized = normalize_output(out)
|
|
name = normalized.get('name', 'unknown')
|
|
type_val = normalized.get('type', 'any')
|
|
description = normalized.get('description', 'No description')
|
|
|
|
lines.append(f"| `{name}` | {type_val} | {description} |")
|
|
|
|
return '\n'.join(lines) + '\n'
|
|
|
|
|
|
def generate_usage_template(manifest: Dict[str, Any]) -> str:
|
|
"""
|
|
Generate a usage template based on the skill manifest.
|
|
|
|
Args:
|
|
manifest: Parsed skill manifest
|
|
|
|
Returns:
|
|
Usage template string
|
|
"""
|
|
skill_name = manifest.get('name', 'skill')
|
|
inputs = manifest.get('inputs', [])
|
|
|
|
# Get the handler/script name
|
|
entrypoints = manifest.get('entrypoints', [])
|
|
if entrypoints:
|
|
handler = entrypoints[0].get('handler', f'{skill_name.replace(".", "_")}.py')
|
|
else:
|
|
handler = f'{skill_name.replace(".", "_")}.py'
|
|
|
|
# Build basic usage
|
|
skill_dir = f"skills/{skill_name}"
|
|
usage = f"```bash\npython {skill_dir}/{handler}"
|
|
|
|
# Normalize inputs
|
|
normalized_inputs = [normalize_input(inp) for inp in inputs]
|
|
|
|
# Add required positional arguments
|
|
required_inputs = [inp for inp in normalized_inputs if inp.get('required', False)]
|
|
for inp in required_inputs:
|
|
usage += f" <{inp['name']}>"
|
|
|
|
# Add optional arguments hint
|
|
if any(not inp.get('required', False) for inp in normalized_inputs):
|
|
usage += " [options]"
|
|
|
|
usage += "\n```"
|
|
|
|
return usage
|
|
|
|
|
|
def generate_parameters_detail(inputs: List[Any]) -> str:
|
|
"""
|
|
Generate detailed parameter documentation.
|
|
|
|
Args:
|
|
inputs: List of input definitions (strings or dicts)
|
|
|
|
Returns:
|
|
Formatted parameter details
|
|
"""
|
|
if not inputs:
|
|
return ""
|
|
|
|
lines = []
|
|
for inp in inputs:
|
|
normalized = normalize_input(inp)
|
|
name = normalized.get('name', 'unknown')
|
|
description = normalized.get('description', 'No description')
|
|
type_val = normalized.get('type', 'any')
|
|
required = normalized.get('required', False)
|
|
default = normalized.get('default')
|
|
|
|
detail = f"- `--{name}` ({type_val})"
|
|
if required:
|
|
detail += " **[Required]**"
|
|
detail += f": {description}"
|
|
if default is not None:
|
|
detail += f" (default: `{default}`)"
|
|
|
|
lines.append(detail)
|
|
|
|
return '\n'.join(lines) + '\n'
|
|
|
|
|
|
def generate_skill_documentation(manifest: Dict[str, Any]) -> str:
|
|
"""
|
|
Generate complete SKILL.md documentation from manifest.
|
|
|
|
Args:
|
|
manifest: Parsed skill manifest
|
|
|
|
Returns:
|
|
Generated markdown documentation
|
|
"""
|
|
name = manifest.get('name', 'Unknown Skill')
|
|
description = manifest.get('description', 'No description available')
|
|
version = manifest.get('version', '0.1.0')
|
|
inputs = manifest.get('inputs', [])
|
|
outputs = manifest.get('outputs', [])
|
|
tags = manifest.get('tags', [])
|
|
dependencies = manifest.get('dependencies', [])
|
|
|
|
# Build documentation
|
|
doc = f"""# {name}
|
|
|
|
## Overview
|
|
|
|
**{name}** {description}
|
|
|
|
## Purpose
|
|
|
|
{description}
|
|
|
|
This skill automatically generates documentation by:
|
|
- Reading skill.yaml manifest files
|
|
- Extracting inputs, outputs, and metadata
|
|
- Creating standardized SKILL.md documentation
|
|
- Ensuring consistency across all Betty skills
|
|
|
|
## Usage
|
|
|
|
### Basic Usage
|
|
|
|
{generate_usage_template(manifest)}
|
|
|
|
### Parameters
|
|
|
|
{format_inputs_section(inputs)}
|
|
|
|
## Outputs
|
|
|
|
{format_outputs_section(outputs)}
|
|
|
|
## Usage Template
|
|
|
|
### Example: Generate Documentation for a Skill
|
|
|
|
```bash
|
|
python skills/{name}/{'generate_docs.py' if '.' in name else name + '.py'} path/to/skill.yaml
|
|
```
|
|
|
|
### Example: Preview Without Writing
|
|
|
|
```bash
|
|
python skills/{name}/{'generate_docs.py' if '.' in name else name + '.py'} \\
|
|
path/to/skill.yaml \\
|
|
--dry-run=true
|
|
```
|
|
|
|
### Example: Overwrite Existing Documentation
|
|
|
|
```bash
|
|
python skills/{name}/{'generate_docs.py' if '.' in name else name + '.py'} \\
|
|
path/to/skill.yaml \\
|
|
--overwrite=true
|
|
```
|
|
|
|
## Integration Notes
|
|
|
|
### Use in Workflows
|
|
|
|
```yaml
|
|
# workflows/documentation.yaml
|
|
steps:
|
|
- skill: {name}
|
|
args:
|
|
- "skills/my.skill/skill.yaml"
|
|
- "--overwrite=true"
|
|
```
|
|
|
|
### Use with Other Skills
|
|
|
|
```bash
|
|
# Generate documentation for a newly created skill
|
|
python skills/skill.create/skill_create.py my.new.skill
|
|
python skills/{name}/{'generate_docs.py' if '.' in name else name + '.py'} skills/my.new.skill/skill.yaml
|
|
```
|
|
|
|
### Batch Documentation Generation
|
|
|
|
```bash
|
|
# Generate docs for all skills
|
|
for manifest in skills/*/skill.yaml; do
|
|
python skills/{name}/{'generate_docs.py' if '.' in name else name + '.py'} "$manifest" --overwrite=true
|
|
done
|
|
```
|
|
|
|
## Output Structure
|
|
|
|
The generated SKILL.md includes:
|
|
|
|
1. **Overview** - Skill name and brief description
|
|
2. **Purpose** - Detailed explanation of what the skill does
|
|
3. **Usage** - Command-line usage examples
|
|
4. **Parameters** - Detailed input parameter documentation
|
|
5. **Outputs** - Description of skill outputs
|
|
6. **Usage Template** - Practical examples
|
|
7. **Integration Notes** - How to use with workflows and other skills
|
|
|
|
## Dependencies
|
|
|
|
"""
|
|
|
|
if dependencies:
|
|
for dep in dependencies:
|
|
doc += f"- **{dep}**: Required dependency\n"
|
|
else:
|
|
doc += "_No external dependencies_\n"
|
|
|
|
doc += "\n## Tags\n\n"
|
|
if tags:
|
|
doc += ', '.join(f'`{tag}`' for tag in tags) + '\n'
|
|
else:
|
|
doc += "_No tags defined_\n"
|
|
|
|
doc += f"""
|
|
## See Also
|
|
|
|
- [Betty Architecture](../../docs/betty-architecture.md) - Five-layer model
|
|
- [Skill Development Guide](../../docs/skill-development.md) - Creating new skills
|
|
|
|
## Version
|
|
|
|
**{version}** - Generated documentation from skill manifest
|
|
"""
|
|
|
|
return doc
|
|
|
|
|
|
def generate_docs(
|
|
manifest_path: str,
|
|
overwrite: bool = False,
|
|
dry_run: bool = False,
|
|
output_path: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Generate SKILL.md documentation from a skill manifest.
|
|
|
|
Args:
|
|
manifest_path: Path to skill.yaml file
|
|
overwrite: Whether to overwrite existing SKILL.md
|
|
dry_run: Preview without writing
|
|
output_path: Custom output path (optional)
|
|
|
|
Returns:
|
|
Result dictionary with doc path and content
|
|
|
|
Raises:
|
|
BettyError: If manifest is invalid or file operations fail
|
|
"""
|
|
# Load manifest
|
|
manifest = load_skill_manifest(manifest_path)
|
|
|
|
# Generate documentation
|
|
doc_content = generate_skill_documentation(manifest)
|
|
|
|
# Determine output path
|
|
if output_path:
|
|
doc_path = Path(output_path)
|
|
else:
|
|
# Default to same directory as manifest
|
|
manifest_file = Path(manifest_path)
|
|
doc_path = manifest_file.parent / "SKILL.md"
|
|
|
|
# Check if file exists and overwrite is False
|
|
if doc_path.exists() and not overwrite and not dry_run:
|
|
raise BettyError(
|
|
f"SKILL.md already exists at {doc_path}. "
|
|
f"Use --overwrite=true to replace it or --dry-run=true to preview."
|
|
)
|
|
|
|
result = {
|
|
"doc_path": str(doc_path),
|
|
"doc_content": doc_content,
|
|
"skill_name": manifest.get('name', 'unknown'),
|
|
"dry_run": dry_run
|
|
}
|
|
|
|
if dry_run:
|
|
result["dry_run_preview"] = doc_content
|
|
logger.info(f"DRY RUN: Would write documentation to {doc_path}")
|
|
print("\n" + "="*80)
|
|
print("DRY RUN PREVIEW")
|
|
print("="*80)
|
|
print(doc_content)
|
|
print("="*80)
|
|
return result
|
|
|
|
# Write documentation to file
|
|
try:
|
|
doc_path.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(doc_path, 'w') as f:
|
|
f.write(doc_content)
|
|
logger.info(f"Generated SKILL.md at {doc_path}")
|
|
except Exception as e:
|
|
raise BettyError(f"Failed to write documentation: {e}")
|
|
|
|
return result
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Generate or update SKILL.md documentation from skill.yaml manifest"
|
|
)
|
|
parser.add_argument(
|
|
"manifest_path",
|
|
type=str,
|
|
help="Path to skill.yaml manifest file"
|
|
)
|
|
parser.add_argument(
|
|
"--overwrite",
|
|
type=lambda x: x.lower() in ['true', '1', 'yes'],
|
|
default=False,
|
|
help="Overwrite existing SKILL.md file (default: false)"
|
|
)
|
|
parser.add_argument(
|
|
"--dry-run",
|
|
type=lambda x: x.lower() in ['true', '1', 'yes'],
|
|
default=False,
|
|
help="Preview without writing to disk (default: false)"
|
|
)
|
|
parser.add_argument(
|
|
"--output-path",
|
|
type=str,
|
|
help="Custom output path for SKILL.md (optional)"
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
try:
|
|
# Check if PyYAML is installed
|
|
try:
|
|
import yaml
|
|
except ImportError:
|
|
raise BettyError(
|
|
"PyYAML is required for generate.docs. Install with: pip install pyyaml"
|
|
)
|
|
|
|
# Generate documentation
|
|
logger.info(f"Generating documentation from {args.manifest_path}")
|
|
result = generate_docs(
|
|
manifest_path=args.manifest_path,
|
|
overwrite=args.overwrite,
|
|
dry_run=args.dry_run,
|
|
output_path=args.output_path
|
|
)
|
|
|
|
# Return structured result
|
|
output = {
|
|
"status": "success",
|
|
"data": result
|
|
}
|
|
|
|
if not args.dry_run:
|
|
print(json.dumps(output, indent=2))
|
|
|
|
except Exception as e:
|
|
logger.error(f"Failed to generate documentation: {e}")
|
|
print(json.dumps(format_error_response(e), indent=2))
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|