Compare commits

...

3 Commits

Author SHA1 Message Date
lorenzejay
9d3d3a3942 linted 2026-04-13 16:23:21 -07:00
lorenzejay
c3c9698655 Merge branch 'main' of github.com:crewAIInc/crewAI into worktree-lorenze+feat+install-templates 2026-04-13 14:55:05 -07:00
lorenzejay
6f34db5b21 feat: add template management commands for project templates
- Introduced  command group to browse and install project templates.
- Added  command to display available templates.
- Implemented  command to install a selected template into the current directory.
- Created  class to handle template-related operations, including fetching templates from GitHub and managing installations.
- Enhanced telemetry to track template installations.
2026-04-13 14:51:55 -07:00
6 changed files with 574 additions and 0 deletions

View File

@@ -18,6 +18,7 @@ from crewai.cli.install_crew import install_crew
from crewai.cli.kickoff_flow import kickoff_flow
from crewai.cli.organization.main import OrganizationCommand
from crewai.cli.plot_flow import plot_flow
from crewai.cli.remote_template.main import TemplateCommand
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
@@ -496,6 +497,33 @@ def tool_publish(is_public: bool, force: bool) -> None:
tool_cmd.publish(is_public, force)
@crewai.group()
def template() -> None:
"""Browse and install project templates."""
@template.command(name="list")
def template_list() -> None:
"""List available templates and select one to install."""
template_cmd = TemplateCommand()
template_cmd.list_templates()
@template.command(name="add")
@click.argument("name")
@click.option(
"-o",
"--output-dir",
type=str,
default=None,
help="Directory name for the template (defaults to template name)",
)
def template_add(name: str, output_dir: str | None) -> None:
"""Add a template to the current directory."""
template_cmd = TemplateCommand()
template_cmd.add_template(name, output_dir)
@crewai.group()
def flow() -> None:
"""Flow related commands."""

View File

