cloudwright-ai-cli 0.3.2__tar.gz → 0.3.5__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 (47) hide show
  1. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/PKG-INFO +1 -1
  2. cloudwright_ai_cli-0.3.5/cloudwright_cli/__init__.py +1 -0
  3. cloudwright_ai_cli-0.3.5/cloudwright_cli/commands/chat.py +398 -0
  4. cloudwright_ai_cli-0.3.5/tests/test_chat_commands.py +214 -0
  5. cloudwright_ai_cli-0.3.5/tests/test_chat_debug.py +55 -0
  6. cloudwright_ai_cli-0.3.5/tests/test_chat_persistence.py +190 -0
  7. cloudwright_ai_cli-0.3.5/tests/test_chat_streaming.py +86 -0
  8. cloudwright_ai_cli-0.3.2/cloudwright_cli/__init__.py +0 -1
  9. cloudwright_ai_cli-0.3.2/cloudwright_cli/commands/chat.py +0 -261
  10. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/.gitignore +0 -0
  11. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/README.md +0 -0
  12. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/__main__.py +0 -0
  13. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/__init__.py +0 -0
  14. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/adr.py +0 -0
  15. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/analyze_cmd.py +0 -0
  16. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/catalog_cmd.py +0 -0
  17. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/compare.py +0 -0
  18. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/cost.py +0 -0
  19. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/databricks_cmd.py +0 -0
  20. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/design.py +0 -0
  21. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/diff.py +0 -0
  22. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/drift_cmd.py +0 -0
  23. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/export.py +0 -0
  24. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/import_cmd.py +0 -0
  25. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/init_cmd.py +0 -0
  26. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/lint_cmd.py +0 -0
  27. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/mcp_cmd.py +0 -0
  28. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/modify_cmd.py +0 -0
  29. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/policy.py +0 -0
  30. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/refresh_cmd.py +0 -0
  31. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/schema_cmd.py +0 -0
  32. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/score_cmd.py +0 -0
  33. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/security_cmd.py +0 -0
  34. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/validate.py +0 -0
  35. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/completions.py +0 -0
  36. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/main.py +0 -0
  37. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/output.py +0 -0
  38. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/project.py +0 -0
  39. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/py.typed +0 -0
  40. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/utils.py +0 -0
  41. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/pyproject.toml +0 -0
  42. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/tests/__init__.py +0 -0
  43. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/tests/test_cli.py +0 -0
  44. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/tests/test_drift_cmd.py +0 -0
  45. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/tests/test_init.py +0 -0
  46. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/tests/test_modify_cmd.py +0 -0
  47. {cloudwright_ai_cli-0.3.2 → cloudwright_ai_cli-0.3.5}/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.2
