Skip to content
Closed
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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,28 @@ ucode configure skills --location main.default,ml.prod --mcp
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).

#### Add skill scopes without replacing existing ones

`ucode skill add` registers skills additively, keeping anything already configured. With `--mcp` it
adds the schemas to the connection's scope, otherwise it downloads their skills to disk. `--skills`
narrows a download to a subset of one schema's skills. With no selection flags, a searchable picker
lists finalized skills visible in the metastore.

```bash
# Browse the metastore and choose skills to download.
ucode skill add

# Add schemas to the skills MCP scope, keeping any already configured.
ucode skill add --location main.default,ml.prod --mcp

# Download a schema's skills to disk, keeping existing downloads.
ucode skill add --location main.default

# Download a named subset, by bare name (with --location) or fully-qualified name.
ucode skill add --location main.default --skills my-skill,other-skill
ucode skill add --skills main.default.my-skill,main.default.other-skill
```

### Managed config for a workspace (admins)

Author the coding config your developers pick up automatically, instead of asking each of them to
Expand Down Expand Up @@ -356,6 +378,10 @@ The output looks like:
| `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 |
| `ucode skill add` | Interactively choose finalized metastore skills to download |
| `ucode skill add --location main.default --mcp` | Add schemas to the skills MCP scope, keeping any already configured (additive; never replaces) |
| `ucode skill add --location main.default` | Download a schema's skills to disk without removing existing downloads |
| `ucode skill add --skills main.default.my-skill` | Download a named subset of skills (bare names need `--location`; fully-qualified names stand alone) |
| `ucode setup` | Author the managed config's agents and models (workspace admins only) |
| `ucode setup mcps` | Add or change the managed config's MCP servers |
| `ucode setup skills [--location a.b,c.d]` | Add or change the managed config's skills |
Expand Down
103 changes: 103 additions & 0 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@
MCP_CLIENTS,
SKILLS_MCP_KIND,
add_mcp_command,
add_skills_command,
apply_managed_mcp_servers,
apply_managed_skills,
configure_mcp_command,
Expand All @@ -107,6 +108,7 @@
)
from ucode.skills_download import (
configure_skills_download_command,
configure_skills_download_interactive_command,
download_managed_skills_on_launch,
)
from ucode.smart_routing import v2 as smart_routing_v2
Expand Down Expand Up @@ -1159,6 +1161,8 @@ def revert() -> int:
app.add_typer(configure_app, name="configure", help="Configure workspace and tool settings.")
mcp_app = typer.Typer(add_completion=False, no_args_is_help=True)
app.add_typer(mcp_app, name="mcp", help="MCP servers exposed by ucode.")
skill_app = typer.Typer(add_completion=False, no_args_is_help=True)
app.add_typer(skill_app, name="skill", help="Databricks Skills for your coding tools.")
setup_app = typer.Typer(add_completion=False, no_args_is_help=False)
app.add_typer(
setup_app,
Expand Down Expand Up @@ -1312,6 +1316,105 @@ def mcp_web_search_cmd() -> None:
serve()


@skill_app.command("add")
def skills_add(
location: Annotated[
str | None,
typer.Option(
"--location", help="Comma-separated `<catalog>.<schema>` skill scopes to add."
),
] = None,
mcp: Annotated[
bool,
typer.Option(
"--mcp",
help="Add the schemas to the skills MCP connection's scope instead of downloading.",
),
] = False,
path: Annotated[
str | None,
typer.Option(
"--path",
help="(download) Existing absolute dir to download into; defaults to your home dir.",
),
] = None,
skills: Annotated[
str | None,
typer.Option(
"--skills",
help="(download) Download only this comma-separated subset of skills instead of "
"every skill in the schema. Bare securable names (e.g. `my-skill`) need a single "
"--location; fully-qualified `<catalog>.<schema>.<name>` names work on their own. "
"Not valid with --mcp.",
),
] = None,
) -> None:
"""Add Databricks Skills to your coding tools, keeping any already configured.

With ``--mcp``, adds the given schemas to the skills MCP connection's scope.
Otherwise downloads each schema's skills to disk (under ``--path``, or your home
dir), keeping already-downloaded skills. With no selection flags, opens a searchable
metastore picker. ``--skills`` narrows a download to a subset of one schema's skills,
by bare name (with ``--location``) or fully-qualified
``<catalog>.<schema>.<name>``.
"""
try:
locations = _parse_skill_locations(location)
requested_skills = (
None if skills is None else {s.strip() for s in skills.split(",") if s.strip()}
)
if mcp and path is not None:
raise RuntimeError("--path is not supported when using --mcp")
if mcp and requested_skills is not None:
raise RuntimeError("--skills is not supported when using --mcp")
if requested_skills is not None and not locations:
schemas = {".".join(s.split(".")[:2]) for s in requested_skills if s.count(".") >= 2}
bare = sorted(s for s in requested_skills if s.count(".") < 2)
if bare:
raise RuntimeError(
"--skills short names need --location (or pass full names like "
f"`<catalog>.<schema>.<name>`): {', '.join(bare)}"
)
if len(schemas) != 1:
raise RuntimeError(
"--skills without --location must all share one `<catalog>.<schema>` "
f"(got: {', '.join(sorted(schemas)) or 'none'}); pass --location instead."
)
locations = list(schemas)
if not locations:
if mcp:
raise RuntimeError("--location is required when using --mcp.")
configure_skills_download_interactive_command(path=path)
return
if requested_skills is not None and len(locations) != 1:
raise RuntimeError(
f"--skills requires a single --location (got: {', '.join(locations)})."
)
mismatched_skills = sorted(
skill
for skill in requested_skills or set()
if skill.count(".") >= 2 and ".".join(skill.split(".")[:2]) != locations[0]
)
if mismatched_skills:
raise RuntimeError(
f"--skills entries must match --location `{locations[0]}` "
f"(got: {', '.join(mismatched_skills)})."
)
selected_skills = (
None if requested_skills is None else {s.split(".")[-1] for s in requested_skills}
)
if mcp:
add_skills_command(locations)
else:
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
except KeyboardInterrupt:
print_err("Interrupted.")
raise typer.Exit(130) from None


@app.command("mcp-proxy", hidden=True)
def mcp_proxy_cmd(
url: Annotated[
Expand Down
19 changes: 19 additions & 0 deletions src/ucode/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -2210,3 +2210,22 @@ def register_schemaless_skills_connection(
``--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))


def _union_locations(base: list[str], new: list[str]) -> list[str]:
have = set(base)
merged = list(base)
for location in new:
if location not in have:
merged.append(location)
have.add(location)
return merged


def add_skills_command(locations: list[str]) -> int:
"""Add ``locations`` to the skills MCP connection's scope, keeping any already configured."""
state = load_state()
workspace, profile, clients = setup_mcp_clients(state, "Add Skills MCP")
merged = _union_locations(_skill_mcp_locations(state), locations)
_update_skills_mcp(state, workspace, profile, clients, merged)
return 0
95 changes: 95 additions & 0 deletions src/ucode/skills_download.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
print_success,
print_warning,
progress_bar,
prompt_for_multi_selection,
prompt_yes_no,
)

Expand Down Expand Up @@ -52,6 +53,18 @@ class SkillRef:
bundle_name: str


@dataclass(frozen=True)
class MetastoreSkill:
"""A finalized skill discovered without a schema filter."""

full_name: str
ref: SkillRef

@property
def location(self) -> str:
return self.full_name.rsplit(".", 1)[0]


def _non_empty_str(value: object) -> str | None:
"""``value`` when it is a non-empty string, else None."""
return value if isinstance(value, str) and value else None
Expand Down Expand Up @@ -118,6 +131,58 @@ def list_schema_skills(
return refs, None


def list_metastore_skills(workspace: str, token: str) -> tuple[list[MetastoreSkill], str | None]:
"""List finalized, downloadable skills visible in the current metastore."""
hostname = workspace_hostname(workspace)
base_url = f"https://{hostname}/api/2.1/unity-catalog/skills"

skills: list[MetastoreSkill] = []
page_token: str | None = None
while True:
url = base_url
if page_token:
url = f"{url}?{urlencode({'page_token': page_token})}"
payload, reason = _http_get_json(url, token, timeout=30)
if payload is None:
return [], reason
data = payload if isinstance(payload, dict) else {}
for skill in data.get("skills") or []:
if not isinstance(skill, dict):
continue
resource_name = _non_empty_str(skill.get("name"))
ref = _skill_ref(skill)
if ref is None:
continue
full_name = resource_name.removeprefix("skills/") if resource_name else None
if full_name is None or full_name.count(".") != 2:
print_warning(
f"Skipping `{resource_name or '<unnamed skill>'}`: expected a fully-qualified "
"`<catalog>.<schema>.<name>` from the skills API."
)
continue
skills.append(MetastoreSkill(full_name=full_name, ref=ref))
page_token = data.get("next_page_token")
if not page_token:
return sorted(skills, key=lambda skill: skill.full_name.lower()), None


def prompt_for_skill_download(
skills: list[MetastoreSkill],
) -> list[MetastoreSkill] | None:
"""Select metastore skills to download, or return None when cancelled."""
by_name = {skill.full_name: skill for skill in skills}
options = []
for skill in skills:
label = skill.full_name
if skill.ref.bundle_name != skill.ref.securable_name:
label = f"{label} (bundle: {skill.ref.bundle_name})"
options.append((skill.full_name, label))
selected = prompt_for_multi_selection("Skills:", options, searchable=True)
if selected is None:
return None
return [by_name[name] for name in selected if name in by_name]


def list_skill_files(
workspace: str, token: str, catalog: str, schema: str, securable: str
) -> tuple[list[str], str | None]:
Expand Down Expand Up @@ -450,3 +515,33 @@ def configure_skills_download_command(

register_schemaless_skills_connection(state, workspace, profile, clients)
return 0


def configure_skills_download_interactive_command(*, path: str | None) -> int:
"""Discover metastore skills, let the user select some, and download them."""
state = load_state()
workspace, profile, clients = setup_mcp_clients(state, "Add Skills")
token = get_databricks_token(workspace, profile)

available, reason = list_metastore_skills(workspace, token)
if reason:
raise RuntimeError(f"Could not list workspace skills: {reason}.")
if not available:
print_note("No finalized skills are available to download in this metastore.")
return 0

selected = prompt_for_skill_download(available)
if selected is None:
return 0
if not selected:
print_note("No skills selected. Press space to toggle an item, then enter to download.")
return 0

selected_by_location: dict[str, set[str]] = {}
for skill in selected:
selected_by_location.setdefault(skill.location, set()).add(skill.ref.securable_name)
for location, securable_names in selected_by_location.items():
download_skills(workspace, token, [location], path, securable_names)

register_schemaless_skills_connection(state, workspace, profile, clients)
return 0
Loading
Loading