diff --git a/README.md b/README.md index d33830a7..3e18b02c 100644 --- a/README.md +++ b/README.md @@ -227,13 +227,19 @@ you to run `ucode ` (existing agent sessions need a restart before the MC #### 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. +adds the schemas to every configured agent's scope, or only to `--agents` when supplied. Agents that +are not configured yet are set up first. Without `--mcp`, it downloads skills to disk; download mode +always writes both directory families and does not accept `--agents`. `--skills` narrows a download +to a subset of one schema's skills. Each agent receives the shared location set plus its own +agent-specific additions, with duplicates removed from the effective scope. ```bash # Add schemas to the skills MCP scope, keeping any already configured. ucode skill add --location main.default,ml.prod --mcp +# Add a schema only to selected agents. +ucode skill add --location main.default --mcp --agents claude,codex + # Download a schema's skills to disk, keeping existing downloads. ucode skill add --location main.default @@ -377,6 +383,7 @@ The output looks like: | `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 --location main.default --mcp` | Add schemas to the skills MCP scope, keeping any already configured (additive; never replaces) | +| `ucode skill add --location main.default --mcp --agents claude` | Set up selected agents if needed and add schemas only to their MCP scopes | | `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) | diff --git a/src/ucode/cli.py b/src/ucode/cli.py index 426abbbc..b6e6c68f 100644 --- a/src/ucode/cli.py +++ b/src/ucode/cli.py @@ -1353,6 +1353,14 @@ def skills_add( "Not valid with --mcp.", ), ] = None, + agents: Annotated[ + str | None, + typer.Option( + "--agents", + help="(--mcp only) Comma-separated coding agents whose skills MCP scope should " + "be updated. Any that aren't configured yet are set up first.", + ), + ] = None, ) -> None: """Add Databricks Skills to your coding tools, keeping any already configured. @@ -1368,6 +1376,16 @@ def skills_add( requested_skills = ( None if skills is None else {s.strip() for s in skills.split(",") if s.strip()} ) + requested_agents = None + if agents is not None: + requested_agents = { + agent.strip().lower() for agent in agents.split(",") if agent.strip() + } + if not requested_agents: + raise RuntimeError( + "No agents provided for --agents. Use a comma-separated list like " + "`--agents claude,codex`." + ) if mcp and path is not None: raise RuntimeError("--path is not supported when using --mcp") if mcp and requested_skills is not None: @@ -1388,6 +1406,9 @@ def skills_add( "`..` values " f"(invalid: {', '.join(sorted(invalid_skills))})." ) + # Downloaded skills use shared directory families, so only MCP configs can be agent-scoped. + if not mcp and agents is not None: + raise RuntimeError("--agents is only supported when using --mcp") if requested_skills is not None and not locations: schemas = {".".join(parts[:2]) for parts in qualified_skill_parts.values()} bare = sorted(skill for skill in requested_skills if skill not in qualified_skill_parts) @@ -1422,7 +1443,13 @@ def skills_add( None if requested_skills is None else {s.split(".")[-1] for s in requested_skills} ) if mcp: - add_skills_command(locations) + scope = ( + _configure_agents_for_mcp(sorted(requested_agents)) if requested_agents else None + ) + if scope is None: + add_skills_command(locations) + else: + add_skills_command(locations, agents=scope) else: configure_skills_download_command(locations, path=path, skills=selected_skills) except (RuntimeError, ValueError) as exc: diff --git a/src/ucode/mcp.py b/src/ucode/mcp.py index 4286c4e7..9abe4dca 100644 --- a/src/ucode/mcp.py +++ b/src/ucode/mcp.py @@ -2359,10 +2359,29 @@ def _union_locations(base: list[str], new: list[str]) -> list[str]: return merged -def add_skills_command(locations: list[str]) -> int: +def add_skills_command(locations: list[str], agents: set[str] | None = None) -> 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) + workspace, profile, clients = setup_mcp_clients(state, "Add Skills MCP", agents=agents) + entry = _skills_entry(list(state.get("mcp_servers") or [])) + default = _skill_mcp_locations(state) + overrides = _skill_location_overrides(entry) + if agents is None: + merged = _union_locations(default, locations) + else: + merged = default + for client in clients: + _set_skill_location_override( + overrides, + client, + _union_locations(overrides.get(client, []), locations), + ) + _update_skills_mcp( + state, + workspace, + profile, + clients, + merged, + location_overrides=overrides, + ) return 0 diff --git a/tests/test_cli.py b/tests/test_cli.py index b2cbd23f..0123c3b3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1213,6 +1213,43 @@ def test_malformed_location_exit_1(self): assert "--location" in _strip_ansi(result.output) mock_add.assert_not_called() + def test_agents_scope_is_configured_and_forwarded_for_mcp(self): + with ( + patch("ucode.cli._configure_agents_for_mcp", return_value={"claude"}) as configure, + patch("ucode.cli.add_skills_command") as mock_add, + ): + result = runner.invoke( + app, + ["skill", "add", "--location", "a.b", "--mcp", "--agents", "claude"], + ) + + assert result.exit_code == 0, result.output + configure.assert_called_once_with(["claude"]) + mock_add.assert_called_once_with(["a.b"], agents={"claude"}) + + def test_empty_agents_scope_is_rejected(self): + with ( + patch("ucode.cli._configure_agents_for_mcp") as configure, + patch("ucode.cli.add_skills_command") as mock_add, + ): + result = runner.invoke( + app, + ["skill", "add", "--location", "a.b", "--mcp", "--agents", ","], + ) + + assert result.exit_code == 1 + assert "No agents provided for --agents" in _strip_ansi(result.output) + configure.assert_not_called() + mock_add.assert_not_called() + + def test_agents_is_rejected_for_download_mode(self): + with patch("ucode.cli.configure_skills_download_command") as mock_download: + result = runner.invoke(app, ["skill", "add", "--location", "a.b", "--agents", "claude"]) + + assert result.exit_code == 1 + assert "--agents is only supported when using --mcp" in _strip_ansi(result.output) + mock_download.assert_not_called() + class TestApplyManagedSkills: """The launch path both registers the skills MCP connection and downloads bundles to disk.""" diff --git a/tests/test_mcp.py b/tests/test_mcp.py index 1188d42a..a016434f 100644 --- a/tests/test_mcp.py +++ b/tests/test_mcp.py @@ -2610,6 +2610,79 @@ def test_registers_scope_from_empty_state(self, monkeypatch): assert _find_skills(state["mcp_servers"])[0]["skill_locations"] == ["A.a"] + def test_agents_updates_only_selected_client_scope(self, monkeypatch): + configured: list[tuple[str, str]] = [] + prior = mcp._resolve_skills_mcp_servers(WS, ["claude", "codex"], ["A.a"], []) + state = { + "workspace": WS, + "available_tools": ["claude", "codex"], + "mcp_servers": prior, + } + _stub_location_base(monkeypatch, state) + monkeypatch.setattr(mcp, "available_mcp_clients", lambda: ["claude", "codex"]) + monkeypatch.setattr( + mcp, + "configure_client_mcp_server", + lambda client, name, url, *a, **kw: configured.append((client, url)) or [], + ) + monkeypatch.setattr(mcp, "save_state", lambda s: None) + + assert mcp.add_skills_command(["B.b"], agents={"claude"}) == 0 + + entry = _find_skills(state["mcp_servers"])[0] + assert entry["skill_locations"] == ["A.a"] + assert entry[mcp.SKILL_LOCATION_OVERRIDES_KEY] == {"claude": ["B.b"]} + assert mcp.skill_locations_for_client(entry, "codex") == ["A.a"] + assert configured == [("claude", f"{WS}/ai-gateway/skills/?schema=A.a&schema=B.b")] + + def test_global_addition_does_not_rewrite_agent_override(self, monkeypatch): + prior = mcp._resolve_skills_mcp_servers( + WS, + ["claude", "codex"], + ["A.a"], + [], + {"claude": ["B.b"]}, + ) + state = { + "workspace": WS, + "available_tools": ["claude", "codex"], + "mcp_servers": prior, + } + _stub_location_base(monkeypatch, state) + monkeypatch.setattr(mcp, "available_mcp_clients", lambda: ["claude", "codex"]) + monkeypatch.setattr(mcp, "configure_client_mcp_server", lambda *a, **kw: []) + monkeypatch.setattr(mcp, "save_state", lambda s: None) + + assert mcp.add_skills_command(["C.c"]) == 0 + + entry = _find_skills(state["mcp_servers"])[0] + assert entry["skill_locations"] == ["A.a", "C.c"] + assert entry[mcp.SKILL_LOCATION_OVERRIDES_KEY] == {"claude": ["B.b"]} + assert mcp.skill_locations_for_client(entry, "claude") == ["A.a", "C.c", "B.b"] + + def test_agents_persists_explicit_scope_even_when_it_matches_default(self, monkeypatch): + configured: list[tuple[str, str]] = [] + prior = mcp._resolve_skills_mcp_servers(WS, ["claude", "codex"], ["A.a"], []) + state = { + "workspace": WS, + "available_tools": ["claude", "codex"], + "mcp_servers": prior, + } + _stub_location_base(monkeypatch, state) + monkeypatch.setattr(mcp, "available_mcp_clients", lambda: ["claude", "codex"]) + monkeypatch.setattr( + mcp, + "configure_client_mcp_server", + lambda client, name, url, *a, **kw: configured.append((client, url)) or [], + ) + monkeypatch.setattr(mcp, "save_state", lambda s: None) + + assert mcp.add_skills_command(["A.a"], agents={"claude"}) == 0 + + entry = _find_skills(state["mcp_servers"])[0] + assert entry[mcp.SKILL_LOCATION_OVERRIDES_KEY] == {"claude": ["A.a"]} + assert configured == [] + class TestRegisterSchemalessSkillsConnection: def _stub(self, monkeypatch):