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
33 changes: 27 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,18 +210,36 @@ ucode configure skills --location main.default,ml.prod --mcp
same.
- **Download mode** (with `--location`, no `--mcp`) writes each skill flat as `<leaf>/SKILL.md`
(plus its bundled files) into both `.claude/skills/` and `.agents/skills/`. `--path` (an existing
absolute directory) is optional; when omitted, skills are written under your home directory. Any
pre-existing skill dir prompts before it's overwritten. It then registers a schema-less skills
MCP connection, leaving any prior `--mcp` scope untouched. `--skill <name>[,<name>…]` narrows the
download to the named skills (by leaf name) from the schema instead of all of them; requested
names not found in the schema warn and are skipped. `--skill` requires a single `--location`, is
download-only, and is rejected with `--mcp`.
absolute project directory) is optional; when omitted, skills are written to user-level skill
directories. Any pre-existing skill dir prompts before it's overwritten. It then registers a
schema-less skills MCP connection, leaving any prior `--mcp` scope untouched.
`--skill <name>[,<name>…]` narrows the download to the named skills (by leaf name) from the schema
instead of all of them; requested names not found in the schema warn and are skipped. `--skill`
requires a single `--location`, is download-only, and is rejected with `--mcp`.
- **MCP mode** (`--location … --mcp`) sets the connection's location set to exactly `<list>`
(override-only) and rebuilds its `?schema=` URL; no files are downloaded and `--path` is rejected.

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 @@ -356,6 +374,9 @@ The output looks like:
| `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
116 changes: 116 additions & 0 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,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 @@ -1163,6 +1164,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 @@ -1316,6 +1319,119 @@ 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 project directory to download into; defaults "
"to user-level skill directories.",
),
] = 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 project-level skill directories under
``--path``, or to user-level skill directories when omitted, 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")
qualified_skill_parts: dict[str, list[str]] = {}
invalid_skills: list[str] = []
for skill in requested_skills or set():
if "." not in skill:
continue
parts = skill.split(".")
if len(parts) != 3 or any(not part or part != part.strip() for part in parts):
invalid_skills.append(skill)
else:
qualified_skill_parts[skill] = parts
if invalid_skills:
raise RuntimeError(
"--skills entries must be bare names or fully qualified "
"`<catalog>.<schema>.<name>` values "
f"(invalid: {', '.join(sorted(invalid_skills))})."
)
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)
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`.")
if requested_skills is not None and len(locations) != 1:
raise RuntimeError(
f"--skills requires a single --location (got: {', '.join(locations)})."
)
mismatched_skills = sorted(
skill
for skill, parts in qualified_skill_parts.items()
if ".".join(parts[:2]) != locations[0]
)
if mismatched_skills:
raise RuntimeError(
f"--skills entries must match --location `{locations[0]}` "
f"(got: {', '.join(mismatched_skills)})."
)
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
19 changes: 19 additions & 0 deletions src/ucode/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -2210,3 +2210,22 @@ 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)
merged = list(base)
for location in new:
if location not in have:
merged.append(location)
have.add(location)
return merged
Comment thread
artjen marked this conversation as resolved.


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
121 changes: 121 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,127 @@ 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_fully_qualified_skills_match_explicit_location(self):
with patch("ucode.cli.configure_skills_download_command") as mock_download:
result = runner.invoke(
app, ["skill", "add", "--location", "a.b", "--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_fully_qualified_skills_must_match_explicit_location(self):
with patch("ucode.cli.configure_skills_download_command") as mock_download:
result = runner.invoke(app, ["skill", "add", "--location", "a.b", "--skills", "c.d.s1"])
assert result.exit_code == 1
assert "must match --location `a.b`" in _strip_ansi(result.output)
mock_download.assert_not_called()

@pytest.mark.parametrize("skill", ["a.b", "a..s1", "a.b.c.d"])
def test_malformed_fully_qualified_skill_exit_1(self, skill):
with patch("ucode.cli.configure_skills_download_command") as mock_download:
result = runner.invoke(app, ["skill", "add", "--location", "a.b", "--skills", skill])
assert result.exit_code == 1
assert "must be bare names or fully qualified" in _strip_ansi(result.output)
mock_download.assert_not_called()

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
49 changes: 49 additions & 0 deletions tests/test_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -2489,6 +2489,55 @@ 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"]

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


class TestAddSkillsCommand:
"""`ucode skill 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