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.
- cloudwright_ai_cli-0.1.0/.gitignore +16 -0
- cloudwright_ai_cli-0.1.0/CLAUDE.md +23 -0
- cloudwright_ai_cli-0.1.0/PKG-INFO +42 -0
- cloudwright_ai_cli-0.1.0/README.md +23 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/__init__.py +1 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/__main__.py +5 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/__init__.py +0 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/analyze_cmd.py +117 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/catalog_cmd.py +148 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/chat.py +164 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/compare.py +75 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/cost.py +112 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/design.py +84 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/diff.py +88 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/drift_cmd.py +98 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/export.py +65 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/import_cmd.py +69 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/init_cmd.py +135 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/lint_cmd.py +91 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/modify_cmd.py +125 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/policy.py +88 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/refresh_cmd.py +104 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/score_cmd.py +80 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/commands/validate.py +106 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/main.py +66 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/project.py +50 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/py.typed +0 -0
- cloudwright_ai_cli-0.1.0/cloudwright_cli/utils.py +37 -0
- cloudwright_ai_cli-0.1.0/pyproject.toml +37 -0
- cloudwright_ai_cli-0.1.0/tests/__init__.py +0 -0
- cloudwright_ai_cli-0.1.0/tests/test_cli.py +295 -0
- cloudwright_ai_cli-0.1.0/tests/test_drift_cmd.py +28 -0
- cloudwright_ai_cli-0.1.0/tests/test_init.py +232 -0
- cloudwright_ai_cli-0.1.0/tests/test_modify_cmd.py +21 -0
- 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"
|
|
File without changes
|
|
@@ -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)
|