"""
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()