@@ -0,0 +1,248 @@
import io
import logging
import os
import shutil
from typing import Any
import zipfile
import click
import httpx
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from crewai.cli.command import BaseCommand
logger = logging.getLogger(__name__)
console = Console()
GITHUB_ORG = "crewAIInc"
TEMPLATE_PREFIX = "template_"
GITHUB_API_BASE = "https://api.github.com"
BANNER = """\
[bold white] ██████╗██████╗ ███████╗██╗ ██╗[/bold white] [bold red] █████╗ ██╗[/bold red]
[bold white]██╔════╝██╔══██╗██╔════╝██║ ██║[/bold white] [bold red]██╔══██╗██║[/bold red]
[bold white]██║ ██████╔╝█████╗ ██║ █╗ ██║[/bold white] [bold red]███████║██║[/bold red]
[bold white]██║ ██╔══██╗██╔══╝ ██║███╗██║[/bold white] [bold red]██╔══██║██║[/bold red]
[bold white]╚██████╗██║ ██║███████╗╚███╔███╔╝[/bold white] [bold red]██║ ██║██║[/bold red]
[bold white] ╚═════╝╚═╝ ╚═╝╚══════╝ ╚══╝╚══╝[/bold white] [bold red]╚═╝ ╚═╝╚═╝[/bold red]
[dim white]████████╗███████╗███╗ ███╗██████╗ ██╗ █████╗ ████████╗███████╗███████╗[/dim white]
[dim white]╚══██╔══╝██╔════╝████╗ ████║██╔══██╗██║ ██╔══██╗╚══██╔══╝██╔════╝██╔════╝[/dim white]
[dim white] ██║ █████╗ ██╔████╔██║██████╔╝██║ ███████║ ██║ █████╗ ███████╗[/dim white]
[dim white] ██║ ██╔══╝ ██║╚██╔╝██║██╔═══╝ ██║ ██╔══██║ ██║ ██╔══╝ ╚════██║[/dim white]
[dim white] ██║ ███████╗██║ ╚═╝ ██║██║ ███████╗██║ ██║ ██║ ███████╗███████║[/dim white]
[dim white] ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝ ╚═╝ ╚══════╝╚══════╝[/dim white]"""
class TemplateCommand(BaseCommand):
"""Handle template-related operations for CrewAI projects."""
def __init__(self) -> None:
super().__init__()
def list_templates(self) -> None:
"""List available templates with an interactive selector to install."""
templates = self._fetch_templates()
if not templates:
click.echo("No templates found.")
return
console.print(f"\n{BANNER}\n")
console.print(" [on cyan] templates [/on cyan]\n")
console.print(f" [green]o[/green] Source: https://github.com/{GITHUB_ORG}")
console.print(
f" [green]o[/green] Found [bold]{len(templates)}[/bold] templates\n"
)
console.print(" [green]o[/green] Select a template to install")
for idx, repo in enumerate(templates, start=1):
name = repo["name"].removeprefix(TEMPLATE_PREFIX)
description = repo.get("description") or ""
if description:
console.print(
f" [bold cyan]{idx}.[/bold cyan] [bold white]{name}[/bold white] [dim]({description})[/dim]"
)
else:
console.print(
f" [bold cyan]{idx}.[/bold cyan] [bold white]{name}[/bold white]"
)
console.print(" [bold cyan]q.[/bold cyan] [dim]Quit[/dim]\n")
while True:
choice = click.prompt("Enter your choice", type=str)
if choice.lower() == "q":
return
try:
selected_index = int(choice) - 1
if 0 <= selected_index < len(templates):
break
except ValueError:
pass
click.secho(
f"Please enter a number between 1 and {len(templates)}, or 'q' to quit.",
fg="yellow",
)
selected = templates[selected_index]
repo_name = selected["name"]
template_name = repo_name.removeprefix(TEMPLATE_PREFIX)
self.add_template(template_name)
def add_template(self, name: str, output_dir: str | None = None) -> None:
"""Download a template and copy it into the current working directory.
Args:
name: Template name (with or without the template_ prefix).
output_dir: Optional directory name. Defaults to the template name.
"""
repo_name = self._resolve_repo_name(name)
if repo_name is None:
click.secho(f"Template '{name}' not found.", fg="red")
click.echo("Run 'crewai template list' to see available templates.")
raise SystemExit(1)
folder_name = output_dir or repo_name.removeprefix(TEMPLATE_PREFIX)
dest = os.path.join(os.getcwd(), folder_name)
while os.path.exists(dest):
click.secho(f"Directory '{folder_name}' already exists.", fg="yellow")
folder_name = click.prompt(
"Enter a different directory name (or 'q' to quit)", type=str
)
if folder_name.lower() == "q":
return
dest = os.path.join(os.getcwd(), folder_name)
click.echo(
f"Downloading template '{repo_name.removeprefix(TEMPLATE_PREFIX)}'..."
)
zip_bytes = self._download_zip(repo_name)
self._extract_zip(zip_bytes, dest)
try:
from crewai.telemetry import Telemetry
telemetry = Telemetry()
telemetry.set_tracer()
telemetry.template_installed_span(repo_name.removeprefix(TEMPLATE_PREFIX))
except Exception:
logger.debug("Failed to record template install telemetry")
console.print(
f"\n [green]\u2713[/green] Installed template [bold white]{folder_name}[/bold white]"
f" [dim](source: github.com/{GITHUB_ORG}/{repo_name})[/dim]\n"
)
next_steps = Text()
next_steps.append(f" cd {folder_name}\n", style="bold white")
next_steps.append(" crewai install", style="bold white")
panel = Panel(
next_steps,
title="[green]\u25c7 Next steps[/green]",
title_align="left",
border_style="dim",
padding=(1, 2),
)
console.print(panel)
def _fetch_templates(self) -> list[dict[str, Any]]:
"""Fetch all template repos from the GitHub org."""
templates: list[dict[str, Any]] = []
page = 1
while True:
url = f"{GITHUB_API_BASE}/orgs/{GITHUB_ORG}/repos"
params: dict[str, str | int] = {
"per_page": 100,
"page": page,
"type": "public",
}
try:
response = httpx.get(url, params=params, timeout=15)
response.raise_for_status()
except httpx.HTTPError as e:
click.secho(f"Failed to fetch templates from GitHub: {e}", fg="red")
raise SystemExit(1) from e
repos = response.json()
if not repos:
break
templates.extend(
repo
for repo in repos
if repo["name"].startswith(TEMPLATE_PREFIX) and not repo.get("private")
)
page += 1
templates.sort(key=lambda r: r["name"])
return templates
def _resolve_repo_name(self, name: str) -> str | None:
"""Resolve user input to a full repo name, or None if not found."""
# Accept both 'deep_research' and 'template_deep_research'
candidates = [
f"{TEMPLATE_PREFIX}{name}"
if not name.startswith(TEMPLATE_PREFIX)
else name,
name,
]
templates = self._fetch_templates()
template_names = {t["name"] for t in templates}
for candidate in candidates:
if candidate in template_names:
return candidate
return None
def _download_zip(self, repo_name: str) -> bytes:
"""Download the default branch zipball for a repo."""
url = f"{GITHUB_API_BASE}/repos/{GITHUB_ORG}/{repo_name}/zipball"
try:
response = httpx.get(url, follow_redirects=True, timeout=60)
response.raise_for_status()
except httpx.HTTPError as e:
click.secho(f"Failed to download template: {e}", fg="red")
raise SystemExit(1) from e
return response.content
def _extract_zip(self, zip_bytes: bytes, dest: str) -> None:
"""Extract a GitHub zipball into dest, stripping the top-level directory."""
with zipfile.ZipFile(io.BytesIO(zip_bytes)) as zf:
# GitHub zipballs have a single top-level dir like 'crewAIInc-template_xxx-<sha>/'
members = zf.namelist()
if not members:
click.secho("Downloaded archive is empty.", fg="red")
raise SystemExit(1)
top_dir = members[0].split("/")[0] + "/"
os.makedirs(dest, exist_ok=True)
for member in members:
if member == top_dir or not member.startswith(top_dir):
continue
relative_path = member[len(top_dir) :]
if not relative_path:
continue
target = os.path.join(dest, relative_path)
if member.endswith("/"):
os.makedirs(target, exist_ok=True)
else:
os.makedirs(os.path.dirname(target), exist_ok=True)
with zf.open(member) as src, open(target, "wb") as dst:
shutil.copyfileobj(src, dst)

