name: ir:test-mac description: > Build and run a dev irrlicht daemon + macOS Swift app for local testing. Asks whether to run a SEPARATE instance alongside production (isolated state, port 7838 — production keeps running) or to REPLACE the running production versions (production port 7837 + production state, so the statusline quota feed and existing sessions show up). Use when the user says "test mac", "restart mac", "rebuild mac", or "/ir:test-mac".
Build & Run macOS Dev Stack (separate or replace)
Build the irrlicht daemon and Swift app, then run them in one of two modes the user chooses up front:
- separate (default, recommended) — a dev instance that coexists with
production. The dev daemon binds port 7838 and stores its state under a
worktree-local
IRRLICHT_HOME; the dev app connects to 7838 viaIRRLICHT_DAEMON_PORT. Production stays up untouched on 7837. Only prior dev instances are replaced on rerun. Note: the Claude Code statusline hook is hardcoded to POST quota data to 7837, so a separate dev instance shows the subscription empty-state (no rate-limit data) — that's expected. - replace — take over from production. Kill the running production app +
daemon (and any dev instance), then run the freshly built dev binaries on the
production port 7837 with the production state dir (no
IRRLICHT_HOMEoverride). Because it's on 7837 it receives the statusline quota feed and sees the same on-disk sessions/cost data — i.e. it behaves like production but runs your dev code. There is only one instance afterward.
Steps
Decide the mode, then set the run config. If the user typed an explicit
separateorreplaceargument (e.g./ir:test-mac replace), use it. Otherwise ask withAskUserQuestion— two options, "Separate (alongside production)" (recommended, first) and "Replace production".replaceis destructive (it kills production and lets a dev binary read/write production state) — never default to it; when in doubt, pickseparate.Then edit the
MODE=line below to the chosen value before running the block — it is a placeholder, not a default to run verbatim. Every later step branches on$MODE/$PORT/$DEV_HOME/$SOCK. These are shell variables, and each fenced block runs in a fresh shell, so when you execute a later step you must carry the step-0 values forward (re-declare them, or inline the literal port/path) — an empty$PORTmakes the daemon bind127.0.0.1:(invalid) and an empty$SOCKturns the socket cleanup into a no-op.REPO_ROOT="$(git rev-parse --show-toplevel)" # worktree root if in a worktree, else main repo IRRLICHTD_BIN="/Users/ingo/projects/irrlicht/core/bin/irrlichd" # stable path: build target == launch target DEV_APP="/tmp/IrrlichtDev.app" MODE="separate" # PLACEHOLDER — set to the user's choice ("separate" or "replace") before running if [ "$MODE" = "replace" ]; then PORT=7837 # production port (statusline quota hook targets this) DEV_HOME="" # no IRRLICHT_HOME override → production state dir SOCK="$HOME/.local/share/irrlicht/irrlichd.sock" # production socket else PORT=7838 # isolated dev port DEV_HOME="$REPO_ROOT/.build/irrlicht-home" # isolated state dir (IRRLICHT_HOME) SOCK="$DEV_HOME/irrlichd.sock" mkdir -p "$DEV_HOME" fi echo "MODE=$MODE PORT=$PORT DEV_HOME=${DEV_HOME:-<production>}"$IRRLICHTD_BINis the main repo's bin (a stable absolute path) so the build and launch steps always agree even when$REPO_ROOTis a worktree — the source compiled is still the worktree's (go buildruns in$REPO_ROOT/core).Build the Go daemon — the daemon resolves the dashboard from
platforms/web/index.htmlat runtime via a walk-up search from its own executable; no embed, no codegen.cd "$REPO_ROOT/core" && go build -o "$IRRLICHTD_BIN" ./cmd/irrlichdNote: the binary is built from the worktree's source but placed at the stable
$IRRLICHTD_BINpath that step 5 launches.Build the Swift app and assemble .app bundle (identical in both modes)
cd "$REPO_ROOT/platforms/macos" && swift build 2>&1 | tail -5Then assemble a proper
.appbundle so thatUNUserNotificationCenter(desktop notifications) works:rm -rf "$DEV_APP" mkdir -p "$DEV_APP/Contents/MacOS" "$DEV_APP/Contents/Resources" cp "$REPO_ROOT/platforms/macos/.build/arm64-apple-macosx/debug/Irrlicht" "$DEV_APP/Contents/MacOS/Irrlicht" cp "$REPO_ROOT/platforms/macos/Irrlicht/Resources/AppIcon.icns" "$DEV_APP/Contents/Resources/AppIcon.icns" # Copy the SwiftPM resource bundle so Bundle.module works at runtime cp -R "$REPO_ROOT/platforms/macos/.build/arm64-apple-macosx/debug/Irrlicht_Irrlicht.bundle" "$DEV_APP/Contents/Resources/Irrlicht_Irrlicht.bundle" 2>/dev/null || true # Embed Sparkle.framework (required since v0.4.7 auto-update integration) mkdir -p "$DEV_APP/Contents/Frameworks" cp -R "$REPO_ROOT/platforms/macos/.build/arm64-apple-macosx/debug/Sparkle.framework" "$DEV_APP/Contents/Frameworks/" # The SwiftPM debug binary links Sparkle as @rpath/Sparkle.framework but only # carries an @loader_path rpath (= Contents/MacOS) — it lacks the bundle-layout # rpath a release .app build adds. Without this, dyld can't find the embedded # framework and the app crashes at launch. (Must run BEFORE the codesign step # below, since it mutates the binary and invalidates the signature.) install_name_tool -add_rpath @executable_path/../Frameworks "$DEV_APP/Contents/MacOS/Irrlicht" cat > "$DEV_APP/Contents/Info.plist" << 'PLIST' <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleExecutable</key> <string>Irrlicht</string> <key>CFBundleIdentifier</key> <string>io.irrlicht.app</string> <key>CFBundleIconFile</key> <string>AppIcon</string> <key>CFBundleName</key> <string>Irrlicht Dev</string> <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleShortVersionString</key> <string>dev</string> <key>LSUIElement</key> <true/> <key>NSAppleEventsUsageDescription</key> <string>Irrlicht uses AppleScript to bring the correct iTerm2 or Terminal.app window and tab to the front when you click a session row.</string> <key>NSFocusStatusUsageDescription</key> <string>Irrlicht uses macOS Focus status to silence notification sounds and spoken alerts while you're in Do Not Disturb, Sleep, or any other Focus mode.</string> </dict> </plist> PLIST # Use the dev-only entitlements file (no com.apple.developer.* entries — # Apple gates those to its own certificates and launchd will refuse to spawn # a self-signed/ad-hoc binary that claims them). The full release entitlements # at Irrlicht.entitlements include com.apple.developer.focus-status for the # production Developer-ID build; in dev, INFocusStatusCenter therefore reports # "unauthorized" and FocusMonitor returns false. That's expected — verify the # gating logic via unit tests; the live Focus suppression is only testable in # the signed release build. ENTITLEMENTS="$REPO_ROOT/platforms/macos/Irrlicht/Resources/Irrlicht-dev.entitlements" # Sign with the persistent "Irrlicht Dev" identity if it exists; otherwise # fall back to ad-hoc (TCC permissions will need to be re-granted each # rebuild). Run tools/dev-sign-setup.sh once to install the identity. if security find-identity -v -p codesigning 2>/dev/null | grep -q "Irrlicht Dev"; then codesign --force --deep --sign "Irrlicht Dev" --entitlements "$ENTITLEMENTS" "$DEV_APP" 2>&1 else codesign --force --deep --sign - --entitlements "$ENTITLEMENTS" "$DEV_APP" 2>&1 fiKill the instances this mode replaces.
- separate: only prior dev instances; production (on 7837) stays up.
- replace: the production app + ALL daemons (prod is named
irrlichd, exactly like dev) + any prior dev app, so only the fresh build remains.
if [ "$MODE" = "replace" ]; then pkill -f "Irrlicht\.app/Contents/MacOS/Irrlicht" 2>/dev/null # production app (\. is literal; NOT IrrlichtDev.app — different folder) pkill -f "IrrlichtDev" 2>/dev/null # any prior dev app pkill -x "irrlichd" 2>/dev/null # ALL daemons by exact name (prod bundle + dev) else pkill -f "IrrlichtDev" 2>/dev/null # prior dev app only pkill -f "core/bin/irrlichd" 2>/dev/null # prior dev daemon (NOT production, which runs from the .app bundle) fi PORT_PIDS="$(lsof -ti tcp:$PORT 2>/dev/null)" # belt-and-suspenders: anything still on the target port [ -n "$PORT_PIDS" ] && kill $PORT_PIDS 2>/dev/null sleep 2Clean up the stale socket for the target instance.
rm -f "$SOCK"Start the daemon with
--recordfor lifecycle event capture. In separate mode it getsIRRLICHT_HOME(isolated state) plusIRRLICHT_PERMISSION_MODE=grant-all— a fresh isolated state dir has no consent answers (#570), so without it the daemon monitors nothing until the permission wizard is answered. Drop that variable when the point of the session is to test the wizard itself. In replace modeIRRLICHT_HOMEis omitted so it reads/writes the production state dir — including the user's real permission answers (no grant-all there).if [ "$MODE" = "replace" ]; then IRRLICHT_BIND_ADDR=127.0.0.1:$PORT \ nohup "$IRRLICHTD_BIN" --record > /tmp/irrlichd-dev.log 2>&1 & disown else IRRLICHT_HOME="$DEV_HOME" IRRLICHT_BIND_ADDR=127.0.0.1:$PORT \ IRRLICHT_PERMISSION_MODE=grant-all \ nohup "$IRRLICHTD_BIN" --record > /tmp/irrlichd-dev.log 2>&1 & disown fiWait for the daemon to be reachable — and HARD-ABORT if it isn't. This is a gate, not a courtesy sleep. In replace mode the app adopts an already-reachable daemon on 7837 and skips its own spawn/pkill; if our
--recorddaemon is NOT up yet when the app launches, the app (port 7837 ⇒isCustomPortfalse) runspkill -x irrlichd— killing our daemon — and respawns one without--record, silently defeating the whole point. So if/statenever answers, stop here and do not launch the app.READY="" for i in 1 2 3 4 5 6 7 8; do curl -fsS --max-time 1 "http://127.0.0.1:$PORT/state" >/dev/null 2>&1 && { READY=1; break; } sleep 1 done if [ -z "$READY" ]; then echo "ABORT: daemon never became reachable on $PORT — not launching the app." >&2 echo " (Launching now would let the app pkill our daemon and respawn one without --record.)" >&2 echo " Check /tmp/irrlichd-dev.log." >&2 return 1 2>/dev/null || exit 1 fi lsof -iTCP:$PORT -sTCP:LISTEN -P -n 2>/dev/nullStart the dev Swift app — launched via LaunchServices so
Bundle.mainresolves correctly. In separate mode,IRRLICHT_DAEMON_PORT/IRRLICHT_HOMEpoint it at the isolated dev daemon. In replace mode, no env overrides are passed: the app uses the default port 7837 + production state and, finding the daemon from step 5 already reachable, adopts it instead of spawning its own.if [ "$MODE" = "replace" ]; then open --stdout /tmp/irrlicht-app-dev.log --stderr /tmp/irrlicht-app-dev.log "$DEV_APP" else open --env IRRLICHT_DAEMON_PORT=$PORT --env IRRLICHT_HOME="$DEV_HOME" \ --stdout /tmp/irrlicht-app-dev.log --stderr /tmp/irrlicht-app-dev.log "$DEV_APP" fiVerify — dev daemon + app are running and the daemon is serving sessions. In replace mode, also confirm quota data is present (the whole point of 7837).
pgrep -f "bin/irrlichd" && pgrep -f "IrrlichtDev" && curl -s http://127.0.0.1:$PORT/api/v1/sessions | head -c 200 if [ "$MODE" = "replace" ]; then # 0 is normal right after launch — quota data only appears once Claude Code's # statusline posts its next tick to 7837 (seconds to a minute). Re-run to confirm. echo; echo "rate-limit mentions (0 now is fine; should climb after the next statusline tick):" \ "$(curl -s http://127.0.0.1:$PORT/api/v1/sessions | grep -o 'rate_limit\|used_percent' | wc -l | tr -d ' ')" fiTearing down (replace mode) — REQUIRED to get production back. Quitting the dev app is NOT enough: in replace mode the app only adopted the daemon started in step 5 (it never owns the process —
DaemonManager.start()returns early on a reachable daemon without recording it, so itsterminateProcess()is a no-op), and that daemon wasnohup/disown'd, so it keeps running on 7837 after the app exits. If you then relaunch/Applications/Irrlicht.app, production finds the leftover dev daemon still reachable and adopts it — you'd be running the production UI against the dev--recorddaemon without realizing it. So to return to production: quit the dev app, then explicitly kill the daemon and confirm the port is free before relaunching production.Use the bundled
restore-prod.sh— it does the whole sequence (kill dev app → kill dev daemon → GATE on 7837 actually freeing → launch/Applications/Irrlicht.app→ confirm prod's own daemon comes up). Note: the installer does NOT replace this teardown — it swaps the app bundle but leaves the dev daemon running on 7837, which the new prod app would still adopt; run this script (or at least thepkill -x irrlichd) regardless."$(git rev-parse --show-toplevel)/.claude/skills/ir:test-mac/restore-prod.sh"Equivalent by hand, if you want to see each step:
pkill -f "IrrlichtDev" 2>/dev/null # quit the dev app pkill -x "irrlichd" 2>/dev/null # stop the adopted dev daemon (the app won't) sleep 1 lsof -iTCP:7837 -sTCP:LISTEN -P -n 2>/dev/null && echo "WARNING: 7837 still held — production will adopt whatever is there" || open /Applications/Irrlicht.app
Notes
- separate mode — production keeps running. The production Irrlicht.app (from DMG) and its bundled daemon stay on port 7837 with state under
~/.local/share/irrlicht/+~/Library/Application Support/Irrlicht/. The dev instance binds port 7838 and routes its WRITTEN state — socket, recordings, history, session store, ledgers, and cost store — beneathIRRLICHT_HOME=$DEV_HOME, so it never prunes or mutates production's session/cost data. The dev app reaches the dev daemon becauseIRRLICHT_DAEMON_PORT(viaopen --env) overrides the hardcoded default;DaemonManageralso skips its globalpkillwhen a custom port is set, so it can't take production down. - separate mode shares with production: it reads the same
~/.claudetranscripts (so the dev UI shows the same live sessions) and appends to the same~/Library/Application Support/Irrlicht/logs/events.log. It does NOT share the on-disk session/ledger/cost stores — and it does NOT receive the statusline quota feed (that hook posts only to 7837), so the subscription panel shows its empty-state. - replace mode — single instance on production's footprint. Runs the dev binaries on port 7837 with the production state dir (no
IRRLICHT_HOME), so the statusline quota feed and the production session/cost/ledger stores all apply. Because the dev daemon runs with--record, recordings land in the production recordings dir (~/.local/share/irrlicht/recordings/). ⚠️ The dev daemon mutates production data. WithoutIRRLICHT_HOMEits startup sweeps (PruneStale/ dead-proc / orphan-ledger / cost prune) run against the real~/.local/share/irrlicht/+~/Library/Application Support/Irrlicht/stores — this is exactly the isolation #448 added, deliberately removed here. Only use replace mode when the dev build's on-disk schema matches the installed production build; a dev branch mid-migration can prune or rewrite production sessions/ledgers/cost rows that the production binary then misreads. Returning to production requires the step-9 teardown (runrestore-prod.sh) — quitting the app does NOT stop the adopted daemon, and a relaunched (or freshly reinstalled) production app will silently adopt the leftover dev daemon on 7837 if you skip it. Running the installer is not a substitute: it replaces the app bundle, not the running daemon. - Daemon logs:
/tmp/irrlichd-dev.log· Swift app logs:/tmp/irrlicht-app-dev.log - TCC stability: run
tools/dev-sign-setup.shonce to install the"Irrlicht Dev"self-signed code signing identity. The skill automatically signs with it when present, giving the app a stable designated requirement so Accessibility/Automation grants persist across rebuilds. Without it, every rebuild invalidates TCC and requires re-granting in System Settings.