Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,31 @@ upskill list -v
└── scripts/validate.py
```

### `upskill delete`

Delete a generated skill safely from your skills directory.

```bash
upskill delete SKILL_NAME [OPTIONS]
```

**Arguments:**
- `SKILL_NAME` - Name of the skill directory to remove (not a path)

**Options:**
- `-d, --dir PATH` - Skills directory (default: configured `skills_dir`)
- `--force` - Skip confirmation prompt

**Examples:**

```bash
# Confirm before deleting
upskill delete git-commit-messages

# Delete without confirmation
upskill delete my-skill --force
```

### `upskill runs`

View run results as a plot, or export to CSV. By default, shows a visual comparison of baseline vs with-skill performance.
Expand Down
50 changes: 50 additions & 0 deletions src/upskill/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import asyncio
import json
import shutil
import sys
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
Expand Down Expand Up @@ -1117,6 +1118,55 @@ def list_cmd(skills_dir: str | None, verbose: bool):
console.print(f"[dim]{len(skills)} skills, ~{total_tokens:,} total tokens[/dim]")


@main.command("delete")
@click.argument("skill_name")
@click.option("-d", "--dir", "skills_dir", type=click.Path(), help="Skills directory")
@click.option("--force", is_flag=True, help="Delete without confirmation")
def delete_cmd(skill_name: str, skills_dir: str | None, force: bool):
"""Delete a generated skill directory safely.

Examples:

upskill delete git-commit-messages

upskill delete my-skill --force
"""
config = Config.load()
base_dir = Path(skills_dir) if skills_dir else config.skills_dir

# Safety guard: only allow deleting a single skill directory name
if Path(skill_name).name != skill_name or skill_name in {".", ".."}:
console.print("[red]Skill name must be a directory name, not a path.[/red]")
sys.exit(1)

skill_path = (base_dir / skill_name).resolve()
if not skill_path.is_relative_to(base_dir.resolve()):
console.print("[red]Refusing to delete outside the skills directory.[/red]")
sys.exit(1)

if not skill_path.exists():
console.print(f"[red]Skill not found: {skill_path}[/red]")
sys.exit(1)
if not skill_path.is_dir():
console.print(f"[red]Not a directory: {skill_path}[/red]")
sys.exit(1)
if not (skill_path / "SKILL.md").exists():
console.print(
f"[red]{skill_path} does not look like a skill directory (missing SKILL.md).[/red]"
)
sys.exit(1)

if not force:
click.confirm(
f"Delete skill '{skill_name}' at {skill_path}?",
default=False,
abort=True,
)

shutil.rmtree(skill_path)
console.print(f"[green]Deleted skill:[/green] {skill_path}")


@main.command("benchmark")
@click.argument("skill_path", type=click.Path(exists=True))
@click.option("-m", "--model", "models", multiple=True, required=True, help="Model to benchmark")
Expand Down
54 changes: 54 additions & 0 deletions tests/test_delete_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
from __future__ import annotations

from pathlib import Path

from click.testing import CliRunner

from upskill.cli import main


def test_delete_command_removes_skill_with_force() -> None:
runner = CliRunner()
with runner.isolated_filesystem():
skill_dir = Path("skills/git-commit-messages")
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("---\nname: git-commit-messages\n---\n")

result = runner.invoke(main, ["delete", "git-commit-messages", "--force"])

assert result.exit_code == 0
assert not skill_dir.exists()


def test_delete_command_requires_confirmation_by_default() -> None:
runner = CliRunner()
with runner.isolated_filesystem():
skill_dir = Path("skills/my-skill")
skill_dir.mkdir(parents=True)
(skill_dir / "SKILL.md").write_text("---\nname: my-skill\n---\n")

result = runner.invoke(main, ["delete", "my-skill"], input="y\n")

assert result.exit_code == 0
assert not skill_dir.exists()


def test_delete_command_rejects_path_like_name() -> None:
runner = CliRunner()
with runner.isolated_filesystem():
result = runner.invoke(main, ["delete", "../oops", "--force"])

assert result.exit_code == 1
assert "must be a directory name, not a path" in result.output


def test_delete_command_rejects_non_skill_dir() -> None:
runner = CliRunner()
with runner.isolated_filesystem():
not_skill = Path("skills/random-folder")
not_skill.mkdir(parents=True)

result = runner.invoke(main, ["delete", "random-folder", "--force"])

assert result.exit_code == 1
assert "does not look like a skill directory" in result.output
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.