diff --git a/README.md b/README.md index b856249..909bc1e 100644 --- a/README.md +++ b/README.md @@ -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 ``` @@ -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 [,…]` 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 `` (override-only) and rebuilds its `?schema=` URL; no files are downloaded and `--path` is rejected. @@ -160,6 +166,7 @@ you to run `ucode ` (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 ]` | Download a schema's skills to disk (under ``, 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 diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 1b42b4b..b7b7a6e 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -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. @@ -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 diff --git a/src/ucode/skills_download.py b/src/ucode/skills_download.py index 8822640..124721e 100644 --- a/src/ucode/skills_download.py +++ b/src/ucode/skills_download.py @@ -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 ``.`` 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) @@ -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 @@ -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 diff --git a/tests/test_cli.py b/tests/test_cli.py index 83ed6df..f5119c2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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 ( diff --git a/tests/test_skills_download.py b/tests/test_skills_download.py index 33ccf62..bd5e54d 100644 --- a/tests/test_skills_download.py +++ b/tests/test_skills_download.py @@ -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): @@ -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, @@ -389,7 +445,7 @@ 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): @@ -397,5 +453,13 @@ def test_none_path_threads_through(self, 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"])