cloudwright-ai-cli 0.1.0__tar.gz → 0.2.1__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 (36) hide show
  1. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/PKG-INFO +1 -1
  2. cloudwright_ai_cli-0.2.1/cloudwright_cli/__init__.py +1 -0
  3. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/analyze_cmd.py +8 -0
  4. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/catalog_cmd.py +32 -13
  5. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/chat.py +82 -7
  6. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/design.py +50 -12
  7. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/export.py +43 -2
  8. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/modify_cmd.py +6 -1
  9. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/utils.py +12 -0
  10. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/tests/test_cli.py +40 -1
  11. cloudwright_ai_cli-0.1.0/cloudwright_cli/__init__.py +0 -1
  12. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/.gitignore +0 -0
  13. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/CLAUDE.md +0 -0
  14. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/README.md +0 -0
  15. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/__main__.py +0 -0
  16. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/__init__.py +0 -0
  17. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/compare.py +0 -0
  18. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/cost.py +0 -0
  19. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/diff.py +0 -0
  20. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/drift_cmd.py +0 -0
  21. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/import_cmd.py +0 -0
  22. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/init_cmd.py +0 -0
  23. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/lint_cmd.py +0 -0
  24. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/policy.py +0 -0
  25. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/refresh_cmd.py +0 -0
  26. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/score_cmd.py +0 -0
  27. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/commands/validate.py +0 -0
  28. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/main.py +0 -0
  29. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/project.py +0 -0
  30. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/cloudwright_cli/py.typed +0 -0
  31. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/pyproject.toml +0 -0
  32. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/tests/__init__.py +0 -0
  33. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/tests/test_drift_cmd.py +0 -0
  34. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/tests/test_init.py +0 -0
  35. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/tests/test_modify_cmd.py +0 -0
  36. {cloudwright_ai_cli-0.1.0 → cloudwright_ai_cli-0.2.1}/tests/test_project.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cloudwright-ai-cli
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: CLI for Cloudwright architecture intelligence
5
5
  Project-URL: Homepage, https://github.com/xmpuspus/cloudwright
6
6
  Project-URL: Repository, https://github.com/xmpuspus/cloudwright