View File

@@ -1058,3 +1058,20 @@ class Telemetry:
close_span(span)
self._safe_telemetry_operation(_operation)
def template_installed_span(self, template_name: str) -> None:
"""Records when a template is downloaded and installed.
Args:
template_name: Name of the template that was installed
(without the template_ prefix).
"""
def _operation() -> None:
tracer = trace.get_tracer("crewai.telemetry")
span = tracer.start_span("Template Installed")
self._add_attribute(span, "crewai_version", version("crewai"))
self._add_attribute(span, "template_name", template_name)
close_span(span)
self._safe_telemetry_operation(_operation)

View File

@@ -0,0 +1,281 @@
import io
import os
import zipfile
from unittest.mock import MagicMock, patch
import httpx
import pytest
from click.testing import CliRunner
from crewai.cli.cli import template_add, template_list
from crewai.cli.remote_template.main import TemplateCommand
@pytest.fixture
def runner():
return CliRunner()
SAMPLE_REPOS = [
{"name": "template_deep_research", "description": "Deep research template", "private": False},
{"name": "template_pull_request_review", "description": "PR review template", "private": False},
{"name": "template_conversational_example", "description": "Conversational demo", "private": False},
{"name": "crewai", "description": "Main repo", "private": False},
{"name": "marketplace-crew-template", "description": "Marketplace", "private": False},
]
def _make_zipball(files: dict[str, str], top_dir: str = "crewAIInc-template_test-abc123") -> bytes:
"""Create an in-memory zipball mimicking GitHub's format."""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr(f"{top_dir}/", "")
for path, content in files.items():
zf.writestr(f"{top_dir}/{path}", content)
return buf.getvalue()
# --- CLI command tests ---
@patch("crewai.cli.cli.TemplateCommand")
def test_template_list_command(mock_cls, runner):
mock_instance = MagicMock()
mock_cls.return_value = mock_instance
result = runner.invoke(template_list)
assert result.exit_code == 0
mock_cls.assert_called_once()
mock_instance.list_templates.assert_called_once()
@patch("crewai.cli.cli.TemplateCommand")
def test_template_add_command(mock_cls, runner):
mock_instance = MagicMock()
mock_cls.return_value = mock_instance
result = runner.invoke(template_add, ["deep_research"])
assert result.exit_code == 0
mock_cls.assert_called_once()
mock_instance.add_template.assert_called_once_with("deep_research", None)
@patch("crewai.cli.cli.TemplateCommand")
def test_template_add_with_output_dir(mock_cls, runner):
mock_instance = MagicMock()
mock_cls.return_value = mock_instance
result = runner.invoke(template_add, ["deep_research", "-o", "my_project"])
assert result.exit_code == 0
mock_instance.add_template.assert_called_once_with("deep_research", "my_project")
# --- TemplateCommand unit tests ---
class TestTemplateCommand:
@pytest.fixture
def cmd(self):
with patch.object(TemplateCommand, "__init__", return_value=None):
return TemplateCommand()
@patch("crewai.cli.remote_template.main.httpx.get")
def test_fetch_templates_filters_by_prefix(self, mock_get, cmd):
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_REPOS
mock_response.raise_for_status = MagicMock()
# Return empty on page 2 to stop pagination
mock_empty = MagicMock()
mock_empty.json.return_value = []
mock_empty.raise_for_status = MagicMock()
mock_get.side_effect = [mock_response, mock_empty]
templates = cmd._fetch_templates()
assert len(templates) == 3
assert all(t["name"].startswith("template_") for t in templates)
@patch("crewai.cli.remote_template.main.httpx.get")
def test_fetch_templates_excludes_private(self, mock_get, cmd):
repos = [
{"name": "template_private_one", "description": "", "private": True},
{"name": "template_public_one", "description": "", "private": False},
]
mock_response = MagicMock()
mock_response.json.return_value = repos
mock_response.raise_for_status = MagicMock()
mock_empty = MagicMock()
mock_empty.json.return_value = []
mock_empty.raise_for_status = MagicMock()
mock_get.side_effect = [mock_response, mock_empty]
templates = cmd._fetch_templates()
assert len(templates) == 1
assert templates[0]["name"] == "template_public_one"
@patch("crewai.cli.remote_template.main.httpx.get")
def test_fetch_templates_api_error(self, mock_get, cmd):
mock_get.side_effect = httpx.HTTPError("connection error")
with pytest.raises(SystemExit):
cmd._fetch_templates()
@patch("crewai.cli.remote_template.main.click.prompt", return_value="q")
@patch("crewai.cli.remote_template.main.httpx.get")
def test_list_templates_prints_output(self, mock_get, mock_prompt, cmd):
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_REPOS
mock_response.raise_for_status = MagicMock()
mock_empty = MagicMock()
mock_empty.json.return_value = []
mock_empty.raise_for_status = MagicMock()
mock_get.side_effect = [mock_response, mock_empty]
with patch("crewai.cli.remote_template.main.console") as mock_console:
cmd.list_templates()
assert mock_console.print.call_count > 0
@patch("crewai.cli.remote_template.main.httpx.get")
def test_resolve_repo_name_with_prefix(self, mock_get, cmd):
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_REPOS
mock_response.raise_for_status = MagicMock()
mock_empty = MagicMock()
mock_empty.json.return_value = []
mock_empty.raise_for_status = MagicMock()
mock_get.side_effect = [mock_response, mock_empty]
result = cmd._resolve_repo_name("template_deep_research")
assert result == "template_deep_research"
@patch("crewai.cli.remote_template.main.httpx.get")
def test_resolve_repo_name_without_prefix(self, mock_get, cmd):
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_REPOS
mock_response.raise_for_status = MagicMock()
mock_empty = MagicMock()
mock_empty.json.return_value = []
mock_empty.raise_for_status = MagicMock()
mock_get.side_effect = [mock_response, mock_empty]
result = cmd._resolve_repo_name("deep_research")
assert result == "template_deep_research"
@patch("crewai.cli.remote_template.main.httpx.get")
def test_resolve_repo_name_not_found(self, mock_get, cmd):
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_REPOS
mock_response.raise_for_status = MagicMock()
mock_empty = MagicMock()
mock_empty.json.return_value = []
mock_empty.raise_for_status = MagicMock()
mock_get.side_effect = [mock_response, mock_empty]
result = cmd._resolve_repo_name("nonexistent")
assert result is None
def test_extract_zip(self, cmd, tmp_path):
files = {
"README.md": "# Test Template",
"src/main.py": "print('hello')",
"config/settings.yaml": "key: value",
}
zip_bytes = _make_zipball(files)
dest = str(tmp_path / "output")
cmd._extract_zip(zip_bytes, dest)
assert os.path.isfile(os.path.join(dest, "README.md"))
assert os.path.isfile(os.path.join(dest, "src", "main.py"))
assert os.path.isfile(os.path.join(dest, "config", "settings.yaml"))
with open(os.path.join(dest, "src", "main.py")) as f:
assert f.read() == "print('hello')"
@patch.object(TemplateCommand, "_extract_zip")
@patch.object(TemplateCommand, "_download_zip")
@patch.object(TemplateCommand, "_resolve_repo_name")
def test_add_template_success(self, mock_resolve, mock_download, mock_extract, cmd, tmp_path):
mock_resolve.return_value = "template_deep_research"
mock_download.return_value = b"fake-zip-bytes"
os.chdir(tmp_path)
cmd.add_template("deep_research")
mock_resolve.assert_called_once_with("deep_research")
mock_download.assert_called_once_with("template_deep_research")
expected_dest = os.path.join(str(tmp_path), "deep_research")
mock_extract.assert_called_once_with(b"fake-zip-bytes", expected_dest)
@patch.object(TemplateCommand, "_resolve_repo_name")
def test_add_template_not_found(self, mock_resolve, cmd):
mock_resolve.return_value = None
with pytest.raises(SystemExit):
cmd.add_template("nonexistent")
@patch.object(TemplateCommand, "_extract_zip")
@patch.object(TemplateCommand, "_download_zip")
@patch("crewai.cli.remote_template.main.click.prompt", return_value="my_project")
@patch.object(TemplateCommand, "_resolve_repo_name")
def test_add_template_dir_exists_prompts_rename(self, mock_resolve, mock_prompt, mock_download, mock_extract, cmd, tmp_path):
mock_resolve.return_value = "template_deep_research"
mock_download.return_value = b"fake-zip-bytes"
existing = tmp_path / "deep_research"
existing.mkdir()
os.chdir(tmp_path)
cmd.add_template("deep_research")
expected_dest = os.path.join(str(tmp_path), "my_project")
mock_extract.assert_called_once_with(b"fake-zip-bytes", expected_dest)
@patch.object(TemplateCommand, "_resolve_repo_name")
@patch("crewai.cli.remote_template.main.click.prompt", return_value="q")
def test_add_template_dir_exists_quit(self, mock_prompt, mock_resolve, cmd, tmp_path):
mock_resolve.return_value = "template_deep_research"
existing = tmp_path / "deep_research"
existing.mkdir()
os.chdir(tmp_path)
cmd.add_template("deep_research")
# Should return without downloading
@patch.object(TemplateCommand, "add_template")
@patch("crewai.cli.remote_template.main.click.prompt", return_value="2")
@patch("crewai.cli.remote_template.main.httpx.get")
def test_list_templates_selects_and_installs(self, mock_get, mock_prompt, mock_add, cmd):
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_REPOS
mock_response.raise_for_status = MagicMock()
mock_empty = MagicMock()
mock_empty.json.return_value = []
mock_empty.raise_for_status = MagicMock()
mock_get.side_effect = [mock_response, mock_empty]
with patch("crewai.cli.remote_template.main.console"):
cmd.list_templates()
# Templates are sorted by name; index 1 (choice "2") = template_deep_research
mock_add.assert_called_once_with("deep_research")
@patch.object(TemplateCommand, "add_template")
@patch("crewai.cli.remote_template.main.click.prompt", return_value="q")
@patch("crewai.cli.remote_template.main.httpx.get")
def test_list_templates_quit(self, mock_get, mock_prompt, mock_add, cmd):
mock_response = MagicMock()
mock_response.json.return_value = SAMPLE_REPOS
mock_response.raise_for_status = MagicMock()
mock_empty = MagicMock()
mock_empty.json.return_value = []
mock_empty.raise_for_status = MagicMock()
mock_get.side_effect = [mock_response, mock_empty]
with patch("crewai.cli.remote_template.main.console"):
cmd.list_templates()
mock_add.assert_not_called()