Skip to content

Commit 33d7603

Browse files
xsh310Isaac
andcommitted
[skills] Support ug skill remove --mcp --location
`ug skill remove --mcp` was interactive-only; the non-interactive counterpart to `ug skill add --mcp --location` was missing. Passing `--location` now drops the named `<catalog>.<schema>` schemas from the skills MCP scope without a picker, and `--mcp` with no `--location` on a non-interactive terminal errors asking for it (matching the add path) instead of falling into an unusable picker. The removal core moves into `remove_skill_locations_from_mcp`, shared by the interactive `remove_skills_command` and the new `remove_skills_locations_command`, mirroring the `add_skill_locations_to_mcp` split. `--agents` scopes removal to the named clients as before; `--path` and `--skills` stay rejected with `--mcp`. Co-authored-by: Isaac <no-reply@databricks.com>
1 parent b85013e commit 33d7603

5 files changed

Lines changed: 209 additions & 34 deletions

File tree

README.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -312,15 +312,19 @@ configured agent. It applies only to `--mcp`, since downloaded skills are shared
312312

313313
#### Remove skill scopes
314314

315-
Remove schemas from the skills MCP connection with `ug skill remove --mcp`:
315+
Remove schemas from the skills MCP connection with `ug skill remove --mcp`. `--location` drops the
316+
named schemas; with no `--location` on an interactive terminal a picker lists the scoped schemas.
316317

317318
```bash
318-
# Pick schemas to remove; each is removed from every agent it's on.
319-
ug skill remove --mcp
319+
# Remove specific schemas from the MCP scope; each is removed from every agent it's on.
320+
ug skill remove --location main.default,ml.prod --mcp
320321

321322
# Remove from specific agents only. A schema scoped to several agents is
322323
# removed from the named ones and kept on the rest.
323-
ug skill remove --mcp --agents claude
324+
ug skill remove --location main.default --mcp --agents claude
325+
326+
# No --location launches a picker of the scoped schemas to remove.
327+
ug skill remove --mcp
324328
```
325329

