cloudwright-ai-cli 0.3.5__tar.gz → 1.0.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 (50) hide show
  1. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/PKG-INFO +2 -2
  2. cloudwright_ai_cli-1.0.0/cloudwright_cli/__init__.py +1 -0
  3. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/chat.py +23 -101
  4. cloudwright_ai_cli-1.0.0/cloudwright_cli/commands/chat_session.py +41 -0
  5. cloudwright_ai_cli-1.0.0/cloudwright_cli/commands/chat_streaming.py +51 -0
  6. cloudwright_ai_cli-1.0.0/cloudwright_cli/commands/chat_ui.py +74 -0
  7. cloudwright_ai_cli-1.0.0/cloudwright_cli/decorators.py +72 -0
  8. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/pyproject.toml +1 -1
  9. cloudwright_ai_cli-0.3.5/cloudwright_cli/__init__.py +0 -1
  10. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/.gitignore +0 -0
  11. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/README.md +0 -0
  12. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/__main__.py +0 -0
  13. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/__init__.py +0 -0
  14. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/adr.py +0 -0
  15. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/analyze_cmd.py +0 -0
  16. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/catalog_cmd.py +0 -0
  17. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/compare.py +0 -0
  18. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/cost.py +0 -0
  19. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/databricks_cmd.py +0 -0
  20. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/design.py +0 -0
  21. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/diff.py +0 -0
  22. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/drift_cmd.py +0 -0
  23. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/export.py +0 -0
  24. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/import_cmd.py +0 -0
  25. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/init_cmd.py +0 -0
  26. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/lint_cmd.py +0 -0
  27. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/mcp_cmd.py +0 -0
  28. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/modify_cmd.py +0 -0
  29. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/policy.py +0 -0
  30. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/refresh_cmd.py +0 -0
  31. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/schema_cmd.py +0 -0
  32. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/score_cmd.py +0 -0
  33. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/security_cmd.py +0 -0
  34. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/validate.py +0 -0
  35. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/completions.py +0 -0
  36. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/main.py +0 -0
  37. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/output.py +0 -0
  38. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/project.py +0 -0
  39. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/py.typed +0 -0
  40. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/utils.py +0 -0
  41. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/tests/__init__.py +0 -0
  42. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/tests/test_chat_commands.py +0 -0
  43. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/tests/test_chat_debug.py +0 -0
  44. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/tests/test_chat_persistence.py +0 -0
  45. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/tests/test_chat_streaming.py +0 -0
  46. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/tests/test_cli.py +0 -0
  47. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/tests/test_drift_cmd.py +0 -0
  48. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/tests/test_init.py +0 -0
  49. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/tests/test_modify_cmd.py +0 -0
  50. {cloudwright_ai_cli-0.3.5 → cloudwright_ai_cli-1.0.0}/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.3.5
3
+ Version: 1.0.0
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
@@ -16,7 +16,7 @@ Classifier: Programming Language :: Python :: 3.12
16
16
  Classifier: Programming Language :: Python :: 3.13
17
17
  Classifier: Topic :: System :: Systems Administration
18
18
  Requires-Python: >=3.12
19
- Requires-Dist: cloudwright-ai<1,>=0.1.0
19
+ Requires-Dist: cloudwright-ai<2,>=1.0.0
20
20
  Requires-Dist: rich<15,>=13.9
21
21
  Requires-Dist: typer<1,>=0.21
22
22
  Description-Content-Type: text/markdown
@@ -0,0 +1 @@
1
+ __version__ = "1.0.0"
@@ -17,26 +17,11 @@ from rich.prompt import Prompt
17
17
  from rich.rule import Rule
18
18
  from rich.syntax import Syntax
19
19
 
20
- console = Console()
20
+ from .chat_session import default_session_id, maybe_save_on_quit
21
+ from .chat_streaming import format_error, is_rate_limit, is_timeout
22
+ from .chat_ui import _HELP, print_cost_summary, print_diff, run_validate
21
23
 
