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.
This commit is contained in:
lorenzejay
2026-04-13 14:51:55 -07:00
parent 0450d06a65
commit 6f34db5b21
6 changed files with 565 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
@@ -473,6 +474,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,239 @@
import io
import os
import shutil
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
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:
pass
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]:
"""Fetch all template repos from the GitHub org."""
templates: list[dict] = []
page = 1
while True:
url = f"{GITHUB_API_BASE}/orgs/{GITHUB_ORG}/repos"
params = {"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
for repo in repos:
if repo["name"].startswith(TEMPLATE_PREFIX) and not repo.get("private"):
templates.append(repo)
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

@@ -1057,3 +1057,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()