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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,24 @@ 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 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.

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

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

# Download a named subset, by bare name (with --location) or fully-qualified name.
ucode skill add --location main.default --skills my-skill,other-skill
ucode skill add --skills main.default.my-skill,main.default.other-skill
```

### 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 +315,9 @@ 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 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` | 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) |
| `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
88 changes: 88 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.")
skill_app = typer.Typer(add_completion=False, no_args_is_help=True)
app.add_typer(skill_app, name="skill", 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,91 @@ def mcp_web_search_cmd() -> None:
serve()


@skill_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,
skills: Annotated[
str | None,
typer.Option(
"--skills",
help="(download) Download only this comma-separated subset of skills instead of "
"every skill in the schema. Bare securable names (e.g. `my-skill`) need a single "
"--location; fully-qualified `<catalog>.<schema>.<name>` names work on their own. "
"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. ``--skills`` narrows a download to a
subset of one schema's skills, by bare name (with ``--location``) or
fully-qualified ``<catalog>.<schema>.<name>``.
"""
try:
locations = _parse_skill_locations(location)
requested_skills = (
None if skills is None else {s.strip() for s in skills.split(",") if s.strip()}
)
if mcp and path is not None:
raise RuntimeError("--path is not supported when using --mcp")
if mcp and requested_skills is not None:
raise RuntimeError("--skills is not supported when using --mcp")
if requested_skills is not None and not locations:
schemas = {".".join(s.split(".")[:2]) for s in requested_skills if s.count(".") >= 2}
bare = sorted(s for s in requested_skills if s.count(".") < 2)
if bare:
raise RuntimeError(
"--skills short names need --location (or pass full names like "
f"`<catalog>.<schema>.<name>`): {', '.join(bare)}"
)
if len(schemas) != 1:
raise RuntimeError(
"--skills without --location must all share one `<catalog>.<schema>` "
f"(got: {', '.join(sorted(schemas)) or 'none'}); pass --location instead."
)
locations = list(schemas)
if not locations:
raise RuntimeError("--location is required for `ucode skill add`.")
Comment on lines +1196 to +1197

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Is this error message correct? I think we can also use --skills solely right?

if requested_skills is not None and len(locations) != 1:
raise RuntimeError(
f"--skills requires a single --location (got: {', '.join(locations)})."
)
selected_skills = (
None if requested_skills is None else {s.split(".")[-1] for s in requested_skills}
)
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
98 changes: 98 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -681,6 +681,104 @@ def test_path_without_location_exit_1(self):
mock_download.assert_not_called()


class TestSkillsAddCommand:
"""`ucode skill 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, ["skill", "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, ["skill", "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, ["skill", "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, ["skill", "add", "--location", "a.b", "--skills", "s1, s2"])
assert result.exit_code == 0, result.output
mock_download.assert_called_once_with(["a.b"], path=None, skills={"s1", "s2"})

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

def test_bare_skills_without_location_exit_1(self):
with patch("ucode.cli.configure_skills_download_command") as mock_download:
result = runner.invoke(app, ["skill", "add", "--skills", "s1"])
assert result.exit_code == 1
assert "--skills short names need --location" in _strip_ansi(result.output)
mock_download.assert_not_called()

def test_fully_qualified_skills_across_schemas_exit_1(self):
with patch("ucode.cli.configure_skills_download_command") as mock_download:
result = runner.invoke(app, ["skill", "add", "--skills", "a.b.s1, c.d.s2"])
assert result.exit_code == 1
assert "must all share one" in _strip_ansi(result.output)
mock_download.assert_not_called()

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, ["skill", "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, ["skill", "add", "--location", "a.b", "--mcp", "--skills", "s1"]
)
assert result.exit_code == 1
assert "--skills" 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, ["skill", "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, ["skill", "add", "--location", "a.b, c.d", "--skills", "s1"]
)
assert result.exit_code == 1
assert "--skills 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, ["skill", "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