chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,586 @@
|
||||
"""
|
||||
SKILL.md Generator for CLI-Anything
|
||||
|
||||
This module extracts metadata from CLI-Anything harnesses and generates
|
||||
SKILL.md files following the skill-creator methodology.
|
||||
|
||||
The generated SKILL.md files contain:
|
||||
- YAML frontmatter with name and description (triggering metadata)
|
||||
- Markdown body with usage instructions
|
||||
- Command documentation
|
||||
- Examples for AI agents
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
def _format_display_name(name: str) -> str:
|
||||
"""Format software name for display (replace underscores/hyphens with spaces, then title)."""
|
||||
return name.replace("_", " ").replace("-", " ").title()
|
||||
|
||||
|
||||
def _canonical_skill_name(harness_path: Path, software_name: str) -> str:
|
||||
"""Return the repo-root canonical skill id for a harness."""
|
||||
software_dir = software_name
|
||||
if harness_path.name == "agent-harness" and harness_path.parent.name:
|
||||
software_dir = harness_path.parent.name
|
||||
return f"cli-anything-{software_dir.replace('_', '-')}"
|
||||
|
||||
|
||||
def _click_declared_name(decorator_args: str, fallback: str) -> str:
|
||||
"""Return the Click command/group name declared in a decorator."""
|
||||
match = re.search(r'^\s*["\']([^"\']+)["\']', decorator_args)
|
||||
if not match:
|
||||
match = re.search(r'\bname\s*=\s*["\']([^"\']+)["\']', decorator_args)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return fallback.replace("_", "-")
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandInfo:
|
||||
"""Information about a CLI command."""
|
||||
name: str
|
||||
description: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class CommandGroup:
|
||||
"""A group of related CLI commands."""
|
||||
name: str
|
||||
description: str
|
||||
commands: list[CommandInfo] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Example:
|
||||
"""An example of CLI usage."""
|
||||
title: str
|
||||
description: str
|
||||
code: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillMetadata:
|
||||
"""Metadata extracted from a CLI-Anything harness."""
|
||||
skill_name: str
|
||||
skill_description: str
|
||||
software_name: str
|
||||
skill_intro: str
|
||||
version: str
|
||||
system_package: Optional[str] = None
|
||||
command_groups: list[CommandGroup] = field(default_factory=list)
|
||||
examples: list[Example] = field(default_factory=list)
|
||||
|
||||
|
||||
def extract_cli_metadata(harness_path: str) -> SkillMetadata:
|
||||
"""
|
||||
Extract metadata from a CLI-Anything harness directory.
|
||||
|
||||
Args:
|
||||
harness_path: Path to the agent-harness directory
|
||||
|
||||
Returns:
|
||||
SkillMetadata containing extracted information
|
||||
"""
|
||||
harness_path = Path(harness_path)
|
||||
|
||||
# Find the cli_anything/<software> directory
|
||||
cli_anything_dir = harness_path / "cli_anything"
|
||||
if not cli_anything_dir.exists():
|
||||
raise ValueError(
|
||||
f"cli_anything directory not found in {harness_path}. "
|
||||
"Ensure the harness structure includes cli_anything/<software>/"
|
||||
)
|
||||
software_dirs = [d for d in cli_anything_dir.iterdir()
|
||||
if d.is_dir() and (d / "__init__.py").exists()]
|
||||
|
||||
if not software_dirs:
|
||||
raise ValueError(f"No CLI package found in {harness_path}")
|
||||
|
||||
software_dir = software_dirs[0]
|
||||
software_name = software_dir.name
|
||||
|
||||
# Extract metadata from README.md
|
||||
readme_path = software_dir / "README.md"
|
||||
skill_intro = ""
|
||||
system_package = None
|
||||
|
||||
if readme_path.exists():
|
||||
readme_content = readme_path.read_text(encoding="utf-8")
|
||||
skill_intro = extract_intro_from_readme(readme_content)
|
||||
system_package = extract_system_package(readme_content)
|
||||
|
||||
# Extract version from setup.py
|
||||
setup_path = harness_path / "setup.py"
|
||||
version = "1.0.0"
|
||||
|
||||
if setup_path.exists():
|
||||
version = extract_version_from_setup(setup_path)
|
||||
|
||||
# Extract commands from CLI file
|
||||
cli_file = software_dir / f"{software_name}_cli.py"
|
||||
command_groups = []
|
||||
|
||||
if cli_file.exists():
|
||||
command_groups = extract_commands_from_cli(cli_file)
|
||||
|
||||
# Generate examples based on software type
|
||||
examples = generate_examples(software_name, command_groups)
|
||||
|
||||
# Build skill name and description
|
||||
skill_name = _canonical_skill_name(harness_path, software_name)
|
||||
if skill_intro:
|
||||
intro_snippet = skill_intro[:100]
|
||||
suffix = "..." if len(skill_intro) > 100 else ""
|
||||
skill_description = f"Command-line interface for {_format_display_name(software_name)} - {intro_snippet}{suffix}"
|
||||
else:
|
||||
skill_description = f"Command-line interface for {_format_display_name(software_name)}"
|
||||
|
||||
return SkillMetadata(
|
||||
skill_name=skill_name,
|
||||
skill_description=skill_description,
|
||||
software_name=software_name,
|
||||
skill_intro=skill_intro,
|
||||
version=version,
|
||||
system_package=system_package,
|
||||
command_groups=command_groups,
|
||||
examples=examples
|
||||
)
|
||||
|
||||
|
||||
def extract_intro_from_readme(content: str) -> str:
|
||||
"""Extract introduction text from README content."""
|
||||
# Find the first paragraph after the title
|
||||
lines = content.split("\n")
|
||||
intro_lines = []
|
||||
in_intro = False
|
||||
|
||||
for line in lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
if in_intro and intro_lines:
|
||||
break
|
||||
continue
|
||||
if line.startswith("# "):
|
||||
in_intro = True
|
||||
continue
|
||||
if line.startswith("##"):
|
||||
break
|
||||
if in_intro:
|
||||
intro_lines.append(line)
|
||||
|
||||
return " ".join(intro_lines) or f"CLI interface for the software."
|
||||
|
||||
|
||||
def extract_system_package(content: str) -> Optional[str]:
|
||||
"""Extract system package installation command from README."""
|
||||
# Look for apt/brew install patterns
|
||||
patterns = [
|
||||
r"`apt install ([\w\-]+)`",
|
||||
r"`brew install ([\w\-]+)`",
|
||||
r"`apt-get install ([\w\-]+)`",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, content)
|
||||
if match:
|
||||
package = match.group(1)
|
||||
if "apt-get" in pattern:
|
||||
return f"apt-get install {package}"
|
||||
elif "apt" in pattern:
|
||||
return f"apt install {package}"
|
||||
elif "brew" in pattern:
|
||||
return f"brew install {package}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def extract_version_from_setup(setup_path: Path) -> str:
|
||||
"""Extract version from setup.py."""
|
||||
content = setup_path.read_text(encoding="utf-8")
|
||||
match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return "1.0.0"
|
||||
|
||||
|
||||
def extract_commands_from_cli(cli_path: Path) -> list[CommandGroup]:
|
||||
"""Extract command groups and commands from CLI file."""
|
||||
content = cli_path.read_text(encoding="utf-8")
|
||||
groups = []
|
||||
|
||||
# Find Click group decorators
|
||||
# Pattern handles:
|
||||
# - Multi-line decorators (decorators on separate lines)
|
||||
# - Docstrings on the same line or following line after function definition
|
||||
# - Various Click decorator patterns like @click.option(), @click.argument()
|
||||
# Uses re.DOTALL to match across newlines between decorator and def
|
||||
optional_decorator_pattern = r'(?:\s*@[\w.]+(?:\([^)]*\))?)*'
|
||||
|
||||
group_pattern = (
|
||||
r'@(\w+)\.group\(([^)]*)\)' # @xxx.group(...)
|
||||
+ optional_decorator_pattern # optional additional decorators
|
||||
+ r'\s*def\s+(\w+)\([^)]*\)' # def xxx(...):
|
||||
+ r':\s*' # colon with optional whitespace
|
||||
+ r'(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')?' # optional docstring (""" or ''')
|
||||
)
|
||||
|
||||
group_lookup = {}
|
||||
group_display_paths = {}
|
||||
group_matches = list(re.finditer(group_pattern, content))
|
||||
root_group_funcs = {
|
||||
match.group(3).lower()
|
||||
for match in group_matches
|
||||
if match.group(1).lower() == "click"
|
||||
}
|
||||
|
||||
for match in group_matches:
|
||||
group_parent = match.group(1)
|
||||
group_args = match.group(2)
|
||||
group_func = match.group(3)
|
||||
# Docstring can be in group 4 (triple-double) or group 5 (triple-single)
|
||||
group_doc = (match.group(4) or match.group(5) or "").strip()
|
||||
|
||||
local_group_name = _format_display_name(_click_declared_name(group_args, group_func))
|
||||
parent_key = group_parent.lower()
|
||||
if parent_key == "click" or parent_key in root_group_funcs:
|
||||
group_path = [local_group_name]
|
||||
else:
|
||||
parent_path = group_display_paths.get(parent_key)
|
||||
if parent_path:
|
||||
group_path = [*parent_path, local_group_name]
|
||||
else:
|
||||
group_path = [_format_display_name(group_parent), local_group_name]
|
||||
|
||||
group_name = " ".join(group_path)
|
||||
|
||||
group = CommandGroup(
|
||||
name=group_name,
|
||||
description=group_doc or f"Commands for {group_name.lower()} operations.",
|
||||
commands=[]
|
||||
)
|
||||
groups.append(group)
|
||||
group_lookup[group_func.lower()] = group
|
||||
group_display_paths[group_func.lower()] = group_path
|
||||
|
||||
# Find Click command decorators
|
||||
# Pattern handles:
|
||||
# - Multi-line decorators (decorators on separate lines)
|
||||
# - Docstrings on the same line or following line after function definition
|
||||
# - Various Click decorator patterns like @click.option(), @click.argument()
|
||||
command_pattern = (
|
||||
r'@(\w+)\.command\(([^)]*)\)' # @xxx.command(...)
|
||||
+ optional_decorator_pattern # optional additional decorators
|
||||
+ r'\s*def\s+(\w+)\([^)]*\)' # def xxx(...):
|
||||
+ r':\s*' # colon with optional whitespace
|
||||
+ r'(?:"""([\s\S]*?)"""|\'\'\'([\s\S]*?)\'\'\')?' # optional docstring (""" or ''')
|
||||
)
|
||||
|
||||
for match in re.finditer(command_pattern, content):
|
||||
group_name = match.group(1)
|
||||
cmd_args = match.group(2)
|
||||
cmd_func = match.group(3)
|
||||
# Docstring can be in group 4 (triple-double) or group 5 (triple-single)
|
||||
cmd_doc = (match.group(4) or match.group(5) or "").strip()
|
||||
|
||||
# Find the matching group
|
||||
group = group_lookup.get(group_name.lower())
|
||||
if group:
|
||||
cmd_name = _click_declared_name(cmd_args, cmd_func)
|
||||
group.commands.append(CommandInfo(
|
||||
name=cmd_name,
|
||||
description=cmd_doc or f"Execute {cmd_func} operation."
|
||||
))
|
||||
|
||||
# If no groups found, create a default one with all commands
|
||||
if not groups:
|
||||
default_group = CommandGroup(
|
||||
name="General",
|
||||
description="General commands for the CLI.",
|
||||
commands=[]
|
||||
)
|
||||
|
||||
for match in re.finditer(command_pattern, content):
|
||||
cmd_args = match.group(2)
|
||||
cmd_func = match.group(3)
|
||||
# Docstring can be in group 4 (triple-double) or group 5 (triple-single)
|
||||
cmd_doc = (match.group(4) or match.group(5) or "").strip()
|
||||
cmd_name = _click_declared_name(cmd_args, cmd_func)
|
||||
default_group.commands.append(CommandInfo(
|
||||
name=cmd_name,
|
||||
description=cmd_doc or f"Execute {cmd_func} operation."
|
||||
))
|
||||
|
||||
if default_group.commands:
|
||||
groups.append(default_group)
|
||||
|
||||
return groups
|
||||
|
||||
|
||||
def generate_examples(software_name: str, command_groups: list[CommandGroup]) -> list[Example]:
|
||||
"""Generate usage examples based on software type and available commands."""
|
||||
examples = []
|
||||
|
||||
# Basic project creation example
|
||||
examples.append(Example(
|
||||
title="Create a New Project",
|
||||
description=f"Create a new {software_name} project file.",
|
||||
code=f"""cli-anything-{software_name} project new -o myproject.json
|
||||
# Or with JSON output for programmatic use
|
||||
cli-anything-{software_name} --json project new -o myproject.json"""
|
||||
))
|
||||
|
||||
# REPL usage example
|
||||
examples.append(Example(
|
||||
title="Interactive REPL Session",
|
||||
description="Start an interactive session with undo/redo support.",
|
||||
code=f"""cli-anything-{software_name}
|
||||
# Enter commands interactively
|
||||
# Use 'help' to see available commands
|
||||
# Use 'undo' and 'redo' for history navigation"""
|
||||
))
|
||||
|
||||
# Export example if export commands exist
|
||||
for group in command_groups:
|
||||
if "export" in group.name.lower():
|
||||
examples.append(Example(
|
||||
title="Export Project",
|
||||
description="Export the project to a final output format.",
|
||||
code=f"""cli-anything-{software_name} --project myproject.json export render output.pdf --overwrite"""
|
||||
))
|
||||
break
|
||||
|
||||
return examples
|
||||
|
||||
|
||||
def generate_skill_md(metadata: SkillMetadata, template_path: Optional[str] = None) -> str:
|
||||
"""
|
||||
Generate SKILL.md content from metadata using Jinja2 template.
|
||||
|
||||
Args:
|
||||
metadata: SkillMetadata containing CLI information
|
||||
template_path: Optional path to custom template file
|
||||
|
||||
Returns:
|
||||
Generated SKILL.md content as string
|
||||
"""
|
||||
try:
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
except ImportError:
|
||||
# Fallback to simple string formatting if Jinja2 not available
|
||||
return generate_skill_md_simple(metadata)
|
||||
|
||||
# Load template
|
||||
if template_path is None:
|
||||
template_path = Path(__file__).parent / "templates" / "SKILL.md.template"
|
||||
else:
|
||||
template_path = Path(template_path)
|
||||
|
||||
if not template_path.exists():
|
||||
return generate_skill_md_simple(metadata)
|
||||
|
||||
env = Environment(loader=FileSystemLoader(template_path.parent))
|
||||
template = env.get_template(template_path.name)
|
||||
|
||||
# Render template
|
||||
return template.render(
|
||||
skill_name=metadata.skill_name,
|
||||
skill_description=metadata.skill_description,
|
||||
software_name=metadata.software_name,
|
||||
skill_intro=metadata.skill_intro,
|
||||
version=metadata.version,
|
||||
system_package=metadata.system_package,
|
||||
command_groups=[{
|
||||
"name": g.name,
|
||||
"description": g.description,
|
||||
"commands": [{"name": c.name, "description": c.description} for c in g.commands]
|
||||
} for g in metadata.command_groups],
|
||||
examples=[{
|
||||
"title": e.title,
|
||||
"description": e.description,
|
||||
"code": e.code
|
||||
} for e in metadata.examples]
|
||||
)
|
||||
|
||||
|
||||
def generate_skill_md_simple(metadata: SkillMetadata) -> str:
|
||||
"""Generate SKILL.md without Jinja2 dependency."""
|
||||
lines = [
|
||||
"---",
|
||||
f'name: "{metadata.skill_name}"',
|
||||
f'description: "{metadata.skill_description}"',
|
||||
"---",
|
||||
"",
|
||||
f"# {metadata.skill_name}",
|
||||
"",
|
||||
metadata.skill_intro,
|
||||
"",
|
||||
"## Installation",
|
||||
"",
|
||||
f"This CLI is installed as part of the cli-anything-{metadata.software_name} package:",
|
||||
"",
|
||||
f"```bash",
|
||||
f"pip install cli-anything-{metadata.software_name}",
|
||||
f"```",
|
||||
"",
|
||||
"**Prerequisites:**",
|
||||
"- Python 3.10+",
|
||||
f"- {_format_display_name(metadata.software_name)} must be installed on your system",
|
||||
]
|
||||
|
||||
if metadata.system_package:
|
||||
lines.extend([
|
||||
f"- Install {metadata.software_name}: `{metadata.system_package}`"
|
||||
])
|
||||
|
||||
lines.extend([
|
||||
"",
|
||||
"## Usage",
|
||||
"",
|
||||
"### Basic Commands",
|
||||
"",
|
||||
"```bash",
|
||||
"# Show help",
|
||||
f"cli-anything-{metadata.software_name} --help",
|
||||
"",
|
||||
"# Start interactive REPL mode",
|
||||
f"cli-anything-{metadata.software_name}",
|
||||
"",
|
||||
"# Create a new project",
|
||||
f"cli-anything-{metadata.software_name} project new -o project.json",
|
||||
"",
|
||||
"# Run with JSON output (for agent consumption)",
|
||||
f"cli-anything-{metadata.software_name} --json project info -p project.json",
|
||||
"```",
|
||||
"",
|
||||
])
|
||||
|
||||
# Add command groups
|
||||
if metadata.command_groups:
|
||||
lines.append("## Command Groups")
|
||||
lines.append("")
|
||||
|
||||
for group in metadata.command_groups:
|
||||
lines.append(f"### {group.name}")
|
||||
lines.append("")
|
||||
lines.append(group.description)
|
||||
lines.append("")
|
||||
|
||||
if group.commands:
|
||||
lines.append("| Command | Description |")
|
||||
lines.append("|---------|-------------|")
|
||||
for cmd in group.commands:
|
||||
lines.append(f"| `{cmd.name}` | {cmd.description} |")
|
||||
lines.append("")
|
||||
|
||||
# Add examples
|
||||
if metadata.examples:
|
||||
lines.append("## Examples")
|
||||
lines.append("")
|
||||
|
||||
for example in metadata.examples:
|
||||
lines.append(f"### {example.title}")
|
||||
lines.append("")
|
||||
lines.append(example.description)
|
||||
lines.append("")
|
||||
lines.append("```bash")
|
||||
lines.append(example.code)
|
||||
lines.append("```")
|
||||
lines.append("")
|
||||
|
||||
# Add AI agent guidance
|
||||
lines.extend([
|
||||
"## For AI Agents",
|
||||
"",
|
||||
"When using this CLI programmatically:",
|
||||
"",
|
||||
"1. **Always use `--json` flag** for parseable output",
|
||||
"2. **Check return codes** - 0 for success, non-zero for errors",
|
||||
"3. **Parse stderr** for error messages on failure",
|
||||
"4. **Use absolute paths** for all file operations",
|
||||
"5. **Verify outputs exist** after export operations",
|
||||
"",
|
||||
"## Version",
|
||||
"",
|
||||
metadata.version,
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def generate_skill_file(harness_path: str, output_path: Optional[str] = None,
|
||||
template_path: Optional[str] = None) -> str:
|
||||
"""
|
||||
Generate a SKILL.md file for a CLI-Anything harness.
|
||||
|
||||
Args:
|
||||
harness_path: Path to the agent-harness directory
|
||||
output_path: Optional output path for SKILL.md
|
||||
(default: skills/cli-anything-<software>/SKILL.md)
|
||||
template_path: Optional path to custom Jinja2 template
|
||||
|
||||
Returns:
|
||||
Path to the generated SKILL.md file
|
||||
"""
|
||||
# Extract metadata
|
||||
metadata = extract_cli_metadata(harness_path)
|
||||
|
||||
# Generate content
|
||||
content = generate_skill_md(metadata, template_path)
|
||||
|
||||
# Determine output path
|
||||
harness_path_obj = Path(harness_path)
|
||||
compatibility_path = harness_path_obj / "cli_anything" / metadata.software_name / "skills" / "SKILL.md"
|
||||
if output_path is None:
|
||||
repo_root = harness_path_obj.parent.parent
|
||||
output_path = repo_root / "skills" / metadata.skill_name / "SKILL.md"
|
||||
else:
|
||||
output_path = Path(output_path)
|
||||
|
||||
# Ensure output directory exists
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write file
|
||||
output_path.write_text(content, encoding="utf-8")
|
||||
if compatibility_path != output_path:
|
||||
compatibility_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
compatibility_path.write_text(content, encoding="utf-8")
|
||||
|
||||
return str(output_path)
|
||||
|
||||
|
||||
# CLI interface for standalone usage
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Generate SKILL.md for CLI-Anything harnesses"
|
||||
)
|
||||
parser.add_argument(
|
||||
"harness_path",
|
||||
help="Path to the agent-harness directory"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output",
|
||||
help="Output path for SKILL.md (default: skills/cli-anything-<software>/SKILL.md)",
|
||||
default=None
|
||||
)
|
||||
parser.add_argument(
|
||||
"-t", "--template",
|
||||
help="Path to custom Jinja2 template",
|
||||
default=None
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
output_file = generate_skill_file(
|
||||
args.harness_path,
|
||||
args.output,
|
||||
args.template
|
||||
)
|
||||
|
||||
print(f"Generated: {output_file}")
|
||||
Reference in New Issue
Block a user