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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,20 @@ ucode configure skills --location main.default,ml.prod --mcp
Each run prints the registered server, its URL, the configured agents, and its tools, and reminds
you to run `ucode <agent>` (existing agent sessions need a restart before the MCP tools load).

#### Add skill scopes without replacing existing ones

`ucode skills add` registers skills additively, keeping anything already configured. It requires
`--location`; with `--mcp` it adds the schemas to the connection's scope, otherwise it downloads
their skills to disk.

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

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

### Managed config for a workspace (admins)

Author the coding config your developers pick up automatically, instead of asking each of them to
Expand Down Expand Up @@ -297,6 +311,8 @@ their next ucode run.
| `ucode configure skills --location main.default [--path <dir>]` | Download a schema's skills to disk (under `<dir>`, or your home dir) and register a schema-less skills MCP connection |
| `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 skills add --location main.default --mcp` | Add schemas to the skills MCP scope, keeping any already configured (additive; never replaces) |
| `ucode skills add --location main.default` | Download a schema's skills to disk without removing existing downloads |
| `ucode setup` | Author the managed config's agents and models (workspace admins only) |
| `ucode setup mcps` | Add or change the managed config's MCP servers |
| `ucode setup skills [--location a.b,c.d]` | Add or change the managed config's skills |
Expand Down
69 changes: 69 additions & 0 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
MCP_CLIENTS,
SKILLS_MCP_KIND,
add_mcp_command,
add_skills_command,
apply_managed_mcp_servers,
apply_managed_skills,
configure_mcp_command,
Expand Down Expand Up @@ -1047,6 +1048,8 @@ def revert() -> int:
app.add_typer(configure_app, name="configure", help="Configure workspace and tool settings.")
mcp_app = typer.Typer(add_completion=False, no_args_is_help=True)
app.add_typer(mcp_app, name="mcp", help="MCP servers exposed by ucode.")
skills_app = typer.Typer(add_completion=False, no_args_is_help=True)
app.add_typer(skills_app, name="skills", help="Databricks Skills for your coding tools.")
setup_app = typer.Typer(add_completion=False, no_args_is_help=False)
app.add_typer(
setup_app,
Expand Down Expand Up @@ -1126,6 +1129,72 @@ def mcp_web_search_cmd() -> None:
serve()


@skills_app.command("add")
def skills_add(
location: Annotated[
str | None,
typer.Option(
"--location", help="Comma-separated `<catalog>.<schema>` skill scopes to add."
),
] = None,
mcp: Annotated[
bool,
typer.Option(
"--mcp",
help="Add the schemas to the skills MCP connection's scope instead of downloading.",
),
] = False,
path: Annotated[
str | None,
typer.Option(
"--path",
help="(download) Existing absolute dir to download into; defaults to your home dir.",
),
] = None,
skill: Annotated[
str | None,
typer.Option(
"--skill",
help="(download) Download only this comma-separated subset of skills (by "
"securable name, e.g. `my-skill`) from the schema, instead of every skill. "
"Requires a single --location; not valid with --mcp.",
),
] = None,
) -> None:
"""Add Databricks Skills to your coding tools, keeping any already configured.

With ``--mcp``, adds the given schemas to the skills MCP connection's scope.
Otherwise downloads each schema's skills to disk (under ``--path``, or your home
dir), keeping already-downloaded skills. ``--skill`` narrows a download to a
named subset of a single schema's skills. Requires ``--location``.
"""
try:
locations = _parse_skill_locations(location)
selected_skills = (
None if skill is None else {s.strip() for s in skill.split(",") if s.strip()}
)
if not locations:
raise RuntimeError("--location is required for `ucode skills add`.")
if mcp and path is not None:
raise RuntimeError("--path is not supported when using --mcp")
if mcp and selected_skills is not None:
raise RuntimeError("--skill is not supported when using --mcp")
if selected_skills is not None and len(locations) != 1:
raise RuntimeError(
f"--skill requires a single --location (got: {', '.join(locations)})."
)
if mcp:
add_skills_command(locations)
else:
configure_skills_download_command(locations, path=path, skills=selected_skills)
except (RuntimeError, ValueError) as exc:
print_err(str(exc))
raise typer.Exit(1) from None
except KeyboardInterrupt:
print_err("Interrupted.")
raise typer.Exit(130) from None


@app.command("mcp-proxy", hidden=True)
def mcp_proxy_cmd(
url: Annotated[
Expand Down
14 changes: 14 additions & 0 deletions src/ucode/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -2158,3 +2158,17 @@ def register_schemaless_skills_connection(
``--mcp`` ``skill_locations`` and otherwise registers the bare schema-less
route (utility tools only)."""
_update_skills_mcp(state, workspace, profile, clients, _skill_mcp_locations(state))


def _union_locations(base: list[str], new: list[str]) -> list[str]:
have = set(base)
return [*base, *[loc for loc in new if loc not in have]]


def add_skills_command(locations: list[str]) -> 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)
return 0
78 changes: 78 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,84 @@ def test_path_without_location_exit_1(self):
mock_download.assert_not_called()


class TestSkillsAddCommand:
"""`ucode skills add` is the additive sibling of `configure skills`: `--mcp`
unions schemas into the connection scope, the default mode downloads."""

def test_mcp_flag_unions_locations(self):
with patch("ucode.cli.add_skills_command") as mock_add:
result = runner.invoke(app, ["skills", "add", "--location", "a.b", "--mcp"])
assert result.exit_code == 0, result.output
mock_add.assert_called_once_with(["a.b"])

def test_comma_location_yields_multiple_schemas(self):
with patch("ucode.cli.add_skills_command") as mock_add:
result = runner.invoke(app, ["skills", "add", "--location", "a.b, c.d", "--mcp"])
assert result.exit_code == 0, result.output
mock_add.assert_called_once_with(["a.b", "c.d"])

def test_default_mode_dispatches_download(self):
with patch("ucode.cli.configure_skills_download_command") as mock_download:
result = runner.invoke(app, ["skills", "add", "--location", "a.b", "--path", "/tmp/s"])
assert result.exit_code == 0, result.output
mock_download.assert_called_once_with(["a.b"], path="/tmp/s", skills=None)

def test_skill_filter_dispatches_download_with_subset(self):
with patch("ucode.cli.configure_skills_download_command") as mock_download:
result = runner.invoke(app, ["skills", "add", "--location", "a.b", "--skill", "s1, s2"])
assert result.exit_code == 0, result.output
mock_download.assert_called_once_with(["a.b"], path=None, skills={"s1", "s2"})

def test_without_location_exit_1(self):
with (
patch("ucode.cli.add_skills_command") as mock_add,
patch("ucode.cli.configure_skills_download_command") as mock_download,
):
result = runner.invoke(app, ["skills", "add"])
assert result.exit_code == 1
assert "--location is required" in _strip_ansi(result.output)
mock_add.assert_not_called()
mock_download.assert_not_called()

def test_skill_with_mcp_exit_1(self):
with (
patch("ucode.cli.add_skills_command") as mock_add,
patch("ucode.cli.configure_skills_download_command") as mock_download,
):
result = runner.invoke(
app, ["skills", "add", "--location", "a.b", "--mcp", "--skill", "s1"]
)
assert result.exit_code == 1
assert "--skill" in _strip_ansi(result.output)
mock_add.assert_not_called()
mock_download.assert_not_called()

def test_path_with_mcp_exit_1(self):
with patch("ucode.cli.add_skills_command") as mock_add:
result = runner.invoke(
app, ["skills", "add", "--location", "a.b", "--mcp", "--path", "/tmp/s"]
)
assert result.exit_code == 1
assert "--path" in _strip_ansi(result.output)
mock_add.assert_not_called()

def test_skill_with_multiple_locations_exit_1(self):
with patch("ucode.cli.configure_skills_download_command") as mock_download:
result = runner.invoke(
app, ["skills", "add", "--location", "a.b, c.d", "--skill", "s1"]
)
assert result.exit_code == 1
assert "--skill requires a single --location" in _strip_ansi(result.output)
mock_download.assert_not_called()

def test_malformed_location_exit_1(self):
with patch("ucode.cli.add_skills_command") as mock_add:
result = runner.invoke(app, ["skills", "add", "--location", "a.b.c", "--mcp"])
assert result.exit_code == 1
assert "--location" in _strip_ansi(result.output)
mock_add.assert_not_called()


class TestApplyManagedSkills:
"""The launch path both registers the skills MCP connection and downloads bundles to disk."""

Expand Down
46 changes: 46 additions & 0 deletions tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -2398,6 +2398,52 @@ def test_empty_when_no_skills_entry(self):
assert mcp._skill_mcp_locations(_skills_state()) == []


class TestUnionLocations:
def test_appends_new_after_existing(self):
assert mcp._union_locations(["a.b"], ["c.d"]) == ["a.b", "c.d"]

def test_drops_locations_already_present(self):
assert mcp._union_locations(["a.b", "c.d"], ["c.d", "e.f"]) == ["a.b", "c.d", "e.f"]

def test_empty_base_returns_new(self):
assert mcp._union_locations([], ["a.b", "c.d"]) == ["a.b", "c.d"]


class TestAddSkillsCommand:
"""`ucode skills add --mcp` unions schemas into the connection scope rather
than replacing it (unlike `configure_skills_mcp_command`)."""

def test_unions_into_existing_scope(self, monkeypatch):
state = _skills_state(mcp._resolve_skills_mcp_servers(WS, ["claude"], ["A.a"], []))
_stub_location_base(monkeypatch, state)
monkeypatch.setattr(mcp, "configure_client_mcp_server", lambda *a, **kw: [])
monkeypatch.setattr(mcp, "save_state", lambda s: None)

assert mcp.add_skills_command(["B.b"]) == 0

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

def test_existing_schema_leaves_scope_unchanged(self, monkeypatch):
state = _skills_state(mcp._resolve_skills_mcp_servers(WS, ["claude"], ["A.a", "B.b"], []))
_stub_location_base(monkeypatch, state)
monkeypatch.setattr(mcp, "configure_client_mcp_server", lambda *a, **kw: [])
monkeypatch.setattr(mcp, "save_state", lambda s: None)

assert mcp.add_skills_command(["A.a"]) == 0

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

def test_registers_scope_from_empty_state(self, monkeypatch):
state = _skills_state()
_stub_location_base(monkeypatch, state)
monkeypatch.setattr(mcp, "configure_client_mcp_server", lambda *a, **kw: [])
monkeypatch.setattr(mcp, "save_state", lambda s: None)

assert mcp.add_skills_command(["A.a"]) == 0

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


class TestRegisterSchemalessSkillsConnection:
def _stub(self, monkeypatch):
saved_states: list[dict] = []
Expand Down
Loading