cloudwright-ai-cli 0.2.27__tar.gz → 0.3.1__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 (43) hide show
  1. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/PKG-INFO +1 -1
  2. cloudwright_ai_cli-0.3.1/cloudwright_cli/__init__.py +1 -0
  3. cloudwright_ai_cli-0.3.1/cloudwright_cli/commands/adr.py +213 -0
  4. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/analyze_cmd.py +8 -6
  5. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/catalog_cmd.py +5 -7
  6. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/compare.py +24 -1
  7. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/cost.py +4 -4
  8. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/design.py +22 -7
  9. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/diff.py +4 -4
  10. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/drift_cmd.py +5 -7
  11. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/export.py +15 -9
  12. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/import_cmd.py +4 -4
  13. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/init_cmd.py +2 -0
  14. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/lint_cmd.py +13 -4
  15. cloudwright_ai_cli-0.3.1/cloudwright_cli/commands/mcp_cmd.py +34 -0
  16. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/modify_cmd.py +19 -9
  17. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/policy.py +7 -7
  18. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/refresh_cmd.py +3 -5
  19. cloudwright_ai_cli-0.3.1/cloudwright_cli/commands/schema_cmd.py +178 -0
  20. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/score_cmd.py +3 -3
  21. cloudwright_ai_cli-0.3.1/cloudwright_cli/commands/security_cmd.py +108 -0
  22. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/validate.py +16 -5
  23. cloudwright_ai_cli-0.3.1/cloudwright_cli/completions.py +39 -0
  24. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/main.py +12 -0
  25. cloudwright_ai_cli-0.3.1/cloudwright_cli/output.py +147 -0
  26. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/project.py +28 -0
  27. cloudwright_ai_cli-0.3.1/cloudwright_cli/utils.py +26 -0
  28. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/tests/test_cli.py +17 -11
  29. cloudwright_ai_cli-0.2.27/cloudwright_cli/__init__.py +0 -1
  30. cloudwright_ai_cli-0.2.27/cloudwright_cli/utils.py +0 -49
  31. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/.gitignore +0 -0
  32. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/README.md +0 -0
  33. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/__main__.py +0 -0
  34. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/__init__.py +0 -0
  35. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/chat.py +0 -0
  36. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/commands/databricks_cmd.py +0 -0
  37. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/cloudwright_cli/py.typed +0 -0
  38. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/pyproject.toml +0 -0
  39. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/tests/__init__.py +0 -0
  40. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/tests/test_drift_cmd.py +0 -0
  41. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/tests/test_init.py +0 -0
  42. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/tests/test_modify_cmd.py +0 -0
  43. {cloudwright_ai_cli-0.2.27 → cloudwright_ai_cli-0.3.1}/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.2.27
