Skip to content

Commit 3a4087b

Browse files
authored
skills: add download mode to ucode configure skills (client, writer, CLI wiring) (#237)
* skills: add UC download client (list schemas, list bundle files, fetch bytes) Adds the read-only client layer for `ucode configure skills` download mode: - `list_schema_skills` — finalized skill leaf names in a schema via the UC skills API (`/api/2.1/unity-catalog/skills`, paginated). Uses `bundle_name` as the leaf and `finalize_time` as the readiness signal. - `list_skill_files` — a skill bundle's files (relative paths), by recursively walking its UC Volume directory via the Files API. - `fetch_skill_file` — one bundle file's raw bytes via the Files API. - `_http_get_bytes` — raw-bytes GET helper (skill bundles may be binary). Library only; no CLI wiring yet. All functions return (value, reason) and reuse the existing `_http_get_json` error convention. Verified end-to-end against a staging workspace with a user OAuth token: all three endpoints (skills list, Volume directory listing, raw file bytes) resolve directly with the client token — no storage-credential vending required. Co-authored-by: Isaac * skills: add on-disk writer for downloaded skills New `skills_download.py` owns the filesystem side of `ucode configure skills` download mode: - `skill_dir_roots` — the `.claude/skills` + `.agents/skills` roots for a download, at user scope (`~/`) or a project `--path`. - `write_skill` — writes a skill's `{relpath: bytes}` bundle into every root, one flat `<root>/<leaf>/` dir per skill. Re-downloading the same skill in a run rewrites silently; a different schema colliding on the same leaf prompts keep/overwrite (or overwrites under `--yes`). Rejects invalid leaf names and `..`/absolute paths in the bundle. Transport-agnostic (no HTTP) so it's unit-testable with a literal dict; the bytes come from the download client in `databricks.py`. No CLI wiring yet. Co-authored-by: Isaac * skills: simplify writer — required absolute --path, drop --yes Per review feedback, simplify the download writer: - `skill_dir_roots` now requires an existing absolute `--path` (raises ValueError otherwise); no `~/` user-scope default. - Drop `--yes`/`assume_yes`; a cross-schema leaf collision always prompts. - Rename `written` -> `written_leaves`, extract `_write_bundle`, and flatten the collision decision into one readable conditional. Co-authored-by: Isaac * skills: always prompt on existing skill dir; drop per-run ledger `write_skill` no longer tracks which leaves it wrote this run. Any pre-existing skill directory now prompts before overwrite, uniformly — including re-downloading the same skill. The per-run `written_leaves` ledger only made same-run duplicate leaves rewrite silently, but it added state and a subtle branch to cover a rare case, and it's fragile: ucode doesn't persist which schema wrote a given dir, and users can edit the filesystem, so "it's the same skill" was never reliable. Always asking is simpler and consistent. Co-authored-by: Isaac * skills: address review — tighter docstrings, clearer names - Condense the module and write_skill docstrings. - Rename SKILL_DIR_NAMES -> SKILL_BASE_DIR_NAMES. - Rename _LEAF_PATTERN -> SKILL_NAME_PATTERN, drop its redundant comment. - Rename write_skill's schema_ref param -> location (it's the <catalog>.<schema> string). Co-authored-by: Isaac * skills: add fetch_skill_bundle to compose a skill's full download `fetch_skill_bundle(workspace, token, catalog, schema, leaf)` lists a skill's files then fetches each into a `{relpath: bytes}` map, all-or-nothing. This is the one-skill assembler the download command will call per leaf; keeping it in databricks.py (next to its list_skill_files/fetch_skill_file parts) keeps the writer transport-agnostic and the eventual command loop thin. Co-authored-by: Isaac * skills: wire download mode into configure skills Flip --mcp to optional and add a download-only --path. Without --mcp, configure skills downloads every skill in each --location schema into <path>/.claude/skills + <path>/.agents/skills and registers a schema-less skills MCP connection, leaving any prior --mcp scope intact. Adds configure_skills_download_command and the _skill_mcp_locations read accessor in mcp.py; download persists no disk state. Co-authored-by: Isaac * skills: address review — consolidate download in skills_download.py - Move the UC skill-download client (list/fetch) and the download orchestration out of databricks.py and mcp.py into skills_download.py, so mcp.py keeps only MCP-connection glue and databricks.py only the shared HTTP transport helpers. - Make --path optional: omitted, downloads write under the home dir. - Parallelize per-skill bundle fetches; keep writes sequential (prompts). Add progress messages so users see skills being downloaded. - Drop write_skill's unused return value; it prints its own outcome. Co-authored-by: Isaac * skills: move download command out of mcp.py into skills_download.py configure_skills_download_command now lives in skills_download.py alongside the client, writer, and orchestration. mcp.py keeps only the MCP-connection glue it exposes as setup_mcp_clients and register_schemaless_skills_connection, and no longer imports skills_download — the dependency flows one way (cli -> skills_download -> mcp -> databricks). Co-authored-by: Isaac * skills: show a progress bar and k/n summary during download Replace the per-skill [i/total] prefixes with a Rich progress bar that advances as concurrent fetches complete (transient, tty-only so CI logs stay clean), followed by a 'Downloaded k/n skill(s) from <location>' summary. Adds a reusable progress_bar helper to ui.py. Co-authored-by: Isaac * skills: drop redundant cast and make _fetch_bundles self-contained - Remove the no-op cast(dict, payload) (the isinstance guard already narrows the type) and its now-unused typing.cast import. - Guard _fetch_bundles against empty leaves with an early return, so it no longer relies on the caller to avoid ThreadPoolExecutor(max_workers=0). Co-authored-by: Isaac
1 parent 0fed5fd commit 3a4087b

