cloudwright-ai-cli 0.1.0__tar.gz

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 (35) hide show
  1. cloudwright_ai_cli-0.1.0/.gitignore +16 -0
  2. cloudwright_ai_cli-0.1.0/CLAUDE.md +23 -0
  3. cloudwright_ai_cli-0.1.0/PKG-INFO +42 -0
  4. cloudwright_ai_cli-0.1.0/README.md +23 -0
  5. cloudwright_ai_cli-0.1.0/cloudwright_cli/__init__.py +1 -0
  6. cloudwright_ai_cli-0.1.0/cloudwright_cli/__main__.py +5 -0
  7. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/__init__.py +0 -0
  8. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/analyze_cmd.py +117 -0
  9. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/catalog_cmd.py +148 -0
  10. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/chat.py +164 -0
  11. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/compare.py +75 -0
  12. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/cost.py +112 -0
  13. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/design.py +84 -0
  14. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/diff.py +88 -0
  15. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/drift_cmd.py +98 -0
  16. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/export.py +65 -0
  17. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/import_cmd.py +69 -0
  18. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/init_cmd.py +135 -0
  19. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/lint_cmd.py +91 -0
  20. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/modify_cmd.py +125 -0
  21. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/policy.py +88 -0
  22. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/refresh_cmd.py +104 -0
  23. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/score_cmd.py +80 -0
  24. cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/validate.py +106 -0
  25. cloudwright_ai_cli-0.1.0/cloudwright_cli/main.py +66 -0
  26. cloudwright_ai_cli-0.1.0/cloudwright_cli/project.py +50 -0
  27. cloudwright_ai_cli-0.1.0/cloudwright_cli/py.typed +0 -0
  28. cloudwright_ai_cli-0.1.0/cloudwright_cli/utils.py +37 -0
  29. cloudwright_ai_cli-0.1.0/pyproject.toml +37 -0
  30. cloudwright_ai_cli-0.1.0/tests/__init__.py +0 -0
  31. cloudwright_ai_cli-0.1.0/tests/test_cli.py +295 -0
  32. cloudwright_ai_cli-0.1.0/tests/test_drift_cmd.py +28 -0
  33. cloudwright_ai_cli-0.1.0/tests/test_init.py +232 -0
  34. cloudwright_ai_cli-0.1.0/tests/test_modify_cmd.py +21 -0
  35. cloudwright_ai_cli-0.1.0/tests/test_project.py +63 -0
