Skip to content
Open
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
21 changes: 15 additions & 6 deletions src/ucode/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@
available_mcp_clients,
configure_mcp_command,
configure_skills_mcp_command,
configure_skills_mcp_picker_command,
configured_mcp_clients,
purge_cross_workspace_mcp_residue,
remove_mcp_command,
Expand Down Expand Up @@ -1308,8 +1309,8 @@ def skills_add(
to user-level skill directories when omitted, keeping already-downloaded skills.
``--location`` downloads whole ``<catalog>.<schema>`` schemas; ``--skills``
downloads a named set of fully-qualified skills that may span schemas (and takes
no ``--location``). With none of ``--mcp``/``--location``/``--skills`` on an
interactive terminal, opens a picker of the workspace's skills to download.
no ``--location``). With no ``--location``/``--skills`` on an interactive terminal,
opens a picker of the workspace's schemas to scope (``--mcp``) or skills to download.
"""
try:
requested_skills = (
Expand Down Expand Up @@ -1340,15 +1341,23 @@ def skills_add(
return
locations = _parse_skill_locations(location)
if not locations:
if not mcp and _stdin_is_interactive():
configure_skills_download_picker_command(path=path)
if _stdin_is_interactive():
if mcp:
configured_agents = (
_configure_agents_for_mcp(sorted(requested_agents))
if requested_agents
else None
)
configure_skills_mcp_picker_command(agents=configured_agents)
else:
configure_skills_download_picker_command(path=path)
return
raise RuntimeError("--location is required for `ucode skill add`.")
if mcp:
scope = (
configured_agents = (
_configure_agents_for_mcp(sorted(requested_agents)) if requested_agents else None
)
add_skills_command(locations, agents=scope)
add_skills_command(locations, agents=configured_agents)
else:
configure_location_skills_download_command(locations, path=path)
except (RuntimeError, ValueError) as exc:
Expand Down
100 changes: 94 additions & 6 deletions src/ucode/mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
list_mcp_services,
workspace_hostname,
)
from ucode.skills_api import SkillRef, list_all_skills
from ucode.state import load_full_state, load_state, save_state
from ucode.ui import (
_BACK,
Expand Down Expand Up @@ -1975,6 +1976,28 @@ def _union_locations(base: list[str], new: list[str]) -> list[str]:
return merged


def add_skill_locations_to_mcp(
state: dict,
workspace: str,
profile: str | None,
clients: list[str],
locations: list[str],
) -> None:
"""Add ``locations`` to each client's skill MCP scope, keeping any already configured."""
locations_by_client = _skill_locations_by_client_from_state(state)
for client in clients:
locations_by_client[client] = _union_locations(
locations_by_client.get(client, []), locations
)
_update_skills_mcp(state, workspace, profile, clients, locations_by_client)


def configured_skill_locations(state: dict, clients: list[str]) -> set[str]:
"""The union of skill schemas already in the MCP scope across ``clients``."""
locations_by_client = _skill_locations_by_client_from_state(state)
return {location for client in clients for location in locations_by_client.get(client, [])}


def add_skills_command(locations: list[str], agents: set[str] | None = None) -> int:
"""Add ``locations`` to each targeted client's skill scope, keeping any already configured.

Expand All @@ -1983,12 +2006,77 @@ def add_skills_command(locations: list[str], agents: set[str] | None = None) ->
the only thing ``--agents`` changes."""
state = load_state()
workspace, profile, clients = setup_mcp_clients(state, "Add Skills MCP", agents=agents)
locations_by_client = _skill_locations_by_client_from_state(state)
for client in clients:
locations_by_client[client] = _union_locations(
locations_by_client.get(client, []), locations
)
_update_skills_mcp(state, workspace, profile, clients, locations_by_client)
add_skill_locations_to_mcp(state, workspace, profile, clients, locations)
return 0


def _skill_schema_choice(location: str, skill_count: int, in_scope: bool) -> questionary.Choice:
"""Picker row for one schema: value is ``<catalog>.<schema>``, title carries the skill count.

An already-scoped schema is flagged and stays selectable; re-selecting it is a no-op, since
adding to the MCP scope is additive (removal is ``ug skill remove --mcp``).
"""
noun = "skill" if skill_count == 1 else "skills"
scope_flag = " (already in skill MCP)" if in_scope else ""
return questionary.Choice(
title=f"{location} ({skill_count} {noun}){scope_flag}", value=location
)


def _skill_schema_background_loader(
workspace: str, token: str, in_scope: set[str]
) -> Callable[[Callable[[list[questionary.Choice]], None]], None]:
"""A picker ``background_loader`` that streams the workspace-wide skill walk in as schema rows.

``list_all_skills`` probes one schema per call, so each ``on_skills`` batch is that schema's
complete skill set: one row per schema, carrying its exact skill count.
"""

def loader(append: Callable[[list[questionary.Choice]], None]) -> None:
def on_skills(refs: list[SkillRef]) -> None:
location = f"{refs[0].catalog}.{refs[0].schema}"
append([_skill_schema_choice(location, len(refs), location in in_scope)])

list_all_skills(workspace, token, on_skills=on_skills)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: list_all_skills(...) returns (refs, reason) and yields partial results once its 30s wall-clock deadline is hit (or a schema probe errors), but the loader discards the return. When the walk times out on a large/slow workspace, the picker simply stops adding rows and the "Loading more…" footer disappears — indistinguishable from a complete load. The user then scopes only the schemas that happened to arrive in time, believing they've seen everything.

Suggest capturing reason and surfacing it — e.g. append a disabled sentinel row (⚠ schema discovery timed out — showing partial results) or print_warning after the picker closes. Same pattern exists in the download picker's _skills_download_background_loader (#579).


return loader


def prompt_for_skill_schema_choices(
background_loader: Callable[[Callable[[list[questionary.Choice]], None]], None],
) -> list[str] | None:
"""Show the skill-schema picker, returning the selected schemas or None on Ctrl-C."""
selection = scrolling_checkbox(
"Skill schemas:",
choices=[],
instruction="(space to toggle, ctrl-a all, enter to save, type to filter)",
style=picker_style(),
background_loader=background_loader,
loading_noun="skill schemas",
).ask()
if selection is None:
return None
return [str(value) for value in selection]


def configure_skills_mcp_picker_command(agents: set[str] | None = None) -> int:
"""Pick skill schemas from an interactive workspace-wide list and add them to the MCP scope.

Opens the picker immediately and streams schemas in as discovery finds them. Ctrl-C changes
nothing. ``agents`` scopes the addition to that subset of configured clients.
"""
state = load_state()
workspace, profile, clients = setup_mcp_clients(state, "Add Skills MCP", agents=agents)
token = get_databricks_token(workspace, profile)

loader = _skill_schema_background_loader(
workspace, token, configured_skill_locations(state, clients)
)
locations = prompt_for_skill_schema_choices(loader)
if not locations:
return 0

add_skill_locations_to_mcp(state, workspace, profile, clients, locations)
return 0


Expand Down
Loading