Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 31 additions & 6 deletions .claude/commands/test-pr.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
---
description: Prepare an open PR for local human testing — checks out its branch into an isolated, ready-to-run worktree (deps built) and hands you the launch command.
argument-hint: <pr-number>
argument-hint: <pr-number> [--phone]
---

You are preparing an OPEN pull request for the user to manually test locally, WITHOUT disturbing their main
working tree. The PR number is: **$ARGUMENTS**
working tree. The arguments are: **$ARGUMENTS**

Do this:

1. Run `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/prepare-pr.sh $ARGUMENTS`. It resolves the PR's head branch, fetches it, creates (or
refreshes) a **detached** worktree at `<humanTest.worktreeDir>/pr-$ARGUMENTS` (default `.worktrees/pr-<n>`),
refreshes) a **detached** worktree at `<humanTest.worktreeDir>/pr-<n>` (default `.worktrees/pr-<n>`),
and runs the project's `humanTest.prepare` command (install + build) inside it. The script is idempotent —
re-running it on the same PR just fast-forwards the worktree to the latest pushed commit and rebuilds.

Expand All @@ -26,9 +26,34 @@ Do this:

4. Offer (do not assume) to start the launch command for them in the background if they'd rather you drive it.

## Testing on a phone (opt-in): `--phone`

`/test-pr <pr-number> --phone` (equivalently: `bash ${CLAUDE_PLUGIN_ROOT:-.claude}/scripts/prepare-pr.sh <pr-number> --phone`,
or set `PHONE=1` in the environment) does everything the default path does, but prints `humanTest.launchPhone`
instead of `humanTest.launch`. That command is expected to start the same dev server(s) **and** open a public
tunnel (e.g. a `cloudflared` quick tunnel) to the already-running app, so a phone (or anyone with the link) can
reach it over the internet. Nothing about the app changes — it's the same dev server, just tunneled.

Before telling the user to run the printed command, make sure they see all four caveats:
1. **Public** — the printed tunnel URL (e.g. `https://<random>.trycloudflare.com`) works for anyone who has it
while the tunnel is up, not just the phone it's meant for.
2. **Exposes the whole app** — the tunnel forwards the dev server, including any backend/API it proxies. A visitor
to the URL can do anything the app can, using whatever credentials/keys are configured in the worktree's `.env`.
3. **Ephemeral** — the tunnel and the dev server(s) are typically tied together (backgrounded servers + a trap);
killing the command (Ctrl-C) tears down everything at once. There is no persistence across runs.
4. **Attended use only** — don't leave it running unattended; treat the tunnel URL like a temporary,
unauthenticated view of the app for the duration it's up.

As with step 4 above, offer (do not assume) to start the launch command in the background for the user; if you do
start `humanTest.launchPhone`, watch its output for the tunnel URL and surface that URL clearly once it appears,
so it's easy to open on the phone.

If `humanTest.launchPhone` isn't configured in `.claude/gates.json`, the script says so and falls back to printing
the normal `humanTest.launch` command — report that to the user rather than treating it as a failure.

Notes:
- This never edits the PR and never touches `main` — the worktree is an isolated, detached checkout, so it can
coexist with the same branch checked out elsewhere (e.g. an agent worktree).
- The prepare/launch/worktree-dir commands are read from `.claude/gates.json` → `humanTest`, so this command is
project-agnostic. If `humanTest` is absent, the script still makes the worktree and tells the user to build/run
manually.
- The prepare/launch/launchPhone/worktree-dir commands are read from `.claude/gates.json` → `humanTest`, so this
command is project-agnostic. If `humanTest` is absent, the script still makes the worktree and tells the user to
build/run manually.
8 changes: 8 additions & 0 deletions .claude/gates.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@
"teardown": ""
},

"humanTest": {
"_note": "Optional support for the /test-pr command (.claude/scripts/prepare-pr.sh). `prepare` runs IN the PR worktree to make it runnable (install + build); `launch` is the command printed to start the app for manual testing; `launchPhone` is an OPT-IN variant (via `/test-pr <n> --phone`) that starts the app AND opens a public tunnel (e.g. `cloudflared tunnel --url http://localhost:<port> --http-host-header localhost`) so a phone can reach it — it exposes the app publicly, so keep it attended; `worktreeDir` is where PR worktrees are created (default '.worktrees'). Empty string = skip.",
"prepare": "",
"launch": "",
"launchPhone": "",
"worktreeDir": ".worktrees"
},

"review": {
"lenses": ["correctness", "tests", "security", "performance"],
"consensus": "all",
Expand Down
58 changes: 53 additions & 5 deletions .claude/scripts/prepare-pr.sh
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# prepare-pr.sh <pr-number>
# prepare-pr.sh [--phone] <pr-number>
# Prepare a ready-to-run local checkout of an OPEN pull request so a human can
# manually test it, WITHOUT touching the main working tree. Idempotent.
#
Expand All @@ -13,12 +13,32 @@
# Config (.claude/gates.json → "humanTest"):
# prepare shell cmd run IN the worktree to make it runnable (optional)
# launch shell cmd to start the app for manual testing (printed, optional)
# launchPhone shell cmd to start the app AND a public cloudflared tunnel for
# phone testing (printed instead of launch, opt-in, optional)
# worktreeDir parent dir for PR worktrees (optional, default ".worktrees")
#
# gh reads go through bot-gh.sh for consistent auth; git ops stay local.
#
# --phone / PHONE=1: opt-in "phone testing" mode. Behavior is otherwise
# BYTE-IDENTICAL to the default path — it only changes which launch command is
# printed at the end (humanTest.launchPhone instead of humanTest.launch) and
# prints a caveat block, because launchPhone exposes the dev studio (and its
# /api proxy) publicly over a cloudflared quick tunnel.
set -uo pipefail

pr="${1:?usage: prepare-pr.sh <pr-number>}"
phone=0
rest=()
for arg in "$@"; do
case "$arg" in
--phone) phone=1 ;;
*) rest+=("$arg") ;;
esac
done
if [ -n "${PHONE:-}" ] && [ "$PHONE" != "0" ]; then
phone=1
fi

