mirror of
https://github.com/crewAIInc/crewAI.git
synced 2026-07-22 15:25:09 +00:00
feat(skills)!: promote Skills Repository out of experimental (#6579)
* 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>
This commit is contained in:
@@ -686,20 +686,9 @@ def tool_publish(is_public: bool, force: bool) -> None:
|
||||
tool_cmd.publish(is_public, force)
|
||||
|
||||
|
||||
@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")
|
||||
@crewai.group(name="skill")
|
||||
def skill() -> None:
|
||||
"""Skill Repository related commands (experimental)."""
|
||||
"""Create, publish, and install agent skills."""
|
||||
|
||||
|
||||
@skill.command(name="create")
|
||||
@@ -713,7 +702,7 @@ def skill() -> None:
|
||||
help="Create skill in current dir instead of ./skills/",
|
||||
)
|
||||
def skill_create(name: str, in_project: bool) -> None:
|
||||
from crewai_cli.experimental.skills.main import SkillCommand
|
||||
from crewai_cli.skills.main import SkillCommand
|
||||
|
||||
skill_cmd = SkillCommand()
|
||||
skill_cmd.create(name, in_project=in_project)
|
||||
@@ -722,7 +711,7 @@ def skill_create(name: str, in_project: bool) -> None:
|
||||
@skill.command(name="install")
|
||||
@click.argument("ref")
|
||||
def skill_install(ref: str) -> None:
|
||||
from crewai_cli.experimental.skills.main import SkillCommand
|
||||
from crewai_cli.skills.main import SkillCommand
|
||||
|
||||
skill_cmd = SkillCommand()
|
||||
skill_cmd.install(ref)
|
||||
@@ -736,20 +725,19 @@ def skill_install(ref: str) -> None:
|
||||
show_default=True,
|
||||
help="Skip git-state validation.",
|
||||
)
|
||||
@click.option("--public", "is_public", flag_value=True, default=False)
|
||||
@click.option("--private", "is_public", flag_value=False)
|
||||
@click.option("--org", default=None, help="Organisation slug (overrides settings).")
|
||||
def skill_publish(is_public: bool, org: str | None, force: bool) -> None:
|
||||
from crewai_cli.experimental.skills.main import SkillCommand
|
||||
def skill_publish(org: str | None, force: bool) -> None:
|
||||
"""Publish the skill in the current directory, scoped to your organization."""
|
||||
from crewai_cli.skills.main import SkillCommand
|
||||
|
||||
skill_cmd = SkillCommand()
|
||||
skill_cmd.publish(is_public, org=org, force=force)
|
||||
skill_cmd.publish(org=org, force=force)
|
||||
|
||||
|
||||
@skill.command(name="list")
|
||||
def skill_list() -> None:
|
||||
"""List locally installed skills."""
|
||||
from crewai_cli.experimental.skills.main import SkillCommand
|
||||
from crewai_cli.skills.main import SkillCommand
|
||||
|
||||
skill_cmd = SkillCommand()
|
||||
skill_cmd.list_cached()
|
||||
|
||||
@@ -13,6 +13,7 @@ import zipfile
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from crewai_cli import git
|
||||
from crewai_cli.command import BaseCommand, PlusAPIMixin
|
||||
from crewai_cli.config import Settings
|
||||
from crewai_cli.constants import DEFAULT_CREWAI_ENTERPRISE_URL
|
||||
@@ -63,7 +64,7 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
|
||||
(skill_dir / "assets").mkdir()
|
||||
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
skill_md.write_text(_SKILL_MD_TEMPLATE.format(name=name))
|
||||
skill_md.write_text(_SKILL_MD_TEMPLATE.format(name=name), encoding="utf-8")
|
||||
|
||||
console.print(
|
||||
f"[green]Created skill [bold]{name}[/bold] at [bold]{skill_dir}[/bold].[/green]"
|
||||
@@ -148,7 +149,7 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
|
||||
)
|
||||
else:
|
||||
try:
|
||||
from crewai.experimental.skills.cache import SkillCacheManager
|
||||
from crewai.skills.cache import SkillCacheManager
|
||||
|
||||
cache = SkillCacheManager()
|
||||
cache.store(org, name, version, archive_bytes)
|
||||
@@ -170,13 +171,19 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
|
||||
"version": version,
|
||||
"installed_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
}
|
||||
(cache_dir / ".crewai_meta.json").write_text(json.dumps(meta, indent=2))
|
||||
(cache_dir / ".crewai_meta.json").write_text(
|
||||
json.dumps(meta, indent=2), encoding="utf-8"
|
||||
)
|
||||
console.print(
|
||||
f"[green]Installed [bold]{ref}[/bold]{' (' + version + ')' if version else ''} to global cache.[/green]"
|
||||
)
|
||||
|
||||
def publish(self, is_public: bool, org: str | None, force: bool = False) -> None:
|
||||
"""Publish the skill in the current directory to the registry."""
|
||||
def publish(self, org: str | None = None, force: bool = False) -> None:
|
||||
"""Publish the skill in the current directory to the registry.
|
||||
|
||||
Skills are always scoped to the publishing organization; there is no
|
||||
public visibility option.
|
||||
"""
|
||||
skill_md = Path("SKILL.md")
|
||||
if not skill_md.exists():
|
||||
console.print(
|
||||
@@ -185,8 +192,44 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
if not force:
|
||||
try:
|
||||
repository = git.Repository(fetch=False)
|
||||
except ValueError as exc:
|
||||
if "not a Git repository" not in str(exc):
|
||||
console.print(
|
||||
f"[red]Unable to validate git state: {exc}\n"
|
||||
"Fix the issue or pass --force to skip this check.[/red]"
|
||||
)
|
||||
raise SystemExit(1) from exc
|
||||
# Standalone skill directories may live outside any git repo;
|
||||
# there is no git state to validate in that case.
|
||||
repository = None
|
||||
if repository is not None:
|
||||
try:
|
||||
# Refresh remote-tracking refs so is_synced() compares
|
||||
# against the actual remote, not stale local state.
|
||||
repository.fetch()
|
||||
except ValueError as exc:
|
||||
console.print(
|
||||
f"[red]Unable to validate git state: {exc}\n"
|
||||
"Fix the issue or pass --force to skip this check.[/red]"
|
||||
)
|
||||
raise SystemExit(1) from exc
|
||||
if repository is not None and not repository.is_synced():
|
||||
console.print(
|
||||
"[bold red]Failed to publish skill.[/bold red]\n"
|
||||
"Local changes need to be resolved before publishing. Please do the following:\n"
|
||||
"* [bold]Commit[/bold] your changes.\n"
|
||||
"* [bold]Push[/bold] to sync with the remote.\n"
|
||||
"* [bold]Pull[/bold] the latest changes from the remote.\n"
|
||||
"\nOnce your repository is up-to-date, retry publishing the skill "
|
||||
"(or pass --force to skip this check)."
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
try:
|
||||
frontmatter = self._parse_frontmatter(skill_md.read_text())
|
||||
frontmatter = self._parse_frontmatter(skill_md.read_text(encoding="utf-8"))
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Failed to parse SKILL.md frontmatter: {exc}[/red]")
|
||||
raise SystemExit(1) from exc
|
||||
@@ -233,7 +276,7 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
|
||||
org=effective_org,
|
||||
name=name,
|
||||
version=version,
|
||||
is_public=is_public,
|
||||
is_public=False,
|
||||
description=description,
|
||||
encoded_file=encoded_file,
|
||||
)
|
||||
@@ -277,7 +320,7 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
|
||||
meta_file = skill_dir / ".crewai_meta.json"
|
||||
if meta_file.exists():
|
||||
try:
|
||||
meta = json.loads(meta_file.read_text())
|
||||
meta = json.loads(meta_file.read_text(encoding="utf-8"))
|
||||
table.add_row(
|
||||
"cache",
|
||||
f"@{meta['org']}/{meta['name']}",
|
||||
@@ -368,7 +411,7 @@ class SkillCommand(BaseCommand, PlusAPIMixin):
|
||||
def _read_version(self, skill_md: Path) -> str | None:
|
||||
"""Read the version from a SKILL.md file's metadata, or None."""
|
||||
try:
|
||||
fm = self._parse_frontmatter(skill_md.read_text())
|
||||
fm = self._parse_frontmatter(skill_md.read_text(encoding="utf-8"))
|
||||
raw_metadata = fm.get("metadata")
|
||||
if isinstance(raw_metadata, dict):
|
||||
return raw_metadata.get("version")
|
||||
63
lib/cli/tests/skills/test_cli_commands.py
Normal file
63
lib/cli/tests/skills/test_cli_commands.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Tests for the top-level `crewai skill` command group.
|
||||
|
||||
The Skills Repository graduated from the gated `crewai experimental skill`
|
||||
group: the commands must be reachable at `crewai skill ...` without
|
||||
CREWAI_EXPERIMENTAL set.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
from click.testing import CliRunner
|
||||
from crewai_cli.cli import crewai
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def runner():
|
||||
return CliRunner()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_experimental_env(monkeypatch):
|
||||
monkeypatch.delenv("CREWAI_EXPERIMENTAL", raising=False)
|
||||
|
||||
|
||||
class TestSkillGroupIsTopLevel:
|
||||
def test_skill_group_registered_at_top_level(self, runner, no_experimental_env):
|
||||
result = runner.invoke(crewai, ["skill", "--help"])
|
||||
assert result.exit_code == 0, result.output
|
||||
for subcommand in ("create", "install", "publish", "list"):
|
||||
assert subcommand in result.output
|
||||
|
||||
def test_skill_group_not_gated_by_experimental_env(
|
||||
self, runner, no_experimental_env
|
||||
):
|
||||
with mock.patch("crewai_cli.skills.main.SkillCommand") as mock_cmd:
|
||||
result = runner.invoke(crewai, ["skill", "create", "my-skill"])
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_cmd.return_value.create.assert_called_once_with(
|
||||
"my-skill", in_project=True
|
||||
)
|
||||
|
||||
def test_skill_create_no_project_flag(self, runner, no_experimental_env):
|
||||
with mock.patch("crewai_cli.skills.main.SkillCommand") as mock_cmd:
|
||||
result = runner.invoke(crewai, ["skill", "create", "my-skill", "--no-project"])
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_cmd.return_value.create.assert_called_once_with(
|
||||
"my-skill", in_project=False
|
||||
)
|
||||
|
||||
|
||||
class TestSkillPublishIsOrgScopedOnly:
|
||||
def test_publish_rejects_public_flag(self, runner, no_experimental_env):
|
||||
result = runner.invoke(crewai, ["skill", "publish", "--public"])
|
||||
assert result.exit_code != 0
|
||||
assert "No such option" in result.output
|
||||
|
||||
def test_publish_passes_org_and_force_only(self, runner, no_experimental_env):
|
||||
with mock.patch("crewai_cli.skills.main.SkillCommand") as mock_cmd:
|
||||
result = runner.invoke(
|
||||
crewai, ["skill", "publish", "--org", "acme", "--force"]
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_cmd.return_value.publish.assert_called_once_with(org="acme", force=True)
|
||||
@@ -36,7 +36,7 @@ def skill_command():
|
||||
TokenManager().save_tokens(
|
||||
"test-token", (datetime.now() + timedelta(seconds=36000)).timestamp()
|
||||
)
|
||||
from crewai_cli.experimental.skills.main import SkillCommand
|
||||
from crewai_cli.skills.main import SkillCommand
|
||||
cmd = SkillCommand()
|
||||
yield cmd
|
||||
|
||||
@@ -113,7 +113,7 @@ 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")
|
||||
skill_command.publish(org="acme")
|
||||
|
||||
def test_publish_missing_version(self, skill_command):
|
||||
with in_temp_dir():
|
||||
@@ -121,7 +121,7 @@ class TestSkillPublish:
|
||||
"---\nname: my-skill\ndescription: Test.\n---\nInstructions."
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.publish(is_public=True, org="acme")
|
||||
skill_command.publish(org="acme")
|
||||
|
||||
def test_publish_missing_name(self, skill_command):
|
||||
with in_temp_dir():
|
||||
@@ -129,7 +129,7 @@ class TestSkillPublish:
|
||||
"---\ndescription: Test.\nversion: 1.0.0\n---\nInstructions."
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.publish(is_public=True, org="acme")
|
||||
skill_command.publish(org="acme")
|
||||
|
||||
def test_publish_no_org(self, skill_command):
|
||||
with in_temp_dir():
|
||||
@@ -142,11 +142,11 @@ class TestSkillPublish:
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.json.return_value = {}
|
||||
mock_client.publish_skill.return_value = mock_resp
|
||||
with patch("crewai_cli.experimental.skills.main.Settings") as mock_settings_cls:
|
||||
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)
|
||||
skill_command.publish(org=None)
|
||||
|
||||
def test_publish_calls_api(self, skill_command):
|
||||
with in_temp_dir():
|
||||
@@ -158,16 +158,102 @@ class TestSkillPublish:
|
||||
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.experimental.skills.main.Settings") as mock_settings_cls:
|
||||
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.publish(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"
|
||||
# Skills are always org-scoped; there is no public visibility.
|
||||
assert call_kwargs.kwargs["is_public"] is False
|
||||
|
||||
def test_publish_blocked_when_git_not_synced(self, skill_command):
|
||||
with in_temp_dir():
|
||||
Path("SKILL.md").write_text(
|
||||
"---\nname: my-skill\ndescription: A test skill.\nmetadata:\n version: 1.0.0\n---\nInstructions."
|
||||
)
|
||||
skill_command.plus_api_client.publish_skill = MagicMock()
|
||||
with patch("crewai_cli.skills.main.git.Repository") as mock_repo_cls:
|
||||
mock_repo_cls.return_value.is_synced.return_value = False
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.publish(org="acme")
|
||||
skill_command.plus_api_client.publish_skill.assert_not_called()
|
||||
|
||||
def test_publish_force_skips_git_check(self, skill_command):
|
||||
with in_temp_dir():
|
||||
Path("SKILL.md").write_text(
|
||||
"---\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.git.Repository") as mock_repo_cls,
|
||||
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(org="acme", force=True)
|
||||
mock_repo_cls.assert_not_called()
|
||||
skill_command.plus_api_client.publish_skill.assert_called_once()
|
||||
|
||||
def test_publish_blocked_when_fetch_fails(self, skill_command):
|
||||
"""A failing remote fetch must fail closed, not silently skip syncing."""
|
||||
with in_temp_dir():
|
||||
Path("SKILL.md").write_text(
|
||||
"---\nname: my-skill\ndescription: A test skill.\nmetadata:\n version: 1.0.0\n---\nInstructions."
|
||||
)
|
||||
skill_command.plus_api_client.publish_skill = MagicMock()
|
||||
with patch("crewai_cli.skills.main.git.Repository") as mock_repo_cls:
|
||||
mock_repo_cls.return_value.fetch.side_effect = ValueError(
|
||||
"Git fetch failed with exit code 128"
|
||||
)
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.publish(org="acme")
|
||||
skill_command.plus_api_client.publish_skill.assert_not_called()
|
||||
|
||||
def test_publish_blocked_when_git_state_cannot_be_validated(self, skill_command):
|
||||
"""Git errors other than 'not a repo' must fail closed, not skip the check."""
|
||||
with in_temp_dir():
|
||||
Path("SKILL.md").write_text(
|
||||
"---\nname: my-skill\ndescription: A test skill.\nmetadata:\n version: 1.0.0\n---\nInstructions."
|
||||
)
|
||||
skill_command.plus_api_client.publish_skill = MagicMock()
|
||||
with patch(
|
||||
"crewai_cli.skills.main.git.Repository",
|
||||
side_effect=ValueError("Git fetch failed with exit code 128"),
|
||||
):
|
||||
with pytest.raises(SystemExit):
|
||||
skill_command.publish(org="acme")
|
||||
skill_command.plus_api_client.publish_skill.assert_not_called()
|
||||
|
||||
def test_publish_proceeds_outside_git_repo(self, skill_command):
|
||||
with in_temp_dir():
|
||||
Path("SKILL.md").write_text(
|
||||
"---\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.git.Repository",
|
||||
side_effect=ValueError("not a Git repository"),
|
||||
),
|
||||
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(org="acme")
|
||||
skill_command.plus_api_client.publish_skill.assert_called_once()
|
||||
|
||||
|
||||
class TestSkillListCached:
|
||||
@@ -14,7 +14,7 @@ from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from crewai_cli.experimental.skills.main import _safe_extractall
|
||||
from crewai_cli.skills.main import _safe_extractall
|
||||
|
||||
|
||||
def _tar_from_members(build) -> tarfile.TarFile:
|
||||
|
||||
Reference in New Issue
Block a user