cloudcost-cli 0.1.0__py3-none-any.whl

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.
backend/app/cli.py ADDED
@@ -0,0 +1,726 @@
1
+ import argparse
2
+ import asyncio
3
+ import json
4
+ import os
5
+ import re
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import webbrowser
10
+ from dataclasses import dataclass
11
+ from decimal import Decimal
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from backend.app.comments import format_money, to_decimal
16
+ from backend.app.config import Settings
17
+ from backend.app.infracost import InfracostClient, InfracostEstimate
18
+
19
+
20
+ VERSION = "0.1.0"
21
+ DEFAULT_CONFIG_PATH = Path.home() / ".cloudcost" / "config.json"
22
+ DEFAULT_BACKEND_URL = "https://cloudcost.live"
23
+ DEFAULT_PLAN_NAMES = ("tfplan.json", "plan.json", "terraform-plan.json", "infracost-plan.json")
24
+ SKIPPED_DISCOVERY_DIRS = {
25
+ ".cloudcost",
26
+ ".git",
27
+ ".terraform",
28
+ ".venv",
29
+ "__pycache__",
30
+ "dist",
31
+ "node_modules",
32
+ }
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class ResourceCost:
37
+ name: str
38
+ resource_type: str
39
+ monthly_cost: Any
40
+
41
+
42
+ def config_path() -> Path:
43
+ raw_path = os.environ.get("CLOUDCOST_CONFIG")
44
+ return Path(raw_path).expanduser() if raw_path else DEFAULT_CONFIG_PATH
45
+
46
+
47
+ def read_config() -> dict[str, Any]:
48
+ path = config_path()
49
+ if not path.exists():
50
+ return {}
51
+ try:
52
+ payload = json.loads(path.read_text(encoding="utf-8"))
53
+ except (OSError, json.JSONDecodeError):
54
+ return {}
55
+ return payload if isinstance(payload, dict) else {}
56
+
57
+
58
+ def write_config(payload: dict[str, Any]) -> Path:
59
+ path = config_path()
60
+ path.parent.mkdir(parents=True, exist_ok=True)
61
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
62
+ return path
63
+
64
+
65
+ def normalize_backend_url(value: str) -> str:
66
+ value = value.strip()
67
+ if not value:
68
+ return DEFAULT_BACKEND_URL
69
+ if not value.startswith(("http://", "https://")):
70
+ value = "https://" + value
71
+ return value.rstrip("/")
72
+
73
+
74
+ def backend_url_from_args(args: argparse.Namespace) -> str:
75
+ config = read_config()
76
+ raw_url = (
77
+ getattr(args, "backend_url", None)
78
+ or config.get("backend_url")
79
+ or os.environ.get("CLOUDCOST_BACKEND_URL")
80
+ or os.environ.get("PUBLIC_BASE_URL")
81
+ or DEFAULT_BACKEND_URL
82
+ )
83
+ return normalize_backend_url(str(raw_url))
84
+
85
+
86
+ def github_install_url(args: argparse.Namespace) -> str:
87
+ return f"{backend_url_from_args(args)}/install/github"
88
+
89
+
90
+ def settings_from_args(args: argparse.Namespace) -> Settings:
91
+ config = read_config()
92
+ base = Settings()
93
+ updates: dict[str, Any] = {}
94
+
95
+ infracost_mode = getattr(args, "infracost_mode", None) or config.get("infracost_mode")
96
+ infracost_cli_path = getattr(args, "infracost_cli_path", None) or config.get("infracost_cli_path")
97
+ pricing_api_endpoint = getattr(args, "pricing_api_endpoint", None) or config.get("pricing_api_endpoint")
98
+ infracost_api_key = getattr(args, "infracost_api_key", None) or config.get("infracost_api_key")
99
+
100
+ if infracost_mode:
101
+ updates["infracost_mode"] = infracost_mode
102
+ if infracost_cli_path:
103
+ updates["infracost_cli_path"] = infracost_cli_path
104
+ if pricing_api_endpoint:
105
+ updates["infracost_pricing_api_endpoint"] = pricing_api_endpoint
106
+ if infracost_api_key:
107
+ updates["infracost_api_key"] = infracost_api_key
108
+
109
+ return base.model_copy(update=updates)
110
+
111
+
112
+ def ordered_plan_names(settings: Settings) -> list[str]:
113
+ names: list[str] = []
114
+ for name in (*DEFAULT_PLAN_NAMES, *sorted(settings.plan_names)):
115
+ normalized = name.strip().lower()
116
+ if normalized and normalized not in names:
117
+ names.append(normalized)
118
+ return names
119
+
120
+
121
+ def is_plan_json(path: Path, settings: Settings) -> bool:
122
+ name = path.name.lower()
123
+ return name in ordered_plan_names(settings) or name.endswith(settings.plan_suffixes)
124
+
125
+
126
+ def discover_plan_path(root: Path, settings: Settings, *, recursive: bool = False) -> Path | None:
127
+ root = root.expanduser().resolve()
128
+ if not root.exists() or not root.is_dir():
129
+ return None
130
+
131
+ for name in ordered_plan_names(settings):
132
+ candidate = root / name
133
+ if candidate.is_file():
134
+ return candidate
135
+
136
+ direct_matches = sorted(
137
+ path for path in root.iterdir() if path.is_file() and is_plan_json(path, settings)
138
+ )
139
+ if direct_matches:
140
+ return direct_matches[0]
141
+
142
+ if not recursive:
143
+ return None
144
+
145
+ for dirpath, dirnames, filenames in os.walk(root):
146
+ dirnames[:] = [
147
+ dirname
148
+ for dirname in dirnames
149
+ if dirname not in SKIPPED_DISCOVERY_DIRS and not dirname.startswith(".terraform")
150
+ ]
151
+ current = Path(dirpath)
152
+ for filename in sorted(filenames):
153
+ candidate = current / filename
154
+ if is_plan_json(candidate, settings):
155
+ return candidate
156
+ return None
157
+
158
+
159
+ def run_process(command: list[str], *, cwd: Path, timeout: int) -> subprocess.CompletedProcess[bytes]:
160
+ try:
161
+ return subprocess.run(
162
+ command,
163
+ cwd=str(cwd),
164
+ stdout=subprocess.PIPE,
165
+ stderr=subprocess.PIPE,
166
+ timeout=timeout,
167
+ check=False,
168
+ )
169
+ except FileNotFoundError as exc:
170
+ raise RuntimeError(f"Command not found: {command[0]}") from exc
171
+ except subprocess.TimeoutExpired as exc:
172
+ raise RuntimeError(f"Command timed out after {timeout} seconds: {' '.join(command)}") from exc
173
+
174
+
175
+ def parse_github_remote_url(remote_url: str) -> str | None:
176
+ remote_url = remote_url.strip()
177
+ patterns = [
178
+ r"^git@github\.com:(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?$",
179
+ r"^https?://github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$",
180
+ r"^ssh://git@github\.com/(?P<owner>[^/]+)/(?P<repo>[^/]+?)(?:\.git)?/?$",
181
+ ]
182
+ for pattern in patterns:
183
+ match = re.match(pattern, remote_url)
184
+ if match:
185
+ return f"{match.group('owner')}/{match.group('repo')}"
186
+ return None
187
+
188
+
189
+ def detect_github_repo(root: Path) -> str | None:
190
+ try:
191
+ process = run_process(
192
+ ["git", "remote", "get-url", "origin"],
193
+ cwd=root.expanduser().resolve(),
194
+ timeout=10,
195
+ )
196
+ except RuntimeError:
197
+ return None
198
+ if process.returncode != 0:
199
+ return None
200
+ remote_url = process.stdout.decode("utf-8", errors="replace").strip()
201
+ return parse_github_remote_url(remote_url)
202
+
203
+
204
+ def raise_for_process(process: subprocess.CompletedProcess[bytes], label: str) -> None:
205
+ if process.returncode == 0:
206
+ return
207
+ stderr = process.stderr.decode("utf-8", errors="replace").strip()
208
+ stdout = process.stdout.decode("utf-8", errors="replace").strip()
209
+ detail = stderr or stdout or f"exit code {process.returncode}"
210
+ raise RuntimeError(f"{label} failed: {detail[:2000]}")
211
+
212
+
213
+ def generate_terraform_plan_json(args: argparse.Namespace, root: Path) -> Path | None:
214
+ terraform_bin = getattr(args, "terraform_bin", None) or "terraform"
215
+ terraform_path = Path(terraform_bin)
216
+ if not terraform_path.exists() and shutil.which(terraform_bin) is None:
217
+ return None
218
+
219
+ root = root.expanduser().resolve()
220
+ output_dir = root / ".cloudcost"
221
+ output_dir.mkdir(parents=True, exist_ok=True)
222
+ binary_path = output_dir / "tfplan.binary"
223
+ json_path = output_dir / "tfplan.json"
224
+ timeout = int(getattr(args, "terraform_timeout", 600) or 600)
225
+
226
+ if not getattr(args, "skip_terraform_init", False) and not (root / ".terraform").exists():
227
+ print("No .terraform directory found. Running terraform init...", file=sys.stderr)
228
+ init_process = run_process(
229
+ [terraform_bin, "init", "-input=false"],
230
+ cwd=root,
231
+ timeout=timeout,
232
+ )
233
+ raise_for_process(init_process, "terraform init")
234
+
235
+ print("Generating Terraform plan JSON...", file=sys.stderr)
236
+ plan_process = run_process(
237
+ [terraform_bin, "plan", "-input=false", "-out", str(binary_path)],
238
+ cwd=root,
239
+ timeout=timeout,
240
+ )
241
+ raise_for_process(plan_process, "terraform plan")
242
+
243
+ show_process = run_process(
244
+ [terraform_bin, "show", "-json", str(binary_path)],
245
+ cwd=root,
246
+ timeout=timeout,
247
+ )
248
+ raise_for_process(show_process, "terraform show")
249
+ json_path.write_bytes(show_process.stdout)
250
+ return json_path
251
+
252
+
253
+ def extract_resource_costs(payload: dict[str, Any]) -> list[ResourceCost]:
254
+ resources: list[ResourceCost] = []
255
+ projects = payload.get("projects")
256
+ if not isinstance(projects, list):
257
+ projects = [payload]
258
+
259
+ for project in projects:
260
+ if not isinstance(project, dict):
261
+ continue
262
+ breakdown = project.get("breakdown")
263
+ candidates = []
264
+ if isinstance(breakdown, dict):
265
+ candidates.extend(breakdown.get("resources") or [])
266
+ candidates.extend(project.get("resources") or [])
267
+
268
+ for resource in candidates:
269
+ if not isinstance(resource, dict):
270
+ continue
271
+ name = str(
272
+ resource.get("name")
273
+ or resource.get("resourceName")
274
+ or resource.get("address")
275
+ or resource.get("resourceType")
276
+ or "unknown"
277
+ )
278
+ resource_type = str(resource.get("resourceType") or resource.get("type") or "")
279
+ monthly_cost = (
280
+ resource.get("monthlyCost")
281
+ or resource.get("totalMonthlyCost")
282
+ or resource.get("cost")
283
+ or resource.get("monthly_cost")
284
+ )
285
+ resources.append(
286
+ ResourceCost(
287
+ name=name,
288
+ resource_type=resource_type,
289
+ monthly_cost=monthly_cost,
290
+ )
291
+ )
292
+
293
+ return resources
294
+
295
+
296
+ def sort_resources(resources: list[ResourceCost]) -> list[ResourceCost]:
297
+ return sorted(
298
+ resources,
299
+ key=lambda item: abs(to_decimal(item.monthly_cost) or Decimal("0")),
300
+ reverse=True,
301
+ )
302
+
303
+
304
+ def estimate_summary(estimate: InfracostEstimate, plan_path: Path) -> dict[str, Any]:
305
+ resources = extract_resource_costs(estimate.raw)
306
+ return {
307
+ "plan": str(plan_path),
308
+ "previous_monthly_cost": estimate.past_total_monthly_cost,
309
+ "proposed_monthly_cost": estimate.total_monthly_cost,
310
+ "diff_total_monthly_cost": estimate.diff_total_monthly_cost,
311
+ "resources": [
312
+ {
313
+ "name": item.name,
314
+ "resource_type": item.resource_type,
315
+ "monthly_cost": item.monthly_cost,
316
+ }
317
+ for item in sort_resources(resources)
318
+ ],
319
+ }
320
+
321
+
322
+ def terminal_report(summary: dict[str, Any], limit: int = 12) -> str:
323
+ rows = summary["resources"][:limit]
324
+ lines = [
325
+ "CloudCost AI analyze",
326
+ "",
327
+ f"Plan: {summary['plan']}",
328
+ "",
329
+ "Monthly estimate",
330
+ "----------------",
331
+ f"Previous: {format_money(summary['previous_monthly_cost'])}",
332
+ f"Proposed: {format_money(summary['proposed_monthly_cost'])}",
333
+ f"Delta: {format_money(summary['diff_total_monthly_cost'], signed=True)}",
334
+ ]
335
+
336
+ if rows:
337
+ lines.extend(["", "Largest resources", "-----------------"])
338
+ width = min(max(len(item["name"]) for item in rows), 56)
339
+ for item in rows:
340
+ name = item["name"]
341
+ if len(name) > width:
342
+ name = name[: width - 1] + "."
343
+ lines.append(f"{name:<{width}} {format_money(item['monthly_cost'])}")
344
+ else:
345
+ lines.extend(["", "No resource-level costs were returned."])
346
+
347
+ lines.extend(
348
+ [
349
+ "",
350
+ "Next steps",
351
+ "----------",
352
+ "Use this in CI, or run `cloudcost connect-github` so CloudCost comments on pull requests automatically.",
353
+ ]
354
+ )
355
+ return "\n".join(lines)
356
+
357
+
358
+ def markdown_report(summary: dict[str, Any], limit: int = 12) -> str:
359
+ lines = [
360
+ "## CloudCost AI estimate",
361
+ "",
362
+ "| Metric | Estimate |",
363
+ "| --- | ---: |",
364
+ f"| Previous monthly cost | {format_money(summary['previous_monthly_cost'])} |",
365
+ f"| Proposed monthly cost | {format_money(summary['proposed_monthly_cost'])} |",
366
+ f"| Diff total monthly cost | {format_money(summary['diff_total_monthly_cost'], signed=True)} |",
367
+ "",
368
+ ]
369
+ rows = summary["resources"][:limit]
370
+ if rows:
371
+ lines.extend(["| Resource | Monthly |", "| --- | ---: |"])
372
+ for item in rows:
373
+ lines.append(f"| `{item['name']}` | {format_money(item['monthly_cost'])} |")
374
+ lines.append("")
375
+ lines.append(f"Terraform plan JSON: `{summary['plan']}`")
376
+ return "\n".join(lines)
377
+
378
+
379
+ async def run_analyze(args: argparse.Namespace) -> int:
380
+ settings = settings_from_args(args)
381
+ plan_value = getattr(args, "plan", None)
382
+ if plan_value:
383
+ plan_path = Path(plan_value).expanduser()
384
+ else:
385
+ plan_path = discover_plan_path(
386
+ Path(getattr(args, "path", ".")).expanduser(),
387
+ settings,
388
+ recursive=bool(getattr(args, "recursive", False)),
389
+ )
390
+ if plan_path is None:
391
+ print(
392
+ "No Terraform plan JSON found. Pass --plan, or run `cloudcost` to generate one automatically.",
393
+ file=sys.stderr,
394
+ )
395
+ return 1
396
+
397
+ if not plan_path.exists():
398
+ print(f"Plan file not found: {plan_path}", file=sys.stderr)
399
+ return 1
400
+
401
+ try:
402
+ estimate = await InfracostClient(settings).estimate_plan_json(
403
+ plan_path.read_bytes(),
404
+ filename=plan_path.name,
405
+ )
406
+ except Exception as exc:
407
+ print(f"CloudCost could not estimate the plan: {exc}", file=sys.stderr)
408
+ return 1
409
+
410
+ summary = estimate_summary(estimate, plan_path)
411
+
412
+ if args.format == "json":
413
+ output = json.dumps(summary, indent=2)
414
+ elif args.format == "markdown":
415
+ output = markdown_report(summary, limit=args.limit)
416
+ else:
417
+ output = terminal_report(summary, limit=args.limit)
418
+
419
+ if args.out:
420
+ Path(args.out).expanduser().write_text(output + "\n", encoding="utf-8")
421
+ else:
422
+ print(output)
423
+
424
+ if args.fail_on_increase is not None:
425
+ diff = to_decimal(summary["diff_total_monthly_cost"]) or Decimal("0")
426
+ threshold = Decimal(str(args.fail_on_increase))
427
+ if diff > threshold:
428
+ print(
429
+ f"Cost increase {format_money(diff, signed=True)} exceeds threshold {format_money(threshold)}.",
430
+ file=sys.stderr,
431
+ )
432
+ return 2
433
+ return 0
434
+
435
+
436
+ async def run_one_command(args: argparse.Namespace) -> int:
437
+ settings = settings_from_args(args)
438
+ root = Path(getattr(args, "path", ".")).expanduser()
439
+ plan_value = getattr(args, "plan", None)
440
+ plan_path = Path(plan_value).expanduser() if plan_value else None
441
+
442
+ if plan_path is None:
443
+ plan_path = discover_plan_path(
444
+ root,
445
+ settings,
446
+ recursive=bool(getattr(args, "recursive", False)),
447
+ )
448
+ if plan_path:
449
+ print(f"Using Terraform plan JSON: {plan_path}", file=sys.stderr)
450
+
451
+ if plan_path is None and not getattr(args, "no_terraform", False):
452
+ try:
453
+ plan_path = generate_terraform_plan_json(args, root)
454
+ if plan_path:
455
+ print(f"Generated Terraform plan JSON: {plan_path}", file=sys.stderr)
456
+ except Exception as exc:
457
+ print(f"CloudCost could not generate a Terraform plan: {exc}", file=sys.stderr)
458
+ return 1
459
+
460
+ if plan_path is None:
461
+ print(
462
+ "No Terraform plan JSON found and Terraform could not be auto-run.",
463
+ file=sys.stderr,
464
+ )
465
+ print(
466
+ "From a Terraform project, run `cloudcost`, or pass an existing file with `cloudcost --plan tfplan.json`.",
467
+ file=sys.stderr,
468
+ )
469
+ return 1
470
+
471
+ args.plan = str(plan_path)
472
+ return await run_analyze(args)
473
+
474
+
475
+ def run_connect_github(args: argparse.Namespace) -> int:
476
+ install_url = github_install_url(args)
477
+ repo = getattr(args, "repo", None) or detect_github_repo(Path(getattr(args, "path", ".")))
478
+
479
+ print("CloudCost GitHub automation")
480
+ print("")
481
+ print(f"Install URL: {install_url}")
482
+ if repo:
483
+ print(f"Detected repository: {repo}")
484
+ print("When GitHub opens, choose that repository for installation.")
485
+ else:
486
+ print("When GitHub opens, choose the repository that should receive CloudCost PR comments.")
487
+ print("")
488
+ print("Permissions GitHub will ask for:")
489
+ print(" contents: read")
490
+ print(" metadata: read")
491
+ print(" issues: write")
492
+ print(" pull_requests: write")
493
+ print(" events: pull_request")
494
+
495
+ if getattr(args, "no_open", False):
496
+ return 0
497
+
498
+ if webbrowser.open(install_url):
499
+ print("")
500
+ print("Opened the GitHub App install flow in your browser.")
501
+ return 0
502
+
503
+ print("")
504
+ print("Could not open a browser automatically. Open the Install URL above.")
505
+ return 0
506
+
507
+
508
+ async def run_go(args: argparse.Namespace) -> int:
509
+ print("CloudCost go")
510
+ print("")
511
+ estimate_code = 0
512
+ if not getattr(args, "skip_estimate", False):
513
+ estimate_code = await run_one_command(args)
514
+ print("")
515
+ if estimate_code != 0:
516
+ print("Local estimate did not complete. You can still connect GitHub automation.")
517
+ if getattr(args, "require_estimate", False):
518
+ return estimate_code
519
+
520
+ return run_connect_github(args)
521
+
522
+
523
+ def run_setup(args: argparse.Namespace) -> int:
524
+ payload = read_config()
525
+ if args.backend_url:
526
+ payload["backend_url"] = args.backend_url.rstrip("/")
527
+ if args.pricing_api_endpoint:
528
+ payload["pricing_api_endpoint"] = args.pricing_api_endpoint.rstrip("/")
529
+ if args.infracost_cli_path:
530
+ payload["infracost_cli_path"] = args.infracost_cli_path
531
+ if args.infracost_mode:
532
+ payload["infracost_mode"] = args.infracost_mode
533
+
534
+ path = write_config(payload)
535
+ print(f"CloudCost config written to {path}")
536
+ print("")
537
+ print("Try your first estimate:")
538
+ print(" cd path/to/terraform-project")
539
+ print(" cloudcost")
540
+ return 0
541
+
542
+
543
+ def run_doctor(args: argparse.Namespace) -> int:
544
+ settings = settings_from_args(args)
545
+ config = read_config()
546
+ cli_path = Path(settings.infracost_cli_path)
547
+ cli_found = cli_path.exists() or shutil.which(settings.infracost_cli_path) is not None
548
+
549
+ checks = [
550
+ ("config", "found" if config else "missing", str(config_path())),
551
+ ("infracost_cli", "ready" if cli_found else "missing", settings.infracost_cli_path),
552
+ (
553
+ "pricing_api",
554
+ "configured" if settings.infracost_pricing_api_endpoint else "missing",
555
+ settings.infracost_pricing_api_endpoint or "set INFRACOST_PRICING_API_ENDPOINT",
556
+ ),
557
+ ("mode", settings.infracost_mode, "cli is recommended for local plan JSON"),
558
+ ]
559
+
560
+ print("CloudCost doctor")
561
+ print("")
562
+ for label, state, detail in checks:
563
+ print(f"{label:<14} {state:<12} {detail}")
564
+ return 0 if cli_found else 1
565
+
566
+
567
+ def add_estimate_options(parser: argparse.ArgumentParser, *, plan_required: bool) -> None:
568
+ parser.add_argument("--plan", required=plan_required, help="Path to Terraform plan JSON.")
569
+ parser.add_argument("--format", choices=["terminal", "json", "markdown"], default="terminal")
570
+ parser.add_argument("--out", help="Write output to a file instead of stdout.")
571
+ parser.add_argument("--limit", type=int, default=12, help="Number of resources to show.")
572
+ parser.add_argument(
573
+ "--fail-on-increase",
574
+ type=Decimal,
575
+ help="Exit 2 if monthly increase is above this amount.",
576
+ )
577
+ parser.add_argument("--infracost-cli-path", help="Path to the infracost binary.")
578
+ parser.add_argument("--pricing-api-endpoint", help="Infracost pricing API endpoint.")
579
+ parser.add_argument("--infracost-mode", choices=["cli", "api"])
580
+ parser.add_argument("--infracost-api-key", help="Infracost or CloudCost pricing API key.")
581
+
582
+
583
+ def add_one_command_options(parser: argparse.ArgumentParser) -> None:
584
+ add_estimate_options(parser, plan_required=False)
585
+ parser.add_argument(
586
+ "--path",
587
+ default=".",
588
+ help="Terraform project directory to scan or run from. Defaults to the current directory.",
589
+ )
590
+ parser.add_argument(
591
+ "--recursive",
592
+ action="store_true",
593
+ help="Search child directories for an existing Terraform plan JSON.",
594
+ )
595
+ parser.add_argument(
596
+ "--no-terraform",
597
+ action="store_true",
598
+ help="Do not auto-run terraform when no plan JSON is found.",
599
+ )
600
+ parser.add_argument("--terraform-bin", default="terraform", help="Terraform executable path.")
601
+ parser.add_argument(
602
+ "--skip-terraform-init",
603
+ action="store_true",
604
+ help="Skip automatic terraform init when .terraform is missing.",
605
+ )
606
+ parser.add_argument(
607
+ "--terraform-timeout",
608
+ type=int,
609
+ default=600,
610
+ help="Seconds to allow each terraform command.",
611
+ )
612
+
613
+
614
+ def add_github_connect_options(parser: argparse.ArgumentParser) -> None:
615
+ parser.add_argument(
616
+ "--backend-url",
617
+ help=(
618
+ "CloudCost backend URL that hosts /install/github. Defaults to saved config, "
619
+ "CLOUDCOST_BACKEND_URL, PUBLIC_BASE_URL, or https://cloudcost.live."
620
+ ),
621
+ )
622
+ parser.add_argument(
623
+ "--path",
624
+ default=".",
625
+ help="Repository directory used to detect the GitHub remote. Defaults to the current directory.",
626
+ )
627
+ parser.add_argument("--repo", help="Repository hint to show after opening GitHub, for example acme/infra.")
628
+ parser.add_argument("--no-open", action="store_true", help="Print the install URL without opening a browser.")
629
+
630
+
631
+ def add_go_options(parser: argparse.ArgumentParser) -> None:
632
+ add_one_command_options(parser)
633
+ parser.add_argument(
634
+ "--backend-url",
635
+ help=(
636
+ "CloudCost backend URL that hosts /install/github. Defaults to saved config, "
637
+ "CLOUDCOST_BACKEND_URL, PUBLIC_BASE_URL, or https://cloudcost.live."
638
+ ),
639
+ )
640
+ parser.add_argument("--repo", help="Repository hint to show after opening GitHub, for example acme/infra.")
641
+ parser.add_argument("--no-open", action="store_true", help="Print the install URL without opening a browser.")
642
+ parser.add_argument(
643
+ "--skip-estimate",
644
+ action="store_true",
645
+ help="Skip the local estimate and only open the GitHub App install flow.",
646
+ )
647
+ parser.add_argument(
648
+ "--require-estimate",
649
+ action="store_true",
650
+ help="Stop before GitHub install if the local estimate fails.",
651
+ )
652
+
653
+
654
+ def build_parser() -> argparse.ArgumentParser:
655
+ parser = argparse.ArgumentParser(
656
+ prog="cloudcost",
657
+ description=(
658
+ "CloudCost AI CLI. Run `cloudcost` in a Terraform project for the one-command "
659
+ "cost estimate path."
660
+ ),
661
+ )
662
+ parser.add_argument("--version", action="version", version=f"cloudcost {VERSION}")
663
+ add_one_command_options(parser)
664
+ parser.set_defaults(func=lambda parsed: asyncio.run(run_one_command(parsed)))
665
+ subparsers = parser.add_subparsers(dest="command")
666
+
667
+ run_parser = subparsers.add_parser(
668
+ "run",
669
+ help="One-command path: find or generate Terraform plan JSON, then estimate it.",
670
+ )
671
+ add_one_command_options(run_parser)
672
+ run_parser.set_defaults(func=lambda parsed: asyncio.run(run_one_command(parsed)))
673
+
674
+ go_parser = subparsers.add_parser(
675
+ "go",
676
+ help="Run the local estimate, then open the GitHub App install flow.",
677
+ )
678
+ add_go_options(go_parser)
679
+ go_parser.set_defaults(func=lambda parsed: asyncio.run(run_go(parsed)))
680
+
681
+ connect_parser = subparsers.add_parser(
682
+ "connect-github",
683
+ help="Open the GitHub App manifest install flow for this CloudCost backend.",
684
+ )
685
+ add_github_connect_options(connect_parser)
686
+ connect_parser.set_defaults(func=run_connect_github)
687
+
688
+ setup_parser = subparsers.add_parser("setup", help="Write local CloudCost CLI configuration.")
689
+ setup_parser.add_argument("--backend-url", help="CloudCost backend URL, for example https://cloudcost.live.")
690
+ setup_parser.add_argument("--pricing-api-endpoint", help="Infracost pricing API endpoint.")
691
+ setup_parser.add_argument("--infracost-cli-path", help="Path to the infracost binary.")
692
+ setup_parser.add_argument("--infracost-mode", choices=["cli", "api"], default="cli")
693
+ setup_parser.set_defaults(func=run_setup)
694
+
695
+ doctor_parser = subparsers.add_parser("doctor", help="Check local CLI prerequisites.")
696
+ doctor_parser.add_argument("--infracost-cli-path", help="Path to the infracost binary.")
697
+ doctor_parser.add_argument("--pricing-api-endpoint", help="Infracost pricing API endpoint.")
698
+ doctor_parser.add_argument("--infracost-mode", choices=["cli", "api"])
699
+ doctor_parser.add_argument("--infracost-api-key", help="Infracost or CloudCost pricing API key.")
700
+ doctor_parser.set_defaults(func=run_doctor)
701
+
702
+ analyze_parser = subparsers.add_parser("analyze", help="Estimate a Terraform plan JSON file.")
703
+ add_estimate_options(analyze_parser, plan_required=False)
704
+ analyze_parser.add_argument(
705
+ "--path",
706
+ default=".",
707
+ help="Directory to scan when --plan is omitted. Defaults to the current directory.",
708
+ )
709
+ analyze_parser.add_argument(
710
+ "--recursive",
711
+ action="store_true",
712
+ help="Search child directories when --plan is omitted.",
713
+ )
714
+ analyze_parser.set_defaults(func=lambda parsed: asyncio.run(run_analyze(parsed)))
715
+
716
+ return parser
717
+
718
+
719
+ def main(argv: list[str] | None = None) -> int:
720
+ parser = build_parser()
721
+ args = parser.parse_args(argv)
722
+ return int(args.func(args))
723
+
724
+
725
+ if __name__ == "__main__":
726
+ raise SystemExit(main())