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
spawn/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ try:
4
+ __version__ = version("spawn")
5
+ except PackageNotFoundError:
6
+ __version__ = "1.0.5"
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