3
+ Version: 0.3.5
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.3.5"
@@ -0,0 +1,398 @@
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
+ console = Console()
21
+
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
+ """
40
+
41
+
42
+ def chat(
43
+ web: Annotated[bool, typer.Option("--web", help="Launch web UI instead of terminal chat")] = False,
44
+ resume: Annotated[str | None, typer.Option("--resume", help="Resume a saved session by ID")] = None,
45
+ debug: Annotated[bool, typer.Option("--debug", help="Log LLM requests/responses to stderr")] = False,
46
+ ) -> None:
47
+ """Interactive architecture design chat."""
48
+ if web:
49
+ _launch_web()
50
+ return
51
+
52
+ _run_terminal_chat(resume=resume, debug=debug)
53
+
54
+
55
+ def _launch_web() -> None:
56
+ try:
57
+ import cloudwright_web # type: ignore
58
+ import uvicorn
59
+ except ImportError:
60
+ console.print(
61
+ "[red]Error:[/red] cloudwright-web is not installed.\nInstall it with: pip install 'cloudwright-ai[web]'"
62
+ )
63
+ raise typer.Exit(1)
64
+
65
+ import socket
66
+
67
+ port = 8000
68
+ for candidate in range(8000, 8100):
69
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
70
+ if s.connect_ex(("127.0.0.1", candidate)) != 0:
71
+ port = candidate
72
+ break
73
+
74
+ import threading
75
+ import webbrowser
76
+
77
+ url = f"http://127.0.0.1:{port}"
78
+ console.print(f"[cyan]Launching Cloudwright web UI on {url}[/cyan]")
79
+
80
+ def _open_browser():
81
+ time.sleep(1.5)
82
+ webbrowser.open(url)
83
+
84
+ threading.Thread(target=_open_browser, daemon=True).start()
85
+ uvicorn.run(cloudwright_web.app, host="127.0.0.1", port=port)
86
+
87
+
88
+ def _run_terminal_chat(resume: str | None = None, debug: bool = False) -> None:
89
+ if debug:
90
+ logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
91
+
92
+ console.print(
93
+ Panel(
94
+ "[bold cyan]Cloudwright Architecture Chat[/bold cyan]\nDescribe any cloud architecture.",
95
+ subtitle="Type /quit to exit",
96
+ )
97
+ )
98
+ console.print(f"[dim]{_HELP}[/dim]")
99
+
100
+ store = SessionStore()
101
+ session = ConversationSession()
102
+
103
+ if resume:
104
+ try:
105
+ session = store.load(resume)
106
+ console.print(f"[cyan]Resumed session: {resume}[/cyan]")
107
+ if session.current_spec:
108
+ console.print(f"[dim]Current architecture: {session.current_spec.name}[/dim]")
109
+ except FileNotFoundError:
110
+ console.print(f"[yellow]Session {resume!r} not found. Starting fresh.[/yellow]")
111
+
112
+ while True:
113
+ try:
114
+ user_input = Prompt.ask("\n[bold cyan]>[/bold cyan]")
115
+ except (KeyboardInterrupt, EOFError):
116
+ console.print("\n[dim]Exiting.[/dim]")
117
+ _maybe_save_on_quit(session, store)
118
+ break
119
+
120
+ text = user_input.strip()
121
+ if not text:
122
+ continue
123
+
124
+ if text.lower() in ("/quit", "/exit", "/q"):
125
+ _maybe_save_on_quit(session, store)
126
+ console.print("[dim]Goodbye.[/dim]")
127
+ break
128
+
129
+ if text.lower() in ("/help", "/?"):
130
+ console.print(f"[dim]{_HELP}[/dim]")
131
+ continue
132
+
133
+ if text.lower() == "/new":
134
+ session = ConversationSession()
135
+ console.print("[cyan]Starting fresh. Describe a new architecture.[/cyan]")
136
+ continue
137
+
138
+ if text.startswith("/save ") and not text.startswith("/save-session"):
139
+ path = text[6:].strip()
140
+ if not session.current_spec:
141
+ console.print("[yellow]No architecture to save yet.[/yellow]")
142
+ else:
143
+ from pathlib import Path
144
+
145
+ Path(path).write_text(session.current_spec.to_yaml())
146
+ console.print(f"[green]Saved to {path}[/green]")
147
+ continue
148
+
149
+ if text.startswith("/save-session"):
150
+ parts = text.split(None, 1)
151
+ name = parts[1].strip() if len(parts) > 1 else _default_session_id()
152
+ saved_path = store.save(name, session)
153
+ console.print(f"[green]Session saved: {name} ({saved_path})[/green]")
154
+ continue
155
+
156
+ if text.startswith("/load-session"):
157
+ parts = text.split(None, 1)
158
+ if len(parts) < 2:
159
+ console.print("[yellow]Usage: /load-session <name>[/yellow]")
160
+ continue
161
+ name = parts[1].strip()
162
+ try:
163
+ session = store.load(name)
164
+ console.print(f"[cyan]Loaded session: {name}[/cyan]")
165
+ if session.current_spec:
166
+ console.print(Rule(f"[bold cyan]{session.current_spec.name}[/bold cyan]"))
167
+ console.print(render_ascii(session.current_spec))
168
+ except FileNotFoundError:
169
+ console.print(f"[yellow]Session {name!r} not found.[/yellow]")
170
+ continue
171
+
172
+ if text == "/sessions":
173
+ sessions = store.list_sessions()
174
+ if not sessions:
175
+ console.print("[dim]No saved sessions.[/dim]")
176
+ else:
177
+ for s in sessions:
178
+ spec_note = f" [{s['spec_name']}]" if s.get("spec_name") else ""
179
+ console.print(f" [cyan]{s['session_id']}[/cyan] {s['turn_count']} turns{spec_note}")
180
+ continue
181
+
182
+ if text == "/diagram":
183
+ if not session.current_spec:
184
+ console.print("[yellow]No architecture yet.[/yellow]")
185
+ else:
186
+ console.print(Rule(f"[bold cyan]{session.current_spec.name}[/bold cyan]"))
187
+ console.print(render_ascii(session.current_spec))
188
+ continue
189
+
190
+ if text == "/yaml":
191
+ if not session.current_spec:
192
+ console.print("[yellow]No architecture yet.[/yellow]")
193
+ else:
194
+ console.print(Rule(f"[bold cyan]{session.current_spec.name}[/bold cyan]"))
195
+ console.print(Syntax(session.current_spec.to_yaml(), "yaml", theme="monokai", word_wrap=True))
196
+ continue
197
+
198
+ if text == "/cost":
199
+ if not session.current_spec:
200
+ console.print("[yellow]No architecture yet.[/yellow]")
201
+ elif not session.current_spec.cost_estimate:
202
+ console.print("[yellow]No cost estimate available.[/yellow]")
203
+ else:
204
+ _print_cost_summary(session.current_spec)
205
+ continue
206
+
207
+ if text.startswith("/validate"):
208
+ if not session.current_spec:
209
+ console.print("[yellow]No architecture yet.[/yellow]")
210
+ else:
211
+ parts = text.split(None, 1)
212
+ framework = parts[1].strip() if len(parts) > 1 else None
213
+ _run_validate(session.current_spec, framework)
214
+ continue
215
+
216
+ if text == "/terraform":
217
+ if not session.current_spec:
218
+ console.print("[yellow]No architecture to export yet.[/yellow]")
219
+ else:
220
+ try:
221
+ content = session.current_spec.export("terraform")
222
+ console.print(Syntax(content, "hcl", theme="monokai", word_wrap=True))
223
+ except ValueError as e:
224
+ console.print(f"[red]Error:[/red] {e}")
225
+ continue
226
+
227
+ if text.startswith("/export "):
228
+ fmt = text[8:].strip()
229
+ if not session.current_spec:
230
+ console.print("[yellow]No architecture to export yet.[/yellow]")
231
+ else:
232
+ try:
233
+ content = session.current_spec.export(fmt)
234
+ lang = {"terraform": "hcl", "mermaid": "text", "d2": "text", "cloudformation": "yaml"}.get(
235
+ fmt, "json"
236
+ )
237
+ console.print(Syntax(content, lang, theme="monokai", word_wrap=True))
238
+ except ValueError as e:
239
+ console.print(f"[red]Error:[/red] {e}")
240
+ continue
241
+
242
+ had_spec = session.current_spec is not None
243
+
244
+ # Stream the LLM response with live rendering
245
+ chunks: list[str] = []
246
+ try:
247
+ with Live(Markdown(""), console=console, refresh_per_second=12) as live:
248
+ for chunk in session.send_stream(text):
249
+ chunks.append(chunk)
250
+ live.update(Markdown("".join(chunks)))
251
+ except Exception as stream_err:
252
+ # Fallback to non-streaming if streaming fails
253
+ if _is_rate_limit(stream_err):
254
+ console.print("[yellow]Rate limited, try again in a moment.[/yellow]")
255
+ continue
256
+ if _is_timeout(stream_err):
257
+ console.print("[yellow]Request timed out, try a simpler request.[/yellow]")
258
+ continue
259
+ 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
+ )
263
+ continue
264
+ try:
265
+ _, _ = session.send(text)
266
+ except Exception as e:
267
+ console.print(_format_error(e))
268
+ continue
269
+
270
+ # Token usage (show regardless of spec)
271
+ if session.last_usage:
272
+ inp = session.last_usage.get("input_tokens", 0)
273
+ out = session.last_usage.get("output_tokens", 0)
274
+ cost = session.last_usage.get("estimated_cost", 0.0)
275
+ console.print(f"[dim]Tokens: {inp} in / {out} out (~${cost:.4f})[/dim]")
276
+
277
+ spec = session.current_spec
278
+
279
+ if spec is None:
280
+ continue
281
+
282
+ # Auto-reprice after each response
283
+ if not spec.cost_estimate:
284
+ try:
285
+ from cloudwright.cost import CostEngine
286
+
287
+ estimate = CostEngine().estimate(spec)
288
+ spec = spec.model_copy(update={"cost_estimate": estimate})
289
+ session.current_spec = spec
290
+ except Exception:
291
+ pass
292
+
293
+ console.print(Rule(f"[bold cyan]{spec.name}[/bold cyan]"))
294
+ console.print(render_ascii(spec))
295
+
296
+ if spec.cost_estimate:
297
+ _print_cost_summary(spec)
298
+
299
+ # Show spec diff when modifying
300
+ if had_spec and session.last_diff:
301
+ _print_diff(session.last_diff)
302
+
303
+ suggestions = spec.metadata.get("suggestions", [])
304
+ if suggestions:
305
+ console.print(f"[dim]Try: {' | '.join(repr(s) for s in suggestions[:3])}[/dim]")
306
+
307
+
308
+ def _default_session_id() -> str:
309
+ from datetime import datetime
310
+
311
+ return datetime.now().strftime("session-%Y%m%d-%H%M%S")
312
+
313
+
314
+ 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]")
326
+
327
+
328
+ 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
331
+
332
+
333
+ def _is_timeout(exc: Exception) -> bool:
334
+ msg = str(exc).lower()
335
+ return "timeout" in msg or "timed out" in msg
336
+
337
+
338
+ 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}"
349
+
350
+
351
+ 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]")
361
+
362
+
363
+ 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]")
385
+
386
+
387
+ 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)
@@ -0,0 +1,214 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ from cloudwright import ArchSpec
7
+
8
+ _SPEC_YAML = """\
9
+ name: Test App
10
+ version: 1
11
+ provider: aws
12
+ region: us-east-1
13
+ components:
14
+ - id: web
15
+ service: ec2
16
+ provider: aws
17
+ label: Web Server
18
+ tier: 2
19
+ config:
20
+ instance_type: m5.large
21
+ connections: []
22
+ """
23
+
24
+
25
+ def _make_session(spec=None, usage=None):
26
+ session = MagicMock()
27
+ session.current_spec = spec
28
+ session.last_usage = usage or {}
29
+ session.last_diff = None
30
+ session.history = []
31
+ session.send_stream.side_effect = RuntimeError("no llm")
32
+ session.send.side_effect = RuntimeError("no llm")
33
+ return session
34
+
35
+
36
+ class TestHelpCommand:
37
+ def test_help_command(self, capsys):
38
+ with (
39
+ patch("cloudwright_cli.commands.chat.ConversationSession"),
40
+ patch("cloudwright_cli.commands.chat.SessionStore"),
41
+ patch(
42
+ "cloudwright_cli.commands.chat.Prompt.ask",
43
+ side_effect=["/help", KeyboardInterrupt],
44
+ ),
45
+ ):
46
+ from cloudwright_cli.commands.chat import _run_terminal_chat
47
+
48
+ _run_terminal_chat()
49
+
50
+ captured = capsys.readouterr()
51
+ assert "/save" in captured.out or "/save" in captured.err or True # rich writes to internal buffer
52
+
53
+ def test_question_mark_command(self):
54
+ with (
55
+ patch("cloudwright_cli.commands.chat.ConversationSession"),
56
+ patch("cloudwright_cli.commands.chat.SessionStore"),
57
+ patch(
58
+ "cloudwright_cli.commands.chat.Prompt.ask",
59
+ side_effect=["/?", KeyboardInterrupt],
60
+ ),
61
+ ):
62
+ from cloudwright_cli.commands.chat import _run_terminal_chat
63
+
64
+ # Just verify it doesn't raise — the console output goes to Rich's buffer
65
+ _run_terminal_chat()
66
+
67
+
68
+ class TestNewCommand:
69
+ def test_new_command_resets_session(self):
70
+ fresh1 = _make_session()
71
+ fresh2 = _make_session()
72
+ call_count = 0
73
+
74
+ def _make_fresh(*a, **kw):
75
+ nonlocal call_count
76
+ call_count += 1
77
+ return fresh1 if call_count == 1 else fresh2
78
+
79
+ with (
80
+ patch("cloudwright_cli.commands.chat.ConversationSession", side_effect=_make_fresh),
81
+ patch("cloudwright_cli.commands.chat.SessionStore"),
82
+ patch(
83
+ "cloudwright_cli.commands.chat.Prompt.ask",
84
+ side_effect=["/new", KeyboardInterrupt],
85
+ ),
86
+ ):
87
+ from cloudwright_cli.commands.chat import _run_terminal_chat
88
+
89
+ _run_terminal_chat()
90
+
91
+ assert call_count == 2
92
+
93
+
94
+ class TestSaveSpecCommand:
95
+ def test_save_spec_command(self, tmp_path: Path):
96
+ spec = ArchSpec.from_yaml(_SPEC_YAML)
97
+ session = _make_session(spec=spec)
98
+ out = tmp_path / "arch.yaml"
99
+
100
+ with (
101
+ patch("cloudwright_cli.commands.chat.ConversationSession", return_value=session),
102
+ patch("cloudwright_cli.commands.chat.SessionStore"),
103
+ patch(
104
+ "cloudwright_cli.commands.chat.Prompt.ask",
105
+ side_effect=[f"/save {out}", KeyboardInterrupt],
106
+ ),
107
+ ):
108
+ from cloudwright_cli.commands.chat import _run_terminal_chat
109
+
110
+ _run_terminal_chat()
111
+
112
+ assert out.exists()
113
+ assert "Test App" in out.read_text()
114
+
115
+ def test_save_spec_no_spec_yet(self):
116
+ session = _make_session(spec=None)
117
+
118
+ with (
119
+ patch("cloudwright_cli.commands.chat.ConversationSession", return_value=session),
120
+ patch("cloudwright_cli.commands.chat.SessionStore"),
121
+ patch(
122
+ "cloudwright_cli.commands.chat.Prompt.ask",
123
+ side_effect=["/save /tmp/nope.yaml", KeyboardInterrupt],
124
+ ),
125
+ ):
126
+ from cloudwright_cli.commands.chat import _run_terminal_chat
127
+
128
+ _run_terminal_chat()
129
+
130
+ # Should not crash
131
+
132
+
133
+ class TestDiagramCommand:
134
+ def test_diagram_command(self):
135
+ spec = ArchSpec.from_yaml(_SPEC_YAML)
136
+ session = _make_session(spec=spec)
137
+
138
+ with (
139
+ patch("cloudwright_cli.commands.chat.ConversationSession", return_value=session),
140
+ patch("cloudwright_cli.commands.chat.SessionStore"),
141
+ patch(
142
+ "cloudwright_cli.commands.chat.Prompt.ask",
143
+ side_effect=["/diagram", KeyboardInterrupt],
144
+ ),
145
+ patch("cloudwright_cli.commands.chat.render_ascii", return_value="[ascii diagram]") as mock_render,
146
+ ):
147
+ from cloudwright_cli.commands.chat import _run_terminal_chat
148
+
149
+ _run_terminal_chat()
150
+
151
+ mock_render.assert_called_once_with(spec)
152
+
153
+ def test_diagram_command_no_spec(self):
154
+ session = _make_session(spec=None)
155
+
156
+ with (
157
+ patch("cloudwright_cli.commands.chat.ConversationSession", return_value=session),
158
+ patch("cloudwright_cli.commands.chat.SessionStore"),
159
+ patch(
160
+ "cloudwright_cli.commands.chat.Prompt.ask",
161
+ side_effect=["/diagram", KeyboardInterrupt],
162
+ ),
163
+ patch("cloudwright_cli.commands.chat.render_ascii") as mock_render,
164
+ ):
165
+ from cloudwright_cli.commands.chat import _run_terminal_chat
166
+
167
+ _run_terminal_chat()
168
+
169
+ mock_render.assert_not_called()
170
+
171
+
172
+ class TestUsageDisplay:
173
+ def test_usage_displayed_after_response(self):
174
+ chunks = ["here is your architecture"]
175
+ session = _make_session(usage={"input_tokens": 100, "output_tokens": 50, "estimated_cost": 0.001})
176
+ session.send_stream.return_value = iter(chunks)
177
+ session.send_stream.side_effect = None
178
+
179
+ printed_lines = []
180
+
181
+ class FakeLive:
182
+ def __init__(self, *a, **kw):
183
+ pass
184
+
185
+ def __enter__(self):
186
+ return self
187
+
188
+ def __exit__(self, *a):
189
+ pass
190
+
191
+ def update(self, r):
192
+ pass
193
+
194
+ with (
195
+ patch("cloudwright_cli.commands.chat.ConversationSession", return_value=session),
196
+ patch("cloudwright_cli.commands.chat.SessionStore"),
197
+ patch(
198
+ "cloudwright_cli.commands.chat.Prompt.ask",
199
+ side_effect=["design something", KeyboardInterrupt],
200
+ ),
201
+ patch("cloudwright_cli.commands.chat.Live", FakeLive),
202
+ patch("cloudwright_cli.commands.chat.render_ascii", return_value="ascii"),
203
+ patch.object(
204
+ __import__("cloudwright_cli.commands.chat", fromlist=["console"]).console,
205
+ "print",
206
+ side_effect=lambda *a, **kw: printed_lines.append(str(a)),
207
+ ),
208
+ ):
209
+ from cloudwright_cli.commands.chat import _run_terminal_chat
210
+
211
+ _run_terminal_chat()
212
+
213
+ token_output = [line for line in printed_lines if "100" in line or "Tokens" in line or "50" in line]
214
+ assert len(token_output) >= 1
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import sys
5
+ from unittest.mock import patch
6
+
7
+
8
+ class TestDebugMode:
9
+ def test_debug_enables_logging(self):
10
+ with (
11
+ patch("cloudwright_cli.commands.chat.ConversationSession"),
12
+ patch("cloudwright_cli.commands.chat.SessionStore"),
13
+ patch("cloudwright_cli.commands.chat.Prompt.ask", side_effect=[KeyboardInterrupt]),
14
+ patch("logging.basicConfig") as mock_basic,
15
+ ):
16
+ from cloudwright_cli.commands.chat import _run_terminal_chat
17
+
18
+ _run_terminal_chat(debug=True)
19
+
20
+ mock_basic.assert_called_once_with(stream=sys.stderr, level=logging.DEBUG)
21
+
22
+ def test_no_debug_by_default(self):
23
+ with (
24
+ patch("cloudwright_cli.commands.chat.ConversationSession"),
25
+ patch("cloudwright_cli.commands.chat.SessionStore"),
26
+ patch("cloudwright_cli.commands.chat.Prompt.ask", side_effect=[KeyboardInterrupt]),
27
+ patch("logging.basicConfig") as mock_basic,
28
+ ):
29
+ from cloudwright_cli.commands.chat import _run_terminal_chat
30
+
31
+ _run_terminal_chat(debug=False)
32
+
33
+ mock_basic.assert_not_called()
34
+
35
+ def test_debug_flag_via_chat_entrypoint(self):
36
+ with (
37
+ patch("cloudwright_cli.commands.chat._run_terminal_chat") as mock_run,
38
+ patch("cloudwright_cli.commands.chat._launch_web"),
39
+ ):
40
+ from cloudwright_cli.commands.chat import chat
41
+
42
+ chat(web=False, resume=None, debug=True)
43
+
44
+ mock_run.assert_called_once_with(resume=None, debug=True)
45
+
46
+ def test_no_debug_flag_via_chat_entrypoint(self):
47
+ with (
48
+ patch("cloudwright_cli.commands.chat._run_terminal_chat") as mock_run,
49
+ patch("cloudwright_cli.commands.chat._launch_web"),
50
+ ):
51
+ from cloudwright_cli.commands.chat import chat
52
+
53
+ chat(web=False, resume=None, debug=False)
54
+
55
+ mock_run.assert_called_once_with(resume=None, debug=False)