aboutsummaryrefslogtreecommitdiff
path: root/claude-templates
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-22 16:24:13 -0500
committerCraig Jennings <c@cjennings.net>2026-05-22 16:24:13 -0500
commit104e640bbb60a49dc363ee1731478cd81deb1cc4 (patch)
treead575d36e351e17d712533621dcc9456841f3068 /claude-templates
parent84c90768f47196d45cb2198c6d24905fca08140d (diff)
downloadrulesets-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-xclaude-templates/.ai/scripts/screenshot.py142
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()