402 lines
13 KiB
Python
Executable File
402 lines
13 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
artifact.create skill - AI-assisted artifact generation from templates
|
|
|
|
Loads templates based on artifact type, populates them with user-provided context,
|
|
and generates professional, ready-to-use artifacts.
|
|
"""
|
|
|
|
import sys
|
|
import os
|
|
import argparse
|
|
from pathlib import Path
|
|
from datetime import datetime
|
|
from typing import Dict, Any, Optional, Tuple
|
|
import re
|
|
import yaml
|
|
|
|
# Add parent directory to path for governance imports
|
|
from betty.governance import enforce_governance, log_governance_action
|
|
|
|
|
|
def load_artifact_registry() -> Dict[str, Any]:
|
|
"""Load artifact registry from artifact.define skill"""
|
|
registry_file = Path(__file__).parent.parent / "artifact.define" / "artifact_define.py"
|
|
|
|
if not registry_file.exists():
|
|
raise FileNotFoundError(f"Artifact registry not found: {registry_file}")
|
|
|
|
with open(registry_file, 'r') as f:
|
|
content = f.read()
|
|
|
|
# Find KNOWN_ARTIFACT_TYPES dictionary
|
|
start_marker = "KNOWN_ARTIFACT_TYPES = {"
|
|
start_idx = content.find(start_marker)
|
|
if start_idx == -1:
|
|
raise ValueError("Could not find KNOWN_ARTIFACT_TYPES in registry file")
|
|
|
|
start_idx += len(start_marker) - 1 # Include the {
|
|
|
|
# Find matching closing brace
|
|
brace_count = 0
|
|
end_idx = start_idx
|
|
for i in range(start_idx, len(content)):
|
|
if content[i] == '{':
|
|
brace_count += 1
|
|
elif content[i] == '}':
|
|
brace_count -= 1
|
|
if brace_count == 0:
|
|
end_idx = i + 1
|
|
break
|
|
|
|
dict_str = content[start_idx:end_idx]
|
|
artifacts = eval(dict_str) # Safe since it's our own code
|
|
return artifacts
|
|
|
|
|
|
def find_template_path(artifact_type: str) -> Optional[Path]:
|
|
"""Find the template file for a given artifact type"""
|
|
templates_dir = Path(__file__).parent.parent.parent / "templates"
|
|
|
|
if not templates_dir.exists():
|
|
raise FileNotFoundError(f"Templates directory not found: {templates_dir}")
|
|
|
|
# Search all subdirectories for the template
|
|
for template_file in templates_dir.rglob(f"{artifact_type}.*"):
|
|
if template_file.is_file() and template_file.suffix in ['.yaml', '.yml', '.md']:
|
|
return template_file
|
|
|
|
return None
|
|
|
|
|
|
def get_artifact_description_path(artifact_type: str) -> Optional[Path]:
|
|
"""Get path to artifact description file for reference"""
|
|
desc_dir = Path(__file__).parent.parent.parent / "artifact_descriptions"
|
|
desc_file = desc_dir / f"{artifact_type}.md"
|
|
|
|
if desc_file.exists():
|
|
return desc_file
|
|
return None
|
|
|
|
|
|
def substitute_metadata(template_content: str, metadata: Optional[Dict[str, Any]] = None) -> str:
|
|
"""Substitute metadata placeholders in template"""
|
|
if metadata is None:
|
|
metadata = {}
|
|
|
|
# Default metadata
|
|
today = datetime.now().strftime("%Y-%m-%d")
|
|
defaults = {
|
|
'date': today,
|
|
'your_name': metadata.get('author', 'TODO: Add author name'),
|
|
'role': metadata.get('role', 'TODO: Define role'),
|
|
'approver_name': metadata.get('approver_name', 'TODO: Add approver name'),
|
|
'approver_role': metadata.get('approver_role', 'TODO: Add approver role'),
|
|
'artifact_type': metadata.get('artifact_type', 'TODO: Specify artifact type'),
|
|
'path': metadata.get('path', 'TODO: Add path'),
|
|
}
|
|
|
|
# Override with provided metadata
|
|
defaults.update(metadata)
|
|
|
|
# Perform substitutions
|
|
result = template_content
|
|
for key, value in defaults.items():
|
|
result = result.replace(f"{{{{{key}}}}}", str(value))
|
|
|
|
return result
|
|
|
|
|
|
def populate_yaml_template(template_content: str, context: str, artifact_type: str) -> str:
|
|
"""Populate YAML template with context-aware content"""
|
|
|
|
# Parse the template to understand structure
|
|
lines = template_content.split('\n')
|
|
result_lines = []
|
|
in_content_section = False
|
|
|
|
for line in lines:
|
|
# Check if we're entering the content section
|
|
if line.strip().startswith('content:') or line.strip().startswith('# Content'):
|
|
in_content_section = True
|
|
result_lines.append(line)
|
|
continue
|
|
|
|
# If we're in content section and find a TODO, replace with context hint
|
|
if in_content_section and 'TODO:' in line:
|
|
indent = len(line) - len(line.lstrip())
|
|
# Keep the TODO but add a hint about using the context
|
|
result_lines.append(line)
|
|
result_lines.append(f"{' ' * indent}# Context provided: {context[:100]}...")
|
|
else:
|
|
result_lines.append(line)
|
|
|
|
return '\n'.join(result_lines)
|
|
|
|
|
|
def populate_markdown_template(template_content: str, context: str, artifact_type: str) -> str:
|
|
"""Populate Markdown template with context-aware content"""
|
|
|
|
# Add context as a note in the document
|
|
lines = template_content.split('\n')
|
|
result_lines = []
|
|
|
|
# Find the first heading and add context after it
|
|
first_heading_found = False
|
|
for line in lines:
|
|
result_lines.append(line)
|
|
|
|
if not first_heading_found and line.startswith('# '):
|
|
first_heading_found = True
|
|
result_lines.append('')
|
|
result_lines.append(f'> **Context**: {context}')
|
|
result_lines.append('')
|
|
|
|
return '\n'.join(result_lines)
|
|
|
|
|
|
def load_existing_artifact_metadata(artifact_path: Path) -> Optional[Dict[str, Any]]:
|
|
"""
|
|
Load metadata from an existing artifact file.
|
|
|
|
Args:
|
|
artifact_path: Path to the existing artifact file
|
|
|
|
Returns:
|
|
Dictionary containing artifact metadata, or None if file doesn't exist
|
|
"""
|
|
if not artifact_path.exists():
|
|
return None
|
|
|
|
try:
|
|
with open(artifact_path, 'r') as f:
|
|
content = f.read()
|
|
|
|
# Try to parse as YAML first
|
|
try:
|
|
data = yaml.safe_load(content)
|
|
if isinstance(data, dict) and 'metadata' in data:
|
|
return data['metadata']
|
|
except yaml.YAMLError:
|
|
pass
|
|
|
|
# If YAML parsing fails or no metadata found, return None
|
|
return None
|
|
|
|
except Exception as e:
|
|
# If we can't read the file, return None
|
|
return None
|
|
|
|
|
|
def generate_artifact(
|
|
artifact_type: str,
|
|
context: str,
|
|
output_path: str,
|
|
metadata: Optional[Dict[str, Any]] = None
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
Generate an artifact from template with AI-assisted population
|
|
|
|
Args:
|
|
artifact_type: Type of artifact (must exist in KNOWN_ARTIFACT_TYPES)
|
|
context: Business context for populating the artifact
|
|
output_path: Where to save the generated artifact
|
|
metadata: Optional metadata overrides
|
|
|
|
Returns:
|
|
Generation report with status, path, and details
|
|
"""
|
|
|
|
# Validate artifact type
|
|
artifacts = load_artifact_registry()
|
|
if artifact_type not in artifacts:
|
|
return {
|
|
'success': False,
|
|
'error': f"Unknown artifact type: {artifact_type}",
|
|
'available_types': list(artifacts.keys())[:10] # Show first 10 as hint
|
|
}
|
|
|
|
# Find template
|
|
template_path = find_template_path(artifact_type)
|
|
if not template_path:
|
|
return {
|
|
'success': False,
|
|
'error': f"No template found for artifact type: {artifact_type}",
|
|
'artifact_type': artifact_type
|
|
}
|
|
|
|
# Load template
|
|
with open(template_path, 'r') as f:
|
|
template_content = f.read()
|
|
|
|
# Determine format
|
|
artifact_format = template_path.suffix.lstrip('.')
|
|
|
|
# Substitute metadata placeholders
|
|
populated_content = substitute_metadata(template_content, metadata)
|
|
|
|
# Populate with context
|
|
if artifact_format in ['yaml', 'yml']:
|
|
populated_content = populate_yaml_template(populated_content, context, artifact_type)
|
|
elif artifact_format == 'md':
|
|
populated_content = populate_markdown_template(populated_content, context, artifact_type)
|
|
|
|
# Ensure output directory exists
|
|
output_file = Path(output_path)
|
|
output_file.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
# Governance check: Enforce governance on existing artifact before overwriting
|
|
existing_metadata = load_existing_artifact_metadata(output_file)
|
|
if existing_metadata:
|
|
try:
|
|
# Add artifact ID if not present
|
|
if 'id' not in existing_metadata:
|
|
existing_metadata['id'] = str(output_file)
|
|
|
|
enforce_governance(existing_metadata)
|
|
|
|
# Log successful governance check
|
|
log_governance_action(
|
|
artifact_id=existing_metadata.get('id', str(output_file)),
|
|
action="write",
|
|
outcome="allowed",
|
|
message="Governance check passed, allowing artifact update",
|
|
metadata={
|
|
'artifact_type': artifact_type,
|
|
'output_path': str(output_file)
|
|
}
|
|
)
|
|
|
|
except PermissionError as e:
|
|
# Governance policy violation - return error
|
|
return {
|
|
'success': False,
|
|
'error': f"Governance policy violation: {str(e)}",
|
|
'artifact_type': artifact_type,
|
|
'policy_violation': True,
|
|
'existing_metadata': existing_metadata
|
|
}
|
|
except ValueError as e:
|
|
# Invalid metadata - log warning but allow write
|
|
log_governance_action(
|
|
artifact_id=str(output_file),
|
|
action="write",
|
|
outcome="warning",
|
|
message=f"Invalid metadata in existing artifact: {str(e)}",
|
|
metadata={
|
|
'artifact_type': artifact_type,
|
|
'output_path': str(output_file)
|
|
}
|
|
)
|
|
|
|
# Save generated artifact
|
|
with open(output_file, 'w') as f:
|
|
f.write(populated_content)
|
|
|
|
# Get artifact description path for reference
|
|
desc_path = get_artifact_description_path(artifact_type)
|
|
|
|
# Generate report
|
|
report = {
|
|
'success': True,
|
|
'artifact_file': str(output_file.absolute()),
|
|
'artifact_type': artifact_type,
|
|
'artifact_format': artifact_format,
|
|
'template_used': str(template_path.name),
|
|
'artifact_description': str(desc_path) if desc_path else None,
|
|
'context_length': len(context),
|
|
'generated_at': datetime.now().isoformat(),
|
|
'next_steps': [
|
|
f"Review the generated artifact at: {output_file}",
|
|
f"Refer to comprehensive guidance at: {desc_path}" if desc_path else "Review and customize the content",
|
|
"Replace any remaining TODO markers with specific information",
|
|
"Validate the artifact structure and content",
|
|
"Update metadata (status, approvers, etc.) as needed"
|
|
]
|
|
}
|
|
|
|
return report
|
|
|
|
|
|
def main():
|
|
"""Main entry point for artifact.create skill"""
|
|
parser = argparse.ArgumentParser(
|
|
description='Create artifacts from templates with AI-assisted population'
|
|
)
|
|
parser.add_argument(
|
|
'artifact_type',
|
|
type=str,
|
|
help='Type of artifact to create (e.g., business-case, threat-model)'
|
|
)
|
|
parser.add_argument(
|
|
'context',
|
|
type=str,
|
|
help='Business context for populating the artifact'
|
|
)
|
|
parser.add_argument(
|
|
'output_path',
|
|
type=str,
|
|
help='Path where the generated artifact should be saved'
|
|
)
|
|
parser.add_argument(
|
|
'--author',
|
|
type=str,
|
|
help='Author name for metadata'
|
|
)
|
|
parser.add_argument(
|
|
'--classification',
|
|
type=str,
|
|
choices=['Public', 'Internal', 'Confidential', 'Restricted'],
|
|
help='Document classification level'
|
|
)
|
|
|
|
args = parser.parse_args()
|
|
|
|
# Build metadata from arguments
|
|
metadata = {}
|
|
if args.author:
|
|
metadata['author'] = args.author
|
|
metadata['your_name'] = args.author
|
|
if args.classification:
|
|
metadata['classification'] = args.classification
|
|
|
|
# Generate artifact
|
|
report = generate_artifact(
|
|
artifact_type=args.artifact_type,
|
|
context=args.context,
|
|
output_path=args.output_path,
|
|
metadata=metadata if metadata else None
|
|
)
|
|
|
|
# Print report
|
|
if report['success']:
|
|
print(f"\n{'='*70}")
|
|
print(f"✓ Artifact Generated Successfully")
|
|
print(f"{'='*70}")
|
|
print(f"Type: {report['artifact_type']}")
|
|
print(f"Format: {report['artifact_format']}")
|
|
print(f"Output: {report['artifact_file']}")
|
|
if report.get('artifact_description'):
|
|
print(f"Guide: {report['artifact_description']}")
|
|
print(f"\nNext Steps:")
|
|
for i, step in enumerate(report['next_steps'], 1):
|
|
print(f" {i}. {step}")
|
|
print(f"{'='*70}\n")
|
|
return 0
|
|
else:
|
|
print(f"\n{'='*70}")
|
|
print(f"✗ Artifact Generation Failed")
|
|
print(f"{'='*70}")
|
|
print(f"Error: {report['error']}")
|
|
if 'available_types' in report:
|
|
print(f"\nAvailable artifact types (showing first 10):")
|
|
for atype in report['available_types']:
|
|
print(f" - {atype}")
|
|
print(f" ... and more")
|
|
print(f"{'='*70}\n")
|
|
return 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|