Skip to content
Merged
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Tests live in `tests/`.

- Use Python 3.12+.
- Keep changes scoped to the requested behavior.
- Follow the existing module boundaries: CLI orchestration in `cli.py`, agent-specific behavior in `agents/<name>.py`, shared agent dispatch in `agents/__init__.py`, Databricks calls in `databricks.py`, skill download (UC fetch client + on-disk writer + download orchestration) in `skills_download.py`, MCP-connection state glue in `mcp.py`, and presentation helpers in `ui.py`. Skill download persists no disk state — it writes files to `--path` (or the home dir) and registers only the schema-less skills MCP connection.
- Follow the existing module boundaries: CLI orchestration in `cli.py`, agent-specific behavior in `agents/<name>.py`, shared agent dispatch in `agents/__init__.py`, Databricks calls in `databricks.py`, skill download (UC fetch client + on-disk writer + download orchestration) in `skills_download.py`, MCP-connection state glue in `mcp.py`, and presentation helpers in `ui.py`. Skill download persists no disk state — it writes files to `--path` (or the home dir) and registers only the schema-less skills MCP connection. `ucode configure skills` with no `--location` (or `--mcp` with no `--location`) registers that schema-less connection without downloading anything.
- Prefer existing helpers for config file writes, state persistence, UI messages, and Databricks authentication.
- Add or update focused tests for behavior changes.
- Do not modify generated or lock files unless the dependency graph intentionally changes.
Expand Down
30 changes: 19 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,25 +109,32 @@ then registers the servers); pass a comma-separated list to register several at

### Skills (optional)

Configure Unity Catalog Skills for your coding tools with `ucode configure skills`. It has two
mutually-exclusive modes, both scoped by `--location <catalog>.<schema>` (comma-separated for
multiple schemas):
Configure Unity Catalog Skills for your coding tools with `ucode configure skills`:

```bash
# Download mode (default): fetch every skill in the schema to disk.
# Utility tools only: register the schema-less skills MCP connection, no download.
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

# MCP mode: expose the schema's skills as MCP tools instead of downloading.
ucode configure skills --location main.default,ml.prod --mcp
```

- **Download mode** 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 (utility tools
only), leaving any prior `--mcp` scope untouched.
- **MCP mode** sets the connection's location set to exactly `<list>` (override-only) and rebuilds
its `?schema=` URL; no files are downloaded and `--path` is rejected.
- **Bare command** (no `--location`) registers the schema-less skills MCP connection — the
cross-schema utility tools only — and downloads nothing. `--mcp` with no `--location` does the
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.
- **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).

---

