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/__init__.py
ADDED
spawn/cli/__init__.py
ADDED
|
File without changes
|
spawn/cli/app.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.prompt import Confirm, Prompt
|
|
7
|
+
|
|
8
|
+
from spawn import __version__
|
|
9
|
+
from spawn.cli.noninteractive import build_config_from_args, build_config_from_file
|
|
10
|
+
from spawn.cli.prompts import get_project_config
|
|
11
|
+
from spawn.generators.project_generator import ProjectGenerator
|
|
12
|
+
from spawn.github.publisher import GitHubPublisher
|
|
13
|
+
from spawn.github.exceptions import GitHubPublishError
|
|
14
|
+
from spawn.utils.banner import show_banner
|
|
15
|
+
from spawn.utils.success import show_success
|
|
16
|
+
from spawn.utils.console import console
|
|
17
|
+
from spawn.core.exceptions import SpawnError
|
|
18
|
+
from spawn.core.registry import instantiate_template
|
|
19
|
+
|
|
20
|
+
app = typer.Typer()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@app.callback(invoke_without_command=True)
|
|
24
|
+
def main_callback(ctx: typer.Context) -> None:
|
|
25
|
+
if ctx.invoked_subcommand is not None:
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
show_banner()
|
|
29
|
+
console.print(f"[dim]v{__version__}[/dim]\n")
|
|
30
|
+
console.print("[bold]Commands[/bold]")
|
|
31
|
+
console.print(" [cyan]create[/cyan] Scaffold a new project")
|
|
32
|
+
console.print(" [cyan]doctor[/cyan] Check the health of a project directory")
|
|
33
|
+
console.print(" [cyan]version[/cyan] Show the installed version")
|
|
34
|
+
console.print("\n[dim]Run [cyan]spawn COMMAND --help[/cyan] for details on a command.[/dim]\n")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _write_custom_metadata(project_path, config) -> None:
|
|
38
|
+
meta_dir = project_path / ".spawn"
|
|
39
|
+
meta_dir.mkdir(exist_ok=True)
|
|
40
|
+
(meta_dir / "meta.json").write_text(
|
|
41
|
+
json.dumps(
|
|
42
|
+
{
|
|
43
|
+
"intent": "custom",
|
|
44
|
+
"framework": None,
|
|
45
|
+
"provider": None,
|
|
46
|
+
"spawn_version": __version__,
|
|
47
|
+
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
48
|
+
"generator": "custom",
|
|
49
|
+
"git": config.use_git,
|
|
50
|
+
"uv": config.use_uv,
|
|
51
|
+
"source": config.custom_source_format or "tree",
|
|
52
|
+
},
|
|
53
|
+
indent=2,
|
|
54
|
+
),
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@app.command()
|
|
60
|
+
def create(
|
|
61
|
+
name: str = typer.Option(None, "--name", help="Project name (enables non-interactive mode)"),
|
|
62
|
+
template: str = typer.Option(None, "--template", help="Template slug: backend-api, cli, automation, chatbot, agent, rag, data"),
|
|
63
|
+
framework: str = typer.Option(None, "--framework", help="Framework choice for templates that support it"),
|
|
64
|
+
provider: str = typer.Option(None, "--provider", help="AI provider choice for chatbot/agent templates"),
|
|
65
|
+
cli_type: str = typer.Option(None, "--cli-type", help="CLI type for the cli template: utility or interactive"),
|
|
66
|
+
data_type: str = typer.Option(None, "--data-type", help="Project type for the data template"),
|
|
67
|
+
extras: str = typer.Option(None, "--extras", help="Comma-separated list of extras, e.g. ruff,pytest"),
|
|
68
|
+
git: bool = typer.Option(True, "--git/--no-git", help="Initialize a Git repository"),
|
|
69
|
+
uv: bool = typer.Option(True, "--uv/--no-uv", help="Initialize a uv environment and install dependencies"),
|
|
70
|
+
claude_md: bool = typer.Option(False, "--claude-md/--no-claude-md", help="Also generate CLAUDE.md alongside AGENTS.md"),
|
|
71
|
+
config_file: str = typer.Option(None, "--config", help="Path to a JSON config file (overrides other flags)"),
|
|
72
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the GitHub publish prompt"),
|
|
73
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Validate and print the resolved config without creating a project"),
|
|
74
|
+
) -> None:
|
|
75
|
+
try:
|
|
76
|
+
non_interactive = config_file is not None or name is not None
|
|
77
|
+
|
|
78
|
+
if non_interactive:
|
|
79
|
+
try:
|
|
80
|
+
if config_file is not None:
|
|
81
|
+
config = build_config_from_file(Path(config_file), use_claude_md=claude_md)
|
|
82
|
+
else:
|
|
83
|
+
if template is None:
|
|
84
|
+
raise SpawnError("--template is required when using --name without --config.")
|
|
85
|
+
extras_list = [e.strip() for e in extras.split(",") if e.strip()] if extras else []
|
|
86
|
+
config = build_config_from_args(
|
|
87
|
+
name=name,
|
|
88
|
+
template=template,
|
|
89
|
+
framework=framework,
|
|
90
|
+
provider=provider,
|
|
91
|
+
cli_type=cli_type,
|
|
92
|
+
data_type=data_type,
|
|
93
|
+
extras=extras_list,
|
|
94
|
+
use_git=git,
|
|
95
|
+
use_uv=uv,
|
|
96
|
+
use_claude_md=claude_md,
|
|
97
|
+
)
|
|
98
|
+
except SpawnError as e:
|
|
99
|
+
console.print(f"[red]❌ {e}[/red]")
|
|
100
|
+
raise typer.Exit(1)
|
|
101
|
+
|
|
102
|
+
if dry_run:
|
|
103
|
+
console.print("[green]✓ Config valid[/green]")
|
|
104
|
+
console.print(config)
|
|
105
|
+
return
|
|
106
|
+
else:
|
|
107
|
+
show_banner()
|
|
108
|
+
config = get_project_config()
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
if config.template == "custom":
|
|
112
|
+
from spawn.generators.custom_structure import CustomStructureGenerator
|
|
113
|
+
project_path = CustomStructureGenerator().generate(
|
|
114
|
+
project_name=config.name,
|
|
115
|
+
entries=config.custom_entries or [],
|
|
116
|
+
use_git=config.use_git,
|
|
117
|
+
use_uv=config.use_uv,
|
|
118
|
+
dependencies=config.custom_dependencies,
|
|
119
|
+
dev_setup=config.custom_dev_setup,
|
|
120
|
+
gitignore_extra=config.custom_gitignore_extra,
|
|
121
|
+
generate_claude_md=config.generate_claude_md,
|
|
122
|
+
)
|
|
123
|
+
_write_custom_metadata(project_path, config)
|
|
124
|
+
next_steps = [
|
|
125
|
+
f"cd {config.name}",
|
|
126
|
+
"Start building your project",
|
|
127
|
+
]
|
|
128
|
+
show_success(
|
|
129
|
+
project_name=config.name,
|
|
130
|
+
template_name="Custom Structure",
|
|
131
|
+
use_git=config.use_git,
|
|
132
|
+
next_steps=next_steps,
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
project_path = ProjectGenerator().generate(config)
|
|
136
|
+
template_obj = instantiate_template(config)
|
|
137
|
+
if template_obj is not None:
|
|
138
|
+
show_success(
|
|
139
|
+
project_name=config.name,
|
|
140
|
+
template_name=template_obj.name,
|
|
141
|
+
use_git=config.use_git,
|
|
142
|
+
next_steps=template_obj.next_steps,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
except SpawnError as e:
|
|
146
|
+
console.print(f"[red]❌ {e}[/red]")
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
if not config.use_git:
|
|
150
|
+
console.print(
|
|
151
|
+
"\n[yellow]ℹ GitHub publishing requires Git. Skipping.[/yellow]"
|
|
152
|
+
)
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
if non_interactive or yes:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
publish_to_github = Confirm.ask(
|
|
159
|
+
"\nPublish to GitHub?",
|
|
160
|
+
default=False,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if not publish_to_github:
|
|
164
|
+
return
|
|
165
|
+
|
|
166
|
+
repo_url = Prompt.ask("Repository URL")
|
|
167
|
+
|
|
168
|
+
publisher = GitHubPublisher()
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
publisher.publish(project_path, repo_url)
|
|
172
|
+
console.print("[green]🚀 Published successfully![/green]")
|
|
173
|
+
|
|
174
|
+
except GitHubPublishError as e:
|
|
175
|
+
console.print(f"[red]❌ {e}[/red]")
|
|
176
|
+
except (KeyboardInterrupt, EOFError, typer.Abort):
|
|
177
|
+
console.print("\n[yellow]Cancelled.[/yellow]")
|
|
178
|
+
raise typer.Exit(130)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command()
|
|
182
|
+
def version():
|
|
183
|
+
"""Show application version."""
|
|
184
|
+
typer.echo(f"Spawn v{__version__}")
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.command()
|
|
188
|
+
def doctor(
|
|
189
|
+
path: str = typer.Argument(
|
|
190
|
+
default=".",
|
|
191
|
+
help="Path to the project directory to check. Defaults to current directory.",
|
|
192
|
+
)
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Check the health of a project directory."""
|
|
195
|
+
from pathlib import Path
|
|
196
|
+
|
|
197
|
+
from spawn.utils.doctor import run_health_check
|
|
198
|
+
|
|
199
|
+
project_path = Path(path).resolve()
|
|
200
|
+
if not project_path.exists():
|
|
201
|
+
console.print(f"[red]❌ Path does not exist: {project_path}[/red]")
|
|
202
|
+
raise typer.Exit(1)
|
|
203
|
+
if not project_path.is_dir():
|
|
204
|
+
console.print(f"[red]❌ Path is not a directory: {project_path}[/red]")
|
|
205
|
+
raise typer.Exit(1)
|
|
206
|
+
run_health_check(project_path)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def main():
|
|
210
|
+
try:
|
|
211
|
+
app()
|
|
212
|
+
except (KeyboardInterrupt, EOFError, typer.Abort):
|
|
213
|
+
console.print("\n[yellow]Cancelled.[/yellow]")
|
|
214
|
+
raise typer.Exit(130)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
main()
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Non-interactive configuration builders for Spawn.
|
|
3
|
+
|
|
4
|
+
Provides two public functions:
|
|
5
|
+
|
|
6
|
+
- build_config_from_args: build a validated ProjectConfig from keyword arguments
|
|
7
|
+
- build_config_from_file: build a validated ProjectConfig from a JSON config file
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from spawn.core.exceptions import SpawnError
|
|
16
|
+
from spawn.core.models import ProjectConfig
|
|
17
|
+
from spawn.core.registry import get_metadata, list_templates
|
|
18
|
+
from spawn.templates.agent import get_supported_providers as get_agent_providers
|
|
19
|
+
from spawn.templates.chatbot import get_supported_providers as get_chatbot_providers
|
|
20
|
+
from spawn.utils.validators import validate_project_name
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_config_from_args(
|
|
24
|
+
name: str,
|
|
25
|
+
template: str,
|
|
26
|
+
framework: str | None = None,
|
|
27
|
+
provider: str | None = None,
|
|
28
|
+
cli_type: str | None = None,
|
|
29
|
+
data_type: str | None = None,
|
|
30
|
+
extras: list[str] | None = None,
|
|
31
|
+
use_git: bool = True,
|
|
32
|
+
use_uv: bool = True,
|
|
33
|
+
use_claude_md: bool = False,
|
|
34
|
+
) -> ProjectConfig:
|
|
35
|
+
"""
|
|
36
|
+
Build and return a validated ProjectConfig from explicit arguments.
|
|
37
|
+
|
|
38
|
+
Raises SpawnError for any invalid input. Validation runs in this order:
|
|
39
|
+
project name, directory existence, template, cli_type, data_type,
|
|
40
|
+
framework, provider, extras.
|
|
41
|
+
"""
|
|
42
|
+
# 1. Project name — let validate_project_name raise naturally.
|
|
43
|
+
validate_project_name(name)
|
|
44
|
+
|
|
45
|
+
# 2. Directory existence.
|
|
46
|
+
if Path(name).exists():
|
|
47
|
+
raise SpawnError(f"A directory named '{name}' already exists.")
|
|
48
|
+
|
|
49
|
+
# 3. Template.
|
|
50
|
+
metadata = get_metadata(template)
|
|
51
|
+
if metadata is None:
|
|
52
|
+
valid = ", ".join(m.slug for m in list_templates())
|
|
53
|
+
raise SpawnError(f"Unknown template: '{template}'. Valid templates: {valid}")
|
|
54
|
+
|
|
55
|
+
# 4. cli_type.
|
|
56
|
+
if metadata.available_cli_types:
|
|
57
|
+
if cli_type is None:
|
|
58
|
+
cli_type = metadata.available_cli_types[0]
|
|
59
|
+
elif cli_type not in metadata.available_cli_types:
|
|
60
|
+
valid = ", ".join(metadata.available_cli_types)
|
|
61
|
+
raise SpawnError(
|
|
62
|
+
f"Invalid cli_type: '{cli_type}'. "
|
|
63
|
+
f"Valid options for '{template}': {valid}"
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
cli_type = None
|
|
67
|
+
|
|
68
|
+
# 5. data_type.
|
|
69
|
+
if metadata.available_data_types:
|
|
70
|
+
if data_type is None:
|
|
71
|
+
data_type = metadata.available_data_types[0]
|
|
72
|
+
elif data_type not in metadata.available_data_types:
|
|
73
|
+
valid = ", ".join(metadata.available_data_types)
|
|
74
|
+
raise SpawnError(
|
|
75
|
+
f"Invalid data_type: '{data_type}'. "
|
|
76
|
+
f"Valid options for '{template}': {valid}"
|
|
77
|
+
)
|
|
78
|
+
else:
|
|
79
|
+
data_type = None
|
|
80
|
+
|
|
81
|
+
# 6. framework.
|
|
82
|
+
if metadata.available_frameworks:
|
|
83
|
+
if framework is None:
|
|
84
|
+
framework = metadata.available_frameworks[0]
|
|
85
|
+
elif framework not in metadata.available_frameworks:
|
|
86
|
+
valid = ", ".join(metadata.available_frameworks)
|
|
87
|
+
raise SpawnError(
|
|
88
|
+
f"Invalid framework: '{framework}'. "
|
|
89
|
+
f"Valid options for '{template}': {valid}"
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
framework = None
|
|
93
|
+
|
|
94
|
+
# 7. provider — only when the template declares providers AND a framework resolved.
|
|
95
|
+
if metadata.available_providers and framework is not None:
|
|
96
|
+
if metadata.slug == "agent":
|
|
97
|
+
valid_providers = get_agent_providers(framework)
|
|
98
|
+
elif metadata.slug == "chatbot":
|
|
99
|
+
valid_providers = get_chatbot_providers(framework)
|
|
100
|
+
else:
|
|
101
|
+
valid_providers = metadata.available_providers
|
|
102
|
+
|
|
103
|
+
if provider is None:
|
|
104
|
+
provider = valid_providers[0]
|
|
105
|
+
elif provider not in valid_providers:
|
|
106
|
+
valid = ", ".join(valid_providers)
|
|
107
|
+
raise SpawnError(
|
|
108
|
+
f"Invalid provider: '{provider}'. "
|
|
109
|
+
f"Valid options for '{template}' with framework '{framework}': {valid}"
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
provider = None
|
|
113
|
+
|
|
114
|
+
# 8. extras — fail-fast on first invalid item, then de-duplicate preserving order.
|
|
115
|
+
input_extras: list[str] = extras if extras is not None else []
|
|
116
|
+
if not metadata.available_extras:
|
|
117
|
+
validated_extras: list[str] = []
|
|
118
|
+
else:
|
|
119
|
+
# Validate first (fail-fast on first bad item in input order).
|
|
120
|
+
for item in input_extras:
|
|
121
|
+
if item not in metadata.available_extras:
|
|
122
|
+
valid = ", ".join(metadata.available_extras)
|
|
123
|
+
raise SpawnError(
|
|
124
|
+
f"Invalid extra: '{item}'. "
|
|
125
|
+
f"Valid options for '{template}': {valid}"
|
|
126
|
+
)
|
|
127
|
+
# Build de-duplicated result preserving input order.
|
|
128
|
+
seen: set[str] = set()
|
|
129
|
+
validated_extras = []
|
|
130
|
+
for item in input_extras:
|
|
131
|
+
if item not in seen:
|
|
132
|
+
validated_extras.append(item)
|
|
133
|
+
seen.add(item)
|
|
134
|
+
|
|
135
|
+
return ProjectConfig(
|
|
136
|
+
name=name,
|
|
137
|
+
template=template,
|
|
138
|
+
use_git=use_git,
|
|
139
|
+
framework=framework,
|
|
140
|
+
extras=validated_extras,
|
|
141
|
+
cli_type=cli_type,
|
|
142
|
+
data_type=data_type,
|
|
143
|
+
provider=provider,
|
|
144
|
+
use_uv=use_uv,
|
|
145
|
+
generate_claude_md=use_claude_md,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_config_from_file(path: Path, use_claude_md: bool = False) -> ProjectConfig:
|
|
150
|
+
"""
|
|
151
|
+
Build and return a validated ProjectConfig from a JSON config file.
|
|
152
|
+
|
|
153
|
+
The file must be a JSON object with at least "name" and "template" fields.
|
|
154
|
+
All other validation is delegated to build_config_from_args.
|
|
155
|
+
|
|
156
|
+
Raises SpawnError for missing file, invalid JSON, or any field errors.
|
|
157
|
+
"""
|
|
158
|
+
if not path.exists():
|
|
159
|
+
raise SpawnError(f"Config file not found: {path}")
|
|
160
|
+
|
|
161
|
+
try:
|
|
162
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
163
|
+
except json.JSONDecodeError as e:
|
|
164
|
+
raise SpawnError(f"Invalid JSON in config file: {e}") from e
|
|
165
|
+
|
|
166
|
+
if not isinstance(data, dict):
|
|
167
|
+
raise SpawnError("Config file must contain a JSON object.")
|
|
168
|
+
|
|
169
|
+
name = data.get("name", "")
|
|
170
|
+
if not name:
|
|
171
|
+
raise SpawnError("Config file must include a 'name' field.")
|
|
172
|
+
|
|
173
|
+
template = data.get("template", "")
|
|
174
|
+
if not template:
|
|
175
|
+
raise SpawnError("Config file must include a 'template' field.")
|
|
176
|
+
|
|
177
|
+
framework: str | None = data.get("framework", None)
|
|
178
|
+
provider: str | None = data.get("provider", None)
|
|
179
|
+
cli_type: str | None = data.get("cli_type", None)
|
|
180
|
+
data_type: str | None = data.get("data_type", None)
|
|
181
|
+
|
|
182
|
+
extras_raw = data.get("extras", [])
|
|
183
|
+
if not isinstance(extras_raw, list) or not all(
|
|
184
|
+
isinstance(e, str) for e in extras_raw
|
|
185
|
+
):
|
|
186
|
+
raise SpawnError("'extras' in config file must be a list of strings.")
|
|
187
|
+
extras: list[str] = extras_raw
|
|
188
|
+
|
|
189
|
+
git: bool = data.get("git", True)
|
|
190
|
+
uv: bool = data.get("uv", True)
|
|
191
|
+
claude_md: bool = data.get("claude_md", use_claude_md)
|
|
192
|
+
|
|
193
|
+
return build_config_from_args(
|
|
194
|
+
name=name,
|
|
195
|
+
template=template,
|
|
196
|
+
framework=framework,
|
|
197
|
+
provider=provider,
|
|
198
|
+
cli_type=cli_type,
|
|
199
|
+
data_type=data_type,
|
|
200
|
+
extras=extras,
|
|
201
|
+
use_git=git,
|
|
202
|
+
use_uv=uv,
|
|
203
|
+
use_claude_md=claude_md,
|
|
204
|
+
)
|
spawn/cli/prompts.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
import questionary
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from rich.prompt import Confirm
|
|
5
|
+
|
|
6
|
+
from spawn.utils.console import console
|
|
7
|
+
from spawn.core.models import ProjectConfig
|
|
8
|
+
from spawn.core.registry import list_templates, get_metadata
|
|
9
|
+
from spawn.templates.chatbot import get_supported_providers as get_chatbot_providers
|
|
10
|
+
from spawn.templates.agent import get_supported_providers as get_agent_providers
|
|
11
|
+
from spawn.utils.validators import validate_project_name
|
|
12
|
+
from spawn.core.exceptions import SpawnError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ---------------------------------------------------------------------------
|
|
16
|
+
# Questionary helpers
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
_QSTYLE = questionary.Style([
|
|
20
|
+
("qmark", "fg:#2D5E8E bold"),
|
|
21
|
+
("question", "bold"),
|
|
22
|
+
("pointer", "fg:#2D5E8E bold"),
|
|
23
|
+
("highlighted", "fg:#2D5E8E bold"),
|
|
24
|
+
("selected", "fg:#2D5E8E"),
|
|
25
|
+
])
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _select(message: str, choices: list[str], default: str | None = None) -> str:
|
|
29
|
+
"""Arrow-key single-select. Raises KeyboardInterrupt on Ctrl+C/Esc so the
|
|
30
|
+
existing top-level abort handling in cli/app.py catches it uniformly."""
|
|
31
|
+
answer = questionary.select(
|
|
32
|
+
message, choices=choices, default=default, style=_QSTYLE
|
|
33
|
+
).ask(kbi_msg="")
|
|
34
|
+
if answer is None:
|
|
35
|
+
raise KeyboardInterrupt
|
|
36
|
+
return answer
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _multiselect(message: str, choices: list[str]) -> list[str]:
|
|
40
|
+
"""Arrow-key + spacebar multi-select. Empty selection is valid (skip)."""
|
|
41
|
+
answer = questionary.checkbox(
|
|
42
|
+
message, choices=choices, style=_QSTYLE
|
|
43
|
+
).ask(kbi_msg="")
|
|
44
|
+
if answer is None:
|
|
45
|
+
raise KeyboardInterrupt
|
|
46
|
+
return answer
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ---------------------------------------------------------------------------
|
|
50
|
+
# Custom Structure optional-setup multi-select
|
|
51
|
+
# ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
_OPTIONAL_SETUP_CHOICES = ["Ruff", "Pytest", "Pre-commit", "Dockerfile"]
|
|
54
|
+
_OPTIONAL_SETUP_KEY = {
|
|
55
|
+
"Ruff": "ruff",
|
|
56
|
+
"Pytest": "pytest",
|
|
57
|
+
"Pre-commit": "precommit",
|
|
58
|
+
"Dockerfile": "dockerfile",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _prompt_optional_setup() -> list[str]:
|
|
63
|
+
"""Checkbox multi-select for optional dev tooling (Custom Structure flow)."""
|
|
64
|
+
chosen = _multiselect("Optional Setup (space to toggle)", _OPTIONAL_SETUP_CHOICES)
|
|
65
|
+
return [_OPTIONAL_SETUP_KEY[label] for label in chosen]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
# Main interactive config builder
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
|
|
72
|
+
def get_project_config() -> ProjectConfig:
|
|
73
|
+
# --- Project name ---
|
|
74
|
+
while True:
|
|
75
|
+
project_name = typer.prompt(
|
|
76
|
+
typer.style("Project Name", fg=typer.colors.CYAN)
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
validate_project_name(project_name)
|
|
81
|
+
except SpawnError as e:
|
|
82
|
+
typer.secho(str(e), fg=typer.colors.RED)
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
if Path(project_name).exists():
|
|
86
|
+
typer.secho(
|
|
87
|
+
f"A directory named '{project_name}' already exists. "
|
|
88
|
+
"Choose a different name.",
|
|
89
|
+
fg=typer.colors.RED,
|
|
90
|
+
)
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
break
|
|
94
|
+
|
|
95
|
+
# --- Template selection ---
|
|
96
|
+
templates = list_templates()
|
|
97
|
+
display_names = [meta.display_name for meta in templates] + ["Custom Structure"]
|
|
98
|
+
slug_by_display = {meta.display_name: meta.slug for meta in templates}
|
|
99
|
+
slug_by_display["Custom Structure"] = "__custom_structure__"
|
|
100
|
+
|
|
101
|
+
chosen_display = _select("Choose a template", display_names)
|
|
102
|
+
chosen_slug = slug_by_display[chosen_display]
|
|
103
|
+
|
|
104
|
+
if chosen_slug == "__custom_structure__":
|
|
105
|
+
return _get_custom_structure_config(project_name)
|
|
106
|
+
|
|
107
|
+
template = chosen_slug
|
|
108
|
+
|
|
109
|
+
# --- Sub-selections ---
|
|
110
|
+
selected_framework: str | None = None
|
|
111
|
+
selected_cli_type: str | None = None
|
|
112
|
+
selected_data_type: str | None = None
|
|
113
|
+
selected_provider: str | None = None
|
|
114
|
+
meta = get_metadata(template)
|
|
115
|
+
|
|
116
|
+
# --- CLI type ---
|
|
117
|
+
if meta and meta.available_cli_types:
|
|
118
|
+
cli_types = meta.available_cli_types
|
|
119
|
+
chosen = _select("Choose CLI Type", cli_types, default=cli_types[0])
|
|
120
|
+
selected_cli_type = chosen
|
|
121
|
+
|
|
122
|
+
# --- Data type ---
|
|
123
|
+
if meta and meta.available_data_types:
|
|
124
|
+
data_types = meta.available_data_types
|
|
125
|
+
chosen = _select("Choose Project Type", data_types, default=data_types[0])
|
|
126
|
+
selected_data_type = chosen
|
|
127
|
+
|
|
128
|
+
# --- Framework ---
|
|
129
|
+
if meta and meta.available_frameworks:
|
|
130
|
+
frameworks = meta.available_frameworks
|
|
131
|
+
chosen = _select("Choose Framework", frameworks, default=frameworks[0])
|
|
132
|
+
selected_framework = chosen
|
|
133
|
+
|
|
134
|
+
# --- Provider ---
|
|
135
|
+
if meta and meta.available_providers and selected_framework:
|
|
136
|
+
if meta.slug == "agent":
|
|
137
|
+
provider_options = get_agent_providers(selected_framework)
|
|
138
|
+
elif meta.slug == "chatbot":
|
|
139
|
+
provider_options = get_chatbot_providers(selected_framework)
|
|
140
|
+
else:
|
|
141
|
+
provider_options = meta.available_providers
|
|
142
|
+
chosen = _select("Choose Provider", provider_options, default=provider_options[0])
|
|
143
|
+
selected_provider = chosen
|
|
144
|
+
|
|
145
|
+
# --- Extras ---
|
|
146
|
+
selected_extras: list[str] = []
|
|
147
|
+
if meta and meta.available_extras:
|
|
148
|
+
selected_extras = _multiselect(
|
|
149
|
+
"Select extras (space to toggle, enter to confirm)",
|
|
150
|
+
meta.available_extras,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# --- Git ---
|
|
154
|
+
use_git = typer.confirm(
|
|
155
|
+
typer.style("Initialize Git?", fg=typer.colors.CYAN),
|
|
156
|
+
default=True,
|
|
157
|
+
)
|
|
158
|
+
generate_claude_md = Confirm.ask("Also generate CLAUDE.md for Claude Code?", default=False)
|
|
159
|
+
|
|
160
|
+
return ProjectConfig(
|
|
161
|
+
name=project_name,
|
|
162
|
+
template=template,
|
|
163
|
+
use_git=use_git,
|
|
164
|
+
generate_claude_md=generate_claude_md,
|
|
165
|
+
framework=selected_framework,
|
|
166
|
+
extras=selected_extras,
|
|
167
|
+
cli_type=selected_cli_type,
|
|
168
|
+
data_type=selected_data_type,
|
|
169
|
+
provider=selected_provider,
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _get_custom_structure_config(project_name: str) -> ProjectConfig:
|
|
174
|
+
"""
|
|
175
|
+
Interactive prompt for the Custom Structure path.
|
|
176
|
+
|
|
177
|
+
Reads a pasted structure (tree, markdown, or indented format),
|
|
178
|
+
previews detected folder/file counts, and collects Git/uv
|
|
179
|
+
preferences, optional dependencies, optional dev tooling
|
|
180
|
+
(ruff/pytest/pre-commit/dockerfile), and optional extra .gitignore
|
|
181
|
+
patterns. Returns a fully populated ProjectConfig for the "custom"
|
|
182
|
+
template.
|
|
183
|
+
"""
|
|
184
|
+
import sys
|
|
185
|
+
|
|
186
|
+
from spawn.core.exceptions import StructureParseError
|
|
187
|
+
from spawn.generators.custom_structure import parse_structure, detect_format
|
|
188
|
+
|
|
189
|
+
console.print()
|
|
190
|
+
console.print(
|
|
191
|
+
"[cyan]Paste your project structure.[/cyan]\n"
|
|
192
|
+
"[dim]Supported formats: Tree (├──/└──), Markdown list (- item), "
|
|
193
|
+
"Indented hierarchy[/dim]\n"
|
|
194
|
+
"[dim]Finish with Ctrl+D (Ctrl+Z then Enter on Windows)[/dim]"
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
raw = sys.stdin.read()
|
|
198
|
+
detected_format = detect_format(raw)
|
|
199
|
+
|
|
200
|
+
try:
|
|
201
|
+
entries = parse_structure(raw)
|
|
202
|
+
except StructureParseError as e:
|
|
203
|
+
typer.secho(str(e), fg=typer.colors.RED)
|
|
204
|
+
raise SpawnError("Could not parse structure.") from e
|
|
205
|
+
|
|
206
|
+
folders = [e for e in entries if not e.is_file]
|
|
207
|
+
files = [e for e in entries if e.is_file]
|
|
208
|
+
|
|
209
|
+
console.print()
|
|
210
|
+
console.print("[bold]Detected[/bold]")
|
|
211
|
+
console.print(f"Folders : {len(folders)}")
|
|
212
|
+
console.print(f"Files : {len(files)}")
|
|
213
|
+
console.print()
|
|
214
|
+
|
|
215
|
+
use_git = typer.confirm(
|
|
216
|
+
typer.style("Initialize Git?", fg=typer.colors.CYAN), default=True
|
|
217
|
+
)
|
|
218
|
+
generate_claude_md = Confirm.ask("Also generate CLAUDE.md for Claude Code?", default=False)
|
|
219
|
+
use_uv = typer.confirm(
|
|
220
|
+
typer.style("Initialize uv?", fg=typer.colors.CYAN), default=True
|
|
221
|
+
)
|
|
222
|
+
custom_dependencies: list[str] = []
|
|
223
|
+
if use_uv:
|
|
224
|
+
deps_raw = typer.prompt(
|
|
225
|
+
typer.style(
|
|
226
|
+
"Dependencies (comma separated, optional)",
|
|
227
|
+
fg=typer.colors.CYAN,
|
|
228
|
+
),
|
|
229
|
+
default="",
|
|
230
|
+
show_default=False,
|
|
231
|
+
)
|
|
232
|
+
if deps_raw.strip():
|
|
233
|
+
custom_dependencies = [
|
|
234
|
+
d.strip() for d in deps_raw.split(",") if d.strip()
|
|
235
|
+
]
|
|
236
|
+
custom_dev_setup = _prompt_optional_setup() if use_uv else []
|
|
237
|
+
console.print()
|
|
238
|
+
extra_ignores_raw = typer.prompt(
|
|
239
|
+
typer.style(
|
|
240
|
+
"Additional ignore patterns (optional, comma separated)",
|
|
241
|
+
fg=typer.colors.CYAN,
|
|
242
|
+
),
|
|
243
|
+
default="",
|
|
244
|
+
show_default=False,
|
|
245
|
+
)
|
|
246
|
+
custom_gitignore_extra = [
|
|
247
|
+
p.strip() for p in extra_ignores_raw.split(",") if p.strip()
|
|
248
|
+
]
|
|
249
|
+
proceed = typer.confirm(
|
|
250
|
+
typer.style("Proceed?", fg=typer.colors.CYAN), default=True
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
if not proceed:
|
|
254
|
+
raise SpawnError("Cancelled.")
|
|
255
|
+
|
|
256
|
+
return ProjectConfig(
|
|
257
|
+
name=project_name,
|
|
258
|
+
template="custom",
|
|
259
|
+
use_git=use_git,
|
|
260
|
+
generate_claude_md=generate_claude_md,
|
|
261
|
+
use_uv=use_uv,
|
|
262
|
+
custom_entries=entries,
|
|
263
|
+
custom_dependencies=custom_dependencies,
|
|
264
|
+
custom_dev_setup=custom_dev_setup,
|
|
265
|
+
custom_gitignore_extra=custom_gitignore_extra,
|
|
266
|
+
custom_source_format=detected_format,
|
|
267
|
+
)
|
spawn/core/__init__.py
ADDED
|
File without changes
|