10 files changed

Lines changed: 900 additions & 14 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Tests live in `tests/`.
2020

2121
- Use Python 3.12+.
2222
- Keep changes scoped to the requested behavior.
23-
- 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`, and presentation helpers in `ui.py`.
23+
- 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.
2424
- Prefer existing helpers for config file writes, state persistence, UI messages, and Databricks authentication.
2525
- Add or update focused tests for behavior changes.
2626
- Do not modify generated or lock files unless the dependency graph intentionally changes.

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,28 @@ ucode configure --agents claude --mcp system.ai.slack
102102
`--mcp` also works without `--agents` for MCP-only clients (it configures just the workspace,
103103
then registers the servers); pass a comma-separated list to register several at once.
104104

105+
### Skills (optional)
106+
107+
Configure Unity Catalog Skills for your coding tools with `ucode configure skills`. It has two
108+
mutually-exclusive modes, both scoped by `--location <catalog>.<schema>` (comma-separated for
109+
multiple schemas):
110+
111+
```bash
112+
# Download mode (default): fetch every skill in the schema to disk.
113+
ucode configure skills --location main.default --path /abs/project/dir
114+
115+
# MCP mode: expose the schema's skills as MCP tools instead of downloading.
116+
ucode configure skills --location main.default,ml.prod --mcp
117+
```
118+
119+
- **Download mode** writes each skill flat as `<leaf>/SKILL.md` (plus its bundled files) into both
120+
`.claude/skills/` and `.agents/skills/`. `--path` (an existing absolute directory) is optional;
121+
when omitted, skills are written under your home directory. Any pre-existing skill dir prompts
122+
before it's overwritten. It then registers a schema-less skills MCP connection (utility tools
123+
only), leaving any prior `--mcp` scope untouched.
124+
- **MCP mode** sets the connection's location set to exactly `<list>` (override-only) and rebuilds
125+
its `?schema=` URL; no files are downloaded and `--path` is rejected.
126+
105127
---
106128

107129
## Other Commands
@@ -118,6 +140,8 @@ then registers the servers); pass a comma-separated list to register several at
118140
| `ucode configure --profiles DEFAULT --use-pat` | Authenticate with the profile's personal access token — no browser login |
119141
| `ucode configure --skip-validate` | Write configs without sending a test message through each agent |
120142
| `ucode configure --agents claude --mcp system.ai.slack` | Configure an agent and register its Databricks MCP server(s) in one command |
143+
| `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 |
144+
| `ucode configure skills --location main.default --mcp` | Expose a schema's skills as MCP tools (override-only) instead of downloading |
121145

122146
## Managed Local Files
123147

src/ucode/cli.py

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
purge_cross_workspace_mcp_residue,
6060
revert_mcp_configs,
6161
)
62+
from ucode.skills_download import configure_skills_download_command
6263
from ucode.state import (
6364
STATE_PATH,
6465
clear_state,
@@ -1403,21 +1404,31 @@ def configure_skills(
14031404
],
14041405
mcp: Annotated[
14051406
bool,
1407+
typer.Option("--mcp", help="Mutate the skills MCP connection instead of downloading."),
1408+
] = False,
1409+
path: Annotated[
1410+
str | None,
14061411
typer.Option(
1407-
"--mcp", help="Manage the skills MCP connection (required until download mode lands)."
1412+
"--path",
1413+
help="(download) Existing absolute dir to download into; defaults to your home dir.",
14081414
),
1409-
] = False,
1415+
] = None,
14101416
) -> None:
14111417
"""Configure Databricks Skills for your coding tools.
14121418
1413-
``--location`` sets the skills MCP connection's scope to exactly the listed
1414-
schemas, replacing any previous set.
1419+
By default, downloads every skill in each ``--location`` schema to disk
1420+
(under ``--path``, or your home dir when omitted) and registers a schema-less
1421+
MCP connection. With ``--mcp``, instead sets the skills MCP connection's scope
1422+
to exactly the listed schemas.
14151423
"""
14161424
try:
1417-
if not mcp:
1418-
raise RuntimeError("Download mode is not available yet; pass --mcp for now.")
1419-
configure_skills_mcp_command(_parse_skill_locations(location))
1420-
except RuntimeError as exc:
1425+
if mcp:
1426+
if path is not None:
1427+
raise RuntimeError("--path is not valid with --mcp.")
1428+
configure_skills_mcp_command(_parse_skill_locations(location))
1429+
else:
1430+
configure_skills_download_command(_parse_skill_locations(location), path=path)
1431+
except (RuntimeError, ValueError) as exc:
14211432
print_err(str(exc))
14221433
raise typer.Exit(1) from None
14231434
except KeyboardInterrupt:

