diff --git a/docs/changelog.md b/docs/changelog.md index 6c6fc9b..062824a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -3,6 +3,34 @@ All notable changes to Spawn are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## v0.7.0 — 2026 + +### New Features + +- **AI Agent intent** — generates a fully runnable tool-calling agent project + with `src/agent/`, `src/tools/`, `src/prompts/`, `src/config/` +- **2 frameworks**: PydanticAI, OpenAI Agents SDK +- **Provider-agnostic architecture** — users select a framework, not a provider; + PydanticAI supports 6 providers (OpenAI, Anthropic, Gemini, OpenRouter, Ollama, + Groq), OpenAI Agents SDK supports 2 (OpenAI, OpenRouter) +- **Calculator tool** — every generated agent ships with one working example + tool, demonstrating real tool invocation with no API keys or external services +- **Centralized prompts** — `src/prompts/agent_prompt.txt` is editable without + touching Python code +- **Provider-specific env vars and dependencies** — correct `.env.example` and + `pyproject.toml` dependencies generated per framework + provider combination + (e.g. `pydantic-ai[groq]` for the Groq combination) + +### Internal + +- Registry: `agent` slug added with `available_frameworks`, `available_providers`, + and `available_extras` +- `AgentTemplate` follows the same `BaseTemplate` contract as chatbot/automation + (`get_dependencies()`, `get_readme_content()`, `post_install()`, `next_steps`) +- CLI prompt flow filters provider choices per selected framework via + `get_supported_providers()`, preventing invalid framework/provider combos +- Version bumped to `0.7.0` + ## v0.6.0 — 2026 ### New Features diff --git a/pyproject.toml b/pyproject.toml index 88a8f47..e79f9ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "spawn" -version = "0.6.0" +version = "0.7.0" description = "Spawn a ready-to-use Python project in seconds" readme = "README.md" requires-python = ">=3.12" diff --git a/src/spawn/__init__.py b/src/spawn/__init__.py index 30fcb35..d20f58e 100644 --- a/src/spawn/__init__.py +++ b/src/spawn/__init__.py @@ -3,4 +3,4 @@ try: __version__ = version("spawn") except PackageNotFoundError: - __version__ = "0.6.0" + __version__ = "0.7.0" diff --git a/src/spawn/cli/prompts.py b/src/spawn/cli/prompts.py index 6f6b570..b78a1b0 100644 --- a/src/spawn/cli/prompts.py +++ b/src/spawn/cli/prompts.py @@ -5,7 +5,8 @@ from spawn.utils.console import console from spawn.core.models import ProjectConfig from spawn.core.registry import list_templates, get_metadata -from spawn.templates.chatbot import get_supported_providers +from spawn.templates.chatbot import get_supported_providers as get_chatbot_providers +from spawn.templates.agent import get_supported_providers as get_agent_providers from spawn.utils.validators import validate_project_name from spawn.core.exceptions import SpawnError @@ -134,11 +135,12 @@ def get_project_config() -> ProjectConfig: # --- Provider selection --- if meta and meta.available_providers and selected_framework: - provider_options = ( - get_supported_providers(selected_framework) - if selected_framework - else meta.available_providers - ) + if meta.slug == "agent": + provider_options = get_agent_providers(selected_framework) + elif meta.slug == "chatbot": + provider_options = get_chatbot_providers(selected_framework) + else: + provider_options = meta.available_providers provider_choice_map = { str(i): p for i, p in enumerate(provider_options, start=1) } diff --git a/src/spawn/core/registry.py b/src/spawn/core/registry.py index 6f4c14b..1e775c8 100644 --- a/src/spawn/core/registry.py +++ b/src/spawn/core/registry.py @@ -10,6 +10,7 @@ from spawn.templates.cli_application import CLITemplate from spawn.templates.automation import AutomationTemplate from spawn.templates.chatbot import ChatbotTemplate +from spawn.templates.agent import AgentTemplate from spawn.templates.base import BaseTemplate @@ -64,6 +65,15 @@ class TemplateMetadata: available_providers=["openai", "anthropic", "gemini", "openrouter", "ollama", "groq"], available_extras=["ruff", "pytest", "rich", "github-actions"], ), + "agent": TemplateMetadata( + slug="agent", + display_name="AI Agent", + description="Tool-calling agent with PydanticAI or OpenAI Agents SDK", + template_class=AgentTemplate, + available_frameworks=["pydantic-ai", "openai-agents"], + available_providers=["openai", "anthropic", "gemini", "openrouter", "ollama", "groq"], + available_extras=["ruff", "pytest", "github-actions"], + ), } diff --git a/src/spawn/templates/agent/__init__.py b/src/spawn/templates/agent/__init__.py new file mode 100644 index 0000000..ce50d33 --- /dev/null +++ b/src/spawn/templates/agent/__init__.py @@ -0,0 +1,177 @@ +from pathlib import Path + +from spawn.templates.base import BaseTemplate +from spawn.templates.agent.content import ( + INIT_CONTENT, + AGENT_PROMPT_TXT_CONTENT, + SETTINGS_CONTENT, + CALCULATOR_TOOL_CONTENT, + PYDANTIC_AI_AGENT_CONTENT, + OPENAI_AGENTS_AGENT_CONTENT, + MAIN_CONTENT, + PYDANTIC_AI_TEST_CONTENT, + OPENAI_AGENTS_TEST_CONTENT, + CONFTEST_CONTENT, + GITHUB_ACTIONS_CI_BASE, + GITHUB_ACTIONS_CI_RUFF_STEP, + GITHUB_ACTIONS_CI_PYTEST_STEP, + ENV_PYDANTIC_OPENAI, + ENV_PYDANTIC_ANTHROPIC, + ENV_PYDANTIC_GEMINI, + ENV_PYDANTIC_OPENROUTER, + ENV_PYDANTIC_OLLAMA, + ENV_PYDANTIC_GROQ, + ENV_OPENAI_AGENTS_OPENAI, + ENV_OPENAI_AGENTS_OPENROUTER, + make_readme, +) + +AGENT_FOLDERS = [ + "src/agent", + "src/tools", + "src/prompts", + "src/config", + "tests", +] + +# Maps (framework, provider) → agent run.py content +_AGENT_MAP: dict[tuple[str, str], str] = { + ("pydantic-ai", "openai"): PYDANTIC_AI_AGENT_CONTENT, + ("pydantic-ai", "anthropic"): PYDANTIC_AI_AGENT_CONTENT, + ("pydantic-ai", "gemini"): PYDANTIC_AI_AGENT_CONTENT, + ("pydantic-ai", "openrouter"): PYDANTIC_AI_AGENT_CONTENT, + ("pydantic-ai", "ollama"): PYDANTIC_AI_AGENT_CONTENT, + ("pydantic-ai", "groq"): PYDANTIC_AI_AGENT_CONTENT, + ("openai-agents", "openai"): OPENAI_AGENTS_AGENT_CONTENT, + ("openai-agents", "openrouter"): OPENAI_AGENTS_AGENT_CONTENT, +} + +# Maps (framework, provider) → env example +_ENV_MAP: dict[tuple[str, str], str] = { + ("pydantic-ai", "openai"): ENV_PYDANTIC_OPENAI, + ("pydantic-ai", "anthropic"): ENV_PYDANTIC_ANTHROPIC, + ("pydantic-ai", "gemini"): ENV_PYDANTIC_GEMINI, + ("pydantic-ai", "openrouter"): ENV_PYDANTIC_OPENROUTER, + ("pydantic-ai", "ollama"): ENV_PYDANTIC_OLLAMA, + ("pydantic-ai", "groq"): ENV_PYDANTIC_GROQ, + ("openai-agents", "openai"): ENV_OPENAI_AGENTS_OPENAI, + ("openai-agents", "openrouter"): ENV_OPENAI_AGENTS_OPENROUTER, +} + +# Maps framework → test content +_TEST_MAP: dict[str, str] = { + "pydantic-ai": PYDANTIC_AI_TEST_CONTENT, + "openai-agents": OPENAI_AGENTS_TEST_CONTENT, +} + +AGENT_PROVIDER_MAP: dict[str, list[str]] = { + "pydantic-ai": ["openai", "anthropic", "gemini", "openrouter", "ollama", "groq"], + "openai-agents": ["openai", "openrouter"], +} + + +def get_supported_providers(framework: str) -> list[str]: + return AGENT_PROVIDER_MAP.get(framework, ["openai"]) + + +def _build_files( + agent_content: str, + test_content: str, + env_content: str, +) -> list: + return [ + ("src/__init__.py", INIT_CONTENT), + ("src/agent/__init__.py", INIT_CONTENT), + ("src/agent/run.py", agent_content), + ("src/tools/__init__.py", INIT_CONTENT), + ("src/tools/calculator.py", CALCULATOR_TOOL_CONTENT), + ("src/prompts/__init__.py", INIT_CONTENT), + ("src/prompts/agent_prompt.txt", AGENT_PROMPT_TXT_CONTENT), + ("src/config/__init__.py", INIT_CONTENT), + ("src/config/settings.py", SETTINGS_CONTENT), + ("src/main.py", MAIN_CONTENT), + ("tests/__init__.py", INIT_CONTENT), + ("tests/conftest.py", CONFTEST_CONTENT), + ("tests/test_agent.py", test_content), + (".env.example", env_content), + ] + + +class AgentTemplate(BaseTemplate): + def __init__( + self, + framework: str | None = None, + provider: str | None = None, + extras: list[str] | None = None, + ) -> None: + self.framework = framework or "pydantic-ai" + self.provider = provider or "openai" + self.extras = extras or [] + + key = (self.framework, self.provider) + agent_content = _AGENT_MAP.get(key, PYDANTIC_AI_AGENT_CONTENT) + env_content = _ENV_MAP.get(key, ENV_PYDANTIC_OPENAI) + test_content = _TEST_MAP.get(self.framework, PYDANTIC_AI_TEST_CONTENT) + + super().__init__( + name="AI Agent", + folders=list(AGENT_FOLDERS), + starter_files=_build_files(agent_content, test_content, env_content), + next_steps=[ + "cd {project_name}", + "Rename .env.example to .env and fill in your API key", + "uv run python -m src.main", + ], + ) + + def get_readme_content(self, context: dict) -> str | None: + raw = make_readme(self.framework, self.provider) + return raw.format_map(context) + + def get_dependencies(self) -> list[str]: + dep_map: dict[tuple[str, str], list[str]] = { + ("pydantic-ai", "openai"): ["pydantic-ai", "python-dotenv"], + ("pydantic-ai", "anthropic"): ["pydantic-ai", "python-dotenv"], + ("pydantic-ai", "gemini"): ["pydantic-ai", "python-dotenv"], + ("pydantic-ai", "openrouter"): ["pydantic-ai", "python-dotenv"], + ("pydantic-ai", "ollama"): ["pydantic-ai", "python-dotenv"], + ("pydantic-ai", "groq"): ["pydantic-ai[groq]", "python-dotenv"], + ("openai-agents", "openai"): ["openai-agents", "python-dotenv"], + ("openai-agents", "openrouter"): ["openai-agents", "python-dotenv"], + } + + base = list(dep_map.get( + (self.framework, self.provider), + ["pydantic-ai", "python-dotenv"], + )) + + if "pytest" in self.extras: + base.append("pytest") + if "ruff" in self.extras: + base.append("ruff") + + return base + + def post_install(self, project_path: Path) -> None: + pyproject = project_path / "pyproject.toml" + current = pyproject.read_text(encoding="utf-8") + additions = "" + + if "pytest" in self.extras: + additions += "\n[tool.pytest.ini_options]\ntestpaths = [\"tests\"]\n" + + if "ruff" in self.extras: + additions += "\n[tool.ruff]\nline-length = 88\n" + + if additions: + pyproject.write_text(current + additions, encoding="utf-8") + + if "github-actions" in self.extras: + workflows_path = project_path / ".github" / "workflows" + workflows_path.mkdir(parents=True, exist_ok=True) + ci = GITHUB_ACTIONS_CI_BASE + if "ruff" in self.extras: + ci += GITHUB_ACTIONS_CI_RUFF_STEP + if "pytest" in self.extras: + ci += GITHUB_ACTIONS_CI_PYTEST_STEP + (workflows_path / "ci.yml").write_text(ci, encoding="utf-8") diff --git a/src/spawn/templates/agent/content.py b/src/spawn/templates/agent/content.py new file mode 100644 index 0000000..1bdb4d0 --- /dev/null +++ b/src/spawn/templates/agent/content.py @@ -0,0 +1,353 @@ +INIT_CONTENT = "" + +# ─── Shared content ─────────────────────────────────────────────────────── + +AGENT_PROMPT_TXT_CONTENT = """\ +You are a helpful AI assistant with access to tools. +Use tools when they are helpful to answer the user's request. +Be concise and accurate in your responses. +""" + +SETTINGS_CONTENT = """\ +import os +from pathlib import Path + +from dotenv import load_dotenv + + +def load_env() -> None: + load_dotenv() + + +def get_agent_prompt() -> str: + prompt_path = Path(__file__).parent.parent / "prompts" / "agent_prompt.txt" + return prompt_path.read_text(encoding="utf-8").strip() +""" + +CALCULATOR_TOOL_CONTENT = """\ +import re + + +def calculate(expression: str) -> str: + \"\"\" + Evaluate a mathematical expression safely. + Supports: +, -, *, /, //, **, (, ), and decimal numbers. + Returns the result as a string, or an error message. + \"\"\" + # Strip whitespace and validate characters + cleaned = expression.strip() + if not re.match(r'^[\\d\\s\\+\\-\\*\\/\\(\\)\\.\\*\\*\\/\\/]+$', cleaned): + return f"Invalid expression: only numbers and operators (+,-,*,/,**,//) are allowed" + try: + result = eval(cleaned, {{"__builtins__": {{}}}}, {{}}) + return str(result) + except ZeroDivisionError: + return "Error: division by zero" + except Exception as e: + return f"Error: {{e}}" +""" + +MAIN_CONTENT = """\ +from src.agent.run import run_agent +from src.config.settings import load_env + + +def main() -> None: + load_env() + print("{project_name} Agent ready. Type 'quit' to exit.\\n") + while True: + user_input = input("You: ").strip() + if not user_input: + continue + if user_input.lower() in ("quit", "exit"): + break + print("\\nThinking...\\n") + response = run_agent(user_input) + print(f"Agent: {{response}}\\n") + + +if __name__ == "__main__": + main() +""" + +CONFTEST_CONTENT = """\ +# conftest.py — no shared fixtures needed for agent tests +""" + +# ─── PydanticAI agent ───────────────────────────────────────────────────── + +PYDANTIC_AI_AGENT_CONTENT = """\ +import os + +from pydantic_ai import Agent + +from src.config.settings import get_agent_prompt +from src.tools.calculator import calculate as _calculate + + +def create_agent() -> Agent: + model = os.getenv("MODEL", "openai:gpt-4o-mini") + system_prompt = get_agent_prompt() + agent = Agent(model, system_prompt=system_prompt) + + @agent.tool_plain + def calculate(expression: str) -> str: + \"\"\"Evaluate a mathematical expression. Supports +, -, *, /, **, //.\"\"\" + return _calculate(expression) + + return agent + + +def run_agent(user_input: str) -> str: + agent = create_agent() + result = agent.run_sync(user_input) + return result.output +""" + +PYDANTIC_AI_TEST_CONTENT = """\ +from unittest.mock import patch, MagicMock + +from src.tools.calculator import calculate + + +def test_calculator_addition(): + assert calculate("2 + 3") == "5" + + +def test_calculator_multiplication(): + assert calculate("6 * 7") == "42" + + +def test_calculator_division(): + result = calculate("10 / 4") + assert "2.5" in result + + +def test_calculator_invalid_expression(): + result = calculate("import os") + assert "Invalid" in result or "Error" in result + + +def test_run_agent_mock(): + mock_result = MagicMock() + mock_result.output = "The answer is 42" + + with patch("src.agent.run.Agent") as MockAgent: + mock_instance = MagicMock() + mock_instance.run_sync.return_value = mock_result + MockAgent.return_value = mock_instance + + from src.agent.run import run_agent + + result = run_agent("What is 6 * 7?") + + assert isinstance(result, str) +""" + +# ─── OpenAI Agents agent ────────────────────────────────────────────────── + +OPENAI_AGENTS_AGENT_CONTENT = """\ +import os + +from agents import Agent, Runner, function_tool + +from src.config.settings import get_agent_prompt +from src.tools.calculator import calculate as _calculate + + +@function_tool +def calculate(expression: str) -> str: + \"\"\"Evaluate a mathematical expression. Supports +, -, *, /, **, //.\"\"\" + return _calculate(expression) + + +def create_agent() -> Agent: + system_prompt = get_agent_prompt() + model = os.getenv("MODEL", "gpt-4o-mini") + return Agent( + name="{project_name}", + instructions=system_prompt, + model=model, + tools=[calculate], + ) + + +def run_agent(user_input: str) -> str: + agent = create_agent() + result = Runner.run_sync(agent, user_input) + return result.final_output +""" + +OPENAI_AGENTS_TEST_CONTENT = """\ +from unittest.mock import patch, MagicMock + +from src.tools.calculator import calculate + + +def test_calculator_addition(): + assert calculate("2 + 3") == "5" + + +def test_calculator_multiplication(): + assert calculate("6 * 7") == "42" + + +def test_calculator_division(): + result = calculate("10 / 4") + assert "2.5" in result + + +def test_calculator_invalid_expression(): + result = calculate("import os") + assert "Invalid" in result or "Error" in result + + +def test_run_agent_mock(): + mock_result = MagicMock() + mock_result.final_output = "The answer is 42" + + with patch("src.agent.run.Runner") as MockRunner: + MockRunner.run_sync.return_value = mock_result + + from src.agent.run import run_agent + + result = run_agent("What is 6 * 7?") + + assert isinstance(result, str) +""" + +# ─── Env examples ───────────────────────────────────────────────────────── + +ENV_PYDANTIC_OPENAI = """\ +APP_NAME={project_name} +OPENAI_API_KEY= +MODEL=openai:gpt-4o-mini +""" + +ENV_PYDANTIC_ANTHROPIC = """\ +APP_NAME={project_name} +ANTHROPIC_API_KEY= +MODEL=anthropic:claude-3-5-haiku-latest +""" + +ENV_PYDANTIC_GEMINI = """\ +APP_NAME={project_name} +GOOGLE_API_KEY= +MODEL=google:gemini-2.0-flash +""" + +ENV_PYDANTIC_OPENROUTER = """\ +APP_NAME={project_name} +OPENROUTER_API_KEY= +MODEL=openai/gpt-4o-mini +""" + +ENV_PYDANTIC_OLLAMA = """\ +APP_NAME={project_name} +OLLAMA_BASE_URL=http://localhost:11434 +MODEL=llama3.2 +""" + +ENV_PYDANTIC_GROQ = """\ +APP_NAME={project_name} +GROQ_API_KEY= +MODEL=groq:llama-3.1-8b-instant +""" + +ENV_OPENAI_AGENTS_OPENAI = """\ +APP_NAME={project_name} +OPENAI_API_KEY= +MODEL=gpt-4o-mini +""" + +ENV_OPENAI_AGENTS_OPENROUTER = """\ +APP_NAME={project_name} +OPENAI_API_KEY= +OPENAI_BASE_URL=https://openrouter.ai/api/v1 +MODEL=openai/gpt-4o-mini +OPENAI_AGENTS_DISABLE_TRACING=1 +""" + +# ─── README ─────────────────────────────────────────────────────────────── + + +def make_readme(framework: str, provider: str) -> str: + provider_key_map = { + "openai": "OPENAI_API_KEY=your-key", + "anthropic": "ANTHROPIC_API_KEY=your-key", + "gemini": "GOOGLE_API_KEY=your-key", + "openrouter": "OPENROUTER_API_KEY=your-key", + "ollama": "OLLAMA_BASE_URL=http://localhost:11434", + "groq": "GROQ_API_KEY=your-key", + } + key_line = provider_key_map.get(provider, "API_KEY=your-key") + return ( + "# {project_name}\n\n" + f"An AI agent generated with Spawn, using {framework.title()} + {provider.title()}.\n\n" + "## Getting Started\n\n" + "1. Rename `.env.example` to `.env` and fill in your API key:\n\n" + "```env\n" + f"{key_line}\n" + "```\n\n" + "2. Run the agent:\n\n" + "```bash\n" + "uv run python -m src.main\n" + "```\n\n" + "## Example\n\n" + "```\n" + "You: What is 145 * 28?\n" + "Thinking...\n" + "Agent: 4060\n" + "```\n\n" + "## Project Structure\n\n" + "```\n" + "{project_name}/\n" + "├── src/\n" + "│ ├── agent/ # Agent definition and runner\n" + "│ ├── tools/ # Tool implementations\n" + "│ ├── prompts/ # agent_prompt.txt\n" + "│ ├── config/ # Settings and env loading\n" + "│ └── main.py\n" + "├── tests/\n" + "├── .env.example\n" + "└── README.md\n" + "```\n\n" + "## Running Tests\n\n" + "```bash\n" + "uv run pytest\n" + "```\n" + ) + +# ─── GitHub Actions ─────────────────────────────────────────────────────── + +GITHUB_ACTIONS_CI_BASE = """\ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + ci: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: astral-sh/setup-uv@v5 + + - name: Install dependencies + run: uv sync +""" + +GITHUB_ACTIONS_CI_RUFF_STEP = """\ + - name: Lint + run: uv run ruff check . +""" + +GITHUB_ACTIONS_CI_PYTEST_STEP = """\ + - name: Test + run: uv run pytest +""" diff --git a/tests/test_agent_generator.py b/tests/test_agent_generator.py new file mode 100644 index 0000000..785eba6 --- /dev/null +++ b/tests/test_agent_generator.py @@ -0,0 +1,214 @@ +import json +from contextlib import contextmanager +from unittest.mock import patch + +from spawn.core.models import ProjectConfig +from spawn.generators.project_generator import ProjectGenerator +from spawn.templates.agent import AgentTemplate + + +def _cfg( + name: str = "my-agent", + framework: str = "pydantic-ai", + provider: str = "openai", + extras: list[str] | None = None, +) -> ProjectConfig: + return ProjectConfig( + name=name, + template="agent", + use_git=False, + framework=framework, + provider=provider, + extras=extras or [], + ) + + +@contextmanager +def _mock_uv_and_install(): + with patch("spawn.generators.project_generator.install_packages"), \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(AgentTemplate, "post_install"): + yield + + +# ─── Structure ─────────────────────────────────────────────────────────── + + +def test_agent_creates_root(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-agent").is_dir() + + +def test_agent_creates_agent_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-agent" / "src" / "agent").is_dir() + + +def test_agent_creates_tools_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-agent" / "src" / "tools").is_dir() + + +def test_agent_creates_prompts_dir(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-agent" / "src" / "prompts").is_dir() + + +# ─── Files ─────────────────────────────────────────────────────────────── + + +def test_agent_creates_run_py(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-agent" / "src" / "agent" / "run.py").exists() + + +def test_agent_creates_calculator(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-agent" / "src" / "tools" / "calculator.py").exists() + + +def test_agent_creates_agent_prompt_txt(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-agent" / "src" / "prompts" / "agent_prompt.txt").exists() + + +def test_agent_creates_test_file(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-agent" / "tests" / "test_agent.py").exists() + + +def test_agent_creates_env_example(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-agent" / ".env.example").exists() + + +def test_agent_creates_conftest(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + assert (tmp_path / "my-agent" / "tests" / "conftest.py").exists() + + +# ─── Content ───────────────────────────────────────────────────────────── + + +def test_agent_readme_has_project_name(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(name="super-agent")) + readme = (tmp_path / "super-agent" / "README.md").read_text(encoding="utf-8") + assert "super-agent" in readme + + +def test_agent_readme_has_tool_example(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + readme = (tmp_path / "my-agent" / "README.md").read_text(encoding="utf-8") + assert "calculator" in readme.lower() or "145" in readme or "tool" in readme.lower() + + +def test_agent_calculator_has_safe_eval(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + calc = (tmp_path / "my-agent" / "src" / "tools" / "calculator.py").read_text( + encoding="utf-8" + ) + assert "__builtins__" in calc + + +def test_agent_pydantic_ai_env_has_openai_key(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(framework="pydantic-ai", provider="openai")) + env = (tmp_path / "my-agent" / ".env.example").read_text(encoding="utf-8") + assert "OPENAI_API_KEY" in env + + +def test_agent_pydantic_ai_groq_env_has_groq_key(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(framework="pydantic-ai", provider="groq")) + env = (tmp_path / "my-agent" / ".env.example").read_text(encoding="utf-8") + assert "GROQ_API_KEY" in env + + +def test_agent_openai_agents_env_has_openai_key(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg(framework="openai-agents", provider="openai")) + env = (tmp_path / "my-agent" / ".env.example").read_text(encoding="utf-8") + assert "OPENAI_API_KEY" in env + + +# ─── meta.json ─────────────────────────────────────────────────────────── + + +def test_agent_meta_json_has_correct_intent(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate(_cfg()) + meta = json.loads( + (tmp_path / "my-agent" / ".spawn" / "meta.json").read_text(encoding="utf-8") + ) + assert meta["intent"] == "agent" + assert meta["framework"] == "pydantic-ai" + assert meta["provider"] == "openai" + assert meta["spawn_version"] == "0.7.0" + + +def test_agent_meta_json_openai_agents(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with _mock_uv_and_install(): + ProjectGenerator().generate( + _cfg(framework="openai-agents", provider="openrouter") + ) + meta = json.loads( + (tmp_path / "my-agent" / ".spawn" / "meta.json").read_text(encoding="utf-8") + ) + assert meta["framework"] == "openai-agents" + assert meta["provider"] == "openrouter" + + +# ─── Dependencies ──────────────────────────────────────────────────────── + + +def test_agent_pydantic_ai_install_packages_called(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with patch("spawn.generators.project_generator.install_packages") as mock_install, \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(AgentTemplate, "post_install"): + ProjectGenerator().generate(_cfg()) + args = mock_install.call_args[0][1] + assert "pydantic-ai" in args + assert "openai-agents" not in args + + +def test_agent_openai_agents_install_packages_called(tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + with patch("spawn.generators.project_generator.install_packages") as mock_install, \ + patch("spawn.generators.project_generator.initialize_uv"), \ + patch.object(AgentTemplate, "post_install"): + ProjectGenerator().generate(_cfg(framework="openai-agents", provider="openai")) + args = mock_install.call_args[0][1] + assert "openai-agents" in args + assert "pydantic-ai" not in args diff --git a/tests/test_agent_template.py b/tests/test_agent_template.py new file mode 100644 index 0000000..2364b70 --- /dev/null +++ b/tests/test_agent_template.py @@ -0,0 +1,220 @@ +import os +import py_compile +import tempfile + +import pytest + +from spawn.templates.agent import AgentTemplate, get_supported_providers + +# ─── Basic instantiation ────────────────────────────────────────────────── + + +def test_agent_template_default_name(): + t = AgentTemplate() + assert t.name == "AI Agent" + + +def test_agent_template_default_framework(): + t = AgentTemplate() + assert t.framework == "pydantic-ai" + + +def test_agent_template_default_provider(): + t = AgentTemplate() + assert t.provider == "openai" + + +def test_agent_template_default_extras_empty(): + t = AgentTemplate() + assert t.extras == [] + + +# ─── Folders and files ─────────────────────────────────────────────────── + + +def test_agent_folders(): + t = AgentTemplate() + assert "src/agent" in t.folders + assert "src/tools" in t.folders + assert "src/prompts" in t.folders + assert "src/config" in t.folders + assert "tests" in t.folders + + +def test_agent_required_files(): + t = AgentTemplate() + paths = [p for p, _ in t.starter_files] + for required in [ + "src/agent/run.py", + "src/tools/calculator.py", + "src/prompts/agent_prompt.txt", + "src/config/settings.py", + "tests/test_agent.py", + ".env.example", + ]: + assert required in paths, f"Missing: {required}" + + +def test_agent_has_conftest(): + t = AgentTemplate() + paths = [p for p, _ in t.starter_files] + assert "tests/conftest.py" in paths + + +# ─── Calculator tool ───────────────────────────────────────────────────── + + +def test_calculator_is_generated(): + t = AgentTemplate() + files = dict(t.starter_files) + calc = files["src/tools/calculator.py"] + assert "calculate" in calc + assert "__builtins__" in calc # safe eval pattern + + +def test_calculator_no_bare_eval(): + """Calculator must use restricted eval, not bare eval.""" + t = AgentTemplate() + files = dict(t.starter_files) + calc = files["src/tools/calculator.py"] + assert '"__builtins__"' in calc or "'__builtins__'" in calc + + +# ─── Provider maps ─────────────────────────────────────────────────────── + + +def test_pydantic_ai_supports_6_providers(): + providers = get_supported_providers("pydantic-ai") + assert len(providers) == 6 + for p in ["openai", "anthropic", "gemini", "openrouter", "ollama", "groq"]: + assert p in providers + + +def test_openai_agents_supports_2_providers(): + providers = get_supported_providers("openai-agents") + assert providers == ["openai", "openrouter"] + + +def test_unknown_framework_defaults_to_openai(): + providers = get_supported_providers("unknown") + assert "openai" in providers + + +# ─── ALL_COMBINATIONS parametrized ─────────────────────────────────────── + +ALL_COMBINATIONS = [ + ("pydantic-ai", "openai"), + ("pydantic-ai", "anthropic"), + ("pydantic-ai", "gemini"), + ("pydantic-ai", "openrouter"), + ("pydantic-ai", "ollama"), + ("pydantic-ai", "groq"), + ("openai-agents", "openai"), + ("openai-agents", "openrouter"), +] + +REQUIRED_FILES = [ + "src/__init__.py", + "src/agent/__init__.py", + "src/agent/run.py", + "src/tools/__init__.py", + "src/tools/calculator.py", + "src/prompts/__init__.py", + "src/config/__init__.py", + "src/config/settings.py", + "tests/__init__.py", + "tests/conftest.py", + "tests/test_agent.py", + ".env.example", +] + + +@pytest.mark.parametrize("framework,provider", ALL_COMBINATIONS) +def test_all_combinations_have_required_files(framework, provider): + t = AgentTemplate(framework=framework, provider=provider) + paths = [p for p, _ in t.starter_files] + for req in REQUIRED_FILES: + assert req in paths, f"{framework}+{provider} missing {req}" + + +@pytest.mark.parametrize("framework,provider", ALL_COMBINATIONS) +def test_all_combinations_agent_run_compiles(framework, provider): + t = AgentTemplate(framework=framework, provider=provider) + files = dict(t.starter_files) + content = files["src/agent/run.py"].format_map({"project_name": "test"}) + with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as f: + f.write(content) + fname = f.name + try: + py_compile.compile(fname, doraise=True) + except py_compile.PyCompileError as e: + raise AssertionError(f"{framework}+{provider} run.py invalid: {e}") from e + finally: + os.unlink(fname) + + +@pytest.mark.parametrize("framework,provider", ALL_COMBINATIONS) +def test_all_combinations_have_python_dotenv(framework, provider): + t = AgentTemplate(framework=framework, provider=provider) + assert "python-dotenv" in t.get_dependencies() + + +@pytest.mark.parametrize("framework,provider", ALL_COMBINATIONS) +def test_all_combinations_env_has_api_key(framework, provider): + t = AgentTemplate(framework=framework, provider=provider) + files = dict(t.starter_files) + env = files[".env.example"].format_map({"project_name": "test"}) + has_key = any( + key in env for key in [ + "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "GOOGLE_API_KEY", + "OPENROUTER_API_KEY", "GROQ_API_KEY", "OLLAMA_BASE_URL", + ] + ) + assert has_key, f"{framework}+{provider} env missing API key" + + +@pytest.mark.parametrize("framework,provider", ALL_COMBINATIONS) +def test_all_combinations_readme_contains_project_name(framework, provider): + t = AgentTemplate(framework=framework, provider=provider) + readme = t.get_readme_content({"project_name": "my-agent"}) + assert readme is not None + assert "my-agent" in readme + + +# ─── Dependencies ──────────────────────────────────────────────────────── + + +def test_pydantic_ai_groq_uses_extras(): + t = AgentTemplate(framework="pydantic-ai", provider="groq") + deps = t.get_dependencies() + assert "pydantic-ai[groq]" in deps + + +def test_openai_agents_uses_openai_agents_package(): + t = AgentTemplate(framework="openai-agents", provider="openai") + deps = t.get_dependencies() + assert "openai-agents" in deps + assert "pydantic-ai" not in deps + + +def test_pytest_extra_adds_pytest(): + t = AgentTemplate(extras=["pytest"]) + assert "pytest" in t.get_dependencies() + + +def test_ruff_extra_adds_ruff(): + t = AgentTemplate(extras=["ruff"]) + assert "ruff" in t.get_dependencies() + + +# ─── next_steps ────────────────────────────────────────────────────────── + + +def test_next_steps_has_rename_instruction(): + t = AgentTemplate() + assert any("Rename" in s for s in t.next_steps) + + +def test_next_steps_has_run_command(): + t = AgentTemplate() + assert any("src.main" in s for s in t.next_steps) diff --git a/tests/test_registry.py b/tests/test_registry.py index e2920d1..86c9734 100644 --- a/tests/test_registry.py +++ b/tests/test_registry.py @@ -25,7 +25,7 @@ def test_list_templates_returns_all(): assert "cli" in slugs assert "automation" in slugs assert "chatbot" in slugs - assert len(slugs) == 4 + assert len(slugs) == 5 def test_get_metadata_returns_none_for_unknown(): @@ -149,3 +149,20 @@ def test_chatbot_metadata(): def test_chatbot_in_list_templates(): slugs = [m.slug for m in list_templates()] assert "chatbot" in slugs + + +def test_agent_template_is_registered(): + from spawn.templates.agent import AgentTemplate + from spawn.core.registry import get_template + t = get_template("agent") + assert t is not None + assert isinstance(t, AgentTemplate) + + +def test_agent_metadata(): + meta = get_metadata("agent") + assert meta is not None + assert meta.slug == "agent" + assert meta.display_name == "AI Agent" + assert "pydantic-ai" in meta.available_frameworks + assert "openai-agents" in meta.available_frameworks diff --git a/uv.lock b/uv.lock index 8c92aba..c1bfbaa 100644 --- a/uv.lock +++ b/uv.lock @@ -142,7 +142,7 @@ wheels = [ [[package]] name = "spawn" -version = "0.6.0" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "rich" },