spawnio 1.0.5__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. spawn/__init__.py +6 -0
  2. spawn/cli/__init__.py +0 -0
  3. spawn/cli/app.py +218 -0
  4. spawn/cli/noninteractive.py +204 -0
  5. spawn/cli/prompts.py +267 -0
  6. spawn/core/__init__.py +0 -0
  7. spawn/core/exceptions.py +7 -0
  8. spawn/core/models.py +21 -0
  9. spawn/core/registry.py +152 -0
  10. spawn/generators/__init__.py +0 -0
  11. spawn/generators/custom_structure.py +508 -0
  12. spawn/generators/project_generator.py +115 -0
  13. spawn/github/__init__.py +0 -0
  14. spawn/github/exceptions.py +5 -0
  15. spawn/github/publisher.py +69 -0
  16. spawn/github/validators.py +23 -0
  17. spawn/templates/__init__.py +0 -0
  18. spawn/templates/agent/__init__.py +182 -0
  19. spawn/templates/agent/content.py +374 -0
  20. spawn/templates/automation/__init__.py +100 -0
  21. spawn/templates/automation/content.py +181 -0
  22. spawn/templates/backend_api/__init__.py +243 -0
  23. spawn/templates/backend_api/content.py +509 -0
  24. spawn/templates/base.py +58 -0
  25. spawn/templates/chatbot/__init__.py +243 -0
  26. spawn/templates/chatbot/content.py +668 -0
  27. spawn/templates/cli_application/__init__.py +211 -0
  28. spawn/templates/cli_application/content.py +512 -0
  29. spawn/templates/data_project/__init__.py +125 -0
  30. spawn/templates/data_project/analysis/__init__.py +74 -0
  31. spawn/templates/data_project/analysis/content.py +190 -0
  32. spawn/templates/data_project/dashboard/__init__.py +61 -0
  33. spawn/templates/data_project/dashboard/content.py +135 -0
  34. spawn/templates/data_project/etl/__init__.py +66 -0
  35. spawn/templates/data_project/etl/content.py +239 -0
  36. spawn/templates/data_project/ml/__init__.py +62 -0
  37. spawn/templates/data_project/ml/content.py +239 -0
  38. spawn/templates/mcp_server/__init__.py +83 -0
  39. spawn/templates/mcp_server/content.py +161 -0
  40. spawn/templates/rag/__init__.py +124 -0
  41. spawn/templates/rag/content.py +463 -0
  42. spawn/templates/shared_content.py +89 -0
  43. spawn/utils/__init__.py +0 -0
  44. spawn/utils/banner.py +41 -0
  45. spawn/utils/console.py +3 -0
  46. spawn/utils/doctor.py +594 -0
  47. spawn/utils/git.py +132 -0
  48. spawn/utils/success.py +47 -0
  49. spawn/utils/theme.py +25 -0
  50. spawn/utils/uv.py +61 -0
  51. spawn/utils/validators.py +15 -0
  52. spawnio-1.0.5.dist-info/METADATA +798 -0
  53. spawnio-1.0.5.dist-info/RECORD +56 -0
  54. spawnio-1.0.5.dist-info/WHEEL +4 -0
  55. spawnio-1.0.5.dist-info/entry_points.txt +2 -0
  56. spawnio-1.0.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,7 @@
