shieldops-cli 1.0.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.
@@ -0,0 +1,3 @@
1
+ """ShieldOps AI CLI — Security scanner for Docker, Kubernetes, Compose, SBOM, and more."""
2
+
3
+ __version__ = "1.0.0"
@@ -0,0 +1,102 @@
1
+ """Thin wrapper around /api/ext/run and other endpoints."""
2
+ from __future__ import annotations
3
+ import requests
4
+ from shieldops_cli import config
5
+
6
+ _TIMEOUT = 120 # seconds
7
+
8
+
9
+ class ApiError(Exception):
10
+ def __init__(self, status: int, code: str, message: str = ""):
11
+ self.status = status
12
+ self.code = code
13
+ self.message = message
14
+ super().__init__(f"[{status}] {code}: {message}")
15
+
16
+
17
+ class ShieldOpsClient:
18
+ def __init__(self, api_url: str | None = None, api_key: str | None = None):
19
+ self.api_url = (api_url or config.get_api_url()).rstrip("/")
20
+ self.api_key = api_key or config.get_api_key()
21
+
22
+ @property
23
+ def _headers(self) -> dict:
24
+ h = {"Content-Type": "application/json"}
25
+ if self.api_key:
26
+ h["X-ShieldOps-Extension-Key"] = self.api_key
27
+ return h
28
+
29
+ # ── Core: run task ──────────────────────────────────
30
+ def run_task(
31
+ self,
32
+ task: str,
33
+ content: str,
34
+ filename: str = "untitled",
35
+ options: dict | None = None,
36
+ ) -> dict:
37
+ """POST /api/ext/run — runs any supported task."""
38
+ body = {
39
+ "task": task,
40
+ "content": content,
41
+ "filename": filename,
42
+ }
43
+ if options:
44
+ body["options"] = options
45
+
46
+ resp = requests.post(
47
+ f"{self.api_url}/api/ext/run",
48
+ json=body,
49
+ headers=self._headers,
50
+ timeout=_TIMEOUT,
51
+ )
52
+ is_json = resp.headers.get("content-type", "").startswith("application/json")
53
+ data = resp.json() if is_json else {}
54
+
55
+ if resp.status_code == 403:
56
+ raise ApiError(403, data.get("error", "access_denied"),
57
+ "Invalid or missing API key. Run: shieldops login --key <YOUR_KEY>")
58
+ if resp.status_code == 429:
59
+ raise ApiError(429, "rate_limit",
60
+ f"Daily limit reached. Upgrade at {self.api_url}/pricing")
61
+ if resp.status_code == 413:
62
+ raise ApiError(413, "content_too_large", "File too large (max 250KB).")
63
+ if resp.status_code >= 400:
64
+ if not is_json:
65
+ raise ApiError(resp.status_code, "server_error",
66
+ f"Server error ({resp.status_code}) while generating report.")
67
+ raise ApiError(resp.status_code,
68
+ data.get("error", "unknown"),
69
+ data.get("details", resp.text[:200]))
70
+ return data
71
+
72
+ # ── Capabilities ────────────────────────────────────
73
+ def capabilities(self) -> dict:
74
+ """GET /api/ext/capabilities — returns plan features and limits."""
75
+ resp = requests.get(
76
+ f"{self.api_url}/api/ext/capabilities",
77
+ headers=self._headers,
78
+ timeout=30,
79
+ )
80
+ if resp.status_code != 200:
81
+ return {}
82
+ return resp.json()
83
+
84
+ # ── Account info ────────────────────────────────────
85
+ def whoami(self) -> dict:
86
+ """GET /api/ext/account — returns current user info."""
87
+ resp = requests.get(
88
+ f"{self.api_url}/api/ext/account",
89
+ headers=self._headers,
90
+ timeout=15,
91
+ )
92
+ if resp.status_code != 200:
93
+ return {"error": "not_authenticated"}
94
+ return resp.json()
95
+
96
+ # ── Health check ────────────────────────────────────
97
+ def ping(self) -> bool:
98
+ try:
99
+ resp = requests.get(f"{self.api_url}/api/ext/health", timeout=5)
100
+ return resp.status_code == 200
101
+ except Exception:
102
+ return False
shieldops_cli/auth.py ADDED
@@ -0,0 +1,64 @@
1
+ """Authentication commands."""
2
+ import click
3
+ from rich.console import Console
4
+ from shieldops_cli import config as cfg
5
+ from shieldops_cli.api_client import ShieldOpsClient
6
+
7
+ console = Console()
8
+
9
+
10
+ @click.command()
11
+ @click.option("--key", prompt="API Key", hide_input=True,
12
+ help="API key from https://shieldops-ai.onrender.com/settings/api-keys")
13
+ @click.option("--url", default=None, help="Override API base URL.")
14
+ def login(key, url):
15
+ """Authenticate with your ShieldOps API key."""
16
+ cfg.set_key("api_key", key.strip())
17
+ if url:
18
+ cfg.set_key("api_url", url.strip().rstrip("/"))
19
+
20
+ # Verify the key
21
+ client = ShieldOpsClient(api_key=key.strip())
22
+ info = client.whoami()
23
+ if "error" in info:
24
+ console.print("[yellow]\u26a0 Key saved but could not verify. Check it at:[/yellow]")
25
+ console.print(f" {cfg.get_api_url()}/settings/api-keys")
26
+ else:
27
+ plan = info.get("plan", "free").title()
28
+ name = info.get("name", info.get("email", "User"))
29
+ console.print(f"[green]\u2705 Authenticated as [bold]{name}[/bold] ({plan} plan)[/green]")
30
+
31
+
32
+ @click.command()
33
+ def logout():
34
+ """Remove stored credentials."""
35
+ cfg.set_key("api_key", "")
36
+ console.print("[green]\u2705 Logged out. API key removed.[/green]")
37
+
38
+
39
+ @click.command()
40
+ def whoami():
41
+ """Show current account info and plan."""
42
+ api_key = cfg.get_api_key()
43
+ if not api_key:
44
+ console.print("[yellow]Not authenticated. Run: shieldops login[/yellow]")
45
+ return
46
+
47
+ client = ShieldOpsClient()
48
+ info = client.whoami()
49
+ if "error" in info:
50
+ console.print("[red]Could not fetch account info. Check your API key.[/red]")
51
+ return
52
+
53
+ console.print(f"[bold]Name:[/bold] {info.get('name', '\u2014')}")
54
+ console.print(f"[bold]Email:[/bold] {info.get('email', '\u2014')}")
55
+ console.print(f"[bold]Plan:[/bold] {info.get('plan', 'free').title()}")
56
+
57
+ caps = client.capabilities()
58
+ limits = caps.get("limits", {})
59
+ used = limits.get("daily_ai_used", 0)
60
+ total = limits.get("daily_ai_requests")
61
+ if total:
62
+ console.print(f"[bold]Usage:[/bold] {used}/{total} requests today")
63
+ else:
64
+ console.print("[bold]Usage:[/bold] Unlimited")
@@ -0,0 +1 @@
1
+ """ShieldOps CLI commands."""
@@ -0,0 +1,107 @@
1
+ """shieldops analyze — Dockerfile analysis."""
2
+ import sys
3
+ import click
4
+ from pathlib import Path
5
+ from rich.console import Console
6
+
7
+ from shieldops_cli.api_client import ShieldOpsClient, ApiError
8
+ from shieldops_cli.formatters import format_result
9
+
10
+ console = Console()
11
+
12
+ SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
13
+
14
+
15
+ @click.command()
16
+ @click.argument("file", type=click.Path(exists=True))
17
+ @click.option("-f", "--format", "fmt", type=click.Choice(["table", "json", "sarif", "summary"]),
18
+ default=None, help="Output format. Default: from config or 'table'.")
19
+ @click.option("-o", "--output", type=click.Path(), default=None,
20
+ help="Write output to file instead of stdout.")
21
+ @click.option("--open-report", is_flag=True, default=False,
22
+ help="Open the full report in browser after scan.")
23
+ @click.option("--fail-on", type=click.Choice(["critical", "high", "medium", "low", "none"]),
24
+ default="none", help="Exit with code 1 if issues >= severity (for CI/CD).")
25
+ def analyze(file, fmt, output, open_report, fail_on):
26
+ """Analyze a Dockerfile for security and best-practice issues.
27
+
28
+ \b
29
+ Examples:
30
+ shieldops analyze Dockerfile
31
+ shieldops analyze Dockerfile --format json --output report.json
32
+ shieldops analyze Dockerfile --fail-on high # CI/CD gate
33
+ shieldops analyze Dockerfile --open-report
34
+ """
35
+ path = Path(file)
36
+ content = path.read_text(encoding="utf-8")
37
+ filename = path.name
38
+
39
+ client = ShieldOpsClient()
40
+
41
+ with console.status("[bold blue]Analyzing...", spinner="dots"):
42
+ try:
43
+ payload = client.run_task("analyze", content, filename)
44
+ except ApiError as e:
45
+ console.print(f"[red]Error: {e.message}[/red]")
46
+ sys.exit(2)
47
+
48
+ result = payload.get("result", {})
49
+
50
+ # ── Format output ──
51
+ formatted = format_result("analyze", result, fmt=fmt or "table")
52
+
53
+ if output:
54
+ Path(output).write_text(formatted, encoding="utf-8")
55
+ console.print(f"[green]\u2705 Report saved to {output}[/green]")
56
+ else:
57
+ console.print(formatted)
58
+
59
+ # ── Report URL (canonical: result.report_url → top-level route → scan_id fallback) ──
60
+ report_url = (
61
+ result.get("report_url")
62
+ or payload.get("route")
63
+ )
64
+ if not report_url:
65
+ scan_id = result.get("scan_id") or payload.get("scan_id") or ""
66
+ if scan_id:
67
+ report_url = f"/analyze/report_view?scan_id={scan_id}&origin=extension"
68
+
69
+ if report_url:
70
+ base = client.api_url
71
+ full_url = report_url if report_url.startswith("http") else f"{base}{report_url}"
72
+ print(f"\nFull report: {full_url}")
73
+
74
+ if open_report:
75
+ import webbrowser
76
+ webbrowser.open(full_url)
77
+ else:
78
+ print("\nNo report URL returned for this scan.")
79
+
80
+ # ── CI/CD exit code ──
81
+ if fail_on != "none":
82
+ if check_severity_gate(result, fail_on):
83
+ console.print(f"\n[red]\u274c Issues found at {fail_on.upper()} or above. Failing.[/red]")
84
+ sys.exit(1)
85
+
86
+
87
+ def check_severity_gate(result: dict, threshold: str) -> bool:
88
+ """Return True if any finding meets or exceeds threshold severity."""
89
+ threshold_level = SEVERITY_ORDER.get(threshold, 3)
90
+
91
+ # Try report_contract first
92
+ contract = result.get("report_contract", {})
93
+ if contract:
94
+ for sev in SEVERITY_ORDER:
95
+ if SEVERITY_ORDER[sev] <= threshold_level:
96
+ count = int(contract.get(f"{sev}_count", 0) or 0)
97
+ if count > 0:
98
+ return True
99
+ return False
100
+
101
+ # Fallback: check raw findings
102
+ findings = result.get("findings", result.get("results", []))
103
+ for f in findings:
104
+ sev = str(f.get("severity", "")).lower()
105
+ if SEVERITY_ORDER.get(sev, 99) <= threshold_level:
106
+ return True
107
+ return False
@@ -0,0 +1,64 @@
1
+ """shieldops autofix — AI-powered Dockerfile auto-fix."""
2
+ import sys
3
+ import click
4
+ from pathlib import Path
5
+ from rich.console import Console
6
+
7
+ from shieldops_cli.api_client import ShieldOpsClient, ApiError
8
+ from shieldops_cli.formatters import format_result
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.command()
14
+ @click.argument("file", type=click.Path(exists=True))
15
+ @click.option("-f", "--format", "fmt", type=click.Choice(["table", "json", "summary"]),
16
+ default=None, help="Output format.")
17
+ @click.option("-o", "--output", type=click.Path(), default=None,
18
+ help="Write output to file.")
19
+ @click.option("--apply", is_flag=True, default=False,
20
+ help="Apply the fix directly to the original file (creates .bak backup).")
21
+ def autofix(file, fmt, output, apply):
22
+ """Auto-fix a Dockerfile using AI.
23
+
24
+ \b
25
+ Examples:
26
+ shieldops autofix Dockerfile
27
+ shieldops autofix Dockerfile --format json -o fixed.json
28
+ shieldops autofix Dockerfile --apply
29
+ """
30
+ path = Path(file)
31
+ content = path.read_text(encoding="utf-8")
32
+
33
+ client = ShieldOpsClient()
34
+
35
+ with console.status("[bold blue]Generating fix...", spinner="dots"):
36
+ try:
37
+ payload = client.run_task("autofix", content, path.name)
38
+ except ApiError as e:
39
+ console.print(f"[red]Error: {e.message}[/red]")
40
+ sys.exit(2)
41
+
42
+ result = payload.get("result", {})
43
+ fixed_content = result.get("fixed_content", "")
44
+
45
+ if apply and fixed_content:
46
+ # Create backup
47
+ backup = path.with_suffix(path.suffix + ".bak")
48
+ backup.write_text(content, encoding="utf-8")
49
+ path.write_text(fixed_content, encoding="utf-8")
50
+ console.print(f"[green]\u2705 Fix applied to {path}. Backup at {backup}[/green]")
51
+ return
52
+
53
+ formatted = format_result("autofix", result, fmt=fmt or "table")
54
+
55
+ if output:
56
+ Path(output).write_text(formatted, encoding="utf-8")
57
+ console.print(f"[green]\u2705 Report saved to {output}[/green]")
58
+ else:
59
+ console.print(formatted)
60
+
61
+ report_url = result.get("report_url")
62
+ if report_url:
63
+ full_url = report_url if report_url.startswith("http") else f"{client.api_url}{report_url}"
64
+ print(f"\nFull report: {full_url}")
@@ -0,0 +1,54 @@
1
+ """shieldops compose-generate — Generate Docker Compose from Dockerfile."""
2
+ import sys
3
+ import click
4
+ from pathlib import Path
5
+ from rich.console import Console
6
+
7
+ from shieldops_cli.api_client import ShieldOpsClient, ApiError
8
+ from shieldops_cli.formatters import format_result
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.command("compose-generate")
14
+ @click.argument("file", type=click.Path(exists=True))
15
+ @click.option("-f", "--format", "fmt", type=click.Choice(["table", "json", "summary"]),
16
+ default=None, help="Output format.")
17
+ @click.option("-o", "--output", type=click.Path(), default=None,
18
+ help="Write generated compose to file.")
19
+ def compose_generate(file, fmt, output):
20
+ """Generate a Docker Compose file from a Dockerfile.
21
+
22
+ \b
23
+ Examples:
24
+ shieldops compose-generate Dockerfile
25
+ shieldops compose-generate Dockerfile -o docker-compose.yml
26
+ """
27
+ path = Path(file)
28
+ content = path.read_text(encoding="utf-8")
29
+
30
+ client = ShieldOpsClient()
31
+
32
+ with console.status("[bold blue]Generating Compose file...", spinner="dots"):
33
+ try:
34
+ payload = client.run_task("compose_generator", content, path.name)
35
+ except ApiError as e:
36
+ console.print(f"[red]Error: {e.message}[/red]")
37
+ sys.exit(2)
38
+
39
+ result = payload.get("result", {})
40
+ generated = result.get("compose_content", "")
41
+
42
+ if output and generated:
43
+ Path(output).write_text(generated, encoding="utf-8")
44
+ console.print(f"[green]\u2705 Compose file saved to {output}[/green]")
45
+ elif output:
46
+ formatted = format_result("compose_generator", result, fmt=fmt or "json")
47
+ Path(output).write_text(formatted, encoding="utf-8")
48
+ console.print(f"[green]\u2705 Output saved to {output}[/green]")
49
+ else:
50
+ if generated:
51
+ console.print(generated)
52
+ else:
53
+ formatted = format_result("compose_generator", result, fmt=fmt or "table")
54
+ console.print(formatted)
@@ -0,0 +1,55 @@
1
+ """shieldops compose-scan — Docker Compose file scanner."""
2
+ import sys
3
+ import click
4
+ from pathlib import Path
5
+ from rich.console import Console
6
+
7
+ from shieldops_cli.api_client import ShieldOpsClient, ApiError
8
+ from shieldops_cli.formatters import format_result
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.command("compose-scan")
14
+ @click.argument("file", type=click.Path(exists=True))
15
+ @click.option("-f", "--format", "fmt", type=click.Choice(["table", "json", "sarif", "summary"]),
16
+ default=None, help="Output format.")
17
+ @click.option("-o", "--output", type=click.Path(), default=None,
18
+ help="Write output to file.")
19
+ @click.option("--fail-on", type=click.Choice(["critical", "high", "medium", "low", "none"]),
20
+ default="none", help="Exit with code 1 if issues >= severity.")
21
+ def compose_scan(file, fmt, output, fail_on):
22
+ """Scan a Docker Compose file for security and configuration issues.
23
+
24
+ \b
25
+ Examples:
26
+ shieldops compose-scan docker-compose.yml
27
+ shieldops compose-scan docker-compose.yml --format json
28
+ shieldops compose-scan docker-compose.yml --fail-on high
29
+ """
30
+ path = Path(file)
31
+ content = path.read_text(encoding="utf-8")
32
+
33
+ client = ShieldOpsClient()
34
+
35
+ with console.status("[bold blue]Scanning Compose file...", spinner="dots"):
36
+ try:
37
+ payload = client.run_task("compose", content, path.name)
38
+ except ApiError as e:
39
+ console.print(f"[red]Error: {e.message}[/red]")
40
+ sys.exit(2)
41
+
42
+ result = payload.get("result", {})
43
+ formatted = format_result("compose", result, fmt=fmt or "table")
44
+
45
+ if output:
46
+ Path(output).write_text(formatted, encoding="utf-8")
47
+ console.print(f"[green]\u2705 Report saved to {output}[/green]")
48
+ else:
49
+ console.print(formatted)
50
+
51
+ if fail_on != "none":
52
+ from shieldops_cli.commands.analyze import check_severity_gate
53
+ if check_severity_gate(result, fail_on):
54
+ console.print(f"\n[red]\u274c Issues at {fail_on.upper()} or above. Failing.[/red]")
55
+ sys.exit(1)
@@ -0,0 +1,46 @@
1
+ """shieldops config — manage CLI settings."""
2
+ import click
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+ from shieldops_cli import config as cfg
6
+
7
+ console = Console()
8
+
9
+
10
+ @click.group("config")
11
+ def config_group():
12
+ """View or change CLI configuration."""
13
+ pass
14
+
15
+
16
+ @config_group.command("list")
17
+ def config_list():
18
+ """Show all configuration values."""
19
+ data = cfg.load()
20
+ table = Table(title="ShieldOps CLI Configuration")
21
+ table.add_column("Key", style="bold")
22
+ table.add_column("Value")
23
+ for k, v in sorted(data.items()):
24
+ display = "***" if k == "api_key" and v else str(v)
25
+ table.add_row(k, display)
26
+ console.print(table)
27
+
28
+
29
+ @config_group.command("set")
30
+ @click.argument("key")
31
+ @click.argument("value")
32
+ def config_set(key, value):
33
+ """Set a configuration value. E.g.: shieldops config set default_format json"""
34
+ cfg.set_key(key, value)
35
+ console.print(f"[green]\u2705 {key} = {value}[/green]")
36
+
37
+
38
+ @config_group.command("get")
39
+ @click.argument("key")
40
+ def config_get(key):
41
+ """Get a configuration value."""
42
+ val = cfg.get(key)
43
+ if val is None:
44
+ console.print(f"[yellow]Key '{key}' not found.[/yellow]")
45
+ else:
46
+ console.print(str(val))
@@ -0,0 +1,68 @@
1
+ """shieldops k8s-scan — Kubernetes manifest scanner."""
2
+ import sys
3
+ import click
4
+ from pathlib import Path
5
+ from rich.console import Console
6
+
7
+ from shieldops_cli.api_client import ShieldOpsClient, ApiError
8
+ from shieldops_cli.formatters import format_result
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.command("k8s-scan")
14
+ @click.argument("file", type=click.Path(exists=True))
15
+ @click.option("-f", "--format", "fmt", type=click.Choice(["table", "json", "sarif", "summary"]),
16
+ default=None, help="Output format.")
17
+ @click.option("-o", "--output", type=click.Path(), default=None,
18
+ help="Write output to file.")
19
+ @click.option("--open-report", is_flag=True, default=False,
20
+ help="Open the full report in browser.")
21
+ @click.option("--fail-on", type=click.Choice(["critical", "high", "medium", "low", "none"]),
22
+ default="none", help="Exit with code 1 if issues >= severity.")
23
+ def k8s_scan(file, fmt, output, open_report, fail_on):
24
+ """Scan a Kubernetes manifest (YAML) for misconfigurations.
25
+
26
+ \b
27
+ Examples:
28
+ shieldops k8s-scan deployment.yaml
29
+ shieldops k8s-scan k8s/ --format sarif --output k8s-report.sarif
30
+ shieldops k8s-scan pod.yaml --fail-on high
31
+ """
32
+ path = Path(file)
33
+ content = path.read_text(encoding="utf-8")
34
+
35
+ client = ShieldOpsClient()
36
+
37
+ with console.status("[bold blue]Scanning Kubernetes manifest...", spinner="dots"):
38
+ try:
39
+ payload = client.run_task("k8s", content, path.name)
40
+ except ApiError as e:
41
+ console.print(f"[red]Error: {e.message}[/red]")
42
+ sys.exit(2)
43
+
44
+ result = payload.get("result", {})
45
+ formatted = format_result("k8s", result, fmt=fmt or "table")
46
+
47
+ if output:
48
+ Path(output).write_text(formatted, encoding="utf-8")
49
+ console.print(f"[green]\u2705 Report saved to {output}[/green]")
50
+ else:
51
+ console.print(formatted)
52
+
53
+ report_url = result.get("report_url")
54
+ if report_url:
55
+ base = client.api_url
56
+ full_url = report_url if report_url.startswith("http") else f"{base}{report_url}"
57
+ print(f"\nFull report: {full_url}")
58
+
59
+ if open_report and report_url:
60
+ import webbrowser
61
+ full_url = report_url if report_url.startswith("http") else f"{client.api_url}{report_url}"
62
+ webbrowser.open(full_url)
63
+
64
+ if fail_on != "none":
65
+ from shieldops_cli.commands.analyze import check_severity_gate
66
+ if check_severity_gate(result, fail_on):
67
+ console.print(f"\n[red]\u274c Issues at {fail_on.upper()} or above. Failing.[/red]")
68
+ sys.exit(1)
@@ -0,0 +1,46 @@
1
+ """shieldops sbom — Generate Software Bill of Materials."""
2
+ import sys
3
+ import click
4
+ from pathlib import Path
5
+ from rich.console import Console
6
+
7
+ from shieldops_cli.api_client import ShieldOpsClient, ApiError
8
+ from shieldops_cli.formatters import format_result
9
+
10
+ console = Console()
11
+
12
+
13
+ @click.command()
14
+ @click.argument("file", type=click.Path(exists=True))
15
+ @click.option("-f", "--format", "fmt", type=click.Choice(["table", "json", "summary"]),
16
+ default=None, help="Output format.")
17
+ @click.option("-o", "--output", type=click.Path(), default=None,
18
+ help="Write output to file.")
19
+ def sbom(file, fmt, output):
20
+ """Generate SBOM (Software Bill of Materials) from a Dockerfile or dependency file.
21
+
22
+ \b
23
+ Examples:
24
+ shieldops sbom Dockerfile
25
+ shieldops sbom requirements.txt -f json -o sbom.json
26
+ """
27
+ path = Path(file)
28
+ content = path.read_text(encoding="utf-8")
29
+
30
+ client = ShieldOpsClient()
31
+
32
+ with console.status("[bold blue]Generating SBOM...", spinner="dots"):
33
+ try:
34
+ payload = client.run_task("sbom", content, path.name)
35
+ except ApiError as e:
36
+ console.print(f"[red]Error: {e.message}[/red]")
37
+ sys.exit(2)
38
+
39
+ result = payload.get("result", {})
40
+ formatted = format_result("sbom", result, fmt=fmt or "table")
41
+
42
+ if output:
43
+ Path(output).write_text(formatted, encoding="utf-8")
44
+ console.print(f"[green]\u2705 SBOM saved to {output}[/green]")
45
+ else:
46
+ console.print(formatted)