3
+ Version: 0.3.1
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.1"
@@ -0,0 +1,213 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated
5
+
6
+ import typer
7
+ from rich.console import Console
8
+
9
+ from cloudwright_cli.output import emit_dry_run, emit_error, validate_output_path
10
+ from cloudwright_cli.utils import handle_error
11
+
12
+ console = Console()
13
+
14
+ _ADR_SYSTEM = """You generate Architecture Decision Records (ADRs) in MADR format.
15
+
16
+ Given an architecture spec as JSON, produce a markdown ADR with this exact structure:
17
+
18
+ # ADR: {name} — {key decision}
19
+
20
+ ## Status
21
+ Proposed
22
+
23
+ ## Context
24
+ {problem and why a decision is needed}
25
+
26
+ ## Decision
27
+ {the chosen architecture and key choices}
28
+
29
+ ## Components
30
+ | ID | Service | Provider | Purpose |
31
+ |---|---|---|---|
32
+ {component rows}
33
+
34
+ ## Consequences
35
+ ### Positive
36
+ - {benefits}
37
+
38
+ ### Negative
39
+ - {trade-offs and risks}
40
+
41
+ ## Alternatives Considered
42
+ {alternatives if any, otherwise note none documented}
43
+
44
+ ## Cost Estimate
45
+ {monthly cost if available, otherwise omit}
46
+
47
+ Be concise. Focus on the WHY, not just what the architecture contains.
48
+ Respond with ONLY the markdown — no explanation, no code fences."""
49
+
50
+
51
+ def adr(
52
+ ctx: typer.Context,
53
+ spec_file: Annotated[Path, typer.Argument(help="Path to ArchSpec YAML file", exists=True)],
54
+ output: Annotated[str | None, typer.Option("--output", "-o", help="Write ADR to this file")] = None,
55
+ title: Annotated[str | None, typer.Option("--title", help="ADR title (default: auto-generated)")] = None,
56
+ decision: Annotated[str | None, typer.Option("--decision", help="Specific decision to document")] = None,
57
+ ) -> None:
58
+ """Generate an Architecture Decision Record from an ArchSpec."""
59
+ try:
60
+ from cloudwright import ArchSpec
61
+
62
+ if output:
63
+ try:
64
+ validate_output_path(output)
65
+ except ValueError as e:
66
+ emit_error(ctx, e)
67
+
68
+ spec = ArchSpec.from_file(spec_file)
69
+
70
+ if ctx.obj and ctx.obj.get("dry_run"):
71
+ from cloudwright.llm.anthropic import GENERATE_MODEL
72
+
73
+ spec_json = spec.model_dump_json(indent=2, exclude_none=True)
74
+ emit_dry_run(ctx, {
75
+ "model": GENERATE_MODEL,
76
+ "estimated_tokens": len(spec_json + _ADR_SYSTEM) // 4,
77
+ "max_tokens": 2000,
78
+ "system_prompt_preview": _ADR_SYSTEM,
79
+ "user_prompt_preview": f"Generate ADR for: {spec.name}",
80
+ })
81
+
82
+ text = _generate_adr(spec, title=title, decision=decision)
83
+
84
+ if output:
85
+ Path(output).write_text(text)
86
+ console.print(f"[green]ADR written to {output}[/green]")
87
+ else:
88
+ print(text)
89
+
90
+ except typer.Exit:
91
+ raise
92
+ except Exception as e:
93
+ handle_error(ctx, e)
94
+
95
+
96
+ def _generate_adr(spec, *, title: str | None = None, decision: str | None = None) -> str:
97
+ try:
98
+ return _llm_adr(spec, title=title, decision=decision)
99
+ except Exception:
100
+ return _deterministic_adr(spec, title=title, decision=decision)
101
+
102
+
103
+ def _llm_adr(spec, *, title: str | None, decision: str | None) -> str:
104
+ from cloudwright.architect import Architect
105
+
106
+ arch = Architect()
107
+ spec_summary = spec.model_dump_json(indent=2, exclude_none=True)
108
+
109
+ decision_hint = f"\nDocument this specific decision: {decision}" if decision else ""
110
+ title_hint = f"\nUse this ADR title: {title}" if title else ""
111
+ prompt = f"Generate an ADR for this architecture:{title_hint}{decision_hint}\n\n{spec_summary}"
112
+
113
+ text, _ = arch.llm.generate([{"role": "user", "content": prompt}], _ADR_SYSTEM, max_tokens=2000)
114
+ if not text.strip().startswith("#"):
115
+ raise ValueError("LLM did not return markdown ADR")
116
+ return text.strip()
117
+
118
+
119
+ def _deterministic_adr(spec, *, title: str | None = None, decision: str | None = None) -> str:
120
+ adr_title = title or spec.name
121
+ key_decision = decision or _infer_key_decision(spec)
122
+
123
+ lines = [
124
+ f"# ADR: {adr_title} — {key_decision}",
125
+ "",
126
+ "## Status",
127
+ "Proposed",
128
+ "",
129
+ "## Context",
130
+ _build_context(spec),
131
+ "",
132
+ "## Decision",
133
+ _build_decision(spec),
134
+ "",
135
+ "## Components",
136
+ "| ID | Service | Provider | Purpose |",
137
+ "|---|---|---|---|",
138
+ ]
139
+
140
+ for c in spec.components:
141
+ purpose = c.description or c.label
142
+ lines.append(f"| {c.id} | {c.service} | {c.provider} | {purpose} |")
143
+
144
+ lines += ["", "## Consequences"]
145
+ lines += _build_consequences(spec)
146
+
147
+ rationale = spec.metadata.get("rationale") or []
148
+ if rationale:
149
+ lines += ["", "## Alternatives Considered"]
150
+ for r in rationale:
151
+ if isinstance(r, dict):
152
+ lines.append(f"- **{r.get('decision', '')}**: {r.get('reason', '')}")
153
+
154
+ if spec.cost_estimate:
155
+ lines += [
156
+ "",
157
+ "## Cost Estimate",
158
+ f"Estimated monthly cost: ${spec.cost_estimate.monthly_total:,.2f} USD",
159
+ ]
160
+
161
+ return "\n".join(lines)
162
+
163
+
164
+ def _infer_key_decision(spec) -> str:
165
+ rationale = spec.metadata.get("rationale") or []
166
+ if rationale and isinstance(rationale[0], dict):
167
+ return rationale[0].get("decision", f"{spec.provider.upper()} architecture")
168
+ return f"{spec.provider.upper()} architecture"
169
+
170
+
171
+ def _build_context(spec) -> str:
172
+ parts = [f"This architecture, {spec.name!r}, targets the {spec.provider.upper()} platform in region {spec.region}."]
173
+ if spec.constraints:
174
+ if spec.constraints.compliance:
175
+ parts.append(f"Compliance requirements: {', '.join(spec.constraints.compliance)}.")
176
+ if spec.constraints.budget_monthly:
177
+ parts.append(f"Monthly budget constraint: ${spec.constraints.budget_monthly:,.0f}.")
178
+ parts.append(f"It consists of {len(spec.components)} components across {len(spec.connections)} connections.")
179
+ return " ".join(parts)
180
+
181
+
182
+ def _build_decision(spec) -> str:
183
+ rationale = spec.metadata.get("rationale") or []
184
+ if rationale:
185
+ items = []
186
+ for r in rationale:
187
+ if isinstance(r, dict):
188
+ items.append(f"- **{r.get('decision', '')}**: {r.get('reason', '')}")
189
+ if items:
190
+ return "\n".join(items)
191
+
192
+ services = ", ".join(c.service for c in spec.components[:5])
193
+ suffix = ", ..." if len(spec.components) > 5 else ""
194
+ return f"Selected architecture using: {services}{suffix}."
195
+
196
+
197
+ def _build_consequences(spec) -> list[str]:
198
+ lines = ["### Positive"]
199
+ suggestions = spec.metadata.get("suggestions") or []
200
+
201
+ positives = [
202
+ f"Established {spec.provider.upper()} native services reduce operational overhead.",
203
+ f"{len(spec.components)} components provide clear separation of concerns.",
204
+ ]
205
+ lines += [f"- {p}" for p in positives]
206
+
207
+ lines += ["", "### Negative"]
208
+ negatives = ["Vendor lock-in to selected provider and service tier."]
209
+ if suggestions:
210
+ negatives.append("Additional configuration required: " + suggestions[0].lower() + ".")
211
+ lines += [f"- {n}" for n in negatives]
212
+
213
+ return lines
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import json
6
5
  from typing import Annotated
