Skip to content
Draft
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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ skillup add anthropics/skills --branch main --skill pdf
### Remove skills

```bash
skillup remove
skillup remove # interactive
skillup remove --repo owner/repo # remove all skills from a repo
skillup remove --repo owner/repo --skill pdf # remove a specific skill
```

### Update skills
Expand Down
63 changes: 48 additions & 15 deletions skillup/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,15 +83,57 @@ def add(
console.print(f"[green]Skills from {repo} installed successfully![/green]")


def _remove_skill(lock: dict, repo: str, skill: str) -> None:
"""Remove a single skill file and update the lock in-place."""
console.print(f"Removing [red]{skill}[/red] from {repo}...")
for target_dir in [settings.skills_dir_agents, settings.skills_dir_claude]:
dest = target_dir / skill
if dest.exists():
shutil.rmtree(dest)
lock["repos"][repo]["skills"].remove(skill)
if not lock["repos"][repo]["skills"]:
del lock["repos"][repo]


@app.command()
def remove():
"""Interactively remove installed skills across all repositories."""
def remove(
repo: Optional[str] = typer.Option(None, "--repo", help="Repository to remove skills from (owner/repo)"),
skills: Optional[List[str]] = typer.Option(None, "--skill", "-s", help="Specific skill(s) to remove (non-interactive)"),
):
"""Remove installed skills. Optionally specify --repo and/or --skill for non-interactive removal."""
lock = load_lock()

if repo:
if repo not in lock["repos"]:
console.print(f"[red]Repository {repo} is not tracked.[/red]")
raise typer.Exit(1)

if skills:
# Remove specific skills from the given repo
repo_skills = lock["repos"][repo]["skills"]
invalid = [s for s in skills if s not in repo_skills]
if invalid:
console.print(f"[yellow]Warning: Skills not found in {repo}: {', '.join(invalid)}[/yellow]")
to_remove = [s for s in skills if s in repo_skills]
if not to_remove:
console.print("No matching skills to remove.")
return
for skill in to_remove:
_remove_skill(lock, repo, skill)
else:
# Remove all skills from the given repo
for skill in list(lock["repos"][repo]["skills"]):
_remove_skill(lock, repo, skill)

save_lock(lock)
console.print("[green]Skills removed successfully![/green]")
return

# Interactive mode
all_installed = []
for repo, data in lock["repos"].items():
for repo_name, data in lock["repos"].items():
for skill in data["skills"]:
all_installed.append(f"{repo}: {skill}")
all_installed.append(f"{repo_name}: {skill}")

if not all_installed:
console.print("[yellow]No skills installed.[/yellow]")
Expand All @@ -107,17 +149,8 @@ def remove():
return

for item in selected:
repo, skill = item.split(": ", 1)
console.print(f"Removing [red]{skill}[/red] from {repo}...")
for target_dir in [settings.skills_dir_agents, settings.skills_dir_claude]:
dest = target_dir / skill
if dest.exists():
import shutil
shutil.rmtree(dest)

lock["repos"][repo]["skills"].remove(skill)
if not lock["repos"][repo]["skills"]:
del lock["repos"][repo]
repo_name, skill = item.split(": ", 1)
_remove_skill(lock, repo_name, skill)

save_lock(lock)
console.print("[green]Skills removed successfully![/green]")
Expand Down
136 changes: 136 additions & 0 deletions tests/test_remove.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
from pathlib import Path
from unittest.mock import patch

import pytest
from typer.testing import CliRunner

from skillup.cli import app
from skillup.install import ensure_dirs
from skillup.lock import load_lock, save_lock
from skillup.settings import settings

runner = CliRunner()


@pytest.fixture
def temp_dirs(tmp_path):
fake_home = tmp_path / "home"
fake_home.mkdir()
fake_cwd = tmp_path / "cwd"
fake_cwd.mkdir()
fake_temp = tmp_path / "temp"
fake_temp.mkdir()

with patch("pathlib.Path.home", return_value=fake_home), \
patch("pathlib.Path.cwd", return_value=fake_cwd), \
patch("os.getenv", side_effect=lambda key, default=None: str(fake_temp) if key == "TEMP" else default):
settings.is_global = False
yield fake_home, fake_cwd


def _setup_lock(with_dirs=True):
lock_data = {
"repos": {
"owner/repo": {
"tag": "v1.0.0",
"skills": ["skill-a", "skill-b"],
},
"other/repo": {
"tag": "v2.0.0",
"skills": ["skill-c"],
},
}
}
if with_dirs:
ensure_dirs()
save_lock(lock_data)
return lock_data


def test_remove_repo_removes_all_skills(temp_dirs):
_setup_lock()

result = runner.invoke(app, ["remove", "--repo", "owner/repo"])
assert result.exit_code == 0, result.stdout
assert "skill-a" in result.stdout
assert "skill-b" in result.stdout
assert "Skills removed successfully" in result.stdout

lock = load_lock()
assert "owner/repo" not in lock["repos"]
assert "other/repo" in lock["repos"]


def test_remove_repo_with_skill_removes_single_skill(temp_dirs):
_setup_lock()

result = runner.invoke(app, ["remove", "--repo", "owner/repo", "--skill", "skill-a"])
assert result.exit_code == 0, result.stdout
assert "skill-a" in result.stdout
assert "Skills removed successfully" in result.stdout

lock = load_lock()
assert "owner/repo" in lock["repos"]
assert "skill-a" not in lock["repos"]["owner/repo"]["skills"]
assert "skill-b" in lock["repos"]["owner/repo"]["skills"]


def test_remove_repo_with_multiple_skills(temp_dirs):
_setup_lock()

result = runner.invoke(app, ["remove", "--repo", "owner/repo", "--skill", "skill-a", "--skill", "skill-b"])
assert result.exit_code == 0, result.stdout

lock = load_lock()
assert "owner/repo" not in lock["repos"]


def test_remove_repo_unknown(temp_dirs):
_setup_lock()

result = runner.invoke(app, ["remove", "--repo", "nonexistent/repo"])
assert result.exit_code == 1
assert "not tracked" in result.stdout


def test_remove_skill_not_in_repo(temp_dirs):
_setup_lock()

result = runner.invoke(app, ["remove", "--repo", "owner/repo", "--skill", "nonexistent-skill"])
assert result.exit_code == 0
assert "Warning" in result.stdout
assert "No matching skills to remove" in result.stdout


def test_remove_skill_deletes_directory(temp_dirs):
fake_home, fake_cwd = temp_dirs
ensure_dirs()
save_lock({
"repos": {
"owner/repo": {
"tag": "v1.0.0",
"skills": ["skill-a"],
}
}
})

# Create a fake skill directory so shutil.rmtree has something to delete
skill_dir = settings.skills_dir_agents / "skill-a"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text("skill")

result = runner.invoke(app, ["remove", "--repo", "owner/repo", "--skill", "skill-a"])
assert result.exit_code == 0, result.stdout
assert not skill_dir.exists()

lock = load_lock()
assert "owner/repo" not in lock["repos"]


def test_remove_no_skills_installed(temp_dirs):
ensure_dirs()
save_lock({"repos": {}})

result = runner.invoke(app, ["remove", "--repo", "owner/repo"])
assert result.exit_code == 1
assert "not tracked" in result.stdout
Loading