prismor-cli 1.3.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.
prismor/cli.py ADDED
@@ -0,0 +1,1305 @@
1
+ """Command-line interface for Prismor security scanning tool."""
2
+
3
+ import sys
4
+ import json
5
+ import os
6
+ import click
7
+ import threading
8
+ import time
9
+ import subprocess
10
+ from typing import Optional
11
+ from . import __version__
12
+ from .api import PrismorClient, PrismorAPIError, parse_github_repo, DEFAULT_SCAN_POLL_INTERVAL_SECONDS, DEFAULT_SCAN_MAX_WAIT_SECONDS
13
+ from .sanitize import strip_sensitive, build_fix_prompt
14
+
15
+ try:
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+ from rich.panel import Panel
19
+ from rich.live import Live
20
+ from rich.spinner import Spinner as RichSpinner
21
+ from rich.text import Text
22
+ from rich import box
23
+ RICH_AVAILABLE = True
24
+ except ImportError:
25
+ RICH_AVAILABLE = False
26
+
27
+ console = Console() if RICH_AVAILABLE else None
28
+
29
+
30
+ def detect_git_branch() -> Optional[str]:
31
+ """Auto-detect the current git branch if running inside a git repository."""
32
+ try:
33
+ result = subprocess.run(
34
+ ['git', 'rev-parse', '--abbrev-ref', 'HEAD'],
35
+ capture_output=True, text=True, timeout=5
36
+ )
37
+ if result.returncode == 0:
38
+ branch = result.stdout.strip()
39
+ if branch and branch != 'HEAD':
40
+ return branch
41
+ except Exception:
42
+ pass
43
+ return None
44
+
45
+
46
+ def print_success(message: str):
47
+ click.secho(f"✓ {message}", fg="green")
48
+
49
+
50
+ def print_error(message: str):
51
+ click.secho(f"✗ {message}", fg="red", err=True)
52
+
53
+
54
+ def print_info(message: str):
55
+ click.secho(f"ℹ {message}", fg="blue")
56
+
57
+
58
+ def print_warning(message: str):
59
+ click.secho(f"⚠ {message}", fg="yellow")
60
+
61
+
62
+ class Spinner:
63
+ """Simple spinner for showing loading state."""
64
+
65
+ def __init__(self, message: str = "Processing"):
66
+ self.message = message
67
+ self.spinner_chars = "|/-\\"
68
+ self.spinner_index = 0
69
+ self.running = False
70
+ self.thread = None
71
+
72
+ def _spin(self):
73
+ while self.running:
74
+ char = self.spinner_chars[self.spinner_index % len(self.spinner_chars)]
75
+ sys.stdout.write(f"\r{char} {self.message}...")
76
+ sys.stdout.flush()
77
+ self.spinner_index += 1
78
+ time.sleep(0.1)
79
+
80
+ def start(self):
81
+ self.running = True
82
+ self.thread = threading.Thread(target=self._spin, daemon=True)
83
+ self.thread.start()
84
+
85
+ def stop(self, message: str = None):
86
+ self.running = False
87
+ if self.thread:
88
+ self.thread.join(timeout=0.2)
89
+ sys.stdout.write("\r" + " " * (len(self.message) + 15) + "\r")
90
+ sys.stdout.flush()
91
+ if message:
92
+ click.echo(message)
93
+
94
+
95
+ def should_show_spinner() -> bool:
96
+ """Show spinners only in interactive terminals, never in CI logs."""
97
+ if os.environ.get("PRISMOR_NO_SPINNER", "").strip().lower() in {"1", "true", "yes", "on"}:
98
+ return False
99
+ if os.environ.get("GITHUB_ACTIONS", "").strip().lower() == "true":
100
+ return False
101
+ if os.environ.get("CI", "").strip().lower() == "true":
102
+ return False
103
+ return sys.stdout.isatty()
104
+
105
+
106
+ # Severity display config
107
+ _SEVERITY_COLORS = {
108
+ "CRITICAL": "bold red",
109
+ "HIGH": "red",
110
+ "MEDIUM": "yellow",
111
+ "LOW": "blue",
112
+ "UNKNOWN": "dim",
113
+ }
114
+
115
+ _SEVERITY_CLICK_COLORS = {
116
+ "CRITICAL": ("red", True),
117
+ "HIGH": ("red", False),
118
+ "MEDIUM": ("yellow", False),
119
+ "LOW": ("blue", False),
120
+ "UNKNOWN": (None, False),
121
+ }
122
+
123
+ MAX_VULN_ROWS = 5
124
+
125
+
126
+ def _render_vuln_table_rich(vulns: list, dashboard_url: Optional[str] = None):
127
+ """Render a Rich vulnerability table capped at MAX_VULN_ROWS."""
128
+ table = Table(box=box.ROUNDED, show_header=True, header_style="bold cyan", expand=False)
129
+ table.add_column("Severity", min_width=10)
130
+ table.add_column("CVE / ID", min_width=20)
131
+ table.add_column("Package", min_width=18)
132
+ table.add_column("Installed", min_width=12)
133
+ table.add_column("Fixed In", min_width=12)
134
+
135
+ shown = vulns[:MAX_VULN_ROWS]
136
+ for v in shown:
137
+ sev = (v.get("Severity") or v.get("severity") or "UNKNOWN").upper()
138
+ style = _SEVERITY_COLORS.get(sev, "dim")
139
+ cve = v.get("VulnerabilityID") or v.get("id") or "—"
140
+ pkg = v.get("PkgName") or v.get("package") or v.get("pkg") or "—"
141
+ installed = v.get("InstalledVersion") or v.get("installed_version") or "—"
142
+ fixed = v.get("FixedVersion") or v.get("fixed_version") or "—"
143
+ table.add_row(
144
+ Text(sev, style=style),
145
+ cve,
146
+ pkg,
147
+ installed,
148
+ fixed,
149
+ )
150
+
151
+ console.print(table)
152
+
153
+ remaining = len(vulns) - MAX_VULN_ROWS
154
+ if remaining > 0:
155
+ msg = f" ... and {remaining} more"
156
+ if dashboard_url:
157
+ msg += f" — view full report: {dashboard_url}"
158
+ click.secho(msg, fg="cyan")
159
+
160
+
161
+ def _render_vuln_table_plain(vulns: list, dashboard_url: Optional[str] = None):
162
+ """Fallback plain-text vulnerability table."""
163
+ header = f" {'SEVERITY':<12} {'CVE / ID':<26} {'PACKAGE':<20} {'INSTALLED':<14} {'FIXED IN'}"
164
+ click.echo(header)
165
+ click.echo(" " + "-" * 90)
166
+ for v in vulns[:MAX_VULN_ROWS]:
167
+ sev = (v.get("Severity") or v.get("severity") or "UNKNOWN").upper()
168
+ fg, bold = _SEVERITY_CLICK_COLORS.get(sev, (None, False))
169
+ cve = (v.get("VulnerabilityID") or v.get("id") or "—")[:25]
170
+ pkg = (v.get("PkgName") or v.get("package") or "—")[:19]
171
+ installed = (v.get("InstalledVersion") or v.get("installed_version") or "—")[:13]
172
+ fixed = (v.get("FixedVersion") or v.get("fixed_version") or "—")[:13]
173
+ row = f" {sev:<12} {cve:<26} {pkg:<20} {installed:<14} {fixed}"
174
+ click.secho(row, fg=fg, bold=bold)
175
+
176
+ remaining = len(vulns) - MAX_VULN_ROWS
177
+ if remaining > 0:
178
+ msg = f" ... and {remaining} more"
179
+ if dashboard_url:
180
+ msg += f" — view full report: {dashboard_url}"
181
+ click.secho(msg, fg="cyan")
182
+
183
+
184
+ def render_vuln_table(vulns: list, dashboard_url: Optional[str] = None):
185
+ """Render vulnerability table using Rich if available, else plain text."""
186
+ if not vulns:
187
+ click.secho(" No vulnerabilities found ✓", fg="green")
188
+ return
189
+ if RICH_AVAILABLE and should_show_spinner():
190
+ _render_vuln_table_rich(vulns, dashboard_url)
191
+ else:
192
+ _render_vuln_table_plain(vulns, dashboard_url)
193
+
194
+
195
+ # Sanitization + prompt-building helpers live in prismor.sanitize so the
196
+ # local-fix path can share them without a circular import. Keep the private
197
+ # alias for the existing call sites in this module.
198
+ _strip_sensitive = strip_sensitive
199
+
200
+
201
+ def format_prompt_results(results: dict, repo: str):
202
+ """Format results with an LLM prompt for fixing vulnerabilities."""
203
+ click.echo(build_fix_prompt(results, repo, mode="advise"))
204
+
205
+
206
+ def format_scan_results(results: dict, scan_type: str, dashboard_url: Optional[str] = None):
207
+ """Format and display scan results."""
208
+ click.echo("\n" + "=" * 60)
209
+ click.secho(f" Scan Results - {scan_type}", fg="cyan", bold=True)
210
+ click.echo("=" * 60 + "\n")
211
+
212
+ if "repository" in results:
213
+ click.secho("Repository:", fg="yellow", bold=True)
214
+ click.echo(f" {results['repository']}\n")
215
+
216
+ if "branch" in results:
217
+ click.secho("Branch:", fg="yellow", bold=True)
218
+ click.echo(f" {results['branch']}\n")
219
+
220
+ if "commit_sha" in results:
221
+ click.secho("Commit SHA:", fg="yellow", bold=True)
222
+ click.echo(f" {results['commit_sha']}\n")
223
+
224
+ if "scans" in results:
225
+ scans = results["scans"]
226
+
227
+ # Vulnerability scan results
228
+ if "vulnerability" in scans:
229
+ vuln_scan = scans["vulnerability"]
230
+ click.secho("Vulnerability Scan:", fg="yellow", bold=True)
231
+ status_color = "green" if vuln_scan.get("status") in ["success", "completed"] else "red"
232
+ click.secho(f" Status: {vuln_scan.get('status', 'unknown')}", fg=status_color)
233
+
234
+ vulns = []
235
+ if "scan_results" in vuln_scan:
236
+ scan_data = vuln_scan["scan_results"]
237
+ # Flat list format (legacy)
238
+ flat = scan_data.get("vulnerabilities")
239
+ if isinstance(flat, list) and flat and isinstance(flat[0], dict) and "Severity" in flat[0]:
240
+ vulns = flat
241
+ else:
242
+ # Trivy format: Results[].Vulnerabilities[]
243
+ for target in scan_data.get("Results", []):
244
+ vulns.extend(target.get("Vulnerabilities") or [])
245
+ # Final fallback: top-level Results as flat list
246
+ if not vulns:
247
+ top = scan_data.get("Results", [])
248
+ if isinstance(top, list) and top and "Severity" in top[0]:
249
+ vulns = top
250
+
251
+ if vulns:
252
+ click.secho(f" Vulnerabilities Found: {len(vulns)}", fg="red" if len(vulns) > 0 else "green")
253
+ click.echo()
254
+ render_vuln_table(vulns, dashboard_url)
255
+ else:
256
+ click.secho(" Vulnerabilities Found: 0", fg="green")
257
+
258
+ if "public_url" in vuln_scan:
259
+ click.echo(f"\n Results URL: {vuln_scan['public_url']}")
260
+ click.echo()
261
+
262
+ # SBOM results
263
+ if "sbom" in scans:
264
+ sbom_scan = scans["sbom"]
265
+ click.secho("SBOM Generation:", fg="yellow", bold=True)
266
+ status_color = "green" if sbom_scan.get("status") in ["success", "completed"] else "red"
267
+ click.secho(f" Status: {sbom_scan.get('status', 'unknown')}", fg=status_color)
268
+ if "sbom" in sbom_scan:
269
+ sbom_data = sbom_scan["sbom"]
270
+ if isinstance(sbom_data, list):
271
+ click.echo(f" Artifacts Found: {len(sbom_data)}")
272
+ if "public_url" in sbom_scan:
273
+ click.echo(f" Results URL: {sbom_scan['public_url']}")
274
+ click.echo()
275
+
276
+ # Secret scan results
277
+ if "secret" in scans:
278
+ secret_scan = scans["secret"]
279
+ click.secho("Secret Detection:", fg="yellow", bold=True)
280
+ status_color = "green" if secret_scan.get("status") in ["success", "completed"] else "red"
281
+ click.secho(f" Status: {secret_scan.get('status', 'unknown')}", fg=status_color)
282
+
283
+ if "summary" in secret_scan:
284
+ summary = secret_scan["summary"]
285
+ if isinstance(summary, dict):
286
+ total = summary.get("total", 0)
287
+ click.echo(f" Total Detected: {total}")
288
+ ai_triage = summary.get("ai_triage")
289
+ if isinstance(ai_triage, dict) and ai_triage.get("total", 0) > 0:
290
+ real = ai_triage.get("real_secrets", 0)
291
+ false_pos = ai_triage.get("false_positives", 0)
292
+ click.secho(" AI Triage:", fg="cyan", bold=True)
293
+ if real > 0:
294
+ click.secho(f" Real Secrets: {real}", fg="red", bold=True)
295
+ else:
296
+ click.secho(f" Real Secrets: {real}", fg="green")
297
+ click.secho(f" False Positives: {false_pos}", fg="bright_black")
298
+ by_severity = ai_triage.get("by_severity", {})
299
+ if by_severity:
300
+ click.secho(" Severity Breakdown:", fg="cyan")
301
+ if by_severity.get("CRITICAL", 0) > 0:
302
+ click.secho(f" CRITICAL: {by_severity['CRITICAL']}", fg="red", bold=True)
303
+ if by_severity.get("HIGH", 0) > 0:
304
+ click.secho(f" HIGH: {by_severity['HIGH']}", fg="red")
305
+ if by_severity.get("MEDIUM", 0) > 0:
306
+ click.secho(f" MEDIUM: {by_severity['MEDIUM']}", fg="yellow")
307
+ if by_severity.get("LOW", 0) > 0:
308
+ click.secho(f" LOW: {by_severity['LOW']}", fg="blue")
309
+ if by_severity.get("FALSE_POSITIVE", 0) > 0:
310
+ click.secho(f" FALSE POSITIVE: {by_severity['FALSE_POSITIVE']}", fg="bright_black")
311
+ else:
312
+ for key, value in summary.items():
313
+ if key not in ("total", "ai_triage"):
314
+ click.echo(f" {key}: {value}")
315
+ else:
316
+ click.echo(f" Summary: {summary}")
317
+
318
+ if "public_url" in secret_scan:
319
+ click.echo(f" Results URL: {secret_scan['public_url']}")
320
+ click.echo()
321
+
322
+ if "scanned_at" in results:
323
+ click.secho("Scanned At:", fg="yellow", bold=True)
324
+ click.echo(f" {results['scanned_at']}\n")
325
+
326
+ click.echo("=" * 60 + "\n")
327
+
328
+
329
+ def _watch_scan(client: PrismorClient, job_id: str):
330
+ """Poll scan status with a live spinner until done, then return status_data."""
331
+ poll_interval = DEFAULT_SCAN_POLL_INTERVAL_SECONDS
332
+ max_wait = DEFAULT_SCAN_MAX_WAIT_SECONDS
333
+ started_at = time.time()
334
+
335
+ use_rich = RICH_AVAILABLE and should_show_spinner()
336
+
337
+ try:
338
+ if use_rich:
339
+ with Live(refresh_per_second=4, transient=True) as live:
340
+ while True:
341
+ elapsed = int(time.time() - started_at)
342
+ live.update(Text(f" ⠸ Polling scan {job_id}... ({elapsed}s elapsed)", style="cyan"))
343
+ time.sleep(poll_interval)
344
+ if time.time() - started_at > max_wait:
345
+ raise PrismorAPIError(f"Timed out after {max_wait}s. Check with: prismor scan-status {job_id}")
346
+ status_data = client.check_scan_status(job_id)
347
+ status = status_data.get("status")
348
+ if status in {"completed", "success", "failed", "error"}:
349
+ return status_data
350
+ else:
351
+ while True:
352
+ elapsed = int(time.time() - started_at)
353
+ print_info(f"Polling scan {job_id}... ({elapsed}s elapsed)")
354
+ time.sleep(poll_interval)
355
+ if time.time() - started_at > max_wait:
356
+ raise PrismorAPIError(f"Timed out after {max_wait}s. Check with: prismor scan-status {job_id}")
357
+ status_data = client.check_scan_status(job_id)
358
+ status = status_data.get("status")
359
+ if status in {"completed", "success", "failed", "error"}:
360
+ return status_data
361
+ except KeyboardInterrupt:
362
+ click.echo()
363
+ print_warning(f"Stopped watching. Scan still running in background.")
364
+ click.echo(f" Check with: prismor scan-status {job_id}")
365
+ sys.exit(0)
366
+
367
+
368
+ def _show_fix_success_panel(status_data: dict):
369
+ """Display a highlighted panel for a successful fix job."""
370
+ pr_url = status_data.get("pr_url", "")
371
+ branch = status_data.get("branch", "")
372
+ files = status_data.get("files_changed") or []
373
+ summary = status_data.get("summary", "")
374
+
375
+ if RICH_AVAILABLE and should_show_spinner():
376
+ lines = ["[bold green]Fix PR Ready![/bold green]"]
377
+ if pr_url:
378
+ lines.append(f"[green]{pr_url}[/green]")
379
+ if branch:
380
+ lines.append(f"Branch: {branch}")
381
+ if files:
382
+ lines.append(f"Files changed: {len(files)}")
383
+ if summary:
384
+ lines.append(f"\n{summary}")
385
+ console.print(Panel("\n".join(lines), border_style="green", expand=False))
386
+ else:
387
+ click.echo("\n" + "=" * 60)
388
+ print_success("Fix PR Ready!")
389
+ if pr_url:
390
+ click.secho(f" PR: {pr_url}", fg="green", bold=True)
391
+ if branch:
392
+ click.echo(f" Branch: {branch}")
393
+ if files:
394
+ click.echo(f" Files changed: {len(files)}")
395
+ for f in files[:10]:
396
+ click.echo(f" • {f}")
397
+ if len(files) > 10:
398
+ click.echo(f" ... and {len(files) - 10} more")
399
+ if summary:
400
+ click.echo(f" Summary: {summary}")
401
+ click.echo("=" * 60 + "\n")
402
+
403
+
404
+ @click.group(invoke_without_command=True)
405
+ @click.option("--repo", "scan_repo", type=str, help="Repository to scan (username/repo, GitHub URL, SSH URL, etc.)")
406
+ @click.option("--scan", is_flag=True, help="Perform vulnerability scanning")
407
+ @click.option("--sbom", is_flag=True, help="Generate Software Bill of Materials")
408
+ @click.option("--detect-secret", is_flag=True, help="Detect secrets in repository")
409
+ @click.option("--fullscan", is_flag=True, help="Perform all scan types")
410
+ @click.option("--fix", is_flag=True, help="AI auto-fix: scan then open a PR with fixes (implies --scan if no scan flag given)")
411
+ @click.option("--branch", type=str, help="Specific branch to scan (defaults to main/master)")
412
+ @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
413
+ @click.option("--output", "-o", type=click.Path(), help="Save results to file (JSON format)")
414
+ @click.option("--quiet", "-q", is_flag=True, help="Minimal output (only errors and final results)")
415
+ @click.option("--action_id", type=str, help="GitHub Action Run ID associated with this scan")
416
+ @click.option("--prompt", is_flag=True, help="Output detailed scan results with an LLM prompt for fixing vulnerabilities")
417
+ @click.version_option(version=__version__, prog_name="prismor")
418
+ @click.pass_context
419
+ def cli(ctx, scan_repo: Optional[str], scan: bool, sbom: bool, detect_secret: bool,
420
+ fullscan: bool, fix: bool, branch: Optional[str], output_json: bool, output: Optional[str], quiet: bool, action_id: Optional[str], prompt: bool):
421
+ """Prismor CLI - Security scanning tool for GitHub repositories.
422
+
423
+ Examples:
424
+ prismor --repo username/repo --scan
425
+ prismor --repo username/repo --fullscan
426
+ prismor --repo username/repo --scan --fix
427
+ prismor --repo https://github.com/username/repo --detect-secret
428
+ prismor --repo git@github.com:username/repo.git --sbom
429
+ prismor --repo github.com/username/repo --fullscan --branch develop
430
+ prismor trigger-fix username/repo
431
+ prismor fix-status <job_id>
432
+ prismor status
433
+ prismor repos
434
+ """
435
+ if ctx.invoked_subcommand is None and not scan_repo:
436
+ click.echo(ctx.get_help())
437
+ return
438
+
439
+ if scan_repo:
440
+ if not any([scan, sbom, detect_secret, fullscan, fix]):
441
+ print_error("Please specify at least one scan type: --scan, --sbom, --detect-secret, --fullscan, or --fix")
442
+ sys.exit(1)
443
+
444
+ if fix and not any([scan, sbom, detect_secret, fullscan]):
445
+ scan = True
446
+
447
+ if not branch:
448
+ detected = detect_git_branch()
449
+ if detected:
450
+ branch = detected
451
+ if not quiet:
452
+ print_info(f"Auto-detected branch: {branch}")
453
+
454
+ try:
455
+ if not quiet:
456
+ print_info(f"Initializing Prismor scan for: {scan_repo}")
457
+ client = PrismorClient()
458
+
459
+ scan_types = []
460
+ if fullscan:
461
+ scan_types.append("Full Scan (scan + SBOM + Secret Detection)")
462
+ else:
463
+ if scan:
464
+ scan_types.append("scan")
465
+ if sbom:
466
+ scan_types.append("SBOM")
467
+ if detect_secret:
468
+ scan_types.append("Secret Detection")
469
+
470
+ if not quiet:
471
+ print_info(f"Scan type: {', '.join(scan_types)}")
472
+ if scan or fullscan:
473
+ print_info("Starting scan... (typical scans finish in 30-90s; large repos may take several minutes)")
474
+ else:
475
+ print_info("Starting scan...")
476
+
477
+ spinner = None
478
+ if not quiet and not output_json and not output and should_show_spinner():
479
+ spinner = Spinner("Scanning repository")
480
+ spinner.start()
481
+
482
+ try:
483
+ results = client.scan(
484
+ repo=scan_repo,
485
+ scan=scan,
486
+ sbom=sbom,
487
+ detect_secret=detect_secret,
488
+ fullscan=fullscan,
489
+ branch=branch,
490
+ action_id=action_id
491
+ )
492
+ if spinner:
493
+ spinner.stop()
494
+ except Exception as e:
495
+ if spinner:
496
+ spinner.stop()
497
+ raise e
498
+
499
+ # Detect silent-success responses. The server returns 200 even when the
500
+ # scanner couldn't fetch the repo (bad branch, no access, repo not connected).
501
+ # Heuristic: if commit_sha is missing AND every scan type came back with
502
+ # zero findings AND no Trivy "Results" array, the scanner never examined
503
+ # real code. Fail loudly instead of letting users mistake it for a clean scan.
504
+ if isinstance(results, dict):
505
+ no_commit = not (results.get("commit_sha") or "").strip()
506
+ scans_dict = results.get("scans") or {}
507
+ vuln_sr = (scans_dict.get("vulnerability") or {}).get("scan_results") or {}
508
+ no_vuln_targets = not (isinstance(vuln_sr, dict) and vuln_sr.get("Results"))
509
+ secret_sr = (scans_dict.get("secret") or {}).get("scan_results")
510
+ no_secrets = not secret_sr
511
+ sbom_sr = (scans_dict.get("sbom") or {}).get("scan_results") or {}
512
+ no_artifacts = not (isinstance(sbom_sr, dict) and sbom_sr.get("artifacts"))
513
+ if no_commit and no_vuln_targets and no_secrets and no_artifacts:
514
+ branch_label = results.get("branch") or branch or "default"
515
+ print_error(
516
+ f"Scan returned no results for {scan_repo} (branch: {branch_label}). "
517
+ "The branch may not exist, the repository may not be connected to your "
518
+ "account, or Prismor may not have access. Verify with: prismor repos"
519
+ )
520
+ sys.exit(2)
521
+
522
+ results = _strip_sensitive(results)
523
+
524
+ if output:
525
+ try:
526
+ with open(output, 'w') as f:
527
+ json.dump(results, f, indent=2)
528
+ if not quiet:
529
+ print_success(f"Results saved to: {output}")
530
+ except Exception as e:
531
+ print_error(f"Failed to save results to file: {str(e)}")
532
+ sys.exit(1)
533
+
534
+ # AI auto-fix: trigger after scan completes
535
+ if fix and not output_json and not output:
536
+ if not quiet:
537
+ click.echo()
538
+ print_info("Triggering AI auto-fix...")
539
+ fix_spinner = None
540
+ if not quiet and should_show_spinner():
541
+ fix_spinner = Spinner("Starting auto-fix")
542
+ fix_spinner.start()
543
+ try:
544
+ fix_result = client.trigger_autofix(scan_repo, branch=branch)
545
+ if fix_spinner:
546
+ fix_spinner.stop()
547
+ except Exception as e:
548
+ if fix_spinner:
549
+ fix_spinner.stop()
550
+ print_error(f"Auto-fix failed to start: {str(e)}")
551
+ fix_result = None
552
+
553
+ if fix_result and fix_result.get("ok"):
554
+ fix_job_id = fix_result.get("job_id", "")
555
+ if not quiet:
556
+ print_success("Auto-fix job started!")
557
+ click.secho(f" Job ID: {fix_job_id}", fg="yellow", bold=True)
558
+ click.echo(f" Track progress: prismor fix-status {fix_job_id} --watch")
559
+
560
+ # Output results
561
+ if output_json or output:
562
+ if not output:
563
+ click.echo(json.dumps(results, indent=2))
564
+ elif prompt:
565
+ format_prompt_results(results, scan_repo)
566
+ else:
567
+ if not quiet:
568
+ print_success("Scan completed successfully!")
569
+
570
+ # Fetch dashboard URL for the "view more" link
571
+ dashboard_url = None
572
+ if not quiet:
573
+ try:
574
+ repo_name = parse_github_repo(scan_repo)
575
+ repo_info = client.get_repository_by_name(repo_name)
576
+ if repo_info.get("success") and "repository" in repo_info:
577
+ repo_id = repo_info["repository"]["id"]
578
+ dashboard_url = f"{client.base_url}/repositories/{repo_id}"
579
+ except Exception:
580
+ pass
581
+
582
+ format_scan_results(results, ', '.join(scan_types), dashboard_url=dashboard_url)
583
+
584
+ if dashboard_url and not quiet:
585
+ click.secho(f" View full analysis: {dashboard_url}", fg="cyan")
586
+ click.echo()
587
+
588
+ except PrismorAPIError as e:
589
+ print_error(str(e))
590
+ sys.exit(1)
591
+ except Exception as e:
592
+ print_error(f"Unexpected error: {str(e)}")
593
+ sys.exit(1)
594
+
595
+
596
+ @cli.command()
597
+ def version():
598
+ """Display the version of Prismor CLI."""
599
+ click.echo(f"Prismor CLI v{__version__}")
600
+
601
+
602
+ @cli.command()
603
+ def config():
604
+ """Display current configuration."""
605
+ click.echo("\n" + "=" * 60)
606
+ click.secho(" Prismor CLI Configuration", fg="cyan", bold=True)
607
+ click.echo("=" * 60 + "\n")
608
+
609
+ api_key = os.environ.get("PRISMOR_API_KEY")
610
+ if api_key:
611
+ masked_key = f"{api_key[:4]}...{api_key[-4:]}" if len(api_key) > 8 else "***"
612
+ print_success(f"PRISMOR_API_KEY: {masked_key}")
613
+ else:
614
+ print_error("PRISMOR_API_KEY: Not set")
615
+ click.echo("\nPlease specify your API key. You can generate one for free at:")
616
+ click.secho(" https://www.prismor.dev/cli", fg="cyan", underline=True)
617
+ click.echo("\nTo set your API key, run:")
618
+ click.echo(" export PRISMOR_API_KEY=your_api_key")
619
+
620
+ click.echo("=" * 60 + "\n")
621
+
622
+
623
+ @cli.group()
624
+ def org():
625
+ """Manage which organization your scans and fixes belong to."""
626
+ pass
627
+
628
+
629
+ @org.command("list")
630
+ def org_list():
631
+ """List the organizations you belong to (★ = active)."""
632
+ from prismor import cli_config
633
+ try:
634
+ client = PrismorClient()
635
+ data = client.list_orgs()
636
+ orgs = data.get("orgs", [])
637
+ active = cli_config.active_org_id() or data.get("activeOrgId")
638
+ if not orgs:
639
+ print_error("You're not a member of any organization.")
640
+ return
641
+ click.echo("")
642
+ for o in orgs:
643
+ mark = "★" if o.get("id") == active else " "
644
+ click.secho(f" {mark} {o.get('name')}", fg="cyan" if mark == "★" else None, bold=mark == "★", nl=False)
645
+ click.echo(f" ({o.get('slug')}) · {str(o.get('role','')).lower()}")
646
+ click.echo("\n Switch with: prismor org switch <slug>\n")
647
+ except Exception as e:
648
+ print_error(f"Could not list organizations: {e}")
649
+
650
+
651
+ @org.command("switch")
652
+ @click.argument("slug_or_id")
653
+ def org_switch(slug_or_id: str):
654
+ """Set the active organization (scans/fixes will be attributed to it)."""
655
+ from prismor import cli_config
656
+ try:
657
+ client = PrismorClient()
658
+ orgs = client.list_orgs().get("orgs", [])
659
+ match = next((o for o in orgs if o.get("slug") == slug_or_id or o.get("id") == slug_or_id), None)
660
+ if not match:
661
+ print_error(f"You're not a member of '{slug_or_id}'. Run `prismor org list` to see your orgs.")
662
+ return
663
+ cli_config.set_active_org(match)
664
+ print_success(f"Active organization: {match.get('name')} ({match.get('slug')})")
665
+ except Exception as e:
666
+ print_error(f"Could not switch organization: {e}")
667
+
668
+
669
+ @org.command("current")
670
+ def org_current():
671
+ """Show the active organization."""
672
+ from prismor import cli_config
673
+ active = cli_config.active_org()
674
+ if active:
675
+ click.echo(f"\n Active organization: {active.get('name')} ({active.get('slug')})\n")
676
+ else:
677
+ click.echo("\n No active organization set — using your API key's default org.")
678
+ click.echo(" Set one with: prismor org switch <slug>\n")
679
+
680
+
681
+ @cli.group()
682
+ def policy():
683
+ """Manage your organization's security policy as code (pull / apply / show)."""
684
+ pass
685
+
686
+
687
+ @policy.command("show")
688
+ def policy_show():
689
+ """Print the active org policy (version + YAML)."""
690
+ try:
691
+ data = PrismorClient().get_org_policy()
692
+ p = data.get("policy", {})
693
+ click.echo("")
694
+ click.secho(f" Org policy · v{p.get('version', 0)}" + (f" · {p.get('name')}" if p.get('name') else " · (default — everything observes)"), fg="cyan", bold=True)
695
+ if not data.get("signingConfigured", True):
696
+ click.secho(" ⚠ Policy signing not configured — devices will reject remote policy.", fg="yellow")
697
+ click.echo("")
698
+ click.echo(p.get("yaml", ""))
699
+ except Exception as e:
700
+ print_error(f"Could not fetch policy: {e}")
701
+
702
+
703
+ @policy.command("pull")
704
+ @click.option("-o", "--output", "output", default=None, help="Write the policy YAML to this file (default: stdout).")
705
+ def policy_pull(output):
706
+ """Fetch the active org policy YAML (for version control / editing)."""
707
+ try:
708
+ data = PrismorClient().get_org_policy()
709
+ yaml_text = data.get("policy", {}).get("yaml", "")
710
+ if output:
711
+ with open(output, "w") as f:
712
+ f.write(yaml_text)
713
+ print_success(f"Wrote org policy (v{data.get('policy', {}).get('version', 0)}) to {output}")
714
+ else:
715
+ click.echo(yaml_text, nl=False)
716
+ except Exception as e:
717
+ print_error(f"Could not pull policy: {e}")
718
+
719
+
720
+ @policy.command("lint")
721
+ @click.argument("file", type=click.Path(exists=True))
722
+ def policy_lint(file):
723
+ """Validate a policy YAML file against the floor (no changes made)."""
724
+ try:
725
+ with open(file) as f:
726
+ yaml_text = f.read()
727
+ PrismorClient().apply_org_policy(yaml_text, dry_run=True)
728
+ print_success("Policy is valid (parses + honors the non-weakening floor).")
729
+ except PrismorAPIError as e:
730
+ print_error(f"Policy is invalid:\n{e}")
731
+ except Exception as e:
732
+ print_error(f"Could not lint policy: {e}")
733
+
734
+
735
+ @policy.command("apply")
736
+ @click.argument("file", type=click.Path(exists=True))
737
+ @click.option("--dry-run", is_flag=True, help="Validate only; don't publish.")
738
+ @click.option("--yes", is_flag=True, help="Skip the confirmation prompt.")
739
+ def policy_apply(file, dry_run, yes):
740
+ """Publish a policy YAML file to the org (signed; devices apply within ~30s)."""
741
+ try:
742
+ with open(file) as f:
743
+ yaml_text = f.read()
744
+ if dry_run:
745
+ PrismorClient().apply_org_policy(yaml_text, dry_run=True)
746
+ print_success("Valid — would publish cleanly. Re-run without --dry-run to apply.")
747
+ return
748
+ if not yes:
749
+ click.secho(" This publishes the org policy to EVERY enrolled device (~30s).", fg="yellow")
750
+ if not click.confirm(" Continue?", default=False):
751
+ click.echo(" Aborted.")
752
+ return
753
+ res = PrismorClient().apply_org_policy(yaml_text, dry_run=False)
754
+ print_success(f"Published org policy v{res.get('version')}. Enrolled devices apply it within ~30s.")
755
+ if not res.get("signingConfigured", True):
756
+ click.secho(" ⚠ Policy signing not configured — devices will reject it until set.", fg="yellow")
757
+ except PrismorAPIError as e:
758
+ print_error(f"Could not apply policy:\n{e}")
759
+ except Exception as e:
760
+ print_error(f"Could not apply policy: {e}")
761
+
762
+
763
+ @cli.command()
764
+ def devices():
765
+ """List enrolled devices in your active organization."""
766
+ try:
767
+ data = PrismorClient().list_devices()
768
+ rows = data.get("devices", [])
769
+ click.echo("")
770
+ click.secho(f" Devices ({len(rows)})", fg="cyan", bold=True)
771
+ click.echo("")
772
+ if not rows:
773
+ click.echo(" No devices enrolled. Enroll one with: immunity enroll <token>\n")
774
+ return
775
+ for d in rows:
776
+ status = d.get("status", "active")
777
+ color = "green" if status == "active" else "red"
778
+ click.secho(f" {d.get('label')}", bold=True, nl=False)
779
+ click.echo(f" · {d.get('platform') or 'unknown'} · {d.get('owner')} · ", nl=False)
780
+ click.secho(status, fg=color, nl=False)
781
+ click.echo(f" · policy v{d.get('appliedPolicyVersion') if d.get('appliedPolicyVersion') is not None else '-'}")
782
+ click.echo("")
783
+ except Exception as e:
784
+ print_error(f"Could not list devices: {e}")
785
+
786
+
787
+ @cli.command()
788
+ def members():
789
+ """List members of your active organization and their roles."""
790
+ try:
791
+ data = PrismorClient().list_members()
792
+ rows = data.get("members", [])
793
+ click.echo("")
794
+ click.secho(f" Members ({len(rows)})", fg="cyan", bold=True)
795
+ click.echo("")
796
+ for mbr in rows:
797
+ click.secho(f" {mbr.get('name') or mbr.get('email')}", bold=True, nl=False)
798
+ click.echo(f" ({mbr.get('email')}) · {str(mbr.get('role', '')).lower()}")
799
+ click.echo("")
800
+ except Exception as e:
801
+ print_error(f"Could not list members: {e}")
802
+
803
+
804
+ @cli.command()
805
+ def repos():
806
+ """List your connected repositories."""
807
+ try:
808
+ client = PrismorClient()
809
+ spinner = Spinner("Loading repositories") if should_show_spinner() else None
810
+ if spinner:
811
+ spinner.start()
812
+ try:
813
+ repos_data = client.get_repositories()
814
+ if spinner:
815
+ spinner.stop()
816
+ except Exception as e:
817
+ if spinner:
818
+ spinner.stop()
819
+ raise e
820
+
821
+ click.echo("\n" + "=" * 60)
822
+ click.secho(" Your Repositories", fg="cyan", bold=True)
823
+ click.echo("=" * 60 + "\n")
824
+
825
+ user_info = repos_data.get("user", {})
826
+ if user_info:
827
+ click.secho(f"User: {user_info.get('name', 'Unknown')} ({user_info.get('email', 'No email')})", fg="yellow")
828
+ click.echo()
829
+
830
+ repositories = repos_data.get("repositories", [])
831
+ if repositories:
832
+ for repo in repositories:
833
+ click.secho(f"• {repo.get('name', 'Unknown')}", fg="green")
834
+ click.echo(f" URL: {repo.get('htmlUrl', 'No URL')}")
835
+ click.echo(f" Owner: {repo.get('githubOwner', 'Unknown')}")
836
+ click.echo()
837
+ else:
838
+ print_warning("No repositories found. Connect repositories through the web interface.")
839
+
840
+ click.echo("=" * 60 + "\n")
841
+
842
+ except PrismorAPIError as e:
843
+ print_error(str(e))
844
+ sys.exit(1)
845
+ except Exception as e:
846
+ print_error(f"Unexpected error: {str(e)}")
847
+ sys.exit(1)
848
+
849
+
850
+ @cli.command()
851
+ @click.argument("repo", type=str)
852
+ @click.option("--branch", type=str, help="Specific branch to scan (defaults to main)")
853
+ @click.option("--token", type=str, help="GitHub token (or set GITHUB_TOKEN env var)")
854
+ @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
855
+ def start_scan(repo: str, branch: Optional[str], token: Optional[str], output_json: bool):
856
+ """Start a vulnerability scan and return a job_id for status checking.
857
+
858
+ REPO is the repository to scan (username/repo, GitHub URL, SSH URL, etc.)
859
+
860
+ Examples:
861
+ prismor start-scan username/repo
862
+ prismor start-scan https://github.com/username/repo --branch develop
863
+ prismor start-scan username/repo --token ghp_xxxxx
864
+ prismor start-scan username/repo --json
865
+ """
866
+ if not branch:
867
+ detected = detect_git_branch()
868
+ if detected:
869
+ branch = detected
870
+ print_info(f"Auto-detected branch: {branch}")
871
+
872
+ try:
873
+ client = PrismorClient()
874
+ print_info(f"Starting vulnerability scan for: {repo}")
875
+ if branch:
876
+ print_info(f"Branch: {branch}")
877
+
878
+ spinner = Spinner("Starting scan") if should_show_spinner() else None
879
+ if spinner:
880
+ spinner.start()
881
+ try:
882
+ result = client.start_vulnerability_scan(repo, branch, token)
883
+ if spinner:
884
+ spinner.stop()
885
+ except Exception as e:
886
+ if spinner:
887
+ spinner.stop()
888
+ raise e
889
+
890
+ if output_json:
891
+ click.echo(json.dumps(result, indent=2))
892
+ else:
893
+ click.echo("\n" + "=" * 60)
894
+ click.secho(" Scan Started", fg="cyan", bold=True)
895
+ click.echo("=" * 60 + "\n")
896
+
897
+ job_id = result.get("job_id")
898
+ if job_id:
899
+ print_success("Scan started successfully!")
900
+ click.echo()
901
+ click.secho(f"Job ID: {job_id}", fg="yellow", bold=True)
902
+ click.echo()
903
+ click.secho("Repository:", fg="yellow", bold=True)
904
+ click.echo(f" {result.get('repository', repo)}")
905
+ click.echo()
906
+ if "branch" in result:
907
+ click.secho("Branch:", fg="yellow", bold=True)
908
+ click.echo(f" {result['branch']}")
909
+ click.echo()
910
+ click.secho("Status:", fg="yellow", bold=True)
911
+ click.echo(f" {result.get('status', 'accepted')}")
912
+ click.echo()
913
+ click.secho("Next Steps:", fg="cyan", bold=True)
914
+ click.echo(f" Check scan status with:")
915
+ click.secho(f" prismor scan-status {job_id} --watch", fg="green", bold=True)
916
+ click.echo()
917
+ else:
918
+ print_error("Failed to get job_id from response")
919
+ click.echo(json.dumps(result, indent=2))
920
+
921
+ click.echo("=" * 60 + "\n")
922
+
923
+ except PrismorAPIError as e:
924
+ print_error(str(e))
925
+ sys.exit(1)
926
+ except Exception as e:
927
+ print_error(f"Unexpected error: {str(e)}")
928
+ sys.exit(1)
929
+
930
+
931
+ @cli.command()
932
+ @click.argument("job_id", type=str)
933
+ @click.option("--watch", is_flag=True, help="Poll until the scan completes (up to 30 min)")
934
+ @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
935
+ @click.option("--prompt", is_flag=True, help="Output detailed scan results with an LLM prompt for fixing vulnerabilities")
936
+ def scan_status(job_id: str, watch: bool, output_json: bool, prompt: bool):
937
+ """Check the status of a vulnerability scan job.
938
+
939
+ JOB_ID is the job ID returned when starting a scan.
940
+
941
+ Examples:
942
+ prismor scan-status a724a4663cda4bf087ad171683cb726d
943
+ prismor scan-status a724a4663cda4bf087ad171683cb726d --watch
944
+ prismor scan-status 50cbe253e5634227b81fe744c2a0b3e7 --json
945
+ """
946
+ try:
947
+ client = PrismorClient()
948
+
949
+ if watch:
950
+ print_info(f"Watching scan {job_id} (Ctrl+C to stop)...")
951
+ status_data = _watch_scan(client, job_id)
952
+ else:
953
+ print_info(f"Checking scan status for job: {job_id}")
954
+ spinner = Spinner("Checking status") if should_show_spinner() else None
955
+ if spinner:
956
+ spinner.start()
957
+ try:
958
+ status_data = client.check_scan_status(job_id)
959
+ if spinner:
960
+ spinner.stop()
961
+ except Exception as e:
962
+ if spinner:
963
+ spinner.stop()
964
+ raise e
965
+
966
+ status_data = _strip_sensitive(status_data)
967
+
968
+ if output_json:
969
+ click.echo(json.dumps(status_data, indent=2))
970
+ return
971
+
972
+ if prompt and status_data.get("status") in {"completed", "success"}:
973
+ repo_name = status_data.get("repository", "Unknown Repository")
974
+ format_prompt_results(status_data.get("results", status_data), repo_name)
975
+ return
976
+
977
+ click.echo("\n" + "=" * 60)
978
+ click.secho(" Scan Status", fg="cyan", bold=True)
979
+ click.echo("=" * 60 + "\n")
980
+
981
+ click.secho(f"Job ID: {status_data.get('job_id', job_id)}", fg="yellow", bold=True)
982
+ click.echo()
983
+
984
+ status = status_data.get("status", "unknown")
985
+ if status in {"completed", "success"}:
986
+ print_success(f"Status: {status}")
987
+ click.echo()
988
+
989
+ if "repository" in status_data:
990
+ click.secho("Repository:", fg="yellow", bold=True)
991
+ click.echo(f" {status_data['repository']}")
992
+ click.echo()
993
+
994
+ if "branch" in status_data:
995
+ click.secho("Branch:", fg="yellow", bold=True)
996
+ click.echo(f" {status_data['branch']}")
997
+ click.echo()
998
+
999
+ if "duration" in status_data:
1000
+ click.secho("Duration:", fg="yellow", bold=True)
1001
+ click.echo(f" {status_data['duration']:.2f} seconds")
1002
+ click.echo()
1003
+
1004
+ if "public_url" in status_data:
1005
+ click.secho("Results URL:", fg="yellow", bold=True)
1006
+ click.secho(f" {status_data['public_url']}", fg="green")
1007
+ click.echo()
1008
+
1009
+ if "presigned_url" in status_data:
1010
+ click.secho("Presigned URL (expires in 1 hour):", fg="yellow", bold=True)
1011
+ click.secho(f" {status_data['presigned_url']}", fg="blue")
1012
+ click.echo()
1013
+
1014
+ if "summary" in status_data:
1015
+ summary = status_data["summary"]
1016
+ click.secho("Vulnerability Summary:", fg="yellow", bold=True)
1017
+ click.echo(f" Total Vulnerabilities: {summary.get('total_vulnerabilities', 0)}")
1018
+ click.echo(f" Total Targets Scanned: {summary.get('total_targets', 0)}")
1019
+ click.echo()
1020
+ severity_breakdown = summary.get('severity_breakdown', {})
1021
+ if severity_breakdown:
1022
+ click.secho(" Severity Breakdown:", fg="yellow")
1023
+ if severity_breakdown.get('CRITICAL', 0) > 0:
1024
+ click.secho(f" CRITICAL: {severity_breakdown['CRITICAL']}", fg="red", bold=True)
1025
+ if severity_breakdown.get('HIGH', 0) > 0:
1026
+ click.secho(f" HIGH: {severity_breakdown['HIGH']}", fg="red")
1027
+ if severity_breakdown.get('MEDIUM', 0) > 0:
1028
+ click.secho(f" MEDIUM: {severity_breakdown['MEDIUM']}", fg="yellow")
1029
+ if severity_breakdown.get('LOW', 0) > 0:
1030
+ click.secho(f" LOW: {severity_breakdown['LOW']}", fg="blue")
1031
+ if severity_breakdown.get('UNKNOWN', 0) > 0:
1032
+ click.secho(f" UNKNOWN: {severity_breakdown['UNKNOWN']}", fg="white")
1033
+ click.echo()
1034
+
1035
+ if "scan_date" in status_data:
1036
+ click.secho("Scan Date:", fg="yellow", bold=True)
1037
+ click.echo(f" {status_data['scan_date']}")
1038
+ click.echo()
1039
+
1040
+ elif status == "running":
1041
+ print_info(f"Status: {status}")
1042
+ if "message" in status_data:
1043
+ click.echo(f" {status_data['message']}")
1044
+ click.echo()
1045
+ click.echo(" The scan is still in progress.")
1046
+ click.echo(f" Watch it live with: prismor scan-status {job_id} --watch")
1047
+ click.echo()
1048
+
1049
+ elif status == "failed":
1050
+ print_error(f"Status: {status}")
1051
+ if "error" in status_data:
1052
+ click.echo(f" Error: {status_data['error']}")
1053
+ click.echo()
1054
+ else:
1055
+ click.secho(f"Status: {status}", fg="yellow")
1056
+ click.echo()
1057
+
1058
+ click.echo("=" * 60 + "\n")
1059
+
1060
+ except PrismorAPIError as e:
1061
+ print_error(str(e))
1062
+ sys.exit(1)
1063
+ except Exception as e:
1064
+ print_error(f"Unexpected error: {str(e)}")
1065
+ sys.exit(1)
1066
+
1067
+
1068
+ @cli.command()
1069
+ def status():
1070
+ """Check your account status and GitHub integration."""
1071
+ try:
1072
+ client = PrismorClient()
1073
+ spinner = Spinner("Checking account status") if should_show_spinner() else None
1074
+ if spinner:
1075
+ spinner.start()
1076
+ try:
1077
+ auth_data = client.authenticate()
1078
+ if spinner:
1079
+ spinner.stop()
1080
+ except Exception as e:
1081
+ if spinner:
1082
+ spinner.stop()
1083
+ raise e
1084
+
1085
+ click.echo("\n" + "=" * 60)
1086
+ click.secho(" Account Status", fg="cyan", bold=True)
1087
+ click.echo("=" * 60 + "\n")
1088
+
1089
+ user_info = auth_data.get("user", {})
1090
+ if user_info:
1091
+ repositories = user_info.get("repositories", [])
1092
+ click.secho(f"Connected Repositories: {len(repositories)}", fg="green")
1093
+ if repositories:
1094
+ click.echo("\nRepository List:")
1095
+ for repo in repositories:
1096
+ click.echo(f" • {repo.get('name', 'Unknown')} ({repo.get('htmlUrl', 'No URL')})")
1097
+ else:
1098
+ print_warning("No repositories connected.")
1099
+ click.echo("\nTo connect repositories:")
1100
+ click.echo(" 1. Visit https://prismor.dev/dashboard")
1101
+ click.echo(" 2. Connect your GitHub account")
1102
+ click.echo(" 3. Select repositories to scan")
1103
+ else:
1104
+ print_error("Failed to retrieve account information.")
1105
+
1106
+ click.echo("\n" + "=" * 60 + "\n")
1107
+
1108
+ except PrismorAPIError as e:
1109
+ print_error(str(e))
1110
+ sys.exit(1)
1111
+ except Exception as e:
1112
+ print_error(f"Unexpected error: {str(e)}")
1113
+ sys.exit(1)
1114
+
1115
+
1116
+ @cli.command("trigger-fix")
1117
+ @click.argument("repo", type=str)
1118
+ @click.option("--branch", type=str, help="Base branch to apply fixes on (defaults to main)")
1119
+ @click.option("--instruction", type=str, help="Custom fix instruction for the AI agent")
1120
+ @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
1121
+ def trigger_fix(repo: str, branch: Optional[str], instruction: Optional[str], output_json: bool):
1122
+ """Trigger AI auto-fix for a repository without running a scan first.
1123
+
1124
+ REPO is the repository to fix (username/repo, GitHub URL, etc.)
1125
+
1126
+ Examples:
1127
+ prismor trigger-fix username/repo
1128
+ prismor trigger-fix https://github.com/username/repo --branch develop
1129
+ prismor trigger-fix username/repo --instruction "Update lodash to 4.17.21"
1130
+ prismor trigger-fix username/repo --json
1131
+ """
1132
+ if not branch:
1133
+ detected = detect_git_branch()
1134
+ if detected:
1135
+ branch = detected
1136
+ print_info(f"Auto-detected branch: {branch}")
1137
+
1138
+ try:
1139
+ client = PrismorClient()
1140
+ print_info(f"Triggering auto-fix for: {repo}")
1141
+
1142
+ spinner = Spinner("Starting auto-fix") if should_show_spinner() else None
1143
+ if spinner:
1144
+ spinner.start()
1145
+ try:
1146
+ result = client.trigger_autofix(repo, branch=branch, instruction=instruction)
1147
+ if spinner:
1148
+ spinner.stop()
1149
+ except Exception as e:
1150
+ if spinner:
1151
+ spinner.stop()
1152
+ raise e
1153
+
1154
+ if output_json:
1155
+ click.echo(json.dumps(result, indent=2))
1156
+ else:
1157
+ click.echo("\n" + "=" * 60)
1158
+ click.secho(" Auto-Fix Triggered", fg="cyan", bold=True)
1159
+ click.echo("=" * 60 + "\n")
1160
+
1161
+ if result.get("ok"):
1162
+ print_success("Auto-fix job started successfully!")
1163
+ click.echo()
1164
+ job_id = result.get("job_id", "")
1165
+ click.secho(f"Job ID: {job_id}", fg="yellow", bold=True)
1166
+ click.echo()
1167
+ click.secho("Next Steps:", fg="cyan", bold=True)
1168
+ click.echo(" Watch for the PR with:")
1169
+ click.secho(f" prismor fix-status {job_id} --watch", fg="green", bold=True)
1170
+ else:
1171
+ print_error(f"Failed: {result.get('error', 'Unknown error')}")
1172
+
1173
+ click.echo("\n" + "=" * 60 + "\n")
1174
+
1175
+ except PrismorAPIError as e:
1176
+ print_error(str(e))
1177
+ sys.exit(1)
1178
+ except Exception as e:
1179
+ print_error(f"Unexpected error: {str(e)}")
1180
+ sys.exit(1)
1181
+
1182
+
1183
+ @cli.command("fix-status")
1184
+ @click.argument("job_id", type=str)
1185
+ @click.option("--watch", is_flag=True, help="Poll until the fix completes (up to 30 min)")
1186
+ @click.option("--wait", is_flag=True, hidden=True, help="Alias for --watch (deprecated)")
1187
+ @click.option("--json", "output_json", is_flag=True, help="Output results in JSON format")
1188
+ def fix_status(job_id: str, watch: bool, wait: bool, output_json: bool):
1189
+ """Check the status of an AI auto-fix job.
1190
+
1191
+ JOB_ID is the job ID returned by 'trigger-fix' or '--fix'.
1192
+
1193
+ Examples:
1194
+ prismor fix-status agent_cli_1234567890_abc123
1195
+ prismor fix-status agent_cli_1234567890_abc123 --watch
1196
+ prismor fix-status agent_cli_1234567890_abc123 --json
1197
+ """
1198
+ polling = watch or wait # support legacy --wait flag
1199
+
1200
+ try:
1201
+ client = PrismorClient()
1202
+ print_info(f"Checking fix status for job: {job_id}")
1203
+
1204
+ max_wait = 1800
1205
+ poll_interval = 10
1206
+ started_at = time.time()
1207
+ use_rich = RICH_AVAILABLE and should_show_spinner()
1208
+
1209
+ while True:
1210
+ spinner = Spinner("Checking status") if (should_show_spinner() and not use_rich) else None
1211
+ if spinner:
1212
+ spinner.start()
1213
+ try:
1214
+ status_data = client.check_fix_status(job_id)
1215
+ if spinner:
1216
+ spinner.stop()
1217
+ except Exception as e:
1218
+ if spinner:
1219
+ spinner.stop()
1220
+ raise e
1221
+
1222
+ status = status_data.get("status", "unknown")
1223
+ done = status in {"success", "failed", "validation_failed"}
1224
+
1225
+ if output_json:
1226
+ click.echo(json.dumps(status_data, indent=2))
1227
+ if not polling or done:
1228
+ break
1229
+ time.sleep(poll_interval)
1230
+ continue
1231
+
1232
+ if status == "processing" and polling and (time.time() - started_at) < max_wait:
1233
+ elapsed = int(time.time() - started_at)
1234
+ if use_rich:
1235
+ # Print a single live line then sleep — simple approach without Live context loop
1236
+ sys.stdout.write(f"\r ⠸ Waiting for AI fix... ({elapsed}s elapsed) ")
1237
+ sys.stdout.flush()
1238
+ else:
1239
+ print_info(f"Still processing... ({elapsed}s elapsed). Checking again in {poll_interval}s")
1240
+ try:
1241
+ time.sleep(poll_interval)
1242
+ except KeyboardInterrupt:
1243
+ click.echo()
1244
+ print_warning("Stopped watching. Fix still running in background.")
1245
+ click.echo(f" Check with: prismor fix-status {job_id}")
1246
+ sys.exit(0)
1247
+ continue
1248
+
1249
+ # Clear any inline progress line before printing the result
1250
+ if use_rich and polling:
1251
+ sys.stdout.write("\r" + " " * 60 + "\r")
1252
+ sys.stdout.flush()
1253
+
1254
+ if status == "success":
1255
+ _show_fix_success_panel(status_data)
1256
+ else:
1257
+ click.echo("\n" + "=" * 60)
1258
+ click.secho(" Auto-Fix Status", fg="cyan", bold=True)
1259
+ click.echo("=" * 60 + "\n")
1260
+ click.secho(f"Job ID: {job_id}", fg="yellow", bold=True)
1261
+ click.echo()
1262
+
1263
+ if status in {"failed", "validation_failed"}:
1264
+ print_error(f"Status: {status}")
1265
+ click.echo()
1266
+ if status_data.get("error"):
1267
+ click.echo(f" Error: {status_data['error']}")
1268
+ click.echo()
1269
+ if status_data.get("retry_count"):
1270
+ click.echo(f" Retries: {status_data['retry_count']}")
1271
+ click.echo()
1272
+ else:
1273
+ click.secho(f"Status: {status}", fg="yellow")
1274
+ click.echo()
1275
+ if not polling:
1276
+ click.echo(f" The fix is still in progress.")
1277
+ click.echo(f" Watch it with: prismor fix-status {job_id} --watch")
1278
+ click.echo()
1279
+
1280
+ click.echo("=" * 60 + "\n")
1281
+
1282
+ break
1283
+
1284
+ except PrismorAPIError as e:
1285
+ print_error(str(e))
1286
+ sys.exit(1)
1287
+ except Exception as e:
1288
+ print_error(f"Unexpected error: {str(e)}")
1289
+ sys.exit(1)
1290
+
1291
+
1292
+ # Register the local AI auto-fix command. Defined in its own module so the
1293
+ # agent-runner logic stays isolated; imported here at the bottom so all of this
1294
+ # module's console helpers exist before fix_local's deferred import resolves them.
1295
+ from .local_fix import fix_local # noqa: E402
1296
+ cli.add_command(fix_local)
1297
+
1298
+
1299
+ def main():
1300
+ """Entry point for the CLI."""
1301
+ cli()
1302
+
1303
+
1304
+ if __name__ == "__main__":
1305
+ main()