Skip to content

Commit 184e027

Browse files
xsh310Isaac
andauthored
[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 292844e commit 184e027

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
@@ -279,15 +279,19 @@ configured agent. It applies only to `--mcp`, since downloaded skills are shared
279279

280280
#### Remove skill scopes
281281

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

284285
```bash
285-
# Pick schemas to remove; each is removed from every agent it's on.
286-
ug skill remove --mcp
286+
# Remove specific schemas from the MCP scope; each is removed from every agent it's on.
287+
ug skill remove --location main.default,ml.prod --mcp
287288

288289
# Remove from specific agents only. A schema scoped to several agents is
289290
# removed from the named ones and kept on the rest.
290-
ug skill remove --mcp --agents claude
291+
ug skill remove --location main.default --mcp --agents claude
292+
293+
# No --location launches a picker of the scoped schemas to remove.
294+
ug skill remove --mcp
291295
```
292296

293297
#### Remove downloaded skills
@@ -374,8 +378,8 @@ The output looks like:
374378
| `ug skill add --location main.default --mcp --agents claude,codex` | Add schemas to specific agents' skills MCP scope (sets up any not yet configured) |
375379
| `ug skill add --location main.default` | Download a schema's skills to disk without removing existing downloads |
376380
| `ug skill add --skills main.default.my-skill` | Download named skills by fully-qualified name (comma-separated; may span schemas) |
377-
| `ug skill remove --mcp` | Remove skill schemas from the skills MCP connection (every agent) |
378-
| `ug skill remove --mcp --agents claude` | Remove skill schemas from specific agents only, keeping them on the rest |
381+
| `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) |
382+
| `ug skill remove --location main.default --mcp --agents claude` | Remove schemas from specific agents' skills MCP scope, keeping them on the rest |
379383
| `ug skill remove` | Pick from every downloaded skill (across all bases) and delete it from disk |
380384
| `ug skill remove --location main.default [--path <dir>]` | Delete every skill downloaded from a schema (all bases, or one under `<dir>`) |
381385
| `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
)
@@ -1311,8 +1312,8 @@ def skills_remove(
13111312
str | None,
13121313
typer.Option(
13131314
"--location",
1314-
help="(download) Comma-separated `<catalog>.<schema>` schemas whose downloaded "
1315-
"skills to remove.",
1315+
help="Comma-separated `<catalog>.<schema>` schemas to remove (from the skills MCP "
1316+
"scope with --mcp, else their downloaded skills).",
13161317
),
13171318
] = None,
13181319
mcp: Annotated[
@@ -1351,26 +1352,33 @@ def skills_remove(
13511352
) -> None:
13521353
"""Remove Skills previously added to your coding tools.
13531354
1354-
With ``--mcp``, interactively drops skill schemas from the skills MCP connection.
1355-
Otherwise removes downloaded skill directories: ``--location`` removes every skill
1356-
downloaded from a ``<catalog>.<schema>``, ``--skills`` removes named fully-qualified
1357-
skills that may span schemas, and with none of them a picker lists every downloaded
1358-
skill. ``--path`` limits either to one download base. Only skills ucode downloaded are
1359-
removed; a same-named skill you authored is left alone.
1355+
With ``--mcp``, drops skill schemas from the skills MCP connection: ``--location`` removes the
1356+
named ``<catalog>.<schema>`` schemas, and with none on an interactive terminal a picker lists
1357+
the scoped schemas. Otherwise removes downloaded skill directories: ``--location`` removes every
1358+
skill downloaded from a ``<catalog>.<schema>``, ``--skills`` removes named fully-qualified skills
1359+
that may span schemas, and with none of them a picker lists every downloaded skill. ``--path``
1360+
limits either to one download base. Only skills ucode downloaded are removed; a same-named skill
1361+
you authored is left alone.
13601362
"""
13611363
try:
13621364
requested_skills = (
13631365
None if skills is None else {s.strip() for s in skills.split(",") if s.strip()}
13641366
)
13651367
if mcp:
1366-
if location is not None or path is not None or requested_skills is not None:
1367-
raise RuntimeError("--location, --path, and --skills are not supported with --mcp.")
1368+
if path is not None or requested_skills is not None:
1369+
raise RuntimeError("--path and --skills are not supported with --mcp.")
13681370
requested_agents = (
13691371
None
13701372
if agents is None
13711373
else ({a.strip().lower() for a in agents.split(",") if a.strip()} or None)
13721374
)
1373-
remove_skills_command(agents=requested_agents)
1375+
locations = _parse_skill_locations(location)
1376+
if locations:
1377+
remove_skills_locations_command(locations, agents=requested_agents)
1378+
elif _stdin_is_interactive():
1379+
remove_skills_command(agents=requested_agents)
1380+
else:
1381+
raise RuntimeError("--location is required for `ug skill remove --mcp`.")
13741382
return
13751383
if agents is not None:
13761384
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
@@ -1990,6 +1990,33 @@ def add_skill_locations_to_mcp(
19901990
_update_skills_mcp(state, workspace, profile, clients, locations_by_client)
19911991

19921992

1993+
def remove_skill_locations_from_mcp(
1994+
state: dict,
1995+
workspace: str,
1996+
profile: str | None,
1997+
clients: list[str],
1998+
locations: set[str],
1999+
) -> list[str]:
2000+
"""Drop ``locations`` from each targeted client's skill MCP scope, returning the schemas removed."""
2001+
locations_by_client = _skill_locations_by_client_from_state(state)
2002+
removed = sorted(
2003+
{
2004+
location
2005+
for client in clients
2006+
for location in locations_by_client.get(client, [])
2007+
if location in locations
2008+
}
2009+
)
2010+
for client in clients:
2011+
locations_by_client[client] = [
2012+
location
2013+
for location in locations_by_client.get(client, [])
2014+
if location not in locations
2015+
]
2016+
_update_skills_mcp(state, workspace, profile, clients, locations_by_client, print_summary=False)
2017+
return removed
2018+
2019+
19932020
def configured_skill_locations(state: dict, clients: list[str]) -> set[str]:
19942021
"""The union of skill schemas already in the MCP scope across ``clients``."""
19952022
locations_by_client = _skill_locations_by_client_from_state(state)
@@ -2113,6 +2140,10 @@ def _prompt_for_skill_removal(locations_by_client: dict[str, list[str]]) -> list
21132140
return [str(value) for value in selection]
21142141

21152142

2143+
def _removed_schemas_summary(count: int) -> str:
2144+
return f"Removed {count} skill schema{'s' if count != 1 else ''}."
2145+
2146+
21162147
def remove_skills_command(agents: set[str] | None = None) -> int:
21172148
"""`ucode skill remove --mcp`: interactively drop skill schemas from clients' skills scopes.
21182149
@@ -2142,15 +2173,28 @@ def remove_skills_command(agents: set[str] | None = None) -> int:
21422173
print_note("No skill schemas selected.")
21432174
return 0
21442175

2145-
remove_locations = set(selection)
2146-
for client in clients:
2147-
locations_by_client[client] = [
2148-
location
2149-
for location in locations_by_client.get(client, [])
2150-
if location not in remove_locations
2151-
]
2152-
_update_skills_mcp(state, workspace, profile, clients, locations_by_client, print_summary=False)
2153-
print_success(
2154-
f"Removed {len(remove_locations)} skill schema{'s' if len(remove_locations) != 1 else ''}."
2176+
removed = remove_skill_locations_from_mcp(state, workspace, profile, clients, set(selection))
2177+
print_success(_removed_schemas_summary(len(removed)))
2178+
return 0
2179+
2180+
2181+
def remove_skills_locations_command(locations: list[str], agents: set[str] | None = None) -> int:
2182+
"""`ucode skill remove --mcp --location`: drop the named schemas from clients' skills scopes.
2183+
2184+
Non-interactive counterpart to ``remove_skills_command``. ``agents`` (from ``--agents``) scopes
2185+
removal to that subset of configured clients; omitting it targets every configured client. A
2186+
schema not in scope is a no-op. Needs no Databricks auth."""
2187+
state = load_state()
2188+
workspace, profile, clients = setup_mcp_clients(
2189+
state,
2190+
"Remove Skills MCP",
2191+
require_auth=False,
2192+
action_note="Removing from",
2193+
agents=agents,
21552194
)
2195+
removed = remove_skill_locations_from_mcp(state, workspace, profile, clients, set(locations))
2196+
if removed:
2197+
print_success(_removed_schemas_summary(len(removed)))
2198+
else:
2199+
print_note("None of the given schemas were in the skills MCP scope.")
21562200
return 0

tests/test_cli.py

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

1730-
def test_mcp_remove_dispatches_global_removal(self):
1731-
with patch("ucode.cli.remove_skills_command") as remove:
1730+
def test_mcp_remove_no_location_interactive_opens_picker(self):
1731+
with (
1732+
patch("ucode.cli._stdin_is_interactive", return_value=True),
1733+
patch("ucode.cli.remove_skills_command") as remove,
1734+
):
17321735
result = runner.invoke(app, ["skill", "remove", "--mcp"])
17331736

17341737
assert result.exit_code == 0, result.output
17351738
remove.assert_called_once_with(agents=None)
17361739

17371740
def test_mcp_remove_forwards_agent_scope(self):
1738-
with patch("ucode.cli.remove_skills_command") as remove:
1741+
with (
1742+
patch("ucode.cli._stdin_is_interactive", return_value=True),
1743+
patch("ucode.cli.remove_skills_command") as remove,
1744+
):
17391745
result = runner.invoke(app, ["skill", "remove", "--mcp", "--agents", "claude, codex"])
17401746

17411747
assert result.exit_code == 0, result.output
@@ -1815,11 +1821,52 @@ def test_agents_without_mcp_exit_1(self):
18151821
assert "--agents is only supported when using --mcp" in _strip_ansi(result.output)
18161822
mock_remove.assert_not_called()
18171823

1818-
def test_mcp_with_location_exit_1(self):
1819-
with patch("ucode.cli.remove_skills_command") as remove:
1820-
result = runner.invoke(app, ["skill", "remove", "--mcp", "--location", "a.b"])
1824+
def test_mcp_with_location_routes_to_location_removal(self):
1825+
with patch("ucode.cli.remove_skills_locations_command") as remove:
1826+
result = runner.invoke(app, ["skill", "remove", "--mcp", "--location", "a.b, c.d"])
1827+
assert result.exit_code == 0, result.output
1828+
remove.assert_called_once_with(["a.b", "c.d"], agents=None)
1829+
1830+
def test_mcp_with_location_forwards_agent_scope(self):
1831+
with patch("ucode.cli.remove_skills_locations_command") as remove:
1832+
result = runner.invoke(
1833+
app,
1834+
["skill", "remove", "--mcp", "--location", "a.b", "--agents", "claude, codex"],
1835+
)
1836+
assert result.exit_code == 0, result.output
1837+
remove.assert_called_once_with(["a.b"], agents={"claude", "codex"})
1838+
1839+
def test_mcp_no_location_non_interactive_exit_1(self):
1840+
with (
1841+
patch("ucode.cli._stdin_is_interactive", return_value=False),
1842+
patch("ucode.cli.remove_skills_command") as remove,
1843+
patch("ucode.cli.remove_skills_locations_command") as remove_locations,
1844+
):
1845+
result = runner.invoke(app, ["skill", "remove", "--mcp"])
1846+
assert result.exit_code == 1
1847+
assert "--location is required" in _strip_ansi(result.output)
1848+
remove.assert_not_called()
1849+
remove_locations.assert_not_called()
1850+
1851+
def test_mcp_malformed_location_exit_1(self):
1852+
with patch("ucode.cli.remove_skills_locations_command") as remove:
1853+
result = runner.invoke(app, ["skill", "remove", "--mcp", "--location", "a.b.c"])
18211854
assert result.exit_code == 1
1822-
assert "not supported with --mcp" in _strip_ansi(result.output)
1855+
assert "--location" in _strip_ansi(result.output)
1856+
remove.assert_not_called()
1857+
1858+
def test_mcp_with_path_exit_1(self):
1859+
with patch("ucode.cli.remove_skills_locations_command") as remove:
1860+
result = runner.invoke(app, ["skill", "remove", "--mcp", "--path", "/abs"])
1861+
assert result.exit_code == 1
1862+
assert "--path" in _strip_ansi(result.output)
1863+
remove.assert_not_called()
1864+
1865+
def test_mcp_with_skills_exit_1(self):
1866+
with patch("ucode.cli.remove_skills_locations_command") as remove:
1867+
result = runner.invoke(app, ["skill", "remove", "--mcp", "--skills", "a.b.s1"])
1868+
assert result.exit_code == 1
1869+
assert "--skills" in _strip_ansi(result.output)
18231870
remove.assert_not_called()
18241871

18251872

tests/test_mcp.py

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

25142514

2515+
class TestRemoveSkillsLocationsCommand:
2516+
"""`ug skill remove --mcp --location`: non-interactive schema removal from the skills scope."""
2517+
2518+
def _state(self, by_client=None):
2519+
by_client = by_client or _by_client(["claude", "codex"], ["A.a", "B.b"])
2520+
return {
2521+
"workspace": WS,
2522+
"available_tools": ["claude", "codex"],
2523+
"mcp_servers": mcp._resolve_skills_mcp_servers(WS, list(by_client), by_client, []),
2524+
}
2525+
2526+
def _stub(self, monkeypatch, state):
2527+
configured: list[tuple[str, str]] = []
2528+
_stub_location_base(monkeypatch, state)
2529+
monkeypatch.setattr(mcp, "available_mcp_clients", lambda: ["claude", "codex"])
2530+
monkeypatch.setattr(
2531+
mcp,
2532+
"configure_client_mcp_server",
2533+
lambda client, name, url, *a, **kw: configured.append((client, url)) or [],
2534+
)
2535+
monkeypatch.setattr(mcp, "save_state", lambda s: None)
2536+
return configured
2537+
2538+
def test_removes_named_schema_from_every_client(self, monkeypatch):
2539+
state = self._state()
2540+
configured = self._stub(monkeypatch, state)
2541+
2542+
assert mcp.remove_skills_locations_command(["A.a"]) == 0
2543+
2544+
entry = _find_skills(state["mcp_servers"])[0]
2545+
assert mcp.skill_locations_for_client(entry, "claude") == ["B.b"]
2546+
assert mcp.skill_locations_for_client(entry, "codex") == ["B.b"]
2547+
assert sorted(configured) == [
2548+
("claude", f"{WS}/ai-gateway/skills/?schema=B.b"),
2549+
("codex", f"{WS}/ai-gateway/skills/?schema=B.b"),
2550+
]
2551+
2552+
def test_schema_not_in_scope_is_a_noop(self, monkeypatch):
2553+
state = self._state()
2554+
configured = self._stub(monkeypatch, state)
2555+
2556+
assert mcp.remove_skills_locations_command(["Z.z"]) == 0
2557+
2558+
entry = _find_skills(state["mcp_servers"])[0]
2559+
assert mcp.skill_locations_for_client(entry, "claude") == ["A.a", "B.b"]
2560+
assert configured == []
2561+
2562+
def test_agents_removes_from_only_named_client(self, monkeypatch):
2563+
state = self._state()
2564+
configured = self._stub(monkeypatch, state)
2565+
2566+
assert mcp.remove_skills_locations_command(["A.a"], agents={"claude"}) == 0
2567+
2568+
entry = _find_skills(state["mcp_servers"])[0]
2569+
assert mcp.skill_locations_for_client(entry, "claude") == ["B.b"]
2570+
assert mcp.skill_locations_for_client(entry, "codex") == ["A.a", "B.b"]
2571+
assert configured == [("claude", f"{WS}/ai-gateway/skills/?schema=B.b")]
2572+
2573+
def test_removing_all_schemas_keeps_schemaless_connection(self, monkeypatch):
2574+
state = self._state()
2575+
configured = self._stub(monkeypatch, state)
2576+
2577+
assert mcp.remove_skills_locations_command(["A.a", "B.b"]) == 0
2578+
2579+
entry = _find_skills(state["mcp_servers"])[0]
2580+
assert entry["skill_locations"] == []
2581+
assert sorted(configured) == [
2582+
("claude", f"{WS}/ai-gateway/skills/"),
2583+
("codex", f"{WS}/ai-gateway/skills/"),
2584+
]
2585+
2586+
25152587
class TestRegisterSchemalessSkillsConnection:
25162588
def _stub(self, monkeypatch):
25172589
saved_states: list[dict] = []

0 commit comments

Comments
 (0)