mirror of
https://fastgit.cc/github.com/HKUDS/CLI-Anything
synced 2026-04-30 22:02:01 +08:00
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>
528 lines
17 KiB
Python
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}")
|