Files
Dorod-Sky/skyvern/cli/skill_commands.py

136 lines
4.8 KiB
Python

"""Skill file management commands."""
from __future__ import annotations
import re
import shutil
from pathlib import Path
import typer
from rich.markdown import Markdown
from rich.table import Table
from skyvern.cli.console import console
skill_app = typer.Typer(help="Manage bundled skill reference files.")
SKILLS_DIR = Path(__file__).parent / "skills"
_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---", re.DOTALL)
def _get_skill_dirs() -> list[Path]:
"""Return sorted list of skill directories (those containing SKILL.md)."""
if not SKILLS_DIR.exists():
return []
return sorted(
d for d in SKILLS_DIR.iterdir() if d.is_dir() and not d.name.startswith("_") and (d / "SKILL.md").exists()
)
def _resolve_skill(name: str) -> Path:
"""Resolve a skill name to its SKILL.md path with path containment check."""
skill_md = (SKILLS_DIR / name / "SKILL.md").resolve()
if not skill_md.is_relative_to(SKILLS_DIR.resolve()):
console.print(f"[red]Invalid skill name: {name}[/red]")
raise typer.Exit(code=1)
if not skill_md.exists():
console.print(f"[red]Skill '{name}' not found. Run 'skyvern skill list' to see available skills.[/red]")
raise typer.Exit(code=1)
return skill_md
def _extract_description(skill_md: Path) -> str:
"""Extract the description field from SKILL.md frontmatter."""
content = skill_md.read_text(encoding="utf-8")
match = _FRONTMATTER_RE.match(content)
if not match:
return ""
for line in match.group(1).splitlines():
line = line.strip()
if line.startswith("description:"):
desc = line[len("description:") :].strip().strip('"').strip("'")
# Truncate long descriptions for table display
if len(desc) > 80:
return desc[:77] + "..."
return desc
return ""
@skill_app.command("list")
def skill_list() -> None:
"""List all bundled skills."""
dirs = _get_skill_dirs()
if not dirs:
console.print("[red]No skills found in package. Re-install skyvern.[/red]")
raise typer.Exit(code=1)
table = Table(title="Bundled Skills")
table.add_column("Name", style="bold")
table.add_column("Description")
for d in dirs:
desc = _extract_description(d / "SKILL.md")
table.add_row(d.name, desc)
console.print(table)
@skill_app.command("path")
def skill_path(
name: str = typer.Argument(None, help="Skill name (omit to show skills directory)"),
) -> None:
"""Print the absolute path to a bundled skill or the skills directory."""
if name is None:
if not SKILLS_DIR.exists():
console.print("[red]Skills directory not found in package. Re-install skyvern.[/red]")
raise typer.Exit(code=1)
typer.echo(str(SKILLS_DIR))
return
skill_md = _resolve_skill(name)
typer.echo(str(skill_md))
@skill_app.command("show")
def skill_show(
name: str = typer.Argument(..., help="Skill name to display"),
) -> None:
"""Display a skill's SKILL.md rendered in the terminal."""
skill_md = _resolve_skill(name)
content = skill_md.read_text(encoding="utf-8")
console.print(Markdown(content))
@skill_app.command("copy")
def skill_copy(
output: str = typer.Option(".", "--output", "-o", help="Destination directory"),
overwrite: bool = typer.Option(False, "--overwrite", help="Overwrite existing files"),
name: str = typer.Argument(None, help="Skill name (omit to copy all skills)"),
) -> None:
"""Copy skill(s) to a local path for customization or agent installation."""
dst = Path(output)
_ignore = shutil.ignore_patterns("__pycache__", "*.pyc")
dst.mkdir(parents=True, exist_ok=True)
if name is not None:
skill_md = _resolve_skill(name)
src = skill_md.parent
target = dst / name
if target.exists() and not overwrite:
console.print(f"[yellow]Destination {target} already exists. Use --overwrite to replace.[/yellow]")
raise typer.Exit(code=1)
shutil.copytree(src, target, dirs_exist_ok=overwrite, ignore=_ignore)
console.print(f"[green]Copied skill '{name}' to {target.resolve()}[/green]")
else:
dirs = _get_skill_dirs()
if not dirs:
console.print("[red]No skills found in package. Re-install skyvern.[/red]")
raise typer.Exit(code=1)
for d in dirs:
target = dst / d.name
if target.exists() and not overwrite:
console.print(f"[yellow]Destination {target} already exists. Use --overwrite to replace.[/yellow]")
raise typer.Exit(code=1)
for d in dirs:
target = dst / d.name
shutil.copytree(d, target, dirs_exist_ok=overwrite, ignore=_ignore)
console.print(f"[green]Copied {len(dirs)} skills to {dst.resolve()}[/green]")