Files
CLI-Anything/cli-anything-plugin/skill_generator.py
yuhao 1db1622156 refactor: move SKILL.md inside Python package for pip install support
Move skills/SKILL.md from the harness root into
cli_anything/<software>/skills/SKILL.md so it is installed alongside the
package via pip. Add package_data to all 11 setup.py files. Make
ReplSkin auto-detect the skill file relative to its own __file__
location, removing the need for an explicit skill_path argument.
Update HARNESS.md, cli-anything.md, plugin README, and main README.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-16 09:06:31 +00:00

528 lines
17 KiB
Python

"""
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()
@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 = f"cli-anything-{software_name}"
skill_description = f"Command-line interface for {_format_display_name(software_name)} - {skill_intro[:100]}..."
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" 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
group_pattern = (
r'@(\w+)\.group\([^)]*\)' # @xxx.group(...)
r'(?:\s*@[\w.]+\([^)]*\))*' # 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(group_pattern, content):
group_func = match.group(2)
# Docstring can be in group 3 (triple-double) or group 4 (triple-single)
group_doc = (match.group(3) or match.group(4) or "").strip()
group_name = group_func.replace("_", " ").title()
if not group_name:
group_name = group_func.title()
groups.append(CommandGroup(
name=group_name,
description=group_doc or f"Commands for {group_name.lower()} operations.",
commands=[]
))
# 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(...)
r'(?:\s*@[\w.]+\([^)]*\))*' # 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_name = match.group(2)
# Docstring can be in group 3 (triple-double) or group 4 (triple-single)
cmd_doc = (match.group(3) or match.group(4) or "").strip()
# Find the matching group
for group in groups:
if group.name.lower().replace(" ", "_") == group_name.lower():
group.commands.append(CommandInfo(
name=cmd_name.replace("_", "-"),
description=cmd_doc or f"Execute {cmd_name} 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_name = match.group(2)
# Docstring can be in group 3 (triple-double) or group 4 (triple-single)
cmd_doc = (match.group(3) or match.group(4) or "").strip()
default_group.commands.append(CommandInfo(
name=cmd_name.replace("_", "-"),
description=cmd_doc or f"Execute {cmd_name} 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: cli_anything/<software>/skills/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
if output_path is None:
# Default to skills/ directory under harness_path
harness_path_obj = Path(harness_path)
output_path = harness_path_obj / "cli_anything" / metadata.software_name / "skills" / "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")
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: cli_anything/<software>/skills/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}")