From 105a9778cca31eb137874496bcfbf855231682c5 Mon Sep 17 00:00:00 2001 From: Lorenze Jay <63378463+lorenzejay@users.noreply.github.com> Date: Thu, 16 Apr 2026 10:18:15 -0700 Subject: [PATCH] feat: add template management commands for project templates (#5444) * 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. * linted * adressing comments * comment addressed --- lib/crewai/src/crewai/cli/cli.py | 28 ++ .../crewai/cli/remote_template/__init__.py | 0 .../src/crewai/cli/remote_template/main.py | 250 ++++++++++++++++ lib/crewai/src/crewai/telemetry/telemetry.py | 17 ++ .../tests/cli/remote_template/__init__.py | 0 .../tests/cli/remote_template/test_main.py | 283 ++++++++++++++++++ 6 files changed, 578 insertions(+) create mode 100644 lib/crewai/src/crewai/cli/remote_template/__init__.py create mode 100644 lib/crewai/src/crewai/cli/remote_template/main.py create mode 100644 lib/crewai/tests/cli/remote_template/__init__.py create mode 100644 lib/crewai/tests/cli/remote_template/test_main.py diff --git a/lib/crewai/src/crewai/cli/cli.py b/lib/crewai/src/crewai/cli/cli.py index 2e10d5162..bc2a9ee26 100644 --- a/lib/crewai/src/crewai/cli/cli.py +++ b/lib/crewai/src/crewai/cli/cli.py @@ -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.""" diff --git a/lib/crewai/src/crewai/cli/remote_template/__init__.py b/lib/crewai/src/crewai/cli/remote_template/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lib/crewai/src/crewai/cli/remote_template/main.py b/lib/crewai/src/crewai/cli/remote_template/main.py new file mode 100644 index 000000000..bbd32184f --- /dev/null +++ b/lib/crewai/src/crewai/cli/remote_template/main.py @@ -0,0 +1,250 @@ +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 + + if choice.isdigit() and 1 <= int(choice) <= len(templates): + selected_index = int(choice) - 1 + break + + 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"] + self._install_repo(repo_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) + + self._install_repo(repo_name, output_dir) + + def _install_repo(self, repo_name: str, output_dir: str | None = None) -> None: + """Download and extract a template repo into the current directory. + + Args: + repo_name: Full GitHub repo name (e.g. template_deep_research). + output_dir: Optional directory name. Defaults to the template name. + """ + 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) + + self._telemetry.template_installed_span(repo_name.removeprefix(TEMPLATE_PREFIX)) + + 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-/' + 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.realpath(os.path.join(dest, relative_path)) + if not target.startswith( + os.path.realpath(dest) + os.sep + ) and target != os.path.realpath(dest): + continue + + 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) diff --git a/lib/crewai/src/crewai/telemetry/telemetry.py b/lib/crewai/src/crewai/telemetry/telemetry.py index 94939bb7a..1e7506da0 100644 --- a/lib/crewai/src/crewai/telemetry/telemetry.py +++ b/lib/crewai/src/crewai/telemetry/telemetry.py @@ -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) diff --git a/lib/crewai/tests/cli/remote_template/__init__.py b/lib/crewai/tests/cli/remote_template/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lib/crewai/tests/cli/remote_template/test_main.py b/lib/crewai/tests/cli/remote_template/test_main.py new file mode 100644 index 000000000..829e956ce --- /dev/null +++ b/lib/crewai/tests/cli/remote_template/test_main.py @@ -0,0 +1,283 @@ +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): + instance = TemplateCommand() + instance._telemetry = MagicMock() + return instance + + @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, "_install_repo") + @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_install, 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_install.assert_called_once_with("template_deep_research") + + @patch.object(TemplateCommand, "_install_repo") + @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_install, 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_install.assert_not_called()