1
+ class SpawnError(Exception):
2
+ """Base exception for Spawn."""
3
+ pass
4
+
5
+
6
+ class StructureParseError(SpawnError):
7
+ """Raised when pasted structure text cannot be parsed."""
spawn/core/models.py ADDED
@@ -0,0 +1,21 @@
1
+ from dataclasses import dataclass, field
2
+
3
+
4
+ @dataclass
5
+ class ProjectConfig:
6
+ name: str
7
+ template: str
8
+ use_git: bool
9
+ framework: str | None = None
10
+ extras: list[str] = field(default_factory=list)
11
+ cli_type: str | None = None
12
+ data_type: str | None = None
13
+ provider: str | None = None
14
+ use_uv: bool = True
15
+ generate_claude_md: bool = False
16
+ custom_entries: list | None = None # list[ParsedEntry] when template == "custom"
17
+ custom_dependencies: list[str] = field(default_factory=list)
18
+ custom_dev_setup: list[str] = field(default_factory=list)
19
+ # subset of ["ruff", "pytest", "precommit", "dockerfile"]
20
+ custom_gitignore_extra: list[str] = field(default_factory=list)
21
+ custom_source_format: str | None = None # "tree" | "markdown" | "indented"
spawn/core/registry.py ADDED
@@ -0,0 +1,152 @@
1
+ import inspect
2
+ from dataclasses import dataclass, field
3
+
4
+ from spawn.core.models import ProjectConfig
5
+ from spawn.templates.backend_api import BackendAPITemplate
6
+ from spawn.templates.cli_application import CLITemplate
7
+ from spawn.templates.automation import AutomationTemplate
8
+ from spawn.templates.chatbot import ChatbotTemplate
9
+ from spawn.templates.agent import AgentTemplate
10
+ from spawn.templates.rag import RAGTemplate
11
+ from spawn.templates.data_project import DataProjectTemplate
12
+ from spawn.templates.mcp_server import MCPServerTemplate
13
+ from spawn.templates.base import BaseTemplate
14
+
15
+
16
+ @dataclass
17
+ class TemplateMetadata:
18
+ slug: str
19
+ display_name: str
20
+ description: str
21
+ template_class: type
22
+ available_frameworks: list[str] = field(default_factory=list)
23
+ available_extras: list[str] = field(default_factory=list)
24
+ available_cli_types: list[str] = field(default_factory=list)
25
+ available_providers: list[str] = field(default_factory=list)
26
+ available_data_types: list[str] = field(default_factory=list)
27
+
28
+
29
+ # Slugs that existed in previous versions but have been superseded.
30
+ # get_template() returns None for these, which surfaces as a clear
31
+ # SpawnError("Unknown template: fastapi") in the generator.
32
+ _REMOVED_SLUGS = {"fastapi", "python"}
33
+
34
+ TEMPLATES: dict[str, TemplateMetadata] = {
35
+ "backend-api": TemplateMetadata(
36
+ slug="backend-api",
37
+ display_name="Backend API",
38
+ description="Production-ready backend with FastAPI, Flask, or Django",
39
+ template_class=BackendAPITemplate,
40
+ available_frameworks=["fastapi", "flask", "django"],
41
+ available_extras=["ruff", "pytest", "docker", "github-actions"],
42
+ ),
43
+ "cli": TemplateMetadata(
44
+ slug="cli",
45
+ display_name="CLI Application",
46
+ description="Command-line application with Typer, Click, or Argparse",
47
+ template_class=CLITemplate,
48
+ available_frameworks=["typer", "click", "argparse"],
49
+ available_extras=["ruff", "pytest", "github-actions"],
50
+ available_cli_types=["utility", "interactive"],
51
+ ),
52
+ "automation": TemplateMetadata(
53
+ slug="automation",
54
+ display_name="Automation Tool",
55
+ description="Workflow-based automation with logging, tasks, and integrations",
56
+ template_class=AutomationTemplate,
57
+ available_extras=["ruff", "pytest", "github-actions"],
58
+ ),
59
+ "chatbot": TemplateMetadata(
60
+ slug="chatbot",
61
+ display_name="AI Chatbot",
62
+ description="Conversational AI with PydanticAI, OpenAI SDK, or LiteLLM",
63
+ template_class=ChatbotTemplate,
64
+ available_frameworks=["pydantic-ai", "openai-sdk", "litellm"],
65
+ available_providers=["openai", "anthropic", "gemini", "openrouter", "ollama", "groq"],
66
+ available_extras=["ruff", "pytest", "rich", "github-actions"],
67
+ ),
68
+ "agent": TemplateMetadata(
69
+ slug="agent",
70
+ display_name="AI Agent",
71
+ description="Tool-calling agent with PydanticAI or OpenAI Agents SDK",
72
+ template_class=AgentTemplate,
73
+ available_frameworks=["pydantic-ai", "openai-agents"],
74
+ available_providers=["openai", "anthropic", "gemini", "openrouter", "ollama", "groq"],
75
+ available_extras=["ruff", "pytest", "github-actions"],
76
+ ),
77
+ "rag": TemplateMetadata(
78
+ slug="rag",
79
+ display_name="RAG System",
80
+ description="Retrieval-Augmented Generation with LlamaIndex and ChromaDB",
81
+ template_class=RAGTemplate,
82
+ available_extras=["ruff", "pytest", "github-actions"],
83
+ ),
84
+ "data": TemplateMetadata(
85
+ slug="data",
86
+ display_name="Data Project",
87
+ description="Data Analysis, Dashboard, ETL Pipeline, or Machine Learning project",
88
+ template_class=DataProjectTemplate,
89
+ available_extras=["ruff", "pytest", "github-actions"],
90
+ available_data_types=["Data Analysis", "Dashboard", "ETL Pipeline", "Machine Learning"],
91
+ ),
92
+ "mcp": TemplateMetadata(
93
+ slug="mcp",
94
+ display_name="MCP Server",
95
+ description="Model Context Protocol server with the official Python SDK",
96
+ template_class=MCPServerTemplate,
97
+ available_extras=["ruff", "pytest", "github-actions"],
98
+ ),
99
+ }
100
+
101
+
102
+ def get_template(template_name: str) -> BaseTemplate | None:
103
+ """Return a no-argument template instance. Used by tests and CLI helpers."""
104
+ metadata = TEMPLATES.get(template_name)
105
+
106
+ if metadata is None:
107
+ return None
108
+
109
+ return metadata.template_class()
110
+
111
+
112
+ def instantiate_template(config: ProjectConfig) -> BaseTemplate | None:
113
+ """
114
+ Instantiate a template from a fully-populated ProjectConfig.
115
+
116
+ For templates that accept framework/extras (e.g. BackendAPITemplate),
117
+ those values are forwarded from config. For all other templates the
118
+ no-argument constructor is used, so existing behaviour is unchanged.
119
+ """
120
+ metadata = TEMPLATES.get(config.template)
121
+
122
+ if metadata is None:
123
+ return None
124
+
125
+ cls = metadata.template_class
126
+
127
+ # Forward framework and extras only when the constructor accepts them.
128
+ # Introspecting the signature avoids coupling the registry to each
129
+ # template class individually.
130
+ params = set(inspect.signature(cls.__init__).parameters)
131
+
132
+ kwargs: dict = {}
133
+ if "framework" in params:
134
+ kwargs["framework"] = config.framework
135
+ if "extras" in params:
136
+ kwargs["extras"] = config.extras
137
+ if "cli_type" in params:
138
+ kwargs["cli_type"] = config.cli_type
139
+ if "provider" in params:
140
+ kwargs["provider"] = config.provider
141
+ if "data_type" in params:
142
+ kwargs["data_type"] = config.data_type
143
+
144
+ return cls(**kwargs)
145
+
146
+
147
+ def get_metadata(template_name: str) -> TemplateMetadata | None:
148
+ return TEMPLATES.get(template_name)
149
+
150
+
151
+ def list_templates() -> list[TemplateMetadata]:
152
+ return list(TEMPLATES.values())
File without changes
@@ -0,0 +1,508 @@
1
+ """
2
+ Parse pasted folder-structure text into a flat list of (path, is_file) entries,
3
+ and generate the filesystem structure from parsed entries.
4
+
5
+ Supported input formats:
6
+ tree — uses ├──, └──, │ connectors (standard Unix tree output)
7
+ markdown — lines starting with optional whitespace + "- " or "* "
8
+ indented — plain indentation (tabs or spaces), no bullets or tree chars
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ import shutil
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ from spawn.core.exceptions import SpawnError, StructureParseError
19
+ from spawn.utils.console import console
20
+ from spawn.utils.git import initialize_git
21
+ from spawn.utils.uv import initialize_uv, install_packages
22
+
23
+ # Tree connector characters
24
+ _TREE_CHARS = {"├", "└", "│"}
25
+ _TREE_CONNECTOR_RE = re.compile(r"^[\s│]*[├└]──\s*")
26
+ _MARKDOWN_LINE_RE = re.compile(r"^(\s*)[-*]\s+")
27
+
28
+
29
+ @dataclass
30
+ class ParsedEntry:
31
+ path: str # relative path, forward-slash separated
32
+ is_file: bool # True if entry has a file extension or no trailing "/"
33
+
34
+
35
+ # ─── Helpers ──────────────────────────────────────────────────────────────
36
+
37
+
38
+ _KNOWN_EXTENSIONLESS_FILES = {
39
+ "license",
40
+ "licence",
41
+ "dockerfile",
42
+ "makefile",
43
+ "procfile",
44
+ "changelog",
45
+ "authors",
46
+ "contributing",
47
+ "codeowners",
48
+ "readme", # bare "README" with no extension, distinct from README.md
49
+ }
50
+
51
+
52
+ def _is_file_entry(name: str) -> bool:
53
+ """
54
+ Decide whether a name represents a file or a folder.
55
+
56
+ Rules (in priority order):
57
+ 1. Trailing "/" → always folder.
58
+ 2. Starts with "." (dotfile like .env, .gitignore) → file.
59
+ 3. Contains a "." after the last "/" segment → file.
60
+ 4. Name is a known extension-less filename (LICENSE, Dockerfile, etc.) → file.
61
+ 5. Anything else → folder.
62
+ """
63
+ if name.endswith("/"):
64
+ return False
65
+ segment = name.rsplit("/", 1)[-1]
66
+ if segment.startswith("."):
67
+ return True
68
+ if "." in segment:
69
+ return True
70
+ if segment.lower() in _KNOWN_EXTENSIONLESS_FILES:
71
+ return True
72
+ return False
73
+
74
+
75
+ def _strip_name(name: str) -> str:
76
+ """Remove trailing slash and inline comments."""
77
+ name = re.sub(r"\s*#.*$", "", name).strip()
78
+ return name.rstrip("/")
79
+
80
+
81
+ # ─── Format detection ─────────────────────────────────────────────────────
82
+
83
+
84
+ def detect_format(raw: str) -> str:
85
+ """Return 'tree', 'markdown', or 'indented'."""
86
+ for line in raw.splitlines():
87
+ if any(c in line for c in _TREE_CHARS):
88
+ return "tree"
89
+ for line in raw.splitlines():
90
+ if _MARKDOWN_LINE_RE.match(line):
91
+ return "markdown"
92
+ return "indented"
93
+
94
+
95
+ # ─── Per-format depth extraction ──────────────────────────────────────────
96
+
97
+
98
+ def _tree_depth_and_name(line: str) -> tuple[int, str] | None:
99
+ stripped = line.rstrip()
100
+ if not stripped:
101
+ return None
102
+
103
+ # Root line: no tree characters
104
+ if not any(c in stripped for c in _TREE_CHARS):
105
+ name = _strip_name(stripped.lstrip())
106
+ if not name:
107
+ return None
108
+ return (0, name)
109
+
110
+ match = re.match(r"^([\s│]*)[├└]──\s*(.+)$", stripped)
111
+ if not match:
112
+ return None
113
+ prefix, name = match.group(1), match.group(2).rstrip()
114
+ name = _strip_name(name)
115
+ if not name:
116
+ return None
117
+ depth = len(prefix) // 4 + 1
118
+ return (depth, name)
119
+
120
+
121
+ def _markdown_depth_and_name(line: str, indent_unit: int) -> tuple[int, str] | None:
122
+ match = _MARKDOWN_LINE_RE.match(line)
123
+ if not match:
124
+ stripped = line.strip()
125
+ if not stripped:
126
+ return None
127
+ return (0, _strip_name(stripped))
128
+ spaces = len(match.group(1))
129
+ name = _strip_name(line[match.end():].strip())
130
+ if not name:
131
+ return None
132
+ depth = (spaces // indent_unit + 1) if indent_unit > 0 else 1
133
+ return (depth, name)
134
+
135
+
136
+ def _indented_depth_and_name(
137
+ line: str, indent_unit: int | None
138
+ ) -> tuple[int, str, int | None]:
139
+ stripped = line.rstrip()
140
+ if not stripped or not stripped.strip():
141
+ return (-1, "", indent_unit)
142
+
143
+ leading = len(stripped) - len(stripped.lstrip())
144
+ name = _strip_name(stripped.strip())
145
+ if not name:
146
+ return (-1, "", indent_unit)
147
+
148
+ if leading == 0:
149
+ return (0, name, indent_unit)
150
+
151
+ if indent_unit is None:
152
+ indent_unit = leading
153
+
154
+ depth = leading // indent_unit
155
+ return (depth, name, indent_unit)
156
+
157
+
158
+ # ─── Main parser ──────────────────────────────────────────────────────────
159
+
160
+
161
+ def parse_structure(raw: str) -> list[ParsedEntry]:
162
+ """
163
+ Parse raw pasted text into a flat list of ParsedEntry with full
164
+ relative paths.
165
+
166
+ Raises StructureParseError for empty input, malformed indentation,
167
+ or duplicate paths.
168
+ """
169
+ lines = [ln for ln in raw.splitlines() if ln.strip()]
170
+ if not lines:
171
+ raise StructureParseError("Input is empty — no structure to parse.")
172
+
173
+ fmt = detect_format(raw)
174
+
175
+ if fmt == "tree":
176
+ depth_name_pairs = _parse_tree_lines(lines)
177
+ elif fmt == "markdown":
178
+ depth_name_pairs = _parse_markdown_lines(lines)
179
+ else:
180
+ depth_name_pairs = _parse_indented_lines(lines)
181
+
182
+ return _build_entries(depth_name_pairs)
183
+
184
+
185
+ def _parse_tree_lines(lines: list[str]) -> list[tuple[int, str]]:
186
+ result = []
187
+ for line in lines:
188
+ parsed = _tree_depth_and_name(line)
189
+ if parsed is None:
190
+ continue
191
+ result.append(parsed)
192
+ return result
193
+
194
+
195
+ def _parse_markdown_lines(lines: list[str]) -> list[tuple[int, str]]:
196
+ indent_unit = 2
197
+ space_counts = []
198
+ for line in lines:
199
+ m = _MARKDOWN_LINE_RE.match(line)
200
+ if m:
201
+ spaces = len(m.group(1))
202
+ if spaces > 0:
203
+ space_counts.append(spaces)
204
+ if space_counts:
205
+ indent_unit = min(space_counts)
206
+
207
+ result = []
208
+ for line in lines:
209
+ parsed = _markdown_depth_and_name(line, indent_unit)
210
+ if parsed is None:
211
+ continue
212
+ result.append(parsed)
213
+ return result
214
+
215
+
216
+ def _parse_indented_lines(lines: list[str]) -> list[tuple[int, str]]:
217
+ indent_unit: int | None = None
218
+ result = []
219
+ for line in lines:
220
+ depth, name, indent_unit = _indented_depth_and_name(line, indent_unit)
221
+ if depth < 0 or not name:
222
+ continue
223
+ result.append((depth, name))
224
+ return result
225
+
226
+
227
+ def _build_entries(
228
+ depth_name_pairs: list[tuple[int, str]],
229
+ ) -> list[ParsedEntry]:
230
+ if not depth_name_pairs:
231
+ raise StructureParseError("No parseable entries found in input.")
232
+
233
+ # Normalise so the shallowest entry is always at depth 0
234
+ min_depth = min(d for d, _ in depth_name_pairs)
235
+ if min_depth != 0:
236
+ depth_name_pairs = [(d - min_depth, n) for d, n in depth_name_pairs]
237
+
238
+ entries: list[ParsedEntry] = []
239
+ seen: set[str] = set()
240
+ stack: list[str] = [""]
241
+ prev_depth = -1
242
+
243
+ for depth, name in depth_name_pairs:
244
+ if depth > prev_depth + 1:
245
+ raise StructureParseError(
246
+ f"Entry '{name}' is indented {depth} level(s) deep but the "
247
+ f"previous depth was {prev_depth} — missing intermediate parent."
248
+ )
249
+
250
+ del stack[depth + 1:]
251
+
252
+ parent = stack[depth] if depth < len(stack) else stack[-1]
253
+ full_path = f"{parent}/{name}".lstrip("/") if parent else name
254
+
255
+ is_file = _is_file_entry(name)
256
+
257
+ if full_path in seen:
258
+ raise StructureParseError(
259
+ f"Duplicate path '{full_path}' declared more than once."
260
+ )
261
+ seen.add(full_path)
262
+
263
+ entries.append(ParsedEntry(path=full_path, is_file=is_file))
264
+
265
+ if not is_file:
266
+ if depth + 1 >= len(stack):
267
+ stack.append(full_path)
268
+ else:
269
+ stack[depth + 1] = full_path
270
+
271
+ prev_depth = depth
272
+
273
+ return entries
274
+
275
+
276
+ # ─── README helpers ───────────────────────────────────────────────────────
277
+
278
+
279
+ # ─── .gitignore helper ────────────────────────────────────────────────────
280
+
281
+
282
+ def _build_gitignore_content(extra_patterns: list[str]) -> str:
283
+ """Return .gitignore content: Python defaults + deduplicated extra patterns."""
284
+ from spawn.templates.shared_content import GITIGNORE_CONTENT
285
+
286
+ existing_lines = {
287
+ ln.strip() for ln in GITIGNORE_CONTENT.splitlines() if ln.strip()
288
+ }
289
+ new_lines = [p for p in extra_patterns if p not in existing_lines]
290
+ if not new_lines:
291
+ return GITIGNORE_CONTENT
292
+ return (
293
+ GITIGNORE_CONTENT.rstrip()
294
+ + "\n\n# Custom\n"
295
+ + "\n".join(new_lines)
296
+ + "\n"
297
+ )
298
+
299
+
300
+
301
+ def _render_tree(entries: list[ParsedEntry]) -> str:
302
+ """Render parsed entries as a normalised indented tree string."""
303
+ lines = []
304
+ for entry in entries:
305
+ depth = entry.path.count("/")
306
+ indent = " " * depth
307
+ name = entry.path.rsplit("/", 1)[-1]
308
+ suffix = "" if entry.is_file else "/"
309
+ lines.append(f"{indent}{name}{suffix}")
310
+ return "\n".join(lines)
311
+
312
+
313
+ def _build_readme_content(
314
+ project_name: str, entries: list[ParsedEntry], use_uv: bool
315
+ ) -> str:
316
+ """Return generated README.md content for a custom-structure project."""
317
+ tree_lines = _render_tree(entries)
318
+ setup_line = "uv sync" if use_uv else "# Set up your environment"
319
+ return (
320
+ f"# {project_name}\n\n"
321
+ "Generated by Spawn\n\n"
322
+ "## Project Structure\n\n"
323
+ "```\n"
324
+ f"{tree_lines}\n"
325
+ "```\n\n"
326
+ "## Setup\n\n"
327
+ "```bash\n"
328
+ f"{setup_line}\n"
329
+ "```\n\n"
330
+ "## Run\n\n"
331
+ "Add project run instructions here.\n"
332
+ )
333
+
334
+
335
+ def _build_agents_md_content(project_name: str, entries: list[ParsedEntry]) -> str:
336
+ """Return generated AGENTS.md content for a custom-structure project."""
337
+ tree_lines = _render_tree(entries)
338
+ return (
339
+ f"# Agent Context: {project_name}\n\n"
340
+ "This file orients coding agents working in this repository.\n\n"
341
+ "## Project Structure\n\n"
342
+ "```\n"
343
+ f"{tree_lines}\n"
344
+ "```\n\n"
345
+ "## Setup\n\n"
346
+ "See README.md for setup instructions.\n"
347
+ )
348
+
349
+
350
+ # ─── Filesystem generator ─────────────────────────────────────────────────
351
+
352
+
353
+ class CustomStructureGenerator:
354
+ def generate(
355
+ self,
356
+ project_name: str,
357
+ entries: list[ParsedEntry],
358
+ use_git: bool = False,
359
+ use_uv: bool = True,
360
+ dependencies: list[str] | None = None,
361
+ dev_setup: list[str] | None = None,
362
+ gitignore_extra: list[str] | None = None,
363
+ generate_claude_md: bool = False,
364
+ ) -> Path:
365
+ """
366
+ Create the folder/file structure described by *entries* under a new
367
+ directory named *project_name* in the current working directory.
368
+
369
+ Raises SpawnError if the directory already exists or an OS error
370
+ occurs. Rolls back on any failure.
371
+ """
372
+ project_path = Path(project_name)
373
+
374
+ if project_path.exists():
375
+ raise SpawnError(f"Directory '{project_name}' already exists.")
376
+
377
+ try:
378
+ project_path.mkdir()
379
+
380
+ for entry in entries:
381
+ if entry.path == "pyproject.toml" and use_uv:
382
+ continue # uv init --bare will create this
383
+ if entry.path == ".git" or entry.path.startswith(".git/"):
384
+ continue # git init will create/manage this
385
+ full_path = project_path / entry.path
386
+ if entry.is_file:
387
+ full_path.parent.mkdir(parents=True, exist_ok=True)
388
+ full_path.touch()
389
+ else:
390
+ full_path.mkdir(parents=True, exist_ok=True)
391
+
392
+ readme_entry = next(
393
+ (
394
+ e for e in entries
395
+ if e.path == "README.md" or e.path.endswith("/README.md")
396
+ ),
397
+ None,
398
+ )
399
+ if readme_entry is not None:
400
+ readme_path = project_path / readme_entry.path
401
+ readme_path.write_text(
402
+ _build_readme_content(project_name, entries, use_uv),
403
+ encoding="utf-8",
404
+ )
405
+
406
+ agents_md_entry = next(
407
+ (
408
+ e for e in entries
409
+ if e.path == "AGENTS.md" or e.path.endswith("/AGENTS.md")
410
+ ),
411
+ None,
412
+ )
413
+ if agents_md_entry is not None:
414
+ agents_md_path = project_path / agents_md_entry.path
415
+ agents_md_path.write_text(
416
+ _build_agents_md_content(project_name, entries),
417
+ encoding="utf-8",
418
+ )
419
+
420
+ if generate_claude_md and agents_md_entry is not None:
421
+ claude_md_path = project_path / agents_md_entry.path.replace("AGENTS.md", "CLAUDE.md")
422
+ claude_md_path.write_text(
423
+ _build_agents_md_content(project_name, entries),
424
+ encoding="utf-8",
425
+ )
426
+
427
+ gitignore_entry = next(
428
+ (
429
+ e for e in entries
430
+ if e.path == ".gitignore" or e.path.endswith("/.gitignore")
431
+ ),
432
+ None,
433
+ )
434
+ if gitignore_entry is not None:
435
+ gitignore_path = project_path / gitignore_entry.path
436
+ gitignore_path.write_text(
437
+ _build_gitignore_content(gitignore_extra or []),
438
+ encoding="utf-8",
439
+ )
440
+
441
+ if use_git:
442
+ console.print("[yellow]Initializing Git...[/yellow]")
443
+ initialize_git(project_path)
444
+
445
+ if use_uv:
446
+ initialize_uv(project_path)
447
+
448
+ if use_uv and dependencies:
449
+ console.print("[yellow]Installing dependencies...[/yellow]")
450
+ install_packages(project_path, dependencies)
451
+
452
+ if use_uv and dev_setup:
453
+ self._apply_dev_setup(project_path, dev_setup)
454
+
455
+ except OSError as e:
456
+ shutil.rmtree(project_path, ignore_errors=True)
457
+ raise SpawnError(str(e)) from e
458
+
459
+ except BaseException:
460
+ shutil.rmtree(project_path, ignore_errors=True)
461
+ raise
462
+
463
+ return project_path
464
+
465
+ def _apply_dev_setup(self, project_path: Path, dev_setup: list[str]) -> None:
466
+ """Install dev tools and generate their config files."""
467
+ dev_deps: list[str] = []
468
+ if "ruff" in dev_setup:
469
+ dev_deps.append("ruff")
470
+ if "pytest" in dev_setup:
471
+ dev_deps.append("pytest")
472
+ if "precommit" in dev_setup:
473
+ dev_deps.append("pre-commit")
474
+
475
+ if dev_deps:
476
+ console.print("[yellow]Installing dev tools...[/yellow]")
477
+ install_packages(project_path, dev_deps, dev=True)
478
+
479
+ if "ruff" in dev_setup:
480
+ (project_path / "ruff.toml").write_text(
481
+ 'line-length = 88\ntarget-version = "py312"\n',
482
+ encoding="utf-8",
483
+ )
484
+
485
+ if "pytest" in dev_setup:
486
+ tests_dir = project_path / "tests"
487
+ tests_dir.mkdir(exist_ok=True)
488
+ (tests_dir / "__init__.py").touch()
489
+
490
+ if "precommit" in dev_setup:
491
+ (project_path / ".pre-commit-config.yaml").write_text(
492
+ "repos:\n"
493
+ " - repo: https://github.com/astral-sh/ruff-pre-commit\n"
494
+ " rev: v0.15.16\n"
495
+ " hooks:\n"
496
+ " - id: ruff\n",
497
+ encoding="utf-8",
498
+ )
499
+
500
+ if "dockerfile" in dev_setup:
501
+ (project_path / "Dockerfile").write_text(
502
+ "FROM python:3.12-slim\n"
503
+ "WORKDIR /app\n"
504
+ "COPY . .\n"
505
+ "RUN pip install uv && uv sync\n"
506
+ 'CMD ["python", "-m", "src.main"]\n',
507
+ encoding="utf-8",
508
+ )