Compare commits

..

1 Commits

Author SHA1 Message Date
Iris Clawd
173796483c 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>
2026-07-19 11:24:58 +00:00
2 changed files with 89 additions and 3 deletions

View File

@@ -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()

View File

@@ -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"