aboutsummaryrefslogtreecommitdiff
path: root/.ai/scripts
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-23 13:02:47 -0500
committerCraig Jennings <c@cjennings.net>2026-05-23 13:02:47 -0500
commit3f802eb819bc9be572b230ac1bd142b7ce13d87f (patch)
tree60be68db7167d064669c608cfb8402812eeae5c2 /.ai/scripts
parent7f2aea1e022c93f3eb463e5222bdb0d8ae6288b9 (diff)
downloadrulesets-3f802eb819bc9be572b230ac1bd142b7ce13d87f.tar.gz
rulesets-3f802eb819bc9be572b230ac1bd142b7ce13d87f.zip
chore(ai): resync workflow and script mirror with canonical
The .ai/ mirror lagged claude-templates/.ai/ for three workflows (task-audit, task-review, triage-intake) and two scripts (screenshot.py and its test) — earlier commits updated the canonical copies without resyncing the mirror in the same commit. The startup rsync caught it up; this commit tracks the result so the two stay identical.
Diffstat (limited to '.ai/scripts')
-rwxr-xr-x.ai/scripts/screenshot.py266
-rw-r--r--.ai/scripts/tests/test_screenshot.py97
2 files changed, 363 insertions, 0 deletions
diff --git a/.ai/scripts/screenshot.py b/.ai/scripts/screenshot.py
new file mode 100755
index 0000000..bdff419
--- /dev/null
+++ b/.ai/scripts/screenshot.py
@@ -0,0 +1,266 @@
+#!/usr/bin/env python3
+"""Capture a screenshot for visual verification (Wayland / Hyprland).
+
+Claude can read PNG files, so this turns "does the UI look right?" into an
+inspectable artifact: capture the screen, a window, or a freshly-launched app,
+then read the path it prints.
+
+Modes (pick one):
+ --full capture everything (all outputs) [default]
+ --active capture the currently focused window
+ --window REGEX capture the window whose class or title matches REGEX
+ --list list open windows (class / title / workspace) and exit
+ --launch CMD run CMD on a transient off-screen (headless) output,
+ capture it, and tear everything down — verifies an app's
+ UI without touching the visible workspace
+
+Layout (with --launch):
+ --layout LAYOUT tiled (window fills output) | monocle (fills, gapless) |
+ floating (window floats, sized by --size, centered)
+ --size WxH tiled/monocle: headless output resolution;
+ floating: window size (e.g. 1600x1000)
+ --timeout SECONDS how long to wait for the launched window (default 10)
+
+Common:
+ --output PATH PNG output path (default: timestamped file in /tmp);
+ the saved path is printed on stdout, parent dir created
+ --delay SECONDS wait before capturing (let a window settle / animate)
+
+Requires grim and hyprctl. For window/region modes the window must be visible
+on the active workspace — grim reads rendered outputs, so a hidden window
+captures whatever is drawn at its region. The --launch mode sidesteps that by
+rendering on a headless output that is real to the compositor but not displayed.
+"""
+
+import argparse
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+import time
+from datetime import datetime
+from typing import NoReturn
+
+
+def die(msg) -> NoReturn:
+ print(f"screenshot: {msg}", file=sys.stderr)
+ sys.exit(1)
+
+
+# --------------------------- pure helpers (unit-tested) ----------------------
+
+def parse_size(size):
+ """Parse 'WxH' into an (width, height) int tuple. Dies on malformed input."""
+ m = re.fullmatch(r"(\d+)x(\d+)", size or "")
+ if not m:
+ die(f"--size expects WxH (e.g. 1600x1000), got {size!r}")
+ return int(m.group(1)), int(m.group(2))
+
+
+def geometry_str(client):
+ """Build a grim -g geometry string ('x,y wxh') from a client's at/size."""
+ (x, y), (w, h) = client["at"], client["size"]
+ return f"{x},{y} {w}x{h}"
+
+
+def match_windows(clients, regex):
+ """Return the CLIENTS whose class or title matches REGEX (case-insensitive)."""
+ pat = re.compile(regex, re.IGNORECASE)
+ return [c for c in clients
+ if pat.search(c.get("class", "") or "")
+ or pat.search(c.get("title", "") or "")]
+
+
+def launch_rule(wsid, layout):
+ """Hyprland exec-rule body: place a window on WSID silently, per LAYOUT."""
+ rule = f"workspace {wsid} silent"
+ if layout == "monocle":
+ rule += ";fullscreen 1"
+ elif layout == "floating":
+ rule += ";float"
+ return rule
+
+
+def center_offset(ow, oh, w, h):
+ """Top-left offset to center a w*h window in an ow*oh output (clamped >=0)."""
+ return max(0, (ow - w) // 2), max(0, (oh - h) // 2)
+
+
+# ------------------------------- I/O wrappers --------------------------------
+
+def require_tools(*tools):
+ missing = [t for t in tools if shutil.which(t) is None]
+ if missing:
+ die(f"missing required tool(s): {', '.join(missing)} "
+ f"(this script targets Wayland/Hyprland)")
+
+
+def hypr(*args):
+ """Run `hyprctl -j <args>` and return parsed JSON."""
+ out = subprocess.run(["hyprctl", "-j", *args], capture_output=True, text=True)
+ if out.returncode != 0:
+ die(f"hyprctl {' '.join(args)} failed: {out.stderr.strip()}")
+ return json.loads(out.stdout)
+
+
+def hypr_run(*args):
+ """Fire a hyprctl dispatch/keyword command (no JSON parse)."""
+ subprocess.run(["hyprctl", *args], capture_output=True, text=True)
+
+
+def grim(output, geometry=None, output_name=None):
+ cmd = ["grim"]
+ if geometry:
+ cmd += ["-g", geometry]
+ if output_name:
+ cmd += ["-o", output_name]
+ cmd.append(output)
+ res = subprocess.run(cmd, capture_output=True, text=True)
+ if res.returncode != 0:
+ die(f"grim failed: {res.stderr.strip()}")
+
+
+def list_windows():
+ rows = sorted(hypr("clients"),
+ key=lambda c: (c.get("workspace", {}).get("id", 0), c.get("class", "")))
+ if not rows:
+ print("(no open windows)")
+ return
+ for c in rows:
+ ws = c.get("workspace", {}).get("name", "?")
+ print(f" ws:{ws:<8} class={c.get('class','')!r:30} title={c.get('title','')!r}")
+
+
+def find_window(regex):
+ matches = match_windows(hypr("clients"), regex)
+ if not matches:
+ die(f"no window matches {regex!r} — run --list to see open windows")
+ if len(matches) > 1:
+ labels = "\n".join(f" class={c.get('class','')!r} title={c.get('title','')!r}"
+ for c in matches)
+ die(f"{len(matches)} windows match {regex!r}; narrow it:\n{labels}")
+ return matches[0]
+
+
+def capture_launched(cmd, out, settle, timeout, layout, size):
+ """Run CMD on a transient headless output, capture it, then tear down.
+
+ Creates a virtual (headless) Hyprland output that the compositor renders but
+ does not display, launches CMD onto its workspace without switching the
+ visible view, applies LAYOUT, captures the output, and removes everything.
+
+ LAYOUT: 'tiled'/'monocle' (window fills the output, sized by SIZE) or
+ 'floating' (window floats at SIZE, centered). SIZE is (w, h) or None.
+ """
+ before_mons = {m["name"] for m in hypr("monitors")}
+ hypr_run("output", "create", "headless")
+ time.sleep(0.4)
+ new_mons = [m for m in hypr("monitors") if m["name"] not in before_mons]
+ if not new_mons:
+ die("could not create a headless output")
+ name = new_mons[0]["name"]
+ wsid = new_mons[0]["activeWorkspace"]["id"]
+ try:
+ if size and layout in ("tiled", "monocle"):
+ hypr_run("keyword", "monitor", f"{name},{size[0]}x{size[1]}@60,0x0,1")
+ time.sleep(0.2)
+
+ before_wins = {c["address"] for c in hypr("clients")}
+ hypr_run("dispatch", "exec", f"[{launch_rule(wsid, layout)}] {cmd}")
+
+ deadline = time.time() + timeout
+ new_wins = []
+ while time.time() < deadline:
+ new_wins = [c for c in hypr("clients") if c["address"] not in before_wins]
+ if new_wins:
+ break
+ time.sleep(0.3)
+ if not new_wins:
+ die(f"no window appeared within {timeout}s of launching: {cmd}")
+
+ # Daemon-spawned frames (emacsclient) ignore the exec rule, so apply
+ # placement and layout by address — robust for both spawn paths.
+ mon = next(m for m in hypr("monitors") if m["name"] == name)
+ ow, oh = mon["width"], mon["height"]
+ for c in new_wins:
+ addr = c["address"]
+ if c.get("workspace", {}).get("id") != wsid:
+ hypr_run("dispatch", "movetoworkspacesilent", f"{wsid},address:{addr}")
+ if layout == "floating":
+ hypr_run("dispatch", "setfloating", f"address:{addr}")
+ if size:
+ hypr_run("dispatch", "resizewindowpixel",
+ f"exact {size[0]} {size[1]},address:{addr}")
+ cx, cy = center_offset(ow, oh, size[0], size[1])
+ hypr_run("dispatch", "movewindowpixel", f"exact {cx} {cy},address:{addr}")
+
+ time.sleep(settle)
+ grim(out, output_name=name)
+ finally:
+ for c in hypr("clients"):
+ if c.get("workspace", {}).get("id") == wsid:
+ hypr_run("dispatch", "closewindow", f"address:{c['address']}")
+ hypr_run("output", "remove", name)
+
+
+def resolve_output(path):
+ out = path or f"/tmp/claude-screenshot-{datetime.now():%Y%m%d-%H%M%S}.png"
+ os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True)
+ return out
+
+
+def main():
+ p = argparse.ArgumentParser(add_help=True, description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ mode = p.add_mutually_exclusive_group()
+ mode.add_argument("--full", action="store_true", help="capture all outputs (default)")
+ mode.add_argument("--active", action="store_true", help="capture the focused window")
+ mode.add_argument("--window", metavar="REGEX", help="capture window matching REGEX")
+ mode.add_argument("--list", action="store_true", help="list windows and exit")
+ mode.add_argument("--launch", metavar="CMD", help="launch CMD off-screen and capture it")
+ p.add_argument("--layout", choices=("tiled", "monocle", "floating"), default="tiled",
+ help="window layout for --launch (default: tiled)")
+ p.add_argument("--size", help="WxH: output res (tiled/monocle) or window size (floating)")
+ p.add_argument("--timeout", type=float, default=10.0,
+ help="seconds to wait for the --launch window (default 10)")
+ p.add_argument("--output", "-o", help="PNG output path")
+ p.add_argument("--delay", type=float, default=0.0, help="seconds to wait before capture")
+ args = p.parse_args()
+
+ require_tools("grim", "hyprctl")
+
+ if args.list:
+ list_windows()
+ return
+
+ size = parse_size(args.size) if args.size else None
+
+ if args.launch:
+ out = resolve_output(args.output)
+ settle = args.delay if args.delay else 1.5
+ capture_launched(args.launch, out, settle, args.timeout, args.layout, size)
+ print(out)
+ return
+
+ if args.delay:
+ time.sleep(args.delay)
+
+ geometry = None
+ if args.window:
+ geometry = geometry_str(find_window(args.window))
+ elif args.active:
+ win = hypr("activewindow")
+ if not win or "at" not in win:
+ die("no active window")
+ geometry = geometry_str(win)
+ # else: --full / default → whole screen
+
+ out = resolve_output(args.output)
+ grim(out, geometry)
+ print(out)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.ai/scripts/tests/test_screenshot.py b/.ai/scripts/tests/test_screenshot.py
new file mode 100644
index 0000000..357caec
--- /dev/null
+++ b/.ai/scripts/tests/test_screenshot.py
@@ -0,0 +1,97 @@
+"""Tests for screenshot.py — the pure helpers behind Wayland screenshot capture.
+
+The script is mostly thin wrappers around grim/hyprctl (I/O boundaries, verified
+functionally). The logic worth testing is pure: size parsing, grim geometry
+strings, window matching, the Hyprland exec-rule body, and floating-window
+centering. Those are imported and exercised directly here; the subprocess
+wrappers and the capture orchestration are not unit-tested.
+"""
+
+import sys
+from pathlib import Path
+
+import pytest
+
+sys.path.insert(0, str(Path(__file__).parent.parent))
+import screenshot # noqa: E402
+
+
+# ------------------------------- parse_size ----------------------------------
+
+def test_parse_size_normal():
+ assert screenshot.parse_size("1600x1000") == (1600, 1000)
+
+def test_parse_size_boundary_zero_and_one():
+ assert screenshot.parse_size("0x0") == (0, 0)
+ assert screenshot.parse_size("1x1") == (1, 1)
+
+@pytest.mark.parametrize("bad", ["", "bad", "100", "100x", "x100", "100X100", " 1x1"])
+def test_parse_size_error_malformed_dies(bad):
+ with pytest.raises(SystemExit):
+ screenshot.parse_size(bad)
+
+
+# ------------------------------ geometry_str ---------------------------------
+
+def test_geometry_str_normal():
+ assert screenshot.geometry_str({"at": [10, 20], "size": [800, 600]}) == "10,20 800x600"
+
+def test_geometry_str_boundary_origin_and_unit():
+ assert screenshot.geometry_str({"at": [0, 0], "size": [1, 1]}) == "0,0 1x1"
+
+
+# ------------------------------ match_windows --------------------------------
+
+CLIENTS = [
+ {"class": "Emacs", "title": "Emacs 30.2 : agent [.emacs.d]"},
+ {"class": "firefox", "title": "TrueNAS — Mozilla Firefox"},
+ {"class": "foot", "title": "foot"},
+]
+
+def test_match_windows_normal_by_class():
+ assert screenshot.match_windows(CLIENTS, "firefox") == [CLIENTS[1]]
+
+def test_match_windows_normal_by_title():
+ assert screenshot.match_windows(CLIENTS, "TrueNAS") == [CLIENTS[1]]
+
+def test_match_windows_case_insensitive():
+ assert screenshot.match_windows(CLIENTS, "EMACS") == [CLIENTS[0]]
+
+def test_match_windows_multiple():
+ # "o" appears in firefox and foot classes
+ assert len(screenshot.match_windows(CLIENTS, "foo")) == 1
+ assert len(screenshot.match_windows(CLIENTS, "e")) >= 2 # Emacs + firefox (title/class)
+
+def test_match_windows_boundary_empty_clients():
+ assert screenshot.match_windows([], "anything") == []
+
+def test_match_windows_boundary_missing_fields():
+ # client with no class/title keys must not raise
+ assert screenshot.match_windows([{"class": None, "title": None}], "x") == []
+
+def test_match_windows_error_no_match():
+ assert screenshot.match_windows(CLIENTS, "nonexistent-zzz") == []
+
+
+# ------------------------------- launch_rule ---------------------------------
+
+def test_launch_rule_tiled_default():
+ assert screenshot.launch_rule(2, "tiled") == "workspace 2 silent"
+
+def test_launch_rule_monocle_adds_fullscreen():
+ assert screenshot.launch_rule(2, "monocle") == "workspace 2 silent;fullscreen 1"
+
+def test_launch_rule_floating_adds_float():
+ assert screenshot.launch_rule(7, "floating") == "workspace 7 silent;float"
+
+
+# ------------------------------ center_offset --------------------------------
+
+def test_center_offset_normal():
+ assert screenshot.center_offset(1920, 1080, 800, 600) == (560, 240)
+
+def test_center_offset_boundary_exact_fit():
+ assert screenshot.center_offset(1920, 1080, 1920, 1080) == (0, 0)
+
+def test_center_offset_error_window_larger_than_output_clamps():
+ assert screenshot.center_offset(800, 600, 1000, 700) == (0, 0)