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
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,13 +227,19 @@ you to run `ucode <agent>` (existing agent sessions need a restart before the MC
#### Add skill scopes without replacing existing ones

`ucode skill add` registers skills additively, keeping anything already configured. With `--mcp` it
adds the schemas to the connection's scope, otherwise it downloads their skills to disk. `--skills`
narrows a download to a subset of one schema's skills.
adds the schemas to every configured agent's scope, or only to `--agents` when supplied. Agents that
are not configured yet are set up first. Without `--mcp`, it downloads skills to disk; download mode
always writes both directory families and does not accept `--agents`. `--skills` narrows a download
to a subset of one schema's skills. Each agent receives the shared location set plus its own
agent-specific additions, with duplicates removed from the effective scope.

```bash
# Add schemas to the skills MCP scope, keeping any already configured.
ucode skill add --location main.default,ml.prod --mcp

# Add a schema only to selected agents.
ucode skill add --location main.default --mcp --agents claude,codex

# Download a schema's skills to disk, keeping existing downloads.
ucode skill add --location main.default

Expand Down Expand Up @@ -377,6 +383,7 @@ The output looks like:
| `ucode configure skills --location main.default --skill my-skill` | Download only the named skill(s) from a schema (comma-separated for several) |
| `ucode configure skills --location main.default --mcp` | Expose a schema's skills as MCP tools (override-only) instead of downloading |
| `ucode skill add --location main.default --mcp` | Add schemas to the skills MCP scope, keeping any already configured (additive; never replaces) |
| `ucode skill add --location main.default --mcp --agents claude` | Set up selected agents if needed and add schemas only to their MCP scopes |
| `ucode skill add --location main.default` | Download a schema's skills to disk without removing existing downloads |
| `ucode skill add --skills main.default.my-skill` | Download a named subset of skills (bare names need `--location`; fully-qualified names stand alone) |
| `ucode setup` | Author the managed config's agents and models (workspace admins only) |
Expand Down
29 changes: 28 additions & 1 deletion src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1353,6 +1353,14 @@ def skills_add(
"Not valid with --mcp.",
),
] = None,
agents: Annotated[
str | None,
typer.Option(
"--agents",
help="(--mcp only) Comma-separated coding agents whose skills MCP scope should "
"be updated. Any that aren't configured yet are set up first.",
),
] = None,
) -> None:
"""Add Databricks Skills to your coding tools, keeping any already configured.

Expand All @@ -1368,6 +1376,16 @@ def skills_add(
requested_skills = (
None if skills is None else {s.strip() for s in skills.split(",") if s.strip()}
)
requested_agents = None
if agents is not None:
requested_agents = {
agent.strip().lower() for agent in agents.split(",") if agent.strip()
}
if not requested_agents:
raise RuntimeError(
"No agents provided for --agents. Use a comma-separated list like "
"`--agents claude,codex`."
)
if mcp and path is not None:
raise RuntimeError("--path is not supported when using --mcp")
if mcp and requested_skills is not None:
Expand All @@ -1388,6 +1406,9 @@ def skills_add(
"`<catalog>.<schema>.<name>` values "
f"(invalid: {', '.join(sorted(invalid_skills))})."
)
# Downloaded skills use shared directory families, so only MCP configs can be agent-scoped.
if not mcp and agents is not None:
raise RuntimeError("--agents is only supported when using --mcp")
if requested_skills is not None and not locations:
schemas = {".".join(parts[:2]) for parts in qualified_skill_parts.values()}
bare = sorted(skill for skill in requested_skills if skill not in qualified_skill_parts)
Expand Down Expand Up @@ -1422,7 +1443,13 @@ def skills_add(
None if requested_skills is None else {s.split(".")[-1] for s in requested_skills}
)
if mcp:
add_skills_command(locations)
scope = (
_configure_agents_for_mcp(sorted(requested_agents)) if requested_agents else None
)
if scope is None:
add_skills_command(locations)
else:
add_skills_command(locations, agents=scope)
else:
configure_skills_download_command(locations, path=path, skills=selected_skills)
except (RuntimeError, ValueError) as exc:
Expand Down
27 changes: 23 additions & 4 deletions src/ucode/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -2359,10 +2359,29 @@ def _union_locations(base: list[str], new: list[str]) -> list[str]:
return merged


def add_skills_command(locations: list[str]) -> int:
def add_skills_command(locations: list[str], agents: set[str] | None = None) -> int:
"""Add ``locations`` to the skills MCP connection's scope, keeping any already configured."""
state = load_state()
workspace, profile, clients = setup_mcp_clients(state, "Add Skills MCP")
merged = _union_locations(_skill_mcp_locations(state), locations)
_update_skills_mcp(state, workspace, profile, clients, merged)
workspace, profile, clients = setup_mcp_clients(state, "Add Skills MCP", agents=agents)
entry = _skills_entry(list(state.get("mcp_servers") or []))
default = _skill_mcp_locations(state)
overrides = _skill_location_overrides(entry)
if agents is None:
merged = _union_locations(default, locations)
else:
merged = default
for client in clients:
_set_skill_location_override(
overrides,
client,
_union_locations(overrides.get(client, []), locations),
)
_update_skills_mcp(
state,
workspace,
profile,
clients,
merged,
location_overrides=overrides,
)
return 0
37 changes: 37 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1213,6 +1213,43 @@ def test_malformed_location_exit_1(self):
assert "--location" in _strip_ansi(result.output)
mock_add.assert_not_called()

def test_agents_scope_is_configured_and_forwarded_for_mcp(self):
with (
patch("ucode.cli._configure_agents_for_mcp", return_value={"claude"}) as configure,
patch("ucode.cli.add_skills_command") as mock_add,
):
result = runner.invoke(
app,
["skill", "add", "--location", "a.b", "--mcp", "--agents", "claude"],
)

assert result.exit_code == 0, result.output
configure.assert_called_once_with(["claude"])
mock_add.assert_called_once_with(["a.b"], agents={"claude"})

def test_empty_agents_scope_is_rejected(self):
with (
patch("ucode.cli._configure_agents_for_mcp") as configure,
patch("ucode.cli.add_skills_command") as mock_add,
):
result = runner.invoke(
app,
["skill", "add", "--location", "a.b", "--mcp", "--agents", ","],
)

assert result.exit_code == 1
assert "No agents provided for --agents" in _strip_ansi(result.output)
configure.assert_not_called()
mock_add.assert_not_called()

def test_agents_is_rejected_for_download_mode(self):
with patch("ucode.cli.configure_skills_download_command") as mock_download:
result = runner.invoke(app, ["skill", "add", "--location", "a.b", "--agents", "claude"])

assert result.exit_code == 1
assert "--agents is only supported when using --mcp" in _strip_ansi(result.output)
mock_download.assert_not_called()


class TestApplyManagedSkills:
"""The launch path both registers the skills MCP connection and downloads bundles to disk."""
Expand Down
73 changes: 73 additions & 0 deletions tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -2610,6 +2610,79 @@ def test_registers_scope_from_empty_state(self, monkeypatch):

assert _find_skills(state["mcp_servers"])[0]["skill_locations"] == ["A.a"]

def test_agents_updates_only_selected_client_scope(self, monkeypatch):
configured: list[tuple[str, str]] = []
prior = mcp._resolve_skills_mcp_servers(WS, ["claude", "codex"], ["A.a"], [])
state = {
"workspace": WS,
"available_tools": ["claude", "codex"],
"mcp_servers": prior,
}
_stub_location_base(monkeypatch, state)
monkeypatch.setattr(mcp, "available_mcp_clients", lambda: ["claude", "codex"])
monkeypatch.setattr(
mcp,
"configure_client_mcp_server",
lambda client, name, url, *a, **kw: configured.append((client, url)) or [],
)
monkeypatch.setattr(mcp, "save_state", lambda s: None)

assert mcp.add_skills_command(["B.b"], agents={"claude"}) == 0

entry = _find_skills(state["mcp_servers"])[0]
assert entry["skill_locations"] == ["A.a"]
assert entry[mcp.SKILL_LOCATION_OVERRIDES_KEY] == {"claude": ["B.b"]}
assert mcp.skill_locations_for_client(entry, "codex") == ["A.a"]
assert configured == [("claude", f"{WS}/ai-gateway/skills/?schema=A.a&schema=B.b")]

def test_global_addition_does_not_rewrite_agent_override(self, monkeypatch):
prior = mcp._resolve_skills_mcp_servers(
WS,
["claude", "codex"],
["A.a"],
[],
{"claude": ["B.b"]},
)
state = {
"workspace": WS,
"available_tools": ["claude", "codex"],
"mcp_servers": prior,
}
_stub_location_base(monkeypatch, state)
monkeypatch.setattr(mcp, "available_mcp_clients", lambda: ["claude", "codex"])
monkeypatch.setattr(mcp, "configure_client_mcp_server", lambda *a, **kw: [])
monkeypatch.setattr(mcp, "save_state", lambda s: None)

assert mcp.add_skills_command(["C.c"]) == 0

entry = _find_skills(state["mcp_servers"])[0]
assert entry["skill_locations"] == ["A.a", "C.c"]
assert entry[mcp.SKILL_LOCATION_OVERRIDES_KEY] == {"claude": ["B.b"]}
assert mcp.skill_locations_for_client(entry, "claude") == ["A.a", "C.c", "B.b"]

def test_agents_persists_explicit_scope_even_when_it_matches_default(self, monkeypatch):
configured: list[tuple[str, str]] = []
prior = mcp._resolve_skills_mcp_servers(WS, ["claude", "codex"], ["A.a"], [])
state = {
"workspace": WS,
"available_tools": ["claude", "codex"],
"mcp_servers": prior,
}
_stub_location_base(monkeypatch, state)
monkeypatch.setattr(mcp, "available_mcp_clients", lambda: ["claude", "codex"])
monkeypatch.setattr(
mcp,
"configure_client_mcp_server",
lambda client, name, url, *a, **kw: configured.append((client, url)) or [],
)
monkeypatch.setattr(mcp, "save_state", lambda s: None)

assert mcp.add_skills_command(["A.a"], agents={"claude"}) == 0

entry = _find_skills(state["mcp_servers"])[0]
assert entry[mcp.SKILL_LOCATION_OVERRIDES_KEY] == {"claude": ["A.a"]}
assert configured == []


class TestRegisterSchemalessSkillsConnection:
def _stub(self, monkeypatch):
Expand Down