Skip to content

Commit c99f3b4

Browse files
authored
Merge branch 'main' into feat/pi-oss-provider-v2
2 parents fdacac3 + 3a4087b commit c99f3b4

11 files changed

Lines changed: 1468 additions & 32 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: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,13 @@
5353
)
5454
from ucode.mcp import (
5555
MCP_CLIENTS,
56+
SKILLS_MCP_KIND,
5657
configure_mcp_command,
58+
configure_skills_mcp_command,
5759
purge_cross_workspace_mcp_residue,
5860
revert_mcp_configs,
5961
)
62+
from ucode.skills_download import configure_skills_download_command
6063
from ucode.state import (
6164
STATE_PATH,
6265
clear_state,
@@ -143,6 +146,27 @@ def _parse_agents_option(agents: str) -> list[str]:
143146
return tools
144147

145148

149+
def _parse_skill_locations(location: str) -> list[str]:
150+
"""Parse a comma-separated `--location` into `<catalog>.<schema>` refs,
151+
dropping duplicates while preserving order."""
152+
locations: list[str] = []
153+
for raw in location.split(","):
154+
raw = raw.strip()
155+
if not raw:
156+
continue
157+
parts = raw.split(".")
158+
if len(parts) != 2 or not all(part.strip() for part in parts):
159+
raise RuntimeError(f"--location entries must be `<catalog>.<schema>`, got `{raw}`.")
160+
if raw not in locations:
161+
locations.append(raw)
162+
if not locations:
163+
raise RuntimeError(
164+
"No schemas provided for --location. Use `<catalog>.<schema>`, "
165+
"comma-separated for multiple."
166+
)
167+
return locations
168+
169+
146170
def _parse_workspaces_option(workspaces: str) -> list[tuple[str, str | None]]:
147171
"""Parse `--workspaces` into [(url, profile_name | None), ...].
148172
@@ -723,7 +747,9 @@ def status() -> int:
723747
tool_mcp_servers = [
724748
str(server.get("name"))
725749
for server in mcp_servers
726-
if tool in (server.get("clients") or []) and server.get("name")
750+
if tool in (server.get("clients") or [])
751+
and server.get("name")
752+
and server.get("kind") != SKILLS_MCP_KIND
727753
]
728754
print_kv("MCP list command", str(MCP_CLIENTS[tool]["list_command"]))
729755
print_kv(
@@ -733,6 +759,23 @@ def status() -> int:
733759
print_kv("Config file", str(config_path) if config_path.exists() else "missing")
734760
console.print()
735761

762+
print_heading("Skills")
763+
skill_mcp_entry = next((s for s in mcp_servers if s.get("kind") == SKILLS_MCP_KIND), None)
764+
if not skill_mcp_entry:
765+
print_kv("Skills", "not configured")
766+
else:
767+
locations = skill_mcp_entry.get("skill_locations") or []
768+
print_kv(
769+
"Skill MCP Locations",
770+
", ".join(locations) if locations else "none — utility tools only",
771+
)
772+
configured_agents = [
773+
str(MCP_CLIENTS[client]["display"])
774+
for client in (skill_mcp_entry.get("clients") or [])
775+
if client in MCP_CLIENTS
776+
]
777+
print_kv("Configured", ", ".join(configured_agents) if configured_agents else "none")
778+
736779
print_heading("Tracing")
737780
tracing = state.get("tracing") or {}
738781
if tracing.get("enabled"):
@@ -757,6 +800,10 @@ def status() -> int:
757800
print_note(
758801
"Use `ucode configure mcp` to add Databricks MCP servers to configured coding tools."
759802
)
803+
print_note(
804+
"Use `ucode configure skills --location <catalog>.<schema> --mcp` to connect Unity "
805+
"Catalog Skills."
806+
)
760807
print_note("Use `ucode configure tracing` to log coding sessions to an MLflow experiment.")
761808
print_note("Use `ucode revert` to clear managed configs and restore prior files.")
762809
return 0
@@ -1349,6 +1396,46 @@ def configure_mcp(
13491396
raise typer.Exit(130) from None
13501397

13511398

1399+
@configure_app.command("skills")
1400+
def configure_skills(
1401+
location: Annotated[
1402+
str,
1403+
typer.Option("--location", help="Comma-separated `<catalog>.<schema>` skill scopes."),
1404+
],
1405+
mcp: Annotated[
1406+
bool,
1407+
typer.Option("--mcp", help="Mutate the skills MCP connection instead of downloading."),
1408+
] = False,
1409+
path: Annotated[
1410+
str | None,
1411+
typer.Option(
1412+
"--path",
1413+
help="(download) Existing absolute dir to download into; defaults to your home dir.",
1414+
),
1415+
] = None,
1416+
) -> None:
1417+
"""Configure Databricks Skills for your coding tools.
1418+
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.
1423+
"""
1424+
try:
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:
1432+
print_err(str(exc))
1433+
raise typer.Exit(1) from None
1434+
except KeyboardInterrupt:
1435+
print_err("Interrupted.")
1436+
raise typer.Exit(130) from None
1437+
1438+
13521439
@configure_app.command("tracing")
13531440
def configure_tracing(
13541441
disable: Annotated[

src/ucode/databricks.py

Lines changed: 42 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
@@ -1427,6 +1456,19 @@ def build_mcp_service_url(workspace: str, full_name: str) -> str:
14271456
return f"{workspace}/ai-gateway/mcp-services/{full_name}"
14281457

14291458

1459+
def build_skills_mcp_url(workspace: str, locations: list[str]) -> str:
1460+
"""Skills route with one ``?schema=`` scope per location. The trailing slash
1461+
is required by the Envoy prefix even with no query params.
1462+
1463+
[] -> ``.../ai-gateway/skills/``
1464+
["main.default", "ml.a"] -> ``.../ai-gateway/skills/?schema=main.default&schema=ml.a``
1465+
"""
1466+
base = f"{workspace}/ai-gateway/skills/"
1467+
if not locations:
1468+
return base
1469+
return base + "?" + urlencode([("schema", loc) for loc in locations])
1470+
1471+
14301472
# Maps the gateway routing dialect a coding tool speaks to the Model Provider
14311473
# Service `provider_type`s it can be backed by. claude speaks Anthropic's API,
14321474
# which both the `anthropic` and `amazon_bedrock` provider types serve (Bedrock

0 commit comments

Comments
 (0)