cloudwright-ai-cli 0.3.3__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 (51) hide show
  1. {cloudwright_ai_cli-0.3.3 → 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-1.0.0/cloudwright_cli/commands/chat.py +320 -0
  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.3 → cloudwright_ai_cli-1.0.0}/pyproject.toml +1 -1
  9. cloudwright_ai_cli-1.0.0/tests/test_chat_commands.py +214 -0
  10. cloudwright_ai_cli-1.0.0/tests/test_chat_debug.py +55 -0
  11. cloudwright_ai_cli-1.0.0/tests/test_chat_persistence.py +190 -0
  12. cloudwright_ai_cli-1.0.0/tests/test_chat_streaming.py +86 -0
  13. cloudwright_ai_cli-0.3.3/cloudwright_cli/__init__.py +0 -1
  14. cloudwright_ai_cli-0.3.3/cloudwright_cli/commands/chat.py +0 -261
  15. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/.gitignore +0 -0
  16. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/README.md +0 -0
  17. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/__main__.py +0 -0
  18. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/__init__.py +0 -0
  19. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/adr.py +0 -0
  20. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/analyze_cmd.py +0 -0
  21. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/catalog_cmd.py +0 -0
  22. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/compare.py +0 -0
  23. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/cost.py +0 -0
  24. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/databricks_cmd.py +0 -0
  25. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/design.py +0 -0
  26. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/diff.py +0 -0
  27. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/drift_cmd.py +0 -0
  28. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/export.py +0 -0
  29. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/import_cmd.py +0 -0
  30. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/init_cmd.py +0 -0
  31. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/lint_cmd.py +0 -0
  32. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/mcp_cmd.py +0 -0
  33. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/modify_cmd.py +0 -0
  34. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/policy.py +0 -0
  35. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/refresh_cmd.py +0 -0
  36. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/schema_cmd.py +0 -0
  37. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/score_cmd.py +0 -0
  38. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/security_cmd.py +0 -0
  39. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/validate.py +0 -0
  40. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/completions.py +0 -0
  41. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/main.py +0 -0
  42. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/output.py +0 -0
  43. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/project.py +0 -0
  44. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/py.typed +0 -0
  45. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/utils.py +0 -0
  46. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/tests/__init__.py +0 -0
  47. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/tests/test_cli.py +0 -0
  48. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/tests/test_drift_cmd.py +0 -0
  49. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/tests/test_init.py +0 -0
  50. {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/tests/test_modify_cmd.py +0 -0
  51. {cloudwright_ai_cli-0.3.3 → 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.3
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"
@@ -0,0 +1,320 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import sys
5
+ import time
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ from cloudwright import ArchSpec, ConversationSession
10
+ from cloudwright.ascii_diagram import render_ascii
11
+ from cloudwright.session_store import SessionStore
12
+ from rich.console import Console
13
+ from rich.live import Live
14
+ from rich.markdown import Markdown
15
+ from rich.panel import Panel
16
+ from rich.prompt import Prompt
17
+ from rich.rule import Rule
18
+ from rich.syntax import Syntax
19
+
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
23
+
24
+ console = Console()
25
+
26
+
27
+ def chat(
28
+ web: Annotated[bool, typer.Option("--web", help="Launch web UI instead of terminal chat")] = False,
29
+ resume: Annotated[str | None, typer.Option("--resume", help="Resume a saved session by ID")] = None,
30
+ debug: Annotated[bool, typer.Option("--debug", help="Log LLM requests/responses to stderr")] = False,
31
+ ) -> None:
32
+ """Interactive architecture design chat."""
33
+ if web:
34
+ _launch_web()
35
+ return
36
+
37
+ _run_terminal_chat(resume=resume, debug=debug)
38
+
39
+
40
+ def _launch_web() -> None:
41
+ try:
42
+ import cloudwright_web # type: ignore
43
+ import uvicorn
44
+ except ImportError:
45
+ console.print(
46
+ "[red]Error:[/red] cloudwright-web is not installed.\nInstall it with: pip install 'cloudwright-ai[web]'"
47
+ )
48
+ raise typer.Exit(1)
49
+
50
+ import socket
51
+
52
+ port = 8000
53
+ for candidate in range(8000, 8100):
54
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
55
+ if s.connect_ex(("127.0.0.1", candidate)) != 0:
56
+ port = candidate
57
+ break
58
+
59
+ import threading
60
+ import webbrowser
61
+
62
+ url = f"http://127.0.0.1:{port}"
63
+ console.print(f"[cyan]Launching Cloudwright web UI on {url}[/cyan]")
64
+
65
+ def _open_browser():
66
+ time.sleep(1.5)
67
+ webbrowser.open(url)
68
+
69
+ threading.Thread(target=_open_browser, daemon=True).start()
70
+ uvicorn.run(cloudwright_web.app, host="127.0.0.1", port=port)
71
+
72
+
73
+ def _run_terminal_chat(resume: str | None = None, debug: bool = False) -> None:
74
+ if debug:
75
+ logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
76
+
77
+ console.print(
78
+ Panel(
79
+ "[bold cyan]Cloudwright Architecture Chat[/bold cyan]\nDescribe any cloud architecture.",
80
+ subtitle="Type /quit to exit",
81
+ )
82
+ )
83
+ console.print(f"[dim]{_HELP}[/dim]")
84
+
85
+ store = SessionStore()
86
+ session = ConversationSession()
87
+
88
+ if resume:
89
+ try:
90
+ session = store.load(resume)
91
+ console.print(f"[cyan]Resumed session: {resume}[/cyan]")
92
+ if session.current_spec:
93
+ console.print(f"[dim]Current architecture: {session.current_spec.name}[/dim]")
94
+ except FileNotFoundError:
95
+ console.print(f"[yellow]Session {resume!r} not found. Starting fresh.[/yellow]")
96
+
97
+ while True:
98
+ try:
99
+ user_input = Prompt.ask("\n[bold cyan]>[/bold cyan]")
100
+ except (KeyboardInterrupt, EOFError):
101
+ console.print("\n[dim]Exiting.[/dim]")
102
+ maybe_save_on_quit(session, store)
103
+ break
104
+
105
+ text = user_input.strip()
106
+ if not text:
107
+ continue
108
+
109
+ if text.lower() in ("/quit", "/exit", "/q"):
110
+ maybe_save_on_quit(session, store)
111
+ console.print("[dim]Goodbye.[/dim]")
112
+ break
113
+
114
+ if text.lower() in ("/help", "/?"):
115
+ console.print(f"[dim]{_HELP}[/dim]")
116
+ continue
117
+
118
+ if text.lower() == "/new":
119
+ session = ConversationSession()
120
+ console.print("[cyan]Starting fresh. Describe a new architecture.[/cyan]")
121
+ continue
122
+
123
+ if text.startswith("/save ") and not text.startswith("/save-session"):
124
+ path = text[6:].strip()
125
+ if not session.current_spec:
126
+ console.print("[yellow]No architecture to save yet.[/yellow]")
127
+ else:
128
+ from pathlib import Path
129
+
130
+ Path(path).write_text(session.current_spec.to_yaml())
131
+ console.print(f"[green]Saved to {path}[/green]")
132
+ continue
133
+
134
+ if text.startswith("/save-session"):
135
+ parts = text.split(None, 1)
136
+ name = parts[1].strip() if len(parts) > 1 else default_session_id()
137
+ saved_path = store.save(name, session)
138
+ console.print(f"[green]Session saved: {name} ({saved_path})[/green]")
139
+ continue
140
+
141
+ if text.startswith("/load-session"):
142
+ parts = text.split(None, 1)
143
+ if len(parts) < 2:
144
+ console.print("[yellow]Usage: /load-session <name>[/yellow]")
145
+ continue
146
+ name = parts[1].strip()
147
+ try:
148
+ session = store.load(name)
149
+ console.print(f"[cyan]Loaded session: {name}[/cyan]")
150
+ if session.current_spec:
151
+ console.print(Rule(f"[bold cyan]{session.current_spec.name}[/bold cyan]"))
152
+ console.print(render_ascii(session.current_spec))
153
+ except FileNotFoundError:
154
+ console.print(f"[yellow]Session {name!r} not found.[/yellow]")
155
+ continue
156
+
157
+ if text == "/sessions":
158
+ sessions = store.list_sessions()
159
+ if not sessions:
160
+ console.print("[dim]No saved sessions.[/dim]")
161
+ else:
162
+ for s in sessions:
163
+ spec_note = f" [{s['spec_name']}]" if s.get("spec_name") else ""
164
+ console.print(f" [cyan]{s['session_id']}[/cyan] {s['turn_count']} turns{spec_note}")
165
+ continue
166
+
167
+ if text == "/diagram":
168
+ if not session.current_spec:
169
+ console.print("[yellow]No architecture yet.[/yellow]")
170
+ else:
171
+ console.print(Rule(f"[bold cyan]{session.current_spec.name}[/bold cyan]"))
172
+ console.print(render_ascii(session.current_spec))
173
+ continue
174
+
175
+ if text == "/yaml":
176
+ if not session.current_spec:
177
+ console.print("[yellow]No architecture yet.[/yellow]")
178
+ else:
179
+ console.print(Rule(f"[bold cyan]{session.current_spec.name}[/bold cyan]"))
180
+ console.print(Syntax(session.current_spec.to_yaml(), "yaml", theme="monokai", word_wrap=True))
181
+ continue
182
+
183
+ if text == "/cost":
184
+ if not session.current_spec:
185
+ console.print("[yellow]No architecture yet.[/yellow]")
186
+ elif not session.current_spec.cost_estimate:
187
+ console.print("[yellow]No cost estimate available.[/yellow]")
188
+ else:
189
+ print_cost_summary(session.current_spec)
190
+ continue
191
+
192
+ if text.startswith("/validate"):
193
+ if not session.current_spec:
194
+ console.print("[yellow]No architecture yet.[/yellow]")
195
+ else:
196
+ parts = text.split(None, 1)
197
+ framework = parts[1].strip() if len(parts) > 1 else None
198
+ run_validate(session.current_spec, framework)
199
+ continue
200
+
201
+ if text == "/terraform":
202
+ if not session.current_spec:
203
+ console.print("[yellow]No architecture to export yet.[/yellow]")
204
+ else:
205
+ try:
206
+ content = session.current_spec.export("terraform")
207
+ console.print(Syntax(content, "hcl", theme="monokai", word_wrap=True))
208
+ except ValueError as e:
209
+ console.print(f"[red]Error:[/red] {e}")
210
+ continue
211
+
212
+ if text.startswith("/export "):
213
+ fmt = text[8:].strip()
214
+ if not session.current_spec:
215
+ console.print("[yellow]No architecture to export yet.[/yellow]")
216
+ else:
217
+ try:
218
+ content = session.current_spec.export(fmt)
219
+ lang = {"terraform": "hcl", "mermaid": "text", "d2": "text", "cloudformation": "yaml"}.get(
220
+ fmt, "json"
221
+ )
222
+ console.print(Syntax(content, lang, theme="monokai", word_wrap=True))
223
+ except ValueError as e:
224
+ console.print(f"[red]Error:[/red] {e}")
225
+ continue
226
+
227
+ had_spec = session.current_spec is not None
228
+
229
+ # Stream the LLM response with live rendering
230
+ chunks: list[str] = []
231
+ try:
232
+ with Live(Markdown(""), console=console, refresh_per_second=12) as live:
233
+ for chunk in session.send_stream(text):
234
+ chunks.append(chunk)
235
+ live.update(Markdown("".join(chunks)))
236
+ except Exception as stream_err:
237
+ # Fallback to non-streaming if streaming fails
238
+ if is_rate_limit(stream_err):
239
+ console.print("[yellow]Rate limited, try again in a moment.[/yellow]")
240
+ continue
241
+ if is_timeout(stream_err):
242
+ console.print("[yellow]Request timed out, try a simpler request.[/yellow]")
243
+ continue
244
+ if isinstance(stream_err, RuntimeError) and "No LLM provider" in str(stream_err):
245
+ console.print("[red]No LLM provider configured.[/red] Set ANTHROPIC_API_KEY or OPENAI_API_KEY.")
246
+ continue
247
+ try:
248
+ _, _ = session.send(text)
249
+ except Exception as e:
250
+ console.print(format_error(e))
251
+ continue
252
+
253
+ # Token usage (show regardless of spec)
254
+ if session.last_usage:
255
+ inp = session.last_usage.get("input_tokens", 0)
256
+ out = session.last_usage.get("output_tokens", 0)
257
+ cost = session.last_usage.get("estimated_cost", 0.0)
258
+ console.print(f"[dim]Tokens: {inp} in / {out} out (~${cost:.4f})[/dim]")
259
+
260
+ spec = session.current_spec
261
+
262
+ if spec is None:
263
+ continue
264
+
265
+ # Auto-reprice after each response
266
+ if not spec.cost_estimate:
267
+ try:
268
+ from cloudwright.cost import CostEngine
269
+
270
+ estimate = CostEngine().estimate(spec)
271
+ spec = spec.model_copy(update={"cost_estimate": estimate})
272
+ session.current_spec = spec
273
+ except Exception:
274
+ pass
275
+
276
+ console.print(Rule(f"[bold cyan]{spec.name}[/bold cyan]"))
277
+ console.print(render_ascii(spec))
278
+
279
+ if spec.cost_estimate:
280
+ print_cost_summary(spec)
281
+
282
+ # Show spec diff when modifying
283
+ if had_spec and session.last_diff:
284
+ print_diff(session.last_diff)
285
+
286
+ suggestions = spec.metadata.get("suggestions", [])
287
+ if suggestions:
288
+ console.print(f"[dim]Try: {' | '.join(repr(s) for s in suggestions[:3])}[/dim]")
289
+
290
+
291
+ def _default_session_id() -> str:
292
+ return default_session_id()
293
+
294
+
295
+ def _maybe_save_on_quit(session: ConversationSession, store: SessionStore) -> None:
296
+ maybe_save_on_quit(session, store)
297
+
298
+
299
+ def _is_rate_limit(exc: Exception) -> bool:
300
+ return is_rate_limit(exc)
301
+
302
+
303
+ def _is_timeout(exc: Exception) -> bool:
304
+ return is_timeout(exc)
305
+
306
+
307
+ def _format_error(exc: Exception) -> str:
308
+ return format_error(exc)
309
+
310
+
311
+ def _print_diff(diff) -> None:
312
+ print_diff(diff)
313
+
314
+
315
+ def _run_validate(spec: ArchSpec, framework: str | None) -> None:
316
+ run_validate(spec, framework)
317
+
318
+
319
+ def _print_cost_summary(spec: ArchSpec) -> None:
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
  ]