valicode 2.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.
aov/cli.py ADDED
@@ -0,0 +1,1705 @@
1
+ #!/usr/bin/env python3
2
+ import json
3
+ import os
4
+ import fnmatch
5
+ import html
6
+ import hashlib
7
+ import shutil
8
+ import shlex
9
+ import subprocess
10
+ import sys
11
+ import tempfile
12
+ import time
13
+ from pathlib import Path
14
+
15
+ import click
16
+ import httpx
17
+ import toml
18
+
19
+ CONFIG_PATH = Path.home() / ".valicode" / "config.toml"
20
+ LEGACY_CONFIG_PATH = Path.home() / ".aov" / "config.toml"
21
+ DEFAULT_API_BASE = "https://api.valicode.sbs/v1"
22
+ DEFAULT_IGNORE_DIRS = {
23
+ "node_modules", ".git", ".next", "dist", "build", "coverage",
24
+ ".venv", "venv", "__pycache__", ".turbo", ".cache",
25
+ ".pytest_cache", ".mypy_cache", ".ruff_cache",
26
+ }
27
+ DEFAULT_IGNORE_EXTENSIONS = {
28
+ ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".ico",
29
+ ".mp4", ".mov", ".avi", ".mkv", ".mp3", ".wav",
30
+ ".pdf", ".zip", ".tar", ".gz", ".tgz", ".rar", ".7z",
31
+ ".bin", ".exe", ".dll", ".so", ".dylib", ".pyc",
32
+ }
33
+ SOURCE_EXTENSIONS = {
34
+ ".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java",
35
+ ".cs", ".cpp", ".c", ".h", ".rb", ".php", ".swift", ".kt",
36
+ ".scala", ".sh", ".yaml", ".yml", ".json", ".toml", ".md",
37
+ ".sql", ".graphql", ".prisma", ".Dockerfile",
38
+ }
39
+ LOCKFILE_NAMES = {
40
+ "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "poetry.lock",
41
+ "Cargo.lock", "Gemfile.lock", "composer.lock",
42
+ }
43
+ DEFAULT_MAX_UPLOAD_BYTES = 500 * 1024 * 1024
44
+ DEFAULT_MAX_FILE_BYTES = 2 * 1024 * 1024
45
+ OUTPUT_FORMATS = ["table", "json", "sarif", "markdown", "html", "junit", "summary", "mermaid", "chat"]
46
+
47
+
48
+ def load_config() -> dict:
49
+ if CONFIG_PATH.exists():
50
+ return toml.loads(CONFIG_PATH.read_text())
51
+ if LEGACY_CONFIG_PATH.exists():
52
+ return toml.loads(LEGACY_CONFIG_PATH.read_text())
53
+ return {}
54
+
55
+
56
+ def save_config(cfg: dict) -> None:
57
+ CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
58
+ CONFIG_PATH.write_text(toml.dumps(cfg))
59
+
60
+
61
+ def get_api_base() -> str:
62
+ cfg = load_config()
63
+ return (
64
+ os.environ.get("VALICODE_API_BASE")
65
+ or os.environ.get("AOV_API_BASE")
66
+ or cfg.get("api_base")
67
+ or DEFAULT_API_BASE
68
+ ).rstrip("/")
69
+
70
+
71
+ @click.group()
72
+ @click.version_option("2.0.0", prog_name="valicode")
73
+ def cli():
74
+ """Valicode - audit AI-generated code before it ships."""
75
+
76
+
77
+ @cli.command()
78
+ @click.option("--check-api", is_flag=True, help="Also call the Valicode API health/docs endpoint")
79
+ def doctor(check_api):
80
+ """Validate the local CLI security-analysis environment."""
81
+ cfg = load_config()
82
+ capabilities = sandbox_capabilities()
83
+ checks = [
84
+ ("api_key", bool(cfg.get("api_key")), "API key configured in ~/.valicode/config.toml", True),
85
+ ("git", shutil.which("git") is not None, "git executable available", True),
86
+ ("semgrep", shutil.which("semgrep") is not None, "semgrep executable available for local parity checks", False),
87
+ ("docker", capabilities["docker_available"], "Docker available for isolated validation", False),
88
+ ("microvm", capabilities["microvm_available"], capabilities["microvm_detail"], False),
89
+ ]
90
+
91
+ in_git_repo = subprocess.run(
92
+ ["git", "rev-parse", "--is-inside-work-tree"],
93
+ capture_output=True,
94
+ text=True,
95
+ check=False,
96
+ ).stdout.strip() == "true"
97
+ checks.append(("git_repo", in_git_repo, "current directory is inside a git repository", True))
98
+
99
+ if check_api:
100
+ try:
101
+ response = httpx.get(get_api_base().rsplit("/v1", 1)[0] + "/health", timeout=10)
102
+ checks.append(("api_reachable", response.status_code < 500, "Valicode API reachable", True))
103
+ except httpx.HTTPError:
104
+ checks.append(("api_reachable", False, "Valicode API reachable", True))
105
+
106
+ click.echo(f"{'Check':18} {'Status':8} Detail")
107
+ for check_id, ok, detail, required in checks:
108
+ click.echo(f"{check_id:18} {'PASS' if ok else 'FAIL':8} {detail}")
109
+
110
+ if not all(ok for _, ok, _, required in checks if required):
111
+ sys.exit(1)
112
+
113
+
114
+ @cli.command()
115
+ @click.argument("path", type=click.Path(exists=True), required=False)
116
+ @click.option("--diff", "diff_file", type=click.Path(exists=True), help="Path to a .diff file")
117
+ @click.option("--staged", is_flag=True, help="Analyze staged git changes")
118
+ @click.option("--pr", type=int, help="GitHub PR number")
119
+ @click.option("--repo", help="GitHub repo (owner/repo)")
120
+ @click.option("--output", type=click.Choice(OUTPUT_FORMATS), default="table")
121
+ @click.option("--save-report", type=click.Path(file_okay=False), help="Write a full local report bundle to this directory")
122
+ @click.option("--fail-under", type=int, default=0, help="Exit 1 if score below threshold")
123
+ @click.option("--context/--no-context", default=True, help="Include safe repository context for cross-file analysis")
124
+ @click.option("--run-tests", is_flag=True, help="Run detected local tests and include the redacted result")
125
+ @click.option("--sandbox", type=click.Choice(["auto", "microvm", "docker", "local", "none"]), default="auto")
126
+ @click.option("--validate-fixes", is_flag=True, help="Apply suggested patches in a temporary workspace and validate them")
127
+ def analyze(path, diff_file, staged, pr, repo, output, save_report, fail_under, context, run_tests, sandbox, validate_fixes):
128
+ """Analyze a file, diff, staged changes, or GitHub PR."""
129
+ cfg = load_config()
130
+ api_key = cfg.get("api_key")
131
+ if not api_key:
132
+ click.echo("Error: No API key configured. Run valicode login first.", err=True)
133
+ sys.exit(1)
134
+
135
+ diff_content = _get_diff_content(path, diff_file, staged, pr, repo, cfg)
136
+ if not diff_content:
137
+ click.echo("No changes to analyze.")
138
+ sys.exit(0)
139
+
140
+ repo_context = build_repository_context(Path.cwd(), include_files=context)
141
+ if run_tests:
142
+ repo_context["test_runs"] = run_local_validation(Path.cwd(), sandbox=sandbox)
143
+ click.echo(f"Analyzing code with {len(repo_context.get('files', []))} context file(s)...")
144
+ try:
145
+ response = httpx.post(
146
+ f"{get_api_base()}/analyses",
147
+ headers={"X-API-Key": api_key, "Content-Type": "application/json"},
148
+ json={"diff": diff_content, "repo": repo, "repo_context": repo_context, "force_reanalysis": True},
149
+ timeout=120,
150
+ )
151
+ response.raise_for_status()
152
+ result = response.json()
153
+ except httpx.HTTPStatusError as exc:
154
+ if exc.response.status_code == 401:
155
+ click.echo("Authentication failed. Check your API key.", err=True)
156
+ elif exc.response.status_code == 429:
157
+ click.echo("Rate limit exceeded. Wait before retrying.", err=True)
158
+ else:
159
+ click.echo(f"API error {exc.response.status_code}", err=True)
160
+ sys.exit(1)
161
+ except httpx.TimeoutException:
162
+ click.echo("Request timed out.", err=True)
163
+ sys.exit(1)
164
+
165
+ if validate_fixes:
166
+ result["fix_validations"] = validate_suggested_fixes(Path.cwd(), result, api_key, sandbox=sandbox)
167
+
168
+ if save_report:
169
+ written = save_report_bundle(Path(save_report), result)
170
+ click.echo(f"Report bundle written to {Path(save_report).resolve()} ({len(written)} files)")
171
+ print_result(result, output)
172
+ if output in {"table", "summary", "chat"}:
173
+ print_analysis_links(result)
174
+
175
+ score = result.get("score", 100)
176
+ if result.get("merge_gate", {}).get("status") == "fail":
177
+ click.echo("\nMerge gate failed because validated blockers were found.", err=True)
178
+ sys.exit(1)
179
+ if fail_under and score < fail_under:
180
+ click.echo(f"\nScore {score} is below threshold {fail_under}. Failing.", err=True)
181
+ sys.exit(1)
182
+
183
+
184
+ @cli.command()
185
+ @click.argument("path", type=click.Path(exists=True, file_okay=False), default=".")
186
+ @click.option("--production", is_flag=True, help="Run full production-readiness workspace scan")
187
+ @click.option("--repo", help="GitHub repo (owner/repo)")
188
+ @click.option("--output", type=click.Choice(OUTPUT_FORMATS), default="table")
189
+ @click.option("--save-report", type=click.Path(file_okay=False), help="Write a full local report bundle to this directory")
190
+ @click.option("--fail-under", type=int, default=0, help="Exit 1 if score below threshold")
191
+ @click.option("--max-upload-mb", type=int, default=500, help="Maximum workspace payload size in MB")
192
+ @click.option("--max-file-kb", type=int, default=2048, help="Maximum individual file size in KB")
193
+ @click.option("--include-lockfiles", is_flag=True, help="Include lockfiles in the workspace payload")
194
+ @click.option("--dry-run", is_flag=True, help="Show files that would be sent without calling the API")
195
+ @click.option("--sandbox", type=click.Choice(["auto", "microvm", "docker", "local", "none"]), default="auto", help="Isolation policy for build and test execution")
196
+ @click.option("--fuzz-command", help="Explicit fuzz command to run under the selected sandbox")
197
+ @click.option("--validate-fixes", is_flag=True, help="Validate suggested patches in an isolated temporary workspace")
198
+ def scan(path, production, repo, output, save_report, fail_under, max_upload_mb, max_file_kb, include_lockfiles, dry_run, sandbox, fuzz_command, validate_fixes):
199
+ """Scan a whole workspace, ignoring dependencies, build output, and binaries."""
200
+ cfg = load_config()
201
+ api_key = cfg.get("api_key")
202
+ if not api_key and not dry_run:
203
+ click.echo("Error: No API key configured. Run valicode login first.", err=True)
204
+ sys.exit(1)
205
+
206
+ root = Path(path).resolve()
207
+ files, ignored = collect_workspace_files(
208
+ root,
209
+ max_total_bytes=max_upload_mb * 1024 * 1024,
210
+ max_file_bytes=max_file_kb * 1024,
211
+ include_lockfiles=include_lockfiles,
212
+ )
213
+
214
+ if not files:
215
+ click.echo("No source files found to scan.")
216
+ sys.exit(0)
217
+
218
+ manifest = {
219
+ "mode": "production" if production else "workspace",
220
+ "root": str(root),
221
+ "files_scanned": len(files),
222
+ "bytes_scanned": sum(item["size"] for item in files),
223
+ "ignored_count": len(ignored),
224
+ "eligible_files": len(files),
225
+ }
226
+
227
+ if dry_run:
228
+ _print_scan_manifest(files, ignored, manifest)
229
+ return
230
+
231
+ diff_content = workspace_files_to_diff(root, files)
232
+ context = build_repository_context(root, include_files=False)
233
+ context["scan"] = manifest
234
+ if production:
235
+ click.echo("Running local validation commands...")
236
+ context["test_runs"] = run_local_validation(root, sandbox=sandbox)
237
+ if fuzz_command:
238
+ context["test_runs"].extend(run_explicit_command(root, fuzz_command, sandbox=sandbox, kind="fuzz"))
239
+ context["sandbox"] = {"requested": sandbox, **sandbox_capabilities()}
240
+ click.echo("Scanning workspace...")
241
+ try:
242
+ response = httpx.post(
243
+ f"{get_api_base()}/analyses",
244
+ headers={"X-API-Key": api_key, "Content-Type": "application/json"},
245
+ json={"diff": diff_content, "repo": repo, "repo_context": context, "force_reanalysis": True},
246
+ timeout=300,
247
+ )
248
+ response.raise_for_status()
249
+ result = response.json()
250
+ except httpx.HTTPStatusError as exc:
251
+ click.echo(f"API error {exc.response.status_code}", err=True)
252
+ sys.exit(1)
253
+ except httpx.TimeoutException:
254
+ click.echo("Workspace scan timed out.", err=True)
255
+ sys.exit(1)
256
+
257
+ if validate_fixes:
258
+ result["fix_validations"] = validate_suggested_fixes(root, result, api_key, sandbox=sandbox)
259
+
260
+ result["workspace_manifest"] = manifest
261
+ if save_report:
262
+ written = save_report_bundle(Path(save_report), result)
263
+ click.echo(f"Report bundle written to {Path(save_report).resolve()} ({len(written)} files)")
264
+ print_result(result, output)
265
+ if output == "table":
266
+ click.echo(f"Workspace files scanned: {manifest['files_scanned']} / bytes: {manifest['bytes_scanned']} / ignored: {manifest['ignored_count']}")
267
+ print_analysis_links(result)
268
+ elif output in {"summary", "chat"}:
269
+ print_analysis_links(result)
270
+
271
+ score = result.get("score", 100)
272
+ if result.get("merge_gate", {}).get("status") == "fail":
273
+ click.echo("\nMerge gate failed because validated blockers or failed checks were found.", err=True)
274
+ sys.exit(1)
275
+ if fail_under and score < fail_under:
276
+ click.echo(f"\nScore {score} is below threshold {fail_under}. Failing.", err=True)
277
+ sys.exit(1)
278
+
279
+
280
+ def build_repository_context(root: Path, *, include_files: bool = True) -> dict:
281
+ root = root.resolve()
282
+ if (root / ".git").exists() is False and shutil.which("git"):
283
+ git_root = subprocess.run(
284
+ ["git", "rev-parse", "--show-toplevel"], cwd=root, capture_output=True, text=True, check=False,
285
+ ).stdout.strip()
286
+ if git_root:
287
+ root = Path(git_root).resolve()
288
+ files, ignored = collect_workspace_files(
289
+ root,
290
+ max_total_bytes=25 * 1024 * 1024 if include_files else DEFAULT_MAX_UPLOAD_BYTES,
291
+ max_file_bytes=512 * 1024,
292
+ include_lockfiles=True,
293
+ )
294
+ suffix_counts: dict[str, int] = {}
295
+ for item in files:
296
+ suffix = Path(item["path"]).suffix.lower() or Path(item["path"]).name
297
+ suffix_counts[suffix] = suffix_counts.get(suffix, 0) + 1
298
+ language_map = {
299
+ ".py": "Python", ".ts": "TypeScript", ".tsx": "TypeScript/React",
300
+ ".js": "JavaScript", ".jsx": "JavaScript/React", ".go": "Go",
301
+ ".rs": "Rust", ".java": "Java", ".cs": "C#", ".php": "PHP",
302
+ }
303
+ dominant = max(suffix_counts, key=suffix_counts.get) if suffix_counts else ""
304
+ context = {
305
+ "primary_language": language_map.get(dominant, dominant or "unknown"),
306
+ "frameworks": detect_frameworks(root),
307
+ "base_sha": git_value(root, ["rev-parse", "HEAD~1"]),
308
+ "head_sha": git_value(root, ["rev-parse", "HEAD"]),
309
+ "scan": {
310
+ "mode": "contextual-diff" if include_files else "workspace",
311
+ "eligible_files": len(files),
312
+ "ignored_count": len(ignored),
313
+ "bytes_scanned": sum(item["size"] for item in files),
314
+ },
315
+ }
316
+ if include_files:
317
+ context["files"] = [
318
+ {
319
+ "path": item["path"],
320
+ "content": (root / item["path"]).read_text(encoding="utf-8", errors="replace"),
321
+ "size": item["size"],
322
+ }
323
+ for item in files
324
+ ]
325
+ return context
326
+
327
+
328
+ def git_value(root: Path, args: list[str]) -> str | None:
329
+ if not shutil.which("git"):
330
+ return None
331
+ result = subprocess.run(["git", *args], cwd=root, capture_output=True, text=True, check=False)
332
+ return result.stdout.strip() or None
333
+
334
+
335
+ def detect_frameworks(root: Path) -> list[str]:
336
+ frameworks = []
337
+ package_json = root / "package.json"
338
+ if package_json.exists():
339
+ try:
340
+ package = json.loads(package_json.read_text(encoding="utf-8"))
341
+ dependencies = {**package.get("dependencies", {}), **package.get("devDependencies", {})}
342
+ for package_name, label in {"next": "Next.js", "react": "React", "vue": "Vue", "express": "Express", "nestjs": "NestJS"}.items():
343
+ if package_name in dependencies:
344
+ frameworks.append(label)
345
+ except (OSError, json.JSONDecodeError):
346
+ pass
347
+ pyproject = (root / "pyproject.toml")
348
+ if pyproject.exists():
349
+ content = pyproject.read_text(encoding="utf-8", errors="ignore").lower()
350
+ for marker, label in {"fastapi": "FastAPI", "django": "Django", "flask": "Flask"}.items():
351
+ if marker in content:
352
+ frameworks.append(label)
353
+ return sorted(set(frameworks))
354
+
355
+
356
+ def run_local_validation(root: Path, *, sandbox: str = "local") -> list[dict]:
357
+ commands: list[list[str]] = []
358
+ if shutil.which("gitleaks"):
359
+ commands.append(["gitleaks", "detect", "--source", ".", "--no-git", "--redact", "--exit-code", "1"])
360
+ if shutil.which("ruff") and any(root.rglob("*.py")):
361
+ commands.append(["ruff", "check", "."])
362
+ if (root / "pyproject.toml").exists() and shutil.which("python"):
363
+ commands.append([sys.executable, "-m", "compileall", "-q", "."])
364
+ commands.append([sys.executable, "-m", "pytest", "-q"])
365
+ if (root / "package.json").exists():
366
+ try:
367
+ package = json.loads((root / "package.json").read_text(encoding="utf-8"))
368
+ scripts = package.get("scripts", {})
369
+ manager = "pnpm" if (root / "pnpm-lock.yaml").exists() else "npm"
370
+ if "test" in scripts and shutil.which(manager):
371
+ commands.append([manager, "test"] if manager == "pnpm" else [manager, "test", "--", "--runInBand"])
372
+ if "lint" in scripts and shutil.which(manager):
373
+ commands.append([manager, "lint"])
374
+ except (OSError, json.JSONDecodeError):
375
+ pass
376
+ if (root / "go.mod").exists() and shutil.which("go"):
377
+ commands.append(["go", "test", "./..."])
378
+ if (root / "Cargo.toml").exists() and shutil.which("cargo"):
379
+ commands.append(["cargo", "test", "--quiet"])
380
+
381
+ runs = []
382
+ for command in commands[:8]:
383
+ runs.extend(_execute_validation_command(root, command, sandbox=sandbox, kind="validation"))
384
+ return runs
385
+
386
+
387
+ def run_explicit_command(root: Path, command: str, *, sandbox: str, kind: str) -> list[dict]:
388
+ try:
389
+ args = shlex.split(command, posix=os.name != "nt")
390
+ except ValueError as exc:
391
+ return [{"command": command, "status": "failed", "exit_code": None, "duration_ms": 0, "output_summary": f"Invalid command: {exc}", "kind": kind}]
392
+ return _execute_validation_command(root, args, sandbox=sandbox, kind=kind)
393
+
394
+
395
+ def sandbox_capabilities() -> dict:
396
+ microvm_runner = load_config().get("microvm_runner")
397
+ firecracker = shutil.which("firecracker")
398
+ has_kvm = Path("/dev/kvm").exists()
399
+ if os.name == "nt":
400
+ microvm_detail = "Firecracker microVM requires a Linux host with KVM; this Windows host cannot run it directly."
401
+ elif microvm_runner:
402
+ microvm_detail = f"microvm_runner configured: {microvm_runner}"
403
+ elif firecracker and has_kvm:
404
+ microvm_detail = "firecracker and /dev/kvm detected; configure microvm_runner to execute validation commands."
405
+ else:
406
+ microvm_detail = "Firecracker microVM unavailable; install Firecracker on Linux/KVM or configure microvm_runner."
407
+ return {
408
+ "docker_available": shutil.which("docker") is not None,
409
+ "microvm_available": bool(microvm_runner and os.name != "nt"),
410
+ "microvm_runner": microvm_runner,
411
+ "microvm_detail": microvm_detail,
412
+ }
413
+
414
+
415
+ def _execute_validation_command(root: Path, command: list[str], *, sandbox: str, kind: str) -> list[dict]:
416
+ command_text = " ".join(command)
417
+ selected = sandbox
418
+ cfg = load_config()
419
+ if sandbox == "auto":
420
+ selected = "docker" if shutil.which("docker") and cfg.get("audit_image") else "none"
421
+ if selected == "none":
422
+ return [{
423
+ "command": command_text, "status": "skipped", "exit_code": None, "duration_ms": 0,
424
+ "output_summary": "Execution skipped: configure audit_image for Docker or explicitly use --sandbox local.",
425
+ "kind": kind, "sandbox": "none",
426
+ }]
427
+
428
+ actual_command = command
429
+ cwd = root
430
+ temp_workspace = None
431
+ if selected == "microvm":
432
+ microvm_runner = cfg.get("microvm_runner")
433
+ if os.name == "nt" or not microvm_runner:
434
+ return [{
435
+ "command": command_text, "status": "skipped", "exit_code": None, "duration_ms": 0,
436
+ "output_summary": sandbox_capabilities()["microvm_detail"],
437
+ "kind": kind, "sandbox": "microvm",
438
+ }]
439
+ temp_workspace = tempfile.TemporaryDirectory(prefix="valicode-microvm-")
440
+ workspace = Path(temp_workspace.name) / "workspace"
441
+ shutil.copytree(root, workspace, ignore=shutil.ignore_patterns(*DEFAULT_IGNORE_DIRS))
442
+ actual_command = [microvm_runner, str(workspace), *command]
443
+ cwd = workspace
444
+ if selected == "docker":
445
+ image = cfg.get("audit_image")
446
+ if not image or not shutil.which("docker"):
447
+ return [{
448
+ "command": command_text, "status": "skipped", "exit_code": None, "duration_ms": 0,
449
+ "output_summary": "Docker sandbox unavailable or audit_image is not configured.",
450
+ "kind": kind, "sandbox": "docker",
451
+ }]
452
+ temp_workspace = tempfile.TemporaryDirectory(prefix="valicode-audit-")
453
+ workspace = Path(temp_workspace.name) / "workspace"
454
+ shutil.copytree(root, workspace, ignore=shutil.ignore_patterns(*DEFAULT_IGNORE_DIRS))
455
+ actual_command = [
456
+ "docker", "run", "--rm", "--network", "none", "--cpus", "2", "--memory", "2g",
457
+ "--pids-limit", "256", "--security-opt", "no-new-privileges", "--cap-drop", "ALL",
458
+ "-v", f"{workspace}:/workspace", "-w", "/workspace", image, *command,
459
+ ]
460
+ cwd = workspace
461
+
462
+ try:
463
+ started = time.perf_counter()
464
+ try:
465
+ result = subprocess.run(actual_command, cwd=cwd, capture_output=True, text=True, timeout=180, check=False)
466
+ output = redact_local_output((result.stdout or "") + "\n" + (result.stderr or ""))
467
+ return [{
468
+ "command": command_text,
469
+ "status": "passed" if result.returncode == 0 else "failed",
470
+ "exit_code": result.returncode,
471
+ "duration_ms": int((time.perf_counter() - started) * 1000),
472
+ "output_summary": output[-4000:],
473
+ "kind": kind, "sandbox": selected,
474
+ }]
475
+ except subprocess.TimeoutExpired:
476
+ return [{
477
+ "command": command_text, "status": "failed", "exit_code": None,
478
+ "duration_ms": int((time.perf_counter() - started) * 1000),
479
+ "output_summary": "Command timed out after 180 seconds.",
480
+ "kind": kind, "sandbox": selected,
481
+ }]
482
+ finally:
483
+ if temp_workspace is not None:
484
+ temp_workspace.cleanup()
485
+
486
+
487
+ def redact_local_output(output: str) -> str:
488
+ import re
489
+ output = re.sub(r"(?i)(api[_-]?key|secret|token|password)(\s*[:=]\s*)\S+", r"\1\2[REDACTED]", output)
490
+ output = re.sub(r"(?i)(sk-|ghp_|github_pat_)[A-Za-z0-9_-]{12,}", "[REDACTED]", output)
491
+ return output
492
+
493
+
494
+ def validate_suggested_fixes(root: Path, result: dict, api_key: str, *, sandbox: str) -> list[dict]:
495
+ validations = []
496
+ analysis_id = result.get("id")
497
+ if not analysis_id or not shutil.which("git"):
498
+ return [{"status": "skipped", "reason": "Analysis id or git executable is unavailable."}]
499
+ for issue in [item for item in result.get("issues", []) if item.get("fix_patch") and item.get("id")][:20]:
500
+ patch = issue["fix_patch"]
501
+ patch_hash = hashlib.sha256(patch.encode()).hexdigest()
502
+ with tempfile.TemporaryDirectory(prefix="valicode-fix-") as temp_dir:
503
+ workspace = Path(temp_dir) / "workspace"
504
+ shutil.copytree(root, workspace, ignore=shutil.ignore_patterns(*DEFAULT_IGNORE_DIRS))
505
+ patch_file = Path(temp_dir) / "fix.patch"
506
+ patch_file.write_text(patch, encoding="utf-8")
507
+ check = subprocess.run(["git", "apply", "--check", str(patch_file)], cwd=workspace, capture_output=True, text=True, check=False)
508
+ syntax_valid = check.returncode == 0
509
+ test_runs = []
510
+ if syntax_valid:
511
+ applied = subprocess.run(["git", "apply", str(patch_file)], cwd=workspace, capture_output=True, text=True, check=False)
512
+ syntax_valid = applied.returncode == 0
513
+ if syntax_valid:
514
+ test_runs = run_local_validation(workspace, sandbox=sandbox)
515
+ executed = [run for run in test_runs if run.get("status") in {"passed", "failed"}]
516
+ tests_passed = bool(executed) and all(run.get("status") == "passed" for run in executed)
517
+ status_value = "validated" if syntax_valid and tests_passed else "syntax_valid" if syntax_valid else "rejected"
518
+ summary = "Patch applies cleanly. " if syntax_valid else redact_local_output(check.stderr or "Patch does not apply cleanly.")
519
+ if test_runs:
520
+ summary += "; ".join(f"{run.get('command')}={run.get('status')}" for run in test_runs)
521
+ payload = {
522
+ "issue_id": issue["id"], "patch_hash": patch_hash, "status": status_value,
523
+ "syntax_valid": syntax_valid, "tests_passed": tests_passed if executed else None,
524
+ "validation_summary": summary[:2000],
525
+ }
526
+ try:
527
+ response = httpx.post(
528
+ f"{get_api_base()}/analyses/{analysis_id}/fix-validation",
529
+ headers={"X-API-Key": api_key, "Content-Type": "application/json"},
530
+ json=payload,
531
+ timeout=30,
532
+ )
533
+ response.raise_for_status()
534
+ payload["recorded"] = True
535
+ except httpx.HTTPError:
536
+ payload["recorded"] = False
537
+ validations.append(payload)
538
+ return validations
539
+
540
+
541
+ def _get_diff_content(path, diff_file, staged, pr, repo, cfg) -> str | None:
542
+ if staged:
543
+ result = subprocess.run(["git", "diff", "--cached"], capture_output=True, text=True, check=False)
544
+ return result.stdout or None
545
+ if diff_file:
546
+ return Path(diff_file).read_text()
547
+ if path:
548
+ path_obj = Path(path)
549
+ if path_obj.is_dir():
550
+ click.echo("Error: Directories must be scanned with valicode scan.", err=True)
551
+ sys.exit(1)
552
+ content = path_obj.read_text()
553
+ return f"+++ b/{path}\n" + "\n".join(f"+{line}" for line in content.splitlines())
554
+ if pr and repo:
555
+ token = cfg.get("github_token")
556
+ headers = {"Accept": "application/vnd.github.diff"}
557
+ if token:
558
+ headers["Authorization"] = f"Bearer {token}"
559
+ resp = httpx.get(f"https://api.github.com/repos/{repo}/pulls/{pr}", headers=headers, timeout=30)
560
+ if resp.status_code == 200:
561
+ return resp.text
562
+ return None
563
+
564
+
565
+ def collect_workspace_files(
566
+ root: Path,
567
+ *,
568
+ max_total_bytes: int = DEFAULT_MAX_UPLOAD_BYTES,
569
+ max_file_bytes: int = DEFAULT_MAX_FILE_BYTES,
570
+ include_lockfiles: bool = False,
571
+ ) -> tuple[list[dict], list[dict]]:
572
+ ignore_patterns = load_valicodeignore(root)
573
+ files: list[dict] = []
574
+ ignored: list[dict] = []
575
+ total = 0
576
+
577
+ for current_root, dirnames, filenames in os.walk(root):
578
+ current_path = Path(current_root)
579
+ rel_dir = current_path.relative_to(root).as_posix()
580
+
581
+ kept_dirs = []
582
+ for dirname in dirnames:
583
+ rel_path = dirname if rel_dir == "." else f"{rel_dir}/{dirname}"
584
+ if dirname in DEFAULT_IGNORE_DIRS or matches_ignore(rel_path, ignore_patterns):
585
+ ignored.append({"path": rel_path, "reason": "ignored_dir"})
586
+ else:
587
+ kept_dirs.append(dirname)
588
+ dirnames[:] = kept_dirs
589
+
590
+ for filename in filenames:
591
+ file_path = current_path / filename
592
+ rel_path = file_path.relative_to(root).as_posix()
593
+ reason = should_ignore_file(file_path, rel_path, ignore_patterns, include_lockfiles, max_file_bytes)
594
+ if reason:
595
+ ignored.append({"path": rel_path, "reason": reason})
596
+ continue
597
+ size = file_path.stat().st_size
598
+ if total + size > max_total_bytes:
599
+ ignored.append({"path": rel_path, "reason": "max_upload_exceeded"})
600
+ continue
601
+ total += size
602
+ files.append({"path": rel_path, "size": size})
603
+
604
+ return sorted(files, key=lambda item: item["path"]), ignored
605
+
606
+
607
+ def load_valicodeignore(root: Path) -> list[str]:
608
+ ignore_file = root / ".valicodeignore"
609
+ if not ignore_file.exists():
610
+ return []
611
+ patterns = []
612
+ for line in ignore_file.read_text(encoding="utf-8", errors="ignore").splitlines():
613
+ stripped = line.strip()
614
+ if stripped and not stripped.startswith("#"):
615
+ patterns.append(stripped)
616
+ return patterns
617
+
618
+
619
+ def matches_ignore(rel_path: str, patterns: list[str]) -> bool:
620
+ normalized = rel_path.strip("/")
621
+ for pattern in patterns:
622
+ normalized_pattern = pattern.strip("/")
623
+ if fnmatch.fnmatch(normalized, normalized_pattern) or fnmatch.fnmatch(normalized, f"{normalized_pattern}/*"):
624
+ return True
625
+ return False
626
+
627
+
628
+ def should_ignore_file(
629
+ file_path: Path,
630
+ rel_path: str,
631
+ ignore_patterns: list[str],
632
+ include_lockfiles: bool,
633
+ max_file_bytes: int,
634
+ ) -> str | None:
635
+ name = file_path.name
636
+ suffix = file_path.suffix.lower()
637
+ if matches_ignore(rel_path, ignore_patterns):
638
+ return "valicodeignore"
639
+ if name == ".valicodeignore":
640
+ return "valicodeignore"
641
+ if name.startswith(".env") and name not in {".env.example", ".env.sample"}:
642
+ return "env_file"
643
+ if suffix in DEFAULT_IGNORE_EXTENSIONS:
644
+ return "binary_or_asset"
645
+ if name in LOCKFILE_NAMES and not include_lockfiles:
646
+ return "lockfile"
647
+ if file_path.stat().st_size > max_file_bytes:
648
+ return "max_file_size"
649
+ if is_binary_file(file_path):
650
+ return "binary"
651
+ if suffix and suffix not in SOURCE_EXTENSIONS and name not in {"Dockerfile", "Makefile", ".env.example", ".env.sample"}:
652
+ return "unsupported_extension"
653
+ return None
654
+
655
+
656
+ def is_binary_file(path: Path) -> bool:
657
+ try:
658
+ chunk = path.read_bytes()[:4096]
659
+ except OSError:
660
+ return True
661
+ return b"\x00" in chunk
662
+
663
+
664
+ def workspace_files_to_diff(root: Path, files: list[dict]) -> str:
665
+ parts = []
666
+ for item in files:
667
+ rel_path = item["path"]
668
+ file_path = root / rel_path
669
+ content = file_path.read_text(encoding="utf-8", errors="replace")
670
+ parts.append(f"diff --git a/{rel_path} b/{rel_path}")
671
+ parts.append("--- /dev/null")
672
+ parts.append(f"+++ b/{rel_path}")
673
+ parts.append("@@ -0,0 +1,0 @@")
674
+ parts.extend(f"+{line}" for line in content.splitlines())
675
+ return "\n".join(parts) + "\n"
676
+
677
+
678
+ def _print_scan_manifest(files: list[dict], ignored: list[dict], manifest: dict) -> None:
679
+ click.echo("Valicode workspace scan")
680
+ click.echo(f"{manifest['files_scanned']} file(s) / {manifest['bytes_scanned']} bytes / {manifest['ignored_count']} ignored")
681
+ click.echo(f"{'Path':60} Size")
682
+ for item in files[:100]:
683
+ click.echo(f"{item['path'][:60]:60} {item['size']}")
684
+ if len(files) > 100:
685
+ click.echo(f"... {len(files) - 100} more file(s)")
686
+ ignored_reasons: dict[str, int] = {}
687
+ for item in ignored:
688
+ ignored_reasons[item["reason"]] = ignored_reasons.get(item["reason"], 0) + 1
689
+ if ignored_reasons:
690
+ click.echo("Ignored: " + ", ".join(f"{reason}={count}" for reason, count in sorted(ignored_reasons.items())))
691
+
692
+
693
+ def _print_table(result: dict) -> None:
694
+ score = result.get("score", 0)
695
+ issues = result.get("issues", [])
696
+ click.echo("Valicode")
697
+ click.echo(f"Score: {score}/100 / {len(issues)} issue(s) found / {result.get('files_analyzed', 0)} file(s) analyzed")
698
+ coverage = result.get("coverage") or {}
699
+ gate = result.get("merge_gate") or {}
700
+ if coverage:
701
+ click.echo(
702
+ f"Coverage: {coverage.get('coverage_percent', 0)}% "
703
+ f"({coverage.get('analyzed_files', 0)}/{coverage.get('eligible_files', 0)} files)"
704
+ )
705
+ click.echo(f"Merge gate: {str(gate.get('status', 'unknown')).upper()} / blockers: {gate.get('blocker_count', 0)}")
706
+ runs = result.get("analysis_runs") or []
707
+ if runs:
708
+ click.echo("Checks: " + ", ".join(
709
+ f"{run.get('tool')}={run.get('status')} ({run.get('findings_count', 0)} findings, {run.get('duration_ms', 0)}ms)"
710
+ for run in runs
711
+ ))
712
+ test_runs = result.get("test_runs") or []
713
+ if test_runs:
714
+ click.echo("Local validation: " + ", ".join(f"{run.get('command')}={run.get('status')}" for run in test_runs))
715
+ if result.get("summary"):
716
+ click.echo(result.get("summary", ""))
717
+ fingerprint = result.get("ai_fingerprint") or {}
718
+ if fingerprint.get("any_ai_detected"):
719
+ confidence = round(float(fingerprint.get("dominant_confidence") or 0) * 100)
720
+ click.echo(f"AI: {fingerprint.get('dominant_tool', 'unknown')} ({confidence}% confidence)")
721
+ if not issues:
722
+ click.echo("No issues detected.")
723
+ return
724
+ click.echo(f"{'Severity':10} {'Category':14} {'File':30} {'Line':6} Issue")
725
+ order = ["critical", "high", "medium", "low", "info"]
726
+ for issue in sorted(issues, key=lambda item: order.index(item.get("severity", "info"))):
727
+ sev = issue.get("severity", "info")
728
+ click.echo(
729
+ f"{sev.upper():10} "
730
+ f"{issue.get('category', '')[:14]:14} "
731
+ f"{issue.get('file_path', '')[-30:]:30} "
732
+ f"{str(issue.get('line_start', '')):6} "
733
+ f"{issue.get('title', '')}"
734
+ )
735
+ confidence = round(float(issue.get("confidence") or 0) * 100)
736
+ flags = []
737
+ if issue.get("is_new"):
738
+ flags.append("new")
739
+ if issue.get("blocks_merge"):
740
+ flags.append("blocks merge")
741
+ click.echo(f" Confidence: {confidence}% / validation: {issue.get('validation_status', 'unknown')} / {', '.join(flags) or 'existing risk'}")
742
+ if issue.get("impact"):
743
+ click.echo(f" Impact: {issue['impact']}")
744
+ if issue.get("code_snippet"):
745
+ click.echo(" Evidence: " + str(issue["code_snippet"]).replace("\n", " | ")[:300])
746
+ if issue.get("suggestion"):
747
+ click.echo(f" Fix: {issue['suggestion']}")
748
+ if issue.get("remediation_test"):
749
+ click.echo(f" Regression test: {issue['remediation_test']}")
750
+
751
+ breakdown = result.get("score_breakdown") or []
752
+ if breakdown:
753
+ click.echo("\nScore breakdown:")
754
+ for item in breakdown[:20]:
755
+ click.echo(f" -{item.get('penalty', 0):>5}: {item.get('reason', '')}")
756
+
757
+
758
+ def _to_sarif(result: dict) -> str:
759
+ sarif = {
760
+ "version": "2.1.0",
761
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
762
+ "runs": [
763
+ {
764
+ "tool": {"driver": {"name": "Valicode", "rules": []}},
765
+ "results": [],
766
+ }
767
+ ],
768
+ }
769
+ for issue in result.get("issues", []):
770
+ sarif["runs"][0]["results"].append(
771
+ {
772
+ "ruleId": issue.get("rule_id") or issue.get("category", "aov"),
773
+ "level": "error" if issue.get("severity") in ("critical", "high") else "warning",
774
+ "message": {"text": issue.get("title", "")},
775
+ "locations": [
776
+ {
777
+ "physicalLocation": {
778
+ "artifactLocation": {"uri": issue.get("file_path", "")},
779
+ "region": {"startLine": issue.get("line_start") or 1},
780
+ }
781
+ }
782
+ ],
783
+ }
784
+ )
785
+ return json.dumps(sarif, indent=2)
786
+
787
+
788
+ def print_result(result: dict, output: str) -> None:
789
+ if output == "json":
790
+ click.echo(json.dumps(result, indent=2, default=str))
791
+ elif output == "sarif":
792
+ click.echo(_to_sarif(result))
793
+ elif output == "markdown":
794
+ click.echo(_to_markdown(result))
795
+ elif output == "html":
796
+ click.echo(_to_html(result))
797
+ elif output == "junit":
798
+ click.echo(_to_junit(result))
799
+ elif output == "summary":
800
+ click.echo(_to_summary(result))
801
+ elif output == "mermaid":
802
+ click.echo(_to_mermaid(result))
803
+ elif output == "chat":
804
+ click.echo(_to_chat_alert(result))
805
+ else:
806
+ _print_table(result)
807
+
808
+
809
+ def print_analysis_links(result: dict) -> None:
810
+ links = result.get("links") or {}
811
+ if not links:
812
+ return
813
+ click.echo("")
814
+ if links.get("dashboard"):
815
+ click.echo(f"Dashboard: {links['dashboard']}")
816
+ if links.get("pdf"):
817
+ click.echo(f"PDF: {links['pdf']}")
818
+ if links.get("sarif"):
819
+ click.echo(f"SARIF: {links['sarif']}")
820
+
821
+
822
+ def save_report_bundle(output_dir: Path, result: dict) -> list[Path]:
823
+ output_dir.mkdir(parents=True, exist_ok=True)
824
+ files = {
825
+ "findings.json": json.dumps(result, indent=2, default=str),
826
+ "findings.sarif": _to_sarif(result),
827
+ "summary.md": _to_markdown(result),
828
+ "report.html": _to_html(result),
829
+ "report.pdf": _to_pdf_bytes(result),
830
+ "chat-alert.txt": _to_chat_alert(result),
831
+ "junit.xml": _to_junit(result),
832
+ "summary.txt": _to_summary(result),
833
+ "architecture.mmd": _to_mermaid(result, graph="architecture"),
834
+ "dataflow.mmd": _to_mermaid(result, graph="dataflow"),
835
+ "score-breakdown.json": json.dumps(result.get("score_breakdown") or [], indent=2, default=str),
836
+ }
837
+ written = []
838
+ for name, content in files.items():
839
+ path = output_dir / name
840
+ if isinstance(content, bytes):
841
+ path.write_bytes(content)
842
+ else:
843
+ path.write_text(content, encoding="utf-8")
844
+ written.append(path)
845
+ return written
846
+
847
+
848
+ def _to_summary(result: dict) -> str:
849
+ issues = result.get("issues", [])
850
+ lines = [
851
+ f"Valicode score: {result.get('score', 0)}/100",
852
+ f"Issues: {len(issues)}",
853
+ f"Files analyzed: {result.get('files_analyzed', 0)}",
854
+ f"Merge gate: {(result.get('merge_gate') or {}).get('status', 'unknown')}",
855
+ ]
856
+ if result.get("summary"):
857
+ lines.append("")
858
+ lines.append(str(result["summary"]))
859
+ by_severity: dict[str, int] = {}
860
+ for issue in issues:
861
+ severity = issue.get("severity", "info")
862
+ by_severity[severity] = by_severity.get(severity, 0) + 1
863
+ if by_severity:
864
+ lines.append("")
865
+ lines.append("By severity: " + ", ".join(f"{key}={value}" for key, value in sorted(by_severity.items())))
866
+ return "\n".join(lines)
867
+
868
+
869
+ def _to_chat_alert(result: dict) -> str:
870
+ issues = result.get("issues", [])
871
+ blockers = [
872
+ issue for issue in issues
873
+ if issue.get("severity") in {"critical", "high"} or issue.get("blocks_merge")
874
+ ]
875
+ shown = blockers[:10] if blockers else issues[:10]
876
+ score = result.get("score", 0)
877
+ gate = (result.get("merge_gate") or {}).get("status", "unknown")
878
+ severity_counts: dict[str, int] = {}
879
+ for issue in issues:
880
+ severity = issue.get("severity", "info")
881
+ severity_counts[severity] = severity_counts.get(severity, 0) + 1
882
+
883
+ if not issues:
884
+ return "\n".join([
885
+ "Valicode alert",
886
+ f"Score: {score}/100",
887
+ "No issues detected.",
888
+ ])
889
+
890
+ lines = [
891
+ "Valicode alert",
892
+ f"Score: {score}/100 | Merge gate: {str(gate).upper()} | Issues: {len(issues)}",
893
+ "Severity: " + ", ".join(f"{key}={severity_counts[key]}" for key in ["critical", "high", "medium", "low", "info"] if key in severity_counts),
894
+ "",
895
+ "Errors to fix:",
896
+ ]
897
+ for index, issue in enumerate(shown, start=1):
898
+ location = _issue_location(issue)
899
+ confidence = round(float(issue.get("confidence") or 0) * 100)
900
+ lines.append(f"{index}. [{str(issue.get('severity', 'info')).upper()}] {issue.get('title', 'Issue')}")
901
+ lines.append(f" Where: {location}")
902
+ if issue.get("category"):
903
+ lines.append(f" Type: {issue.get('category')}")
904
+ if confidence:
905
+ lines.append(f" Confidence: {confidence}%")
906
+ if issue.get("impact"):
907
+ lines.extend(f" Impact: {line}" for line in _wrap_pdf_text(str(issue["impact"]), width=88))
908
+ if issue.get("suggestion"):
909
+ lines.extend(f" Fix: {line}" for line in _wrap_pdf_text(str(issue["suggestion"]), width=88))
910
+ elif issue.get("remediation_test"):
911
+ lines.extend(f" Test: {line}" for line in _wrap_pdf_text(str(issue["remediation_test"]), width=88))
912
+ lines.append("")
913
+ if len(issues) > len(shown):
914
+ lines.append(f"... {len(issues) - len(shown)} more issue(s). Open report.pdf or findings.json for the full list.")
915
+ if result.get("id"):
916
+ lines.append(f"Analysis ID: {result['id']}")
917
+ return "\n".join(lines).strip()
918
+
919
+
920
+ def _issue_location(issue: dict) -> str:
921
+ path = issue.get("file_path") or issue.get("path") or "unknown file"
922
+ line = issue.get("line_start") or issue.get("line") or issue.get("start_line")
923
+ if line:
924
+ return f"{path}:{line}"
925
+ return str(path)
926
+
927
+
928
+ def _to_markdown(result: dict) -> str:
929
+ issues = result.get("issues", [])
930
+ lines = [
931
+ "# Valicode Audit Report",
932
+ "",
933
+ f"- Score: `{result.get('score', 0)}/100`",
934
+ f"- Issues: `{len(issues)}`",
935
+ f"- Files analyzed: `{result.get('files_analyzed', 0)}`",
936
+ f"- Merge gate: `{(result.get('merge_gate') or {}).get('status', 'unknown')}`",
937
+ ]
938
+ if result.get("summary"):
939
+ lines.extend(["", "## Summary", "", str(result["summary"])])
940
+ if result.get("score_breakdown"):
941
+ lines.extend(["", "## Score Breakdown", ""])
942
+ for item in result["score_breakdown"][:30]:
943
+ lines.append(f"- `-{item.get('penalty', 0)}` {item.get('reason', '')}")
944
+ if issues:
945
+ lines.extend(["", "## Findings", ""])
946
+ for issue in issues:
947
+ location = f"{issue.get('file_path', '')}:{issue.get('line_start', '')}".rstrip(":")
948
+ lines.extend([
949
+ f"### {issue.get('title', 'Issue')}",
950
+ "",
951
+ f"- Severity: `{issue.get('severity', 'info')}`",
952
+ f"- Category: `{issue.get('category', 'logic')}`",
953
+ f"- Location: `{location}`",
954
+ f"- Confidence: `{round(float(issue.get('confidence') or 0) * 100)}%`",
955
+ ])
956
+ if issue.get("description"):
957
+ lines.append(f"- Description: {issue['description']}")
958
+ if issue.get("suggestion"):
959
+ lines.append(f"- Fix: {issue['suggestion']}")
960
+ if issue.get("remediation_test"):
961
+ lines.append(f"- Regression test: {issue['remediation_test']}")
962
+ if issue.get("code_snippet"):
963
+ lines.extend(["", "```", str(issue["code_snippet"])[:1200], "```", ""])
964
+ return "\n".join(lines)
965
+
966
+
967
+ def _to_html(result: dict) -> str:
968
+ issue_rows = []
969
+ for issue in result.get("issues", []):
970
+ issue_rows.append(
971
+ "<tr>"
972
+ f"<td>{html.escape(str(issue.get('severity', 'info')))}</td>"
973
+ f"<td>{html.escape(str(issue.get('category', 'logic')))}</td>"
974
+ f"<td>{html.escape(str(issue.get('file_path', '')))}:{html.escape(str(issue.get('line_start', '')))}</td>"
975
+ f"<td>{html.escape(str(issue.get('title', '')))}</td>"
976
+ f"<td>{html.escape(str(issue.get('suggestion', '')))}</td>"
977
+ "</tr>"
978
+ )
979
+ return """<!doctype html>
980
+ <html lang="en">
981
+ <head>
982
+ <meta charset="utf-8">
983
+ <title>Valicode Audit Report</title>
984
+ <style>
985
+ body { font-family: Inter, Arial, sans-serif; margin: 32px; color: #18181b; }
986
+ table { border-collapse: collapse; width: 100%; font-size: 13px; }
987
+ th, td { border-bottom: 1px solid #e4e4e7; padding: 8px; text-align: left; vertical-align: top; }
988
+ .score { font-size: 40px; font-weight: 700; }
989
+ .muted { color: #71717a; }
990
+ </style>
991
+ </head>
992
+ <body>
993
+ <h1>Valicode Audit Report</h1>
994
+ <div class="score">""" + html.escape(str(result.get("score", 0))) + """/100</div>
995
+ <p class="muted">""" + html.escape(str(len(result.get("issues", [])))) + """ issue(s), """ + html.escape(str(result.get("files_analyzed", 0))) + """ file(s) analyzed.</p>
996
+ <p>""" + html.escape(str(result.get("summary", ""))) + """</p>
997
+ <table>
998
+ <thead><tr><th>Severity</th><th>Category</th><th>Location</th><th>Issue</th><th>Fix</th></tr></thead>
999
+ <tbody>""" + "\n".join(issue_rows) + """</tbody>
1000
+ </table>
1001
+ </body>
1002
+ </html>
1003
+ """
1004
+
1005
+
1006
+ def _to_junit(result: dict) -> str:
1007
+ issues = result.get("issues", [])
1008
+ cases = []
1009
+ for issue in issues:
1010
+ name = html.escape(str(issue.get("rule_id") or issue.get("title") or "valicode-issue"))
1011
+ message = html.escape(str(issue.get("title", "")))
1012
+ details = html.escape(f"{issue.get('file_path', '')}:{issue.get('line_start', '')} {issue.get('description', '')}")
1013
+ cases.append(f'<testcase classname="Valicode" name="{name}"><failure message="{message}">{details}</failure></testcase>')
1014
+ return f'<?xml version="1.0" encoding="UTF-8"?><testsuite name="Valicode" tests="{len(issues)}" failures="{len(issues)}">' + "".join(cases) + "</testsuite>"
1015
+
1016
+
1017
+ def _to_pdf_bytes(result: dict) -> bytes:
1018
+ lines = _pdf_report_lines(result)
1019
+ pages = [lines[index:index + 46] for index in range(0, len(lines), 46)] or [[]]
1020
+ objects: list[bytes] = []
1021
+
1022
+ def add_object(payload: bytes) -> int:
1023
+ objects.append(payload)
1024
+ return len(objects)
1025
+
1026
+ font_obj = add_object(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
1027
+ page_refs: list[int] = []
1028
+ page_payloads: list[tuple[int, bytes]] = []
1029
+ pages_obj = 0
1030
+ for page_number, page_lines in enumerate(pages, start=1):
1031
+ content = _pdf_page_stream(page_lines, page_number, len(pages))
1032
+ content_obj = add_object(b"<< /Length " + str(len(content)).encode("ascii") + b" >>\nstream\n" + content + b"\nendstream")
1033
+ page_ref = add_object(b"")
1034
+ page_refs.append(page_ref)
1035
+ page_payloads.append((page_ref, b"<< /Type /Page /Parent {PAGES} 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 " + str(font_obj).encode("ascii") + b" 0 R >> >> /Contents " + str(content_obj).encode("ascii") + b" 0 R >>"))
1036
+ pages_obj = add_object(b"<< /Type /Pages /Kids [" + b" ".join(f"{ref} 0 R".encode("ascii") for ref in page_refs) + b"] /Count " + str(len(page_refs)).encode("ascii") + b" >>")
1037
+ for page_ref, payload in page_payloads:
1038
+ objects[page_ref - 1] = payload.replace(b"{PAGES}", str(pages_obj).encode("ascii"))
1039
+ catalog_obj = add_object(b"<< /Type /Catalog /Pages " + str(pages_obj).encode("ascii") + b" 0 R >>")
1040
+
1041
+ output = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
1042
+ offsets = [0]
1043
+ for index, payload in enumerate(objects, start=1):
1044
+ offsets.append(len(output))
1045
+ output.extend(f"{index} 0 obj\n".encode("ascii"))
1046
+ output.extend(payload)
1047
+ output.extend(b"\nendobj\n")
1048
+ xref_offset = len(output)
1049
+ output.extend(f"xref\n0 {len(objects) + 1}\n".encode("ascii"))
1050
+ output.extend(b"0000000000 65535 f \n")
1051
+ for offset in offsets[1:]:
1052
+ output.extend(f"{offset:010d} 00000 n \n".encode("ascii"))
1053
+ output.extend(
1054
+ f"trailer\n<< /Size {len(objects) + 1} /Root {catalog_obj} 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n".encode("ascii")
1055
+ )
1056
+ return bytes(output)
1057
+
1058
+
1059
+ def _pdf_report_lines(result: dict) -> list[str]:
1060
+ issues = result.get("issues", [])
1061
+ lines = [
1062
+ "Valicode Audit Report",
1063
+ "",
1064
+ f"Score: {result.get('score', 0)}/100",
1065
+ f"Issues: {len(issues)}",
1066
+ f"Files analyzed: {result.get('files_analyzed', 0)}",
1067
+ f"Merge gate: {(result.get('merge_gate') or {}).get('status', 'unknown')}",
1068
+ "",
1069
+ ]
1070
+ if result.get("summary"):
1071
+ lines.extend(_wrap_pdf_text(f"Summary: {result['summary']}"))
1072
+ lines.append("")
1073
+ if result.get("workspace_manifest"):
1074
+ manifest = result["workspace_manifest"]
1075
+ lines.extend([
1076
+ "Workspace",
1077
+ f"Files scanned: {manifest.get('files_scanned', 0)}",
1078
+ f"Bytes scanned: {manifest.get('bytes_scanned', 0)}",
1079
+ f"Ignored: {manifest.get('ignored_count', 0)}",
1080
+ "",
1081
+ ])
1082
+ if result.get("score_breakdown"):
1083
+ lines.append("Score Breakdown")
1084
+ for item in result["score_breakdown"][:20]:
1085
+ lines.extend(_wrap_pdf_text(f"-{item.get('penalty', 0)}: {item.get('reason', '')}"))
1086
+ lines.append("")
1087
+ if issues:
1088
+ lines.append("Findings")
1089
+ for index, issue in enumerate(issues[:80], start=1):
1090
+ location = f"{issue.get('file_path', '')}:{issue.get('line_start', '')}".rstrip(":")
1091
+ header = f"{index}. [{str(issue.get('severity', 'info')).upper()}] {issue.get('title', 'Issue')}"
1092
+ lines.extend(_wrap_pdf_text(header))
1093
+ lines.extend(_wrap_pdf_text(f"Location: {location}"))
1094
+ if issue.get("suggestion"):
1095
+ lines.extend(_wrap_pdf_text(f"Fix: {issue['suggestion']}"))
1096
+ lines.append("")
1097
+ if len(issues) > 80:
1098
+ lines.append(f"... {len(issues) - 80} additional finding(s) omitted from PDF. See findings.json for full data.")
1099
+ return lines
1100
+
1101
+
1102
+ def _pdf_page_stream(lines: list[str], page_number: int, page_count: int) -> bytes:
1103
+ commands = ["BT", "/F1 11 Tf", "50 742 Td", "14 TL"]
1104
+ for line in lines:
1105
+ commands.append(f"({_pdf_escape(line)}) Tj")
1106
+ commands.append("T*")
1107
+ commands.extend(["/F1 9 Tf", f"0 -18 Td", f"(Page {page_number} of {page_count}) Tj", "ET"])
1108
+ return "\n".join(commands).encode("latin-1", errors="replace")
1109
+
1110
+
1111
+ def _wrap_pdf_text(value: str, width: int = 96) -> list[str]:
1112
+ words = str(value).replace("\n", " ").split()
1113
+ lines: list[str] = []
1114
+ current = ""
1115
+ for word in words:
1116
+ candidate = f"{current} {word}".strip()
1117
+ if len(candidate) > width and current:
1118
+ lines.append(current)
1119
+ current = word
1120
+ else:
1121
+ current = candidate
1122
+ if current:
1123
+ lines.append(current)
1124
+ return lines or [""]
1125
+
1126
+
1127
+ def _pdf_escape(value: str) -> str:
1128
+ text = str(value).encode("latin-1", errors="replace").decode("latin-1")
1129
+ return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
1130
+
1131
+
1132
+ def _to_mermaid(result: dict, graph: str = "architecture") -> str:
1133
+ if graph == "dataflow":
1134
+ dataflow = result.get("dataflow") or result.get("data_flow") or {}
1135
+ edges = dataflow.get("edges") if isinstance(dataflow, dict) else None
1136
+ if edges:
1137
+ lines = ["flowchart LR"]
1138
+ for edge in edges[:80]:
1139
+ source = _mermaid_node(edge.get("source") or edge.get("from") or "input")
1140
+ target = _mermaid_node(edge.get("target") or edge.get("to") or "output")
1141
+ label = str(edge.get("label") or edge.get("type") or "")
1142
+ lines.append(f" {source} -->|{_mermaid_label(label)}| {target}")
1143
+ return "\n".join(lines)
1144
+ graph_data = result.get("architecture_graph") or result.get("architecture") or {}
1145
+ if isinstance(graph_data, dict) and graph_data.get("edges"):
1146
+ lines = ["flowchart TD"]
1147
+ for edge in graph_data["edges"][:80]:
1148
+ source = _mermaid_node(edge.get("source") or edge.get("from") or "component")
1149
+ target = _mermaid_node(edge.get("target") or edge.get("to") or "dependency")
1150
+ lines.append(f" {source} --> {target}")
1151
+ return "\n".join(lines)
1152
+ files = sorted({issue.get("file_path") for issue in result.get("issues", []) if issue.get("file_path")})
1153
+ lines = ["flowchart TD", " audit[Valicode audit]"]
1154
+ for path in files[:40]:
1155
+ lines.append(f" audit --> {_mermaid_node(path)}")
1156
+ return "\n".join(lines)
1157
+
1158
+
1159
+ def _mermaid_node(value: str) -> str:
1160
+ safe = "".join(ch if ch.isalnum() else "_" for ch in str(value))[:60].strip("_") or "node"
1161
+ label = _mermaid_label(str(value)[:80])
1162
+ return f'{safe}["{label}"]'
1163
+
1164
+
1165
+ def _mermaid_label(value: str) -> str:
1166
+ return str(value).replace('"', "'").replace("\n", " ")
1167
+
1168
+
1169
+ def api_request(method: str, path: str, *, json_body: dict | None = None, params: dict | None = None, raw: bool = False):
1170
+ cfg = load_config()
1171
+ api_key = cfg.get("api_key")
1172
+ if not api_key:
1173
+ click.echo("Error: No API key configured. Run valicode login first.", err=True)
1174
+ sys.exit(1)
1175
+ url = f"{get_api_base()}/{path.lstrip('/')}"
1176
+ headers = {"X-API-Key": api_key, "Content-Type": "application/json"}
1177
+ try:
1178
+ response = httpx.request(method, url, headers=headers, json=json_body, params=_clean_params(params), timeout=60)
1179
+ response.raise_for_status()
1180
+ except httpx.HTTPStatusError as exc:
1181
+ message = _response_error_message(exc.response)
1182
+ click.echo(f"API error {exc.response.status_code}: {message}", err=True)
1183
+ sys.exit(1)
1184
+ except httpx.HTTPError as exc:
1185
+ click.echo(f"API request failed: {exc}", err=True)
1186
+ sys.exit(1)
1187
+ if raw:
1188
+ return response
1189
+ if not response.content:
1190
+ return {}
1191
+ return response.json()
1192
+
1193
+
1194
+ def _clean_params(params: dict | None) -> dict | None:
1195
+ if not params:
1196
+ return None
1197
+ return {key: value for key, value in params.items() if value is not None}
1198
+
1199
+
1200
+ def _response_error_message(response: httpx.Response) -> str:
1201
+ try:
1202
+ data = response.json()
1203
+ return str(data.get("message") or data.get("detail") or data.get("error") or data)
1204
+ except ValueError:
1205
+ return response.text[:500]
1206
+
1207
+
1208
+ def print_payload(data, *, json_output: bool = False) -> None:
1209
+ if json_output:
1210
+ click.echo(json.dumps(data, indent=2, default=str))
1211
+ return
1212
+ if isinstance(data, dict):
1213
+ _print_dict_payload(data)
1214
+ elif isinstance(data, list):
1215
+ _print_rows(data)
1216
+ else:
1217
+ click.echo(str(data))
1218
+
1219
+
1220
+ def _print_dict_payload(data: dict) -> None:
1221
+ for key, value in data.items():
1222
+ if isinstance(value, list):
1223
+ click.echo(f"{key}:")
1224
+ _print_rows(value)
1225
+ elif isinstance(value, dict):
1226
+ click.echo(f"{key}:")
1227
+ for child_key, child_value in value.items():
1228
+ click.echo(f" {child_key}: {_format_cell(child_value)}")
1229
+ else:
1230
+ click.echo(f"{key}: {_format_cell(value)}")
1231
+
1232
+
1233
+ def _print_rows(rows: list[dict]) -> None:
1234
+ if not rows:
1235
+ click.echo("No records.")
1236
+ return
1237
+ if not all(isinstance(row, dict) for row in rows):
1238
+ for row in rows:
1239
+ click.echo(_format_cell(row))
1240
+ return
1241
+ columns = _select_columns(rows)
1242
+ widths = {column: min(36, max(len(column), *(len(_format_cell(row.get(column))) for row in rows[:50]))) for column in columns}
1243
+ click.echo(" ".join(column[:widths[column]].ljust(widths[column]) for column in columns))
1244
+ for row in rows:
1245
+ click.echo(" ".join(_format_cell(row.get(column))[:widths[column]].ljust(widths[column]) for column in columns))
1246
+
1247
+
1248
+ def _select_columns(rows: list[dict]) -> list[str]:
1249
+ preferred = [
1250
+ "id", "email", "name", "full_name", "repository", "plan", "role", "status", "score",
1251
+ "severity", "category", "title", "created_at", "last_used_at", "requests", "errors",
1252
+ ]
1253
+ present = []
1254
+ keys = set().union(*(row.keys() for row in rows))
1255
+ for key in preferred:
1256
+ if key in keys:
1257
+ present.append(key)
1258
+ for key in sorted(keys):
1259
+ if key not in present and len(present) < 8:
1260
+ present.append(key)
1261
+ return present[:8]
1262
+
1263
+
1264
+ def _format_cell(value) -> str:
1265
+ if value is None:
1266
+ return ""
1267
+ if isinstance(value, (dict, list)):
1268
+ return json.dumps(value, default=str)[:120]
1269
+ return str(value)
1270
+
1271
+
1272
+ @cli.command()
1273
+ def login():
1274
+ """Authenticate with your Valicode account."""
1275
+ api_key = click.prompt("Paste your API key", hide_input=True)
1276
+ cfg = load_config()
1277
+ cfg["api_key"] = api_key.strip()
1278
+ save_config(cfg)
1279
+ click.echo("API key saved.")
1280
+
1281
+
1282
+ @cli.group()
1283
+ def config():
1284
+ """Manage local CLI configuration."""
1285
+
1286
+
1287
+ @config.command("set")
1288
+ @click.argument("key")
1289
+ @click.argument("value")
1290
+ def config_set(key: str, value: str):
1291
+ cfg = load_config()
1292
+ cfg[key] = value
1293
+ save_config(cfg)
1294
+ click.echo(f"Saved {key}.")
1295
+
1296
+
1297
+ @cli.command()
1298
+ @click.option("--json", "json_output", is_flag=True, help="Print raw JSON")
1299
+ def dashboard(json_output: bool):
1300
+ """Show the same operational overview as the web dashboard."""
1301
+ data = api_request("GET", "dashboard/overview")
1302
+ if json_output:
1303
+ print_payload(data, json_output=True)
1304
+ return
1305
+ latest = data.get("latest_analysis") or {}
1306
+ quota = data.get("quota") or {}
1307
+ click.echo("Valicode dashboard")
1308
+ click.echo(f"Current score: {latest.get('score', 'none')}")
1309
+ click.echo(f"Clean streak: {data.get('streak_days', 0)} day(s)")
1310
+ click.echo(f"This week: {data.get('week_avg', 'n/a')} / previous: {data.get('prev_week_avg', 'n/a')}")
1311
+ click.echo(f"Usage: {quota.get('used', 0)} / {quota.get('limit', 'unlimited')} ({quota.get('plan', 'unknown')})")
1312
+ if data.get("action_required"):
1313
+ click.echo(f"Action required: {data['action_required'].get('message')}")
1314
+ if data.get("repos_needing_attention"):
1315
+ click.echo("\nRepos needing attention:")
1316
+ _print_rows(data["repos_needing_attention"])
1317
+
1318
+
1319
+ @cli.group("analyses")
1320
+ def analyses_group():
1321
+ """Browse analysis history and reports."""
1322
+
1323
+
1324
+ @analyses_group.command("list")
1325
+ @click.option("--limit", default=20, show_default=True)
1326
+ @click.option("--offset", default=0, show_default=True)
1327
+ @click.option("--json", "json_output", is_flag=True)
1328
+ def analyses_list(limit: int, offset: int, json_output: bool):
1329
+ data = api_request("GET", "analyses", params={"limit": limit, "offset": offset})
1330
+ print_payload(data if json_output else data.get("analyses", data), json_output=json_output)
1331
+
1332
+
1333
+ @analyses_group.command("overview")
1334
+ @click.option("--json", "json_output", is_flag=True)
1335
+ def analyses_overview(json_output: bool):
1336
+ print_payload(api_request("GET", "analyses/overview"), json_output=json_output)
1337
+
1338
+
1339
+ @analyses_group.command("show")
1340
+ @click.argument("analysis_id")
1341
+ @click.option("--json", "json_output", is_flag=True)
1342
+ def analyses_show(analysis_id: str, json_output: bool):
1343
+ print_payload(api_request("GET", f"analyses/{analysis_id}"), json_output=json_output)
1344
+
1345
+
1346
+ @analyses_group.command("report")
1347
+ @click.argument("analysis_id")
1348
+ @click.option("--json", "json_output", is_flag=True)
1349
+ def analyses_report(analysis_id: str, json_output: bool):
1350
+ print_payload(api_request("GET", f"analyses/{analysis_id}/report"), json_output=json_output)
1351
+
1352
+
1353
+ @analyses_group.command("compliance")
1354
+ @click.argument("analysis_id")
1355
+ @click.option("--json", "json_output", is_flag=True)
1356
+ def analyses_compliance(analysis_id: str, json_output: bool):
1357
+ print_payload(api_request("GET", f"analyses/{analysis_id}/compliance"), json_output=json_output)
1358
+
1359
+
1360
+ @analyses_group.command("autofix")
1361
+ @click.argument("analysis_id")
1362
+ @click.option("--json", "json_output", is_flag=True)
1363
+ def analyses_autofix(analysis_id: str, json_output: bool):
1364
+ print_payload(api_request("POST", f"analyses/{analysis_id}/autofix"), json_output=json_output)
1365
+
1366
+
1367
+ @cli.group("repos")
1368
+ def repos_group():
1369
+ """Manage repositories and repository-level rule settings."""
1370
+
1371
+
1372
+ @repos_group.command("list")
1373
+ @click.option("--json", "json_output", is_flag=True)
1374
+ def repos_list(json_output: bool):
1375
+ data = api_request("GET", "repositories")
1376
+ print_payload(data if json_output else data.get("repositories", data), json_output=json_output)
1377
+
1378
+
1379
+ @repos_group.command("show")
1380
+ @click.argument("repository_id")
1381
+ @click.option("--json", "json_output", is_flag=True)
1382
+ def repos_show(repository_id: str, json_output: bool):
1383
+ print_payload(api_request("GET", f"repositories/{repository_id}"), json_output=json_output)
1384
+
1385
+
1386
+ @repos_group.command("add")
1387
+ @click.argument("full_name")
1388
+ @click.option("--github-repo-id", required=True, help="GitHub numeric repository id")
1389
+ @click.option("--default-branch", default="main", show_default=True)
1390
+ @click.option("--json", "json_output", is_flag=True)
1391
+ def repos_add(full_name: str, github_repo_id: str, default_branch: str, json_output: bool):
1392
+ payload = {"full_name": full_name, "github_repo_id": int(github_repo_id), "default_branch": default_branch}
1393
+ print_payload(api_request("POST", "repositories", json_body=payload), json_output=json_output)
1394
+
1395
+
1396
+ @repos_group.command("rules")
1397
+ @click.argument("repository_id", required=False)
1398
+ @click.option("--json", "json_output", is_flag=True)
1399
+ def repos_rules(repository_id: str | None, json_output: bool):
1400
+ data = api_request("GET", "rules", params={"repository_id": repository_id})
1401
+ print_payload(data if json_output else data.get("rules", data), json_output=json_output)
1402
+
1403
+
1404
+ @repos_group.command("set-rules")
1405
+ @click.argument("repository_id")
1406
+ @click.option("--disable", "disabled_rules", multiple=True, help="Rule id to disable. Repeat for multiple rules.")
1407
+ @click.option("--json", "json_output", is_flag=True)
1408
+ def repos_set_rules(repository_id: str, disabled_rules: tuple[str, ...], json_output: bool):
1409
+ payload = {"disabled_rules": list(disabled_rules)}
1410
+ print_payload(api_request("PATCH", f"repositories/{repository_id}/rules", json_body=payload), json_output=json_output)
1411
+
1412
+
1413
+ @cli.group("issues")
1414
+ def issues_group():
1415
+ """List and triage open findings."""
1416
+
1417
+
1418
+ @issues_group.command("list")
1419
+ @click.option("--severity", type=click.Choice(["critical", "high", "medium", "low", "info"]))
1420
+ @click.option("--limit", default=100, show_default=True)
1421
+ @click.option("--json", "json_output", is_flag=True)
1422
+ def issues_list(severity: str | None, limit: int, json_output: bool):
1423
+ data = api_request("GET", "account/issues", params={"severity": severity, "limit": limit})
1424
+ print_payload(data if json_output else data.get("issues", data), json_output=json_output)
1425
+
1426
+
1427
+ @issues_group.command("feedback")
1428
+ @click.argument("analysis_id")
1429
+ @click.argument("issue_id")
1430
+ @click.option("--value", required=True, type=click.Choice(["confirmed", "false_positive", "not_sure"]))
1431
+ @click.option("--comment", default="")
1432
+ @click.option("--json", "json_output", is_flag=True)
1433
+ def issues_feedback(analysis_id: str, issue_id: str, value: str, comment: str, json_output: bool):
1434
+ payload = {"feedback": value, "comment": comment}
1435
+ print_payload(api_request("POST", f"analyses/{analysis_id}/issues/{issue_id}/feedback", json_body=payload), json_output=json_output)
1436
+
1437
+
1438
+ @issues_group.command("status")
1439
+ @click.argument("analysis_id")
1440
+ @click.argument("issue_id")
1441
+ @click.argument("status", type=click.Choice(["open", "confirmed", "false_positive", "fixed", "ignored", "reopened"]))
1442
+ @click.option("--json", "json_output", is_flag=True)
1443
+ def issues_status(analysis_id: str, issue_id: str, status: str, json_output: bool):
1444
+ payload = {"status": status}
1445
+ print_payload(api_request("PATCH", f"analyses/{analysis_id}/issues/{issue_id}/status", json_body=payload), json_output=json_output)
1446
+
1447
+
1448
+ @cli.group("keys")
1449
+ def keys_group():
1450
+ """Manage API keys."""
1451
+
1452
+
1453
+ @keys_group.command("list")
1454
+ @click.option("--json", "json_output", is_flag=True)
1455
+ def keys_list(json_output: bool):
1456
+ data = api_request("GET", "api-keys")
1457
+ print_payload(data if json_output else data.get("keys", data), json_output=json_output)
1458
+
1459
+
1460
+ @keys_group.command("create")
1461
+ @click.argument("label")
1462
+ @click.option("--expires-in-days", type=int)
1463
+ @click.option("--json", "json_output", is_flag=True)
1464
+ def keys_create(label: str, expires_in_days: int | None, json_output: bool):
1465
+ payload = {"label": label, "expires_in_days": expires_in_days}
1466
+ data = api_request("POST", "api-keys", json_body=payload)
1467
+ if json_output:
1468
+ print_payload(data, json_output=True)
1469
+ return
1470
+ click.echo("API key created. Copy it now; it will not be shown again.")
1471
+ click.echo(data.get("raw_key") or data.get("api_key", ""))
1472
+
1473
+
1474
+ @keys_group.command("revoke")
1475
+ @click.argument("key_id")
1476
+ @click.option("--json", "json_output", is_flag=True)
1477
+ def keys_revoke(key_id: str, json_output: bool):
1478
+ print_payload(api_request("DELETE", f"api-keys/{key_id}"), json_output=json_output)
1479
+
1480
+
1481
+ @cli.command("usage")
1482
+ @click.option("--json", "json_output", is_flag=True)
1483
+ def usage_command(json_output: bool):
1484
+ """Show quota and recent API usage."""
1485
+ print_payload(api_request("GET", "account/usage"), json_output=json_output)
1486
+
1487
+
1488
+ @cli.group("billing")
1489
+ def billing_group():
1490
+ """Manage plan and billing links."""
1491
+
1492
+
1493
+ @billing_group.command("summary")
1494
+ @click.option("--json", "json_output", is_flag=True)
1495
+ def billing_summary_command(json_output: bool):
1496
+ print_payload(api_request("GET", "billing"), json_output=json_output)
1497
+
1498
+
1499
+ @billing_group.command("checkout")
1500
+ @click.argument("plan", type=click.Choice(["pro", "team", "enterprise"]))
1501
+ @click.option("--json", "json_output", is_flag=True)
1502
+ def billing_checkout(plan: str, json_output: bool):
1503
+ print_payload(api_request("POST", f"billing/checkout/{plan}"), json_output=json_output)
1504
+
1505
+
1506
+ @billing_group.command("portal")
1507
+ @click.option("--json", "json_output", is_flag=True)
1508
+ def billing_portal(json_output: bool):
1509
+ print_payload(api_request("POST", "billing/portal"), json_output=json_output)
1510
+
1511
+
1512
+ @cli.group("integrations")
1513
+ def integrations_group():
1514
+ """Manage GitHub, Slack, email digest, and CI integrations."""
1515
+
1516
+
1517
+ @integrations_group.command("status")
1518
+ @click.option("--json", "json_output", is_flag=True)
1519
+ def integrations_status(json_output: bool):
1520
+ print_payload(api_request("GET", "dashboard/integrations"), json_output=json_output)
1521
+
1522
+
1523
+ @integrations_group.command("slack-set")
1524
+ @click.argument("webhook_url")
1525
+ @click.option("--json", "json_output", is_flag=True)
1526
+ def integrations_slack_set(webhook_url: str, json_output: bool):
1527
+ print_payload(api_request("PATCH", "dashboard/integrations", json_body={"slack_webhook_url": webhook_url}), json_output=json_output)
1528
+
1529
+
1530
+ @integrations_group.command("slack-clear")
1531
+ @click.option("--json", "json_output", is_flag=True)
1532
+ def integrations_slack_clear(json_output: bool):
1533
+ print_payload(api_request("PATCH", "dashboard/integrations", json_body={"slack_webhook_url": ""}), json_output=json_output)
1534
+
1535
+
1536
+ @integrations_group.command("slack-test")
1537
+ @click.option("--json", "json_output", is_flag=True)
1538
+ def integrations_slack_test(json_output: bool):
1539
+ print_payload(api_request("POST", "dashboard/integrations/test-slack"), json_output=json_output)
1540
+
1541
+
1542
+ @integrations_group.command("digest")
1543
+ @click.argument("state", type=click.Choice(["on", "off"]))
1544
+ @click.option("--json", "json_output", is_flag=True)
1545
+ def integrations_digest(state: str, json_output: bool):
1546
+ print_payload(api_request("PATCH", "dashboard/integrations", json_body={"weekly_digest_enabled": state == "on"}), json_output=json_output)
1547
+
1548
+
1549
+ @cli.group("account")
1550
+ def account_group():
1551
+ """Manage the current account."""
1552
+
1553
+
1554
+ @account_group.command("me")
1555
+ @click.option("--json", "json_output", is_flag=True)
1556
+ def account_me(json_output: bool):
1557
+ print_payload(api_request("GET", "account/me"), json_output=json_output)
1558
+
1559
+
1560
+ @account_group.command("preferences")
1561
+ @click.option("--weekly-digest/--no-weekly-digest", default=True)
1562
+ @click.option("--critical-alerts/--no-critical-alerts", default=True)
1563
+ @click.option("--json", "json_output", is_flag=True)
1564
+ def account_preferences(weekly_digest: bool, critical_alerts: bool, json_output: bool):
1565
+ payload = {"weekly_digest": weekly_digest, "critical_alerts": critical_alerts}
1566
+ print_payload(api_request("PATCH", "account/preferences", json_body=payload), json_output=json_output)
1567
+
1568
+
1569
+ @cli.group("admin")
1570
+ def admin_group():
1571
+ """Admin controls for users, beta access, billing, rules, and system health."""
1572
+
1573
+
1574
+ @admin_group.command("overview")
1575
+ @click.option("--json", "json_output", is_flag=True)
1576
+ def admin_overview(json_output: bool):
1577
+ print_payload(api_request("GET", "admin/overview"), json_output=json_output)
1578
+
1579
+
1580
+ @admin_group.command("users")
1581
+ @click.option("--search")
1582
+ @click.option("--plan")
1583
+ @click.option("--status")
1584
+ @click.option("--page", default=1, show_default=True)
1585
+ @click.option("--per-page", default=20, show_default=True)
1586
+ @click.option("--json", "json_output", is_flag=True)
1587
+ def admin_users(search: str | None, plan: str | None, status: str | None, page: int, per_page: int, json_output: bool):
1588
+ data = api_request("GET", "admin/users", params={"search": search, "plan": plan, "status": status, "page": page, "per_page": per_page})
1589
+ print_payload(data if json_output else data.get("users", data), json_output=json_output)
1590
+
1591
+
1592
+ @admin_group.command("user-update")
1593
+ @click.argument("user_id")
1594
+ @click.option("--plan", type=click.Choice(["free", "pro", "team", "enterprise"]))
1595
+ @click.option("--role", type=click.Choice(["user", "admin"]))
1596
+ @click.option("--status", type=click.Choice(["active", "suspended"]))
1597
+ @click.option("--suspend/--unsuspend", "suspended", default=None, help="Suspend or reactivate the user.")
1598
+ @click.option("--json", "json_output", is_flag=True)
1599
+ def admin_user_update(user_id: str, plan: str | None, role: str | None, status: str | None, suspended: bool | None, json_output: bool):
1600
+ payload = _clean_params({"plan": plan, "role": role, "status": status, "suspended": suspended}) or {}
1601
+ print_payload(api_request("PATCH", f"admin/users/{user_id}", json_body=payload), json_output=json_output)
1602
+
1603
+
1604
+ @admin_group.command("impersonate")
1605
+ @click.argument("user_id")
1606
+ @click.option("--json", "json_output", is_flag=True)
1607
+ def admin_impersonate(user_id: str, json_output: bool):
1608
+ data = api_request("POST", f"admin/users/{user_id}/impersonate")
1609
+ if json_output:
1610
+ print_payload(data, json_output=True)
1611
+ return
1612
+ click.echo(f"Temporary read-only token for {data.get('target_user', {}).get('email')}:")
1613
+ click.echo(data.get("token", ""))
1614
+ click.echo(f"Expires in {data.get('expires_in', 900)} seconds.")
1615
+
1616
+
1617
+ @admin_group.command("revoke-user-keys")
1618
+ @click.argument("user_id")
1619
+ @click.option("--json", "json_output", is_flag=True)
1620
+ def admin_revoke_user_keys(user_id: str, json_output: bool):
1621
+ print_payload(api_request("DELETE", f"admin/users/{user_id}/api-keys"), json_output=json_output)
1622
+
1623
+
1624
+ @admin_group.command("revoke-access")
1625
+ @click.argument("user_id")
1626
+ @click.option("--json", "json_output", is_flag=True)
1627
+ def admin_revoke_access(user_id: str, json_output: bool):
1628
+ print_payload(api_request("POST", f"admin/users/{user_id}/revoke-access"), json_output=json_output)
1629
+
1630
+
1631
+ @admin_group.command("export-users")
1632
+ @click.option("--output", type=click.Path(dir_okay=False), default="valicode-users.csv", show_default=True)
1633
+ def admin_export_users(output: str):
1634
+ response = api_request("GET", "admin/users/export", raw=True)
1635
+ Path(output).write_bytes(response.content)
1636
+ click.echo(f"Exported users to {Path(output).resolve()}")
1637
+
1638
+
1639
+ @admin_group.command("beta")
1640
+ @click.option("--status")
1641
+ @click.option("--search")
1642
+ @click.option("--page", default=1, show_default=True)
1643
+ @click.option("--per-page", default=20, show_default=True)
1644
+ @click.option("--json", "json_output", is_flag=True)
1645
+ def admin_beta(status: str | None, search: str | None, page: int, per_page: int, json_output: bool):
1646
+ data = api_request("GET", "admin/beta-applications", params={"status": status, "q": search, "page": page, "per_page": per_page})
1647
+ print_payload(data if json_output else data.get("applications", data), json_output=json_output)
1648
+
1649
+
1650
+ @admin_group.command("beta-set")
1651
+ @click.argument("application_id")
1652
+ @click.argument("status", type=click.Choice(["pending", "approved", "rejected"]))
1653
+ @click.option("--json", "json_output", is_flag=True)
1654
+ def admin_beta_set(application_id: str, status: str, json_output: bool):
1655
+ print_payload(api_request("PATCH", f"admin/beta-applications/{application_id}", json_body={"status": status}), json_output=json_output)
1656
+
1657
+
1658
+ @admin_group.command("rules")
1659
+ @click.option("--json", "json_output", is_flag=True)
1660
+ def admin_rules_command(json_output: bool):
1661
+ data = api_request("GET", "admin/rules")
1662
+ print_payload(data if json_output else data.get("rules", data), json_output=json_output)
1663
+
1664
+
1665
+ @admin_group.command("rule-set")
1666
+ @click.argument("rule_id")
1667
+ @click.argument("state", type=click.Choice(["on", "off"]))
1668
+ @click.option("--severity", type=click.Choice(["critical", "high", "medium", "low", "info"]))
1669
+ @click.option("--fail-merge/--no-fail-merge", default=None)
1670
+ @click.option("--json", "json_output", is_flag=True)
1671
+ def admin_rule_set(rule_id: str, state: str, severity: str | None, fail_merge: bool | None, json_output: bool):
1672
+ payload = _clean_params({"enabled": state == "on", "severity_override": severity, "fail_merge": fail_merge}) or {}
1673
+ print_payload(api_request("PATCH", f"admin/rules/{rule_id}", json_body=payload), json_output=json_output)
1674
+
1675
+
1676
+ @admin_group.command("subscriptions")
1677
+ @click.option("--json", "json_output", is_flag=True)
1678
+ def admin_subscriptions(json_output: bool):
1679
+ data = api_request("GET", "admin/subscriptions")
1680
+ print_payload(data if json_output else data.get("subscriptions", data), json_output=json_output)
1681
+
1682
+
1683
+ @admin_group.command("usage")
1684
+ @click.option("--days", default=30, show_default=True)
1685
+ @click.option("--json", "json_output", is_flag=True)
1686
+ def admin_usage(days: int, json_output: bool):
1687
+ print_payload(api_request("GET", "admin/usage", params={"days": days}), json_output=json_output)
1688
+
1689
+
1690
+ @admin_group.command("audit")
1691
+ @click.option("--limit", default=100, show_default=True)
1692
+ @click.option("--json", "json_output", is_flag=True)
1693
+ def admin_audit(limit: int, json_output: bool):
1694
+ data = api_request("GET", "admin/audit-logs", params={"limit": limit})
1695
+ print_payload(data if json_output else data.get("logs", data), json_output=json_output)
1696
+
1697
+
1698
+ @admin_group.command("system")
1699
+ @click.option("--json", "json_output", is_flag=True)
1700
+ def admin_system(json_output: bool):
1701
+ print_payload(api_request("GET", "admin/system"), json_output=json_output)
1702
+
1703
+
1704
+ if __name__ == "__main__":
1705
+ cli()