cloudwright-ai-cli 0.1.0__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.
@@ -0,0 +1,112 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from cloudwright import ArchSpec
8
+ from cloudwright.cost import CostEngine
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ console = Console()
13
+
14
+
15
+ def cost(
16
+ ctx: typer.Context,
17
+ spec_file: Annotated[Path, typer.Argument(help="Path to spec YAML file", exists=True)],
18
+ compare: Annotated[str | None, typer.Option(help="Comma-separated providers to compare")] = None,
19
+ pricing_tier: Annotated[
20
+ str | None, typer.Option(help="Pricing tier (on_demand, reserved_1yr, reserved_3yr, spot)")
21
+ ] = None,
22
+ ) -> None:
23
+ """Show cost breakdown for an architecture spec."""
24
+ spec = ArchSpec.from_file(spec_file)
25
+
26
+ # Compute cost estimate if not present
27
+ if not spec.cost_estimate:
28
+ engine = CostEngine()
29
+ spec.cost_estimate = engine.estimate(spec)
30
+
31
+ if ctx.obj and ctx.obj.get("json"):
32
+ import json
33
+
34
+ print(json.dumps({"estimate": spec.cost_estimate.model_dump()}, default=str))
35
+ return
36
+
37
+ if compare:
38
+ providers = [p.strip() for p in compare.split(",") if p.strip()]
39
+ _print_multi_cloud_table(spec, providers)
40
+ else:
41
+ _print_single_cost_table(spec)
42
+
43
+
44
+ def _print_single_cost_table(spec: ArchSpec) -> None:
45
+ if not spec.cost_estimate:
46
+ console.print("[yellow]No cost estimate in spec. Run 'cloudwright design' to generate one.[/yellow]")
47
+ return
48
+
49
+ table = Table(title=f"Cost Breakdown — {spec.name}", show_footer=True)
50
+ table.add_column("Component", style="cyan")
51
+ table.add_column("Service")
52
+ table.add_column("Monthly", justify="right", footer=f"${spec.cost_estimate.monthly_total:,.2f}")
53
+ table.add_column("Notes", style="dim")
54
+
55
+ comp_map = {c.id: c for c in spec.components}
56
+ for item in spec.cost_estimate.breakdown:
57
+ comp = comp_map.get(item.component_id)
58
+ svc_label = comp.service if comp else item.service
59
+ table.add_row(
60
+ item.component_id,
61
+ svc_label,
62
+ f"${item.monthly:,.2f}",
63
+ item.notes,
64
+ )
65
+
66
+ console.print(table)
67
+
68
+
69
+ def _print_multi_cloud_table(spec: ArchSpec, providers: list[str]) -> None:
70
+ all_providers = [spec.provider] + [p for p in providers if p != spec.provider]
71
+ alternatives_map: dict = {spec.provider: spec}
72
+
73
+ engine = CostEngine()
74
+ if not spec.cost_estimate:
75
+ spec.cost_estimate = engine.estimate(spec)
76
+ with console.status("Computing alternatives..."):
77
+ alts = engine.compare_providers(spec, providers)
78
+ for alt in alts:
79
+ if alt.spec:
80
+ alt.spec.cost_estimate = engine.estimate(alt.spec)
81
+ alternatives_map[alt.provider] = alt.spec
82
+
83
+ table = Table(title=f"Multi-Cloud Comparison — {spec.name}")
84
+ table.add_column("Component", style="cyan")
85
+
86
+ for p in all_providers:
87
+ table.add_column(p.upper(), justify="right")
88
+
89
+ comp_ids = [c.id for c in spec.components]
90
+ for cid in comp_ids:
91
+ row = [cid]
92
+ for p in all_providers:
93
+ s = alternatives_map.get(p)
94
+ if s and s.cost_estimate:
95
+ item = next((i for i in s.cost_estimate.breakdown if i.component_id == cid), None)
96
+ row.append(f"${item.monthly:,.2f}" if item else "-")
97
+ else:
98
+ row.append("-")
99
+ table.add_row(*row)
100
+
101
+ # Totals row
102
+ totals = []
103
+ for p in all_providers:
104
+ s = alternatives_map.get(p)
105
+ if s and s.cost_estimate:
106
+ totals.append(f"${s.cost_estimate.monthly_total:,.2f}")
107
+ else:
108
+ totals.append("-")
109
+ table.add_section()
110
+ table.add_row("[bold]TOTAL[/bold]", *totals)
111
+
112
+ console.print(table)
@@ -0,0 +1,84 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from cloudwright import Architect, Constraints
8
+ from rich.console import Console
9
+ from rich.panel import Panel
10
+ from rich.syntax import Syntax
11
+ from rich.table import Table
12
+
13
+ console = Console()
14
+
15
+
16
+ def design(
17
+ ctx: typer.Context,
18
+ description: Annotated[str, typer.Argument(help="Natural language architecture description")],
19
+ provider: Annotated[str, typer.Option(help="Cloud provider")] = "aws",
20
+ region: Annotated[str, typer.Option(help="Primary region")] = "us-east-1",
21
+ budget: Annotated[float | None, typer.Option(help="Monthly budget in USD")] = None,
22
+ compliance: Annotated[
23
+ list[str] | None, typer.Option(help="Compliance frameworks (hipaa, pci-dss, soc2, fedramp, gdpr)")
24
+ ] = None,
25
+ output: Annotated[Path | None, typer.Option("--output", "-o", help="Write YAML to file")] = None,
26
+ ) -> None:
27
+ """Design a cloud architecture from a natural language description."""
28
+ constraints = Constraints(
29
+ regions=[region] if region else [],
30
+ budget_monthly=budget,
31
+ compliance=compliance or [],
32
+ )
33
+
34
+ with console.status("Designing architecture..."):
35
+ spec = Architect().design(description, constraints=constraints)
36
+ # Set provider/region from CLI args if not overridden by LLM
37
+ if spec.provider == "aws" and provider != "aws":
38
+ spec = spec.model_copy(update={"provider": provider})
39
+ if spec.region == "us-east-1" and region != "us-east-1":
40
+ spec = spec.model_copy(update={"region": region})
41
+
42
+ if ctx.obj and ctx.obj.get("json"):
43
+ import json
44
+
45
+ print(json.dumps(spec.model_dump(), default=str))
46
+ return
47
+
48
+ yaml_str = spec.to_yaml()
49
+
50
+ console.print(
51
+ Panel(
52
+ Syntax(yaml_str, "yaml", theme="monokai", word_wrap=True),
53
+ title=f"[bold cyan]{spec.name}[/bold cyan]",
54
+ subtitle=f"{spec.provider.upper()} / {spec.region}",
55
+ )
56
+ )
57
+
58
+ if spec.cost_estimate:
59
+ _print_cost_table(spec)
60
+
61
+ if output:
62
+ output.write_text(yaml_str)
63
+ console.print(f"[green]Saved to {output}[/green]")
64
+
65
+
66
+ def _print_cost_table(spec) -> None:
67
+ table = Table(title="Cost Estimate", show_footer=True)
68
+ table.add_column("Component", style="cyan")
69
+ table.add_column("Service")
70
+ table.add_column("Monthly", justify="right", footer=f"${spec.cost_estimate.monthly_total:,.2f}")
71
+ table.add_column("Notes", style="dim")
72
+
73
+ comp_map = {c.id: c for c in spec.components}
74
+ for item in spec.cost_estimate.breakdown:
75
+ service = comp_map.get(item.component_id, None)
76
+ svc_label = service.service if service else item.service
77
+ table.add_row(
78
+ item.component_id,
79
+ svc_label,
80
+ f"${item.monthly:,.2f}",
81
+ item.notes,
82
+ )
83
+
84
+ console.print(table)
@@ -0,0 +1,88 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from cloudwright import ArchSpec, Differ
8
+ from rich.console import Console
9
+ from rich.rule import Rule
10
+ from rich.table import Table
11
+
12
+ console = Console()
13
+
14
+
15
+ def diff(
16
+ ctx: typer.Context,
17
+ spec_a: Annotated[Path, typer.Argument(help="First spec file (baseline)", exists=True)],
18
+ spec_b: Annotated[Path, typer.Argument(help="Second spec file (new)", exists=True)],
19
+ ) -> None:
20
+ """Show the diff between two architecture specs."""
21
+ a = ArchSpec.from_file(spec_a)
22
+ b = ArchSpec.from_file(spec_b)
23
+
24
+ with console.status("Computing diff..."):
25
+ result = Differ().diff(a, b)
26
+
27
+ if ctx.obj and ctx.obj.get("json"):
28
+ import json
29
+
30
+ print(json.dumps(result.model_dump(), default=str))
31
+ return
32
+
33
+ console.print(Rule(f"[bold]Diff: {spec_a.name} → {spec_b.name}[/bold]"))
34
+
35
+ if result.summary:
36
+ console.print(f"[dim]{result.summary}[/dim]\n")
37
+
38
+ if not result.added and not result.removed and not result.changed:
39
+ console.print("[green]No changes detected.[/green]")
40
+ return
41
+
42
+ if result.added:
43
+ console.print("[bold green]Added Components[/bold green]")
44
+ for comp in result.added:
45
+ console.print(f" [green]+[/green] {comp.id} ({comp.service}) — {comp.label}")
46
+ console.print()
47
+
48
+ if result.removed:
49
+ console.print("[bold red]Removed Components[/bold red]")
50
+ for comp in result.removed:
51
+ console.print(f" [red]-[/red] {comp.id} ({comp.service}) — {comp.label}")
52
+ console.print()
53
+
54
+ if result.changed:
55
+ table = Table(title="Changed Components")
56
+ table.add_column("Component", style="cyan")
57
+ table.add_column("Field")
58
+ table.add_column("Before", style="red")
59
+ table.add_column("After", style="green")
60
+ table.add_column("Cost Delta", justify="right")
61
+
62
+ for change in result.changed:
63
+ delta_str = _fmt_delta(change.cost_delta)
64
+ table.add_row(
65
+ change.component_id,
66
+ change.field,
67
+ change.old_value,
68
+ change.new_value,
69
+ delta_str,
70
+ )
71
+ console.print(table)
72
+
73
+ if result.cost_delta != 0.0:
74
+ delta_str = _fmt_delta(result.cost_delta)
75
+ console.print(f"\nTotal cost delta: {delta_str}/month")
76
+
77
+ if result.compliance_impact:
78
+ console.print("\n[bold yellow]Compliance Impact[/bold yellow]")
79
+ for item in result.compliance_impact:
80
+ console.print(f" [yellow]![/yellow] {item}")
81
+
82
+
83
+ def _fmt_delta(delta: float) -> str:
84
+ if delta > 0:
85
+ return f"[red]+${delta:,.2f}[/red]"
86
+ if delta < 0:
87
+ return f"[green]-${abs(delta):,.2f}[/green]"
88
+ return "[dim]$0.00[/dim]"
@@ -0,0 +1,98 @@
1
+ """Drift detection — compare design spec against deployed infrastructure."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.rule import Rule
12
+ from rich.table import Table
13
+
14
+ from cloudwright_cli.utils import handle_error
15
+
16
+ console = Console()
17
+
18
+
19
+ def drift(
20
+ ctx: typer.Context,
21
+ spec_file: Annotated[str, typer.Argument(help="Path to the design ArchSpec YAML")],
22
+ infra_file: Annotated[str, typer.Argument(help="Path to Terraform .tfstate or CloudFormation template")],
23
+ fmt: Annotated[
24
+ str, typer.Option("--format", "-f", help="Infrastructure format: auto, terraform, cloudformation")
25
+ ] = "auto",
26
+ json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
27
+ ) -> None:
28
+ """Compare design spec against deployed infrastructure to detect drift."""
29
+ try:
30
+ from cloudwright.drift import detect_drift
31
+
32
+ if not Path(spec_file).exists():
33
+ console.print(f"[red]Error:[/red] Design spec not found: {spec_file}")
34
+ raise typer.Exit(1)
35
+ if not Path(infra_file).exists():
36
+ console.print(f"[red]Error:[/red] Infrastructure file not found: {infra_file}")
37
+ raise typer.Exit(1)
38
+
39
+ with console.status("Detecting drift..."):
40
+ report = detect_drift(spec_file, infra_file, infra_format=fmt)
41
+
42
+ if json_output:
43
+ import json
44
+
45
+ result = {
46
+ "drift_score": report.drift_score,
47
+ "drifted_components": report.drifted_components,
48
+ "extra_components": report.extra_components,
49
+ "missing_components": report.missing_components,
50
+ "diff": report.diff.model_dump(),
51
+ "summary": report.summary,
52
+ }
53
+ console.print_json(json.dumps(result, default=str))
54
+ return
55
+
56
+ score_color = "green" if report.drift_score == 0 else "yellow" if report.drift_score < 0.3 else "red"
57
+
58
+ console.print(Rule("[bold]Cloudwright Drift Detection[/bold]"))
59
+ console.print(
60
+ Panel(
61
+ f"[{score_color}]Drift Score: {report.drift_score:.0%}[/{score_color}]\n[dim]{report.summary}[/dim]",
62
+ title=f"[dim]{Path(spec_file).name}[/dim] vs [dim]{Path(infra_file).name}[/dim]",
63
+ )
64
+ )
65
+
66
+ if report.drift_score == 0:
67
+ return
68
+
69
+ if report.missing_components:
70
+ console.print(f"\n[bold red]Missing from deployment ({len(report.missing_components)})[/bold red]")
71
+ for cid in report.missing_components:
72
+ console.print(f" [red]-[/red] {cid}")
73
+
74
+ if report.extra_components:
75
+ console.print(f"\n[bold yellow]Extra in deployment ({len(report.extra_components)})[/bold yellow]")
76
+ for cid in report.extra_components:
77
+ console.print(f" [yellow]+[/yellow] {cid}")
78
+
79
+ if report.drifted_components:
80
+ table = Table(title=f"Configuration Drift ({len(report.drifted_components)} components)")
81
+ table.add_column("Component", style="cyan")
82
+ table.add_column("Field")
83
+ table.add_column("Design", style="green")
84
+ table.add_column("Deployed", style="red")
85
+ for change in report.diff.changed:
86
+ table.add_row(change.component_id, change.field, change.old_value, change.new_value)
87
+ console.print()
88
+ console.print(table)
89
+
90
+ if report.diff.compliance_impact:
91
+ console.print("\n[bold red]Compliance Impact[/bold red]")
92
+ for impact in report.diff.compliance_impact:
93
+ console.print(f" [red]![/red] {impact}")
94
+
95
+ except typer.Exit:
96
+ raise
97
+ except Exception as e:
98
+ handle_error(ctx, e)
@@ -0,0 +1,65 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from cloudwright import ArchSpec
8
+ from cloudwright.exporter import FORMATS
9
+ from rich.console import Console
10
+ from rich.syntax import Syntax
11
+
12
+ console = Console()
13
+
14
+ _SYNTAX_MAP = {
15
+ "terraform": "hcl",
16
+ "cloudformation": "yaml",
17
+ "mermaid": "text",
18
+ "sbom": "json",
19
+ "aibom": "json",
20
+ }
21
+
22
+
23
+ def export(
24
+ ctx: typer.Context,
25
+ spec_file: Annotated[Path, typer.Argument(help="Path to spec YAML file", exists=True)],
26
+ format: Annotated[str, typer.Option("--format", "-f", help=f"Export format: {', '.join(FORMATS)}")],
27
+ output: Annotated[Path | None, typer.Option("--output", "-o", help="Output file or directory")] = None,
28
+ ) -> None:
29
+ """Export an architecture spec to Terraform, CloudFormation, Mermaid, SBOM, or AIBOM."""
30
+ fmt = format.lower().strip()
31
+ if fmt not in FORMATS and fmt != "cfn":
32
+ console.print(f"[red]Error:[/red] Unknown format {fmt!r}. Supported: {', '.join(FORMATS)}")
33
+ raise typer.Exit(1)
34
+
35
+ spec = ArchSpec.from_file(spec_file)
36
+
37
+ output_str = str(output) if output else None
38
+ output_dir_str = None
39
+
40
+ # Terraform with a directory target writes main.tf inside the dir
41
+ if fmt == "terraform" and output and output.is_dir():
42
+ output_dir_str = output_str
43
+ output_str = None
44
+ elif fmt == "terraform" and output and not output.suffix:
45
+ # Treat extensionless output as a directory path
46
+ output_dir_str = output_str
47
+ output_str = None
48
+
49
+ with console.status(f"Exporting as {fmt}..."):
50
+ content = spec.export(fmt, output=output_str, output_dir=output_dir_str)
51
+
52
+ if ctx.obj and ctx.obj.get("json"):
53
+ import json
54
+
55
+ print(json.dumps({"format": fmt, "content": content}, default=str))
56
+ return
57
+
58
+ if output:
59
+ if output_dir_str:
60
+ console.print(f"[green]Written to {output_dir_str}/main.tf[/green]")
61
+ else:
62
+ console.print(f"[green]Written to {output}[/green]")
63
+ else:
64
+ lang = _SYNTAX_MAP.get(fmt, "text")
65
+ console.print(Syntax(content, lang, theme="monokai", word_wrap=True))
@@ -0,0 +1,69 @@
1
+ """Import existing infrastructure (Terraform state, CloudFormation) into an ArchSpec."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ from cloudwright_cli.utils import handle_error
14
+
15
+ console = Console()
16
+
17
+
18
+ def import_infra(
19
+ ctx: typer.Context,
20
+ source: Annotated[str, typer.Argument(help="Path to .tfstate or CloudFormation template")],
21
+ fmt: Annotated[
22
+ str | None,
23
+ typer.Option("--format", "-f", help="Import format: terraform, cloudformation (default: auto-detect)"),
24
+ ] = None,
25
+ output: Annotated[
26
+ str | None,
27
+ typer.Option("--output", "-o", help="Write ArchSpec YAML to this file instead of stdout"),
28
+ ] = None,
29
+ name: Annotated[
30
+ str | None,
31
+ typer.Option("--name", help="Override the architecture name"),
32
+ ] = None,
33
+ ) -> None:
34
+ """Import infrastructure state or templates into an ArchSpec YAML file."""
35
+ try:
36
+ from cloudwright.importer import import_spec
37
+
38
+ kwargs: dict = {}
39
+ if fmt:
40
+ kwargs["fmt"] = fmt
41
+
42
+ with console.status(f"Importing {Path(source).name}..."):
43
+ spec = import_spec(source, **kwargs)
44
+
45
+ if name:
46
+ spec = spec.model_copy(update={"name": name})
47
+
48
+ json_mode = ctx.obj and ctx.obj.get("json")
49
+
50
+ if json_mode:
51
+ print(json.dumps(json.loads(spec.to_json()), indent=2))
52
+ return
53
+
54
+ content = spec.to_yaml()
55
+
56
+ if output:
57
+ Path(output).write_text(content)
58
+ n_comps = len(spec.components)
59
+ n_conns = len(spec.connections)
60
+ console.print(
61
+ f"[green]Imported[/green] {n_comps} component(s), {n_conns} connection(s) → [bold]{output}[/bold]"
62
+ )
63
+ else:
64
+ sys.stdout.write(content)
65
+
66
+ except typer.Exit:
67
+ raise
68
+ except Exception as e:
69
+ handle_error(ctx, e)
@@ -0,0 +1,135 @@
1
+ """Initialize a new ArchSpec from a template."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ import yaml
10
+ from rich.console import Console
11
+ from rich.table import Table
12
+
13
+ from cloudwright_cli.utils import handle_error
14
+
15
+ console = Console()
16
+
17
+ try:
18
+ from importlib.resources import files as _pkg_files
19
+
20
+ _TEMPLATES_DIR = Path(str(_pkg_files("cloudwright") / "data" / "templates"))
21
+ except Exception:
22
+ _TEMPLATES_DIR = Path(__file__).resolve().parents[4] / "catalog" / "templates"
23
+
24
+
25
+ def init(
26
+ ctx: typer.Context,
27
+ template: Annotated[str | None, typer.Option("--template", "-t", help="Template name")] = None,
28
+ output: Annotated[str, typer.Option("--output", "-o", help="Output file path")] = "spec.yaml",
29
+ list_templates: Annotated[bool, typer.Option("--list", "-l", help="List available templates")] = False,
30
+ provider: Annotated[str | None, typer.Option(help="Override provider (aws, gcp, azure)")] = None,
31
+ region: Annotated[str | None, typer.Option(help="Override region")] = None,
32
+ name: Annotated[str | None, typer.Option(help="Override architecture name")] = None,
33
+ compliance: Annotated[
34
+ str | None, typer.Option(help="Comma-separated compliance frameworks (hipaa, pci-dss, soc2, fedramp, gdpr)")
35
+ ] = None,
36
+ budget: Annotated[float | None, typer.Option(help="Monthly budget in USD")] = None,
37
+ project: Annotated[bool, typer.Option("--project", "-p", help="Create a .cloudwright/ project directory")] = False,
38
+ ) -> None:
39
+ """Initialize a new ArchSpec from a template."""
40
+ try:
41
+ index_path = _TEMPLATES_DIR / "_index.yaml"
42
+ if not index_path.exists():
43
+ console.print("[red]Error:[/red] Template index not found.")
44
+ raise typer.Exit(1)
45
+
46
+ index = yaml.safe_load(index_path.read_text())
47
+ templates = index.get("templates", {})
48
+
49
+ if list_templates:
50
+ table = Table(title="Available Templates")
51
+ table.add_column("Name", style="cyan")
52
+ table.add_column("Description")
53
+ table.add_column("Provider")
54
+ table.add_column("Complexity")
55
+ table.add_column("Tags")
56
+
57
+ for key, tmpl in templates.items():
58
+ table.add_row(
59
+ key,
60
+ tmpl.get("description", ""),
61
+ tmpl.get("provider", ""),
62
+ tmpl.get("complexity", ""),
63
+ ", ".join(tmpl.get("tags", [])),
64
+ )
65
+ console.print(table)
66
+ return
67
+
68
+ if not template:
69
+ console.print("[red]Error:[/red] Specify a template with --template <name>, or use --list to see options.")
70
+ raise typer.Exit(1)
71
+
72
+ if template not in templates:
73
+ console.print(f"[red]Error:[/red] Unknown template '{template}'. Use --list to see available templates.")
74
+ raise typer.Exit(1)
75
+
76
+ tmpl_info = templates[template]
77
+ tmpl_file = _TEMPLATES_DIR / tmpl_info["file"]
78
+
79
+ if not tmpl_file.exists():
80
+ console.print(f"[red]Error:[/red] Template file not found: {tmpl_info['file']}")
81
+ raise typer.Exit(1)
82
+
83
+ spec_data = yaml.safe_load(tmpl_file.read_text())
84
+
85
+ if name:
86
+ spec_data["name"] = name
87
+ if provider:
88
+ spec_data["provider"] = provider
89
+ for comp in spec_data.get("components", []):
90
+ comp["provider"] = provider
91
+ if region:
92
+ spec_data["region"] = region
93
+
94
+ if compliance:
95
+ if "constraints" not in spec_data:
96
+ spec_data["constraints"] = {}
97
+ spec_data["constraints"]["compliance"] = [c.strip() for c in compliance.split(",")]
98
+
99
+ if budget is not None:
100
+ if "constraints" not in spec_data:
101
+ spec_data["constraints"] = {}
102
+ spec_data["constraints"]["budget_monthly"] = budget
103
+
104
+ if project:
105
+ proj_dir = Path(".cloudwright")
106
+ proj_dir.mkdir(exist_ok=True)
107
+ output_path = proj_dir / "spec.yaml"
108
+ config = {
109
+ "version": 1,
110
+ "default_provider": spec_data.get("provider", "aws"),
111
+ "default_region": spec_data.get("region", "us-east-1"),
112
+ "compliance": [c.strip() for c in compliance.split(",")] if compliance else [],
113
+ "budget_monthly": budget,
114
+ }
115
+ config = {k: v for k, v in config.items() if v is not None}
116
+ (proj_dir / "config.yaml").write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
117
+ else:
118
+ output_path = Path(output)
119
+
120
+ output_path.write_text(yaml.dump(spec_data, default_flow_style=False, sort_keys=False, allow_unicode=True))
121
+
122
+ console.print(f"[green]Created {output_path}[/green] from template '{template}'")
123
+ console.print(f" Provider: {spec_data.get('provider', 'aws')}")
124
+ console.print(f" Components: {len(spec_data.get('components', []))}")
125
+ if project:
126
+ console.print(f" Config: {proj_dir / 'config.yaml'}")
127
+ console.print("\nNext steps:")
128
+ console.print(f" cloudwright cost {output_path}")
129
+ console.print(f" cloudwright validate {output_path}")
130
+ console.print(f" cloudwright export {output_path} --format terraform -o ./infra")
131
+
132
+ except typer.Exit:
133
+ raise
134
+ except Exception as e:
135
+ handle_error(ctx, e)