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.
- spawn/__init__.py +6 -0
- spawn/cli/__init__.py +0 -0
- spawn/cli/app.py +218 -0
- spawn/cli/noninteractive.py +204 -0
- spawn/cli/prompts.py +267 -0
- spawn/core/__init__.py +0 -0
- spawn/core/exceptions.py +7 -0
- spawn/core/models.py +21 -0
- spawn/core/registry.py +152 -0
- spawn/generators/__init__.py +0 -0
- spawn/generators/custom_structure.py +508 -0
- spawn/generators/project_generator.py +115 -0
- spawn/github/__init__.py +0 -0
- spawn/github/exceptions.py +5 -0
- spawn/github/publisher.py +69 -0
- spawn/github/validators.py +23 -0
- spawn/templates/__init__.py +0 -0
- spawn/templates/agent/__init__.py +182 -0
- spawn/templates/agent/content.py +374 -0
- spawn/templates/automation/__init__.py +100 -0
- spawn/templates/automation/content.py +181 -0
- spawn/templates/backend_api/__init__.py +243 -0
- spawn/templates/backend_api/content.py +509 -0
- spawn/templates/base.py +58 -0
- spawn/templates/chatbot/__init__.py +243 -0
- spawn/templates/chatbot/content.py +668 -0
- spawn/templates/cli_application/__init__.py +211 -0
- spawn/templates/cli_application/content.py +512 -0
- spawn/templates/data_project/__init__.py +125 -0
- spawn/templates/data_project/analysis/__init__.py +74 -0
- spawn/templates/data_project/analysis/content.py +190 -0
- spawn/templates/data_project/dashboard/__init__.py +61 -0
- spawn/templates/data_project/dashboard/content.py +135 -0
- spawn/templates/data_project/etl/__init__.py +66 -0
- spawn/templates/data_project/etl/content.py +239 -0
- spawn/templates/data_project/ml/__init__.py +62 -0
- spawn/templates/data_project/ml/content.py +239 -0
- spawn/templates/mcp_server/__init__.py +83 -0
- spawn/templates/mcp_server/content.py +161 -0
- spawn/templates/rag/__init__.py +124 -0
- spawn/templates/rag/content.py +463 -0
- spawn/templates/shared_content.py +89 -0
- spawn/utils/__init__.py +0 -0
- spawn/utils/banner.py +41 -0
- spawn/utils/console.py +3 -0
- spawn/utils/doctor.py +594 -0
- spawn/utils/git.py +132 -0
- spawn/utils/success.py +47 -0
- spawn/utils/theme.py +25 -0
- spawn/utils/uv.py +61 -0
- spawn/utils/validators.py +15 -0
- spawnio-1.0.5.dist-info/METADATA +798 -0
- spawnio-1.0.5.dist-info/RECORD +56 -0
- spawnio-1.0.5.dist-info/WHEEL +4 -0
- spawnio-1.0.5.dist-info/entry_points.txt +2 -0
- spawnio-1.0.5.dist-info/licenses/LICENSE +21 -0
spawn/core/exceptions.py
ADDED
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
|
+
)
|