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:
João Moura
2026-07-17 11:21:15 -03:00
committed by GitHub
parent 4e23bf6d45
commit f0704ebb22
28 changed files with 604 additions and 143 deletions

View File

@@ -24,15 +24,23 @@ mode: "wide"
## البداية السريعة
### 1. إنشاء مجلد المهارة
### 1. إنشاء مهارة باستخدام سطر الأوامر (CLI)
واجهة سطر الأوامر هي الطريقة المدعومة لإنشاء مهارة — فهي تُنشئ لك هيكل المجلد وملف `SKILL.md` صالحًا:
```shell Terminal
crewai skill create code-review
```
داخل مشروع طاقم (حيث يوجد `pyproject.toml`) يُنشئ هذا الأمر `./skills/code-review/`؛ وخارج المشروع يُنشئ `./code-review/` في المجلد الحالي (يمكنك فرض هذا السلوك باستخدام `--no-project`):
```
skills/
└── code-review/
├── SKILL.md # مطلوب — التعليمات
├── references/ # اختياري — مستندات مرجعية
│ └── style-guide.md
└── scripts/ # اختياري — سكربتات قابلة للتنفيذ
├── SKILL.md # Required — instructions (pre-filled template)
├── references/ # Optional — reference docs
├── scripts/ # Optional — executable scripts
└── assets/ # Optional — static files
```
### 2. كتابة SKILL.md الخاص بك
@@ -164,6 +172,65 @@ agent = Agent(
---
## إنشاء المهارات ونشرها وتثبيتها
للمهارات دورة حياة كاملة تُدار عبر واجهة سطر الأوامر: **أنشئها باستخدام `crewai skill create`، وانشرها باستخدام `crewai skill publish`** — إنشاء المجلدات يدويًا يصلح للتجارب المحلية، لكن واجهة سطر الأوامر هي سير العمل المقصود، وهي تحافظ على صحة هيكل المهارة وبياناتها الوصفية.
### الإنشاء
```shell Terminal
crewai skill create my-skill
```
يُنشئ هذا الأمر المجلد (داخل `./skills/` في مشروع الطاقم) مع قالب `SKILL.md`، بالإضافة إلى مجلدات فارغة `scripts/` و `references/` و `assets/`. عدّل `SKILL.md` لتعريف التعليمات.
### النشر
نفّذ الأمر من داخل مجلد المهارة (حيث يوجد `SKILL.md`):
```shell Terminal
cd skills/my-skill
crewai skill publish
```
يقرأ النشر الحقول `name` و `description` و `metadata.version` من البيانات الوصفية في مقدمة `SKILL.md` ويدفع المهارة إلى سجل CrewAI. **المهارات المنشورة تكون دائمًا مقيّدة بنطاق مؤسستك** — مثل الأدوات، لا يستطيع رؤيتها وتثبيتها إلا أعضاء المؤسسة الناشرة؛ ولا توجد رؤية عامة. أعلام مفيدة:
| العلم | التأثير |
| :--- | :--- |
| `--org <slug>` | النشر تحت مؤسسة محددة (يتجاوز الإعدادات). |
| `--force` | تخطي التحقق من حالة git (تغييرات غير مُثبتة، إلخ). |
### التثبيت
ثبّت مهارة منشورة عبر مرجعها `@org/name`:
```shell Terminal
crewai skill install @acme/code-review
```
داخل مشروع الطاقم تُثبَّت المهارة في `./skills/{name}/`؛ وخارج المشروع تذهب إلى ذاكرة التخزين المؤقتة المشتركة في `~/.crewai/skills/{org}/{name}/`.
يمكن للوكلاء أيضًا الإشارة إلى مهارات السجل مباشرة — يتم حلّها من ذاكرة التخزين المؤقتة المحلية (أو من مجلد `skills/` في المشروع) وقت التشغيل:
```python
agent = Agent(
role="Senior Code Reviewer",
goal="Review pull requests for quality and security issues",
backstory="Staff engineer with expertise in secure coding practices.",
skills=["@acme/code-review"], # registry ref, resolved locally
)
```
### عرض القائمة
```shell Terminal
crewai skill list
```
يعرض المهارات المثبّتة من مجلد المشروع `./skills/` ومن ذاكرة التخزين المؤقتة العامة معًا، مع إصداراتها ومساراتها.
---
## المهارات على مستوى الطاقم
يمكن تعيين المهارات على الطاقم لتُطبّق على **جميع الوكلاء**:

View File

@@ -24,15 +24,23 @@ You often need **both**: skills for expertise, tools for action. They are config
## Quick Start
### 1. Create a Skill Directory
### 1. Create a Skill with the CLI
The CLI is the supported way to create a skill — it scaffolds the directory layout and a valid `SKILL.md` for you:
```shell Terminal
crewai skill create code-review
```
Inside a crew project (where `pyproject.toml` lives) this creates `./skills/code-review/`; outside a project it creates `./code-review/` in the current directory (you can force that behavior with `--no-project`):
```
skills/
└── code-review/
├── SKILL.md # Required — instructions
├── SKILL.md # Required — instructions (pre-filled template)
├── references/ # Optional — reference docs
│ └── style-guide.md
└── scripts/ # Optional — executable scripts
├── scripts/ # Optional — executable scripts
└── assets/ # Optional — static files
```
### 2. Write Your SKILL.md
@@ -164,6 +172,65 @@ agent = Agent(
---
## Creating, Publishing, and Installing Skills
Skills have a full lifecycle managed by the CLI: **create them with `crewai skill create`, publish them with `crewai skill publish`** — hand-rolling directories works for local experiments, but the CLI is the intended workflow and keeps your skill layout and frontmatter valid.
### Create
```shell Terminal
crewai skill create my-skill
```
Scaffolds the directory (into `./skills/` inside a crew project) with a template `SKILL.md`, plus empty `scripts/`, `references/`, and `assets/` directories. Edit `SKILL.md` to define the instructions.
### Publish
Run from inside the skill directory (where `SKILL.md` is):
```shell Terminal
cd skills/my-skill
crewai skill publish
```
Publishing reads `name`, `description`, and `metadata.version` from the `SKILL.md` frontmatter and pushes the skill to the CrewAI registry. **Published skills are always scoped to your organization** — like tools, only members of the publishing org can see and install them; there is no public visibility. Useful flags:
| Flag | Effect |
| :--- | :--- |
| `--org <slug>` | Publish under a specific organization (overrides settings). |
| `--force` | Skip git-state validation (uncommitted changes, etc.). |
### Install
Install a published skill by its `@org/name` reference:
```shell Terminal
crewai skill install @acme/code-review
```
Inside a crew project the skill lands in `./skills/{name}/`; outside a project it goes to the shared cache at `~/.crewai/skills/{org}/{name}/`.
Agents can also reference registry skills directly — they resolve from the local cache (or project `skills/` directory) at runtime:
```python
agent = Agent(
role="Senior Code Reviewer",
goal="Review pull requests for quality and security issues",
backstory="Staff engineer with expertise in secure coding practices.",
skills=["@acme/code-review"], # registry ref, resolved locally
)
```
### List
```shell Terminal
crewai skill list
```
Shows installed skills from both the project `./skills/` directory and the global cache, with their versions and paths.
---
## Crew-Level Skills
Skills can be set on a crew to apply to **all agents**:

View File

@@ -24,15 +24,23 @@ mode: "wide"
## 빠른 시작
### 1. 스킬 디렉터리 생성
### 1. CLI로 스킬 생성
CLI는 스킬을 생성하는 공식 지원 방식입니다 — 디렉터리 레이아웃과 유효한 `SKILL.md`를 자동으로 스캐폴딩해 줍니다:
```shell Terminal
crewai skill create code-review
```
크루 프로젝트 내부(`pyproject.toml`이 있는 곳)에서는 `./skills/code-review/`가 생성되고, 프로젝트 외부에서는 현재 디렉터리에 `./code-review/`가 생성됩니다 (`--no-project`로 이 동작을 강제할 수 있습니다):
```
skills/
└── code-review/
├── SKILL.md # 필수 — 지침
├── SKILL.md # 필수 — 지침 (미리 채워진 템플릿)
├── references/ # 선택 — 참조 문서
│ └── style-guide.md
└── scripts/ # 선택 — 실행 가능한 스크립트
├── scripts/ # 선택 — 실행 가능한 스크립트
└── assets/ # 선택 — 정적 파일
```
### 2. SKILL.md 작성
@@ -164,6 +172,65 @@ agent = Agent(
---
## 스킬 생성, 게시 및 설치
스킬은 CLI로 관리되는 전체 라이프사이클을 갖습니다: **`crewai skill create`로 생성하고, `crewai skill publish`로 게시하세요** — 디렉터리를 직접 만드는 방식도 로컬 실험에는 사용할 수 있지만, CLI가 의도된 워크플로우이며 스킬 레이아웃과 프론트매터를 유효하게 유지해 줍니다.
### 생성
```shell Terminal
crewai skill create my-skill
```
디렉터리를 스캐폴딩하고 (크루 프로젝트 내부에서는 `./skills/`에 생성) 템플릿 `SKILL.md`와 함께 빈 `scripts/`, `references/`, `assets/` 디렉터리를 만듭니다. `SKILL.md`를 편집하여 지침을 정의하세요.
### 게시
스킬 디렉터리 내부(`SKILL.md`가 있는 곳)에서 실행하세요:
```shell Terminal
cd skills/my-skill
crewai skill publish
```
게시 시 `SKILL.md` 프론트매터에서 `name`, `description`, `metadata.version`을 읽어 스킬을 CrewAI 레지스트리로 푸시합니다. **게시된 스킬은 항상 조직 범위로 제한됩니다** — 도구와 마찬가지로 게시한 조직의 멤버만 스킬을 보고 설치할 수 있으며, 공개 가시성은 없습니다. 유용한 플래그:
| 플래그 | 효과 |
| :--- | :--- |
| `--org <slug>` | 특정 조직으로 게시합니다 (설정을 재정의). |
| `--force` | git 상태 검증을 건너뜁니다 (커밋되지 않은 변경 사항 등). |
### 설치
게시된 스킬을 `@org/name` 참조로 설치합니다:
```shell Terminal
crewai skill install @acme/code-review
```
크루 프로젝트 내부에서는 스킬이 `./skills/{name}/`에 설치되고, 프로젝트 외부에서는 공유 캐시인 `~/.crewai/skills/{org}/{name}/`에 저장됩니다.
에이전트는 레지스트리 스킬을 직접 참조할 수도 있습니다 — 런타임에 로컬 캐시(또는 프로젝트 `skills/` 디렉터리)에서 해석됩니다:
```python
agent = Agent(
role="Senior Code Reviewer",
goal="Review pull requests for quality and security issues",
backstory="Staff engineer with expertise in secure coding practices.",
skills=["@acme/code-review"], # registry ref, resolved locally
)
```
### 목록 조회
```shell Terminal
crewai skill list
```
프로젝트 `./skills/` 디렉터리와 전역 캐시 양쪽에 설치된 스킬을 버전 및 경로와 함께 보여줍니다.
---
## 크루 레벨 스킬
스킬을 크루에 설정하여 **모든 에이전트**에 적용할 수 있습니다:

View File

@@ -24,15 +24,23 @@ Frequentemente você precisa de **ambos**: skills para expertise, ferramentas pa
## Início Rápido
### 1. Crie um Diretório de Skill
### 1. Crie uma Skill com a CLI
A CLI é a forma suportada de criar uma skill — ela gera a estrutura de diretórios e um `SKILL.md` válido para você:
```shell Terminal
crewai skill create code-review
```
Dentro de um projeto de crew (onde o `pyproject.toml` está) isso cria `./skills/code-review/`; fora de um projeto, cria `./code-review/` no diretório atual (você pode forçar esse comportamento com `--no-project`):
```
skills/
└── code-review/
├── SKILL.md # Obrigatório — instruções
├── SKILL.md # Obrigatório — instruções (template pré-preenchido)
├── references/ # Opcional — documentos de referência
│ └── style-guide.md
└── scripts/ # Opcional — scripts executáveis
├── scripts/ # Opcional — scripts executáveis
└── assets/ # Opcional — arquivos estáticos
```
### 2. Escreva seu SKILL.md
@@ -164,6 +172,65 @@ agent = Agent(
---
## Criando, Publicando e Instalando Skills
Skills têm um ciclo de vida completo gerenciado pela CLI: **crie-as com `crewai skill create`, publique-as com `crewai skill publish`** — criar diretórios à mão funciona para experimentos locais, mas a CLI é o fluxo de trabalho pretendido e mantém a estrutura e o frontmatter da sua skill válidos.
### Criar
```shell Terminal
crewai skill create my-skill
```
Gera o diretório (em `./skills/` dentro de um projeto de crew) com um `SKILL.md` de template, além dos diretórios vazios `scripts/`, `references/` e `assets/`. Edite o `SKILL.md` para definir as instruções.
### Publicar
Execute de dentro do diretório da skill (onde o `SKILL.md` está):
```shell Terminal
cd skills/my-skill
crewai skill publish
```
A publicação lê `name`, `description` e `metadata.version` do frontmatter do `SKILL.md` e envia a skill para o registro da CrewAI. **Skills publicadas são sempre escopadas à sua organização** — assim como ferramentas, apenas membros da organização que publicou podem vê-las e instalá-las; não há visibilidade pública. Flags úteis:
| Flag | Efeito |
| :--- | :--- |
| `--org <slug>` | Publica sob uma organização específica (sobrepõe as configurações). |
| `--force` | Pula a validação de estado do git (alterações não commitadas, etc.). |
### Instalar
Instale uma skill publicada pela sua referência `@org/name`:
```shell Terminal
crewai skill install @acme/code-review
```
Dentro de um projeto de crew, a skill é colocada em `./skills/{name}/`; fora de um projeto, vai para o cache compartilhado em `~/.crewai/skills/{org}/{name}/`.
Agentes também podem referenciar skills do registro diretamente — elas são resolvidas a partir do cache local (ou do diretório `skills/` do projeto) em tempo de execução:
```python
agent = Agent(
role="Senior Code Reviewer",
goal="Review pull requests for quality and security issues",
backstory="Staff engineer with expertise in secure coding practices.",
skills=["@acme/code-review"], # registry ref, resolved locally
)
```
### Listar
```shell Terminal
crewai skill list
```
Mostra as skills instaladas tanto do diretório `./skills/` do projeto quanto do cache global, com suas versões e caminhos.
---
## Skills no Nível do Crew
Skills podem ser definidas no crew para aplicar a **todos os agentes**:

View File

@@ -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()

View File

@@ -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")

View 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)

View File

@@ -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:

View File

@@ -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:

View File

@@ -1,12 +1,17 @@
"""Experimental Skills Repository — registry refs, global cache, downloads.
"""Deprecated location for the Skills Repository.
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`.
The registry-backed skills feature graduated out of experimental; it now
lives in `crewai.skills` alongside the filesystem-based loader. This module
re-exports the public names and aliases the old submodules so imports
written against the experimental namespace keep working, and will be
removed in a future release.
"""
from crewai.experimental.skills.cache import SkillCacheManager
from crewai.experimental.skills.registry import (
import sys
from crewai.skills import cache, events, registry
from crewai.skills.cache import SkillCacheManager
from crewai.skills.registry import (
SkillNotCachedError,
is_registry_ref,
parse_registry_ref,
@@ -14,10 +19,19 @@ from crewai.experimental.skills.registry import (
)
# Keep `crewai.experimental.skills.<module>` imports (and patch targets)
# resolving to the real modules in crewai.skills.
sys.modules[__name__ + ".cache"] = cache
sys.modules[__name__ + ".events"] = events
sys.modules[__name__ + ".registry"] = registry
__all__ = [
"SkillCacheManager",
"SkillNotCachedError",
"cache",
"events",
"is_registry_ref",
"parse_registry_ref",
"registry",
"resolve_registry_ref",
]

View File

@@ -1,24 +0,0 @@
"""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."
)

View File

@@ -1,8 +1,11 @@
"""Agent Skills standard implementation for crewAI.
Provides filesystem-based skill packaging with progressive disclosure.
Provides filesystem-based skill packaging with progressive disclosure, plus
the registry-backed Skills Repository (`@org/name` refs, global cache,
downloads).
"""
from crewai.skills.cache import SkillCacheManager
from crewai.skills.loader import (
activate_skill,
discover_skills,
@@ -11,14 +14,25 @@ from crewai.skills.loader import (
)
from crewai.skills.models import Skill, SkillFrontmatter
from crewai.skills.parser import SkillParseError
from crewai.skills.registry import (
SkillNotCachedError,
is_registry_ref,
parse_registry_ref,
resolve_registry_ref,
)
__all__ = [
"Skill",
"SkillCacheManager",
"SkillFrontmatter",
"SkillNotCachedError",
"SkillParseError",
"activate_skill",
"discover_skills",
"is_registry_ref",
"load_skill",
"load_skills",
"parse_registry_ref",
"resolve_registry_ref",
]

View File

@@ -88,7 +88,9 @@ class SkillCacheManager:
"version": version,
"installed_at": datetime.now(tz=timezone.utc).isoformat(),
}
(skill_dir / _META_FILENAME).write_text(json.dumps(meta, indent=2))
(skill_dir / _META_FILENAME).write_text(
json.dumps(meta, indent=2), encoding="utf-8"
)
return skill_dir
def list_cached(self) -> list[SkillMetadata]:
@@ -103,7 +105,9 @@ class SkillCacheManager:
meta_file = skill_dir / _META_FILENAME
if meta_file.exists():
try:
results.append(json.loads(meta_file.read_text()))
results.append(
json.loads(meta_file.read_text(encoding="utf-8"))
)
except (json.JSONDecodeError, KeyError):
_logger.debug(
"Skipping malformed cache entry: %s",

View File

@@ -164,7 +164,7 @@ def load_skill(
for s in discover_skills(skill, source=source)
]
if isinstance(skill, str) and skill.startswith("@"):
from crewai.experimental.skills.registry import resolve_registry_ref
from crewai.skills.registry import resolve_registry_ref
return [resolve_registry_ref(skill, source=source)]
if isinstance(skill, str) and skill.lstrip().startswith("---\n"):
@@ -202,7 +202,7 @@ def load_skills(
for skill in load_skill(skill_input, source=source):
dedup_key = skill.name
if isinstance(skill_input, str) and skill_input.startswith("@"):
from crewai.experimental.skills.registry import parse_registry_ref
from crewai.skills.registry import parse_registry_ref
org, _ = parse_registry_ref(skill_input)
dedup_key = f"{org}/{skill.name}"

View File

@@ -11,7 +11,7 @@ from pathlib import Path
import sys
from typing import Any
from crewai.experimental.skills.cache import SkillCacheManager
from crewai.skills.cache import SkillCacheManager
_logger = logging.getLogger(__name__)
@@ -100,11 +100,9 @@ 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
@@ -154,7 +152,7 @@ def download_skill(
try:
from crewai.events.event_bus import crewai_event_bus
from crewai.experimental.skills.events import (
from crewai.skills.events import (
SkillDownloadCompletedEvent,
SkillDownloadStartedEvent,
)

View File

@@ -1,6 +0,0 @@
import pytest
@pytest.fixture(autouse=True)
def _enable_experimental_skills(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("CREWAI_EXPERIMENTAL", "1")

View File

@@ -1,30 +0,0 @@
"""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()

View File

@@ -10,7 +10,7 @@ from pathlib import Path
import pytest
from crewai.experimental.skills.cache import SkillCacheManager, _safe_extractall
from crewai.skills.cache import SkillCacheManager, _safe_extractall
def _make_tar_gz(files: dict[str, str]) -> bytes:

View File

@@ -0,0 +1,43 @@
"""The old experimental namespace must keep re-exporting the public names."""
def test_experimental_namespace_reexports_public_names():
from crewai.experimental import skills as experimental_skills
from crewai.skills.cache import SkillCacheManager
from crewai.skills.registry import (
SkillNotCachedError,
is_registry_ref,
parse_registry_ref,
resolve_registry_ref,
)
assert experimental_skills.SkillCacheManager is SkillCacheManager
assert experimental_skills.SkillNotCachedError is SkillNotCachedError
assert experimental_skills.is_registry_ref is is_registry_ref
assert experimental_skills.parse_registry_ref is parse_registry_ref
assert experimental_skills.resolve_registry_ref is resolve_registry_ref
def test_experimental_submodule_imports_alias_real_modules():
"""Old submodule import style must resolve to the crewai.skills modules."""
import importlib
from crewai.experimental.skills import cache as shim_cache
from crewai.experimental.skills import events as shim_events
from crewai.experimental.skills import registry as shim_registry
from crewai.experimental.skills.cache import SkillCacheManager
from crewai.experimental.skills.registry import resolve_registry_ref
from crewai.skills import cache as real_cache
from crewai.skills import events as real_events
from crewai.skills import registry as real_registry
assert shim_cache is real_cache
assert shim_events is real_events
assert shim_registry is real_registry
assert SkillCacheManager is real_cache.SkillCacheManager
assert resolve_registry_ref is real_registry.resolve_registry_ref
# Dotted-path import resolves through sys.modules to the same module.
assert (
importlib.import_module("crewai.experimental.skills.registry")
is real_registry
)

View File

@@ -197,7 +197,7 @@ class TestLoadSkill:
}[ref]
monkeypatch.setattr(
"crewai.experimental.skills.registry.resolve_registry_ref",
"crewai.skills.registry.resolve_registry_ref",
resolve_registry_ref,
)

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
from crewai.experimental.skills.registry import (
from crewai.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.experimental.skills.registry._is_noninteractive", return_value=False),
patch("crewai.skills.registry._is_noninteractive", return_value=False),
patch.object(Path, "cwd", return_value=tmp_path),
patch("crewai.experimental.skills.registry.SkillCacheManager", return_value=mock_cache),
patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache),
):
from crewai.experimental.skills.registry import resolve_registry_ref
from crewai.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.experimental.skills.registry._is_noninteractive", return_value=True),
patch("crewai.skills.registry._is_noninteractive", return_value=True),
patch.object(Path, "cwd", return_value=tmp_path),
patch("crewai.experimental.skills.registry.SkillCacheManager", return_value=mock_cache),
patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache),
):
from crewai.experimental.skills.registry import resolve_registry_ref
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)
@@ -112,11 +112,11 @@ class TestResolveRegistryRef:
# tmp_path has no ./skills/ directory
with (
patch("crewai.experimental.skills.registry._is_noninteractive", return_value=False),
patch("crewai.skills.registry._is_noninteractive", return_value=False),
patch.object(Path, "cwd", return_value=tmp_path),
patch("crewai.experimental.skills.registry.SkillCacheManager", return_value=mock_cache),
patch("crewai.skills.registry.SkillCacheManager", return_value=mock_cache),
):
from crewai.experimental.skills.registry import resolve_registry_ref
from crewai.skills.registry import resolve_registry_ref
skill = resolve_registry_ref("@acme/cached-skill")
assert skill.name == "cached-skill"