pr="${rest[0]:?usage: prepare-pr.sh [--phone] <pr-number>}"
case "$pr" in
''|*[!0-9]*) echo "prepare-pr: PR number must be numeric (got '$pr')" >&2; exit 2 ;;
esac
Expand All @@ -33,6 +53,7 @@ read_gate() {
}
prepare_cmd="$(read_gate prepare)"
launch_cmd="$(read_gate launch)"
launch_phone_cmd="$(read_gate launchPhone)"
wt_parent="$(read_gate worktreeDir)"; wt_parent="${wt_parent:-.worktrees}"

# Resolve the PR's head branch (gh through the bot wrapper for consistent auth).
Expand All @@ -56,6 +77,12 @@ else
git worktree add -f --detach "$wt" "$sha" || { echo "prepare-pr: git worktree add failed" >&2; exit 1; }
fi

# Many apps read a gitignored repo-root .env at runtime (e.g. the deploy-server).
# A detached worktree has no .env, so symlink the main checkout's if present.
if [ -f "$root/.env" ] && [ ! -e "$wt/.env" ]; then
ln -s "$root/.env" "$wt/.env" && echo "▶ linked $wt/.env → $root/.env"
fi

if [ -n "$prepare_cmd" ]; then
echo "▶ prepare: $prepare_cmd"
( cd "$wt" && eval "$prepare_cmd" ) || { echo "prepare-pr: humanTest.prepare failed" >&2; exit 1; }
Expand All @@ -66,10 +93,31 @@ fi
echo ""
echo "✅ PR #$pr is ready to test. In your terminal:"
echo " cd $wt"
if [ -n "$launch_cmd" ]; then
echo " $launch_cmd"
if [ "$phone" = "1" ]; then
if [ -n "$launch_phone_cmd" ]; then
echo " $launch_phone_cmd"
echo ""
echo "⚠️ Phone testing mode — before you run this, know that:"
echo " 1. The printed https://*.trycloudflare.com URL is PUBLIC while the tunnel is up —"
echo " anyone with the link can reach it, not just your phone."
echo " 2. It exposes the dev studio AND its /api proxy, which can trigger REAL deploys"
echo " using whatever RPC URL / private keys are in your .env."
echo " 3. It is ephemeral — the tunnel and both dev servers die together on Ctrl-C."
echo " 4. It must be attended — don't leave it running unattended."
else
echo " (humanTest.launchPhone not configured in gates.json — falling back to launch)"
if [ -n "$launch_cmd" ]; then
echo " $launch_cmd"
else
echo " (no humanTest.launch configured — start the app manually)"
fi
fi
else
echo " (no humanTest.launch configured — start the app manually)"
if [ -n "$launch_cmd" ]; then
echo " $launch_cmd"
else
echo " (no humanTest.launch configured — start the app manually)"
fi
fi
echo ""
echo "When done, tear it down with: git worktree remove $wt"
15 changes: 15 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,24 @@ Configure the commands once in `.claude/gates.json`:
"humanTest": {
"prepare": "pnpm install --prefer-offline && pnpm -r build",
"launch": "pnpm dev",
"launchPhone": "sh -c 'pnpm dev & devpid=$!; trap \"kill $devpid 2>/dev/null\" EXIT INT TERM; cloudflared tunnel --url http://localhost:5173 --http-host-header localhost'",
"worktreeDir": ".worktrees"
}
```

Add `humanTest.worktreeDir` to `.gitignore`. Re-running `/test-pr` on the same PR fast-forwards the worktree to the
latest commit (idempotent). Tear down with `git worktree remove <path>`.

### Testing on a phone (`/test-pr <n> --phone`)

To iterate on a UI from a phone (touch gestures, small-screen layout) rather than a desktop browser, configure
`humanTest.launchPhone` and run `/test-pr <n> --phone`. It prepares the worktree exactly as above but prints
`launchPhone` instead of `launch`. `launchPhone` starts the dev server(s) **and** opens a public tunnel (e.g. a
`cloudflared` quick tunnel) to the running app, printing a `https://<random>.trycloudflare.com` URL you open in the
phone's own browser. The `--http-host-header localhost` flag makes the tunnel send `Host: localhost`, so a dev
server with a strict host allow-list (e.g. Vite) accepts it with no config change.

This is opt-in and attended, because the tunnel is **public** while up: anyone with the link reaches the app and
whatever backend/API it proxies, using the credentials in the worktree's `.env`. It's ephemeral — Ctrl-C tears down
the tunnel and the dev servers together. Don't leave it running unattended. Without `--phone`, behavior is
unchanged; if `launchPhone` isn't configured, `--phone` falls back to printing `launch`.
Loading