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
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,25 @@ ug skill remove --mcp
ug skill remove --mcp --agents claude
```

`--mcp` is required; removing downloaded skills from disk isn't supported yet.
#### Remove downloaded skills

Without `--mcp`, `ug skill remove` deletes downloaded skill directories. Only skills
`ug` downloaded are removed, so a same-named skill you authored is left alone.

```bash
# Pick from every skill downloaded to disk, across all download bases.
ug skill remove

# Remove every skill downloaded from a schema (all bases, or one with --path).
ug skill remove --location main.default
ug skill remove --location main.default --path /abs/project/dir

# Remove named skills by fully-qualified name (may span schemas).
ug skill remove --skills main.default.my-skill,ml.prod.other-skill
```

`--location` and `--skills` each accept `--path` to limit removal to one download base, and are
mutually exclusive with each other.

### Exporting the config

Expand Down Expand Up @@ -355,6 +373,9 @@ The output looks like:
| `ug skill add --skills main.default.my-skill` | Download named skills by fully-qualified name (comma-separated; may span schemas) |
| `ug skill remove --mcp` | Remove skill schemas from the skills MCP connection (every agent) |
| `ug skill remove --mcp --agents claude` | Remove skill schemas from specific agents only, keeping them on the rest |
| `ug skill remove` | Pick from every downloaded skill (across all bases) and delete it from disk |
| `ug skill remove --location main.default [--path <dir>]` | Delete every skill downloaded from a schema (all bases, or one under `<dir>`) |
| `ug skill remove --skills main.default.my-skill [--path <dir>]` | Delete named downloaded skills by fully-qualified name (comma-separated; may span schemas; `--path` limits to one base) |

Databricks AI Tools are installed only by `ug configure`, never by `ug <agent>` launches.
Use `--enable-databricks-ai-tools` or `--disable-databricks-ai-tools` with `ug configure` to
Expand Down
87 changes: 69 additions & 18 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@
configure_selected_skills_download_command,
configure_skills_download_picker_command,
download_managed_skills_on_launch,
remove_downloaded_skills_command,
)
from ucode.smart_routing import v2 as smart_routing_v2
from ucode.smart_routing.claude_hooks import FIRST_PROMPT_SOCKET_ENV, ROUTE_FIRST_PROMPT_EVENT
Expand Down Expand Up @@ -1360,41 +1361,91 @@ def skills_add(

@skill_app.command("remove")
def skills_remove(
location: Annotated[
Comment thread
xsh310 marked this conversation as resolved.
str | None,
typer.Option(
"--location",
help="(download) Comma-separated `<catalog>.<schema>` schemas whose downloaded "
"skills to remove.",
),
] = None,
mcp: Annotated[
bool,
typer.Option(
"--mcp",
help="Remove schemas from the skills MCP connection instead of downloaded files.",
),
] = False,
path: Annotated[
str | None,
typer.Option(
"--path",
help="(download) Limit removal to skills downloaded under this base directory; "
"without it, every base is in scope.",
),
] = None,
skills: Annotated[
str | None,
typer.Option(
"--skills",
help="(download) Remove exactly these comma-separated fully-qualified "
"`<catalog>.<schema>.<name>` skills, spanning any number of schemas. Not valid "
"with --mcp or --location.",
),
] = None,
agents: Annotated[
str | None,
typer.Option(
"--agents",
help="Comma-separated coding agents to remove the schemas from (e.g. claude,codex). "
"A schema scoped to several agents is removed only from the named ones and kept on "
"the rest. Without --agents, a selected schema is removed from every agent it's on.",
help="(--mcp only) Comma-separated coding agents to remove the schemas from "
"(e.g. claude,codex). A schema scoped to several agents is removed only from the "
"named ones and kept on the rest. Without --agents, it is removed from every agent.",
),
] = None,
) -> None:
"""Interactively remove Skill schemas from the skills MCP connection.

Without ``--agents`` a selected schema is removed from every configured agent; ``--agents``
scopes the removal to the named agents and keeps the schema on the rest.
"""Remove Skills previously added to your coding tools.

With ``--mcp``, interactively drops skill schemas from the skills MCP connection.
Otherwise removes downloaded skill directories: ``--location`` removes every skill
downloaded from a ``<catalog>.<schema>``, ``--skills`` removes named fully-qualified
skills that may span schemas, and with none of them a picker lists every downloaded
skill. ``--path`` limits either to one download base. Only skills ucode downloaded are
removed; a same-named skill you authored is left alone.
"""
try:
if not mcp:
raise RuntimeError(
"Removing downloaded skills is not supported yet. Pass --mcp to remove "
"schemas from the skills MCP connection."
)
requested_agents = (
None
if agents is None
else ({agent.strip().lower() for agent in agents.split(",") if agent.strip()} or None)
requested_skills = (
None if skills is None else {s.strip() for s in skills.split(",") if s.strip()}
)
remove_skills_command(agents=requested_agents)
except RuntimeError as exc:
if mcp:
if location is not None or path is not None or requested_skills is not None:
raise RuntimeError("--location, --path, and --skills are not supported with --mcp.")
requested_agents = (
None
if agents is None
else ({a.strip().lower() for a in agents.split(",") if a.strip()} or None)
)
remove_skills_command(agents=requested_agents)
return
if agents is not None:
raise RuntimeError("--agents is only supported when using --mcp.")
if requested_skills is not None and location is not None:
raise RuntimeError("--skills takes fully-qualified names; drop --location.")
if requested_skills is not None:
invalid = sorted(s for s in requested_skills if not _is_qualified_skill_name(s))
if invalid:
raise RuntimeError(
"--skills entries must be fully-qualified `<catalog>.<schema>.<name>` names "
f"(invalid: {', '.join(invalid)})."
)
remove_downloaded_skills_command([], sorted(requested_skills), path=path)
return
locations = _parse_skill_locations(location)
if path is not None and not locations:
raise RuntimeError("--path is only supported with --location or --skills.")
if not locations and not _stdin_is_interactive():
raise RuntimeError("--location or --skills is required for `ug skill remove`.")
remove_downloaded_skills_command(locations, path=path)
except (RuntimeError, ValueError) as exc:
print_err(str(exc))
raise typer.Exit(1) from None
except KeyboardInterrupt:
Expand Down
89 changes: 88 additions & 1 deletion src/ucode/skills_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,14 @@
workspace_hostname,
)
from ucode.mcp import register_schemaless_skills_connection, setup_mcp_clients
from ucode.skills_state import SkillInstall, record_downloads
from ucode.skills_state import (
SkillInstall,
list_downloaded,
record_downloads,
records_for_fqns,
records_for_schema,
remove_downloads,
)
from ucode.state import load_state
from ucode.ui import (
console,
Expand Down Expand Up @@ -677,3 +684,83 @@ def configure_skills_download_picker_command(path: str | None = None) -> int:
download_selected_skills(workspace, token, fqns, path)
register_schemaless_skills_connection(state, workspace, profile, clients)
return 0


# --- Removing and listing downloaded skills ---------------------------------


def _record_dirs_missing(record: dict) -> bool:
"""Whether any of a record's on-disk directories no longer exists."""
return any(not Path(directory).exists() for directory in record.get("dirs") or [])


def _download_label(record: dict) -> str:
label = f"{record.get('fqn')} ({record.get('scope')}: {record.get('base')})"
return f"{label} (missing)" if _record_dirs_missing(record) else label


def _removal_choice(record: dict, index: int) -> questionary.Choice:
"""Picker row for one downloaded skill, labeled by its scope and base (and missing dirs)."""
return questionary.Choice(title=_download_label(record), value=index)


def _prompt_for_downloaded_skill_removal(records: list[dict]) -> list[dict] | None:
"""Checklist of downloaded skills to remove, across every base.

Returns the selected records, ``None`` if cancelled (Ctrl-C), or ``[]`` if nothing
is checked. Only recorded downloads are offered, so a user-authored skill directory
with no attribution can never be selected.
"""
if not records:
print_note("No downloaded skills to remove.")
return []
choices = [_removal_choice(record, index) for index, record in enumerate(records)]
selection = scrolling_checkbox(
"Remove downloaded skills:",
choices=choices,
style=picker_style(),
instruction="(space to toggle, ctrl-a all, enter to remove, type to filter)",
).ask()
if selection is None:
return None
return [records[int(index)] for index in selection]


def remove_downloaded_skills_command(
locations: list[str], fqns: list[str] | None = None, *, path: str | None
) -> int:
"""`ug skill remove` (download side): delete downloaded skills and forget them.

With ``fqns``, removes those fully-qualified skills; with ``locations``, every skill
downloaded from those ``<catalog>.<schema>`` schemas; with neither, opens a picker over
every downloaded skill. ``path`` limits any of these to one download base. Removal is
driven entirely by attribution, so a same-named skill the user authored is never touched.
"""
if fqns is not None:
records = records_for_fqns(set(fqns), path)
if not records:
scope = f" under `{path}`" if path else ""
joined = ", ".join(f"`{fqn}`" for fqn in fqns) or "those names"
print_note(f"No downloaded skills matching {joined}{scope}.")
return 0
elif locations:
records = [
record for location in locations for record in records_for_schema(location, path)
]
if not records:
scope = f" under `{path}`" if path else ""
joined = ", ".join(f"`{location}`" for location in locations)
print_note(f"No downloaded skills from {joined}{scope}.")
return 0
else:
selected = _prompt_for_downloaded_skill_removal(list_downloaded())
if selected is None:
return 0
if not selected:
print_note("No skills selected.")
return 0
records = selected

remove_downloads(records)
print_success(f"Removed {len(records)} downloaded skill(s).")
return 0
20 changes: 20 additions & 0 deletions src/ucode/skills_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,29 @@ def records_for_schema(location: str, base: str | None = None) -> list[dict]:
]


def records_for_fqns(fqns: set[str], base: str | None = None) -> list[dict]:
"""Installs whose fully-qualified name is in ``fqns``, optionally under one base."""
base_norm = _norm(base) if base is not None else None
return [
record
for record in list_downloaded()
if record.get("fqn") in fqns
and (base_norm is None or _norm(record.get("base", "")) == base_norm)
]


def forget(records: list[dict]) -> None:
"""Drop ``records`` from the manifest, leaving their on-disk directories alone."""
if not records:
return
dropped = {_record_key(record) for record in records}
_save([record for record in _load() if _record_key(record) not in dropped])


def remove_downloads(records: list[dict]) -> None:
"""Delete each record's on-disk directories, then drop it from the manifest."""
if not records:
return
for record in records:
_delete_dirs(record.get("dirs") or [])

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: remove_downloads deletes the on-disk dirs first, then forget() rewrites the manifest. If _save fails after deletion (disk full, etc.), the files are gone but their records remain → phantom entries pointing at deleted dirs on the next run. Also, _delete_dirs uses shutil.rmtree(..., ignore_errors=True), so a dir that's a symlink is silently skipped yet still forget()-ten — the link lingers with no signal. Both are minor; consider forgetting-then-deleting, or at least noting undeletable paths.

forget(records)
89 changes: 82 additions & 7 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1688,13 +1688,7 @@ def test_all_configured_skips_bootstrap(self):


class TestSkillsRemoveCommand:
def test_requires_mcp_until_download_removal_is_supported(self):
with patch("ucode.cli.remove_skills_command") as remove:
result = runner.invoke(app, ["skill", "remove"])

assert result.exit_code == 1
assert "Removing downloaded skills is not supported yet" in _strip_ansi(result.output)
remove.assert_not_called()
"""`ug skill remove`: `--mcp` drops MCP scopes, the default mode deletes downloads."""

def test_mcp_remove_dispatches_global_removal(self):
with patch("ucode.cli.remove_skills_command") as remove:
Expand All @@ -1710,6 +1704,87 @@ def test_mcp_remove_forwards_agent_scope(self):
assert result.exit_code == 0, result.output
remove.assert_called_once_with(agents={"claude", "codex"})

def test_location_routes_to_download_remove(self):
with patch("ucode.cli.remove_downloaded_skills_command") as mock_remove:
result = runner.invoke(app, ["skill", "remove", "--location", "a.b, c.d"])
assert result.exit_code == 0, result.output
mock_remove.assert_called_once_with(["a.b", "c.d"], path=None)

def test_location_with_path_narrows_base(self):
with patch("ucode.cli.remove_downloaded_skills_command") as mock_remove:
result = runner.invoke(app, ["skill", "remove", "--location", "a.b", "--path", "/abs"])
assert result.exit_code == 0, result.output
mock_remove.assert_called_once_with(["a.b"], path="/abs")

def test_skills_routes_to_download_remove_by_name(self):
with patch("ucode.cli.remove_downloaded_skills_command") as mock_remove:
result = runner.invoke(app, ["skill", "remove", "--skills", "a.b.s1, c.d.s2"])
assert result.exit_code == 0, result.output
mock_remove.assert_called_once_with([], ["a.b.s1", "c.d.s2"], path=None)

def test_skills_with_path(self):
with patch("ucode.cli.remove_downloaded_skills_command") as mock_remove:
result = runner.invoke(app, ["skill", "remove", "--skills", "a.b.s1", "--path", "/abs"])
assert result.exit_code == 0, result.output
mock_remove.assert_called_once_with([], ["a.b.s1"], path="/abs")

def test_skills_with_location_exit_1(self):
with patch("ucode.cli.remove_downloaded_skills_command") as mock_remove:
result = runner.invoke(
app, ["skill", "remove", "--skills", "a.b.s1", "--location", "a.b"]
)
assert result.exit_code == 1
assert "--skills takes fully-qualified names; drop --location" in _strip_ansi(result.output)
mock_remove.assert_not_called()

@pytest.mark.parametrize("skill", ["a.b", "a..s1", "a.b.c.d", "leaf"])
def test_non_fully_qualified_skill_exit_1(self, skill):
with patch("ucode.cli.remove_downloaded_skills_command") as mock_remove:
result = runner.invoke(app, ["skill", "remove", "--skills", skill])
assert result.exit_code == 1
assert "must be fully-qualified" in _strip_ansi(result.output)
mock_remove.assert_not_called()

def test_no_args_interactive_opens_picker(self):
with (
patch("ucode.cli._stdin_is_interactive", return_value=True),
patch("ucode.cli.remove_downloaded_skills_command") as mock_remove,
):
result = runner.invoke(app, ["skill", "remove"])
assert result.exit_code == 0, result.output
mock_remove.assert_called_once_with([], path=None)

def test_no_args_non_interactive_exit_1(self):
with (
patch("ucode.cli._stdin_is_interactive", return_value=False),
patch("ucode.cli.remove_downloaded_skills_command") as mock_remove,
):
result = runner.invoke(app, ["skill", "remove"])
assert result.exit_code == 1
assert "--location or --skills is required" in _strip_ansi(result.output)
mock_remove.assert_not_called()

def test_path_without_location_exit_1(self):
with patch("ucode.cli.remove_downloaded_skills_command") as mock_remove:
result = runner.invoke(app, ["skill", "remove", "--path", "/abs"])
assert result.exit_code == 1
assert "--path is only supported with --location or --skills" in _strip_ansi(result.output)
mock_remove.assert_not_called()

def test_agents_without_mcp_exit_1(self):
with patch("ucode.cli.remove_downloaded_skills_command") as mock_remove:
result = runner.invoke(app, ["skill", "remove", "--agents", "claude"])
assert result.exit_code == 1
assert "--agents is only supported when using --mcp" in _strip_ansi(result.output)
mock_remove.assert_not_called()

def test_mcp_with_location_exit_1(self):
with patch("ucode.cli.remove_skills_command") as remove:
result = runner.invoke(app, ["skill", "remove", "--mcp", "--location", "a.b"])
assert result.exit_code == 1
assert "not supported with --mcp" in _strip_ansi(result.output)
remove.assert_not_called()


class TestManagedSkillsOnLaunch:
"""Managed skills are delivered by download only: the launch path downloads them and never
Expand Down
Loading
Loading