7
6
 
8
7
  import typer
@@ -11,6 +10,7 @@ from rich.panel import Panel
11
10
  from rich.table import Table
12
11
  from rich.tree import Tree
13
12
 
13
+ from cloudwright_cli.output import emit_error, emit_success, is_json_mode
14
14
  from cloudwright_cli.utils import handle_error
15
15
 
16
16
  console = Console()
@@ -31,15 +31,17 @@ def analyze(
31
31
  if component:
32
32
  valid_ids = {c.id for c in spec.components}
33
33
  if component not in valid_ids:
34
- console.print(f"[red]Error:[/red] Component '{component}' not found in spec.")
35
- console.print(f"[dim]Available components: {', '.join(sorted(valid_ids))}[/dim]")
36
- raise typer.Exit(1)
34
+ emit_error(
35
+ ctx,
36
+ ValueError(f"Component '{component}' not found in spec"),
37
+ action=f"Available: {', '.join(sorted(valid_ids))}",
38
+ )
37
39
 
38
40
  analyzer = Analyzer()
39
41
  result = analyzer.analyze(spec, component_id=component)
40
42
 
41
- if ctx.obj and ctx.obj.get("json"):
42
- print(json.dumps(result.to_dict(), indent=2))
43
+ if is_json_mode(ctx):
44
+ emit_success(ctx, {"analysis": result.to_dict()})
43
45
  return
44
46
 
45
47
  spof_text = ", ".join(result.spofs) if result.spofs else "None"
@@ -8,6 +8,8 @@ from cloudwright import Catalog
8
8
  from rich.console import Console
9
9
  from rich.table import Table
10
10
 
11
+ from cloudwright_cli.output import emit_success, err_console
12
+
11
13
  console = Console()
12
14
 
13
15
  catalog_app = typer.Typer(
@@ -68,9 +70,7 @@ def catalog_search(
68
70
  # Resolve ctx.obj through parent chain when invoked via sub-app
69
71
  obj = ctx.obj or (ctx.parent.obj if ctx.parent else None)
70
72
  if obj and obj.get("json"):
71
- import json
72
-
73
- print(json.dumps({"results": results}, default=str))
73
+ emit_success(ctx, {"results": results})
74
74
  return
75
75
 
76
76
  if not results:
@@ -108,7 +108,7 @@ def catalog_compare(
108
108
  ) -> None:
109
109
  """Compare two or more cloud instances side by side."""
110
110
  if len(instances) < 2:
111
- console.print("[red]Error:[/red] Provide at least 2 instance names to compare.")
111
+ err_console.print("[red]Error:[/red] Provide at least 2 instance names to compare.")
112
112
  raise typer.Exit(1)
113
113
 
114
114
  with console.status("Fetching instance details..."):
@@ -118,10 +118,8 @@ def catalog_compare(
118
118
  # Resolve ctx.obj through parent chain when invoked via sub-app
119
119
  obj = ctx.obj or (ctx.parent.obj if ctx.parent else None)
120
120
  if obj and obj.get("json"):
121
- import json
122
-
123
121
  inst_map = {r.get("name", r.get("id", "")): r for r in results}
124
- print(json.dumps({"comparison": inst_map}, default=str))
122
+ emit_success(ctx, {"comparison": inst_map})
125
123
  return
126
124
 
127
125
  if not results:
@@ -8,17 +8,20 @@ from cloudwright import Architect, ArchSpec
8
8
  from rich.console import Console
9
9
  from rich.table import Table
10
10
 
11
+ from cloudwright_cli.output import emit_success, err_console, is_json_mode
12
+
11
13
  console = Console()
12
14
 
13
15
 
14
16
  def compare(
17
+ ctx: typer.Context,
15
18
  spec_file: Annotated[Path, typer.Argument(help="Path to spec YAML file", exists=True)],
16
19
  providers: Annotated[str, typer.Option(help="Comma-separated target providers")],
17
20
  ) -> None:
18
21
  """Compare an architecture across multiple cloud providers."""
19
22
  target_providers = [p.strip() for p in providers.split(",") if p.strip()]
20
23
  if not target_providers:
21
- console.print("[red]Error:[/red] --providers requires at least one provider")
24
+ err_console.print("[red]Error:[/red] --providers requires at least one provider")
22
25
  raise typer.Exit(1)
23
26
 
24
27
  spec = ArchSpec.from_file(spec_file)
@@ -32,6 +35,26 @@ def compare(
32
35
  if alt.spec:
33
36
  alt_map[alt.provider] = alt.spec
34
37
 
38
+ if is_json_mode(ctx):
39
+ origin_total = spec.cost_estimate.monthly_total if spec.cost_estimate else 0.0
40
+ data = {
41
+ "baseline": spec.provider,
42
+ "providers": {
43
+ spec.provider: {
44
+ "monthly_total": origin_total,
45
+ "components": [c.model_dump() for c in spec.components],
46
+ }
47
+ },
48
+ }
49
+ for alt in alts:
50
+ data["providers"][alt.provider] = {
51
+ "monthly_total": alt.monthly_total,
52
+ "key_differences": alt.key_differences,
53
+ "components": [c.model_dump() for c in alt.spec.components] if alt.spec else [],
54
+ }
55
+ emit_success(ctx, {"comparison": data})
56
+ return
57
+
35
58
  # Side-by-side service comparison
36
59
  table = Table(title=f"Provider Comparison — {spec.name}")
37
60
  table.add_column("Component", style="cyan")
@@ -9,6 +9,8 @@ from cloudwright.cost import CostEngine
9
9
  from rich.console import Console
10
10
  from rich.table import Table
11
11
 
12
+ from cloudwright_cli.output import emit_success, is_json_mode
13
+
12
14
  console = Console()
13
15
 
14
16
 
@@ -28,10 +30,8 @@ def cost(
28
30
  engine = CostEngine()
29
31
  spec.cost_estimate = engine.estimate(spec)
30
32
 
31
- if ctx.obj and ctx.obj.get("json"):
32
- import json
33
-
34
- print(json.dumps({"estimate": spec.cost_estimate.model_dump()}, default=str))
33
+ if is_json_mode(ctx):
34
+ emit_success(ctx, {"estimate": spec.cost_estimate.model_dump(exclude_none=True)})
35
35
  return
36
36
 
37
37
  if compare:
@@ -13,6 +13,8 @@ from rich.panel import Panel
13
13
  from rich.syntax import Syntax
14
14
  from rich.table import Table
15
15
 
16
+ from cloudwright_cli.output import emit_dry_run, emit_error, emit_success, is_json_mode
17
+
16
18
  console = Console()
17
19
 
18
20
 
@@ -35,24 +37,37 @@ def design(
35
37
  compliance=compliance or [],
36
38
  )
37
39
 
40
+ # Dry-run: show what the LLM call would look like
41
+ if ctx.obj and ctx.obj.get("dry_run"):
42
+ from cloudwright.architect import _build_constraint_prompt
43
+ from cloudwright.llm.anthropic import GENERATE_MODEL
44
+
45
+ system = Architect._select_system_prompt(description)
46
+ if constraints:
47
+ system += _build_constraint_prompt(constraints)
48
+ emit_dry_run(ctx, {
49
+ "model": GENERATE_MODEL,
50
+ "estimated_tokens": len(system + description) // 4,
51
+ "max_tokens": 10000,
52
+ "system_prompt_preview": system[:200],
53
+ "user_prompt_preview": description,
54
+ "constraints": constraints.model_dump(exclude_none=True),
55
+ })
56
+
38
57
  try:
39
58
  architect = Architect()
40
59
  except RuntimeError as e:
41
- console.print(f"[red]Error:[/red] {e}")
42
- raise typer.Exit(1) from None
60
+ emit_error(ctx, e, action="Set ANTHROPIC_API_KEY or OPENAI_API_KEY")
43
61
 
44
62
  with console.status("Designing architecture..."):
45
63
  spec = architect.design(description, constraints=constraints)
46
- # Set provider/region from CLI args if not overridden by LLM
47
64
  if spec.provider == "aws" and provider != "aws":
48
65
  spec = spec.model_copy(update={"provider": provider})
49
66
  if spec.region == "us-east-1" and region != "us-east-1":
50
67
  spec = spec.model_copy(update={"region": region})
51
68
 
52
- if ctx.obj and ctx.obj.get("json"):
53
- import json
54
-
55
- print(json.dumps(spec.model_dump(), default=str))
69
+ if is_json_mode(ctx):
70
+ emit_success(ctx, {"spec": spec.model_dump(exclude_none=True), "yaml": spec.to_yaml()})
56
71
  return
57
72
 
58
73
  yaml_str = spec.to_yaml()
@@ -9,6 +9,8 @@ from rich.console import Console
9
9
  from rich.rule import Rule
10
10
  from rich.table import Table
11
11
 
12
+ from cloudwright_cli.output import emit_success, is_json_mode
13
+
12
14
  console = Console()
13
15
 
14
16
 
@@ -24,10 +26,8 @@ def diff(
24
26
  with console.status("Computing diff..."):
25
27
  result = Differ().diff(a, b)
26
28
 
27
- if ctx.obj and ctx.obj.get("json"):
28
- import json
29
-
30
- print(json.dumps(result.model_dump(), default=str))
29
+ if is_json_mode(ctx):
30
+ emit_success(ctx, {"diff": result.model_dump(exclude_none=True)})
31
31
  return
32
32
 
33
33
  console.print(Rule(f"[bold]Diff: {spec_a.name} → {spec_b.name}[/bold]"))
@@ -11,6 +11,7 @@ from rich.panel import Panel
11
11
  from rich.rule import Rule
12
12
  from rich.table import Table
13
13
 
14
+ from cloudwright_cli.output import emit_success, err_console, is_json_mode
14
15
  from cloudwright_cli.utils import handle_error
15
16
 
16
17
  console = Console()
@@ -23,25 +24,22 @@ def drift(
23
24
  fmt: Annotated[
24
25
  str, typer.Option("--format", "-f", help="Infrastructure format: auto, terraform, cloudformation")
25
26
  ] = "auto",
26
- json_output: Annotated[bool, typer.Option("--json", help="Output as JSON")] = False,
27
27
  ) -> None:
28
28
  """Compare design spec against deployed infrastructure to detect drift."""
29
29
  try:
30
30
  from cloudwright.drift import detect_drift
31
31
 
32
32
  if not Path(spec_file).exists():
33
- console.print(f"[red]Error:[/red] Design spec not found: {spec_file}")
33
+ err_console.print(f"[red]Error:[/red] Design spec not found: {spec_file}")
34
34
  raise typer.Exit(1)
35
35
  if not Path(infra_file).exists():
36
- console.print(f"[red]Error:[/red] Infrastructure file not found: {infra_file}")
36
+ err_console.print(f"[red]Error:[/red] Infrastructure file not found: {infra_file}")
37
37
  raise typer.Exit(1)
38
38
 
39
39
  with console.status("Detecting drift..."):
40
40
  report = detect_drift(spec_file, infra_file, infra_format=fmt)
41
41
 
42
- if json_output:
43
- import json
44
-
42
+ if is_json_mode(ctx):
45
43
  result = {
46
44
  "drift_score": report.drift_score,
47
45
  "drifted_components": report.drifted_components,
@@ -50,7 +48,7 @@ def drift(
50
48
  "diff": report.diff.model_dump(),
51
49
  "summary": report.summary,
52
50
  }
53
- console.print_json(json.dumps(result, default=str))
51
+ emit_success(ctx, {"drift": result})
54
52
  return
55
53
 
56
54
  score_color = "green" if report.drift_score == 0 else "yellow" if report.drift_score < 0.3 else "red"
@@ -9,6 +9,8 @@ from cloudwright.exporter import FORMATS
9
9
  from rich.console import Console
10
10
  from rich.syntax import Syntax
11
11
 
12
+ from cloudwright_cli.output import emit_error, emit_success, is_json_mode, validate_output_path
13
+
12
14
  console = Console()
13
15
 
14
16
  _SYNTAX_MAP = {
@@ -40,8 +42,13 @@ def export(
40
42
  """Export an architecture spec to Terraform, CloudFormation, Mermaid, SVG, PNG, SBOM, or AIBOM."""
41
43
  fmt = format.lower().strip()
42
44
  if fmt not in FORMATS and fmt != "cfn":
43
- console.print(f"[red]Error:[/red] Unknown format {fmt!r}. Supported: {', '.join(FORMATS)}")
44
- raise typer.Exit(1)
45
+ emit_error(ctx, ValueError(f"Unknown format {fmt!r}"), action=f"Use one of: {', '.join(FORMATS)}")
46
+
47
+ if output:
48
+ try:
49
+ validate_output_path(output)
50
+ except ValueError as e:
51
+ emit_error(ctx, e)
45
52
 
46
53
  spec = ArchSpec.from_file(spec_file)
47
54
 
@@ -62,10 +69,11 @@ def export(
62
69
  from cloudwright.exporter.renderer import DiagramRenderer
63
70
 
64
71
  if not DiagramRenderer.is_available():
65
- console.print(
66
- "[red]Error:[/red] D2 binary not found. Install: curl -fsSL https://d2lang.com/install.sh | sh"
72
+ emit_error(
73
+ ctx,
74
+ RuntimeError("D2 binary not found"),
75
+ action="Install: curl -fsSL https://d2lang.com/install.sh | sh",
67
76
  )
68
- raise typer.Exit(1)
69
77
 
70
78
  with console.status("Rendering PNG via D2..."):
71
79
  data = DiagramRenderer().render_png(spec)
@@ -90,10 +98,8 @@ def export(
90
98
  with console.status(f"Exporting as {fmt}..."):
91
99
  content = spec.export(fmt, output=output_str, output_dir=output_dir_str)
92
100
 
93
- if ctx.obj and ctx.obj.get("json"):
94
- import json
95
-
96
- print(json.dumps({"format": fmt, "content": content}, default=str))
101
+ if is_json_mode(ctx):
102
+ emit_success(ctx, {"format": fmt, "content": content})
97
103
  return
98
104
 
99
105
  if output:
@@ -10,6 +10,7 @@ from typing import Annotated
10
10
  import typer
11
11
  from rich.console import Console
12
12
 
13
+ from cloudwright_cli.output import emit_success, is_json_mode, validate_output_path
13
14
  from cloudwright_cli.utils import handle_error
14
15
 
15
16
  console = Console()
@@ -45,15 +46,14 @@ def import_infra(
45
46
  if name:
46
47
  spec = spec.model_copy(update={"name": name})
47
48
 
48
- json_mode = ctx.obj and ctx.obj.get("json")
49
-
50
- if json_mode:
51
- print(json.dumps(json.loads(spec.to_json()), indent=2))
49
+ if is_json_mode(ctx):
50
+ emit_success(ctx, {"spec": json.loads(spec.to_json())})
52
51
  return
53
52
 
54
53
  content = spec.to_yaml()
55
54
 
56
55
  if output:
56
+ validate_output_path(output)
57
57
  Path(output).write_text(content)
58
58
  n_comps = len(spec.components)
59
59
  n_conns = len(spec.connections)
@@ -10,6 +10,7 @@ import yaml
10
10
  from rich.console import Console
11
11
  from rich.table import Table
12
12
 
13
+ from cloudwright_cli.output import validate_output_path
13
14
  from cloudwright_cli.utils import handle_error
14
15
 
15
16
  console = Console()
@@ -116,6 +117,7 @@ def init(
116
117
  (proj_dir / "config.yaml").write_text(yaml.dump(config, default_flow_style=False, sort_keys=False))
117
118
  else:
118
119
  output_path = Path(output)
120
+ validate_output_path(output_path)
119
121
 
120
122
  output_path.write_text(yaml.dump(spec_data, default_flow_style=False, sort_keys=False, allow_unicode=True))
121
123
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import json
6
5
  from pathlib import Path
7
6
  from typing import Annotated
8
7
 
@@ -11,6 +10,7 @@ from rich.console import Console
11
10
  from rich.table import Table
12
11
  from rich.text import Text
13
12
 
13
+ from cloudwright_cli.output import emit_stream, emit_success, is_json_mode, should_stream
14
14
  from cloudwright_cli.utils import handle_error
15
15
 
16
16
  console = Console()
@@ -19,7 +19,6 @@ console = Console()
19
19
  def lint(
20
20
  ctx: typer.Context,
21
21
  spec_file: Annotated[Path, typer.Argument(help="Architecture spec YAML file", exists=True)],
22
- output: Annotated[str, typer.Option(help="Output format: text, json")] = "text",
23
22
  strict: Annotated[bool, typer.Option(help="Fail on warnings too")] = False,
24
23
  ) -> None:
25
24
  """Detect architecture anti-patterns in a spec file."""
@@ -30,7 +29,17 @@ def lint(
30
29
  spec = ArchSpec.from_file(spec_file)
31
30
  warnings = run_lint(spec)
32
31
 
33
- if output == "json":
32
+ if is_json_mode(ctx):
33
+ if should_stream(ctx):
34
+ for w in warnings:
35
+ emit_stream({
36
+ "rule": w.rule,
37
+ "severity": w.severity,
38
+ "component": w.component,
39
+ "message": w.message,
40
+ "recommendation": w.recommendation,
41
+ })
42
+ return
34
43
  result = [
35
44
  {
36
45
  "rule": w.rule,
@@ -41,7 +50,7 @@ def lint(
41
50
  }
42
51
  for w in warnings
43
52
  ]
44
- print(json.dumps(result, indent=2))
53
+ emit_success(ctx, {"warnings": result})
45
54
  else:
46
55
  if not warnings:
47
56
  console.print(f"[green][PASS][/green] No anti-patterns detected in {spec.name}")
@@ -0,0 +1,34 @@
1
+ """Serve cloudwright functions as an MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+
10
+ def mcp_serve(
11
+ tools: Annotated[
12
+ str | None,
13
+ typer.Option("--tools", "-t", help="Comma-separated tool groups: design,cost,validate,analyze,export,session"),
14
+ ] = None,
15
+ transport: Annotated[str, typer.Option("--transport", help="Transport: stdio or sse")] = "stdio",
16
+ ) -> None:
17
+ """Start an MCP server exposing cloudwright tools."""
18
+ try:
19
+ from cloudwright_mcp.server import create_server
20
+ except ImportError:
21
+ from rich.console import Console
22
+
23
+ Console(stderr=True).print(
24
+ "[red]Error:[/red] cloudwright-ai-mcp not installed.\n"
25
+ " Install: pip install cloudwright-ai-mcp"
26
+ )
27
+ raise typer.Exit(1) from None
28
+
29
+ tool_set: set[str] | None = None
30
+ if tools:
31
+ tool_set = {t.strip() for t in tools.split(",") if t.strip()}
32
+
33
+ server = create_server(tools=tool_set)
34
+ server.run(transport=transport)