Skip to content
Merged
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
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,9 @@ ucode configure skills
# Download mode: fetch every skill in the schema to disk (and register the connection).
ucode configure skills --location main.default --path /abs/project/dir

# Download a named subset of the schema's skills instead of all of them.
ucode configure skills --location main.default --skill my-skill

# MCP mode: expose the schema's skills as MCP tools instead of downloading.
ucode configure skills --location main.default,ml.prod --mcp
```
Expand All @@ -135,7 +138,10 @@ ucode configure skills --location main.default,ml.prod --mcp
(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.
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.

Expand All @@ -160,6 +166,7 @@ you to run `ucode <agent>` (existing agent sessions need a restart before the MC
| `ucode configure --agents claude --mcp system.ai.slack` | Configure an agent and register its Databricks MCP server(s) in one command |
| `ucode configure skills` | Register the skills MCP connection (utility tools only); no skills download |
| `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 |

## Managed Local Files
Expand Down
28 changes: 26 additions & 2 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1545,6 +1545,15 @@ def configure_skills(
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 leaf "
"name, e.g. `my-skill`) from the schema, instead of every skill. Requires a "
"single --location; not valid with --mcp.",
),
] = None,
) -> None:
"""Configure Databricks Skills for your coding tools.

Expand All @@ -1554,18 +1563,33 @@ def configure_skills(
When ``--location`` is provided: with ``--mcp``, sets the connection's scope to
exactly the listed schemas (no download); otherwise, downloads every skill in
each schema to disk (under ``--path``, or your home dir when omitted) and
registers the MCP connection with utility tools only.
registers the MCP connection with utility tools only. ``--skill`` narrows a
download to a named subset of a single schema's skills (requires exactly one
``--location``).
"""
try:
locations = _parse_skill_locations(location)
# `--skill` absent -> None (whole schema); present (even empty) -> the
# explicit subset, so `--skill ""` downloads nothing.
selected_skills = (
None if skill is None else {s.strip() for s in skill.split(",") if s.strip()}
)
if mcp and path is not None:
raise RuntimeError("--path is not valid with --mcp.")
if mcp and selected_skills is not None:
raise RuntimeError("--skill is not valid with --mcp; it only applies when downloading.")
if path is not None and not locations:
raise RuntimeError("--path only applies when downloading with --location.")
if selected_skills is not None and not locations:
raise RuntimeError("--skill only applies when downloading with --location.")
if selected_skills is not None and len(locations) != 1:
raise RuntimeError(
f"--skill requires a single --location (got: {', '.join(locations)})."
)
if mcp or not locations:
configure_skills_mcp_command(locations)
else:
configure_skills_download_command(locations, path=path)
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
Expand Down
31 changes: 27 additions & 4 deletions src/ucode/skills_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,12 +247,21 @@ def _fetch_bundles(
return results


def download_skills(workspace: str, token: str, locations: list[str], path: str | None) -> None:
def download_skills(
workspace: str,
token: str,
locations: list[str],
path: str | None,
skills: set[str] | None = None,
) -> None:
"""Download every skill in each ``<catalog>.<schema>`` location to disk.

Bundles are fetched concurrently (with a progress bar) per schema, then
written sequentially so overwrite prompts don't interleave. A failure on one
skill warns and skips it without aborting the batch.

When ``skills`` is given, only those leaf names are downloaded; names absent
from a schema warn and are skipped. ``None`` downloads the whole schema.
"""
roots = skill_dir_roots(path)
roots_display = " and ".join(str(root) for root in roots)
Expand All @@ -262,6 +271,17 @@ def download_skills(workspace: str, token: str, locations: list[str], path: str
if reason:
print_warning(f"Skipping `{location}`: {reason}.")
continue
if skills is not None:
unknown = skills - set(leaves)
if unknown:
print_warning(
f"Skipping requested skill(s) not found in `{location}`: "
f"{', '.join(sorted(unknown))}."
)
leaves = [leaf for leaf in leaves if leaf in skills]
if not leaves:
print_note(f"No requested skills to download from `{location}`.")
continue
if not leaves:
print_note(f"No skills found in `{location}`.")
continue
Expand All @@ -281,17 +301,20 @@ def download_skills(workspace: str, token: str, locations: list[str], path: str
)


def configure_skills_download_command(locations: list[str], *, path: str | None) -> int:
def configure_skills_download_command(
locations: list[str], *, path: str | None, skills: set[str] | None = None
) -> int:
"""Download every skill in each schema to disk and register the skills connection.

Downloads to ``path`` (or the home dir when None), then registers/keeps the
schema-less MCP connection. ``skill_locations`` is never touched, so a prior
``--mcp`` set survives a download run."""
``--mcp`` set survives a download run. ``skills`` narrows the download (see
``download_skills``)."""
state = load_state()
workspace, profile, clients = setup_mcp_clients(state, "Skills")
token = get_databricks_token(workspace, profile)

download_skills(workspace, token, locations, path)
download_skills(workspace, token, locations, path, skills)

register_schemaless_skills_connection(state, workspace, profile, clients)
return 0
54 changes: 52 additions & 2 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,13 +407,63 @@ def test_default_mode_dispatches_download_with_path(self):
app, ["configure", "skills", "--location", "a.b", "--path", "/tmp/skills"]
)
assert result.exit_code == 0, result.output
mock_download.assert_called_once_with(["a.b"], path="/tmp/skills")
mock_download.assert_called_once_with(["a.b"], path="/tmp/skills", skills=None)

def test_default_mode_without_path_dispatches_download(self):
with patch("ucode.cli.configure_skills_download_command") as mock_download:
result = runner.invoke(app, ["configure", "skills", "--location", "a.b"])
assert result.exit_code == 0, result.output
mock_download.assert_called_once_with(["a.b"], path=None)
mock_download.assert_called_once_with(["a.b"], path=None, 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, ["configure", "skills", "--location", "a.b", "--skill", "my_skill"]
)
assert result.exit_code == 0, result.output
mock_download.assert_called_once_with(["a.b"], path=None, skills={"my_skill"})

def test_skill_filter_parses_comma_list(self):
with patch("ucode.cli.configure_skills_download_command") as mock_download:
result = runner.invoke(
app, ["configure", "skills", "--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_skill_with_mcp_exit_1(self):
with (
patch("ucode.cli.configure_skills_mcp_command") as mock_mcp,
patch("ucode.cli.configure_skills_download_command") as mock_download,
):
result = runner.invoke(
app, ["configure", "skills", "--location", "a.b", "--mcp", "--skill", "my_skill"]
)
assert result.exit_code == 1
assert "--skill" in _strip_ansi(result.output)
mock_mcp.assert_not_called()
mock_download.assert_not_called()

def test_skill_without_location_exit_1(self):
with (
patch("ucode.cli.configure_skills_mcp_command") as mock_mcp,
patch("ucode.cli.configure_skills_download_command") as mock_download,
):
result = runner.invoke(app, ["configure", "skills", "--skill", "my_skill"])
assert result.exit_code == 1
assert "--skill" in _strip_ansi(result.output)
mock_mcp.assert_not_called()
mock_download.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, ["configure", "skills", "--location", "a.b, c.d", "--skill", "my_skill"]
)
assert result.exit_code == 1
output = _strip_ansi(result.output)
assert "--skill requires a single --location" in output
mock_download.assert_not_called()

def test_path_with_mcp_exit_1(self):
with (
Expand Down
70 changes: 67 additions & 3 deletions tests/test_skills_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,60 @@ def test_summary_counts_only_written_skills(self, tmp_path, monkeypatch, capsys)

assert "Downloaded 1/2 skill(s) from `main.default` in" in capsys.readouterr().out

def test_skill_filter_downloads_only_matching_leaves(self, tmp_path, monkeypatch):
monkeypatch.setattr(
sd, "list_schema_skills", lambda *a, **k: (["pii-handling", "triage"], None)
)
monkeypatch.setattr(sd, "fetch_skill_bundle", lambda *a, **k: ({"SKILL.md": b"x"}, None))

sd.download_skills(WS, "token", ["main.default"], str(tmp_path), {"triage"})

assert (tmp_path / ".claude/skills/triage/SKILL.md").read_bytes() == b"x"
assert not (tmp_path / ".claude/skills/pii-handling").exists()

def test_skill_filter_warns_on_unknown_and_downloads_rest(self, tmp_path, monkeypatch, capsys):
monkeypatch.setattr(sd, "list_schema_skills", lambda *a, **k: (["triage"], None))
monkeypatch.setattr(sd, "fetch_skill_bundle", lambda *a, **k: ({"SKILL.md": b"x"}, None))

sd.download_skills(WS, "token", ["main.default"], str(tmp_path), {"triage", "ghost"})

out = capsys.readouterr().out
assert "Skipping requested skill(s) not found in `main.default`: ghost" in out
assert (tmp_path / ".claude/skills/triage/SKILL.md").read_bytes() == b"x"

def test_empty_skill_filter_downloads_nothing(self, tmp_path, monkeypatch, capsys):
monkeypatch.setattr(sd, "list_schema_skills", lambda *a, **k: (["triage"], None))
called = []
monkeypatch.setattr(
sd, "fetch_skill_bundle", lambda *a, **k: called.append(1) or ({"SKILL.md": b"x"}, None)
)

sd.download_skills(WS, "token", ["main.default"], str(tmp_path), set())

assert called == []
assert not (tmp_path / ".claude/skills/triage").exists()
# The schema has skills; the filter selected none — distinct from the
# empty-schema note.
out = capsys.readouterr().out
assert "No requested skills to download from `main.default`." in out
assert "No skills found" not in out

def test_empty_schema_reports_no_skills_found(self, tmp_path, monkeypatch, capsys):
monkeypatch.setattr(sd, "list_schema_skills", lambda *a, **k: ([], None))

sd.download_skills(WS, "token", ["main.default"], str(tmp_path), None)

assert "No skills found in `main.default`." in capsys.readouterr().out

def test_none_skill_filter_downloads_everything(self, tmp_path, monkeypatch):
monkeypatch.setattr(sd, "list_schema_skills", lambda *a, **k: (["a", "b"], None))
monkeypatch.setattr(sd, "fetch_skill_bundle", lambda *a, **k: ({"SKILL.md": b"x"}, None))

sd.download_skills(WS, "token", ["main.default"], str(tmp_path), None)

assert (tmp_path / ".claude/skills/a/SKILL.md").exists()
assert (tmp_path / ".claude/skills/b/SKILL.md").exists()


class TestConfigureSkillsDownloadCommand:
def _stub(self, monkeypatch):
Expand All @@ -375,7 +429,9 @@ def _stub(self, monkeypatch):
monkeypatch.setattr(
sd,
"download_skills",
lambda ws, tok, locations, path: calls.update(download=(ws, tok, locations, path)),
lambda ws, tok, locations, path, skills=None: calls.update(
download=(ws, tok, locations, path, skills)
),
)
monkeypatch.setattr(
sd,
Expand All @@ -389,13 +445,21 @@ def test_downloads_then_registers_connection(self, monkeypatch):

assert sd.configure_skills_download_command(["a.b"], path="/tmp/skills") == 0

assert calls["download"] == (WS, "token", ["a.b"], "/tmp/skills")
assert calls["download"] == (WS, "token", ["a.b"], "/tmp/skills", None)
assert calls["register"] == (WS, "profile", ["claude"])

def test_none_path_threads_through(self, monkeypatch):
calls = self._stub(monkeypatch)

assert sd.configure_skills_download_command(["a.b"], path=None) == 0

assert calls["download"] == (WS, "token", ["a.b"], None)
assert calls["download"] == (WS, "token", ["a.b"], None, None)
assert calls["register"] == (WS, "profile", ["claude"])

def test_skills_filter_threads_through(self, monkeypatch):
calls = self._stub(monkeypatch)

assert sd.configure_skills_download_command(["a.b"], path=None, skills={"triage"}) == 0

assert calls["download"] == (WS, "token", ["a.b"], None, {"triage"})
assert calls["register"] == (WS, "profile", ["claude"])
Loading