# Nix supplies the whole toolchain (Zig, GTK, libadwaita), so there is # nothing for mise to install; these tasks just drive it. [tasks.web-deps] description = "Install the review UI's npm dependencies" run = "nix develop --command npm --prefix web install --no-audit --no-fund" [tasks.web-dev] description = "Hot-reloading review UI against a running playpen (open /t//)" run = "nix develop --command npm --prefix web run dev" [tasks.build] description = "Build a release binary into zig-out/bin" run = "nix develop --command zig build -Doptimize=ReleaseFast" [tasks.build-debug] description = "Build a debug binary (leak checking, safety checks)" run = "nix develop --command zig build" [tasks.run] description = "Build and run playpen" run = "nix develop --command zig build run" [tasks.fmt] description = "Format all Zig source" run = "nix develop --command zig fmt build.zig src" [tasks.screenshot] description = "Screenshot playpen in a throwaway headless compositor" depends = ["build-debug"] run = "nix develop --command ./shot.sh" [tasks.install-claude-hooks] description = "Teach Claude Code to report its state to the tab it runs in" run = ''' #!/usr/bin/env bash set -euo pipefail command -v jq >/dev/null || { echo "install-claude-hooks needs jq" >&2; exit 1; } claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" settings="$claude/settings.json" script="$claude/hooks/playpen-status.sh" mkdir -p "$claude/hooks" install -m755 hooks/playpen-status.sh "$script" # Copied rather than referenced in place: hooks configured to run out of a # checkout break the day the checkout moves, and they break silently, in # every Claude session at once. [ -f "$settings" ] || echo '{}' > "$settings" # Every entry carries a marker comment. It is what makes re-running this # replace the previous install instead of stacking a second copy, and it is # what `uninstall-claude-hooks` matches on. The shell ignores it. merged=$(jq --arg script "$script" ' def marker: "# playpen-status-hook"; # Drop what a previous run added, leaving every other tools hooks alone. def clean: map(.hooks |= map(select((.command // "") | contains(marker) | not))) | map(select((.hooks | length) > 0)); def entry($state; $async): { hooks: [ { type: "command", command: ($script + " " + $state + " " + marker), timeout: 5 } + (if $async then { async: true } else {} end) ] }; .hooks //= {} # UserPromptSubmit is the only one left synchronous: it reads the prompt off # stdin to build the tab title. The rest are a single printf and need not # hold the turn up at all. | .hooks.UserPromptSubmit = ((.hooks.UserPromptSubmit // []) | clean) + [entry("busy"; false)] | .hooks.Notification = ((.hooks.Notification // []) | clean) + [entry("input"; true)] | .hooks.Stop = ((.hooks.Stop // []) | clean) + [entry("idle"; true)] | .hooks.SessionStart = ((.hooks.SessionStart // []) | clean) + [entry("idle"; true)] | .hooks.SessionEnd = ((.hooks.SessionEnd // []) | clean) + [entry("idle"; true)] ' "$settings") # Keep a copy of what was there, and swap the new file in whole. Claude reads # this file constantly; a half-written one is a broken session. cp "$settings" "$settings.playpen-backup" printf '%s\n' "$merged" > "$settings.tmp" mv "$settings.tmp" "$settings" echo "installed $script" echo "hooked into $settings (previous version kept at $settings.playpen-backup)" echo echo "Open a new Claude session to pick them up — hooks are read at startup." ''' [tasks.uninstall-claude-hooks] description = "Remove the Claude Code hooks, leaving any other tools' alone" run = ''' #!/usr/bin/env bash set -euo pipefail command -v jq >/dev/null || { echo "uninstall-claude-hooks needs jq" >&2; exit 1; } claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" settings="$claude/settings.json" [ -f "$settings" ] || { echo "no $settings; nothing to do"; exit 0; } stripped=$(jq ' def marker: "# playpen-status-hook"; def clean: map(.hooks |= map(select((.command // "") | contains(marker) | not))) | map(select((.hooks | length) > 0)); .hooks //= {} | reduce ("UserPromptSubmit","Notification","Stop","SessionStart","SessionEnd") as $ev (.; .hooks[$ev] = ((.hooks[$ev] // []) | clean)) # An event we emptied was ours alone; drop the key rather than leave "Stop": [] | .hooks |= with_entries(select((.value | type) != "array" or (.value | length) > 0)) ' "$settings") cp "$settings" "$settings.playpen-backup" printf '%s\n' "$stripped" > "$settings.tmp" mv "$settings.tmp" "$settings" rm -f "$claude/hooks/playpen-status.sh" echo "removed the hooks from $settings (previous version kept at $settings.playpen-backup)" ''' [tasks.install-skills] description = "Install the review skills so Claude can drive the review pane" run = ''' #!/usr/bin/env bash set -euo pipefail claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" dest="$claude/skills" # Outside the skills directory on purpose: anything under it is loaded as a # skill, so a backup kept alongside would show up in the list as a second, # stale copy of the thing it is a backup of. backups="$claude/playpen-skill-backups" mkdir -p "$dest" "$backups" for name in address-review leave-review; do src=".claude/skills/$name" [ -d "$src" ] || { echo "missing $src" >&2; exit 1; } # Anything already installed under this name is kept, not replaced. These are # global names: the version being overwritten may be one that drives an # entirely different tool, and losing it silently would be the worst kind of # breakage — the skill still runs, against a server that is not there. if [ -e "$dest/$name" ] && [ ! -L "$dest/$name" ]; then rm -rf "$backups/$name" cp -r "$dest/$name" "$backups/$name" echo "kept the previous $name at $backups/$name" fi rm -rf "$dest/$name" cp -r "$src" "$dest/$name" echo "installed $dest/$name" done echo echo "Open a new Claude session to pick them up — skills are read at startup." ''' [tasks.uninstall-skills] description = "Remove the review skills, restoring whatever they replaced" run = ''' #!/usr/bin/env bash set -euo pipefail claude="${CLAUDE_CONFIG_DIR:-$HOME/.claude}" dest="$claude/skills" backups="$claude/playpen-skill-backups" for name in address-review leave-review; do rm -rf "$dest/$name" if [ -d "$backups/$name" ]; then mv "$backups/$name" "$dest/$name" echo "restored the previous $name from the backup" else echo "removed $dest/$name" fi done rmdir "$backups" 2>/dev/null || true ''' [tasks.install] description = "Install playpen into ~/.local with a desktop entry and icon" depends = ["build"] run = ''' #!/usr/bin/env bash set -euo pipefail data="${XDG_DATA_HOME:-$HOME/.local/share}" bindir="$HOME/.local/bin" libexec="$HOME/.local/libexec/playpen" apps="$data/applications" icons="$data/icons/hicolor/scalable/apps" gcroots="$data/playpen/gcroots" # The binary carries an RPATH into the Nix store, so it runs anywhere, but it # still has to be told where GTK's GSettings schemas and icon themes live or # it aborts on a missing schema, where GIO's TLS module lives or every # https:// page in a web pane fails, and which mesa to render web panes # through or WebKit's web process dies on startup. Read those paths back out # of the dev shell that built it rather than hardcoding store hashes. schemas="$(nix develop --command printenv PLAYPEN_GSETTINGS_SCHEMA_DIR)" datadirs="$(nix develop --command printenv PLAYPEN_RUNTIME_DATA_DIRS)" giomodules="$(nix develop --command printenv PLAYPEN_GIO_MODULE_DIR)" eglvendor="$(nix develop --command printenv PLAYPEN_EGL_VENDOR_DIR)" dridrivers="$(nix develop --command printenv PLAYPEN_DRI_DRIVERS_PATH)" gbmbackends="$(nix develop --command printenv PLAYPEN_GBM_BACKENDS_PATH)" mkdir -p "$bindir" "$libexec" "$apps" "$icons" "$gcroots" install -m755 zig-out/bin/playpen "$libexec/playpen" # A launcher script rather than a symlink: a desktop launcher starts the app # with a minimal environment, so the GTK runtime paths must be set here. # Existing values are preserved so this composes with the rest of the session. cat > "$bindir/playpen" <"\$log/playpen.log" EOF chmod 755 "$bindir/playpen" sed "s|@EXEC@|$bindir/playpen|" dist/dev.greyson.playpen.desktop.in \ > "$apps/dev.greyson.playpen.desktop" install -m644 dist/dev.greyson.playpen.svg "$icons/dev.greyson.playpen.svg" # Pin the store paths the installed binary links against. Without this a # later `nix-collect-garbage` deletes GTK and the installed app stops # starting, with no obvious connection to the command that broke it. # Nix roots are transitive, so rooting each RPATH entry covers its closure. rm -f "$gcroots"/* { # What the dynamic linker needs, read straight out of the binary. if command -v readelf >/dev/null; then readelf -d "$libexec/playpen" \ | sed -n 's/.*R\(UN\)\?PATH.*\[\(.*\)\]/\2/p' \ | tr ':' '\n' else echo "warning: readelf not found; only the launcher's own paths are pinned" >&2 fi # What the launcher points at. These are opened by name at runtime rather # than linked, so they never show up in the RPATH and would otherwise be # left for the collector — losing the TLS module breaks https in web panes # long after the command that deleted it. printf '%s\n' "$schemas" "$datadirs" "$giomodules" \ "$eglvendor" "$dridrivers" "$gbmbackends" | tr ':' '\n' } \ | sed -n 's|^\(/nix/store/[^/]*\).*|\1|p' \ | sort -u \ | while read -r path; do nix-store --realise --add-root "$gcroots/$(basename "$path")" \ --indirect "$path" >/dev/null 2>&1 || true done echo "pinned $(find "$gcroots" -maxdepth 1 -type l | wc -l) store paths against garbage collection" # Let the desktop notice the new entry and icon right away. command -v update-desktop-database >/dev/null && update-desktop-database -q "$apps" || true command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -qtf "$data/icons/hicolor" 2>/dev/null || true echo "installed $bindir/playpen" case ":$PATH:" in *":$bindir:"*) ;; *) echo "note: $bindir is not on your PATH; the desktop entry still works" >&2 ;; esac ''' [tasks.uninstall] description = "Remove everything `install` put into ~/.local" run = ''' #!/usr/bin/env bash set -euo pipefail data="${XDG_DATA_HOME:-$HOME/.local/share}" apps="$data/applications" rm -f "$HOME/.local/bin/playpen" rm -rf "$HOME/.local/libexec/playpen" rm -f "$apps/dev.greyson.playpen.desktop" rm -f "$data/icons/hicolor/scalable/apps/dev.greyson.playpen.svg" # Dropping the roots lets Nix reclaim the runtime closure on its next GC. rm -rf "$data/playpen" command -v update-desktop-database >/dev/null && update-desktop-database -q "$apps" || true command -v gtk-update-icon-cache >/dev/null && gtk-update-icon-cache -qtf "$data/icons/hicolor" 2>/dev/null || true echo "uninstalled playpen" '''