mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-22 07:15:10 +00:00
fix: allow skill download in non-interactive environments via auth hook
Add _create_plus_client_hook to the skill registry, mirroring the existing pattern in agent_utils for from_repository agents. When set (by the Studio Runner or similar host), download_skill() uses the hook-provided PlusAPI client instead of the default unauthenticated one. In non-interactive mode, the SkillNotCachedError is only raised when no hook is available — allowing deployed environments to auto-download skills on first use. Co-authored-by: João Moura <joaomdmoura@gmail.com>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user