@@ -0,0 +1,16 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .env
7
+ *.db
8
+ !packages/core/cloudwright/data/catalog.db
9
+ node_modules/
10
+ .vite/
11
+ .ruff_cache/
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+
15
+ # Codebase intelligence (auto-generated)
16
+ .planning/
@@ -0,0 +1,23 @@
1
+ # cloudwright-cli
2
+
3
+ CLI interface for Cloudwright. Wraps core package with Typer commands and Rich formatting.
4
+
5
+ ## Commands
6
+
7
+ All commands are in `cloudwright_cli/commands/`. Each is a standalone module registered in `main.py`.
8
+
9
+ - `design` — Generate architecture from natural language
10
+ - `cost` — Price an ArchSpec, optional multi-cloud comparison
11
+ - `compare` — Full multi-cloud architecture comparison
12
+ - `validate` — Compliance and Well-Architected checks
13
+ - `export` — Export to Terraform, CFN, Mermaid, SBOM, AIBOM
14
+ - `diff` — Compare two ArchSpec files
15
+ - `chat` — Interactive terminal chat or web UI launcher
16
+ - `catalog` — Subgroup with `search` and `compare` subcommands
17
+
18
+ ## Conventions
19
+
20
+ - Typer for argument parsing, Rich for output formatting
21
+ - All output goes through Rich Console for consistent formatting
22
+ - Errors shown as `[red]Error:[/red] message`
23
+ - Tables for data, Panels for summaries, Syntax blocks for YAML/HCL
@@ -0,0 +1,42 @@
1
+ Metadata-Version: 2.4
2
+ Name: cloudwright-ai-cli
3
+ Version: 0.1.0
4
+ Summary: CLI for Cloudwright architecture intelligence
5
+ Project-URL: Homepage, https://github.com/xmpuspus/cloudwright
6
+ Project-URL: Repository, https://github.com/xmpuspus/cloudwright
7
+ Author: Xavier Puspus
8
+ License-Expression: MIT
9
+ Keywords: architecture,cli,cloud,terraform
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Requires-Python: >=3.12
15
+ Requires-Dist: cloudwright-ai<1,>=0.1.0
16
+ Requires-Dist: rich<15,>=13.9
17
+ Requires-Dist: typer<1,>=0.21
18
+ Description-Content-Type: text/markdown
19
+
20
+ # cloudwright-cli
21
+
22
+ Command-line interface for [Cloudwright](https://github.com/xmpuspus/cloudwright) architecture intelligence.
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ pip install cloudwright[cli]
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ ```bash
33
+ cloudwright design "3-tier web app on AWS"
34
+ cloudwright cost spec.yaml
35
+ cloudwright validate spec.yaml --compliance hipaa
36
+ cloudwright export spec.yaml --format terraform -o ./infra
37
+ cloudwright diff v1.yaml v2.yaml
38
+ cloudwright catalog search "4 vcpu 16gb"
39
+ cloudwright chat
40
+ ```
41
+
42
+ See the [main project README](https://github.com/xmpuspus/cloudwright) for full documentation.
@@ -0,0 +1,23 @@
1
+ # cloudwright-cli
2
+
3
+ Command-line interface for [Cloudwright](https://github.com/xmpuspus/cloudwright) architecture intelligence.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pip install cloudwright[cli]
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```bash
14
+ cloudwright design "3-tier web app on AWS"
15
+ cloudwright cost spec.yaml
16
+ cloudwright validate spec.yaml --compliance hipaa
17
+ cloudwright export spec.yaml --format terraform -o ./infra
18
+ cloudwright diff v1.yaml v2.yaml
19
+ cloudwright catalog search "4 vcpu 16gb"
20
+ cloudwright chat
21
+ ```
22
+
23
+ See the [main project README](https://github.com/xmpuspus/cloudwright) for full documentation.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow running as `python3 -m cloudwright_cli`."""
2
+
3
+ from cloudwright_cli.main import app
4
+
5
+ app()
@@ -0,0 +1,117 @@
1
+ """Analyze architecture blast radius and dependencies."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from rich.console import Console
10
+ from rich.panel import Panel
11
+ from rich.table import Table
12
+ from rich.tree import Tree
13
+
14
+ from cloudwright_cli.utils import handle_error
15
+
16
+ console = Console()
17
+
18
+
19
+ def analyze(
20
+ ctx: typer.Context,
21
+ spec_file: Annotated[str, typer.Argument(help="Path to ArchSpec YAML/JSON file")],
22
+ component: Annotated[str | None, typer.Option("--component", "-c", help="Analyze specific component")] = None,
23
+ ) -> None:
24
+ """Analyze blast radius, SPOFs, and dependency structure."""
25
+ try:
26
+ from cloudwright import ArchSpec
27
+ from cloudwright.analyzer import Analyzer
28
+
29
+ spec = ArchSpec.from_file(spec_file)
30
+ analyzer = Analyzer()
31
+ result = analyzer.analyze(spec, component_id=component)
32
+
33
+ if ctx.obj and ctx.obj.get("json"):
34
+ print(json.dumps(result.to_dict(), indent=2))
35
+ return
36
+
37
+ spof_text = ", ".join(result.spofs) if result.spofs else "None"
38
+ console.print(
39
+ Panel(
40
+ f"Components: {result.total_components} | "
41
+ f"Max Blast Radius: {result.max_blast_radius} | "
42
+ f"SPOFs: {len(result.spofs)}",
43
+ title=f"Blast Radius Analysis: {spec.name}",
44
+ border_style="red" if result.spofs else "green",
45
+ )
46
+ )
47
+
48
+ if result.spofs:
49
+ console.print(f"\n[bold red]Single Points of Failure:[/bold red] {spof_text}")
50
+
51
+ if result.critical_path:
52
+ console.print(f"\n[bold]Critical Path:[/bold] {' -> '.join(result.critical_path)}")
53
+
54
+ table = Table(title="\nComponent Impact")
55
+ table.add_column("Component", style="cyan")
56
+ table.add_column("Service")
57
+ table.add_column("Tier", justify="right")
58
+ table.add_column("Direct Deps", justify="right")
59
+ table.add_column("Blast Radius", justify="right")
60
+ table.add_column("SPOF")
61
+
62
+ for impact in result.components:
63
+ spof_marker = "[red]YES[/red]" if impact.is_spof else ""
64
+ if impact.blast_radius > result.total_components * 0.5:
65
+ blast_color = "red"
66
+ elif impact.blast_radius > 0:
67
+ blast_color = "yellow"
68
+ else:
69
+ blast_color = "green"
70
+ table.add_row(
71
+ impact.component_id,
72
+ impact.service,
73
+ str(impact.tier),
74
+ str(len(impact.direct_dependents)),
75
+ f"[{blast_color}]{impact.blast_radius}[/{blast_color}]",
76
+ spof_marker,
77
+ )
78
+
79
+ console.print(table)
80
+
81
+ if not component:
82
+ all_targets: set[str] = set()
83
+ for deps in result.graph.values():
84
+ all_targets.update(deps)
85
+ roots = [c.component_id for c in result.components if c.component_id not in all_targets]
86
+ if not roots and result.components:
87
+ roots = [result.components[0].component_id]
88
+
89
+ tree = Tree("[bold]Dependency Graph[/bold]")
90
+ visited_tree: set[str] = set()
91
+ for root in roots:
92
+ _build_tree(tree, root, result.graph, visited_tree)
93
+
94
+ console.print(tree)
95
+
96
+ except typer.Exit:
97
+ raise
98
+ except Exception as e:
99
+ handle_error(ctx, e)
100
+
101
+
102
+ def _build_tree(
103
+ parent: Tree,
104
+ node_id: str,
105
+ graph: dict[str, list[str]],
106
+ visited: set[str],
107
+ depth: int = 0,
108
+ ) -> None:
109
+ if depth > 10:
110
+ return
111
+ label = f"[cyan]{node_id}[/cyan]" if node_id not in visited else f"[dim]{node_id} (cycle)[/dim]"
112
+ branch = parent.add(label)
113
+ if node_id in visited:
114
+ return
115
+ visited.add(node_id)
116
+ for dep in graph.get(node_id, []):
117
+ _build_tree(branch, dep, graph, visited, depth + 1)
@@ -0,0 +1,148 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from cloudwright import Catalog
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ console = Console()
11
+
12
+ catalog_app = typer.Typer(
13
+ name="catalog",
14
+ help="Search and compare cloud service catalog.",
15
+ no_args_is_help=True,
16
+ )
17
+
18
+
19
+ @catalog_app.callback(invoke_without_command=True)
20
+ def catalog_callback(ctx: typer.Context) -> None:
21
+ # Propagate json/verbose flags from parent ctx into this sub-app's ctx
22
+ if ctx.obj is None and ctx.parent and ctx.parent.obj:
23
+ ctx.obj = ctx.parent.obj
24
+ elif ctx.obj is None:
25
+ ctx.ensure_object(dict)
26
+
27
+
28
+ @catalog_app.command("search")
29
+ def catalog_search(
30
+ ctx: typer.Context,
31
+ query: Annotated[str, typer.Argument(help="Natural language search query")],
32
+ provider: Annotated[str | None, typer.Option(help="Filter by provider (aws, gcp, azure)")] = None,
33
+ vcpus: Annotated[int | None, typer.Option(help="Minimum vCPUs")] = None,
34
+ memory: Annotated[float | None, typer.Option(help="Minimum memory in GB")] = None,
35
+ ) -> None:
36
+ """Search the cloud service catalog."""
37
+ filters: dict = {}
38
+ if provider:
39
+ filters["provider"] = provider.lower()
40
+ if vcpus is not None:
41
+ filters["min_vcpus"] = vcpus
42
+ if memory is not None:
43
+ filters["min_memory_gb"] = memory
44
+
45
+ with console.status("Searching catalog..."):
46
+ results = Catalog().search(query, **filters)
47
+
48
+ # Resolve ctx.obj through parent chain when invoked via sub-app
49
+ obj = ctx.obj or (ctx.parent.obj if ctx.parent else None)
50
+ if obj and obj.get("json"):
51
+ import json
52
+
53
+ print(json.dumps({"results": results}, default=str))
54
+ return
55
+
56
+ if not results:
57
+ console.print("[yellow]No results found.[/yellow]")
58
+ return
59
+
60
+ table = Table(title=f'Catalog Search: "{query}"')
61
+ table.add_column("Service", style="cyan")
62
+ table.add_column("Provider")
63
+ table.add_column("Label")
64
+ table.add_column("vCPUs", justify="right")
65
+ table.add_column("Memory (GB)", justify="right")
66
+ table.add_column("$/hr", justify="right")
67
+ table.add_column("Notes", style="dim")
68
+
69
+ for item in results:
70
+ table.add_row(
71
+ item.get("service", ""),
72
+ item.get("provider", ""),
73
+ item.get("label", ""),
74
+ str(item.get("vcpus", "-")),
75
+ str(item.get("memory_gb", "-")),
76
+ f"${item['hourly']:.4f}" if item.get("hourly") else "-",
77
+ item.get("notes", ""),
78
+ )
79
+
80
+ console.print(table)
81
+
82
+
83
+ @catalog_app.command("compare")
84
+ def catalog_compare(
85
+ ctx: typer.Context,
86
+ instances: Annotated[list[str], typer.Argument(help="Instance names to compare (2 or more)")],
87
+ ) -> None:
88
+ """Compare two or more cloud instances side by side."""
89
+ if len(instances) < 2:
90
+ console.print("[red]Error:[/red] Provide at least 2 instance names to compare.")
91
+ raise typer.Exit(1)
92
+
93
+ with console.status("Fetching instance details..."):
94
+ # compare() takes *args, not a list — unpack here
95
+ results = Catalog().compare(*instances)
96
+
97
+ # Resolve ctx.obj through parent chain when invoked via sub-app
98
+ obj = ctx.obj or (ctx.parent.obj if ctx.parent else None)
99
+ if obj and obj.get("json"):
100
+ import json
101
+
102
+ inst_map = {r.get("name", r.get("id", "")): r for r in results}
103
+ print(json.dumps({"comparison": inst_map}, default=str))
104
+ return
105
+
106
+ if not results:
107
+ console.print("[yellow]No data found for the given instances.[/yellow]")
108
+ return
109
+
110
+ # Results is expected to be a list of dicts, one per instance
111
+ all_keys = set()
112
+ for r in results:
113
+ all_keys.update(r.keys())
114
+
115
+ display_fields = ["service", "provider", "label", "vcpus", "memory_gb", "hourly", "monthly", "notes"]
116
+ fields = [f for f in display_fields if f in all_keys]
117
+ for k in sorted(all_keys):
118
+ if k not in fields:
119
+ fields.append(k)
120
+
121
+ table = Table(title="Instance Comparison")
122
+ table.add_column("Attribute", style="cyan")
123
+ for instance in instances:
124
+ table.add_column(instance, justify="right")
125
+
126
+ inst_map = {r.get("name", r.get("id", r.get("service", ""))): r for r in results}
127
+
128
+ for field in fields:
129
+ row = [field]
130
+ for inst in instances:
131
+ # Try both bare name and provider-prefixed id
132
+ data = (
133
+ inst_map.get(inst)
134
+ or inst_map.get(f"aws:{inst}")
135
+ or inst_map.get(f"gcp:{inst}")
136
+ or inst_map.get(f"azure:{inst}")
137
+ or {}
138
+ )
139
+ val = data.get(field, "-")
140
+ if field in ("hourly", "price_per_hour") and isinstance(val, float):
141
+ row.append(f"${val:.4f}")
142
+ elif field in ("monthly", "price_per_month") and isinstance(val, float):
143
+ row.append(f"${val:,.2f}")
144
+ else:
145
+ row.append(str(val) if val is not None else "-")
146
+ table.add_row(*row)
147
+
148
+ console.print(table)
@@ -0,0 +1,164 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Annotated
4
+
5
+ import typer
6
+ from cloudwright import Architect, ArchSpec
7
+ from rich.console import Console
8
+ from rich.panel import Panel
9
+ from rich.prompt import Prompt
10
+ from rich.rule import Rule
11
+ from rich.syntax import Syntax
12
+
13
+ console = Console()
14
+
15
+ _HELP = """\
16
+ Commands:
17
+ /save <file> Save last architecture to YAML file
18
+ /cost Show cost estimate for last architecture
19
+ /export <fmt> Export last architecture (terraform, mermaid, sbom, aibom)
20
+ /quit Exit
21
+ """
22
+
23
+
24
+ def chat(
25
+ web: Annotated[bool, typer.Option("--web", help="Launch web UI instead of terminal chat")] = False,
26
+ ) -> None:
27
+ """Interactive architecture design chat."""
28
+ if web:
29
+ _launch_web()
30
+ return
31
+
32
+ _run_terminal_chat()
33
+
34
+
35
+ def _launch_web() -> None:
36
+ try:
37
+ import cloudwright_web # type: ignore
38
+ import uvicorn
39
+
40
+ console.print("[cyan]Launching Cloudwright web UI...[/cyan]")
41
+ uvicorn.run(cloudwright_web.app, host="127.0.0.1", port=8000)
42
+ except ImportError:
43
+ console.print(
44
+ "[red]Error:[/red] cloudwright-web is not installed.\nInstall it with: pip install cloudwright-web"
45
+ )
46
+ raise typer.Exit(1)
47
+
48
+
49
+ def _run_terminal_chat() -> None:
50
+ console.print(
51
+ Panel(
52
+ "[bold cyan]Cloudwright Architecture Chat[/bold cyan]\nDescribe any cloud architecture.",
53
+ subtitle="Type /quit to exit",
54
+ )
55
+ )
56
+ console.print(f"[dim]{_HELP}[/dim]")
57
+
58
+ architect = Architect()
59
+ history: list[dict] = []
60
+ last_spec: ArchSpec | None = None
61
+
62
+ while True:
63
+ try:
64
+ user_input = Prompt.ask("\n[bold cyan]>[/bold cyan]")
65
+ except (KeyboardInterrupt, EOFError):
66
+ console.print("\n[dim]Exiting.[/dim]")
67
+ break
68
+
69
+ text = user_input.strip()
70
+ if not text:
71
+ continue
72
+
73
+ if text.lower() in ("/quit", "/exit", "/q"):
74
+ console.print("[dim]Goodbye.[/dim]")
75
+ break
76
+
77
+ if text.startswith("/save "):
78
+ path = text[6:].strip()
79
+ if not last_spec:
80
+ console.print("[yellow]No architecture to save yet.[/yellow]")
81
+ else:
82
+ from pathlib import Path
83
+
84
+ Path(path).write_text(last_spec.to_yaml())
85
+ console.print(f"[green]Saved to {path}[/green]")
86
+ continue
87
+
88
+ if text == "/cost":
89
+ if not last_spec:
90
+ console.print("[yellow]No architecture yet.[/yellow]")
91
+ elif not last_spec.cost_estimate:
92
+ console.print("[yellow]No cost estimate available.[/yellow]")
93
+ else:
94
+ _print_cost_summary(last_spec)
95
+ continue
96
+
97
+ if text.startswith("/export "):
98
+ fmt = text[8:].strip()
99
+ if not last_spec:
100
+ console.print("[yellow]No architecture to export yet.[/yellow]")
101
+ else:
102
+ try:
103
+ content = last_spec.export(fmt)
104
+ lang = {"terraform": "hcl", "mermaid": "text"}.get(fmt, "json")
105
+ console.print(Syntax(content, lang, theme="monokai", word_wrap=True))
106
+ except ValueError as e:
107
+ console.print(f"[red]Error:[/red] {e}")
108
+ continue
109
+
110
+ # Treat as architecture request
111
+ history.append({"role": "user", "content": text})
112
+
113
+ with console.status("Thinking..."):
114
+ try:
115
+ if last_spec and _looks_like_modification(text):
116
+ spec = architect.modify(last_spec, text)
117
+ else:
118
+ spec = architect.design(text)
119
+ except Exception as e:
120
+ console.print(f"[red]Error:[/red] {e}")
121
+ history.pop()
122
+ continue
123
+
124
+ last_spec = spec
125
+ history.append({"role": "assistant", "content": f"Designed: {spec.name}"})
126
+
127
+ yaml_str = spec.to_yaml()
128
+ console.print(Rule(f"[bold cyan]{spec.name}[/bold cyan]"))
129
+ console.print(Syntax(yaml_str, "yaml", theme="monokai", word_wrap=True))
130
+
131
+ if spec.cost_estimate:
132
+ _print_cost_summary(spec)
133
+
134
+
135
+ def _looks_like_modification(text: str) -> bool:
136
+ mod_verbs = (
137
+ "add",
138
+ "remove",
139
+ "change",
140
+ "update",
141
+ "replace",
142
+ "increase",
143
+ "decrease",
144
+ "modify",
145
+ "swap",
146
+ "upgrade",
147
+ "downgrade",
148
+ )
149
+ lower = text.lower()
150
+ return any(lower.startswith(v) or f" {v} " in lower for v in mod_verbs)
151
+
152
+
153
+ def _print_cost_summary(spec: ArchSpec) -> None:
154
+ from rich.table import Table
155
+
156
+ table = Table(title="Cost Estimate", show_footer=True)
157
+ table.add_column("Component", style="cyan")
158
+ table.add_column("Monthly", justify="right", footer=f"${spec.cost_estimate.monthly_total:,.2f}")
159
+ table.add_column("Notes", style="dim")
160
+
161
+ for item in spec.cost_estimate.breakdown:
162
+ table.add_row(item.component_id, f"${item.monthly:,.2f}", item.notes)
163
+
164
+ console.print(table)
@@ -0,0 +1,75 @@
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, ArchSpec
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ console = Console()
12
+
13
+
14
+ def compare(
15
+ spec_file: Annotated[Path, typer.Argument(help="Path to spec YAML file", exists=True)],
16
+ providers: Annotated[str, typer.Option(help="Comma-separated target providers")],
17
+ ) -> None:
18
+ """Compare an architecture across multiple cloud providers."""
19
+ target_providers = [p.strip() for p in providers.split(",") if p.strip()]
20
+ if not target_providers:
21
+ console.print("[red]Error:[/red] --providers requires at least one provider")
22
+ raise typer.Exit(1)
23
+
24
+ spec = ArchSpec.from_file(spec_file)
25
+
26
+ with console.status("Generating alternatives..."):
27
+ alts = Architect().compare(spec, target_providers)
28
+
29
+ all_providers = [spec.provider] + [a.provider for a in alts]
30
+ alt_map = {spec.provider: spec}
31
+ for alt in alts:
32
+ if alt.spec:
33
+ alt_map[alt.provider] = alt.spec
34
+
35
+ # Side-by-side service comparison
36
+ table = Table(title=f"Provider Comparison — {spec.name}")
37
+ table.add_column("Component", style="cyan")
38
+ table.add_column("Original Label")
39
+ for p in all_providers:
40
+ table.add_column(p.upper())
41
+
42
+ for comp in spec.components:
43
+ row = [comp.id, comp.label]
44
+ for p in all_providers:
45
+ s = alt_map.get(p)
46
+ if s:
47
+ mapped = next((c for c in s.components if c.id == comp.id), None)
48
+ row.append(mapped.service if mapped else "-")
49
+ else:
50
+ row.append("-")
51
+ table.add_row(*row)
52
+
53
+ console.print(table)
54
+
55
+ # Monthly totals
56
+ totals_table = Table(title="Monthly Cost Totals", show_header=True)
57
+ totals_table.add_column("Provider")
58
+ totals_table.add_column("Monthly Total", justify="right")
59
+ totals_table.add_column("Key Differences", style="dim")
60
+
61
+ origin_total = spec.cost_estimate.monthly_total if spec.cost_estimate else 0.0
62
+ totals_table.add_row(
63
+ spec.provider.upper(),
64
+ f"${origin_total:,.2f}" if origin_total else "-",
65
+ "(baseline)",
66
+ )
67
+ for alt in alts:
68
+ diffs = ", ".join(alt.key_differences[:3]) if alt.key_differences else ""
69
+ totals_table.add_row(
70
+ alt.provider.upper(),
71
+ f"${alt.monthly_total:,.2f}" if alt.monthly_total else "-",
72
+ diffs,
73
+ )
74
+
75
+ console.print(totals_table)