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.
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/PKG-INFO +2 -2
- cloudwright_ai_cli-1.0.0/cloudwright_cli/__init__.py +1 -0
- cloudwright_ai_cli-1.0.0/cloudwright_cli/commands/chat.py +320 -0
- cloudwright_ai_cli-1.0.0/cloudwright_cli/commands/chat_session.py +41 -0
- cloudwright_ai_cli-1.0.0/cloudwright_cli/commands/chat_streaming.py +51 -0
- cloudwright_ai_cli-1.0.0/cloudwright_cli/commands/chat_ui.py +74 -0
- cloudwright_ai_cli-1.0.0/cloudwright_cli/decorators.py +72 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/pyproject.toml +1 -1
- cloudwright_ai_cli-1.0.0/tests/test_chat_commands.py +214 -0
- cloudwright_ai_cli-1.0.0/tests/test_chat_debug.py +55 -0
- cloudwright_ai_cli-1.0.0/tests/test_chat_persistence.py +190 -0
- cloudwright_ai_cli-1.0.0/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-1.0.0}/.gitignore +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/README.md +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/__main__.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/__init__.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/adr.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/analyze_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/catalog_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/compare.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/cost.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/databricks_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/design.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/diff.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/drift_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/export.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/import_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/init_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/lint_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/mcp_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/modify_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/policy.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/refresh_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/schema_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/score_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/security_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/commands/validate.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/completions.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/main.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/output.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/project.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/py.typed +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/cloudwright_cli/utils.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/tests/__init__.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/tests/test_cli.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/tests/test_drift_cmd.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/tests/test_init.py +0 -0
- {cloudwright_ai_cli-0.3.3 → cloudwright_ai_cli-1.0.0}/tests/test_modify_cmd.py +0 -0
- {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
|
+
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<
|
|
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
|