""" UICopilot CLI — analyze a screenshot or HTML file from the command line. Usage: python backend/cli.py check screenshot.png python backend/cli.py check page.html --threshold 60 python backend/cli.py check screenshot.png --format json --out report.json python backend/cli.py check screenshot.png --device mobile --no-attention Exit codes: 0 — score >= threshold (PASS) 1 — score < threshold (FAIL) 2 — input error / file not found """ from __future__ import annotations import argparse import json import pathlib import sys if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf-16"): sys.stdout.reconfigure(encoding="utf-8", errors="replace") _IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"} _HTML_EXTS = {".html", ".htm"} def _die(msg: str, code: int = 2) -> None: print(f"ERROR: {msg}", file=sys.stderr) sys.exit(code) def _build_parser() -> argparse.ArgumentParser: ap = argparse.ArgumentParser( prog="uicopilot", description="UICopilot — UI quality analysis from the command line", ) sub = ap.add_subparsers(dest="cmd", metavar="COMMAND") ck = sub.add_parser("check", help="Analyze a file and exit non-zero if score < threshold") ck.add_argument("file", help="Screenshot (PNG/JPG/WebP) or HTML file to analyze") ck.add_argument("--threshold", type=int, default=70, help="Minimum passing score, 0-100 (default: 70)") ck.add_argument("--format", choices=["text", "json"], default="text", help="Output format (default: text)") ck.add_argument("--out", metavar="FILE", help="Write JSON report to this file (in addition to stdout)") ck.add_argument("--device", choices=["auto", "mobile", "desktop"], default="auto", help="Device type hint for screenshots (default: auto)") ck.add_argument("--no-attention", action="store_true", help="Skip attention heatmap computation (faster)") return ap def _analyze(path: pathlib.Path, device: str, include_attention: bool) -> dict: from backend.analyzers import html_analyzer, screenshot_analyzer from backend.services import attention_service, scoring_engine ext = path.suffix.lower() if ext in _HTML_EXTS: html = path.read_text(encoding="utf-8", errors="replace") parsed = html_analyzer.parse(html) image_bytes = None elif ext in _IMAGE_EXTS: image_bytes = path.read_bytes() parsed = screenshot_analyzer.analyze(image_bytes, device_hint=device) else: _die(f"Unsupported file type '{ext}'. Use {sorted(_IMAGE_EXTS | _HTML_EXTS)}") result = scoring_engine.analyze(parsed) out: dict = { "file": str(path), "score": round(result.overall_score, 1), "issue_count": len(result.issues), "category_scores": { cs.category.value: round(cs.score, 1) for cs in result.category_scores }, "top_issues": [ { "rule_id": i.rule_id, "severity": i.severity.value, "message": i.message, "gain": round(i.estimated_gain, 1), "time": i.estimated_time, } for i in result.top_issues[:5] ], "quick_wins": [ {"rule_id": i.rule_id, "message": i.message, "gain": round(i.estimated_gain, 1)} for i in result.quick_wins[:3] ], } if include_attention and image_bytes is not None: attn = attention_service.analyze(image_bytes) out["focus_score"] = attn["focus_score"] out["attention_regions"] = len(attn.get("top_regions", [])) return out def _print_text(out: dict, threshold: int) -> None: score = out["score"] passed = score >= threshold status = "PASS" if passed else "FAIL" mark = "+" if passed else "-" print(f"\nUICopilot {out['file']}") print("─" * 52) print(f" Score {score}/100 (threshold: {threshold}) [{status}]") print(f" Issues {out['issue_count']}") if "focus_score" in out: print(f" Focus {out['focus_score']}/100") if out["top_issues"]: print("\n Top issues:") for i in out["top_issues"]: sev = i["severity"].upper() print(f" [{sev:<8}] +{i['gain']:.1f}pt {i['message']}") if out["quick_wins"]: print("\n Quick wins (fix first):") for w in out["quick_wins"]: print(f" +{w['gain']:.1f}pt {w['message']}") print("\n Category scores:") for cat, s in sorted(out["category_scores"].items(), key=lambda x: x[1]): bar = int(s / 5) print(f" {cat.replace('_',' '):<24} {'█'*bar}{'░'*(20-bar)} {s}") print(f"\n {mark * 3} {status} — {score}/100 (threshold {threshold}) {mark * 3}\n") def cmd_check(args: argparse.Namespace) -> None: path = pathlib.Path(args.file) if not path.exists(): _die(f"File not found: {path}") include_attention = not args.no_attention out = _analyze(path, args.device, include_attention) out["threshold"] = args.threshold out["passed"] = out["score"] >= args.threshold if args.out: pathlib.Path(args.out).write_text( json.dumps(out, indent=2), encoding="utf-8" ) if args.format == "json": print(json.dumps(out, indent=2)) else: _print_text(out, args.threshold) sys.exit(0 if out["passed"] else 1) def main() -> None: ap = _build_parser() args = ap.parse_args() if args.cmd == "check": cmd_check(args) else: ap.print_help() sys.exit(2) if __name__ == "__main__": main()