mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-01 13:18:10 +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>
206 lines
7.9 KiB
Python
206 lines
7.9 KiB
Python
"""Tests for SkillCommand CLI."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import os
|
|
import tempfile
|
|
import zipfile
|
|
from contextlib import contextmanager
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from crewai_cli.shared.token_manager import TokenManager
|
|
|
|
|
|
@contextmanager
|
|
def in_temp_dir():
|
|
original = os.getcwd()
|
|
with tempfile.TemporaryDirectory() as td:
|
|
os.chdir(td)
|
|
try:
|
|
yield td
|
|
finally:
|
|
os.chdir(original)
|
|
|
|
|
|
@pytest.fixture
|
|
def skill_command():
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
with patch.object(
|
|
TokenManager, "_get_secure_storage_path", return_value=Path(temp_dir)
|
|
):
|
|
TokenManager().save_tokens(
|
|
"test-token", (datetime.now() + timedelta(seconds=36000)).timestamp()
|
|
)
|
|
from crewai_cli.skills.main import SkillCommand
|
|
cmd = SkillCommand()
|
|
yield cmd
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# create
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSkillCreate:
|
|
def test_create_in_project(self, skill_command, tmp_path):
|
|
with in_temp_dir():
|
|
# Simulate being inside a project
|
|
Path("pyproject.toml").write_text("[tool.poetry]\nname = 'test'\n")
|
|
skill_command.create("my-skill")
|
|
assert Path("skills/my-skill/SKILL.md").exists()
|
|
assert Path("skills/my-skill/scripts").is_dir()
|
|
assert Path("skills/my-skill/references").is_dir()
|
|
assert Path("skills/my-skill/assets").is_dir()
|
|
|
|
def test_create_outside_project(self, skill_command, tmp_path):
|
|
with in_temp_dir():
|
|
skill_command.create("standalone-skill", in_project=False)
|
|
assert Path("standalone-skill/SKILL.md").exists()
|
|
|
|
def test_create_adds_name_to_skill_md(self, skill_command):
|
|
with in_temp_dir():
|
|
skill_command.create("hello-world", in_project=False)
|
|
content = Path("hello-world/SKILL.md").read_text()
|
|
assert "name: hello-world" in content
|
|
assert "version: 0.1.0" in content
|
|
|
|
def test_create_fails_if_dir_exists(self, skill_command):
|
|
with in_temp_dir():
|
|
Path("existing-skill").mkdir()
|
|
with pytest.raises(SystemExit):
|
|
skill_command.create("existing-skill", in_project=False)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# install
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSkillInstall:
|
|
def _zip_skill(self, name: str) -> bytes:
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w") as zf:
|
|
zf.writestr("SKILL.md", f"---\nname: {name}\ndescription: Test.\n---\nInstructions.")
|
|
return buf.getvalue()
|
|
|
|
def test_install_invalid_ref_no_at(self, skill_command):
|
|
with pytest.raises(SystemExit):
|
|
skill_command.install("acme/my-skill")
|
|
|
|
def test_install_invalid_ref_no_slash(self, skill_command):
|
|
with pytest.raises(SystemExit):
|
|
skill_command.install("@acmeskill")
|
|
|
|
def test_install_404(self, skill_command):
|
|
mock_resp = MagicMock()
|
|
mock_resp.status_code = 404
|
|
skill_command.plus_api_client.get_skill = MagicMock(return_value=mock_resp)
|
|
|
|
with pytest.raises(SystemExit):
|
|
skill_command.install("@acme/ghost")
|
|
|
|
def test_install_in_project(self, skill_command):
|
|
import base64
|
|
archive = self._zip_skill("my-skill")
|
|
encoded = "data:application/zip;base64," + base64.b64encode(archive).decode()
|
|
|
|
mock_resp = MagicMock()
|
|
mock_resp.status_code = 200
|
|
mock_resp.json.return_value = {"file": encoded, "version": "1.0.0"}
|
|
skill_command.plus_api_client.get_skill = MagicMock(return_value=mock_resp)
|
|
|
|
with in_temp_dir():
|
|
Path("pyproject.toml").write_text("[tool]\n")
|
|
skill_command.install("@acme/my-skill")
|
|
assert Path("skills/my-skill/SKILL.md").exists()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# publish
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSkillPublish:
|
|
def test_publish_no_skill_md(self, skill_command):
|
|
with in_temp_dir():
|
|
with pytest.raises(SystemExit):
|
|
skill_command.publish(is_public=True, org="acme")
|
|
|
|
def test_publish_missing_version(self, skill_command):
|
|
with in_temp_dir():
|
|
Path("SKILL.md").write_text(
|
|
"---\nname: my-skill\ndescription: Test.\n---\nInstructions."
|
|
)
|
|
with pytest.raises(SystemExit):
|
|
skill_command.publish(is_public=True, org="acme")
|
|
|
|
def test_publish_missing_name(self, skill_command):
|
|
with in_temp_dir():
|
|
Path("SKILL.md").write_text(
|
|
"---\ndescription: Test.\nversion: 1.0.0\n---\nInstructions."
|
|
)
|
|
with pytest.raises(SystemExit):
|
|
skill_command.publish(is_public=True, org="acme")
|
|
|
|
def test_publish_no_org(self, skill_command):
|
|
with in_temp_dir():
|
|
Path("SKILL.md").write_text(
|
|
"---\nname: my-skill\nversion: 1.0.0\ndescription: Test.\n---\nInstructions."
|
|
)
|
|
with patch.object(skill_command, "plus_api_client") as mock_client:
|
|
mock_resp = MagicMock()
|
|
mock_resp.is_success = True
|
|
mock_resp.status_code = 200
|
|
mock_resp.json.return_value = {}
|
|
mock_client.publish_skill.return_value = mock_resp
|
|
# No org set → should SystemExit (no org_name in settings)
|
|
with patch("crewai_cli.skills.main.Settings") as mock_settings_cls:
|
|
mock_settings_cls.return_value.org_name = None
|
|
mock_settings_cls.return_value.enterprise_base_url = None
|
|
with pytest.raises(SystemExit):
|
|
skill_command.publish(is_public=True, org=None)
|
|
|
|
def test_publish_calls_api(self, skill_command):
|
|
with in_temp_dir():
|
|
Path("SKILL.md").write_text(
|
|
"---\nname: my-skill\nversion: 1.0.0\ndescription: A test skill.\n---\nInstructions."
|
|
)
|
|
mock_resp = MagicMock()
|
|
mock_resp.is_success = True
|
|
mock_resp.status_code = 200
|
|
mock_resp.json.return_value = {}
|
|
skill_command.plus_api_client.publish_skill = MagicMock(return_value=mock_resp)
|
|
with patch("crewai_cli.skills.main.Settings") as mock_settings_cls:
|
|
mock_settings_cls.return_value.org_name = "acme"
|
|
mock_settings_cls.return_value.enterprise_base_url = None
|
|
|
|
skill_command.publish(is_public=False, org="acme")
|
|
|
|
skill_command.plus_api_client.publish_skill.assert_called_once()
|
|
call_kwargs = skill_command.plus_api_client.publish_skill.call_args
|
|
assert call_kwargs.kwargs["name"] == "my-skill"
|
|
assert call_kwargs.kwargs["version"] == "1.0.0"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# list_cached
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestSkillListCached:
|
|
def test_list_cached_empty(self, skill_command, capsys):
|
|
with in_temp_dir():
|
|
skill_command.list_cached()
|
|
# Should not raise
|
|
|
|
def test_list_cached_shows_project_skills(self, skill_command, capsys):
|
|
with in_temp_dir():
|
|
skill_dir = Path("skills/my-skill")
|
|
skill_dir.mkdir(parents=True)
|
|
(skill_dir / "SKILL.md").write_text(
|
|
"---\nname: my-skill\nversion: 0.5.0\ndescription: A skill.\n---\nBody."
|
|
)
|
|
skill_command.list_cached()
|
|
# Should complete without error
|