@@ -0,0 +1 @@
1
+ __version__ = "0.2.1"
@@ -27,6 +27,14 @@ def analyze(
27
27
  from cloudwright.analyzer import Analyzer
28
28
 
29
29
  spec = ArchSpec.from_file(spec_file)
30
+
31
+ if component:
32
+ valid_ids = {c.id for c in spec.components}
33
+ if component not in valid_ids:
34
+ console.print(f"[red]Error:[/red] Component '{component}' not found in spec.")
35
+ console.print(f"[dim]Available components: {', '.join(sorted(valid_ids))}[/dim]")
36
+ raise typer.Exit(1)
37
+
30
38
  analyzer = Analyzer()
31
39
  result = analyzer.analyze(spec, component_id=component)
32
40
 
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import re
3
4
  from typing import Annotated
4
5
 
5
6
  import typer
@@ -34,16 +35,33 @@ def catalog_search(
34
35
  memory: Annotated[float | None, typer.Option(help="Minimum memory in GB")] = None,
35
36
  ) -> None:
36
37
  """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
38
+ # Extract structured filters from natural language query
39
+ parsed_vcpus = vcpus
40
+ parsed_memory = memory
41
+ text_query = query
42
+
43
+ if parsed_vcpus is None:
44
+ m = re.search(r"(\d+)\s*vcpu", query, re.IGNORECASE)
45
+ if m:
46
+ parsed_vcpus = int(m.group(1))
47
+ text_query = re.sub(r"\d+\s*vcpus?\s*", "", text_query, flags=re.IGNORECASE).strip()
48
+
49
+ if parsed_memory is None:
50
+ m = re.search(r"(\d+(?:\.\d+)?)\s*(?:gb|gib)\s*(?:memory|ram)?", query, re.IGNORECASE)
51
+ if m:
52
+ parsed_memory = float(m.group(1))
53
+ text_query = re.sub(r"\d+(?:\.\d+)?\s*(?:gb|gib)\s*(?:memory|ram)?\s*", "", text_query, flags=re.IGNORECASE).strip()
54
+
55
+ # Clean up leftover whitespace/empty query
56
+ text_query = text_query.strip() or None
44
57
 
45
58
  with console.status("Searching catalog..."):
46
- results = Catalog().search(query, **filters)
59
+ results = Catalog().search(
60
+ query=text_query,
61
+ vcpus=parsed_vcpus,
62
+ memory_gb=parsed_memory,
63
+ provider=provider,
64
+ )
47
65
 
48
66
  # Resolve ctx.obj through parent chain when invoked via sub-app
49
67
  obj = ctx.obj or (ctx.parent.obj if ctx.parent else None)
@@ -67,14 +85,15 @@ def catalog_search(
67
85
  table.add_column("Notes", style="dim")
68
86
 
69
87
  for item in results:
88
+ price = item.get("price_per_hour")
70
89
  table.add_row(
71
- item.get("service", ""),
72
- item.get("provider", ""),
73
- item.get("label", ""),
90
+ item.get("name", ""),
91
+ item.get("provider_id", ""),
92
+ item.get("family", ""),
74
93
  str(item.get("vcpus", "-")),
75
94
  str(item.get("memory_gb", "-")),
76
- f"${item['hourly']:.4f}" if item.get("hourly") else "-",
77
- item.get("notes", ""),
95
+ f"${price:.4f}" if price else "-",
96
+ item.get("description", ""),
78
97
  )
79
98
 
80
99
  console.print(table)
@@ -4,6 +4,7 @@ from typing import Annotated
4
4
 
5
5
  import typer
6
6
  from cloudwright import Architect, ArchSpec
7
+ from cloudwright.ascii_diagram import render_ascii
7
8
  from rich.console import Console
8
9
  from rich.panel import Panel
9
10
  from rich.prompt import Prompt
@@ -14,10 +15,14 @@ console = Console()
14
15
 
15
16
  _HELP = """\
16
17
  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
18
+ /save <file> Save last architecture to YAML file
19
+ /diagram Show ASCII diagram for last architecture
20
+ /yaml Show YAML for last architecture
21
+ /cost Show cost estimate for last architecture
22
+ /validate [fw] Run compliance check (hipaa, pci-dss, soc2, fedramp, gdpr)
23
+ /export <fmt> Export last architecture (terraform, mermaid, d2, cloudformation, sbom, aibom)
24
+ /terraform Export last architecture as Terraform
25
+ /quit Exit
21
26
  """
22
27
 
23
28
 
@@ -85,6 +90,22 @@ def _run_terminal_chat() -> None:
85
90
  console.print(f"[green]Saved to {path}[/green]")
86
91
  continue
87
92
 
93
+ if text == "/diagram":
94
+ if not last_spec:
95
+ console.print("[yellow]No architecture yet.[/yellow]")
96
+ else:
97
+ console.print(Rule(f"[bold cyan]{last_spec.name}[/bold cyan]"))
98
+ console.print(render_ascii(last_spec))
99
+ continue
100
+
101
+ if text == "/yaml":
102
+ if not last_spec:
103
+ console.print("[yellow]No architecture yet.[/yellow]")
104
+ else:
105
+ console.print(Rule(f"[bold cyan]{last_spec.name}[/bold cyan]"))
106
+ console.print(Syntax(last_spec.to_yaml(), "yaml", theme="monokai", word_wrap=True))
107
+ continue
108
+
88
109
  if text == "/cost":
89
110
  if not last_spec:
90
111
  console.print("[yellow]No architecture yet.[/yellow]")
@@ -94,6 +115,26 @@ def _run_terminal_chat() -> None:
94
115
  _print_cost_summary(last_spec)
95
116
  continue
96
117
 
118
+ if text.startswith("/validate"):
119
+ if not last_spec:
120
+ console.print("[yellow]No architecture yet.[/yellow]")
121
+ else:
122
+ parts = text.split(None, 1)
123
+ framework = parts[1].strip() if len(parts) > 1 else None
124
+ _run_validate(last_spec, framework)
125
+ continue
126
+
127
+ if text == "/terraform":
128
+ if not last_spec:
129
+ console.print("[yellow]No architecture to export yet.[/yellow]")
130
+ else:
131
+ try:
132
+ content = last_spec.export("terraform")
133
+ console.print(Syntax(content, "hcl", theme="monokai", word_wrap=True))
134
+ except ValueError as e:
135
+ console.print(f"[red]Error:[/red] {e}")
136
+ continue
137
+
97
138
  if text.startswith("/export "):
98
139
  fmt = text[8:].strip()
99
140
  if not last_spec:
@@ -101,7 +142,9 @@ def _run_terminal_chat() -> None:
101
142
  else:
102
143
  try:
103
144
  content = last_spec.export(fmt)
104
- lang = {"terraform": "hcl", "mermaid": "text"}.get(fmt, "json")
145
+ lang = {"terraform": "hcl", "mermaid": "text", "d2": "text", "cloudformation": "yaml"}.get(
146
+ fmt, "json"
147
+ )
105
148
  console.print(Syntax(content, lang, theme="monokai", word_wrap=True))
106
149
  except ValueError as e:
107
150
  console.print(f"[red]Error:[/red] {e}")
@@ -124,13 +167,45 @@ def _run_terminal_chat() -> None:
124
167
  last_spec = spec
125
168
  history.append({"role": "assistant", "content": f"Designed: {spec.name}"})
126
169
 
127
- yaml_str = spec.to_yaml()
128
170
  console.print(Rule(f"[bold cyan]{spec.name}[/bold cyan]"))
129
- console.print(Syntax(yaml_str, "yaml", theme="monokai", word_wrap=True))
171
+ console.print(render_ascii(spec))
130
172
 
131
173
  if spec.cost_estimate:
132
174
  _print_cost_summary(spec)
133
175
 
176
+ from cloudwright_cli.utils import auto_save_spec
177
+
178
+ save_path = auto_save_spec(spec)
179
+ console.print(f"[dim]Saved: {save_path}[/dim]")
180
+
181
+ suggestions = spec.metadata.get("suggestions", [])
182
+ if suggestions:
183
+ console.print(f"[dim]Try: {' | '.join(repr(s) for s in suggestions[:3])}[/dim]")
184
+
185
+
186
+ def _run_validate(spec: ArchSpec, framework: str | None) -> None:
187
+ from cloudwright.validator import Validator
188
+
189
+ if framework:
190
+ results = Validator().validate(spec, compliance=[framework])
191
+ else:
192
+ results = Validator().validate(spec, well_architected=True)
193
+
194
+ if not results:
195
+ console.print("[yellow]No validation results.[/yellow]")
196
+ return
197
+
198
+ for result in results:
199
+ passed = sum(1 for c in result.checks if c.passed)
200
+ total = len(result.checks)
201
+ status = "[green]PASS[/green]" if result.passed else "[red]FAIL[/red]"
202
+ console.print(f"{result.framework}: {status} ({passed}/{total} checks passed)")
203
+ for check in result.checks:
204
+ icon = "[green]+[/green]" if check.passed else "[red]-[/red]"
205
+ console.print(f" {icon} {check.name}")
206
+ if not check.passed and check.recommendation:
207
+ console.print(f" [dim]{check.recommendation}[/dim]")
208
+
134
209
 
135
210
  def _looks_like_modification(text: str) -> bool:
136
211
  mod_verbs = (
@@ -5,6 +5,9 @@ from typing import Annotated
5
5
 
6
6
  import typer
7
7
  from cloudwright import Architect, Constraints
8
+ from cloudwright.ascii_diagram import render_ascii, render_next_steps
9
+ from cloudwright.cost import CostEngine
10
+ from cloudwright.validator import Validator
8
11
  from rich.console import Console
9
12
  from rich.panel import Panel
10
13
  from rich.syntax import Syntax
@@ -23,6 +26,7 @@ def design(
23
26
  list[str] | None, typer.Option(help="Compliance frameworks (hipaa, pci-dss, soc2, fedramp, gdpr)")
24
27
  ] = None,
25
28
  output: Annotated[Path | None, typer.Option("--output", "-o", help="Write YAML to file")] = None,
29
+ yaml_output: Annotated[bool, typer.Option("--yaml")] = False,
26
30
  ) -> None:
27
31
  """Design a cloud architecture from a natural language description."""
28
32
  constraints = Constraints(
@@ -31,8 +35,14 @@ def design(
31
35
  compliance=compliance or [],
32
36
  )
33
37
 
38
+ try:
39
+ architect = Architect()
40
+ except RuntimeError as e:
41
+ console.print(f"[red]Error:[/red] {e}")
42
+ raise typer.Exit(1) from None
43
+
34
44
  with console.status("Designing architecture..."):
35
- spec = Architect().design(description, constraints=constraints)
45
+ spec = architect.design(description, constraints=constraints)
36
46
  # Set provider/region from CLI args if not overridden by LLM
37
47
  if spec.provider == "aws" and provider != "aws":
38
48
  spec = spec.model_copy(update={"provider": provider})
@@ -47,20 +57,48 @@ def design(
47
57
 
48
58
  yaml_str = spec.to_yaml()
49
59
 
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}",
60
+ if yaml_output:
61
+ console.print(
62
+ Panel(
63
+ Syntax(yaml_str, "yaml", theme="monokai", word_wrap=True),
64
+ title=f"[bold cyan]{spec.name}[/bold cyan]",
65
+ subtitle=f"{spec.provider.upper()} / {spec.region}",
66
+ )
55
67
  )
56
- )
68
+ if spec.cost_estimate:
69
+ _print_cost_table(spec)
70
+ if output:
71
+ output.write_text(yaml_str)
72
+ console.print(f"[green]Saved to {output}[/green]")
73
+ return
74
+
75
+ # Default: ASCII diagram + auto-save + next steps
76
+ console.print(render_ascii(spec))
77
+
78
+ if not spec.cost_estimate:
79
+ spec = spec.model_copy(update={"cost_estimate": CostEngine().estimate(spec)})
80
+ _print_cost_table(spec)
57
81
 
58
- if spec.cost_estimate:
59
- _print_cost_table(spec)
82
+ _print_compliance_flags(spec)
60
83
 
61
- if output:
62
- output.write_text(yaml_str)
63
- console.print(f"[green]Saved to {output}[/green]")
84
+ from cloudwright_cli.utils import auto_save_spec
85
+
86
+ save_path = auto_save_spec(spec, output)
87
+ console.print(f"[dim]Saved: {save_path}[/dim]")
88
+ console.print(f"[dim]{render_next_steps()}[/dim]")
89
+
90
+
91
+ def _print_compliance_flags(spec) -> None:
92
+ results = Validator().validate(spec, well_architected=True)
93
+ if not results:
94
+ return
95
+ wa = results[0]
96
+ total = len(wa.checks)
97
+ passed = sum(1 for c in wa.checks if c.passed)
98
+ console.print(
99
+ f"[dim]Well-Architected: {passed}/{total} checks passed | "
100
+ "Run 'cloudwright validate --compliance hipaa' for full report[/dim]"
101
+ )
64
102
 
65
103
 
66
104
  def _print_cost_table(spec) -> None:
@@ -15,6 +15,10 @@ _SYNTAX_MAP = {
15
15
  "terraform": "hcl",
16
16
  "cloudformation": "yaml",
17
17
  "mermaid": "text",
18
+ "d2": "text",
19
+ "svg": "xml",
20
+ "png": "text",
21
+ "c4": "text",
18
22
  "sbom": "json",
19
23
  "aibom": "json",
20
24
  }
@@ -23,10 +27,17 @@ _SYNTAX_MAP = {
23
27
  def export(
24
28
  ctx: typer.Context,
25
29
  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)}")],
30
+ format: Annotated[
31
+ str,
32
+ typer.Option(
33
+ "--format",
34
+ "-f",
35
+ help=f"Export format: {', '.join(FORMATS)}. svg/png require the D2 binary (https://d2lang.com).",
36
+ ),
37
+ ],
27
38
  output: Annotated[Path | None, typer.Option("--output", "-o", help="Output file or directory")] = None,
28
39
  ) -> None:
29
- """Export an architecture spec to Terraform, CloudFormation, Mermaid, SBOM, or AIBOM."""
40
+ """Export an architecture spec to Terraform, CloudFormation, Mermaid, SVG, PNG, SBOM, or AIBOM."""
30
41
  fmt = format.lower().strip()
31
42
  if fmt not in FORMATS and fmt != "cfn":
32
43
  console.print(f"[red]Error:[/red] Unknown format {fmt!r}. Supported: {', '.join(FORMATS)}")
@@ -46,6 +57,36 @@ def export(
46
57
  output_dir_str = output_str
47
58
  output_str = None
48
59
 
60
+ # PNG is binary — handle separately before the text-oriented path
61
+ if fmt == "png":
62
+ from cloudwright.exporter.renderer import DiagramRenderer
63
+
64
+ if not DiagramRenderer.is_available():
65
+ console.print(
66
+ "[red]Error:[/red] D2 binary not found. Install: curl -fsSL https://d2lang.com/install.sh | sh"
67
+ )
68
+ raise typer.Exit(1)
69
+
70
+ with console.status("Rendering PNG via D2..."):
71
+ data = DiagramRenderer().render_png(spec)
72
+
73
+ if output:
74
+ output.write_bytes(data)
75
+ console.print(f"[green]Written to {output}[/green]")
76
+ else:
77
+ console.print(f"[green]PNG rendered: {len(data)} bytes (use --output to save)[/green]")
78
+ return
79
+
80
+ # Warn when svg/c4 requested but D2 not installed — render still proceeds with fallback
81
+ if fmt in ("svg", "c4"):
82
+ from cloudwright.exporter.renderer import DiagramRenderer
83
+
84
+ if not DiagramRenderer.is_available():
85
+ console.print(
86
+ "[yellow]Warning:[/yellow] D2 binary not found — returning D2 source text. "
87
+ "Install: curl -fsSL https://d2lang.com/install.sh | sh"
88
+ )
89
+
49
90
  with console.status(f"Exporting as {fmt}..."):
50
91
  content = spec.export(fmt, output=output_str, output_dir=output_dir_str)
51
92
 
@@ -40,8 +40,13 @@ def modify(
40
40
 
41
41
  console.print(f"Modifying [cyan]{spec_file}[/cyan]: [yellow]{instruction}[/yellow]\n")
42
42
 
43
- with console.status("Applying modification..."):
43
+ try:
44
44
  architect = Architect()
45
+ except RuntimeError as e:
46
+ console.print(f"[red]Error:[/red] {e}")
47
+ raise typer.Exit(1) from None
48
+
49
+ with console.status("Applying modification..."):
45
50
  modified = architect.modify(original, instruction)
46
51
 
47
52
  # Price both versions; ignore errors (catalog may not have all services)
@@ -1,6 +1,8 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import re
5
+ from pathlib import Path
4
6
 
5
7
  import typer
6
8
  from rich.console import Console
@@ -35,3 +37,13 @@ def handle_error(ctx: typer.Context, e: Exception) -> None:
35
37
  _err_console.print_exception()
36
38
 
37
39
  raise typer.Exit(1)
40
+
41
+
42
+ def auto_save_spec(spec, explicit_output: Path | None = None) -> Path:
43
+ if explicit_output:
44
+ explicit_output.write_text(spec.to_yaml())
45
+ return explicit_output
46
+ slug = re.sub(r"[^a-z0-9]+", "-", spec.name.lower()).strip("-")
47
+ path = Path(f"{slug}.yaml")
48
+ path.write_text(spec.to_yaml())
49
+ return path
@@ -65,7 +65,7 @@ def spec_pair(tmp_path: Path) -> tuple[Path, Path]:
65
65
  class TestAppHelp:
66
66
  def test_no_args_shows_help(self):
67
67
  result = runner.invoke(app, [])
68
- assert result.exit_code == 0
68
+ assert result.exit_code in (0, 2) # Typer returns 2 for missing required command
69
69
  assert "cloudwright" in result.output.lower() or "architecture" in result.output.lower()
70
70
 
71
71
  def test_help_flag(self):
@@ -263,6 +263,45 @@ class TestJsonOutput:
263
263
  assert "comparison" in data
264
264
 
265
265
 
266
+ class TestRenderNextSteps:
267
+ def test_render_next_steps_content(self):
268
+ from cloudwright.ascii_diagram import render_next_steps
269
+
270
+ result = render_next_steps()
271
+ assert "cloudwright cost" in result
272
+ assert "cloudwright validate" in result
273
+
274
+ def test_render_next_steps_returns_string(self):
275
+ from cloudwright.ascii_diagram import render_next_steps
276
+
277
+ result = render_next_steps()
278
+ assert isinstance(result, str)
279
+ assert len(result) > 0
280
+
281
+
282
+ class TestAutoSaveSpec:
283
+ def test_auto_save_explicit_output(self, tmp_path: Path):
284
+ from cloudwright import ArchSpec
285
+ from cloudwright_cli.utils import auto_save_spec
286
+
287
+ spec = ArchSpec.from_yaml(_SPEC_YAML)
288
+ out = tmp_path / "out.yaml"
289
+ result = auto_save_spec(spec, out)
290
+ assert result == out
291
+ assert out.exists()
292
+
293
+ def test_auto_save_slug_path(self, tmp_path: Path, monkeypatch):
294
+ from cloudwright import ArchSpec
295
+ from cloudwright_cli.utils import auto_save_spec
296
+
297
+ monkeypatch.chdir(tmp_path)
298
+ spec = ArchSpec.from_yaml(_SPEC_YAML)
299
+ result = auto_save_spec(spec)
300
+ assert result.exists()
301
+ assert result.suffix == ".yaml"
302
+ assert "test" in result.stem
303
+
304
+
266
305
  class TestErrorHandling:
267
306
  def test_error_handling_invalid_yaml(self, tmp_path: Path):
268
307
  bad_yaml = tmp_path / "bad.yaml"
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"