Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 64 additions & 39 deletions src/spawn/cli/app.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import typer
import datetime
import json

import typer
from rich.prompt import Confirm, Prompt

from spawn import __version__
from spawn.cli.prompts import get_project_config
from spawn.generators.project_generator import ProjectGenerator
from spawn.github.publisher import GitHubPublisher
Expand All @@ -15,35 +18,71 @@
app = typer.Typer()


def _write_custom_metadata(project_path, config) -> None:
meta_dir = project_path / ".spawn"
meta_dir.mkdir(exist_ok=True)
(meta_dir / "meta.json").write_text(
json.dumps(
{
"intent": "custom",
"framework": None,
"provider": None,
"spawn_version": __version__,
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"generator": "custom",
"git": config.use_git,
"uv": config.use_uv,
# detect_format() result not yet threaded through ProjectConfig;
# "tree" is a known placeholder — tracked as follow-up for Phase 5
"source": "tree",
},
indent=2,
),
encoding="utf-8",
)


@app.command()
def create() -> None:
show_banner()

config = get_project_config()

generator = ProjectGenerator()

try:
project_path = generator.generate(
config
)
if config.template == "custom":
from spawn.generators.custom_structure import CustomStructureGenerator
project_path = CustomStructureGenerator().generate(
project_name=config.name,
entries=config.custom_entries or [],
use_git=config.use_git,
use_uv=config.use_uv,
)
_write_custom_metadata(project_path, config)
next_steps = [
f"cd {config.name}",
"Start building your project",
]
show_success(
project_name=config.name,
template_name="Custom Structure",
use_git=config.use_git,
next_steps=next_steps,
)
else:
project_path = ProjectGenerator().generate(config)
template_obj = instantiate_template(config)
if template_obj is not None:
show_success(
project_name=config.name,
template_name=template_obj.name,
use_git=config.use_git,
next_steps=template_obj.next_steps,
)

except SpawnError as e:
console.print(
f"[red]❌ {e}[/red]"
)
console.print(f"[red]❌ {e}[/red]")
return

template_obj = instantiate_template(config)

if template_obj is not None:
show_success(
project_name=config.name,
template_name=template_obj.name,
use_git=config.use_git,
next_steps=template_obj.next_steps,
)

if not config.use_git:
console.print(
"\n[yellow]ℹ GitHub publishing requires Git. Skipping.[/yellow]"
Expand All @@ -58,36 +97,22 @@ def create() -> None:
if not publish_to_github:
return

repo_url = Prompt.ask(
"Repository URL"
)
repo_url = Prompt.ask("Repository URL")

publisher = GitHubPublisher()

try:
publisher.publish(
project_path,
repo_url,
)

console.print(
"[green]🚀 Published successfully![/green]"
)
publisher.publish(project_path, repo_url)
console.print("[green]🚀 Published successfully![/green]")

except GitHubPublishError as e:
console.print(
f"[red]❌ {e}[/red]"
)
console.print(f"[red]❌ {e}[/red]")


@app.command()
def version():
"""Show application version."""
from spawn import __version__

typer.echo(
f"Spawn v{__version__}"
)
typer.echo(f"Spawn v{__version__}")


@app.command()
Expand Down Expand Up @@ -117,4 +142,4 @@ def main():


if __name__ == "__main__":
main()
main()
70 changes: 68 additions & 2 deletions src/spawn/cli/prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,18 @@ def get_project_config() -> ProjectConfig:
# --- Template selection ---
templates = list_templates()

# registry templates in order, then the sentinel for Custom Structure
choice_map = {
str(i): meta.slug
for i, meta in enumerate(templates, start=1)
}
custom_index = str(len(templates) + 1)
choice_map[custom_index] = "__custom_structure__"

_print_list([meta.display_name for meta in templates])
display_names = [meta.display_name for meta in templates] + ["Custom Structure"]
_print_list(display_names)

valid_range = len(templates)
valid_range = len(display_names)
choice = typer.prompt(
typer.style(f"Choose Template [1-{valid_range}]", fg=typer.colors.CYAN)
)
Expand All @@ -66,6 +70,9 @@ def get_project_config() -> ProjectConfig:
typer.style(f"Choose Template [1-{valid_range}]", fg=typer.colors.CYAN)
)

if choice_map[choice] == "__custom_structure__":
return _get_custom_structure_config(project_name)

template = choice_map[choice]

# --- Framework selection ---
Expand Down Expand Up @@ -254,3 +261,62 @@ def get_project_config() -> ProjectConfig:
data_type=selected_data_type,
provider=selected_provider,
)

def _get_custom_structure_config(project_name: str) -> ProjectConfig:
"""
Interactive prompt for the Custom Structure path.

Reads a pasted structure, previews detected folders/files, confirms
Git/uv preferences, then raises SpawnError until Phase 4 wires in
the required ProjectConfig fields.
"""
import sys

from spawn.core.exceptions import StructureParseError
from spawn.generators.custom_structure import parse_structure

console.print()
console.print(
"[cyan]Paste your project structure.[/cyan]\n"
"[dim]Supported formats: Tree (├──/└──), Markdown list (- item), "
"Indented hierarchy[/dim]\n"
"[dim]Finish with Ctrl+D (Ctrl+Z then Enter on Windows)[/dim]"
)

raw = sys.stdin.read()

try:
entries = parse_structure(raw)
except StructureParseError as e:
typer.secho(str(e), fg=typer.colors.RED)
raise SpawnError("Could not parse structure.") from e

folders = [e for e in entries if not e.is_file]
files = [e for e in entries if e.is_file]

console.print()
console.print("[bold]Detected[/bold]")
console.print(f"Folders : {len(folders)}")
console.print(f"Files : {len(files)}")
console.print()

use_git = typer.confirm(
typer.style("Initialize Git?", fg=typer.colors.CYAN), default=True
)
use_uv = typer.confirm(
typer.style("Initialize uv?", fg=typer.colors.CYAN), default=True
)
proceed = typer.confirm(
typer.style("Proceed?", fg=typer.colors.CYAN), default=True
)

if not proceed:
raise SpawnError("Cancelled.")

return ProjectConfig(
name=project_name,
template="custom",
use_git=use_git,
use_uv=use_uv,
custom_entries=entries,
)
6 changes: 5 additions & 1 deletion src/spawn/core/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
class SpawnError(Exception):
"""Base exception for Spawn."""
pass
pass


class StructureParseError(SpawnError):
"""Raised when pasted structure text cannot be parsed."""
2 changes: 2 additions & 0 deletions src/spawn/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,5 @@ class ProjectConfig:
cli_type: str | None = None
data_type: str | None = None
provider: str | None = None
use_uv: bool = True
custom_entries: list | None = None # list[ParsedEntry] when template == "custom"
Loading
Loading