diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-22 16:24:13 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-22 16:24:13 -0500 |
| commit | 104e640bbb60a49dc363ee1731478cd81deb1cc4 (patch) | |
| tree | ad575d36e351e17d712533621dcc9456841f3068 /claude-templates | |
| parent | 84c90768f47196d45cb2198c6d24905fca08140d (diff) | |
| download | rulesets-104e640bbb60a49dc363ee1731478cd81deb1cc4.tar.gz rulesets-104e640bbb60a49dc363ee1731478cd81deb1cc4.zip | |
feat(scripts): add screenshot.py for visual verification on Wayland
Adds a grim + hyprctl wrapper so a session can capture the screen or a single window and read the resulting PNG, turning "does this look right?" into an inspectable artifact. Modes: --full (all outputs), --active (focused window), --window REGEX (matched against class or title), and --list to enumerate open windows. Output goes to a chosen path (default a timestamped file in /tmp) and the saved path is printed on stdout so the caller can read it back; the parent directory is created if it does not exist. Syncs into every project's .ai/scripts/ via the startup rsync.
Diffstat (limited to 'claude-templates')
| -rwxr-xr-x | claude-templates/.ai/scripts/screenshot.py | 142 |
1 files changed, 142 insertions, 0 deletions
diff --git a/claude-templates/.ai/scripts/screenshot.py b/claude-templates/.ai/scripts/screenshot.py new file mode 100755 index 0000000..03e76ae --- /dev/null +++ b/claude-templates/.ai/scripts/screenshot.py @@ -0,0 +1,142 @@ +#!/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 or one window, 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 + (case-insensitive). Errors if zero or many match. + --list list open windows (class / title / workspace) and exit + +Options: + --output PATH where to write the PNG + (default: /tmp/claude-screenshot-<timestamp>.png) + --delay SECONDS wait before capturing (let a window settle / animate) + +On success the saved path is printed on its own line; nothing else goes to +stdout, so a caller can capture it directly. + +Requires grim and hyprctl. A window must be visible on the active workspace +for its region to capture meaningfully — grim reads the compositor's rendered +output, so a window on another workspace yields whatever is drawn at that +region, not the hidden window. +""" + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import time +from datetime import datetime + + +def die(msg): + print(f"screenshot: {msg}", file=sys.stderr) + sys.exit(1) + + +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 geometry_str(client): + """Build a grim -g geometry string from a Hyprland client's at/size.""" + (x, y), (w, h) = client["at"], client["size"] + return f"{x},{y} {w}x{h}" + + +def list_windows(): + clients = hypr("clients") + rows = sorted(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): + pat = re.compile(regex, re.IGNORECASE) + matches = [c for c in hypr("clients") + if pat.search(c.get("class", "") or "") + or pat.search(c.get("title", "") or "")] + 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 grim(output, geometry=None): + cmd = ["grim"] + if geometry: + cmd += ["-g", geometry] + cmd.append(output) + res = subprocess.run(cmd, capture_output=True, text=True) + if res.returncode != 0: + die(f"grim failed: {res.stderr.strip()}") + + +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") + 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 + + 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 = args.output or f"/tmp/claude-screenshot-{datetime.now():%Y%m%d-%H%M%S}.png" + parent = os.path.dirname(os.path.abspath(out)) + os.makedirs(parent, exist_ok=True) + grim(out, geometry) + print(out) + + +if __name__ == "__main__": + main() |