326330
#### Remove downloaded skills
@@ -407,8 +411,8 @@ The output looks like:
407411
| `ug skill add --location main.default --mcp --agents claude,codex` | Add schemas to specific agents' skills MCP scope (sets up any not yet configured) |
408412
| `ug skill add --location main.default` | Download a schema's skills to disk without removing existing downloads |
409413
| `ug skill add --skills main.default.my-skill` | Download named skills by fully-qualified name (comma-separated; may span schemas) |
410-
| `ug skill remove --mcp` | Remove skill schemas from the skills MCP connection (every agent) |
411-
| `ug skill remove --mcp --agents claude` | Remove skill schemas from specific agents only, keeping them on the rest |
414+
| `ug skill remove --location main.default --mcp` | Remove specific schemas from the skills MCP scope, or omit `--location` on a TTY for a picker (every agent) |
415+
| `ug skill remove --location main.default --mcp --agents claude` | Remove schemas from specific agents' skills MCP scope, keeping them on the rest |
412416
| `ug skill remove` | Pick from every downloaded skill (across all bases) and delete it from disk |
413417
| `ug skill remove --location main.default [--path <dir>]` | Delete every skill downloaded from a schema (all bases, or one under `<dir>`) |
414418
| `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) |

src/ucode/cli.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@
103103
purge_cross_workspace_mcp_residue,
104104
remove_mcp_command,
105105
remove_skills_command,
106+
remove_skills_locations_command,
106107
revert_mcp_configs,
107108
skill_locations_for_client,
108109
)
@@ -1296,8 +1297,8 @@ def skills_remove(
12961297
str | None,
12971298
typer.Option(
12981299
"--location",
1299-
help="(download) Comma-separated `<catalog>.<schema>` schemas whose downloaded "
1300-
"skills to remove.",
1300+
help="Comma-separated `<catalog>.<schema>` schemas to remove (from the skills MCP "
1301+
"scope with --mcp, else their downloaded skills).",
13011302
),
13021303
] = None,
13031304
mcp: Annotated[
@@ -1336,26 +1337,33 @@ def skills_remove(
13361337
) -> None:
13371338
"""Remove Skills previously added to your coding tools.
13381339
1339-
With ``--mcp``, interactively drops skill schemas from the skills MCP connection.
1340-
Otherwise removes downloaded skill directories: ``--location`` removes every skill
1341-
downloaded from a ``<catalog>.<schema>``, ``--skills`` removes named fully-qualified
1342-
skills that may span schemas, and with none of them a picker lists every downloaded
1343-
skill. ``--path`` limits either to one download base. Only skills ucode downloaded are
1344-
removed; a same-named skill you authored is left alone.
1340+
With ``--mcp``, drops skill schemas from the skills MCP connection: ``--location`` removes the
1341+
named ``<catalog>.<schema>`` schemas, and with none on an interactive terminal a picker lists
1342+
the scoped schemas. Otherwise removes downloaded skill directories: ``--location`` removes every
1343+
skill downloaded from a ``<catalog>.<schema>``, ``--skills`` removes named fully-qualified skills
1344+
that may span schemas, and with none of them a picker lists every downloaded skill. ``--path``
1345+
limits either to one download base. Only skills ucode downloaded are removed; a same-named skill
1346+
you authored is left alone.
13451347
"""
13461348
try:
13471349
requested_skills = (
13481350
None if skills is None else {s.strip() for s in skills.split(",") if s.strip()}
13491351
)
13501352
if mcp:
1351-
if location is not None or path is not None or requested_skills is not None:
1352-
raise RuntimeError("--location, --path, and --skills are not supported with --mcp.")
1353+
if path is not None or requested_skills is not None:
1354+
raise RuntimeError("--path and --skills are not supported with --mcp.")
13531355
requested_agents = (
13541356
None
13551357
if agents is None
13561358
else ({a.strip().lower() for a in agents.split(",") if a.strip()} or None)
13571359
)
1358-
remove_skills_command(agents=requested_agents)
1360+
locations = _parse_skill_locations(location)
1361+
if locations:
1362+
remove_skills_locations_command(locations, agents=requested_agents)
1363+
elif _stdin_is_interactive():
1364+
remove_skills_command(agents=requested_agents)
1365+
else:
1366+
raise RuntimeError("--location is required for `ug skill remove --mcp`.")
13591367
return
13601368
if agents is not None:
13611369
raise RuntimeError("--agents is only supported when using --mcp.")

src/ucode/mcp.py

Lines changed: 54 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1995,6 +1995,33 @@ def add_skill_locations_to_mcp(
19951995
_update_skills_mcp(state, workspace, profile, clients, locations_by_client)
19961996

19971997

1998+
def remove_skill_locations_from_mcp(
1999+
state: dict,
2000+
workspace: str,
2001+
profile: str | None,
2002+
clients: list[str],
2003+
locations: set[str],
2004+
) -> list[str]:
2005+
"""Drop ``locations`` from each targeted client's skill MCP scope, returning the schemas removed."""
2006+
locations_by_client = _skill_locations_by_client_from_state(state)
2007+
removed = sorted(
2008+
{
2009+
location
2010+
for client in clients
2011+
for location in locations_by_client.get(client, [])
2012+
if location in locations
2013+
}
2014+
)
2015+
for client in clients:
2016+
locations_by_client[client] = [
2017+
location
2018+
for location in locations_by_client.get(client, [])
2019+
if location not in locations
2020+
]
2021+
_update_skills_mcp(state, workspace, profile, clients, locations_by_client, print_summary=False)
2022+
return removed
2023+
2024+
19982025
def configured_skill_locations(state: dict, clients: list[str]) -> set[str]:
19992026
"""The union of skill schemas already in the MCP scope across ``clients``."""
20002027
locations_by_client = _skill_locations_by_client_from_state(state)
@@ -2125,6 +2152,10 @@ def _prompt_for_skill_removal(locations_by_client: dict[str, list[str]]) -> list
21252152
return [str(value) for value in selection]
21262153

21272154

2155+
def _removed_schemas_summary(count: int) -> str:
2156+
return f"Removed {count} skill schema{'s' if count != 1 else ''}."
2157+
2158+
21282159
def remove_skills_command(agents: set[str] | None = None) -> int:
21292160
"""`ucode skill remove --mcp`: interactively drop skill schemas from clients' skills scopes.
21302161
@@ -2154,15 +2185,28 @@ def remove_skills_command(agents: set[str] | None = None) -> int:
21542185
print_note("No skill schemas selected.")
21552186
return 0
21562187

2157-
remove_locations = set(selection)
2158-
for client in clients:
2159-
locations_by_client[client] = [
2160-
location
2161-
for location in locations_by_client.get(client, [])
2162-
if location not in remove_locations
2163-
]
2164-
_update_skills_mcp(state, workspace, profile, clients, locations_by_client, print_summary=False)
2165-
print_success(
2166-
f"Removed {len(remove_locations)} skill schema{'s' if len(remove_locations) != 1 else ''}."
2188+
removed = remove_skill_locations_from_mcp(state, workspace, profile, clients, set(selection))
2189+
print_success(_removed_schemas_summary(len(removed)))
2190+
return 0
2191+
2192+
2193+
def remove_skills_locations_command(locations: list[str], agents: set[str] | None = None) -> int:
2194+
"""`ucode skill remove --mcp --location`: drop the named schemas from clients' skills scopes.
2195+
2196+
Non-interactive counterpart to ``remove_skills_command``. ``agents`` (from ``--agents``) scopes
2197+
removal to that subset of configured clients; omitting it targets every configured client. A
2198+
schema not in scope is a no-op. Needs no Databricks auth."""
2199+
state = load_state()
2200+
workspace, profile, clients = setup_mcp_clients(
2201+
state,
2202+
"Remove Skills MCP",
2203+
require_auth=False,
2204+
action_note="Removing from",
2205+
agents=agents,
21672206
)
2207+
removed = remove_skill_locations_from_mcp(state, workspace, profile, clients, set(locations))
2208+
if removed:
2209+
print_success(_removed_schemas_summary(len(removed)))
2210+
else:
2211+
print_note("None of the given schemas were in the skills MCP scope.")
21682212
return 0

tests/test_cli.py

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1737,15 +1737,21 @@ def test_all_configured_skips_bootstrap(self):
17371737
class TestSkillsRemoveCommand:
17381738
"""`ug skill remove`: `--mcp` drops MCP scopes, the default mode deletes downloads."""
17391739

1740-
def test_mcp_remove_dispatches_global_removal(self):
1741-
with patch("ucode.cli.remove_skills_command") as remove:
1740+
def test_mcp_remove_no_location_interactive_opens_picker(self):
1741+
with (
1742+
patch("ucode.cli._stdin_is_interactive", return_value=True),
1743+
patch("ucode.cli.remove_skills_command") as remove,
1744+
):
17421745
result = runner.invoke(app, ["skill", "remove", "--mcp"])
17431746

17441747
assert result.exit_code == 0, result.output
17451748
remove.assert_called_once_with(agents=None)
17461749

17471750
def test_mcp_remove_forwards_agent_scope(self):
1748-
with patch("ucode.cli.remove_skills_command") as remove:
1751+
with (
1752+
patch("ucode.cli._stdin_is_interactive", return_value=True),
1753+
patch("ucode.cli.remove_skills_command") as remove,
1754+
):
17491755
result = runner.invoke(app, ["skill", "remove", "--mcp", "--agents", "claude, codex"])
17501756

17511757
assert result.exit_code == 0, result.output
@@ -1825,11 +1831,52 @@ def test_agents_without_mcp_exit_1(self):
18251831
assert "--agents is only supported when using --mcp" in _strip_ansi(result.output)
18261832
mock_remove.assert_not_called()
18271833

1828-
def test_mcp_with_location_exit_1(self):
1829-
with patch("ucode.cli.remove_skills_command") as remove:
1830-
result = runner.invoke(app, ["skill", "remove", "--mcp", "--location", "a.b"])
1834+
def test_mcp_with_location_routes_to_location_removal(self):
1835+
with patch("ucode.cli.remove_skills_locations_command") as remove:
1836+
result = runner.invoke(app, ["skill", "remove", "--mcp", "--location", "a.b, c.d"])
1837+
assert result.exit_code == 0, result.output
1838+
remove.assert_called_once_with(["a.b", "c.d"], agents=None)
1839+
1840+
def test_mcp_with_location_forwards_agent_scope(self):
1841+
with patch("ucode.cli.remove_skills_locations_command") as remove:
1842+
result = runner.invoke(
1843+
app,
1844+
["skill", "remove", "--mcp", "--location", "a.b", "--agents", "claude, codex"],
1845+
)
1846+
assert result.exit_code == 0, result.output
1847+
remove.assert_called_once_with(["a.b"], agents={"claude", "codex"})
1848+
1849+
def test_mcp_no_location_non_interactive_exit_1(self):
1850+
with (
1851+
patch("ucode.cli._stdin_is_interactive", return_value=False),
1852+
patch("ucode.cli.remove_skills_command") as remove,
1853+
patch("ucode.cli.remove_skills_locations_command") as remove_locations,
1854+
):
1855+
result = runner.invoke(app, ["skill", "remove", "--mcp"])
1856+
assert result.exit_code == 1
1857+
assert "--location is required" in _strip_ansi(result.output)
1858+
remove.assert_not_called()
1859+
remove_locations.assert_not_called()
1860+
1861+
def test_mcp_malformed_location_exit_1(self):
1862+
with patch("ucode.cli.remove_skills_locations_command") as remove:
1863+
result = runner.invoke(app, ["skill", "remove", "--mcp", "--location", "a.b.c"])
18311864
assert result.exit_code == 1
1832-
assert "not supported with --mcp" in _strip_ansi(result.output)
1865+
assert "--location" in _strip_ansi(result.output)
1866+
remove.assert_not_called()
1867+
1868+
def test_mcp_with_path_exit_1(self):
1869+
with patch("ucode.cli.remove_skills_locations_command") as remove:
1870+
result = runner.invoke(app, ["skill", "remove", "--mcp", "--path", "/abs"])
1871+
assert result.exit_code == 1
1872+
assert "--path" in _strip_ansi(result.output)
1873+
remove.assert_not_called()
1874+
1875+
def test_mcp_with_skills_exit_1(self):
1876+
with patch("ucode.cli.remove_skills_locations_command") as remove:
1877+
result = runner.invoke(app, ["skill", "remove", "--mcp", "--skills", "a.b.s1"])
1878+
assert result.exit_code == 1
1879+
assert "--skills" in _strip_ansi(result.output)
18331880
remove.assert_not_called()
18341881

18351882

tests/test_mcp.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2529,6 +2529,78 @@ def test_agent_scope_with_empty_scope_is_a_noop(self, monkeypatch):
25292529
assert "called" not in captured
25302530

25312531

2532+
class TestRemoveSkillsLocationsCommand:
2533+
"""`ug skill remove --mcp --location`: non-interactive schema removal from the skills scope."""
2534+
2535+
def _state(self, by_client=None):
2536+
by_client = by_client or _by_client(["claude", "codex"], ["A.a", "B.b"])
2537+
return {
2538+
"workspace": WS,
2539+
"available_tools": ["claude", "codex"],
2540+
"mcp_servers": mcp._resolve_skills_mcp_servers(WS, list(by_client), by_client, []),
2541+
}
2542+
2543+
def _stub(self, monkeypatch, state):
2544+
configured: list[tuple[str, str]] = []
2545+
_stub_location_base(monkeypatch, state)
2546+
monkeypatch.setattr(mcp, "available_mcp_clients", lambda: ["claude", "codex"])
2547+
monkeypatch.setattr(
2548+
mcp,
2549+
"configure_client_mcp_server",
2550+
lambda client, name, url, *a, **kw: configured.append((client, url)) or [],
2551+
)
2552+
monkeypatch.setattr(mcp, "save_state", lambda s: None)
2553+
return configured
2554+
2555+
def test_removes_named_schema_from_every_client(self, monkeypatch):
2556+
state = self._state()
2557+
configured = self._stub(monkeypatch, state)
2558+
2559+
assert mcp.remove_skills_locations_command(["A.a"]) == 0
2560+
2561+
entry = _find_skills(state["mcp_servers"])[0]
2562+
assert mcp.skill_locations_for_client(entry, "claude") == ["B.b"]
2563+
assert mcp.skill_locations_for_client(entry, "codex") == ["B.b"]
2564+
assert sorted(configured) == [
2565+
("claude", f"{WS}/ai-gateway/skills/?schema=B.b"),
2566+
("codex", f"{WS}/ai-gateway/skills/?schema=B.b"),
2567+
]
2568+
2569+
def test_schema_not_in_scope_is_a_noop(self, monkeypatch):
2570+
state = self._state()
2571+
configured = self._stub(monkeypatch, state)
2572+
2573+
assert mcp.remove_skills_locations_command(["Z.z"]) == 0
2574+
2575+
entry = _find_skills(state["mcp_servers"])[0]
2576+
assert mcp.skill_locations_for_client(entry, "claude") == ["A.a", "B.b"]
2577+
assert configured == []
2578+
2579+
def test_agents_removes_from_only_named_client(self, monkeypatch):
2580+
state = self._state()
2581+
configured = self._stub(monkeypatch, state)
2582+
2583+
assert mcp.remove_skills_locations_command(["A.a"], agents={"claude"}) == 0
2584+
2585+
entry = _find_skills(state["mcp_servers"])[0]
2586+
assert mcp.skill_locations_for_client(entry, "claude") == ["B.b"]
2587+
assert mcp.skill_locations_for_client(entry, "codex") == ["A.a", "B.b"]
2588+
assert configured == [("claude", f"{WS}/ai-gateway/skills/?schema=B.b")]
2589+
2590+
def test_removing_all_schemas_keeps_schemaless_connection(self, monkeypatch):
2591+
state = self._state()
2592+
configured = self._stub(monkeypatch, state)
2593+
2594+
assert mcp.remove_skills_locations_command(["A.a", "B.b"]) == 0
2595+
2596+
entry = _find_skills(state["mcp_servers"])[0]
2597+
assert entry["skill_locations"] == []
2598+
assert sorted(configured) == [
2599+
("claude", f"{WS}/ai-gateway/skills/"),
2600+
("codex", f"{WS}/ai-gateway/skills/"),
2601+
]
2602+
2603+
25322604
class TestRegisterSchemalessSkillsConnection:
25332605
def _stub(self, monkeypatch):
25342606
saved_states: list[dict] = []

0 commit comments

Comments
 (0)