mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-26 01:05:10 +00:00
* feat(skills)!: promote Skills Repository out of experimental The registry-backed Skills Repository (crewai skill create/publish/ install/list, @org/name refs, global cache) is now mainline: - CLI: `crewai skill ...` is a top-level group; the CREWAI_EXPERIMENTAL gate and the now-empty `crewai experimental` group are removed. - Runtime: registry.py, cache.py, and events.py move from crewai.experimental.skills into crewai.skills next to the loader; the require_experimental_skills() gate is gone. crewai.experimental.skills remains as a deprecated re-export shim. - Docs: concepts/skills now leads with the CLI workflow and documents the create -> publish -> install lifecycle. Linear: n/a (requested promotion) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(skills): org-scoped publish only + docs in all languages Skills are always scoped to the publishing organization, like tools: drop the --public/--private flags from `crewai skill publish` and always send is_public=False to the registry. CLI tests assert the flag is rejected and the API never receives a public publish. Translate the new CLI-first Quick Start and the create -> publish -> install lifecycle section into ar, pt-BR, and ko concepts/skills docs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): address review comments on the promotion PR - Back-compat shim now aliases the old submodules in sys.modules so `crewai.experimental.skills.registry/cache/events` imports (and patch targets) resolve to the real crewai.skills modules, not just the package-root re-exports. - `crewai skill publish` actually enforces the git-state check that --force claims to skip: unsynced repos block publishing (mirroring tool publish); standalone skill dirs outside any git repo publish without a check. - Explicit UTF-8 encoding on SKILL.md and cache-metadata reads/writes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): fail closed when git state cannot be validated on publish Follow deploy's pattern: construct git.Repository(fetch=False) and only treat "not a Git repository" as skippable — any other git error (fetch/auth/misconfiguration) now blocks publish with a --force escape hatch instead of silently bypassing the sync check. Also single-style imports in the shim test (CodeQL) with the dotted shim import covered via importlib. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(skills): fetch before sync check on publish; bump mcp past advisories Publish now refreshes remote-tracking refs (repository.fetch()) before is_synced(), so ahead/behind is judged against the actual remote rather than stale local refs; a failing fetch blocks publish with the --force escape hatch. Adds a fail-closed test for fetch errors. Raise mcp to >=1.28.1,<2 (locks 1.28.1): the ~=1.26.0 pin blocked GHSA-hvrp-rf83-w775 / GHSA-jpw9-pfvf-9f58 (fixed 1.27.2) and GHSA-vj7q-gjh5-988w (fixed 1.28.1), which were failing pip-audit on this PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: Vinicius Brasil <vini@hey.com>
128 lines
4.6 KiB
Python
128 lines
4.6 KiB
Python
"""Tests for SkillRegistry."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from crewai.skills.registry import (
|
|
SkillNotCachedError,
|
|
is_registry_ref,
|
|
parse_registry_ref,
|
|
)
|
|
import pytest
|
|
|
|
|
|
class TestIsRegistryRef:
|
|
def test_at_prefixed(self) -> None:
|
|
assert is_registry_ref("@acme/my-skill") is True
|
|
|
|
def test_plain_string(self) -> None:
|
|
assert is_registry_ref("my-skill") is False
|
|
|
|
def test_path_like_string(self) -> None:
|
|
assert is_registry_ref("./skills/my-skill") is False
|
|
|
|
def test_non_string(self) -> None:
|
|
assert is_registry_ref(None) is False
|
|
assert is_registry_ref(42) is False
|
|
assert is_registry_ref(Path("something")) is False
|
|
|
|
|
|
class TestParseRegistryRef:
|
|
def test_valid(self) -> None:
|
|
assert parse_registry_ref("@acme/my-skill") == ("acme", "my-skill")
|
|
|
|
def test_valid_with_dashes(self) -> None:
|
|
assert parse_registry_ref("@my-org/cool-skill") == ("my-org", "cool-skill")
|
|
|
|
def test_missing_at(self) -> None:
|
|
with pytest.raises(ValueError, match="must start with '@'"):
|
|
parse_registry_ref("acme/my-skill")
|
|
|
|
def test_missing_slash(self) -> None:
|
|
with pytest.raises(ValueError, match="'@org/name' format"):
|
|
parse_registry_ref("@acme-skill")
|
|
|
|
def test_empty_org(self) -> None:
|
|
with pytest.raises(ValueError, match="non-empty"):
|
|
parse_registry_ref("@/my-skill")
|
|
|
|
def test_empty_name(self) -> None:
|
|
with pytest.raises(ValueError, match="non-empty"):
|
|
parse_registry_ref("@acme/")
|
|
|
|
|
|
class TestResolveRegistryRef:
|
|
"""Test resolution order and CI mode behaviour."""
|
|
|
|
def _make_skill_dir(self, base: Path, name: str) -> Path:
|
|
"""Write a minimal SKILL.md into base/name/."""
|
|
skill_dir = base / name
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
f"---\nname: {name}\ndescription: Test skill.\n---\n\nInstructions."
|
|
)
|
|
return skill_dir
|
|
|
|
def test_resolves_project_local(self, tmp_path: Path) -> None:
|
|
"""Local ./skills/{name}/ takes priority over cache."""
|
|
skills_dir = tmp_path / "skills"
|
|
skills_dir.mkdir()
|
|
self._make_skill_dir(skills_dir, "my-skill")
|
|
|
|
mock_cache = MagicMock()
|
|
mock_cache.get_cached_path.return_value = None
|
|
|
|
with (
|
|
patch("crewai.skills.registry._is_noninteractive", return_value=False),
|
|
patch.object(Path, "cwd", return_value=tmp_path),
|
|
patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache),
|
|
):
|
|
from crewai.skills.registry import resolve_registry_ref
|
|
skill = resolve_registry_ref("@acme/my-skill")
|
|
|
|
assert skill.name == "my-skill"
|
|
|
|
def test_raises_in_ci_when_not_cached(self, tmp_path: Path) -> None:
|
|
"""In CI mode, raise SkillNotCachedError if no local or cached copy."""
|
|
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),
|
|
):
|
|
from crewai.skills.registry import resolve_registry_ref
|
|
with pytest.raises(SkillNotCachedError) as exc_info:
|
|
resolve_registry_ref("@acme/ghost-skill")
|
|
assert "@acme/ghost-skill" in str(exc_info.value)
|
|
|
|
def test_resolves_from_cache(self, tmp_path: Path) -> None:
|
|
"""Falls back to global cache when no project-local skill exists."""
|
|
cache_dir = tmp_path / "acme" / "cached-skill"
|
|
cache_dir.mkdir(parents=True)
|
|
(cache_dir / "SKILL.md").write_text(
|
|
"---\nname: cached-skill\ndescription: Cached.\n---\n\nCached instructions."
|
|
)
|
|
|
|
mock_cache = MagicMock()
|
|
mock_cache.get_cached_path.return_value = cache_dir
|
|
|
|
# tmp_path has no ./skills/ directory
|
|
with (
|
|
patch("crewai.skills.registry._is_noninteractive", return_value=False),
|
|
patch.object(Path, "cwd", return_value=tmp_path),
|
|
patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache),
|
|
):
|
|
from crewai.skills.registry import resolve_registry_ref
|
|
skill = resolve_registry_ref("@acme/cached-skill")
|
|
|
|
assert skill.name == "cached-skill"
|
|
|
|
def test_skill_not_cached_error_contains_ref(self) -> None:
|
|
err = SkillNotCachedError("@foo/bar")
|
|
assert "@foo/bar" in str(err)
|
|
assert err.ref == "@foo/bar"
|