mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-06 23:49:24 +00:00
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Vulnerability Scan / pip-audit (push) Has been cancelled
Nightly Canary Release / Check for new commits (push) Has been cancelled
Nightly Canary Release / Build nightly packages (push) Has been cancelled
Nightly Canary Release / Publish nightly to PyPI (push) Has been cancelled
* feat: add Skills Repository — registry, cache, CLI, and SDK integration
Adds a Skills Repository feature allowing users to publish, install,
and use skills from the CrewAI registry with @org/skill-name refs.
## What's New
### SDK (lib/crewai/)
- SkillFrontmatter: added optional 'version' field (backward compatible)
- SkillCacheManager: manages ~/.crewai/skills/{org}/{name}/ with
.crewai_meta.json tracking, path-traversal-safe tar extraction
- SkillRegistry: parse @org/skill-name refs, local-first resolution
(./skills/ > cache > download), interactive prompt on first use,
CI-mode guard (CREWAI_NONINTERACTIVE/CI env vars)
- Agent.skills and Crew.skills widened to accept str refs (@org/name)
- set_skills() resolves registry refs with org-prefixed dedup keys
- New events: SkillDownloadStartedEvent, SkillDownloadCompletedEvent
### CLI (lib/cli/)
- crewai skill create <name> — context-aware (project vs standalone)
- crewai skill install @org/name — downloads to ./skills/ or cache
- crewai skill publish — ZIP + upload to org registry
- crewai skill list — show installed skills
### PlusAPI (lib/crewai-core/)
- Added SKILLS_RESOURCE, get_skill(), publish_skill(), list_skills()
### Scaffolding
- crew and flow templates now include skills/ directory
### Tests
- 91 SDK skill tests + 15 CLI skill tests, all passing
* fix: address all CI failures and CodeRabbit review comments
Lint:
- Remove unused imports (click, pytest, json)
- Replace try-except-pass with logging (S110)
- Fix unprotected zipfile.extractall (S202)
Security:
- Path traversal: startswith → is_relative_to for tar extraction
- Add path traversal protection to ZIP extraction via _safe_extract_zip
- Both cache.py and CLI main.py hardened
Type checker:
- Fix import path: crewai.events.event_bus (not crewai_event_bus)
- Remove unused type: ignore comments
- Fix type mismatches in set_skills() variable types
Code quality:
- Fix f-string interpolation in SkillNotCachedError
- Use ValidationError instead of Exception in test
* style: ruff format + autofix remaining lint errors
* refactor: reuse SDK parser and SkillCacheManager in CLI
- _parse_frontmatter() now delegates to crewai.skills.parser.parse_frontmatter
when available, with a minimal fallback for CLI-only installs
- install() global cache path now reuses SkillCacheManager.store() instead
of duplicating metadata writing logic
* refactor: add _print_current_organization to SkillCommand (matches ToolCommand pattern)
* fix: write .crewai_meta.json in fallback install path
CodeRabbit caught that the ImportError fallback in install() didn't write
cache metadata, making skills invisible to 'crewai skill list'.
* fix: tighten @org/name ref validation to prevent path traversal
Reject refs with multiple slashes (@org/a/b), dot segments (@../skill),
or leading dots in org/name. Applied to both CLI install() and SDK
parse_registry_ref() so the contract is enforced consistently.
* fix: update test assertions to match tightened error messages
* fix: align OSS client with AMP API contract
- download_skill(): fetch download_url (presigned URL) instead of
expecting inline base64. Falls back to 'file' field for compat.
- Read 'latest_version' field, fall back to 'version'
- Same fixes applied to CLI install() command
* fix: publish as tar.gz (matches AMP content_type validation) + add zip fallback to SDK cache
CLI publish:
- _build_skill_zip → _build_skill_tarball (tar.gz format)
- Content type: application/x-gzip (matches SkillVersion validation)
SDK cache:
- store() now tries tar.gz first, falls back to zip extraction
- Added _safe_extract_zip for path-traversal-safe zip handling
- Both formats work for download/install regardless of server format
---------
Co-authored-by: João Moura <joaomdmoura@gmail.com>
130 lines
4.7 KiB
Python
130 lines
4.7 KiB
Python
"""Tests for SkillRegistry."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from crewai.skills.registry import (
|
|
SkillNotCachedError,
|
|
is_registry_ref,
|
|
parse_registry_ref,
|
|
)
|
|
|
|
|
|
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 SkillCacheManager to return None (not cached) so only local is hit
|
|
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"
|