22
- _HELP = """\
23
- Commands:
24
- /save <file> Save last architecture to YAML file
25
- /save-session [name] Save this conversation session
26
- /load-session <name> Load a saved session
27
- /sessions List saved sessions
28
- /diagram Show ASCII diagram for last architecture
29
- /yaml Show YAML for last architecture
30
- /cost Show cost estimate for last architecture
31
- /validate [fw] Run compliance check (hipaa, pci-dss, soc2, fedramp, gdpr)
32
- /export <fmt> Export last architecture (terraform, mermaid, d2, cloudformation, sbom, aibom)
33
- /terraform Export last architecture as Terraform
34
- /new Start a new architecture from scratch
35
- /help, /? Show this help
36
- /quit Exit
37
-
38
- Follow-up messages modify the current architecture. Use /new to start over.
39
- """
24
+ console = Console()
40
25
 
41
26
 
42
27
  def chat(
@@ -114,7 +99,7 @@ def _run_terminal_chat(resume: str | None = None, debug: bool = False) -> None:
114
99
  user_input = Prompt.ask("\n[bold cyan]>[/bold cyan]")
115
100
  except (KeyboardInterrupt, EOFError):
116
101
  console.print("\n[dim]Exiting.[/dim]")
117
- _maybe_save_on_quit(session, store)
102
+ maybe_save_on_quit(session, store)
118
103
  break
119
104
 
120
105
  text = user_input.strip()
@@ -122,7 +107,7 @@ def _run_terminal_chat(resume: str | None = None, debug: bool = False) -> None:
122
107
  continue
123
108
 
124
109
  if text.lower() in ("/quit", "/exit", "/q"):
125
- _maybe_save_on_quit(session, store)
110
+ maybe_save_on_quit(session, store)
126
111
  console.print("[dim]Goodbye.[/dim]")
127
112
  break
128
113
 
@@ -148,7 +133,7 @@ def _run_terminal_chat(resume: str | None = None, debug: bool = False) -> None:
148
133
 
149
134
  if text.startswith("/save-session"):
150
135
  parts = text.split(None, 1)
151
- name = parts[1].strip() if len(parts) > 1 else _default_session_id()
136
+ name = parts[1].strip() if len(parts) > 1 else default_session_id()
152
137
  saved_path = store.save(name, session)
153
138
  console.print(f"[green]Session saved: {name} ({saved_path})[/green]")
154
139
  continue
@@ -201,7 +186,7 @@ def _run_terminal_chat(resume: str | None = None, debug: bool = False) -> None:
201
186
  elif not session.current_spec.cost_estimate:
202
187
  console.print("[yellow]No cost estimate available.[/yellow]")
203
188
  else:
204
- _print_cost_summary(session.current_spec)
189
+ print_cost_summary(session.current_spec)
205
190
  continue
206
191
 
207
192
  if text.startswith("/validate"):
@@ -210,7 +195,7 @@ def _run_terminal_chat(resume: str | None = None, debug: bool = False) -> None:
210
195
  else:
211
196
  parts = text.split(None, 1)
212
197
  framework = parts[1].strip() if len(parts) > 1 else None
213
- _run_validate(session.current_spec, framework)
198
+ run_validate(session.current_spec, framework)
214
199
  continue
215
200
 
216
201
  if text == "/terraform":
@@ -250,21 +235,19 @@ def _run_terminal_chat(resume: str | None = None, debug: bool = False) -> None:
250
235
  live.update(Markdown("".join(chunks)))
251
236
  except Exception as stream_err:
252
237
  # Fallback to non-streaming if streaming fails
253
- if _is_rate_limit(stream_err):
238
+ if is_rate_limit(stream_err):
254
239
  console.print("[yellow]Rate limited, try again in a moment.[/yellow]")
255
240
  continue
256
- if _is_timeout(stream_err):
241
+ if is_timeout(stream_err):
257
242
  console.print("[yellow]Request timed out, try a simpler request.[/yellow]")
258
243
  continue
259
244
  if isinstance(stream_err, RuntimeError) and "No LLM provider" in str(stream_err):
260
- console.print(
261
- "[red]No LLM provider configured.[/red] Set ANTHROPIC_API_KEY or OPENAI_API_KEY."
262
- )
245
+ console.print("[red]No LLM provider configured.[/red] Set ANTHROPIC_API_KEY or OPENAI_API_KEY.")
263
246
  continue
264
247
  try:
265
248
  _, _ = session.send(text)
266
249
  except Exception as e:
267
- console.print(_format_error(e))
250
+ console.print(format_error(e))
268
251
  continue
269
252
 
270
253
  # Token usage (show regardless of spec)
@@ -294,11 +277,11 @@ def _run_terminal_chat(resume: str | None = None, debug: bool = False) -> None:
294
277
  console.print(render_ascii(spec))
295
278
 
296
279
  if spec.cost_estimate:
297
- _print_cost_summary(spec)
280
+ print_cost_summary(spec)
298
281
 
299
282
  # Show spec diff when modifying
300
283
  if had_spec and session.last_diff:
301
- _print_diff(session.last_diff)
284
+ print_diff(session.last_diff)
302
285
 
303
286
  suggestions = spec.metadata.get("suggestions", [])
304
287
  if suggestions:
@@ -306,93 +289,32 @@ def _run_terminal_chat(resume: str | None = None, debug: bool = False) -> None:
306
289
 
307
290
 
308
291
  def _default_session_id() -> str:
309
- from datetime import datetime
310
-
311
- return datetime.now().strftime("session-%Y%m%d-%H%M%S")
292
+ return default_session_id()
312
293
 
313
294
 
314
295
  def _maybe_save_on_quit(session: ConversationSession, store: SessionStore) -> None:
315
- turn_count = sum(1 for m in session.history if m.get("role") == "user")
316
- if turn_count == 0:
317
- return
318
- try:
319
- answer = Prompt.ask("Save session? (y/N)", default="N")
320
- except (KeyboardInterrupt, EOFError):
321
- return
322
- if answer.strip().lower() == "y":
323
- name = _default_session_id()
324
- store.save(name, session)
325
- console.print(f"[green]Session saved as: {name}[/green]")
296
+ maybe_save_on_quit(session, store)
326
297
 
327
298
 
328
299
  def _is_rate_limit(exc: Exception) -> bool:
329
- msg = str(exc).lower()
330
- return "rate limit" in msg or "rate_limit" in msg or "429" in msg
300
+ return is_rate_limit(exc)
331
301
 
332
302
 
333
303
  def _is_timeout(exc: Exception) -> bool:
334
- msg = str(exc).lower()
335
- return "timeout" in msg or "timed out" in msg
304
+ return is_timeout(exc)
336
305
 
337
306
 
338
307
  def _format_error(exc: Exception) -> str:
339
- msg = str(exc)
340
- if isinstance(exc, RuntimeError) and "No LLM provider" in msg:
341
- return "[red]No LLM provider configured.[/red] Set ANTHROPIC_API_KEY or OPENAI_API_KEY."
342
- if _is_rate_limit(exc):
343
- return "[yellow]Rate limited, try again in a moment.[/yellow]"
344
- if _is_timeout(exc):
345
- return "[yellow]Request timed out, try a simpler request.[/yellow]"
346
- if isinstance(exc, ValueError):
347
- return "[red]Failed to parse architecture, try rephrasing.[/red]"
348
- return f"[red]Error:[/red] {exc}"
308
+ return format_error(exc)
349
309
 
350
310
 
351
311
  def _print_diff(diff) -> None:
352
- if diff.added:
353
- console.print(f"[green]+ Added: {', '.join(c.id for c in diff.added)}[/green]")
354
- if diff.removed:
355
- console.print(f"[red]- Removed: {', '.join(c.id for c in diff.removed)}[/red]")
356
- if diff.changed:
357
- console.print(f"[yellow]~ Changed: {', '.join(c.id for c in diff.changed)}[/yellow]")
358
- if diff.cost_delta is not None and diff.cost_delta != 0:
359
- sign = "+" if diff.cost_delta > 0 else ""
360
- console.print(f"[dim]Cost delta: {sign}${diff.cost_delta:,.2f}/mo[/dim]")
312
+ print_diff(diff)
361
313
 
362
314
 
363
315
  def _run_validate(spec: ArchSpec, framework: str | None) -> None:
364
- from cloudwright.validator import Validator
365
-
366
- if framework:
367
- results = Validator().validate(spec, compliance=[framework])
368
- else:
369
- results = Validator().validate(spec, well_architected=True)
370
-
371
- if not results:
372
- console.print("[yellow]No validation results.[/yellow]")
373
- return
374
-
375
- for result in results:
376
- passed = sum(1 for c in result.checks if c.passed)
377
- total = len(result.checks)
378
- status = "[green]PASS[/green]" if result.passed else "[red]FAIL[/red]"
379
- console.print(f"{result.framework}: {status} ({passed}/{total} checks passed)")
380
- for check in result.checks:
381
- icon = "[green]+[/green]" if check.passed else "[red]-[/red]"
382
- console.print(f" {icon} {check.name}")
383
- if not check.passed and check.recommendation:
384
- console.print(f" [dim]{check.recommendation}[/dim]")
316
+ run_validate(spec, framework)
385
317
 
386
318
 
387
319
  def _print_cost_summary(spec: ArchSpec) -> None:
388
- from rich.table import Table
389
-
390
- table = Table(title="Cost Estimate", show_footer=True)
391
- table.add_column("Component", style="cyan")
392
- table.add_column("Monthly", justify="right", footer=f"${spec.cost_estimate.monthly_total:,.2f}")
393
- table.add_column("Notes", style="dim")
394
-
395
- for item in spec.cost_estimate.breakdown:
396
- table.add_row(item.component_id, f"${item.monthly:,.2f}", item.notes)
397
-
398
- console.print(table)
320
+ print_cost_summary(spec)
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from datetime import datetime
4
+
5
+ from cloudwright import ConversationSession
6
+ from cloudwright.session_store import SessionStore
7
+ from rich.console import Console
8
+ from rich.prompt import Prompt
9
+
10
+ console = Console()
11
+
12
+
13
+ def make_session(resume: str | None, store: SessionStore) -> ConversationSession:
14
+ session = ConversationSession()
15
+ if resume:
16
+ try:
17
+ session = store.load(resume)
18
+ console.print(f"[cyan]Resumed session: {resume}[/cyan]")
19
+ if session.current_spec:
20
+ console.print(f"[dim]Current architecture: {session.current_spec.name}[/dim]")
21
+ except FileNotFoundError:
22
+ console.print(f"[yellow]Session {resume!r} not found. Starting fresh.[/yellow]")
23
+ return session
24
+
25
+
26
+ def default_session_id() -> str:
27
+ return datetime.now().strftime("session-%Y%m%d-%H%M%S")
28
+
29
+
30
+ def maybe_save_on_quit(session: ConversationSession, store: SessionStore) -> None:
31
+ turn_count = sum(1 for m in session.history if m.get("role") == "user")
32
+ if turn_count == 0:
33
+ return
34
+ try:
35
+ answer = Prompt.ask("Save session? (y/N)", default="N")
36
+ except (KeyboardInterrupt, EOFError):
37
+ return
38
+ if answer.strip().lower() == "y":
39
+ name = default_session_id()
40
+ store.save(name, session)
41
+ console.print(f"[green]Session saved as: {name}[/green]")
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from cloudwright import ConversationSession
4
+ from rich.console import Console
5
+ from rich.live import Live
6
+ from rich.markdown import Markdown
7
+
8
+ console = Console()
9
+
10
+
11
+ def stream_response(session: ConversationSession, text: str) -> list[str]:
12
+ """Stream LLM response with Live rendering. Returns collected chunks.
13
+
14
+ Falls back to session.send() if streaming fails due to a non-rate-limit,
15
+ non-timeout error.
16
+
17
+ Returns an empty list if the error was handled (rate limit / timeout / no provider).
18
+ Returns None on fallback send() error.
19
+ """
20
+ chunks: list[str] = []
21
+ try:
22
+ with Live(Markdown(""), console=console, refresh_per_second=12) as live:
23
+ for chunk in session.send_stream(text):
24
+ chunks.append(chunk)
25
+ live.update(Markdown("".join(chunks)))
26
+ return chunks
27
+ except Exception as stream_err:
28
+ raise stream_err
29
+
30
+
31
+ def is_rate_limit(exc: Exception) -> bool:
32
+ msg = str(exc).lower()
33
+ return "rate limit" in msg or "rate_limit" in msg or "429" in msg
34
+
35
+
36
+ def is_timeout(exc: Exception) -> bool:
37
+ msg = str(exc).lower()
38
+ return "timeout" in msg or "timed out" in msg
39
+
40
+
41
+ def format_error(exc: Exception) -> str:
42
+ msg = str(exc)
43
+ if isinstance(exc, RuntimeError) and "No LLM provider" in msg:
44
+ return "[red]No LLM provider configured.[/red] Set ANTHROPIC_API_KEY or OPENAI_API_KEY."
45
+ if is_rate_limit(exc):
46
+ return "[yellow]Rate limited, try again in a moment.[/yellow]"
47
+ if is_timeout(exc):
48
+ return "[yellow]Request timed out, try a simpler request.[/yellow]"
49
+ if isinstance(exc, ValueError):
50
+ return "[red]Failed to parse architecture, try rephrasing.[/red]"
51
+ return f"[red]Error:[/red] {exc}"
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from cloudwright import ArchSpec
4
+ from rich.console import Console
5
+ from rich.table import Table
6
+
7
+ console = Console()
8
+
9
+ _HELP = """\
10
+ Commands:
11
+ /save <file> Save last architecture to YAML file
12
+ /save-session [name] Save this conversation session
13
+ /load-session <name> Load a saved session
14
+ /sessions List saved sessions
15
+ /diagram Show ASCII diagram for last architecture
16
+ /yaml Show YAML for last architecture
17
+ /cost Show cost estimate for last architecture
18
+ /validate [fw] Run compliance check (hipaa, pci-dss, soc2, fedramp, gdpr)
19
+ /export <fmt> Export last architecture (terraform, mermaid, d2, cloudformation, sbom, aibom)
20
+ /terraform Export last architecture as Terraform
21
+ /new Start a new architecture from scratch
22
+ /help, /? Show this help
23
+ /quit Exit
24
+
25
+ Follow-up messages modify the current architecture. Use /new to start over.
26
+ """
27
+
28
+
29
+ def print_diff(diff) -> None:
30
+ if diff.added:
31
+ console.print(f"[green]+ Added: {', '.join(c.id for c in diff.added)}[/green]")
32
+ if diff.removed:
33
+ console.print(f"[red]- Removed: {', '.join(c.id for c in diff.removed)}[/red]")
34
+ if diff.changed:
35
+ console.print(f"[yellow]~ Changed: {', '.join(c.id for c in diff.changed)}[/yellow]")
36
+ if diff.cost_delta is not None and diff.cost_delta != 0:
37
+ sign = "+" if diff.cost_delta > 0 else ""
38
+ console.print(f"[dim]Cost delta: {sign}${diff.cost_delta:,.2f}/mo[/dim]")
39
+
40
+
41
+ def run_validate(spec: ArchSpec, framework: str | None) -> None:
42
+ from cloudwright.validator import Validator
43
+
44
+ if framework:
45
+ results = Validator().validate(spec, compliance=[framework])
46
+ else:
47
+ results = Validator().validate(spec, well_architected=True)
48
+
49
+ if not results:
50
+ console.print("[yellow]No validation results.[/yellow]")
51
+ return
52
+
53
+ for result in results:
54
+ passed = sum(1 for c in result.checks if c.passed)
55
+ total = len(result.checks)
56
+ status = "[green]PASS[/green]" if result.passed else "[red]FAIL[/red]"
57
+ console.print(f"{result.framework}: {status} ({passed}/{total} checks passed)")
58
+ for check in result.checks:
59
+ icon = "[green]+[/green]" if check.passed else "[red]-[/red]"
60
+ console.print(f" {icon} {check.name}")
61
+ if not check.passed and check.recommendation:
62
+ console.print(f" [dim]{check.recommendation}[/dim]")
63
+
64
+
65
+ def print_cost_summary(spec: ArchSpec) -> None:
66
+ table = Table(title="Cost Estimate", show_footer=True)
67
+ table.add_column("Component", style="cyan")
68
+ table.add_column("Monthly", justify="right", footer=f"${spec.cost_estimate.monthly_total:,.2f}")
69
+ table.add_column("Notes", style="dim")
70
+
71
+ for item in spec.cost_estimate.breakdown:
72
+ table.add_row(item.component_id, f"${item.monthly:,.2f}", item.notes)
73
+
74
+ console.print(table)
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ import functools
4
+ import traceback
5
+ from typing import Any, Callable
6
+
7
+ import typer
8
+ from rich.console import Console
9
+
10
+ err_console = Console(stderr=True)
11
+
12
+
13
+ def cloudwright_command(json_output: bool = True, dry_run: bool = False) -> Callable:
14
+ """Decorator that wraps command functions with standard output handling.
15
+
16
+ Handles: JSON envelope wrapping, Rich console formatting, --verbose stack
17
+ traces, --dry-run interception, and exit codes.
18
+
19
+ Args:
20
+ json_output: Whether the command supports --json output mode.
21
+ dry_run: Whether the command supports --dry-run interception.
22
+ """
23
+
24
+ def decorator(fn: Callable) -> Callable:
25
+ @functools.wraps(fn)
26
+ def wrapper(*args: Any, **kwargs: Any) -> None:
27
+ # Extract typer context from positional or keyword args
28
+ ctx = _extract_ctx(fn, args, kwargs)
29
+ verbose = bool(ctx and ctx.obj and ctx.obj.get("verbose"))
30
+ is_dry = dry_run and bool(ctx and ctx.obj and ctx.obj.get("dry_run"))
31
+
32
+ if is_dry:
33
+ # Commands that handle dry_run themselves will see ctx.obj["dry_run"]
34
+ # This outer gate lets the inner function emit_dry_run and exit.
35
+ pass
36
+
37
+ try:
38
+ fn(*args, **kwargs)
39
+ except typer.Exit:
40
+ raise
41
+ except SystemExit:
42
+ raise
43
+ except Exception as e:
44
+ if verbose:
45
+ err_console.print_exception()
46
+ else:
47
+ err_console.print(f"[red]Error:[/red] {e}")
48
+ if verbose:
49
+ err_console.print(traceback.format_exc())
50
+ raise typer.Exit(1)
51
+
52
+ return wrapper
53
+
54
+ return decorator
55
+
56
+
57
+ def _extract_ctx(fn: Callable, args: tuple, kwargs: dict) -> typer.Context | None:
58
+ import inspect
59
+
60
+ sig = inspect.signature(fn)
61
+ param_names = list(sig.parameters.keys())
62
+
63
+ # Check kwargs first
64
+ if "ctx" in kwargs:
65
+ return kwargs["ctx"]
66
+
67
+ # Check positional args by parameter name
68
+ for i, name in enumerate(param_names):
69
+ if name == "ctx" and i < len(args):
70
+ return args[i]
71
+
72
+ return None
@@ -20,7 +20,7 @@ classifiers = [
20
20
  "Topic :: System :: Systems Administration",
21
21
  ]
22
22
  dependencies = [
23
- "cloudwright-ai>=0.1.0,<1",
23
+ "cloudwright-ai>=1.0.0,<2",
24
24
  "typer>=0.21,<1",
25
25
  "rich>=13.9,<15",
26
26
  ]
@@ -1 +0,0 @@
1
- __version__ = "0.3.5"