diff --git a/lib/crewai/src/crewai/skills/registry.py b/lib/crewai/src/crewai/skills/registry.py index d82613e89..e6d27e258 100644 --- a/lib/crewai/src/crewai/skills/registry.py +++ b/lib/crewai/src/crewai/skills/registry.py @@ -6,6 +6,7 @@ via the CrewAI+ API with a global cache at ~/.crewai/skills/. from __future__ import annotations +from collections.abc import Callable import logging from pathlib import Path import sys @@ -16,6 +17,12 @@ from crewai.skills.cache import SkillCacheManager _logger = logging.getLogger(__name__) +# Hook for injecting an authenticated PlusAPI client in environments that lack +# interactive auth (e.g. the Studio Runner subprocess). When set, it must be a +# callable that returns a ``PlusAPI`` instance. See the analogous pattern in +# ``crewai.utilities.agent_utils._create_plus_client_hook``. +_create_plus_client_hook: Callable[[], Any] | None = None + class SkillNotCachedError(Exception): """Raised when a registry skill is not cached and the environment is non-interactive.""" @@ -125,7 +132,11 @@ def resolve_registry_ref( ) if _is_noninteractive(): - raise SkillNotCachedError(ref) + if not callable(_create_plus_client_hook): + raise SkillNotCachedError(ref) + _logger.debug( + "Non-interactive but auth hook available; attempting download for %s", ref + ) return download_skill(org, name, source=source) @@ -170,9 +181,12 @@ def download_skill( ) try: - from crewai_core.plus_api import PlusAPI + if callable(_create_plus_client_hook): + api = _create_plus_client_hook() + else: + from crewai_core.plus_api import PlusAPI - api = PlusAPI() + api = PlusAPI() response = api.get_skill(org, name) response.raise_for_status() data = response.json() diff --git a/lib/crewai/tests/skills/test_registry.py b/lib/crewai/tests/skills/test_registry.py index 5f588ccf7..596c4b181 100644 --- a/lib/crewai/tests/skills/test_registry.py +++ b/lib/crewai/tests/skills/test_registry.py @@ -125,3 +125,75 @@ class TestResolveRegistryRef: err = SkillNotCachedError("@foo/bar") assert "@foo/bar" in str(err) assert err.ref == "@foo/bar" + + def test_noninteractive_downloads_when_hook_set(self, tmp_path: Path) -> None: + """When _create_plus_client_hook is set, non-interactive mode attempts download.""" + mock_cache = MagicMock() + mock_cache.get_cached_path.return_value = None + + mock_download = MagicMock() + mock_download.return_value = MagicMock(name="downloaded-skill") + + with ( + patch("crewai.skills.registry._is_noninteractive", return_value=True), + patch.object(Path, "cwd", return_value=tmp_path), + patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache), + patch("crewai.skills.registry._create_plus_client_hook", new=MagicMock), + patch("crewai.skills.registry.download_skill", mock_download), + ): + from crewai.skills.registry import resolve_registry_ref + + result = resolve_registry_ref("@acme/remote-skill") + + mock_download.assert_called_once_with("acme", "remote-skill", source=None) + assert result == mock_download.return_value + + def test_noninteractive_raises_without_hook(self, tmp_path: Path) -> None: + """Without _create_plus_client_hook, non-interactive mode still raises.""" + mock_cache = MagicMock() + mock_cache.get_cached_path.return_value = None + + with ( + patch("crewai.skills.registry._is_noninteractive", return_value=True), + patch.object(Path, "cwd", return_value=tmp_path), + patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache), + patch("crewai.skills.registry._create_plus_client_hook", new=None), + ): + from crewai.skills.registry import resolve_registry_ref + + with pytest.raises(SkillNotCachedError): + resolve_registry_ref("@acme/ghost-skill") + + +class TestDownloadSkillUsesHook: + """Test that download_skill respects _create_plus_client_hook.""" + + def test_uses_hook_client(self, tmp_path: Path) -> None: + """download_skill uses the hook-provided client when available.""" + mock_api = MagicMock() + mock_response = MagicMock() + mock_response.json.return_value = { + "latest_version": "1.0.0", + "file": "," + __import__("base64").b64encode(b"").decode(), + } + mock_response.raise_for_status = MagicMock() + mock_api.get_skill.return_value = mock_response + + mock_cache = MagicMock() + skill_dir = tmp_path / "acme" / "hooked-skill" + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + "---\nname: hooked-skill\ndescription: Hooked.\n---\n\nHooked instructions." + ) + mock_cache.store.return_value = skill_dir + + with ( + patch("crewai.skills.registry._create_plus_client_hook", new=lambda: mock_api), + patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache), + ): + from crewai.skills.registry import download_skill + + skill = download_skill("acme", "hooked-skill") + + mock_api.get_skill.assert_called_once_with("acme", "hooked-skill") + assert skill.name == "hooked-skill"