mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-22 23:35:08 +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>
284 lines
9.6 KiB
Python
284 lines
9.6 KiB
Python
"""Tests for skills/loader.py."""
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from crewai.skills.loader import (
|
|
activate_skill,
|
|
discover_skills,
|
|
format_skill_context,
|
|
load_resources,
|
|
load_skill,
|
|
load_skills,
|
|
)
|
|
from crewai.skills.models import INSTRUCTIONS, METADATA, RESOURCES, Skill, SkillFrontmatter
|
|
from crewai.skills.parser import SkillParseError, load_skill_metadata
|
|
|
|
|
|
def _create_skill_dir(parent: Path, name: str, body: str = "Body.") -> Path:
|
|
"""Helper to create a skill directory with SKILL.md."""
|
|
skill_dir = parent / name
|
|
skill_dir.mkdir()
|
|
(skill_dir / "SKILL.md").write_text(
|
|
f"---\nname: {name}\ndescription: Skill {name}\n---\n{body}"
|
|
)
|
|
return skill_dir
|
|
|
|
|
|
class TestDiscoverSkills:
|
|
"""Tests for discover_skills."""
|
|
|
|
def test_finds_valid_skills(self, tmp_path: Path) -> None:
|
|
_create_skill_dir(tmp_path, "alpha")
|
|
_create_skill_dir(tmp_path, "beta")
|
|
skills = discover_skills(tmp_path)
|
|
names = {s.name for s in skills}
|
|
assert names == {"alpha", "beta"}
|
|
|
|
def test_skips_dirs_without_skill_md(self, tmp_path: Path) -> None:
|
|
_create_skill_dir(tmp_path, "valid")
|
|
(tmp_path / "no-skill").mkdir()
|
|
skills = discover_skills(tmp_path)
|
|
assert len(skills) == 1
|
|
assert skills[0].name == "valid"
|
|
|
|
def test_skips_invalid_skills(self, tmp_path: Path) -> None:
|
|
_create_skill_dir(tmp_path, "good-skill")
|
|
bad_dir = tmp_path / "bad-skill"
|
|
bad_dir.mkdir()
|
|
(bad_dir / "SKILL.md").write_text(
|
|
"---\nname: Wrong-Name\ndescription: bad\n---\n"
|
|
)
|
|
skills = discover_skills(tmp_path)
|
|
assert len(skills) == 1
|
|
|
|
def test_empty_directory(self, tmp_path: Path) -> None:
|
|
skills = discover_skills(tmp_path)
|
|
assert skills == []
|
|
|
|
def test_nonexistent_path(self, tmp_path: Path) -> None:
|
|
with pytest.raises(FileNotFoundError):
|
|
discover_skills(tmp_path / "nonexistent")
|
|
|
|
def test_sorted_by_name(self, tmp_path: Path) -> None:
|
|
_create_skill_dir(tmp_path, "zebra")
|
|
_create_skill_dir(tmp_path, "alpha")
|
|
skills = discover_skills(tmp_path)
|
|
assert [s.name for s in skills] == ["alpha", "zebra"]
|
|
|
|
|
|
class TestActivateSkill:
|
|
"""Tests for activate_skill."""
|
|
|
|
def test_promotes_to_instructions(self, tmp_path: Path) -> None:
|
|
_create_skill_dir(tmp_path, "my-skill", body="Instructions.")
|
|
skill = load_skill_metadata(tmp_path / "my-skill")
|
|
activated = activate_skill(skill)
|
|
assert activated.disclosure_level == INSTRUCTIONS
|
|
assert activated.instructions == "Instructions."
|
|
|
|
def test_idempotent(self, tmp_path: Path) -> None:
|
|
_create_skill_dir(tmp_path, "my-skill")
|
|
skill = load_skill_metadata(tmp_path / "my-skill")
|
|
activated = activate_skill(skill)
|
|
again = activate_skill(activated)
|
|
assert again is activated
|
|
|
|
|
|
class TestLoadSkill:
|
|
"""Tests for load_skill."""
|
|
|
|
@pytest.mark.parametrize("as_string", [False, True])
|
|
def test_loads_path_input(self, tmp_path: Path, as_string: bool) -> None:
|
|
_create_skill_dir(tmp_path, "first-skill", body="First.")
|
|
_create_skill_dir(tmp_path, "second-skill", body="Second.")
|
|
path = str(tmp_path) if as_string else tmp_path
|
|
|
|
skills = load_skill(path)
|
|
|
|
assert [skill.name for skill in skills] == ["first-skill", "second-skill"]
|
|
assert [skill.disclosure_level for skill in skills] == [
|
|
INSTRUCTIONS,
|
|
INSTRUCTIONS,
|
|
]
|
|
assert [skill.instructions for skill in skills] == ["First.", "Second."]
|
|
|
|
def test_loads_preloaded_skill(self, tmp_path: Path) -> None:
|
|
preloaded = Skill(
|
|
frontmatter=SkillFrontmatter(
|
|
name="preloaded-skill",
|
|
description="Preloaded skill",
|
|
),
|
|
path=tmp_path / "preloaded-skill",
|
|
)
|
|
|
|
skills = load_skill(preloaded)
|
|
|
|
assert skills == [preloaded]
|
|
|
|
def test_loads_inline_skill(self) -> None:
|
|
inline_skill = (
|
|
"---\n"
|
|
"name: inline-skill\n"
|
|
"description: Inline guidance\n"
|
|
"---\n"
|
|
"Follow these instructions."
|
|
)
|
|
|
|
skills = load_skill(inline_skill)
|
|
|
|
assert [skill.name for skill in skills] == ["inline-skill"]
|
|
assert [skill.disclosure_level for skill in skills] == [INSTRUCTIONS]
|
|
assert [skill.instructions for skill in skills] == [
|
|
"Follow these instructions."
|
|
]
|
|
|
|
def test_invalid_inline_skill_raises_parse_error(self) -> None:
|
|
with pytest.raises(SkillParseError, match="missing closing"):
|
|
load_skill("---\nname: inline-skill\n")
|
|
|
|
def test_missing_path_raises_file_not_found(self, tmp_path: Path) -> None:
|
|
with pytest.raises(FileNotFoundError):
|
|
load_skill(tmp_path / "missing")
|
|
|
|
def test_unsupported_input_raises_type_error(self) -> None:
|
|
with pytest.raises(TypeError, match="Unsupported skill input"):
|
|
load_skill(object()) # type: ignore[arg-type]
|
|
|
|
def test_load_skills_deduplicates_by_name(self, tmp_path: Path) -> None:
|
|
first = Skill(
|
|
frontmatter=SkillFrontmatter(
|
|
name="duplicate-skill",
|
|
description="First skill",
|
|
),
|
|
path=tmp_path / "first",
|
|
)
|
|
second = Skill(
|
|
frontmatter=SkillFrontmatter(
|
|
name="duplicate-skill",
|
|
description="Second skill",
|
|
),
|
|
path=tmp_path / "second",
|
|
)
|
|
|
|
skills = load_skills([first, second])
|
|
|
|
assert skills == [first]
|
|
|
|
def test_load_skills_keeps_registry_refs_from_different_orgs(
|
|
self,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
first = Skill(
|
|
frontmatter=SkillFrontmatter(
|
|
name="shared-skill",
|
|
description="First registry skill",
|
|
),
|
|
path=tmp_path / "first",
|
|
disclosure_level=INSTRUCTIONS,
|
|
instructions="First instructions.",
|
|
)
|
|
second = Skill(
|
|
frontmatter=SkillFrontmatter(
|
|
name="shared-skill",
|
|
description="Second registry skill",
|
|
),
|
|
path=tmp_path / "second",
|
|
disclosure_level=INSTRUCTIONS,
|
|
instructions="Second instructions.",
|
|
)
|
|
|
|
def resolve_registry_ref(ref: str, source: object = None) -> Skill:
|
|
return {
|
|
"@first/shared-skill": first,
|
|
"@second/shared-skill": second,
|
|
}[ref]
|
|
|
|
monkeypatch.setattr(
|
|
"crewai.skills.registry.resolve_registry_ref",
|
|
resolve_registry_ref,
|
|
)
|
|
|
|
skills = load_skills(["@first/shared-skill", "@second/shared-skill"])
|
|
|
|
assert skills == [first, second]
|
|
|
|
|
|
class TestLoadResources:
|
|
"""Tests for load_resources."""
|
|
|
|
def test_promotes_to_resources(self, tmp_path: Path) -> None:
|
|
skill_dir = _create_skill_dir(tmp_path, "my-skill")
|
|
(skill_dir / "scripts").mkdir()
|
|
(skill_dir / "scripts" / "run.sh").write_text("#!/bin/bash")
|
|
skill = load_skill_metadata(skill_dir)
|
|
full = load_resources(skill)
|
|
assert full.disclosure_level == RESOURCES
|
|
|
|
|
|
class TestFormatSkillContext:
|
|
"""Tests for format_skill_context."""
|
|
|
|
def test_metadata_level(self, tmp_path: Path) -> None:
|
|
fm = SkillFrontmatter(name="test-skill", description="A skill")
|
|
skill = Skill(
|
|
frontmatter=fm, path=tmp_path, disclosure_level=METADATA
|
|
)
|
|
ctx = format_skill_context(skill)
|
|
assert '<skill name="test-skill">' in ctx
|
|
assert "A skill" in ctx
|
|
|
|
def test_instructions_level(self, tmp_path: Path) -> None:
|
|
fm = SkillFrontmatter(name="test-skill", description="A skill")
|
|
skill = Skill(
|
|
frontmatter=fm,
|
|
path=tmp_path,
|
|
disclosure_level=INSTRUCTIONS,
|
|
instructions="Do these things.",
|
|
)
|
|
ctx = format_skill_context(skill)
|
|
assert '<skill name="test-skill">' in ctx
|
|
assert "Do these things." in ctx
|
|
|
|
def test_no_instructions_at_instructions_level(self, tmp_path: Path) -> None:
|
|
fm = SkillFrontmatter(name="test-skill", description="A skill")
|
|
skill = Skill(
|
|
frontmatter=fm,
|
|
path=tmp_path,
|
|
disclosure_level=INSTRUCTIONS,
|
|
instructions=None,
|
|
)
|
|
ctx = format_skill_context(skill)
|
|
assert ctx == '<skill name="test-skill">\nA skill\n</skill>'
|
|
|
|
def test_resources_level(self, tmp_path: Path) -> None:
|
|
fm = SkillFrontmatter(name="test-skill", description="A skill")
|
|
skill = Skill(
|
|
frontmatter=fm,
|
|
path=tmp_path,
|
|
disclosure_level=RESOURCES,
|
|
instructions="Do things.",
|
|
resource_files={
|
|
"scripts": ["run.sh"],
|
|
"assets": ["data.json", "config.yaml"],
|
|
},
|
|
)
|
|
ctx = format_skill_context(skill)
|
|
assert "### Available Resources" in ctx
|
|
assert "**assets/**: data.json, config.yaml" in ctx
|
|
assert "**scripts/**: run.sh" in ctx
|
|
|
|
def test_resources_level_empty_files(self, tmp_path: Path) -> None:
|
|
fm = SkillFrontmatter(name="test-skill", description="A skill")
|
|
skill = Skill(
|
|
frontmatter=fm,
|
|
path=tmp_path,
|
|
disclosure_level=RESOURCES,
|
|
instructions="Do things.",
|
|
resource_files={},
|
|
)
|
|
ctx = format_skill_context(skill)
|
|
assert "### Available Resources" not in ctx
|