diff --git a/README.md b/README.md
index 00b3e71..1f47d86 100644
--- a/README.md
+++ b/README.md
@@ -77,6 +77,7 @@ It's repetitive. It's inconsistent. And you haven't written a single line of *re
| **Arrow-key menus** | Every prompt in `spawn create` is arrow-key/spacebar driven, not typed numbers |
| **GitHub publishing** | Connects your project to an existing GitHub repo and pushes the initial commit |
| **spawn doctor** | Scores your project's health out of 100, with per-category breakdowns and a prioritized next step |
+| **spawn doctor --fix** | Fixes missing files and config automatically — never overwrites, never installs packages |
---
@@ -132,7 +133,7 @@ Running `spawn` with no arguments shows the banner, the installed version, and t
```
SPAWN — scaffold your next project
-v1.0.6
+v1.0.8
Commands
create Scaffold a new project
@@ -179,7 +180,15 @@ Use the arrow keys to move, Enter to select — the same pattern every prompt in
**Step 3 — Additional prompts** *(template-dependent — framework, provider, project type, and/or extras, depending on what you picked. See [Project Templates](#project-templates) below for each one's exact flow.)*
-**Step 4 — Git**
+**Step 4 — License**
+
+```
+? Choose a license (Use arrow keys)
+ » MIT
+ None
+```
+
+**Step 5 — Git**
```
Initialize Git? [Y/n]: Y
@@ -242,9 +251,11 @@ my-api/
● pytest
○ docker
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
-**Available extras:** `ruff` `pytest` `docker` `github-actions`
+**Available extras:** `ruff` `pytest` `docker` `github-actions` `pre-commit` `mypy`
**Run it:**
@@ -295,9 +306,11 @@ my-cli/
● ruff
● pytest
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
-**Available extras:** `ruff` `pytest` `github-actions`
+**Available extras:** `ruff` `pytest` `github-actions` `pre-commit` `mypy`
**Run it:**
@@ -334,9 +347,11 @@ my-automation/
● ruff
● pytest
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
-**Available extras:** `ruff` `pytest` `github-actions`
+**Available extras:** `ruff` `pytest` `github-actions` `pre-commit` `mypy`
**Run it:**
@@ -386,9 +401,11 @@ my-chatbot/
● pytest
○ rich
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
-**Available extras:** `ruff` `pytest` `rich` `github-actions`
+**Available extras:** `ruff` `pytest` `rich` `github-actions` `pre-commit` `mypy`
**Run it:**
@@ -436,11 +453,13 @@ my-agent/
● ruff
● pytest
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
> If you pick `openai-agents`, only `openai` and `openrouter` appear as provider choices — the list is filtered per framework.
-**Available extras:** `ruff` `pytest` `github-actions`
+**Available extras:** `ruff` `pytest` `github-actions` `pre-commit` `mypy`
**Run it:**
@@ -481,11 +500,13 @@ my-rag/
● ruff
● pytest
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
RAG System uses a fixed stack — **LlamaIndex + ChromaDB + OpenAI** — so there's no framework or provider prompt, unlike Chatbot and Agent. On first run, it automatically ingests documents from `data/` into a local ChromaDB index, then lets you ask questions against them. Requires an `OPENAI_API_KEY` (used for both the LLM and embeddings).
-**Available extras:** `ruff` `pytest` `github-actions`
+**Available extras:** `ruff` `pytest` `github-actions` `pre-commit` `mypy`
**Run it:**
@@ -529,9 +550,11 @@ my-data-project/
● ruff
● pytest
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
-**Available extras:** `ruff` `pytest` `github-actions`
+**Available extras:** `ruff` `pytest` `github-actions` `pre-commit` `mypy`
**Run it:**
@@ -575,11 +598,13 @@ my-mcp-server/
● ruff
● pytest
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
MCP Server uses a fixed stack — the official `mcp` Python SDK's `FastMCP` — so there's no framework or provider prompt, the same as RAG System.
-**Available extras:** `ruff` `pytest` `github-actions`
+**Available extras:** `ruff` `pytest` `github-actions` `pre-commit` `mypy`
**Run it:**
@@ -630,6 +655,7 @@ Dependencies (comma separated, optional): fastapi, uvicorn
● Pytest
○ Pre-commit
○ Dockerfile
+ ○ Mypy
Additional ignore patterns (optional, comma separated): data/, *.csv
@@ -643,7 +669,7 @@ Proceed? [Y/n]: Y
**The Dependencies prompt** (shown only when uv is enabled) installs packages immediately via `uv add`.
-**The Optional Setup menu** installs dev tools via `uv add --dev` and generates their config files: `ruff.toml`, `tests/__init__.py`, `.pre-commit-config.yaml`, and/or `Dockerfile`.
+**The Optional Setup menu** installs dev tools via `uv add --dev` and generates their config files: `ruff.toml`, `tests/__init__.py`, `.pre-commit-config.yaml`, `Dockerfile`, and/or `[tool.mypy]` in `pyproject.toml`.
### Non-Interactive Mode
@@ -652,7 +678,7 @@ Skip all prompts by passing flags directly or pointing to a JSON config file.
**Using flags:**
```bash
-spawn create --name my-api --template backend-api --framework fastapi --extras ruff,pytest --git
+spawn create --name my-api --template backend-api --framework fastapi --extras ruff,pytest --license mit --git
```
**Using a config file:**
@@ -663,6 +689,7 @@ spawn create --name my-api --template backend-api --framework fastapi --extras r
"template": "backend-api",
"framework": "fastapi",
"extras": ["ruff", "pytest"],
+ "license": "mit",
"git": true,
"uv": true
}
@@ -730,11 +757,30 @@ spawn doctor ./path/to/project
Checks span six categories — Documentation, Version Control, Configuration, Testing, Automation, and Code Quality (Ruff, type checking, pre-commit) — all filesystem-based, nothing is executed or sent over the network.
+#### Automatic Remediation with `--fix`
+
+`spawn doctor --fix` creates missing files and config automatically. It **never overwrites** pre-existing files and **never installs packages** directly (instead printing `uv add` commands for optional tools).
+
+```bash
+# Automatically create missing files and config
+spawn doctor --fix
+spawn doctor ./path/to/project --fix
+
+# Preview what would be created without touching disk
+spawn doctor --fix --dry-run
+
+# Run non-interactively without the confirmation prompt
+spawn doctor --fix --yes
+
+# Also add an MIT license if missing
+spawn doctor --fix --license mit
+```
+
### `spawn version`
```bash
spawn version
-# → Spawn v1.0.7
+# → Spawn v1.0.8
```
### Publish to GitHub
@@ -768,6 +814,7 @@ All tests should pass. If they don't, please [open an issue](https://github.com/
| Version | Highlight |
|---|---|
+| **v1.0.8** | `spawn doctor --fix` (`--dry-run`, `--yes`, `--license`), `--license` flag and prompt on `create`, `CHANGELOG.md` generated for every project, `pre-commit` and `mypy` extras across all templates, Custom Structure Mypy option |
| **v1.0.7** | Fixed UTF-8 console crash on Windows when stdout is redirected/piped, added Windows reserved device name validation (`CON`, `PRN`, `AUX`, `NUL`, `COM1`–`9`, `LPT1`–`9`), clarified Python 3.12 requirement |
| **v1.0.6** | Removed duplicate banner from `spawn create`; `spawn` alone now shows the banner and command overview |
| **v1.0.5** | Published to PyPI as `spawnio`; arrow-key selection menus, no-args banner, consistent Ctrl+C handling |
diff --git a/docs/architecture.md b/docs/architecture.md
index 1f380b6..e15592e 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -23,16 +23,19 @@ Entry point: `spawn.cli.app:main` (defined in `pyproject.toml` as `[project.scri
```
src/spawn/
-├── __init__.py # __version__ via importlib.metadata, fallback "1.0.6"
+├── __init__.py # __version__ via importlib.metadata, fallback "1.0.8"
├── cli/
│ ├── app.py # Typer app: create, version, doctor commands
+│ ├── noninteractive.py # Build config from CLI flags or JSON config file
│ └── prompts.py # Interactive prompts; derives menu from registry
├── core/
│ ├── exceptions.py # SpawnError — base exception
-│ ├── models.py # ProjectConfig dataclass (name, template, use_git, framework, extras)
+│ ├── models.py # ProjectConfig dataclass (name, template, use_git, framework, extras, license)
│ └── registry.py # TemplateMetadata, TEMPLATES dict, get_template(),
│ # instantiate_template(), get_metadata(), list_templates()
├── generators/
+│ ├── custom_structure.py # Custom structure parser and generator
+│ ├── project_files.py # Standalone file writers for LICENSE, README, configs, CI
│ └── project_generator.py # Orchestrates generation, dependency install, meta.json write
├── github/
│ ├── exceptions.py # GitHubPublishError
@@ -40,7 +43,7 @@ src/spawn/
│ └── validators.py # GitHub URL validation
├── templates/
│ ├── base.py # BaseTemplate dataclass
-│ ├── shared_content.py # README_CONTENT, GITIGNORE_CONTENT
+│ ├── shared_content.py # README_CONTENT, GITIGNORE_CONTENT, CI, and tool configs
│ ├── backend_api/
│ │ ├── __init__.py # BackendAPITemplate (dispatches by framework)
│ │ └── content.py # All FastAPI/Flask/Django/Docker/CI content strings
@@ -60,8 +63,10 @@ src/spawn/
├── banner.py # show_banner() — ASCII wordmark with ice-fade colours
├── console.py # Shared Rich Console instance
├── doctor.py # ProjectHealthChecker, run_health_check()
+ ├── doctor_fix.py # FixAction, FixResult, plan_fixes(), apply_fixes(), tool_hints()
├── git.py # initialize_git(), run_git_command(), add_all(), commit() etc.
├── success.py # show_success() — renders the success panel
+ ├── theme.py # UI theme colors and styles
├── uv.py # initialize_uv(), install_packages()
└── validators.py # validate_project_name()
```
@@ -137,14 +142,14 @@ Each template lives in its own subdirectory with an `__init__.py` (class) and `c
| Key | Template class | Frameworks | Extras |
|---|---|---|---|
-| `backend-api` | `BackendAPITemplate` | fastapi, flask, django | ruff, pytest, docker, github-actions |
-| `cli` | `CLITemplate` | typer, click, argparse | ruff, pytest, github-actions |
-| `automation` | `AutomationTemplate` | none | ruff, pytest, github-actions |
-| `chatbot` | `ChatbotTemplate` | pydantic-ai, openai-sdk | ruff, pytest, github-actions |
-| `agent` | `AgentTemplate` | pydantic-ai, openai-agents | ruff, pytest, github-actions |
-| `rag` | `RAGTemplate` | none | ruff, pytest, github-actions |
-| `data` | `DataProjectTemplate` | none | ruff, pytest, github-actions |
-| `mcp` | `MCPServerTemplate` | none | ruff, pytest, github-actions |
+| `backend-api` | `BackendAPITemplate` | fastapi, flask, django | ruff, pytest, docker, github-actions, pre-commit, mypy |
+| `cli` | `CLITemplate` | typer, click, argparse | ruff, pytest, github-actions, pre-commit, mypy |
+| `automation` | `AutomationTemplate` | none | ruff, pytest, github-actions, pre-commit, mypy |
+| `chatbot` | `ChatbotTemplate` | pydantic-ai, openai-sdk, litellm | ruff, pytest, rich, github-actions, pre-commit, mypy |
+| `agent` | `AgentTemplate` | pydantic-ai, openai-agents | ruff, pytest, github-actions, pre-commit, mypy |
+| `rag` | `RAGTemplate` | none | ruff, pytest, github-actions, pre-commit, mypy |
+| `data` | `DataProjectTemplate` | none | ruff, pytest, github-actions, pre-commit, mypy |
+| `mcp` | `MCPServerTemplate` | none | ruff, pytest, github-actions, pre-commit, mypy |
`get_template(slug)` returns a default-constructed instance. `instantiate_template(config)` forwards `framework`, `extras`, and `cli_type` from `ProjectConfig` to templates whose constructors accept them, using signature introspection.
diff --git a/docs/changelog.md b/docs/changelog.md
index bb6c0e7..70ec2ce 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -3,6 +3,20 @@
All notable changes to Spawn are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
+## v1.0.8 — 2026
+
+### Added
+
+- **`spawn doctor --fix`** — `spawn doctor
--fix [--dry-run] [--yes] [--license mit]` creates missing files and config, never overwrites, never installs packages.
+- **`--license` selection on `spawn create`** — added `--license (mit/none)` CLI option and an interactive license prompt (MIT default).
+- **`CHANGELOG.md` generation** — generated for every new project.
+- **`pre-commit` and `mypy` extras** — available across all 8 project templates.
+- **Custom Structure Mypy option** — added Mypy to optional setup choices in Custom Structure.
+
+### Fixed
+
+- **`--template` help text** — now lists all 8 supported template slugs including `mcp`.
+
## v1.0.7 — 2026
### Fixed
diff --git a/docs/commands.md b/docs/commands.md
index a4cdd59..0fdb746 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -17,7 +17,7 @@ Running `spawn` with no arguments prints the banner, the installed version, and
```
SPAWN — scaffold your next project
-v1.0.6
+v1.0.8
Commands
create Scaffold a new project
@@ -52,11 +52,12 @@ spawn create
| 5 | `Choose Framework` | Backend API, CLI Application, AI Chatbot, AI Agent |
| 6 | `Choose Provider` | AI Chatbot, AI Agent (filtered by framework) |
| 7 | `Select extras` | Templates with available extras |
-| 8 | `Initialize Git? [Y/n]` | Always |
-| 9 | `Also generate CLAUDE.md for Claude Code? [y/N]` | Always |
-| 10 | `Publish to GitHub? [y/N]` | Only when Git was enabled and not in non-interactive mode |
+| 8 | `Choose a license` | Always (`MIT`, `None`) |
+| 9 | `Initialize Git? [Y/n]` | Always |
+| 10 | `Also generate CLAUDE.md for Claude Code? [y/N]` | Always |
+| 11 | `Publish to GitHub? [y/N]` | Only when Git was enabled and not in non-interactive mode |
-Every menu (steps 2–7) is **arrow-key / spacebar** driven — no typed numbers.
+Every menu (steps 2–8) is **arrow-key / spacebar** driven — no typed numbers.
**Template list**
@@ -81,6 +82,8 @@ Every menu (steps 2–7) is **arrow-key / spacebar** driven — no typed numbers
● pytest
○ docker
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
Space toggles, Enter confirms. Empty selection is valid (skip all extras).
@@ -100,7 +103,7 @@ Space toggles, Enter confirms. Empty selection is valid (skip all extras).
Pass `--name` to skip all prompts. `--template` is required when using `--name`.
```bash
-spawn create --name my-api --template backend-api --framework fastapi --extras ruff,pytest
+spawn create --name my-api --template backend-api --framework fastapi --extras ruff,pytest --license mit
```
Pass `--config` to read settings from a JSON file instead of flags:
@@ -120,6 +123,7 @@ spawn create --config spawn.json
"cli_type": null,
"data_type": null,
"extras": ["ruff", "pytest"],
+ "license": "mit",
"git": true,
"uv": true,
"claude_md": false
@@ -137,6 +141,7 @@ spawn create --config spawn.json
| `--cli-type` | `utility` | `utility` or `interactive` (CLI Application only) |
| `--data-type` | `Data Analysis` | Sub-type for Data Project |
| `--extras` | none | Comma-separated extras, e.g. `ruff,pytest` |
+| `--license` | `mit` | Project license (`mit` or `none`) |
| `--git` / `--no-git` | `--git` | Initialize a Git repository |
| `--uv` / `--no-uv` | `--uv` | Initialize uv and install dependencies |
| `--claude-md` / `--no-claude-md` | `--no-claude-md` | Also write `CLAUDE.md` alongside `AGENTS.md` |
@@ -148,14 +153,14 @@ spawn create --config spawn.json
| Slug | Display name | Frameworks | Providers | CLI types | Project types | Extras |
|---|---|---|---|---|---|---|
-| `backend-api` | Backend API | fastapi, flask, django | — | — | — | ruff, pytest, docker, github-actions |
-| `cli` | CLI Application | typer, click, argparse | — | utility, interactive | — | ruff, pytest, github-actions |
-| `automation` | Automation Tool | — | — | — | — | ruff, pytest, github-actions |
-| `chatbot` | AI Chatbot | pydantic-ai, openai-sdk, litellm | openai, anthropic, gemini, openrouter, ollama, groq | — | — | ruff, pytest, rich, github-actions |
-| `agent` | AI Agent | pydantic-ai, openai-agents | openai, anthropic, gemini, openrouter, ollama, groq | — | — | ruff, pytest, github-actions |
-| `rag` | RAG System | — | — | — | — | ruff, pytest, github-actions |
-| `data` | Data Project | — | — | — | Data Analysis, Dashboard, ETL Pipeline, Machine Learning | ruff, pytest, github-actions |
-| `mcp` | MCP Server | — | — | — | — | ruff, pytest, github-actions |
+| `backend-api` | Backend API | fastapi, flask, django | — | — | — | ruff, pytest, docker, github-actions, pre-commit, mypy |
+| `cli` | CLI Application | typer, click, argparse | — | utility, interactive | — | ruff, pytest, github-actions, pre-commit, mypy |
+| `automation` | Automation Tool | — | — | — | — | ruff, pytest, github-actions, pre-commit, mypy |
+| `chatbot` | AI Chatbot | pydantic-ai, openai-sdk, litellm | openai, anthropic, gemini, openrouter, ollama, groq | — | — | ruff, pytest, rich, github-actions, pre-commit, mypy |
+| `agent` | AI Agent | pydantic-ai, openai-agents | openai, anthropic, gemini, openrouter, ollama, groq | — | — | ruff, pytest, github-actions, pre-commit, mypy |
+| `rag` | RAG System | — | — | — | — | ruff, pytest, github-actions, pre-commit, mypy |
+| `data` | Data Project | — | — | — | Data Analysis, Dashboard, ETL Pipeline, Machine Learning | ruff, pytest, github-actions, pre-commit, mypy |
+| `mcp` | MCP Server | — | — | — | — | ruff, pytest, github-actions, pre-commit, mypy |
> `openai-agents` only supports `openai` and `openrouter` as providers — the list is filtered per framework.
@@ -178,6 +183,8 @@ spawn create --config spawn.json
○ pytest
○ docker
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
| Framework | Run command |
@@ -204,6 +211,8 @@ spawn create --config spawn.json
○ ruff
○ pytest
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
| CLI type | Run command |
@@ -220,6 +229,8 @@ spawn create --config spawn.json
○ ruff
○ pytest
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
Run: `uv run python -m src.main`
@@ -247,6 +258,8 @@ Run: `uv run python -m src.main`
○ pytest
○ rich
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
Run: add provider API key to `.env`, then `uv run python -m src.main`
@@ -269,6 +282,8 @@ Run: add provider API key to `.env`, then `uv run python -m src.main`
○ ruff
○ pytest
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
Run: add provider API key to `.env`, then `uv run python -m src.main`
@@ -284,6 +299,8 @@ No framework or provider prompt. Requires `OPENAI_API_KEY` in `.env`.
○ ruff
○ pytest
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
Run: `uv run python -m src.main` (auto-ingests `data/` on first run)
@@ -303,6 +320,8 @@ Run: `uv run python -m src.main` (auto-ingests `data/` on first run)
○ ruff
○ pytest
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
| Type | Run command |
@@ -323,6 +342,8 @@ No framework or provider prompt.
○ ruff
○ pytest
○ github-actions
+ ○ pre-commit
+ ○ mypy
```
Run: `uv run python -m src.server` (waits on stdio for an MCP client to connect)
@@ -339,7 +360,7 @@ The last option in the template picker. After selecting it, Spawn switches to a
4. `Also generate CLAUDE.md for Claude Code? [y/N]`
5. `Initialize uv? [Y/n]`
6. *(if uv)* `Dependencies (comma separated, optional):`
-7. *(if uv)* Optional Setup checkbox — `Ruff`, `Pytest`, `Pre-commit`, `Dockerfile`
+7. *(if uv)* Optional Setup checkbox — `Ruff`, `Pytest`, `Pre-commit`, `Dockerfile`, `Mypy`
8. `Additional ignore patterns (optional, comma separated):`
9. `Proceed? [Y/n]`
@@ -355,6 +376,8 @@ Every generated project includes:
|---|---|
| `README.md` | Template-specific content with project name, structure, setup, and run instructions |
| `AGENTS.md` | Agent context file with project structure, setup, and conventions |
+| `CHANGELOG.md` | Keep a Changelog starter documenting the initial release |
+| `LICENSE` | MIT license (when `--license mit`, the default) |
| `.gitignore` | Python defaults (`.venv/`, `__pycache__/`, `.pytest_cache/`, `.mypy_cache/`, `.ruff_cache/`, etc.) |
| `.spawn/meta.json` | `intent`, `framework`, `provider`, `spawn_version`, `created_at`, `git`, `uv` |
@@ -367,7 +390,7 @@ If `--claude-md` was passed (or `"claude_md": true` in the config file), a `CLAU
"intent": "backend-api",
"framework": "fastapi",
"provider": null,
- "spawn_version": "1.0.6",
+ "spawn_version": "1.0.8",
"created_at": "2026-01-01T00:00:00+00:00",
"generator": "blueprint",
"git": true,
@@ -419,6 +442,40 @@ spawn doctor
spawn doctor ./path/to/project
```
+### Remediation with `--fix`
+
+`spawn doctor --fix` scans the project and automatically creates missing standard files and configuration.
+
+```bash
+# Automatically create missing files and config
+spawn doctor --fix
+spawn doctor ./path/to/project --fix
+
+# Preview what would change without writing to disk
+spawn doctor --fix --dry-run
+
+# Run non-interactively without the confirmation prompt
+spawn doctor --fix --yes
+
+# Also add an MIT license if missing
+spawn doctor --fix --license mit
+```
+
+**Doctor flags**
+
+| Flag | Default | Description |
+|---|---|---|
+| `path` | `.` | Path to the project directory to check |
+| `--fix` | false | Create missing files and config. Never overwrites, never installs packages |
+| `--dry-run` | false | With `--fix`: show what would change without writing |
+| `--yes` / `-y` | false | With `--fix`: skip the confirmation prompt |
+| `--license` | — | With `--fix`: also add a LICENSE (`mit`) |
+
+**Guarantees**
+
+- **Never overwrites**: If a file or configuration already exists, `doctor --fix` never touches or replaces it. Only missing items are created.
+- **Never installs packages**: `doctor --fix` will write standard configuration (such as `ruff.toml`, `[tool.mypy]`, or `.pre-commit-config.yaml`), but will never run package managers or install dependencies into your environment. Instead, it prints helpful `uv add --dev ...` commands so you remain in control.
+
**All checks**
| Check | Category | Weight |
@@ -458,7 +515,7 @@ Prints the installed version.
```bash
spawn version
-# Spawn v1.0.6
+# Spawn v1.0.8
```
---
@@ -469,5 +526,5 @@ spawn version
|---|---|
| Any command succeeds | 0 |
| `spawn doctor` — path does not exist or is not a directory | 1 |
-| Non-interactive validation error | 1 |
+| Non-interactive validation error / flag error | 1 |
| Ctrl+C / EOF at any point | 130 |
diff --git a/docs/getting-started.md b/docs/getting-started.md
index b1a3219..3ed9869 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -49,14 +49,14 @@ After installation, `spawn` is available anywhere on your machine.
```bash
spawn version
-# Spawn v1.0.7
+# Spawn v1.0.8
```
Or just run `spawn` with no arguments to see the command overview:
```
SPAWN — scaffold your next project
-v1.0.6
+v1.0.8
Commands
create Scaffold a new project
@@ -95,6 +95,10 @@ Project Name: my-api
○ docker
○ github-actions
+? Choose a license (Use arrow keys)
+ » MIT
+ None
+
Initialize Git? [Y/n]: Y
Also generate CLAUDE.md for Claude Code? [y/N]: N
```
@@ -151,6 +155,10 @@ Project Name: my-cli
○ pytest
○ github-actions
+? Choose a license (Use arrow keys)
+ » MIT
+ None
+
Initialize Git? [Y/n]: Y
Also generate CLAUDE.md for Claude Code? [y/N]: N
```
@@ -165,7 +173,7 @@ uv run python -m src.main hello
### Option C — Non-interactive (zero prompts)
```bash
-spawn create --name my-api --template backend-api --framework fastapi --extras ruff,pytest --no-git
+spawn create --name my-api --template backend-api --framework fastapi --extras ruff,pytest --license mit --no-git
```
Or from a JSON config file:
@@ -176,6 +184,7 @@ Or from a JSON config file:
"template": "backend-api",
"framework": "fastapi",
"extras": ["ruff", "pytest"],
+ "license": "mit",
"git": false,
"uv": true
}
@@ -203,6 +212,8 @@ Every generated project includes:
|---|---|
| `README.md` | Project README with setup and run instructions |
| `AGENTS.md` | Agent context file (structure, setup, conventions) |
+| `CHANGELOG.md` | Keep a Changelog starter documenting project history |
+| `LICENSE` | MIT license (default) |
| `.gitignore` | Python defaults |
| `.spawn/meta.json` | Spawn metadata (intent, framework, version, timestamps) |
| `pyproject.toml` | Project config + installed dependencies |
@@ -223,6 +234,8 @@ my-api/
│ └── test_health.py
├── .env.example
├── AGENTS.md
+├── CHANGELOG.md
+├── LICENSE
├── README.md
├── .gitignore
└── pyproject.toml
@@ -258,6 +271,14 @@ spawn doctor
Spawn scores the project out of 135 across Documentation, Version Control, Configuration, Testing, Automation, and Code Quality, and shows a prioritized recommendation for the most impactful next improvement.
+You can also run `spawn doctor --fix` (with optional `--dry-run`, `--yes`, or `--license mit`) to automatically create missing standard files and baseline configuration:
+
+```bash
+spawn doctor --fix
+```
+
+It **never overwrites** existing files and **never installs packages** into your environment directly.
+
---
## 8. Next steps
diff --git a/pyproject.toml b/pyproject.toml
index 1ffd28a..b95d604 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "spawnio"
-version = "1.0.7"
+version = "1.0.8"
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 707c73f..85d8e4c 100644
--- a/src/spawn/__init__.py
+++ b/src/spawn/__init__.py
@@ -1,6 +1,6 @@
-from importlib.metadata import version, PackageNotFoundError
+from importlib.metadata import PackageNotFoundError, version
try:
__version__ = version("spawnio")
except PackageNotFoundError:
- __version__ = "1.0.7"
+ __version__ = "1.0.8"
diff --git a/src/spawn/cli/app.py b/src/spawn/cli/app.py
index 1ffbb5c..67054bf 100644
--- a/src/spawn/cli/app.py
+++ b/src/spawn/cli/app.py
@@ -8,14 +8,14 @@
from spawn import __version__
from spawn.cli.noninteractive import build_config_from_args, build_config_from_file
from spawn.cli.prompts import get_project_config
+from spawn.core.exceptions import SpawnError
+from spawn.core.registry import instantiate_template
from spawn.generators.project_generator import ProjectGenerator
-from spawn.github.publisher import GitHubPublisher
from spawn.github.exceptions import GitHubPublishError
+from spawn.github.publisher import GitHubPublisher
from spawn.utils.banner import show_banner
-from spawn.utils.success import show_success
from spawn.utils.console import console
-from spawn.core.exceptions import SpawnError
-from spawn.core.registry import instantiate_template
+from spawn.utils.success import show_success
app = typer.Typer()
@@ -46,7 +46,7 @@ def _write_custom_metadata(project_path, config) -> None:
"framework": None,
"provider": None,
"spawn_version": __version__,
- "created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
+ "created_at": datetime.datetime.now(datetime.UTC).isoformat(),
"generator": "custom",
"git": config.use_git,
"uv": config.use_uv,
@@ -66,7 +66,7 @@ def create(
template: str = typer.Option(
None,
"--template",
- help="Template slug: backend-api, cli, automation, chatbot, agent, rag, data",
+ help="Template slug: backend-api, cli, automation, chatbot, agent, rag, data, mcp",
),
framework: str = typer.Option(
None, "--framework", help="Framework choice for templates that support it"
@@ -96,6 +96,11 @@ def create(
"--claude-md/--no-claude-md",
help="Also generate CLAUDE.md alongside AGENTS.md",
),
+ license_kind: str = typer.Option(
+ "mit",
+ "--license",
+ help="License for the new project: mit or none",
+ ),
config_file: str = typer.Option(
None, "--config", help="Path to a JSON config file (overrides other flags)"
),
@@ -115,7 +120,9 @@ def create(
try:
if config_file is not None:
config = build_config_from_file(
- Path(config_file), use_claude_md=claude_md
+ Path(config_file),
+ use_claude_md=claude_md,
+ license_kind=license_kind,
)
else:
if template is None:
@@ -138,7 +145,9 @@ def create(
use_git=git,
use_uv=uv,
use_claude_md=claude_md,
+ license=license_kind,
)
+
except SpawnError as e:
console.print(f"[red]❌ {e}[/red]")
raise typer.Exit(1)
@@ -234,20 +243,123 @@ def doctor(
default=".",
help="Path to the project directory to check. Defaults to current directory.",
),
+ fix: bool = typer.Option(
+ False,
+ "--fix",
+ help="Create missing files and config. Never overwrites, never installs packages",
+ ),
+ dry_run: bool = typer.Option(
+ False,
+ "--dry-run",
+ help="With --fix: show what would change without writing",
+ ),
+ yes: bool = typer.Option(
+ False,
+ "--yes",
+ "-y",
+ help="With --fix: skip the confirmation prompt",
+ ),
+ license_kind: str = typer.Option(
+ None,
+ "--license",
+ help="With --fix: also add a LICENSE (mit)",
+ ),
) -> None:
"""Check the health of a project directory."""
- from pathlib import Path
-
- from spawn.utils.doctor import run_health_check
-
- project_path = Path(path).resolve()
- if not project_path.exists():
- console.print(f"[red]❌ Path does not exist: {project_path}[/red]")
- raise typer.Exit(1)
- if not project_path.is_dir():
- console.print(f"[red]❌ Path is not a directory: {project_path}[/red]")
- raise typer.Exit(1)
- run_health_check(project_path)
+ try:
+ if not fix:
+ if dry_run:
+ console.print("[red]❌ --dry-run requires --fix.[/red]")
+ raise typer.Exit(1)
+ if yes:
+ console.print("[red]❌ --yes requires --fix.[/red]")
+ raise typer.Exit(1)
+ if license_kind is not None:
+ console.print("[red]❌ --license requires --fix.[/red]")
+ raise typer.Exit(1)
+
+ if license_kind is not None and license_kind != "mit":
+ console.print(
+ f"[red]❌ Unsupported license: '{license_kind}'. Valid options: mit[/red]"
+ )
+ raise typer.Exit(1)
+
+ project_path = Path(path).resolve()
+ if not project_path.exists():
+ console.print(f"[red]❌ Path does not exist: {project_path}[/red]")
+ raise typer.Exit(1)
+ if not project_path.is_dir():
+ console.print(f"[red]❌ Path is not a directory: {project_path}[/red]")
+ raise typer.Exit(1)
+
+ from spawn.utils.doctor import ProjectHealthChecker, run_health_check
+
+ if not fix:
+ run_health_check(project_path)
+ return
+
+ from spawn.utils.doctor_fix import apply_fixes, plan_fixes, tool_hints
+
+ checker = ProjectHealthChecker(project_path)
+ checks = checker.run_all_checks()
+ checker.format_report(checks)
+
+ actions, manual = plan_fixes(project_path, checks, license_kind=license_kind)
+
+ if not actions and not manual:
+ console.print("[green]✓ Nothing to fix.[/green]")
+ return
+
+ if actions:
+ console.print("[bold]Planned fixes:[/bold]")
+ for a in actions:
+ console.print(f" • {a.description}")
+
+ if manual:
+ if actions:
+ console.print()
+ console.print("[bold]Needs manual action:[/bold]")
+ for check_name, reason in manual:
+ console.print(f" • {check_name}: {reason}")
+
+ if dry_run:
+ return
+
+ if not actions:
+ return
+
+ if not yes and not typer.confirm(f"Apply {len(actions)} fixes?", default=True):
+ console.print("[yellow]Cancelled.[/yellow]")
+ return
+
+ score_before, max_before = checker.calculate_score(checks)
+ pct_before = int((score_before / max_before * 100) if max_before > 0 else 0)
+
+ results = apply_fixes(actions)
+ console.print()
+ for r in results:
+ if r.status == "applied":
+ console.print(f"[green]✓[/green] {r.action.description}")
+ elif r.status == "skipped":
+ detail_msg = f" ({r.detail})" if r.detail else ""
+ console.print(f"[yellow]○[/yellow] {r.action.description}{detail_msg}")
+ else:
+ console.print(f"[red]✗[/red] {r.action.description}: {r.detail}")
+
+ hints = tool_hints(results)
+ if hints:
+ console.print()
+ for hint in hints:
+ console.print(hint)
+
+ new_checks = checker.run_all_checks()
+ score_after, max_after = checker.calculate_score(new_checks)
+ pct_after = int((score_after / max_after * 100) if max_after > 0 else 0)
+ console.print(f"\n[bold]Score: {pct_before}% → {pct_after}%[/bold]")
+
+ except (KeyboardInterrupt, EOFError, typer.Abort):
+ console.print("\n[yellow]Cancelled.[/yellow]")
+ raise typer.Exit(130)
def main():
diff --git a/src/spawn/cli/noninteractive.py b/src/spawn/cli/noninteractive.py
index cff34da..e04218d 100644
--- a/src/spawn/cli/noninteractive.py
+++ b/src/spawn/cli/noninteractive.py
@@ -15,6 +15,7 @@
from spawn.core.exceptions import SpawnError
from spawn.core.models import ProjectConfig
from spawn.core.registry import get_metadata, list_templates
+from spawn.generators.project_files import SUPPORTED_LICENSES
from spawn.templates.agent import get_supported_providers as get_agent_providers
from spawn.templates.chatbot import get_supported_providers as get_chatbot_providers
from spawn.utils.validators import validate_project_name
@@ -31,6 +32,7 @@ def build_config_from_args(
use_git: bool = True,
use_uv: bool = True,
use_claude_md: bool = False,
+ license: str = "mit",
) -> ProjectConfig:
"""
Build and return a validated ProjectConfig from explicit arguments.
@@ -131,10 +133,14 @@ def build_config_from_args(
validated_extras.append(item)
seen.add(item)
+ if license not in SUPPORTED_LICENSES:
+ raise SpawnError(f"Invalid license: '{license}'. Valid options: mit, none")
+
return ProjectConfig(
name=name,
template=template,
use_git=use_git,
+ license=license,
framework=framework,
extras=validated_extras,
cli_type=cli_type,
@@ -145,7 +151,11 @@ def build_config_from_args(
)
-def build_config_from_file(path: Path, use_claude_md: bool = False) -> ProjectConfig:
+def build_config_from_file(
+ path: Path,
+ use_claude_md: bool = False,
+ license_kind: str = "mit",
+) -> ProjectConfig:
"""
Build and return a validated ProjectConfig from a JSON config file.
@@ -188,6 +198,9 @@ def build_config_from_file(path: Path, use_claude_md: bool = False) -> ProjectCo
git: bool = data.get("git", True)
uv: bool = data.get("uv", True)
claude_md: bool = data.get("claude_md", use_claude_md)
+ license_val = data.get("license", license_kind)
+ if not isinstance(license_val, str):
+ raise SpawnError("'license' in config file must be a string.")
return build_config_from_args(
name=name,
@@ -200,4 +213,5 @@ def build_config_from_file(path: Path, use_claude_md: bool = False) -> ProjectCo
use_git=git,
use_uv=uv,
use_claude_md=claude_md,
+ license=license_val,
)
diff --git a/src/spawn/cli/prompts.py b/src/spawn/cli/prompts.py
index f9f1a67..9aec4e0 100644
--- a/src/spawn/cli/prompts.py
+++ b/src/spawn/cli/prompts.py
@@ -1,16 +1,16 @@
-import typer
-import questionary
from pathlib import Path
+
+import questionary
+import typer
from rich.prompt import Confirm
-from spawn.utils.console import console
+from spawn.core.exceptions import SpawnError
from spawn.core.models import ProjectConfig
-from spawn.core.registry import list_templates, get_metadata
-from spawn.templates.chatbot import get_supported_providers as get_chatbot_providers
+from spawn.core.registry import get_metadata, list_templates
from spawn.templates.agent import get_supported_providers as get_agent_providers
+from spawn.templates.chatbot import get_supported_providers as get_chatbot_providers
+from spawn.utils.console import console
from spawn.utils.validators import validate_project_name
-from spawn.core.exceptions import SpawnError
-
# ---------------------------------------------------------------------------
# Questionary helpers
@@ -52,11 +52,12 @@ def _multiselect(message: str, choices: list[str]) -> list[str]:
# Custom Structure optional-setup multi-select
# ---------------------------------------------------------------------------
-_OPTIONAL_SETUP_CHOICES = ["Ruff", "Pytest", "Pre-commit", "Dockerfile"]
+_OPTIONAL_SETUP_CHOICES = ["Ruff", "Pytest", "Pre-commit", "Mypy", "Dockerfile"]
_OPTIONAL_SETUP_KEY = {
"Ruff": "ruff",
"Pytest": "pytest",
"Pre-commit": "precommit",
+ "Mypy": "mypy",
"Dockerfile": "dockerfile",
}
@@ -153,6 +154,9 @@ def get_project_config() -> ProjectConfig:
meta.available_extras,
)
+ license_choice = _select("Choose a license", ["MIT", "None"], default="MIT")
+ license_mapped = {"MIT": "mit", "None": "none"}[license_choice]
+
# --- Git ---
use_git = typer.confirm(
typer.style("Initialize Git?", fg=typer.colors.CYAN),
@@ -167,6 +171,7 @@ def get_project_config() -> ProjectConfig:
template=template,
use_git=use_git,
generate_claude_md=generate_claude_md,
+ license=license_mapped,
framework=selected_framework,
extras=selected_extras,
cli_type=selected_cli_type,
@@ -189,7 +194,7 @@ def _get_custom_structure_config(project_name: str) -> ProjectConfig:
import sys
from spawn.core.exceptions import StructureParseError
- from spawn.generators.custom_structure import parse_structure, detect_format
+ from spawn.generators.custom_structure import detect_format, parse_structure
console.print()
console.print(
diff --git a/src/spawn/core/models.py b/src/spawn/core/models.py
index fe64e8c..f138cf1 100644
--- a/src/spawn/core/models.py
+++ b/src/spawn/core/models.py
@@ -13,6 +13,7 @@ class ProjectConfig:
provider: str | None = None
use_uv: bool = True
generate_claude_md: bool = False
+ license: str = "mit"
custom_entries: list | None = None # list[ParsedEntry] when template == "custom"
custom_dependencies: list[str] = field(default_factory=list)
custom_dev_setup: list[str] = field(default_factory=list)
diff --git a/src/spawn/core/registry.py b/src/spawn/core/registry.py
index bec4d70..d5c1c8b 100644
--- a/src/spawn/core/registry.py
+++ b/src/spawn/core/registry.py
@@ -2,15 +2,15 @@
from dataclasses import dataclass, field
from spawn.core.models import ProjectConfig
-from spawn.templates.backend_api import BackendAPITemplate
-from spawn.templates.cli_application import CLITemplate
+from spawn.templates.agent import AgentTemplate
from spawn.templates.automation import AutomationTemplate
+from spawn.templates.backend_api import BackendAPITemplate
+from spawn.templates.base import BaseTemplate
from spawn.templates.chatbot import ChatbotTemplate
-from spawn.templates.agent import AgentTemplate
-from spawn.templates.rag import RAGTemplate
+from spawn.templates.cli_application import CLITemplate
from spawn.templates.data_project import DataProjectTemplate
from spawn.templates.mcp_server import MCPServerTemplate
-from spawn.templates.base import BaseTemplate
+from spawn.templates.rag import RAGTemplate
@dataclass
@@ -38,7 +38,14 @@ class TemplateMetadata:
description="Production-ready backend with FastAPI, Flask, or Django",
template_class=BackendAPITemplate,
available_frameworks=["fastapi", "flask", "django"],
- available_extras=["ruff", "pytest", "docker", "github-actions"],
+ available_extras=[
+ "ruff",
+ "pytest",
+ "docker",
+ "github-actions",
+ "pre-commit",
+ "mypy",
+ ],
),
"cli": TemplateMetadata(
slug="cli",
@@ -46,7 +53,13 @@ class TemplateMetadata:
description="Command-line application with Typer, Click, or Argparse",
template_class=CLITemplate,
available_frameworks=["typer", "click", "argparse"],
- available_extras=["ruff", "pytest", "github-actions"],
+ available_extras=[
+ "ruff",
+ "pytest",
+ "github-actions",
+ "pre-commit",
+ "mypy",
+ ],
available_cli_types=["utility", "interactive"],
),
"automation": TemplateMetadata(
@@ -54,7 +67,13 @@ class TemplateMetadata:
display_name="Automation Tool",
description="Workflow-based automation with logging, tasks, and integrations",
template_class=AutomationTemplate,
- available_extras=["ruff", "pytest", "github-actions"],
+ available_extras=[
+ "ruff",
+ "pytest",
+ "github-actions",
+ "pre-commit",
+ "mypy",
+ ],
),
"chatbot": TemplateMetadata(
slug="chatbot",
@@ -70,7 +89,14 @@ class TemplateMetadata:
"ollama",
"groq",
],
- available_extras=["ruff", "pytest", "rich", "github-actions"],
+ available_extras=[
+ "ruff",
+ "pytest",
+ "rich",
+ "github-actions",
+ "pre-commit",
+ "mypy",
+ ],
),
"agent": TemplateMetadata(
slug="agent",
@@ -86,21 +112,39 @@ class TemplateMetadata:
"ollama",
"groq",
],
- available_extras=["ruff", "pytest", "github-actions"],
+ available_extras=[
+ "ruff",
+ "pytest",
+ "github-actions",
+ "pre-commit",
+ "mypy",
+ ],
),
"rag": TemplateMetadata(
slug="rag",
display_name="RAG System",
description="Retrieval-Augmented Generation with LlamaIndex and ChromaDB",
template_class=RAGTemplate,
- available_extras=["ruff", "pytest", "github-actions"],
+ available_extras=[
+ "ruff",
+ "pytest",
+ "github-actions",
+ "pre-commit",
+ "mypy",
+ ],
),
"data": TemplateMetadata(
slug="data",
display_name="Data Project",
description="Data Analysis, Dashboard, ETL Pipeline, or Machine Learning project",
template_class=DataProjectTemplate,
- available_extras=["ruff", "pytest", "github-actions"],
+ available_extras=[
+ "ruff",
+ "pytest",
+ "github-actions",
+ "pre-commit",
+ "mypy",
+ ],
available_data_types=[
"Data Analysis",
"Dashboard",
@@ -113,7 +157,13 @@ class TemplateMetadata:
display_name="MCP Server",
description="Model Context Protocol server with the official Python SDK",
template_class=MCPServerTemplate,
- available_extras=["ruff", "pytest", "github-actions"],
+ available_extras=[
+ "ruff",
+ "pytest",
+ "github-actions",
+ "pre-commit",
+ "mypy",
+ ],
),
}
diff --git a/src/spawn/generators/custom_structure.py b/src/spawn/generators/custom_structure.py
index b781caf..e9901ea 100644
--- a/src/spawn/generators/custom_structure.py
+++ b/src/spawn/generators/custom_structure.py
@@ -16,6 +16,8 @@
from pathlib import Path
from spawn.core.exceptions import SpawnError, StructureParseError
+from spawn.generators.project_files import write_mypy_ini
+from spawn.templates.shared_content import PRECOMMIT_CONFIG_CONTENT
from spawn.utils.console import console
from spawn.utils.git import initialize_git
from spawn.utils.uv import initialize_uv, install_packages
@@ -67,9 +69,7 @@ def _is_file_entry(name: str) -> bool:
return True
if "." in segment:
return True
- if segment.lower() in _KNOWN_EXTENSIONLESS_FILES:
- return True
- return False
+ return segment.lower() in _KNOWN_EXTENSIONLESS_FILES
def _strip_name(name: str) -> str:
@@ -468,6 +468,8 @@ def _apply_dev_setup(self, project_path: Path, dev_setup: list[str]) -> None:
dev_deps.append("pytest")
if "precommit" in dev_setup:
dev_deps.append("pre-commit")
+ if "mypy" in dev_setup:
+ dev_deps.append("mypy")
if dev_deps:
console.print("[yellow]Installing dev tools...[/yellow]")
@@ -486,14 +488,13 @@ def _apply_dev_setup(self, project_path: Path, dev_setup: list[str]) -> None:
if "precommit" in dev_setup:
(project_path / ".pre-commit-config.yaml").write_text(
- "repos:\n"
- " - repo: https://github.com/astral-sh/ruff-pre-commit\n"
- " rev: v0.15.16\n"
- " hooks:\n"
- " - id: ruff\n",
+ PRECOMMIT_CONFIG_CONTENT,
encoding="utf-8",
)
+ if "mypy" in dev_setup:
+ write_mypy_ini(project_path)
+
if "dockerfile" in dev_setup:
(project_path / "Dockerfile").write_text(
"FROM python:3.12-slim\n"
diff --git a/src/spawn/generators/project_files.py b/src/spawn/generators/project_files.py
new file mode 100644
index 0000000..5c2b6b3
--- /dev/null
+++ b/src/spawn/generators/project_files.py
@@ -0,0 +1,191 @@
+import datetime
+import re
+import tomllib
+from pathlib import Path
+
+from spawn.core.exceptions import SpawnError
+from spawn.templates.shared_content import (
+ AGENTS_MD_FIX_CONTENT,
+ CHANGELOG_CONTENT,
+ GITHUB_ACTIONS_CI_BASE,
+ GITHUB_ACTIONS_CI_PYTEST_STEP,
+ GITHUB_ACTIONS_CI_RUFF_STEP,
+ GITIGNORE_CONTENT,
+ MIT_LICENSE_CONTENT,
+ MYPY_INI_CONTENT,
+ MYPY_PYPROJECT_SECTION,
+ PRECOMMIT_CONFIG_CONTENT,
+ PYTEST_PYPROJECT_SECTION,
+ RUFF_PYPROJECT_SECTION,
+)
+
+SUPPORTED_LICENSES = ["mit", "none"]
+
+
+def _write_new(path: Path, content: str) -> bool:
+ if path.exists():
+ return False
+ path.parent.mkdir(parents=True, exist_ok=True)
+ path.write_text(content, encoding="utf-8")
+ return True
+
+
+def _append_section(project_path: Path, marker: str, section: str) -> bool:
+ pyproject = project_path / "pyproject.toml"
+ if not pyproject.exists():
+ return False
+ text = pyproject.read_text(encoding="utf-8")
+ if marker in text:
+ return False
+ pyproject.write_text(text + section, encoding="utf-8")
+ return True
+
+
+def write_license(
+ project_path: Path,
+ kind: str,
+ holder: str,
+ year: int | None = None,
+) -> bool:
+ if kind == "none":
+ return False
+ if kind == "mit":
+ target_year = year or datetime.datetime.now(datetime.UTC).year
+ content = MIT_LICENSE_CONTENT.format(year=target_year, holder=holder)
+ return _write_new(project_path / "LICENSE", content)
+ raise SpawnError(f"Unsupported license: '{kind}'.")
+
+
+def write_changelog(project_path: Path) -> bool:
+ return _write_new(project_path / "CHANGELOG.md", CHANGELOG_CONTENT)
+
+
+def write_precommit_config(project_path: Path) -> bool:
+ return _write_new(
+ project_path / ".pre-commit-config.yaml", PRECOMMIT_CONFIG_CONTENT
+ )
+
+
+def write_gitignore(project_path: Path) -> bool:
+ return _write_new(project_path / ".gitignore", GITIGNORE_CONTENT)
+
+
+def add_ruff_config(project_path: Path) -> bool:
+ return _append_section(project_path, "[tool.ruff]", RUFF_PYPROJECT_SECTION)
+
+
+def add_mypy_config(project_path: Path) -> bool:
+ return _append_section(project_path, "[tool.mypy]", MYPY_PYPROJECT_SECTION)
+
+
+def add_pytest_config(project_path: Path) -> bool:
+ return _append_section(project_path, "[tool.pytest", PYTEST_PYPROJECT_SECTION)
+
+
+def write_mypy_ini(project_path: Path) -> bool:
+ return _write_new(project_path / "mypy.ini", MYPY_INI_CONTENT)
+
+
+def declares_dependency(project_path: Path, name: str) -> bool:
+ pyproject = project_path / "pyproject.toml"
+ if not pyproject.exists():
+ return False
+ try:
+ with pyproject.open("rb") as f:
+ data = tomllib.load(f)
+ except (OSError, tomllib.TOMLDecodeError):
+ return False
+
+ requirements: list[str] = []
+ project = data.get("project")
+ if isinstance(project, dict):
+ deps = project.get("dependencies")
+ if isinstance(deps, list):
+ for item in deps:
+ if isinstance(item, str):
+ requirements.append(item)
+
+ dep_groups = data.get("dependency-groups")
+ if isinstance(dep_groups, dict):
+ for group in dep_groups.values():
+ if isinstance(group, list):
+ for item in group:
+ if isinstance(item, str):
+ requirements.append(item)
+
+ tool = data.get("tool")
+ if isinstance(tool, dict):
+ uv = tool.get("uv")
+ if isinstance(uv, dict):
+ uv_dev = uv.get("dev-dependencies")
+ if isinstance(uv_dev, list):
+ for item in uv_dev:
+ if isinstance(item, str):
+ requirements.append(item)
+
+ target = name.lower()
+ for req in requirements:
+ match = re.match(r"[A-Za-z0-9_.-]+", req)
+ if match and match.group(0).lower() == target:
+ return True
+ return False
+
+
+def write_ci_workflow(project_path: Path) -> bool:
+ workflows_dir = project_path / ".github" / "workflows"
+ if workflows_dir.is_dir() and (
+ any(workflows_dir.glob("*.yml")) or any(workflows_dir.glob("*.yaml"))
+ ):
+ return False
+ content = GITHUB_ACTIONS_CI_BASE
+ if declares_dependency(project_path, "ruff"):
+ content += GITHUB_ACTIONS_CI_RUFF_STEP
+ if declares_dependency(project_path, "pytest"):
+ content += GITHUB_ACTIONS_CI_PYTEST_STEP
+ return _write_new(workflows_dir / "ci.yml", content)
+
+
+def write_readme_from_project(project_path: Path) -> bool:
+ name = project_path.name
+ description = None
+ pyproject = project_path / "pyproject.toml"
+ if pyproject.exists():
+ try:
+ with pyproject.open("rb") as f:
+ data = tomllib.load(f)
+ project = data.get("project")
+ if isinstance(project, dict):
+ proj_name = project.get("name")
+ if isinstance(proj_name, str) and proj_name.strip():
+ name = proj_name
+ proj_desc = project.get("description")
+ if isinstance(proj_desc, str) and proj_desc:
+ description = proj_desc
+ except (OSError, tomllib.TOMLDecodeError):
+ pass
+
+ content = f"# {name}\n"
+ if description:
+ content += f"\n{description}\n"
+ return _write_new(project_path / "README.md", content)
+
+
+def write_agents_md_generic(project_path: Path) -> bool:
+ uv = (project_path / "uv.lock").exists()
+ install_cmd = "uv sync" if uv else "pip install -e ."
+ test_cmd = "uv run pytest" if uv else "pytest"
+ content = AGENTS_MD_FIX_CONTENT.format(
+ project_name=project_path.name,
+ install_cmd=install_cmd,
+ test_cmd=test_cmd,
+ )
+ return _write_new(project_path / "AGENTS.md", content)
+
+
+def write_tests_dir(project_path: Path) -> bool:
+ tests_dir = project_path / "tests"
+ if tests_dir.exists():
+ return False
+ tests_dir.mkdir(parents=True, exist_ok=True)
+ (tests_dir / "__init__.py").write_text("", encoding="utf-8")
+ return True
diff --git a/src/spawn/generators/project_generator.py b/src/spawn/generators/project_generator.py
index 0ad992e..259e80b 100644
--- a/src/spawn/generators/project_generator.py
+++ b/src/spawn/generators/project_generator.py
@@ -4,20 +4,40 @@
from pathlib import Path
from spawn import __version__
+from spawn.core.exceptions import SpawnError
+from spawn.core.models import ProjectConfig
+from spawn.core.registry import instantiate_template
+from spawn.generators.project_files import (
+ add_mypy_config,
+ write_changelog,
+ write_license,
+ write_precommit_config,
+)
from spawn.templates.shared_content import (
- README_CONTENT,
- GITIGNORE_CONTENT,
AGENTS_MD_CONTENT,
+ GITIGNORE_CONTENT,
+ README_CONTENT,
)
-from spawn.core.models import ProjectConfig
-from spawn.core.registry import instantiate_template
-from spawn.utils.git import initialize_git
-from spawn.utils.uv import initialize_uv, install_packages
-from spawn.core.exceptions import SpawnError
from spawn.utils.console import console
+from spawn.utils.git import get_git_user_name, initialize_git
+from spawn.utils.uv import initialize_uv, install_packages
class ProjectGenerator:
+ def _apply_quality_extras(self, project_path: Path, extras: list[str]) -> None:
+ dev_deps = []
+ if "mypy" in extras:
+ dev_deps.append("mypy")
+ if "pre-commit" in extras:
+ dev_deps.append("pre-commit")
+ if dev_deps:
+ console.print("[yellow]Installing dev tools...[/yellow]")
+ install_packages(project_path, dev_deps, dev=True)
+ if "mypy" in extras:
+ add_mypy_config(project_path)
+ if "pre-commit" in extras:
+ write_precommit_config(project_path)
+
def generate(self, config: ProjectConfig) -> Path:
template = instantiate_template(config)
@@ -53,6 +73,11 @@ def generate(self, config: ProjectConfig) -> Path:
claude_md_path = project_path / "CLAUDE.md"
claude_md_path.write_text(agents_md_content, encoding="utf-8")
+ write_changelog(project_path)
+ if config.license != "none":
+ holder = get_git_user_name() or f"{config.name} contributors"
+ write_license(project_path, config.license, holder)
+
gitignore_path = project_path / ".gitignore"
gitignore_path.write_text(
@@ -72,6 +97,7 @@ def generate(self, config: ProjectConfig) -> Path:
install_packages(project_path, deps)
template.post_install(project_path)
+ self._apply_quality_extras(project_path, config.extras)
meta_dir = project_path / ".spawn"
meta_dir.mkdir()
@@ -83,9 +109,7 @@ def generate(self, config: ProjectConfig) -> Path:
"framework": config.framework,
"provider": config.provider,
"spawn_version": __version__,
- "created_at": datetime.datetime.now(
- datetime.timezone.utc
- ).isoformat(),
+ "created_at": datetime.datetime.now(datetime.UTC).isoformat(),
"generator": "blueprint",
"git": config.use_git,
"uv": True,
diff --git a/src/spawn/templates/shared_content.py b/src/spawn/templates/shared_content.py
index 1bcbfaf..8f1379e 100644
--- a/src/spawn/templates/shared_content.py
+++ b/src/spawn/templates/shared_content.py
@@ -87,3 +87,98 @@
- Run `uv run ruff check .` before committing, if Ruff is configured
for this project (see `pyproject.toml`).
"""
+
+RUFF_PRECOMMIT_REV = "v0.15.16"
+
+MIT_LICENSE_CONTENT = """MIT License
+
+Copyright (c) {year} {holder}
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+"""
+
+CHANGELOG_CONTENT = """# Changelog
+
+All notable changes to this project are documented here.
+Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
+
+## [Unreleased]
+"""
+
+PRECOMMIT_CONFIG_CONTENT = (
+ "repos:\n"
+ " - repo: https://github.com/astral-sh/ruff-pre-commit\n"
+ f" rev: {RUFF_PRECOMMIT_REV}\n"
+ " hooks:\n"
+ " - id: ruff\n"
+)
+
+RUFF_PYPROJECT_SECTION = "\n[tool.ruff]\nline-length = 88\n"
+MYPY_PYPROJECT_SECTION = (
+ '\n[tool.mypy]\npython_version = "3.12"\nignore_missing_imports = true\n'
+)
+PYTEST_PYPROJECT_SECTION = '\n[tool.pytest.ini_options]\ntestpaths = ["tests"]\n'
+MYPY_INI_CONTENT = "[mypy]\npython_version = 3.12\nignore_missing_imports = True\n"
+
+AGENTS_MD_FIX_CONTENT = (
+ "# Agent Context: {project_name}\n\n"
+ "This file orients coding agents working in this repository.\n\n"
+ "## Project structure\n\n"
+ "See the folder layout in `README.md`.\n\n"
+ "## Setup\n\n"
+ "```bash\n"
+ "{install_cmd}\n"
+ "```\n\n"
+ "## Running tests\n\n"
+ "```bash\n"
+ "{test_cmd}\n"
+ "```\n"
+)
+
+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/src/spawn/utils/doctor_fix.py b/src/spawn/utils/doctor_fix.py
new file mode 100644
index 0000000..286f24d
--- /dev/null
+++ b/src/spawn/utils/doctor_fix.py
@@ -0,0 +1,226 @@
+from collections.abc import Callable
+from dataclasses import dataclass
+from pathlib import Path
+
+from spawn.core.exceptions import SpawnError
+from spawn.generators.project_files import (
+ add_mypy_config,
+ add_pytest_config,
+ add_ruff_config,
+ write_agents_md_generic,
+ write_changelog,
+ write_ci_workflow,
+ write_gitignore,
+ write_license,
+ write_precommit_config,
+ write_readme_from_project,
+ write_tests_dir,
+)
+from spawn.utils.doctor import HealthCheck
+from spawn.utils.git import get_git_user_name, initialize_git
+
+
+@dataclass
+class FixAction:
+ check: str
+ description: str
+ apply: Callable[[], bool]
+
+
+@dataclass
+class FixResult:
+ action: FixAction
+ status: str
+ detail: str = ""
+
+
+_ALWAYS_MANUAL: dict[str, str] = {
+ "Dockerfile": "needs your app's entrypoint",
+ ".env.example": "depends on your project's variables",
+ "pyproject.toml": "run 'uv init'",
+}
+
+_DEV_TOOL_PACKAGES: list[tuple[str, str]] = [
+ ("Pytest", "pytest"),
+ ("Ruff", "ruff"),
+ ("Type Checking", "mypy"),
+ ("Pre-commit", "pre-commit"),
+]
+
+
+def plan_fixes(
+ project_path: Path,
+ checks: list[HealthCheck],
+ license_kind: str | None = None,
+) -> tuple[list[FixAction], list[tuple[str, str]]]:
+ failed = {c.name for c in checks if not c.passed}
+ has_pyproject = (project_path / "pyproject.toml").is_file()
+
+ actions: list[FixAction] = []
+ manual: list[tuple[str, str]] = []
+
+ if "README.md" in failed:
+ actions.append(
+ FixAction(
+ check="README.md",
+ description="Create README.md",
+ apply=lambda: write_readme_from_project(project_path),
+ )
+ )
+
+ if "AGENTS.md" in failed:
+ actions.append(
+ FixAction(
+ check="AGENTS.md",
+ description="Create AGENTS.md",
+ apply=lambda: write_agents_md_generic(project_path),
+ )
+ )
+
+ if ".gitignore" in failed:
+ actions.append(
+ FixAction(
+ check=".gitignore",
+ description="Create .gitignore",
+ apply=lambda: write_gitignore(project_path),
+ )
+ )
+
+ if "CHANGELOG.md" in failed:
+ actions.append(
+ FixAction(
+ check="CHANGELOG.md",
+ description="Create CHANGELOG.md",
+ apply=lambda: write_changelog(project_path),
+ )
+ )
+
+ if "LICENSE" in failed:
+ if license_kind == "mit":
+
+ def _apply_license() -> bool:
+ holder = get_git_user_name() or f"{project_path.name} contributors"
+ return write_license(project_path, "mit", holder)
+
+ actions.append(
+ FixAction(
+ check="LICENSE",
+ description="Create LICENSE (MIT)",
+ apply=_apply_license,
+ )
+ )
+ else:
+ manual.append(("LICENSE", "pass --license mit to add one"))
+
+ if "Tests" in failed:
+ actions.append(
+ FixAction(
+ check="Tests",
+ description="Create tests/ directory",
+ apply=lambda: write_tests_dir(project_path),
+ )
+ )
+
+ if "Pytest" in failed:
+ if has_pyproject:
+ actions.append(
+ FixAction(
+ check="Pytest",
+ description="Add Pytest configuration to pyproject.toml",
+ apply=lambda: add_pytest_config(project_path),
+ )
+ )
+ else:
+ manual.append(("Pytest", "no pyproject.toml"))
+
+ if "Ruff" in failed:
+ if has_pyproject:
+ actions.append(
+ FixAction(
+ check="Ruff",
+ description="Add Ruff configuration to pyproject.toml",
+ apply=lambda: add_ruff_config(project_path),
+ )
+ )
+ else:
+ manual.append(("Ruff", "no pyproject.toml"))
+
+ if "Type Checking" in failed:
+ if has_pyproject:
+ actions.append(
+ FixAction(
+ check="Type Checking",
+ description="Add Mypy configuration to pyproject.toml",
+ apply=lambda: add_mypy_config(project_path),
+ )
+ )
+ else:
+ manual.append(("Type Checking", "no pyproject.toml"))
+
+ if "Pre-commit" in failed:
+ actions.append(
+ FixAction(
+ check="Pre-commit",
+ description="Create .pre-commit-config.yaml",
+ apply=lambda: write_precommit_config(project_path),
+ )
+ )
+
+ if "GitHub Actions" in failed:
+ if has_pyproject:
+ actions.append(
+ FixAction(
+ check="GitHub Actions",
+ description="Create GitHub Actions CI workflow",
+ apply=lambda: write_ci_workflow(project_path),
+ )
+ )
+ else:
+ manual.append(("GitHub Actions", "no pyproject.toml"))
+
+ if "Git Repository" in failed:
+
+ def _apply_git() -> bool:
+ initialize_git(project_path)
+ return True
+
+ actions.append(
+ FixAction(
+ check="Git Repository",
+ description="Initialize Git repository",
+ apply=_apply_git,
+ )
+ )
+
+ for check_name, reason in _ALWAYS_MANUAL.items():
+ if check_name in failed:
+ manual.append((check_name, reason))
+
+ return actions, manual
+
+
+def apply_fixes(actions: list[FixAction]) -> list[FixResult]:
+ results: list[FixResult] = []
+ for action in actions:
+ try:
+ ok = action.apply()
+ if ok:
+ results.append(FixResult(action=action, status="applied", detail=""))
+ else:
+ results.append(
+ FixResult(action=action, status="skipped", detail="already exists")
+ )
+ except (SpawnError, OSError) as e:
+ results.append(FixResult(action=action, status="failed", detail=str(e)))
+ return results
+
+
+def tool_hints(results: list[FixResult]) -> list[str]:
+ applied = {r.action.check for r in results if r.status == "applied"}
+ tools = [pkg for check, pkg in _DEV_TOOL_PACKAGES if check in applied]
+ hints: list[str] = []
+ if tools:
+ hints.append(f"uv add --dev {' '.join(tools)}")
+ if "Pre-commit" in applied:
+ hints.append("pre-commit install")
+ return hints
diff --git a/src/spawn/utils/git.py b/src/spawn/utils/git.py
index 7832b69..0a76ace 100644
--- a/src/spawn/utils/git.py
+++ b/src/spawn/utils/git.py
@@ -127,3 +127,18 @@ def is_git_repository(
) -> bool:
"""Check if the given path is inside a Git repository."""
return (project_path / ".git").is_dir()
+
+
+def get_git_user_name() -> str | None:
+ try:
+ result = subprocess.run(
+ ["git", "config", "user.name"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+ name = result.stdout.strip()
+ return name if name else None
+
+ except (FileNotFoundError, subprocess.CalledProcessError):
+ return None
diff --git a/tests/test_app.py b/tests/test_app.py
index 6d1a4ad..4143a46 100644
--- a/tests/test_app.py
+++ b/tests/test_app.py
@@ -6,6 +6,8 @@
from pathlib import Path
from unittest.mock import patch
+
+import typer
from typer.testing import CliRunner
from spawn.cli.app import app
@@ -192,6 +194,103 @@ def test_doctor_path_is_file_not_directory(tmp_path):
assert "Path is not a directory" in result.output
+# ---------------------------------------------------------------------------
+# spawn doctor — --fix, --dry-run, --yes, --license
+# ---------------------------------------------------------------------------
+
+
+def test_doctor_without_flags_unchanged(tmp_path):
+ result = runner.invoke(app, ["doctor", str(tmp_path)])
+ assert result.exit_code == 0
+ assert "Project Health Report" in result.output
+ assert "→" not in result.output
+ assert "Planned fixes" not in result.output
+
+
+def test_doctor_dry_run_alone_exits_1(tmp_path):
+ result = runner.invoke(app, ["doctor", str(tmp_path), "--dry-run"])
+ assert result.exit_code == 1
+ assert "--dry-run requires --fix" in result.output
+
+
+def test_doctor_fix_dry_run_writes_nothing(tmp_path):
+ (tmp_path / "pyproject.toml").write_text(
+ '[project]\nname = "demo"\n', encoding="utf-8"
+ )
+ files_before = set(tmp_path.iterdir())
+ result = runner.invoke(app, ["doctor", str(tmp_path), "--fix", "--dry-run"])
+ assert result.exit_code == 0
+ assert "Planned fixes" in result.output
+ assert set(tmp_path.iterdir()) == files_before
+
+
+def test_doctor_fix_yes_creates_files_and_score_improves(tmp_path):
+ (tmp_path / "pyproject.toml").write_text(
+ '[project]\nname = "demo"\n', encoding="utf-8"
+ )
+
+ def fake_git(p):
+ (p / ".git").mkdir()
+
+ with patch("spawn.utils.doctor_fix.initialize_git", side_effect=fake_git):
+ result = runner.invoke(app, ["doctor", str(tmp_path), "--fix", "--yes"])
+ assert result.exit_code == 0
+ assert (tmp_path / "README.md").is_file()
+ assert (tmp_path / ".gitignore").is_file()
+ assert "Score:" in result.output
+ assert "→" in result.output
+
+
+def test_doctor_second_run_prints_nothing_to_fix_or_manual_items(tmp_path):
+ (tmp_path / "pyproject.toml").write_text(
+ '[project]\nname = "demo"\n', encoding="utf-8"
+ )
+
+ def fake_git(p):
+ (p / ".git").mkdir()
+
+ with patch("spawn.utils.doctor_fix.initialize_git", side_effect=fake_git):
+ runner.invoke(app, ["doctor", str(tmp_path), "--fix", "--yes"])
+ result = runner.invoke(app, ["doctor", str(tmp_path), "--fix", "--yes"])
+ assert result.exit_code == 0
+ assert "Nothing to fix" in result.output or "Needs manual action" in result.output
+ assert "Planned fixes" not in result.output
+
+
+def test_doctor_fix_yes_license_mit_writes_license(tmp_path):
+ (tmp_path / "pyproject.toml").write_text(
+ '[project]\nname = "demo"\n', encoding="utf-8"
+ )
+ with patch("spawn.utils.doctor_fix.initialize_git"):
+ result = runner.invoke(
+ app, ["doctor", str(tmp_path), "--fix", "--yes", "--license", "mit"]
+ )
+ assert result.exit_code == 0
+ assert (tmp_path / "LICENSE").is_file()
+ assert "MIT License" in (tmp_path / "LICENSE").read_text(encoding="utf-8")
+
+
+def test_doctor_fix_with_input_n_applies_nothing(tmp_path):
+ (tmp_path / "pyproject.toml").write_text(
+ '[project]\nname = "demo"\n', encoding="utf-8"
+ )
+ files_before = set(tmp_path.iterdir())
+ result = runner.invoke(app, ["doctor", str(tmp_path), "--fix"], input="n\n")
+ assert result.exit_code == 0
+ assert "Cancelled." in result.output
+ assert set(tmp_path.iterdir()) == files_before
+
+
+def test_doctor_typer_abort_exits_130_with_cancelled(tmp_path):
+ (tmp_path / "pyproject.toml").write_text(
+ '[project]\nname = "demo"\n', encoding="utf-8"
+ )
+ with patch("spawn.cli.app.typer.confirm", side_effect=typer.Abort):
+ result = runner.invoke(app, ["doctor", str(tmp_path), "--fix"])
+ assert result.exit_code == 130
+ assert "Cancelled." in result.output
+
+
# ---------------------------------------------------------------------------
# spawn create — non-interactive (--name / --template flags)
# ---------------------------------------------------------------------------
@@ -422,6 +521,33 @@ def capture_generate(config):
assert captured["config"].generate_claude_md is False
+@patch("spawn.cli.app.get_project_config")
+@patch("spawn.cli.app.ProjectGenerator")
+@patch("spawn.cli.app.show_success")
+@patch("spawn.cli.app.instantiate_template")
+def test_license_none_reaches_generator_config(
+ mock_instantiate, mock_show_success, mock_generator_cls, mock_get_config
+):
+ """--license none must result in license='none' on the config passed to generate()."""
+ captured = {}
+
+ def capture_generate(config):
+ captured["config"] = config
+ return Path("demo")
+
+ mock_generator_cls.return_value.generate.side_effect = capture_generate
+ mock_instantiate.return_value = None
+
+ result = runner.invoke(
+ app,
+ ["create", "--name", "demo", "--template", "automation", "--license", "none"],
+ )
+
+ assert result.exit_code == 0
+ assert "config" in captured
+ assert captured["config"].license == "none"
+
+
# ---------------------------------------------------------------------------
# Bug regressions — Bug 1 (config-file path for --claude-md)
# ---------------------------------------------------------------------------
diff --git a/tests/test_custom_structure.py b/tests/test_custom_structure.py
index 158cbbb..301b4c1 100644
--- a/tests/test_custom_structure.py
+++ b/tests/test_custom_structure.py
@@ -367,11 +367,13 @@ def patched_mkdir(self, *args, **kwargs):
raise OSError("simulated disk error")
return original_mkdir(self, *args, **kwargs)
- with patch.object(Path, "mkdir", patched_mkdir):
- with pytest.raises((SpawnError, OSError)):
- CustomStructureGenerator().generate(
- "my-project", entries, use_git=False, use_uv=False
- )
+ with (
+ patch.object(Path, "mkdir", patched_mkdir),
+ pytest.raises((SpawnError, OSError)),
+ ):
+ CustomStructureGenerator().generate(
+ "my-project", entries, use_git=False, use_uv=False
+ )
assert not (tmp_path / "my-project").exists(), (
"rollback failed — directory still exists"
@@ -507,6 +509,25 @@ def test_dockerfile_selected_creates_dockerfile(tmp_path, monkeypatch):
assert "python:3.12-slim" in df.read_text(encoding="utf-8")
+def test_mypy_selected_creates_mypy_ini(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ project_path = _generate_with_dev_setup(tmp_path, ["mypy"])
+ cfg = project_path / "mypy.ini"
+ assert cfg.is_file()
+ assert "[mypy]" in cfg.read_text(encoding="utf-8")
+
+
+def test_mypy_in_optional_setup_menu():
+ from spawn.cli.prompts import _OPTIONAL_SETUP_CHOICES, _OPTIONAL_SETUP_KEY
+
+ assert "Mypy" in _OPTIONAL_SETUP_CHOICES
+ assert (
+ _OPTIONAL_SETUP_CHOICES.index("Mypy")
+ == _OPTIONAL_SETUP_CHOICES.index("Pre-commit") + 1
+ )
+ assert _OPTIONAL_SETUP_KEY["Mypy"] == "mypy"
+
+
def test_dev_setup_skipped_when_use_uv_false(tmp_path, monkeypatch):
"""Even with dev_setup=['ruff'], nothing is created when use_uv=False."""
monkeypatch.chdir(tmp_path)
@@ -534,6 +555,24 @@ def test_install_packages_called_with_dev_flag(tmp_path, monkeypatch):
)
+def test_install_packages_called_with_mypy(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ entries = parse_structure(_TREE_RAW)
+ with patch(_PATCH_UV), patch(_PATCH_GIT), patch(_PATCH_INST) as mock_install:
+ CustomStructureGenerator().generate(
+ "dev-project",
+ entries,
+ use_git=False,
+ use_uv=True,
+ dev_setup=["mypy"],
+ )
+ mock_install.assert_called_once_with(
+ Path("dev-project"),
+ ["mypy"],
+ dev=True,
+ )
+
+
def test_dockerfile_no_dependency_install(tmp_path, monkeypatch):
"""Dockerfile-only selection must NOT trigger install_packages at all."""
monkeypatch.chdir(tmp_path)
@@ -588,7 +627,7 @@ def test_readme_populated_when_present(tmp_path, monkeypatch):
def test_readme_contains_structure_tree(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
- project_path, entries = _gen_with_readme(tmp_path, _README_RAW)
+ project_path, _entries = _gen_with_readme(tmp_path, _README_RAW)
content = (project_path / "README.md").read_text(encoding="utf-8")
# names from the parsed entries should appear in the tree section
assert "app/" in content
diff --git a/tests/test_doctor_fix.py b/tests/test_doctor_fix.py
new file mode 100644
index 0000000..9e498bb
--- /dev/null
+++ b/tests/test_doctor_fix.py
@@ -0,0 +1,223 @@
+from unittest.mock import patch
+
+from spawn.core.exceptions import SpawnError
+from spawn.generators.project_files import (
+ write_gitignore,
+ write_readme_from_project,
+)
+from spawn.utils.doctor import ProjectHealthChecker
+from spawn.utils.doctor_fix import (
+ FixAction,
+ FixResult,
+ apply_fixes,
+ plan_fixes,
+ tool_hints,
+)
+
+
+def test_bare_project_plan_apply_and_recheck(tmp_path):
+ project = tmp_path / "my_proj"
+ project.mkdir()
+ (project / "pyproject.toml").write_text(
+ '[project]\nname = "my_proj"\nversion = "0.1.0"\n',
+ encoding="utf-8",
+ )
+ (project / "src").mkdir()
+ (project / "src" / "main.py").write_text('print("hello")\n', encoding="utf-8")
+
+ checker = ProjectHealthChecker(project)
+ initial_checks = checker.run_all_checks()
+
+ def fake_git(p):
+ (p / ".git").mkdir()
+
+ with patch("spawn.utils.doctor_fix.initialize_git", side_effect=fake_git):
+ actions, _manual = plan_fixes(project, initial_checks, license_kind="mit")
+ results = apply_fixes(actions)
+
+ assert all(r.status == "applied" for r in results)
+
+ fresh_checker = ProjectHealthChecker(project)
+ post_checks = {c.name: c for c in fresh_checker.run_all_checks()}
+
+ fixed_check_names = [a.check for a in actions]
+ for name in fixed_check_names:
+ assert post_checks[name].passed is True
+
+ second_checks = fresh_checker.run_all_checks()
+ second_actions, _ = plan_fixes(project, second_checks, license_kind="mit")
+ assert second_actions == []
+
+
+def test_preexisting_files_byte_identical(tmp_path):
+ project = tmp_path / "my_proj"
+ project.mkdir()
+ readme = project / "README.md"
+ readme_content = "# Custom Readme\nDo not overwrite.\n"
+ readme.write_text(readme_content, encoding="utf-8")
+
+ gitignore = project / ".gitignore"
+ gi_content = "*.custom\n"
+ gitignore.write_text(gi_content, encoding="utf-8")
+
+ (project / "pyproject.toml").write_text(
+ '[project]\nname = "my_proj"\n',
+ encoding="utf-8",
+ )
+
+ action_readme = FixAction(
+ "README.md", "desc", lambda: write_readme_from_project(project)
+ )
+ action_gi = FixAction(".gitignore", "desc", lambda: write_gitignore(project))
+ results = apply_fixes([action_readme, action_gi])
+
+ assert all(r.status == "skipped" for r in results)
+ assert all(r.detail == "already exists" for r in results)
+ assert readme.read_text(encoding="utf-8") == readme_content
+ assert gitignore.read_text(encoding="utf-8") == gi_content
+
+
+def test_no_pyproject_sends_config_fixers_to_manual(tmp_path):
+ project = tmp_path / "no_pyproject"
+ project.mkdir()
+
+ checker = ProjectHealthChecker(project)
+ checks = checker.run_all_checks()
+
+ actions, manual = plan_fixes(project, checks)
+ manual_dict = dict(manual)
+
+ assert manual_dict["Pytest"] == "no pyproject.toml"
+ assert manual_dict["Ruff"] == "no pyproject.toml"
+ assert manual_dict["Type Checking"] == "no pyproject.toml"
+ assert manual_dict["GitHub Actions"] == "no pyproject.toml"
+ assert manual_dict["pyproject.toml"] == "run 'uv init'"
+
+ action_checks = {a.check for a in actions}
+ assert "Pytest" not in action_checks
+ assert "Ruff" not in action_checks
+ assert "Type Checking" not in action_checks
+ assert "GitHub Actions" not in action_checks
+
+
+def test_license_manual_without_license_kind(tmp_path):
+ project = tmp_path / "lic_test"
+ project.mkdir()
+ checker = ProjectHealthChecker(project)
+ checks = checker.run_all_checks()
+
+ actions, manual = plan_fixes(project, checks, license_kind=None)
+ manual_dict = dict(manual)
+ assert manual_dict["LICENSE"] == "pass --license mit to add one"
+ assert not any(a.check == "LICENSE" for a in actions)
+
+
+def test_license_written_with_mit(tmp_path):
+ project = tmp_path / "lic_test_mit"
+ project.mkdir()
+ checker = ProjectHealthChecker(project)
+ checks = checker.run_all_checks()
+
+ actions, _ = plan_fixes(project, checks, license_kind="mit")
+ lic_action = next(a for a in actions if a.check == "LICENSE")
+ results = apply_fixes([lic_action])
+
+ assert results[0].status == "applied"
+ lic_file = project / "LICENSE"
+ assert lic_file.is_file()
+ assert "MIT License" in lic_file.read_text(encoding="utf-8")
+
+
+def test_ci_workflow_step_inclusion(tmp_path):
+ p1 = tmp_path / "p1"
+ p1.mkdir()
+ (p1 / "pyproject.toml").write_text('[project]\nname = "p1"\n', encoding="utf-8")
+ checker1 = ProjectHealthChecker(p1)
+ actions1, _ = plan_fixes(p1, checker1.run_all_checks())
+ ci_action1 = next(a for a in actions1 if a.check == "GitHub Actions")
+ apply_fixes([ci_action1])
+ ci_content1 = (p1 / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
+ assert "pytest" not in ci_content1
+ assert "ruff" not in ci_content1
+
+ p2 = tmp_path / "p2"
+ p2.mkdir()
+ (p2 / "pyproject.toml").write_text(
+ '[project]\nname = "p2"\ndependencies = ["ruff", "pytest"]\n',
+ encoding="utf-8",
+ )
+ checker2 = ProjectHealthChecker(p2)
+ actions2, _ = plan_fixes(p2, checker2.run_all_checks())
+ ci_action2 = next(a for a in actions2 if a.check == "GitHub Actions")
+ apply_fixes([ci_action2])
+ ci_content2 = (p2 / ".github" / "workflows" / "ci.yml").read_text(encoding="utf-8")
+ assert "uv run pytest" in ci_content2
+ assert "uv run ruff check" in ci_content2
+
+
+def test_git_init_mock_and_failure_path(tmp_path):
+ project = tmp_path / "git_fail"
+ project.mkdir()
+ checker = ProjectHealthChecker(project)
+ actions, _ = plan_fixes(project, checker.run_all_checks())
+ git_action = next(a for a in actions if a.check == "Git Repository")
+
+ with patch("spawn.utils.doctor_fix.initialize_git") as mock_init:
+ res = apply_fixes([git_action])
+ assert res[0].status == "applied"
+ assert res[0].detail == ""
+ mock_init.assert_called_once_with(project)
+
+ with patch(
+ "spawn.utils.doctor_fix.initialize_git",
+ side_effect=SpawnError("Git not found"),
+ ):
+ res = apply_fixes([git_action])
+ assert res[0].status == "failed"
+ assert res[0].detail == "Git not found"
+
+ with patch(
+ "spawn.utils.doctor_fix.initialize_git",
+ side_effect=OSError("Disk error"),
+ ):
+ res = apply_fixes([git_action])
+ assert res[0].status == "failed"
+ assert res[0].detail == "Disk error"
+
+
+def test_tool_hints():
+ a_pytest = FixAction("Pytest", "", lambda: True)
+ a_ruff = FixAction("Ruff", "", lambda: True)
+ a_mypy = FixAction("Type Checking", "", lambda: True)
+ a_precommit = FixAction("Pre-commit", "", lambda: True)
+ a_readme = FixAction("README.md", "", lambda: True)
+
+ results = [
+ FixResult(a_pytest, "applied"),
+ FixResult(a_ruff, "applied"),
+ FixResult(a_mypy, "applied"),
+ FixResult(a_precommit, "applied"),
+ ]
+ assert tool_hints(results) == [
+ "uv add --dev pytest ruff mypy pre-commit",
+ "pre-commit install",
+ ]
+
+ results_sub = [
+ FixResult(a_pytest, "applied"),
+ FixResult(a_precommit, "applied"),
+ ]
+ assert tool_hints(results_sub) == [
+ "uv add --dev pytest pre-commit",
+ "pre-commit install",
+ ]
+
+ results_non_tool = [
+ FixResult(a_readme, "applied"),
+ ]
+ assert tool_hints(results_non_tool) == []
+
+ results_skipped = [
+ FixResult(a_pytest, "skipped", "already exists"),
+ ]
+ assert tool_hints(results_skipped) == []
diff --git a/tests/test_generator.py b/tests/test_generator.py
index 1f242da..05b6bea 100644
--- a/tests/test_generator.py
+++ b/tests/test_generator.py
@@ -1,13 +1,15 @@
-import pytest
-
+from pathlib import Path
from unittest.mock import patch
+import pytest
+
from spawn.core.exceptions import SpawnError
from spawn.core.models import ProjectConfig
from spawn.generators.project_generator import ProjectGenerator
-
+from spawn.templates.shared_content import RUFF_PRECOMMIT_REV
# ---------------------------------------------------------------------------
+
# Helpers
# ---------------------------------------------------------------------------
@@ -122,9 +124,8 @@ def test_existing_directory_raises_error(mock_uv, mock_install, tmp_path, monkey
def test_uv_failure_cleans_up_directory(mock_uv, mock_install, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
mock_uv.side_effect = SpawnError("uv not found")
- with _patch_post_install():
- with pytest.raises(SpawnError):
- ProjectGenerator().generate(_cli_config())
+ with _patch_post_install(), pytest.raises(SpawnError):
+ ProjectGenerator().generate(_cli_config())
assert not (tmp_path / "demo").exists()
@@ -134,10 +135,12 @@ def test_git_failure_cleans_up_directory(mock_git, mock_uv, tmp_path, monkeypatc
monkeypatch.chdir(tmp_path)
mock_git.side_effect = SpawnError("Git is not installed or not available in PATH.")
config = ProjectConfig(name="demo", template="cli", use_git=True)
- with _patch_post_install():
- with patch("spawn.generators.project_generator.install_packages"):
- with pytest.raises(SpawnError):
- ProjectGenerator().generate(config)
+ with (
+ _patch_post_install(),
+ patch("spawn.generators.project_generator.install_packages"),
+ pytest.raises(SpawnError),
+ ):
+ ProjectGenerator().generate(config)
assert not (tmp_path / "demo").exists()
@@ -245,9 +248,11 @@ def _failing_write_text(self, *args, **kwargs):
monkeypatch.setattr(Path, "write_text", _failing_write_text)
- with _patch_post_install():
- with pytest.raises(SpawnError, match="No space left on device"):
- ProjectGenerator().generate(_cli_config())
+ with (
+ _patch_post_install(),
+ pytest.raises(SpawnError, match="No space left on device"),
+ ):
+ ProjectGenerator().generate(_cli_config())
assert not (tmp_path / "demo").exists()
@@ -457,9 +462,8 @@ def failing_mkdir(self, *args, **kwargs):
monkeypatch.setattr(Path, "mkdir", failing_mkdir)
- with _patch_post_install():
- with pytest.raises(SpawnError):
- ProjectGenerator().generate(_cli_config())
+ with _patch_post_install(), pytest.raises(SpawnError):
+ ProjectGenerator().generate(_cli_config())
assert not (tmp_path / "demo").exists()
@@ -778,3 +782,90 @@ def test_claude_md_created_when_flag_set(mock_uv, mock_install, tmp_path, monkey
agents = (tmp_path / "demo" / "AGENTS.md").read_text(encoding="utf-8")
claude = (tmp_path / "demo" / "CLAUDE.md").read_text(encoding="utf-8")
assert claude == agents
+
+
+@patch("spawn.generators.project_generator.install_packages")
+@patch("spawn.generators.project_generator.initialize_uv")
+def test_default_generation_creates_license_and_changelog(
+ mock_uv, mock_install, tmp_path, monkeypatch
+):
+ monkeypatch.chdir(tmp_path)
+ with _patch_post_install():
+ ProjectGenerator().generate(_cli_config())
+ assert (tmp_path / "demo" / "LICENSE").is_file()
+ assert (tmp_path / "demo" / "CHANGELOG.md").is_file()
+
+
+@patch("spawn.generators.project_generator.install_packages")
+@patch("spawn.generators.project_generator.initialize_uv")
+def test_license_none_produces_no_license(mock_uv, mock_install, tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ with _patch_post_install():
+ ProjectGenerator().generate(_cli_config(license="none"))
+ assert not (tmp_path / "demo" / "LICENSE").exists()
+ assert (tmp_path / "demo" / "CHANGELOG.md").is_file()
+
+
+@patch(
+ "spawn.generators.project_generator.get_git_user_name",
+ return_value="Alice Developer",
+)
+@patch("spawn.generators.project_generator.install_packages")
+@patch("spawn.generators.project_generator.initialize_uv")
+def test_license_holder_from_git_user_name(
+ mock_uv, mock_install, mock_git_user, tmp_path, monkeypatch
+):
+ monkeypatch.chdir(tmp_path)
+ with _patch_post_install():
+ ProjectGenerator().generate(_cli_config())
+ content = (tmp_path / "demo" / "LICENSE").read_text(encoding="utf-8")
+ assert "Alice Developer" in content
+
+
+@patch("spawn.generators.project_generator.get_git_user_name", return_value=None)
+@patch("spawn.generators.project_generator.install_packages")
+@patch("spawn.generators.project_generator.initialize_uv")
+def test_license_holder_fallback_when_git_user_name_none(
+ mock_uv, mock_install, mock_git_user, tmp_path, monkeypatch
+):
+ monkeypatch.chdir(tmp_path)
+ with _patch_post_install():
+ ProjectGenerator().generate(_cli_config(name="my-app"))
+ content = (tmp_path / "my-app" / "LICENSE").read_text(encoding="utf-8")
+ assert "my-app contributors" in content
+
+
+@patch("spawn.generators.project_generator.install_packages")
+@patch("spawn.generators.project_generator.initialize_uv")
+def test_extras_mypy_installs_and_configures(
+ mock_uv, mock_install, tmp_path, monkeypatch
+):
+ monkeypatch.chdir(tmp_path)
+
+ def _create_pyproject(path):
+ (path / "pyproject.toml").write_text(
+ "[project]\nname = 'demo'\n", encoding="utf-8"
+ )
+
+ mock_uv.side_effect = _create_pyproject
+ with _patch_post_install():
+ ProjectGenerator().generate(_cli_config(extras=["mypy"]))
+
+ mock_install.assert_any_call(Path("demo"), ["mypy"], dev=True)
+ content = (tmp_path / "demo" / "pyproject.toml").read_text(encoding="utf-8")
+ assert "[tool.mypy]" in content
+
+
+@patch("spawn.generators.project_generator.install_packages")
+@patch("spawn.generators.project_generator.initialize_uv")
+def test_extras_precommit_installs_and_writes_config(
+ mock_uv, mock_install, tmp_path, monkeypatch
+):
+ monkeypatch.chdir(tmp_path)
+ with _patch_post_install():
+ ProjectGenerator().generate(_cli_config(extras=["pre-commit"]))
+
+ mock_install.assert_any_call(Path("demo"), ["pre-commit"], dev=True)
+ cfg_file = tmp_path / "demo" / ".pre-commit-config.yaml"
+ assert cfg_file.is_file()
+ assert RUFF_PRECOMMIT_REV in cfg_file.read_text(encoding="utf-8")
diff --git a/tests/test_noninteractive.py b/tests/test_noninteractive.py
index 06813b5..bb597a1 100644
--- a/tests/test_noninteractive.py
+++ b/tests/test_noninteractive.py
@@ -7,7 +7,6 @@
from spawn.cli.noninteractive import build_config_from_args, build_config_from_file
from spawn.core.exceptions import SpawnError
-
# ---------------------------------------------------------------------------
# build_config_from_args — valid paths
# ---------------------------------------------------------------------------
@@ -77,6 +76,18 @@ def test_use_git_and_use_uv_forwarded():
assert config.use_uv is False
+def test_license_defaults_to_mit():
+ config = build_config_from_args(name="my-tool", template="automation")
+ assert config.license == "mit"
+
+
+def test_license_none_passes():
+ config = build_config_from_args(
+ name="my-tool", template="automation", license="none"
+ )
+ assert config.license == "none"
+
+
# ---------------------------------------------------------------------------
# build_config_from_args — validation errors
# ---------------------------------------------------------------------------
@@ -134,6 +145,13 @@ def test_invalid_project_name_raises():
build_config_from_args(name="has spaces", template="automation")
+def test_invalid_license_raises():
+ with pytest.raises(
+ SpawnError, match="Invalid license: 'bogus'. Valid options: mit, none"
+ ):
+ build_config_from_args(name="my-tool", template="automation", license="bogus")
+
+
# ---------------------------------------------------------------------------
# build_config_from_file — valid paths
# ---------------------------------------------------------------------------
@@ -172,6 +190,17 @@ def test_json_file_all_optional_fields(tmp_path, monkeypatch):
assert config.use_uv is False
+def test_json_file_license_read(tmp_path, monkeypatch):
+ monkeypatch.chdir(tmp_path)
+ cfg_file = tmp_path / "spawn.json"
+ cfg_file.write_text(
+ json.dumps({"name": "demo", "template": "automation", "license": "none"}),
+ encoding="utf-8",
+ )
+ config = build_config_from_file(cfg_file)
+ assert config.license == "none"
+
+
# ---------------------------------------------------------------------------
# build_config_from_file — error paths
# ---------------------------------------------------------------------------
@@ -217,3 +246,13 @@ def test_extras_not_a_list_raises(tmp_path):
)
with pytest.raises(SpawnError, match="list of strings"):
build_config_from_file(f)
+
+
+def test_license_not_a_string_raises(tmp_path):
+ f = tmp_path / "bad_license.json"
+ f.write_text(
+ json.dumps({"name": "demo", "template": "automation", "license": 123}),
+ encoding="utf-8",
+ )
+ with pytest.raises(SpawnError, match="'license' in config file must be a string."):
+ build_config_from_file(f)
diff --git a/tests/test_project_files.py b/tests/test_project_files.py
new file mode 100644
index 0000000..845bf21
--- /dev/null
+++ b/tests/test_project_files.py
@@ -0,0 +1,371 @@
+import datetime
+import subprocess
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from spawn.core.exceptions import SpawnError
+from spawn.generators.project_files import (
+ _append_section,
+ _write_new,
+ add_mypy_config,
+ add_pytest_config,
+ add_ruff_config,
+ declares_dependency,
+ write_agents_md_generic,
+ write_changelog,
+ write_ci_workflow,
+ write_gitignore,
+ write_license,
+ write_mypy_ini,
+ write_precommit_config,
+ write_readme_from_project,
+ write_tests_dir,
+)
+from spawn.templates.shared_content import (
+ CHANGELOG_CONTENT,
+ GITIGNORE_CONTENT,
+ MYPY_INI_CONTENT,
+ MYPY_PYPROJECT_SECTION,
+ PRECOMMIT_CONFIG_CONTENT,
+ PYTEST_PYPROJECT_SECTION,
+ RUFF_PYPROJECT_SECTION,
+)
+from spawn.utils.git import get_git_user_name
+
+
+def test_write_new_creates_file_and_parents(tmp_path):
+ target = tmp_path / "deep" / "nested" / "file.txt"
+ assert _write_new(target, "hello world") is True
+ assert target.read_text(encoding="utf-8") == "hello world"
+
+
+def test_write_new_skips_when_exists(tmp_path):
+ target = tmp_path / "file.txt"
+ target.write_text("initial", encoding="utf-8")
+ assert _write_new(target, "overwritten") is False
+ assert target.read_text(encoding="utf-8") == "initial"
+
+
+def test_append_section_success_and_idempotency(tmp_path):
+ pyproject = tmp_path / "pyproject.toml"
+ pyproject.write_text("[project]\nname = 'demo'\n", encoding="utf-8")
+
+ assert _append_section(tmp_path, "[tool.ruff]", RUFF_PYPROJECT_SECTION) is True
+ content_after_first = pyproject.read_text(encoding="utf-8")
+ assert "[tool.ruff]" in content_after_first
+ assert "line-length = 88" in content_after_first
+
+ assert _append_section(tmp_path, "[tool.ruff]", RUFF_PYPROJECT_SECTION) is False
+ assert pyproject.read_text(encoding="utf-8") == content_after_first
+
+
+def test_append_section_missing_pyproject(tmp_path):
+ assert _append_section(tmp_path, "[tool.ruff]", RUFF_PYPROJECT_SECTION) is False
+
+
+def test_write_license_mit_success(tmp_path):
+ assert write_license(tmp_path, "mit", "Test Holder", year=2025) is True
+ license_file = tmp_path / "LICENSE"
+ assert license_file.exists()
+ content = license_file.read_text(encoding="utf-8")
+ assert "Copyright (c) 2025 Test Holder" in content
+ assert "MIT License" in content
+
+
+def test_write_license_mit_default_year(tmp_path):
+ assert write_license(tmp_path, "mit", "Test Holder") is True
+ current_year = datetime.datetime.now(datetime.UTC).year
+ content = (tmp_path / "LICENSE").read_text(encoding="utf-8")
+ assert f"Copyright (c) {current_year} Test Holder" in content
+
+
+def test_write_license_skips_when_file_exists(tmp_path):
+ license_file = tmp_path / "LICENSE"
+ license_file.write_text("custom license", encoding="utf-8")
+ assert write_license(tmp_path, "mit", "Test Holder", year=2025) is False
+ assert license_file.read_text(encoding="utf-8") == "custom license"
+
+
+def test_write_license_none_returns_false(tmp_path):
+ assert write_license(tmp_path, "none", "Test Holder") is False
+ assert not (tmp_path / "LICENSE").exists()
+
+
+def test_write_license_unsupported_raises(tmp_path):
+ with pytest.raises(SpawnError, match="Unsupported license: 'apache'"):
+ write_license(tmp_path, "apache", "Test Holder")
+
+
+def test_write_changelog(tmp_path):
+ assert write_changelog(tmp_path) is True
+ changelog = tmp_path / "CHANGELOG.md"
+ assert changelog.read_text(encoding="utf-8") == CHANGELOG_CONTENT
+ assert write_changelog(tmp_path) is False
+ assert changelog.read_text(encoding="utf-8") == CHANGELOG_CONTENT
+
+
+def test_write_precommit_config(tmp_path):
+ assert write_precommit_config(tmp_path) is True
+ cfg = tmp_path / ".pre-commit-config.yaml"
+ assert cfg.read_text(encoding="utf-8") == PRECOMMIT_CONFIG_CONTENT
+ assert write_precommit_config(tmp_path) is False
+ assert cfg.read_text(encoding="utf-8") == PRECOMMIT_CONFIG_CONTENT
+
+
+def test_write_gitignore(tmp_path):
+ assert write_gitignore(tmp_path) is True
+ gitignore = tmp_path / ".gitignore"
+ assert gitignore.read_text(encoding="utf-8") == GITIGNORE_CONTENT
+ assert write_gitignore(tmp_path) is False
+ assert gitignore.read_text(encoding="utf-8") == GITIGNORE_CONTENT
+
+
+def test_add_ruff_config(tmp_path):
+ pyproject = tmp_path / "pyproject.toml"
+ assert add_ruff_config(tmp_path) is False
+ pyproject.write_text("[project]\nname = 'test'\n", encoding="utf-8")
+ assert add_ruff_config(tmp_path) is True
+ assert "[tool.ruff]" in pyproject.read_text(encoding="utf-8")
+ assert add_ruff_config(tmp_path) is False
+
+
+def test_add_mypy_config(tmp_path):
+ pyproject = tmp_path / "pyproject.toml"
+ assert add_mypy_config(tmp_path) is False
+ pyproject.write_text("[project]\nname = 'test'\n", encoding="utf-8")
+ assert add_mypy_config(tmp_path) is True
+ content = pyproject.read_text(encoding="utf-8")
+ assert "[tool.mypy]" in content
+ assert MYPY_PYPROJECT_SECTION.strip() in content
+ assert add_mypy_config(tmp_path) is False
+
+
+def test_add_pytest_config(tmp_path):
+ pyproject = tmp_path / "pyproject.toml"
+ assert add_pytest_config(tmp_path) is False
+ pyproject.write_text("[project]\nname = 'test'\n", encoding="utf-8")
+ assert add_pytest_config(tmp_path) is True
+ content = pyproject.read_text(encoding="utf-8")
+ assert "[tool.pytest.ini_options]" in content
+ assert PYTEST_PYPROJECT_SECTION.strip() in content
+ assert add_pytest_config(tmp_path) is False
+
+
+def test_write_mypy_ini(tmp_path):
+ assert write_mypy_ini(tmp_path) is True
+ ini = tmp_path / "mypy.ini"
+ assert ini.read_text(encoding="utf-8") == MYPY_INI_CONTENT
+ assert write_mypy_ini(tmp_path) is False
+ assert ini.read_text(encoding="utf-8") == MYPY_INI_CONTENT
+
+
+def test_declares_dependency(tmp_path):
+ assert declares_dependency(tmp_path, "ruff") is False
+
+ pyproject = tmp_path / "pyproject.toml"
+ pyproject.write_text("invalid toml [[]", encoding="utf-8")
+ assert declares_dependency(tmp_path, "ruff") is False
+
+ pyproject.write_text(
+ """[project]
+dependencies = ["fastapi>=0.115.0", "uvicorn[standard]>=0.30.0"]
+
+[dependency-groups]
+dev = ["ruff>=0.15.16", "pytest>=9.0.3"]
+
+[tool.uv]
+dev-dependencies = ["mypy>=1.14.0"]
+""",
+ encoding="utf-8",
+ )
+
+ assert declares_dependency(tmp_path, "fastapi") is True
+ assert declares_dependency(tmp_path, "uvicorn") is True
+ assert declares_dependency(tmp_path, "ruff") is True
+ assert declares_dependency(tmp_path, "RUFF") is True
+ assert declares_dependency(tmp_path, "pytest") is True
+ assert declares_dependency(tmp_path, "mypy") is True
+ assert declares_dependency(tmp_path, "pytest-cov") is False
+ assert declares_dependency(tmp_path, "flask") is False
+
+
+def test_write_ci_workflow_no_dependencies(tmp_path):
+ assert write_ci_workflow(tmp_path) is True
+ ci_file = tmp_path / ".github" / "workflows" / "ci.yml"
+ assert ci_file.exists()
+ content = ci_file.read_text(encoding="utf-8")
+ assert "name: CI" in content
+ assert "Install dependencies" in content
+ assert "uv run ruff check ." not in content
+ assert "uv run pytest" not in content
+
+
+def test_write_ci_workflow_step_selection(tmp_path):
+ pyproject = tmp_path / "pyproject.toml"
+ pyproject.write_text(
+ """[project]
+dependencies = ["ruff>=0.15.16", "pytest>=9.0.3"]
+""",
+ encoding="utf-8",
+ )
+
+ assert write_ci_workflow(tmp_path) is True
+ ci_file = tmp_path / ".github" / "workflows" / "ci.yml"
+ content = ci_file.read_text(encoding="utf-8")
+ assert "uv run ruff check ." in content
+ assert "uv run pytest" in content
+
+
+def test_write_ci_workflow_ruff_only(tmp_path):
+ pyproject = tmp_path / "pyproject.toml"
+ pyproject.write_text(
+ """[project]
+dependencies = ["ruff>=0.15.16"]
+""",
+ encoding="utf-8",
+ )
+
+ assert write_ci_workflow(tmp_path) is True
+ ci_file = tmp_path / ".github" / "workflows" / "ci.yml"
+ content = ci_file.read_text(encoding="utf-8")
+ assert "uv run ruff check ." in content
+ assert "uv run pytest" not in content
+
+
+def test_write_ci_workflow_skips_when_workflow_exists(tmp_path):
+ workflows_dir = tmp_path / ".github" / "workflows"
+ workflows_dir.mkdir(parents=True)
+ existing_yaml = workflows_dir / "build.yaml"
+ existing_yaml.write_text("existing workflow", encoding="utf-8")
+
+ assert write_ci_workflow(tmp_path) is False
+ assert not (workflows_dir / "ci.yml").exists()
+ assert existing_yaml.read_text(encoding="utf-8") == "existing workflow"
+
+
+def test_write_ci_workflow_skips_when_ci_yml_exists(tmp_path):
+ workflows_dir = tmp_path / ".github" / "workflows"
+ workflows_dir.mkdir(parents=True)
+ ci_yml = workflows_dir / "ci.yml"
+ ci_yml.write_text("existing ci", encoding="utf-8")
+
+ assert write_ci_workflow(tmp_path) is False
+ assert ci_yml.read_text(encoding="utf-8") == "existing ci"
+
+
+def test_write_readme_from_project_with_pyproject(tmp_path):
+ pyproject = tmp_path / "pyproject.toml"
+ pyproject.write_text(
+ """[project]
+name = "awesome-app"
+description = "An awesome application."
+""",
+ encoding="utf-8",
+ )
+
+ assert write_readme_from_project(tmp_path) is True
+ readme = tmp_path / "README.md"
+ assert (
+ readme.read_text(encoding="utf-8")
+ == "# awesome-app\n\nAn awesome application.\n"
+ )
+
+ assert write_readme_from_project(tmp_path) is False
+ assert (
+ readme.read_text(encoding="utf-8")
+ == "# awesome-app\n\nAn awesome application.\n"
+ )
+
+
+def test_write_readme_from_project_without_pyproject(tmp_path):
+ project_dir = tmp_path / "my-project"
+ project_dir.mkdir()
+ assert write_readme_from_project(project_dir) is True
+ readme = project_dir / "README.md"
+ assert readme.read_text(encoding="utf-8") == "# my-project\n"
+
+
+def test_write_readme_from_project_without_description(tmp_path):
+ pyproject = tmp_path / "pyproject.toml"
+ pyproject.write_text(
+ """[project]
+name = "simple-pkg"
+""",
+ encoding="utf-8",
+ )
+ assert write_readme_from_project(tmp_path) is True
+ readme = tmp_path / "README.md"
+ assert readme.read_text(encoding="utf-8") == "# simple-pkg\n"
+
+
+def test_write_agents_md_generic_with_uv(tmp_path):
+ (tmp_path / "uv.lock").touch()
+ project_dir = tmp_path / "agent-project"
+ project_dir.mkdir()
+ (project_dir / "uv.lock").touch()
+
+ assert write_agents_md_generic(project_dir) is True
+ agents_md = project_dir / "AGENTS.md"
+ content = agents_md.read_text(encoding="utf-8")
+ assert "# Agent Context: agent-project" in content
+ assert "uv sync" in content
+ assert "uv run pytest" in content
+
+ assert write_agents_md_generic(project_dir) is False
+
+
+def test_write_agents_md_generic_without_uv(tmp_path):
+ project_dir = tmp_path / "pip-project"
+ project_dir.mkdir()
+
+ assert write_agents_md_generic(project_dir) is True
+ agents_md = project_dir / "AGENTS.md"
+ content = agents_md.read_text(encoding="utf-8")
+ assert "# Agent Context: pip-project" in content
+ assert "pip install -e ." in content
+ assert "```bash\npytest\n```" in content
+
+
+def test_write_tests_dir(tmp_path):
+ assert write_tests_dir(tmp_path) is True
+ tests_dir = tmp_path / "tests"
+ assert tests_dir.is_dir()
+ init_file = tests_dir / "__init__.py"
+ assert init_file.exists()
+ assert init_file.read_text(encoding="utf-8") == ""
+
+ existing_test = tests_dir / "test_example.py"
+ existing_test.write_text("def test_one(): pass", encoding="utf-8")
+ assert write_tests_dir(tmp_path) is False
+ assert existing_test.read_text(encoding="utf-8") == "def test_one(): pass"
+
+
+@patch("subprocess.run")
+def test_get_git_user_name_success(mock_run):
+ mock_run.return_value = MagicMock(stdout="Jane Doe\n")
+ assert get_git_user_name() == "Jane Doe"
+ mock_run.assert_called_once_with(
+ ["git", "config", "user.name"],
+ capture_output=True,
+ text=True,
+ check=True,
+ )
+
+
+@patch("subprocess.run")
+def test_get_git_user_name_empty(mock_run):
+ mock_run.return_value = MagicMock(stdout=" \n")
+ assert get_git_user_name() is None
+
+
+@patch("subprocess.run")
+def test_get_git_user_name_file_not_found(mock_run):
+ mock_run.side_effect = FileNotFoundError
+ assert get_git_user_name() is None
+
+
+@patch("subprocess.run")
+def test_get_git_user_name_called_process_error(mock_run):
+ mock_run.side_effect = subprocess.CalledProcessError(1, ["git", "config"])
+ assert get_git_user_name() is None
diff --git a/tests/test_prompts.py b/tests/test_prompts.py
index b6396ab..a5dbf5d 100644
--- a/tests/test_prompts.py
+++ b/tests/test_prompts.py
@@ -1,15 +1,14 @@
"""Tests for cli/prompts.py — get_project_config()."""
-from unittest.mock import patch, MagicMock
import io
+from unittest.mock import MagicMock, patch
import pytest
-from spawn.cli.prompts import get_project_config, _select, _multiselect
+from spawn.cli.prompts import _multiselect, _select, get_project_config
from spawn.core.models import ProjectConfig
from spawn.core.registry import get_metadata
-
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -87,7 +86,12 @@ def test_valid_name_and_template_returns_config():
patch("spawn.cli.prompts.questionary.checkbox") as mock_chk,
):
# Template → CLI Application; CLI type → utility; Framework → typer
- mock_sel.return_value.ask.side_effect = ["CLI Application", "utility", "typer"]
+ mock_sel.return_value.ask.side_effect = [
+ "CLI Application",
+ "utility",
+ "typer",
+ "MIT",
+ ]
mock_chk.return_value.ask.return_value = [] # no extras
config = get_project_config()
@@ -105,7 +109,7 @@ def test_git_false_reflected_in_config():
patch("spawn.cli.prompts.questionary.checkbox") as mock_chk,
):
# Template → Backend API; Framework → fastapi
- mock_sel.return_value.ask.side_effect = ["Backend API", "fastapi"]
+ mock_sel.return_value.ask.side_effect = ["Backend API", "fastapi", "MIT"]
mock_chk.return_value.ask.return_value = []
config = get_project_config()
@@ -121,13 +125,37 @@ def test_generate_claude_md_true_reflected_in_config(monkeypatch):
patch("spawn.cli.prompts.questionary.select") as mock_sel,
patch("spawn.cli.prompts.questionary.checkbox") as mock_chk,
):
- mock_sel.return_value.ask.side_effect = ["CLI Application", "utility", "typer"]
+ mock_sel.return_value.ask.side_effect = [
+ "CLI Application",
+ "utility",
+ "typer",
+ "MIT",
+ ]
mock_chk.return_value.ask.return_value = []
config = get_project_config()
assert config.generate_claude_md is True
+def test_license_none_reflected_in_config():
+ with (
+ patch("spawn.cli.prompts.typer.prompt", return_value="my-project"),
+ patch("spawn.cli.prompts.typer.confirm", return_value=True),
+ patch("spawn.cli.prompts.questionary.select") as mock_sel,
+ patch("spawn.cli.prompts.questionary.checkbox") as mock_chk,
+ ):
+ mock_sel.return_value.ask.side_effect = [
+ "CLI Application",
+ "utility",
+ "typer",
+ "None",
+ ]
+ mock_chk.return_value.ask.return_value = []
+ config = get_project_config()
+
+ assert config.license == "none"
+
+
# ---------------------------------------------------------------------------
# Invalid project name retried until valid
# ---------------------------------------------------------------------------
@@ -141,7 +169,12 @@ def test_invalid_name_retried_until_valid():
patch("spawn.cli.prompts.questionary.select") as mock_sel,
patch("spawn.cli.prompts.questionary.checkbox") as mock_chk,
):
- mock_sel.return_value.ask.side_effect = ["CLI Application", "utility", "typer"]
+ mock_sel.return_value.ask.side_effect = [
+ "CLI Application",
+ "utility",
+ "typer",
+ "MIT",
+ ]
mock_chk.return_value.ask.return_value = []
config = get_project_config()
@@ -157,7 +190,12 @@ def test_name_with_space_retried():
patch("spawn.cli.prompts.questionary.select") as mock_sel,
patch("spawn.cli.prompts.questionary.checkbox") as mock_chk,
):
- mock_sel.return_value.ask.side_effect = ["CLI Application", "utility", "typer"]
+ mock_sel.return_value.ask.side_effect = [
+ "CLI Application",
+ "utility",
+ "typer",
+ "MIT",
+ ]
mock_chk.return_value.ask.return_value = []
config = get_project_config()
@@ -204,6 +242,8 @@ def test_all_template_choices(display_name, expected_template):
get_supported_providers(meta.available_frameworks[0])[0]
)
+ select_side_effects.append("MIT")
+
with (
patch("spawn.cli.prompts.typer.prompt", return_value="project"),
patch("spawn.cli.prompts.typer.confirm", return_value=False),
@@ -229,7 +269,7 @@ def test_backend_api_with_framework_and_extras():
patch("spawn.cli.prompts.questionary.select") as mock_sel,
patch("spawn.cli.prompts.questionary.checkbox") as mock_chk,
):
- mock_sel.return_value.ask.side_effect = ["Backend API", "fastapi"]
+ mock_sel.return_value.ask.side_effect = ["Backend API", "fastapi", "MIT"]
mock_chk.return_value.ask.return_value = ["ruff", "pytest"]
config = get_project_config()
@@ -247,7 +287,7 @@ def test_backend_api_with_no_extras():
patch("spawn.cli.prompts.questionary.select") as mock_sel,
patch("spawn.cli.prompts.questionary.checkbox") as mock_chk,
):
- mock_sel.return_value.ask.side_effect = ["Backend API", "fastapi"]
+ mock_sel.return_value.ask.side_effect = ["Backend API", "fastapi", "MIT"]
mock_chk.return_value.ask.return_value = []
config = get_project_config()
@@ -263,7 +303,7 @@ def test_backend_api_flask_framework_selected():
patch("spawn.cli.prompts.questionary.select") as mock_sel,
patch("spawn.cli.prompts.questionary.checkbox") as mock_chk,
):
- mock_sel.return_value.ask.side_effect = ["Backend API", "flask"]
+ mock_sel.return_value.ask.side_effect = ["Backend API", "flask", "MIT"]
mock_chk.return_value.ask.return_value = []
config = get_project_config()
@@ -279,7 +319,7 @@ def test_backend_api_django_framework_selected():
patch("spawn.cli.prompts.questionary.select") as mock_sel,
patch("spawn.cli.prompts.questionary.checkbox") as mock_chk,
):
- mock_sel.return_value.ask.side_effect = ["Backend API", "django"]
+ mock_sel.return_value.ask.side_effect = ["Backend API", "django", "MIT"]
mock_chk.return_value.ask.return_value = []
config = get_project_config()
@@ -294,7 +334,7 @@ def test_backend_api_docker_and_github_actions_extras():
patch("spawn.cli.prompts.questionary.select") as mock_sel,
patch("spawn.cli.prompts.questionary.checkbox") as mock_chk,
):
- mock_sel.return_value.ask.side_effect = ["Backend API", "fastapi"]
+ mock_sel.return_value.ask.side_effect = ["Backend API", "fastapi", "MIT"]
mock_chk.return_value.ask.return_value = ["docker", "github-actions"]
config = get_project_config()
@@ -317,7 +357,12 @@ def test_existing_directory_name_retried(tmp_path, monkeypatch):
patch("spawn.cli.prompts.questionary.select") as mock_sel,
patch("spawn.cli.prompts.questionary.checkbox") as mock_chk,
):
- mock_sel.return_value.ask.side_effect = ["CLI Application", "utility", "typer"]
+ mock_sel.return_value.ask.side_effect = [
+ "CLI Application",
+ "utility",
+ "typer",
+ "MIT",
+ ]
mock_chk.return_value.ask.return_value = []
config = get_project_config()
@@ -335,7 +380,12 @@ def test_existing_directory_shows_error_message(tmp_path, monkeypatch):
patch("spawn.cli.prompts.questionary.select") as mock_sel,
patch("spawn.cli.prompts.questionary.checkbox") as mock_chk,
):
- mock_sel.return_value.ask.side_effect = ["CLI Application", "utility", "typer"]
+ mock_sel.return_value.ask.side_effect = [
+ "CLI Application",
+ "utility",
+ "typer",
+ "MIT",
+ ]
mock_chk.return_value.ask.return_value = []
get_project_config()
diff --git a/tests/test_registry.py b/tests/test_registry.py
index 20b6611..0ce0b83 100644
--- a/tests/test_registry.py
+++ b/tests/test_registry.py
@@ -1,7 +1,12 @@
-from spawn.core.registry import get_template, get_metadata, list_templates
+from unittest.mock import patch
+
+from spawn.core.models import ProjectConfig
+from spawn.core.registry import get_metadata, get_template, list_templates
+from spawn.generators.project_generator import ProjectGenerator
def test_invalid_template_returns_none():
+
assert get_template("banana") is None
@@ -157,8 +162,8 @@ def test_chatbot_in_list_templates():
def test_agent_template_is_registered():
- from spawn.templates.agent import AgentTemplate
from spawn.core.registry import get_template
+ from spawn.templates.agent import AgentTemplate
t = get_template("agent")
assert t is not None
@@ -191,3 +196,49 @@ def test_rag_metadata():
assert not meta.available_providers
assert "ruff" in meta.available_extras
assert "pytest" in meta.available_extras
+
+
+@patch("spawn.generators.project_generator.install_packages")
+@patch("spawn.generators.project_generator.initialize_uv")
+def test_generate_all_templates_with_full_available_extras(
+ mock_uv, mock_install, tmp_path, monkeypatch
+):
+ monkeypatch.chdir(tmp_path)
+
+ def _create_pyproject(path):
+ (path / "pyproject.toml").write_text(
+ "[project]\nname = 'test'\n", encoding="utf-8"
+ )
+
+ mock_uv.side_effect = _create_pyproject
+
+ defaults = {
+ "backend-api": {"framework": "fastapi"},
+ "cli": {"framework": "typer", "cli_type": "utility"},
+ "automation": {},
+ "chatbot": {"framework": "pydantic-ai", "provider": "openai"},
+ "agent": {"framework": "pydantic-ai", "provider": "openai"},
+ "rag": {},
+ "data": {"data_type": "Data Analysis"},
+ "mcp": {},
+ }
+
+ for meta in list_templates():
+ assert "pre-commit" in meta.available_extras
+ assert "mypy" in meta.available_extras
+ slug = meta.slug
+ extra_kwargs = defaults.get(slug, {})
+ config = ProjectConfig(
+ name=f"proj-{slug}",
+ template=slug,
+ use_git=False,
+ extras=list(meta.available_extras),
+ **extra_kwargs,
+ )
+ gen = ProjectGenerator()
+ path = gen.generate(config)
+ assert path.exists()
+ assert (path / "LICENSE").exists()
+ assert (path / "CHANGELOG.md").exists()
+ assert (path / ".pre-commit-config.yaml").exists()
+ assert "[tool.mypy]" in (path / "pyproject.toml").read_text(encoding="utf-8")
diff --git a/uv.lock b/uv.lock
index 090ea8a..d805f58 100644
--- a/uv.lock
+++ b/uv.lock
@@ -279,7 +279,7 @@ wheels = [
[[package]]
name = "spawnio"
-version = "1.0.7"
+version = "1.0.8"
source = { editable = "." }
dependencies = [
{ name = "questionary" },