src/ucode/databricks.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,35 @@ def _http_post_json(
290290
return None, f"network error: {exc.reason}"
291291

292292

293+
def _http_get_bytes(url: str, token: str, *, timeout: int = 10) -> tuple[bytes | None, str | None]:
294+
"""GET raw bytes. Returns (body, None) on success, (None, reason) on failure.
295+
296+
Like `_http_get_json` but leaves the body undecoded, since skill bundles can
297+
contain binary files.
298+
"""
299+
request = urllib_request.Request(url, headers={"Authorization": f"Bearer {token}"})
300+
try:
301+
with urllib_request.urlopen(request, timeout=timeout) as response:
302+
body = response.read()
303+
_debug(f"GET {url}", f"HTTP 200, {len(body)} bytes")
304+
return body, None
305+
except urllib_error.HTTPError as exc:
306+
detail = ""
307+
try:
308+
detail = exc.read().decode("utf-8", errors="replace") if exc.fp else ""
309+
except Exception:
310+
detail = ""
311+
_debug(f"GET {url}", f"HTTP {exc.code} {exc.reason}")
312+
reason = f"HTTP {exc.code} {exc.reason}"
313+
excerpt = detail.strip()[:200]
314+
if excerpt:
315+
reason = f"{reason}: {excerpt}"
316+
return None, reason
317+
except urllib_error.URLError as exc:
318+
_debug(f"GET {url}", f"URLError: {exc.reason}")
319+
return None, f"network error: {exc.reason}"
320+
321+
293322
def get_current_user_name(workspace: str, token: str) -> str | None:
294323
"""Return the current user's login (email) via SCIM `Me`, or None on failure.
295324

src/ucode/mcp.py

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1190,7 +1190,7 @@ def _resolve_location_mcp_servers(
11901190
return [*working_servers, *_skills_entries(original_servers)]
11911191

11921192

1193-
def _setup_mcp_clients(state: dict, section: str) -> tuple[str, str | None, list[str]]:
1193+
def setup_mcp_clients(state: dict, section: str) -> tuple[str, str | None, list[str]]:
11941194
"""Validate the workspace, resolve configured MCP clients, and prepare auth.
11951195
11961196
Returns ``(workspace, profile, clients)`` and prints the section header, the
@@ -1253,7 +1253,7 @@ def configure_mcp_command(location: str | None = None, services: set[str] | None
12531253
)
12541254
location = next(iter(schemas))
12551255
state = load_state()
1256-
workspace, profile, clients = _setup_mcp_clients(state, "MCP Servers")
1256+
workspace, profile, clients = setup_mcp_clients(state, "MCP Servers")
12571257

12581258
original_mcp_servers_for_location: list[dict] = list(state.get("mcp_servers") or [])
12591259
if location is not None:
@@ -1424,6 +1424,21 @@ def configure_skills_mcp_command(locations: list[str]) -> int:
14241424
"""Set the skills MCP connection's ``skill_locations`` to exactly ``locations``,
14251425
replacing any previous set."""
14261426
state = load_state()
1427-
workspace, _profile, clients = _setup_mcp_clients(state, "Skills MCP")
1427+
workspace, _profile, clients = setup_mcp_clients(state, "Skills MCP")
14281428
_update_skills_mcp(state, workspace, clients, locations)
14291429
return 0
1430+
1431+
1432+
def _skill_mcp_locations(state: dict) -> list[str]:
1433+
"""The skills MCP connection's ``skill_locations``, or ``[]`` if none exists."""
1434+
entry = next(iter(_skills_entries(list(state.get("mcp_servers") or []))), None)
1435+
return list((entry or {}).get("skill_locations") or [])
1436+
1437+
1438+
def register_schemaless_skills_connection(state: dict, workspace: str, clients: list[str]) -> None:
1439+
"""Register/keep the skills MCP connection without changing its schema set.
1440+
1441+
Download mode calls this after writing files: it preserves any prior
1442+
``--mcp`` ``skill_locations`` and otherwise registers the bare schema-less
1443+
route (utility tools only)."""
1444+
_update_skills_mcp(state, workspace, clients, _skill_mcp_locations(state))

0 commit comments

Comments
 (0)