Expand All @@ -145,6 +152,7 @@ ucode configure skills --location main.default,ml.prod --mcp
| `ucode configure --profiles DEFAULT --use-pat` | Authenticate with the profile's personal access token — no browser login |
| `ucode configure --skip-validate` | Write configs without sending a test message through each agent |
| `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 schema-less skills MCP connection (utility tools only); no download |
Comment thread
xsh310 marked this conversation as resolved.
Outdated
| `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 --mcp` | Expose a schema's skills as MCP tools (override-only) instead of downloading |

Expand Down
42 changes: 21 additions & 21 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,12 @@ def _parse_agents_option(agents: str) -> list[str]:
return tools


def _parse_skill_locations(location: str) -> list[str]:
def _parse_skill_locations(location: str | None) -> list[str]:
"""Parse a comma-separated `--location` into `<catalog>.<schema>` refs,
dropping duplicates while preserving order."""
dropping duplicates while preserving order. `None`/empty yields `[]` (the
schema-less, utility-tools-only connection)."""
locations: list[str] = []
for raw in location.split(","):
for raw in (location or "").split(","):
raw = raw.strip()
if not raw:
continue
Expand All @@ -159,11 +160,6 @@ def _parse_skill_locations(location: str) -> list[str]:
raise RuntimeError(f"--location entries must be `<catalog>.<schema>`, got `{raw}`.")
if raw not in locations:
locations.append(raw)
if not locations:
raise RuntimeError(
"No schemas provided for --location. Use `<catalog>.<schema>`, "
"comma-separated for multiple."
)
return locations


Expand Down Expand Up @@ -801,8 +797,8 @@ def status() -> int:
"Use `ucode configure mcp` to add Databricks MCP servers to configured coding tools."
)
print_note(
"Use `ucode configure skills --location <catalog>.<schema> --mcp` to connect Unity "
"Catalog Skills."
"Use `ucode configure skills` to connect Unity Catalog Skills (add "
Comment thread
xsh310 marked this conversation as resolved.
Outdated
"`--location <catalog>.<schema>` to download a schema's skills)."
)
print_note("Use `ucode configure tracing` to log coding sessions to an MLflow experiment.")
print_note("Use `ucode revert` to clear managed configs and restore prior files.")
Expand Down Expand Up @@ -1436,9 +1432,9 @@ def configure_mcp(
@configure_app.command("skills")
def configure_skills(
location: Annotated[
str,
str | None,
typer.Option("--location", help="Comma-separated `<catalog>.<schema>` skill scopes."),
],
] = None,
mcp: Annotated[
bool,
typer.Option("--mcp", help="Mutate the skills MCP connection instead of downloading."),
Expand All @@ -1453,18 +1449,22 @@ def configure_skills(
) -> None:
"""Configure Databricks Skills for your coding tools.

By default, downloads every skill in each ``--location`` schema to disk
(under ``--path``, or your home dir when omitted) and registers a schema-less
MCP connection. With ``--mcp``, instead sets the skills MCP connection's scope
to exactly the listed schemas.
With no ``--location``, registers the schema-less skills MCP connection
(cross-schema utility tools only) without downloading anything. With
Comment thread
xsh310 marked this conversation as resolved.
Outdated
``--location`` (and no ``--mcp``), also downloads every skill in each schema to
disk (under ``--path``, or your home dir when omitted). ``--mcp`` instead sets
the connection's scope to exactly the listed schemas without downloading.
"""
try:
if mcp:
if path is not None:
raise RuntimeError("--path is not valid with --mcp.")
configure_skills_mcp_command(_parse_skill_locations(location))
locations = _parse_skill_locations(location)
if mcp and path is not None:
raise RuntimeError("--path is not valid with --mcp.")
if path is not None and not locations:
raise RuntimeError("--path only applies when downloading with --location.")
if mcp or not locations:
configure_skills_mcp_command(locations)
else:
configure_skills_download_command(_parse_skill_locations(location), path=path)
configure_skills_download_command(locations, path=path)
except (RuntimeError, ValueError) as exc:
print_err(str(exc))
raise typer.Exit(1) from None
Expand Down
79 changes: 74 additions & 5 deletions src/ucode/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import string
import subprocess
from collections.abc import Callable
from pathlib import Path
from typing import Any
from urllib.parse import urlparse

Expand Down Expand Up @@ -43,6 +44,8 @@
)
from ucode.state import load_full_state, load_state, save_state
from ucode.ui import (
print_heading,
print_kv,
print_note,
print_section,
print_success,
Expand Down Expand Up @@ -1445,17 +1448,70 @@ def _resolve_skills_mcp_servers(
return [*kept, _build_skills_entry(workspace, locations, merged)]


def _join_human(items: list[str]) -> str:
Comment thread
xsh310 marked this conversation as resolved.
Outdated
if len(items) <= 1:
return items[0] if items else ""
return ", ".join(items[:-1]) + " and " + items[-1]


def _skills_tools_summary(locations: list[str]) -> str:
"""One-liner describing the connection's tools. Names the tool *categories*,
Comment thread
xsh310 marked this conversation as resolved.
Outdated
not individual tools, so it never drifts when the backend changes its
offering; the per-schema skill tools are resolved live by the server."""
if not locations:
return "UC skill utility tools"
return f"UC skill utility tools + live skills tools in schema {_join_human(locations)}"


def _print_skills_summary(entry: dict, *, download_roots: list[Path] | None = None) -> None:
"""Report the registered skills connection: server, URL, agents, tools, and how
Comment thread
xsh310 marked this conversation as resolved.
Outdated
to start using it. ``download_roots`` (download mode) tweaks the closing line to
note that the on-disk files already work."""
locations = entry.get("skill_locations") or []
displays = [
Comment thread
xsh310 marked this conversation as resolved.
Outdated
str(MCP_CLIENTS[client]["display"])
for client in (entry.get("clients") or [])
if client in MCP_CLIENTS
]
print_heading("Skills MCP registered")
Comment thread
xsh310 marked this conversation as resolved.
Outdated
print_kv("Server", str(entry.get("name") or SKILLS_MCP_SERVER_NAME))
print_kv("URL", str(entry.get("url") or ""))
print_kv("Configured", ", ".join(displays) if displays else "none")
print_kv("Tools", _skills_tools_summary(locations))

restart = (
"Run `ucode <agent>` to use the skills MCP. For existing sessions, "
"restart the agent before skills become available."
Comment thread
xsh310 marked this conversation as resolved.
Outdated
)
if download_roots is not None:
restart += (
Comment thread
xsh310 marked this conversation as resolved.
Outdated
" Downloaded skill files already work — agents discover them from disk on next launch."
)
print_note(restart)


def _update_skills_mcp(
state: dict, workspace: str, profile: str | None, clients: list[str], locations: list[str]
state: dict,
workspace: str,
profile: str | None,
clients: list[str],
locations: list[str],
*,
download_roots: list[Path] | None = None,
) -> None:
"""Rebuild the single skills connection for ``locations`` and persist it."""
"""Rebuild the single skills connection for ``locations``, persist it, and print
Comment thread
xsh310 marked this conversation as resolved.
Outdated
the registration summary. The summary always prints (so a no-op re-run still
reports what's registered and reminds the user to restart); only the save is
gated on an actual change."""
original = list(state.get("mcp_servers") or [])
working = _resolve_skills_mcp_servers(workspace, clients, locations, original)
changed = apply_mcp_server_changes(original, working, clients, workspace, profile)
if changed or original != working:
state["mcp_servers"] = working
save_state(state)
print_success("Saved")
entry = next(s for s in working if s.get("kind") == SKILLS_MCP_KIND)
_print_skills_summary(entry, download_roots=download_roots)


def configure_skills_mcp_command(locations: list[str]) -> int:
Expand All @@ -1474,11 +1530,24 @@ def _skill_mcp_locations(state: dict) -> list[str]:


def register_schemaless_skills_connection(
state: dict, workspace: str, profile: str | None, clients: list[str]
state: dict,
workspace: str,
profile: str | None,
clients: list[str],
*,
download_roots: list[Path] | None = None,
) -> None:
"""Register/keep the skills MCP connection without changing its schema set.

Download mode calls this after writing files: it preserves any prior
``--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))
route (utility tools only). ``download_roots`` flows into the summary's closing
line."""
_update_skills_mcp(
state,
workspace,
profile,
clients,
_skill_mcp_locations(state),
download_roots=download_roots,
)
18 changes: 12 additions & 6 deletions src/ucode/skills_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,14 +240,15 @@ def _fetch_bundles(
return results


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

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.
skill warns and skips it without aborting the batch. Returns the total number
of skills written across all locations.
"""
roots = skill_dir_roots(path)
total_written = 0
for location in locations:
catalog, schema = location.split(".")
leaves, reason = list_schema_skills(workspace, token, catalog, schema)
Expand All @@ -267,7 +268,9 @@ def download_skills(workspace: str, token: str, locations: list[str], path: str
continue
if write_skill(roots, leaf, files, location=location):
written += 1
total_written += written
print_success(f"Downloaded {written}/{len(leaves)} skill(s) from `{location}`.")
return total_written


def configure_skills_download_command(locations: list[str], *, path: str | None) -> int:
Expand All @@ -280,7 +283,10 @@ def configure_skills_download_command(locations: list[str], *, path: str | None)
workspace, profile, clients = setup_mcp_clients(state, "Skills")
token = get_databricks_token(workspace, profile)

download_skills(workspace, token, locations, path)
roots = skill_dir_roots(path)
written = download_skills(workspace, token, locations, roots)
if written:
print_note(f"Skill files written under {' and '.join(str(root) for root in roots)}")
Comment thread
xsh310 marked this conversation as resolved.
Outdated

register_schemaless_skills_connection(state, workspace, profile, clients)
register_schemaless_skills_connection(state, workspace, profile, clients, download_roots=roots)
return 0
25 changes: 22 additions & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -404,9 +404,28 @@ def test_malformed_location_exit_1_names_location(self):
assert "--location" in _strip_ansi(result.output)
mock_mcp.assert_not_called()

def test_missing_location_is_typer_usage_error(self):
result = runner.invoke(app, ["configure", "skills"])
assert result.exit_code == 2
def test_bare_command_registers_schemaless_connection(self):
with patch("ucode.cli.configure_skills_mcp_command") as mock_mcp:
result = runner.invoke(app, ["configure", "skills"])
assert result.exit_code == 0, result.output
mock_mcp.assert_called_once_with([])

def test_mcp_without_location_registers_schemaless_connection(self):
with patch("ucode.cli.configure_skills_mcp_command") as mock_mcp:
result = runner.invoke(app, ["configure", "skills", "--mcp"])
assert result.exit_code == 0, result.output
mock_mcp.assert_called_once_with([])

def test_path_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", "--path", "/tmp/skills"])
assert result.exit_code == 1
assert "--path" in _strip_ansi(result.output)
mock_mcp.assert_not_called()
mock_download.assert_not_called()


class TestStatusSkillsSection:
Expand Down
Loading
Loading