cloudwright-ai-cli 0.3.3__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.
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/PKG-INFO +1 -1
- cloudwright_ai_cli-0.3.5/cloudwright_cli/__init__.py +1 -0
- cloudwright_ai_cli-0.3.5/cloudwright_cli/commands/chat.py +398 -0
- cloudwright_ai_cli-0.3.5/tests/test_chat_commands.py +214 -0
- cloudwright_ai_cli-0.3.5/tests/test_chat_debug.py +55 -0
- cloudwright_ai_cli-0.3.5/tests/test_chat_persistence.py +190 -0
- cloudwright_ai_cli-0.3.5/tests/test_chat_streaming.py +86 -0
- cloudwright_ai_cli-0.3.3/cloudwright_cli/__init__.py +0 -1
- cloudwright_ai_cli-0.3.3/cloudwright_cli/commands/chat.py +0 -261
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/.gitignore +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/README.md +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/__main__.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/__init__.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/adr.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/analyze_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/catalog_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/compare.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/cost.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/databricks_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/design.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/diff.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/drift_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/export.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/import_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/init_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/lint_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/mcp_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/modify_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/policy.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/refresh_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/schema_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/score_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/security_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/commands/validate.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/completions.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/main.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/output.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/project.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/py.typed +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/cloudwright_cli/utils.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/pyproject.toml +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/tests/__init__.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/tests/test_cli.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/tests/test_drift_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/tests/test_init.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-0.3.5}/tests/test_modify_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → 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.
|
|
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)
|