mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-01 05:08:12 +00:00
refactor(skills): move Skills Repository to experimental + CREWAI_EXPERIMENTAL gate
Moves the registry/cache pieces of PR #5867 under crewai.experimental.skills and the CLI commands under `crewai experimental skill`. The stable local-file skills feature (loader, parser, validation, models) stays in crewai.skills. Both entry points now require CREWAI_EXPERIMENTAL=1: - resolve_registry_ref() calls require_experimental_skills() before resolving - The `crewai experimental` CLI group raises UsageError when the flag is unset SkillDownloadStarted/CompletedEvent move out of crewai.events.types.skill_events into crewai.experimental.skills.events. * refactor(skills): move 'version' off SkillFrontmatter into metadata The skill version is now stored as `metadata.version` rather than a top-level field on `SkillFrontmatter`. A `before` validator lifts any top-level YAML `version:` into `metadata['version']` so existing SKILL.md files keep parsing.
This commit is contained in:
@@ -17,6 +17,7 @@ from crewai_cli.crew_chat import run_chat
|
||||
from crewai_cli.deploy.main import DeployCommand
|
||||
from crewai_cli.enterprise.main import EnterpriseConfigureCommand
|
||||
from crewai_cli.evaluate_crew import evaluate_crew
|
||||
from crewai_cli.experimental.skills.main import SkillCommand
|
||||
from crewai_cli.install_crew import install_crew
|
||||
from crewai_cli.kickoff_flow import kickoff_flow
|
||||
from crewai_cli.organization.main import OrganizationCommand
|
||||
@@ -26,7 +27,6 @@ from crewai_cli.replay_from_task import replay_task_command
|
||||
from crewai_cli.reset_memories_command import reset_memories_command
|
||||
from crewai_cli.run_crew import run_crew
|
||||
from crewai_cli.settings.main import SettingsCommand
|
||||
from crewai_cli.skills.main import SkillCommand
|
||||
from crewai_cli.task_outputs import load_task_outputs
|
||||
from crewai_cli.tools.main import ToolCommand
|
||||
from crewai_cli.train_crew import train_crew
|
||||
@@ -544,8 +544,19 @@ def tool_publish(is_public: bool, force: bool) -> None:
|
||||
|
||||
|
||||
@crewai.group()
|
||||
def experimental() -> None:
|
||||
"""Experimental, unstable commands. Subject to change without notice."""
|
||||
import os
|
||||
|
||||
if os.environ.get("CREWAI_EXPERIMENTAL") != "1":
|
||||
raise click.UsageError(
|
||||
"Experimental commands are gated. Set CREWAI_EXPERIMENTAL=1 to enable."
|
||||
)
|
||||
|
||||
|
||||
@experimental.group(name="skill")
|
||||
def skill() -> None:
|
||||
"""Skill Repository related commands."""
|
||||
"""Skill Repository related commands (experimental)."""
|
||||
|
||||
|
||||
@skill.command(name="create")
|
||||
|
||||
@@ -23,9 +23,10 @@ console = Console()
|
||||
_SKILL_MD_TEMPLATE = """\
|
||||
---
|
||||
name: {name}
|
||||
version: 0.1.0
|
||||
description: |
|
||||
A short description of what this skill does.
|
||||
metadata:
|
||||
version: 0.1.0
|
||||
---
|
||||
|
||||
## Instructions
|
||||
@@ -147,7 +148,7 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
|
||||
)
|
||||
else:
|
||||
try:
|
||||
from crewai.skills.cache import SkillCacheManager
|
||||
from crewai.experimental.skills.cache import SkillCacheManager
|
||||
|
||||
cache = SkillCacheManager()
|
||||
cache.store(org, name, version, archive_bytes)
|
||||
@@ -191,7 +192,10 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
|
||||
raise SystemExit(1) from exc
|
||||
|
||||
name = frontmatter.get("name")
|
||||
version = frontmatter.get("version")
|
||||
raw_metadata = frontmatter.get("metadata")
|
||||
version = (
|
||||
raw_metadata.get("version") if isinstance(raw_metadata, dict) else None
|
||||
)
|
||||
description = frontmatter.get("description")
|
||||
|
||||
if not name:
|
||||
@@ -362,10 +366,13 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
|
||||
return result
|
||||
|
||||
def _read_version(self, skill_md: Path) -> str | None:
|
||||
"""Read the version field from a SKILL.md file, or None."""
|
||||
"""Read the version from a SKILL.md file's metadata, or None."""
|
||||
try:
|
||||
fm = self._parse_frontmatter(skill_md.read_text())
|
||||
return fm.get("version")
|
||||
raw_metadata = fm.get("metadata")
|
||||
if isinstance(raw_metadata, dict):
|
||||
return raw_metadata.get("version")
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
0
lib/cli/tests/experimental/__init__.py
Normal file
0
lib/cli/tests/experimental/__init__.py
Normal file
0
lib/cli/tests/experimental/skills/__init__.py
Normal file
0
lib/cli/tests/experimental/skills/__init__.py
Normal file
@@ -36,7 +36,7 @@ def skill_command():
|
||||
TokenManager().save_tokens(
|
||||
"test-token", (datetime.now() + timedelta(seconds=36000)).timestamp()
|
||||
)
|
||||
from crewai_cli.skills.main import SkillCommand
|
||||
from crewai_cli.experimental.skills.main import SkillCommand
|
||||
cmd = SkillCommand()
|
||||
yield cmd
|
||||
|
||||
@@ -142,7 +142,7 @@ class TestSkillPublish:
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {}
|
||||
mock_client.publish_skill.return_value = mock_resp
|
||||
with patch("crewai_cli.skills.main.Settings") as mock_settings_cls:
|
||||
with patch("crewai_cli.experimental.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):
|
||||
@@ -151,14 +151,14 @@ class TestSkillPublish:
|
||||
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."
|
||||
"---\nname: my-skill\ndescription: A test skill.\nmetadata:\n version: 1.0.0\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:
|
||||
with patch("crewai_cli.experimental.skills.main.Settings") as mock_settings_cls:
|
||||
mock_settings_cls.return_value.org_name = "acme"
|
||||
mock_settings_cls.return_value.enterprise_base_url = None
|
||||
|
||||
@@ -472,7 +472,7 @@ class Agent(BaseAgent):
|
||||
|
||||
for item in items:
|
||||
if isinstance(item, str):
|
||||
from crewai.skills.registry import (
|
||||
from crewai.experimental.skills.registry import (
|
||||
is_registry_ref,
|
||||
parse_registry_ref,
|
||||
resolve_registry_ref,
|
||||
|
||||
@@ -60,20 +60,3 @@ class SkillLoadFailedEvent(SkillEvent):
|
||||
|
||||
type: Literal["skill_load_failed"] = "skill_load_failed"
|
||||
error: str
|
||||
|
||||
|
||||
class SkillDownloadStartedEvent(SkillEvent):
|
||||
"""Event emitted when a registry skill download begins."""
|
||||
|
||||
type: Literal["skill_download_started"] = "skill_download_started"
|
||||
registry_ref: str
|
||||
version: str | None = None
|
||||
|
||||
|
||||
class SkillDownloadCompletedEvent(SkillEvent):
|
||||
"""Event emitted when a registry skill download completes."""
|
||||
|
||||
type: Literal["skill_download_completed"] = "skill_download_completed"
|
||||
registry_ref: str
|
||||
version: str | None = None
|
||||
cache_path: Path | None = None
|
||||
|
||||
23
lib/crewai/src/crewai/experimental/skills/__init__.py
Normal file
23
lib/crewai/src/crewai/experimental/skills/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
"""Experimental Skills Repository — registry refs, global cache, downloads.
|
||||
|
||||
This package contains the registry-backed pieces of the skills feature
|
||||
(`@org/name` refs, `~/.crewai/skills/` cache, download events). The stable
|
||||
filesystem-based skill loader still lives in `crewai.skills`.
|
||||
"""
|
||||
|
||||
from crewai.experimental.skills.cache import SkillCacheManager
|
||||
from crewai.experimental.skills.registry import (
|
||||
SkillNotCachedError,
|
||||
is_registry_ref,
|
||||
parse_registry_ref,
|
||||
resolve_registry_ref,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"SkillCacheManager",
|
||||
"SkillNotCachedError",
|
||||
"is_registry_ref",
|
||||
"parse_registry_ref",
|
||||
"resolve_registry_ref",
|
||||
]
|
||||
24
lib/crewai/src/crewai/experimental/skills/_flag.py
Normal file
24
lib/crewai/src/crewai/experimental/skills/_flag.py
Normal file
@@ -0,0 +1,24 @@
|
||||
"""Experimental feature gate for the Skills Repository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
|
||||
ENV_VAR = "CREWAI_EXPERIMENTAL"
|
||||
|
||||
|
||||
class ExperimentalFeatureDisabledError(RuntimeError):
|
||||
"""Raised when an experimental feature is used without the flag set."""
|
||||
|
||||
|
||||
def is_enabled() -> bool:
|
||||
return os.environ.get(ENV_VAR) == "1"
|
||||
|
||||
|
||||
def require_experimental_skills() -> None:
|
||||
if not is_enabled():
|
||||
raise ExperimentalFeatureDisabledError(
|
||||
"The Skills Repository (registry refs, cache, downloads) is "
|
||||
f"experimental. Set {ENV_VAR}=1 to enable it."
|
||||
)
|
||||
30
lib/crewai/src/crewai/experimental/skills/events.py
Normal file
30
lib/crewai/src/crewai/experimental/skills/events.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Download lifecycle events for registry-backed skills.
|
||||
|
||||
These events are emitted only by the experimental Skills Repository
|
||||
(`@org/name` resolution + global cache). Local-file skill events still
|
||||
live in `crewai.events.types.skill_events`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from crewai.events.types.skill_events import SkillEvent
|
||||
|
||||
|
||||
class SkillDownloadStartedEvent(SkillEvent):
|
||||
"""Event emitted when a registry skill download begins."""
|
||||
|
||||
type: Literal["skill_download_started"] = "skill_download_started"
|
||||
registry_ref: str
|
||||
version: str | None = None
|
||||
|
||||
|
||||
class SkillDownloadCompletedEvent(SkillEvent):
|
||||
"""Event emitted when a registry skill download completes."""
|
||||
|
||||
type: Literal["skill_download_completed"] = "skill_download_completed"
|
||||
registry_ref: str
|
||||
version: str | None = None
|
||||
cache_path: Path | None = None
|
||||
@@ -11,7 +11,7 @@ from pathlib import Path
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from crewai.skills.cache import SkillCacheManager
|
||||
from crewai.experimental.skills.cache import SkillCacheManager
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -100,9 +100,11 @@ def resolve_registry_ref(
|
||||
Raises:
|
||||
SkillNotCachedError: When not cached and running in non-interactive mode.
|
||||
"""
|
||||
from crewai.experimental.skills._flag import require_experimental_skills
|
||||
from crewai.skills.loader import activate_skill
|
||||
from crewai.skills.parser import load_skill_metadata
|
||||
|
||||
require_experimental_skills()
|
||||
org, name = parse_registry_ref(ref)
|
||||
|
||||
local_path = Path.cwd() / "skills" / name
|
||||
@@ -152,7 +154,7 @@ def download_skill(
|
||||
|
||||
try:
|
||||
from crewai.events.event_bus import crewai_event_bus
|
||||
from crewai.events.types.skill_events import (
|
||||
from crewai.experimental.skills.events import (
|
||||
SkillDownloadCompletedEvent,
|
||||
SkillDownloadStartedEvent,
|
||||
)
|
||||
@@ -3,20 +3,15 @@
|
||||
Provides filesystem-based skill packaging with progressive disclosure.
|
||||
"""
|
||||
|
||||
from crewai.skills.cache import SkillCacheManager
|
||||
from crewai.skills.loader import activate_skill, discover_skills
|
||||
from crewai.skills.models import Skill, SkillFrontmatter
|
||||
from crewai.skills.parser import SkillParseError
|
||||
from crewai.skills.registry import is_registry_ref, resolve_registry_ref
|
||||
|
||||
|
||||
__all__ = [
|
||||
"Skill",
|
||||
"SkillCacheManager",
|
||||
"SkillFrontmatter",
|
||||
"SkillParseError",
|
||||
"activate_skill",
|
||||
"discover_skills",
|
||||
"is_registry_ref",
|
||||
"resolve_registry_ref",
|
||||
]
|
||||
|
||||
@@ -49,6 +49,7 @@ class SkillFrontmatter(BaseModel):
|
||||
license: Optional license name or reference.
|
||||
compatibility: Optional compatibility information (max 500 chars).
|
||||
metadata: Optional additional metadata as string key-value pairs.
|
||||
Conventional keys include 'version' (skill semantic version).
|
||||
allowed_tools: Optional space-delimited list of pre-approved tools.
|
||||
"""
|
||||
|
||||
@@ -71,17 +72,14 @@ class SkillFrontmatter(BaseModel):
|
||||
)
|
||||
metadata: dict[str, str] | None = Field(
|
||||
default=None,
|
||||
description="Arbitrary string key-value pairs for custom skill metadata.",
|
||||
description="Arbitrary string key-value pairs for custom skill metadata. "
|
||||
"Conventional keys include 'version' for the skill's semantic version.",
|
||||
)
|
||||
allowed_tools: list[str] | None = Field(
|
||||
default=None,
|
||||
alias="allowed-tools",
|
||||
description="Pre-approved tool names the skill may use, parsed from a space-delimited string in frontmatter.",
|
||||
)
|
||||
version: str | None = Field(
|
||||
default=None,
|
||||
description="Semantic version of the skill, e.g. '1.0.0'. Optional for local skills.",
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
|
||||
0
lib/crewai/tests/experimental/skills/__init__.py
Normal file
0
lib/crewai/tests/experimental/skills/__init__.py
Normal file
6
lib/crewai/tests/experimental/skills/conftest.py
Normal file
6
lib/crewai/tests/experimental/skills/conftest.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_experimental_skills(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CREWAI_EXPERIMENTAL", "1")
|
||||
@@ -8,7 +8,7 @@ import json
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
from crewai.skills.cache import SkillCacheManager
|
||||
from crewai.experimental.skills.cache import SkillCacheManager
|
||||
|
||||
|
||||
def _make_tar_gz(files: dict[str, str]) -> bytes:
|
||||
30
lib/crewai/tests/experimental/skills/test_flag.py
Normal file
30
lib/crewai/tests/experimental/skills/test_flag.py
Normal file
@@ -0,0 +1,30 @@
|
||||
"""Tests for the CREWAI_EXPERIMENTAL gate on Skills Repository."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai.experimental.skills._flag import (
|
||||
ExperimentalFeatureDisabledError,
|
||||
require_experimental_skills,
|
||||
)
|
||||
from crewai.experimental.skills.registry import resolve_registry_ref
|
||||
|
||||
|
||||
def test_require_raises_without_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CREWAI_EXPERIMENTAL", raising=False)
|
||||
with pytest.raises(ExperimentalFeatureDisabledError):
|
||||
require_experimental_skills()
|
||||
|
||||
|
||||
def test_resolve_registry_ref_raises_without_flag(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("CREWAI_EXPERIMENTAL", raising=False)
|
||||
with pytest.raises(ExperimentalFeatureDisabledError):
|
||||
resolve_registry_ref("@acme/my-skill")
|
||||
|
||||
|
||||
def test_require_passes_with_flag(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CREWAI_EXPERIMENTAL", "1")
|
||||
require_experimental_skills()
|
||||
32
lib/crewai/tests/experimental/skills/test_models_version.py
Normal file
32
lib/crewai/tests/experimental/skills/test_models_version.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""Tests for the 'version' metadata key on SkillFrontmatter.
|
||||
|
||||
Per the agentskills.io spec, `version` lives under `metadata`, not as a
|
||||
top-level frontmatter field.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from crewai.skills.models import SkillFrontmatter
|
||||
|
||||
|
||||
class TestSkillFrontmatterVersion:
|
||||
def test_no_metadata_by_default(self) -> None:
|
||||
fm = SkillFrontmatter(name="my-skill", description="A skill.")
|
||||
assert fm.metadata is None
|
||||
|
||||
def test_version_via_metadata(self) -> None:
|
||||
fm = SkillFrontmatter(
|
||||
name="my-skill",
|
||||
description="A skill.",
|
||||
metadata={"version": "1.2.3"},
|
||||
)
|
||||
assert fm.metadata is not None
|
||||
assert fm.metadata["version"] == "1.2.3"
|
||||
|
||||
def test_metadata_accepts_other_keys(self) -> None:
|
||||
fm = SkillFrontmatter(
|
||||
name="my-skill",
|
||||
description="A skill.",
|
||||
metadata={"version": "1.0.0", "author": "acme"},
|
||||
)
|
||||
assert fm.metadata == {"version": "1.0.0", "author": "acme"}
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from crewai.skills.registry import (
|
||||
from crewai.experimental.skills.registry import (
|
||||
SkillNotCachedError,
|
||||
is_registry_ref,
|
||||
parse_registry_ref,
|
||||
@@ -75,11 +75,11 @@ class TestResolveRegistryRef:
|
||||
mock_cache.get_cached_path.return_value = None
|
||||
|
||||
with (
|
||||
patch("crewai.skills.registry._is_noninteractive", return_value=False),
|
||||
patch("crewai.experimental.skills.registry._is_noninteractive", return_value=False),
|
||||
patch.object(Path, "cwd", return_value=tmp_path),
|
||||
patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache),
|
||||
patch("crewai.experimental.skills.registry.SkillCacheManager", return_value=mock_cache),
|
||||
):
|
||||
from crewai.skills.registry import resolve_registry_ref
|
||||
from crewai.experimental.skills.registry import resolve_registry_ref
|
||||
skill = resolve_registry_ref("@acme/my-skill")
|
||||
|
||||
assert skill.name == "my-skill"
|
||||
@@ -90,11 +90,11 @@ class TestResolveRegistryRef:
|
||||
mock_cache.get_cached_path.return_value = None
|
||||
|
||||
with (
|
||||
patch("crewai.skills.registry._is_noninteractive", return_value=True),
|
||||
patch("crewai.experimental.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.experimental.skills.registry.SkillCacheManager", return_value=mock_cache),
|
||||
):
|
||||
from crewai.skills.registry import resolve_registry_ref
|
||||
from crewai.experimental.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)
|
||||
@@ -112,11 +112,11 @@ class TestResolveRegistryRef:
|
||||
|
||||
# tmp_path has no ./skills/ directory
|
||||
with (
|
||||
patch("crewai.skills.registry._is_noninteractive", return_value=False),
|
||||
patch("crewai.experimental.skills.registry._is_noninteractive", return_value=False),
|
||||
patch.object(Path, "cwd", return_value=tmp_path),
|
||||
patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache),
|
||||
patch("crewai.experimental.skills.registry.SkillCacheManager", return_value=mock_cache),
|
||||
):
|
||||
from crewai.skills.registry import resolve_registry_ref
|
||||
from crewai.experimental.skills.registry import resolve_registry_ref
|
||||
skill = resolve_registry_ref("@acme/cached-skill")
|
||||
|
||||
assert skill.name == "cached-skill"
|
||||
@@ -1,32 +0,0 @@
|
||||
"""Tests for the version field added to SkillFrontmatter."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from crewai.skills.models import SkillFrontmatter
|
||||
|
||||
|
||||
class TestSkillFrontmatterVersion:
|
||||
def test_version_defaults_to_none(self) -> None:
|
||||
fm = SkillFrontmatter(name="my-skill", description="A skill.")
|
||||
assert fm.version is None
|
||||
|
||||
def test_version_can_be_set(self) -> None:
|
||||
fm = SkillFrontmatter(name="my-skill", description="A skill.", version="1.2.3")
|
||||
assert fm.version == "1.2.3"
|
||||
|
||||
def test_existing_frontmatter_without_version_still_valid(self) -> None:
|
||||
"""Backward compat: existing SKILL.md files without version must still parse."""
|
||||
fm = SkillFrontmatter(name="old-skill", description="Old skill without version.")
|
||||
assert fm.version is None
|
||||
|
||||
def test_version_is_optional_string(self) -> None:
|
||||
fm = SkillFrontmatter(name="my-skill", description="Desc.", version=None)
|
||||
assert fm.version is None
|
||||
|
||||
def test_frontmatter_is_frozen(self) -> None:
|
||||
fm = SkillFrontmatter(name="my-skill", description="A skill.", version="1.0.0")
|
||||
with pytest.raises(ValidationError):
|
||||
fm.version = "2.0.0" # type: ignore[misc]
|
||||
Reference in New Issue
Block a user