Files
gh-epieczko-betty/skills/generate.docs/generate_docs.py
2025-11-29 18:26:08 +08:00

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()