Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/spawn/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@
try:
__version__ = version("spawn")
except PackageNotFoundError:
__version__ = "0.6.0"
__version__ = "0.7.0"
14 changes: 8 additions & 6 deletions src/spawn/cli/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
}
Expand Down
10 changes: 10 additions & 0 deletions src/spawn/core/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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"],
),
}


Expand Down
177 changes: 177 additions & 0 deletions src/spawn/templates/agent/__init__.py
Original file line number Diff line number Diff line change
@@ -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")
Loading
Loading