| name | rn-iso |
| description | The React Native / Expo CLI for AI agents. Each project (or worktree) gets its own owned simulator/emulator, a collision-free Metro port, a supervised dev server (`rn-iso start`), a build/install/launch that installs from a shared fingerprint cache when nothing native changed (`rn-iso ios` / `rn-iso android`), and a queryable log timeline (`rn-iso logs --errors`). Use to spin up a worktree, get a running app on a device that is yours, read the errors a build or a redbox produced, and discover which device to target for UI interactions. |
| user_invocable | true |
rn-iso -- the RN / Expo CLI for agents
You are an AI agent working on a React Native / Expo project, possibly alongside other agents working on different projects or worktrees. The RN and Expo CLIs are built for a human at a terminal: interactive pickers, progress bars, ephemeral colored output, thousands of lines you pay for in tokens whether the build succeeded or not. rn-iso reimplements the handful of operations an agent actually needs, with a deliberately small option surface: never prompt, print little, capture everything, expose state as JSON.
Invoke the CLI via npx: npx rn-iso <command>. Don't npm install -g.
npx usually resolves the latest published version, but not always: a stale entry in the npm cache (or a different node version's cache) can serve an old one, and an old CLI silently lacks commands this file documents. Check once per session -- npx rn-iso --version -- and use npx rn-iso@latest if it disagrees with the version stamped at the bottom of this file.
In a repo whose .npmrc pins a private registry (registry=https://<host>/..., common in company monorepos), npx tries to fetch rn-iso from THAT registry and fails -- typically npm error code E401 (expired token) -- because rn-iso is published on the public npm registry. Point npx at the public registry for this one package: npx --registry=https://registry.npmjs.org rn-iso <command> (and the same on rn-iso@latest). An E401/E404 on npx rn-iso is almost always this, not a broken install.
Read the CLI's own docs for anything specific
This file covers the rules that do not change between releases. For exact flags, the JSON payloads, and error remedies, ask the binary you are actually running:
npx rn-iso guide
npx rn-iso guide lifecycle
npx rn-iso guide facts
npx rn-iso guide metro
npx rn-iso guide logs
npx rn-iso guide errors
npx rn-iso guide cleanup
npx rn-iso guide settings
That output is generated by the installed CLI, so it cannot be out of date the way this file can. If this file and guide disagree, guide is right -- this copy may be older than the CLI. (Refresh it with npx skills.)
The model, in five rules
-
Every device rn-iso uses is one rn-iso created, named rn-iso-<label>, and recorded as owned. It never boots, adopts, or destroys a device it did not create. Teardown of the owner destroys the device, not just its assignment. There is no exception: physical-device support was removed, so there is no path that touches hardware.
-
Identity is proven before anything is destroyed. A port is not identity and a recorded id is not identity. Before killing a dev server or a device, rn-iso re-checks against live state and refuses when it cannot prove ownership. A refusal means something is genuinely unexpected -- investigate it, don't reach for --force.
-
You are not the only agent on this machine. Never assume booted is your simulator; never call a device verb without an explicit udid/serial; never hardcode a port. Read them from the --json payload every time.
-
Capture is unconditional and stdout is small. Every bundler event, in-app console.log, redbox, device log line and build transcript is written as NDJSON to $RN_ISO_HOME/workspaces/<readable-project-slug>--<16hex-path-digest>/logs (by default ~/.rn-iso/workspaces/...). workspace.json records the canonical project root. Commands print on the order of ten lines; a failing build prints the extracted compiler diagnostic and a log path, never the transcript. rn-iso runtime state is never written into the project tree.
-
Blocking is opt-in, and errors are codes. Long-running things return immediately unless you pass --follow / --wait. Every refusal carries {code, message, remedy} -- branch on code, never on the message text.
The flow
Step 1 is OPTIONAL: working directly in the checked-out repo is fully supported -- the main checkout is a workspace like any other, with its own port and owned device. Create a worktree only when you need a PARALLEL environment (another agent, another branch, side by side).
cd "$(npx rn-iso worktree create app-412 --carry-ignored)"
npx rn-iso start
npx rn-iso ios
npx rn-iso logs --errors --json
npx rn-iso logs --since 30s --level error
npx rn-iso stop
npx rn-iso worktree remove
Step 2 before step 3, always. ios / android never start the bundler. If nothing that verifies as this workspace's dev server holds the reserved port, they refuse in about a second with RN_ISO_NO_METRO rather than spending four minutes producing an app that cannot load a bundle. --no-metro-check overrides it when you know what you are doing.
start reserves the port, hosts the dev server under a detached per-workspace supervisor, and does not return until the server verifies as this project's -- so there is no backgrounding idiom, no sleep, and no poll loop. It is idempotent: run it twice and you get one supervisor, exit 0. A dev server you started is detected, left alone, and reported with supervisorPid: null.
ios / android check the port, then boot the owned device IN PARALLEL with fingerprinting and the cache-or-build work (install is the first step that needs a live device, so a cold boot costs no extra wall time), and install a build another workspace already made when the fingerprint matches -- so the second workspace on a commit costs a boot, not four minutes of xcodebuild. Then they install, launch the app wired to the reserved port, and attach a device-log collector. Repeat only when a NATIVE input changes; a JS edit needs nothing. While pod install or the compiler actually runs, a heartbeat lands on stderr about every 30s -- elapsed time plus the phase's current line -- so a multi-minute build is never indistinguishable from a wedged one; silence well past a minute means something is genuinely stuck.
Release builds (npx rn-iso ios --configuration Release, simulator only; npx rn-iso android --variant <anything>Release, emulator only) run with NO Metro at all -- the JS bundle is embedded in the app, so there is no start prerequisite, no RN_ISO_NO_METRO gate, no port wiring and no dev-client deep link (a plain launch), and the payload says metroPort: null. launched is verified by the app process staying alive after launch rather than by a bundle fetch. The cache keys the configuration separately (-release-sim), and because a cached Release .app carries the JS of whoever built it, a cache hit regenerates THIS workspace's bundle with the project's own tools (expo export:embed / react-native bundle, plus its hermesc when Hermes is enabled), swaps it into a copy of the artifact, re-signs and installs that -- a failed swap falls back to a full build rather than ever installing stale JS. The device-log collector still attaches, so rn-iso logs --errors still answers "does it repro in release / Hermes bytecode". ios.configuration in a settings file makes it the repo default; the flag overrides it.
On Android the same behaviour is selected by the variant's NAME, not by a second flag: any --variant (or android.variant) ending in Release -- release, productionRelease -- is a release build. No adb reverse, no debug_http_host, no dev-client deep link; a plain am start of the launcher activity, and launched proven by the app process being alive on the device (pidof). A release cache hit re-packs the cached APK rather than installing it: the bundle is regenerated and hermes-compiled, zip -0'd back in (stored, never deflated -- the runtime mmaps it), then zipaligned and re-signed with android/app/debug.keystore (android.keystore / android.keystorePassword override it). Two Android-only notes to expect on stderr, both benign: the asset gate falls back to a full gradle build whenever this workspace's emitted assets are not byte-identical to the ones the cached build emitted (it compares sha256 content hashes against an assets-manifest.json recorded in the cache entry, so an added, a removed AND a REPLACED image under an unchanged filename are all caught; an APK cannot be made to carry an asset AAPT did not package, so each of those costs one full build). An entry with no manifest -- stored before asset tracking, or downloaded from a build-cache provider -- never swaps at all; that run builds fresh and REPLACES the entry, so the next one swaps normally. The second note is a signer conflict with an already-installed CI-signed copy: it uninstalls the package once and retries, which takes the app's data with it, and is why only release runs do it.
rn-iso logs --errors is the query the whole design exists to make possible: the redbox with a symbolicated stack, the Metro resolution failure, the native crash, from one merged timeline. Do not read the NDJSON files directly -- npx rn-iso guide logs documents the filters and the record shape.
On APFS, worktree create --carry-ignored clones the source's WORKING STATE instead of leaving you to reinstall: every gitignored path (node_modules, ios/Pods, build codegen), plus anything a repo-root .worktreeexclude adds -- AND the source tree's uncommitted tracked changes, carried as a patch and applied when it fits the base (the cloned artifacts were installed against that working tree, not against a clean HEAD). The clone matches the source worktree, not this branch's manifests, so reinstall if the branch changes them. Off by default: outside APFS the clone falls back to a real copy of every byte, which worktree create warns about.
Read what --carry-ignored prints; it is not just a count. Three lines there are the difference between building and losing half an hour:
- No node_modules among them -- the source worktree has none, so the clone carried nothing you can build against. Install before doing anything else.
- Carried Pods do not match Podfile.lock --
ios/Pods is gitignored and cloned, ios/Podfile.lock is tracked and comes from the branch, so the two can disagree. rn-iso ios detects this and runs pod install itself; the warning matters if you build by hand, because xcodebuild reports The sandbox is not in sync with the Podfile.lock only after every pod has compiled.
Carried N uncommitted change(s) from the source (<files>) -- uncommitted here too; commit deliberately. -- the source's uncommitted tracked changes landed in this worktree as a patch, still uncommitted; whether they belong in a commit is your call, not the tool's. When the base diverges from the source HEAD the patch is NOT applied and a warning names the files instead -- the carried artifacts were installed for the source's uncommitted state, so fingerprints and cache keys will differ from the source's until reconciled.
One more consequence of "the clone matches the source, not the branch": a carried path that the BASE's .gitignore does not ignore (an ios/ dir carried into a branch that gitignores it differently, a generated file the branch stopped ignoring) shows up as untracked churn in git status -- and worktree remove will later, correctly, refuse over it. Restore it first (git checkout -- . for modified tracked files, git clean -fd <path> for untracked ones -- the refusal names the right command per class) rather than reaching for --force.
Env vars reach the dev server only through the start that spawns it. The supervisor (and a metro.config.js evaluated inside the expo child) inherits the environment of the start call that created it. A second start against a healthy supervisor is a no-op -- it cannot inject new env into the running one. To apply an env var (a cache flag, an API host): stop, then start with it set.
A foreign process on your reserved port is reported, not silently tolerated. rn-iso status warns about it (port 8082: pid 900 runs from /elsewhere), ios / android refuse with RN_ISO_NO_METRO, and stop refuses to kill it without --force. start re-reserves a fresh port instead, so the project is never stranded. npx rn-iso guide errors lists the causes -- most often a bundler started from a monorepo's root instead of the app directory.
Destructive commands -- ask the user first
Destruction lives in exactly two commands. Everything else, including stop, is safe.
worktree remove --force discards uncommitted changes and untracked files permanently. Plain worktree remove deletes the worktree and reaps its owned device; it refuses when the tree is dirty. pod install churn no longer causes that refusal: when the only dirt left is <app>/ios/Podfile.lock or <app>/ios/*.xcodeproj/project.pbxproj, tracked and unstaged, rn-iso restores those files itself and proceeds, printing restored <path> (pod install churn; the worktree is being removed) for each. They are inside a directory about to be deleted wholesale, and a lockfile change anyone intended would have been committed. One other dirty path and the whole set is refused, churn included -- that fail-closed rule is what keeps this from ever eating real work. When it does refuse over something else, restore what it names and retry rather than forcing. The refusal names the right command per class: git checkout -- for modified tracked files, git clean -fd (or an rm) for untracked ones, which checkout cannot clear. Current rn-iso state is global and never dirties the tree. It takes no argument: run it from anywhere inside the worktree, including a monorepo app dir, and it resolves the enclosing worktree root. npx rn-iso guide errors shows how. On the main checkout it reclaims the environment only: the owned device is deleted, the Metro port freed, the registry entries dropped, and the global workspace directory removed -- and it never touches source files (git cannot remove the main working tree, so the dirty-tree guard does not apply). It ends with Reclaimed the environment; the working tree stays (it is the main checkout). A registered project directory that is not a git repo at all gets the same environment reclaim.
gc --delete drops dead project entries and deletes the orphaned rn-iso-* devices it reported, potentially tens of GB of simulator. With --older-than <days> it also reaps the device of a project nothing has touched that long. With --all it empties the shared build caches, which every project on the machine then pays to refill. A bare gc only reports -- it writes nothing and is always safe.
stop --force is the one flag on a non-destructive command: it kills a process on the reserved port that rn-iso could not identify as yours. It deletes nothing. Plain stop halts the supervisor, reaps the device-log collectors, shuts the owned device down (never deletes it) and frees the port, so coming back to the branch costs a boot rather than a create and a reinstall. There is no --delete on it, deliberately -- an agent reaching for stop to reclaim memory must not have one within reach of a typo.
Capacity
A booted iOS sim is roughly 1-2 GB of RAM, an Android emulator 2-3 GB. On a 16 GB machine plan for 2-3 live environments, not more. By default nothing enforces this. npx rn-iso status reports every workspace on the machine (it is machine-wide by default, not scoped to where you are standing), which is how you find out you would be the fourth. One caveat when counting: a monorepo workspace holds TWO registry entries -- the worktree root (which only reserves the label) and the app directory -- so the root's entry carries labelOnly: true in --json and is relabelled in the human view; count only the entries without it. Tear down what you're done with before creating more.
Opt-in concurrency limits (unlimited by default). When a machine cannot host as many parallel builds or devices as there are agents, two machine-level caps can rein it in -- set them under a top-level concurrency key in ~/.rn-iso/config.json ({ "concurrency": { "maxBuilds": 2, "maxDevices": 3 } }), or via RN_ISO_MAX_BUILDS / RN_ISO_MAX_DEVICES which override the file. maxBuilds caps how many builds compile at once (a full slate WAITS -- it is a semaphore taken after the single-flight lock, so a waiter installing another workspace's artifact never burns a slot); maxDevices caps how many rn-iso-owned devices are booted at once, and a new ios/android at that cap is refused with RN_ISO_AT_CAPACITY (interactive-shaped: it does not queue -- stop an environment or raise the cap). Unset, 0, or any non-positive value means no enforcement. There is no rn-iso config command; these are files and env vars. See npx rn-iso guide lifecycle and guide settings.
Skipping the build when nothing native changed
Most changes touch no native input, and those should not compile anything. rn-iso ios / rn-iso android fingerprint native inputs with rn-iso's direct @expo/fingerprint dependency before building. The fingerprint is platform-scoped, and cache misses explain changed sources.
There are two levels. Level one is that local cache, shared by every worktree on the machine. Level two -- Expo projects only -- is the build-cache provider the project already configured for Expo (expo.buildCacheProvider: "eas", or a module of its own): it is consulted only when level one misses, a hit is copied into level one on the way past (cache remote hit (eas) -> stored locally), and a fresh build is stored locally and handed back to the provider. Every provider call is time-bounded and every failure is a note, so a slow, unauthenticated or misconfigured remote cache costs a build, never the run. The "eas" provider gets one extra, cheap check first, because it is the one that cannot report its own failures (it catches every eas-cli error and returns null, so a dead session looks exactly like an empty cache): a bounded eas whoami, once per run. Definitively logged out -> one yellow line (cache eas is not authenticated -- run \eas login` (or set EXPO_TOKEN); building with the local cache only) and the remote tier is skipped for that run; authenticated as somebody who does not cover the project's expo.owner-> a line naming both, and the cache is still consulted. Offline or unknown changes nothing at all.doctor` reports the same three states standing still. rn-iso never configures a provider and never suggests changing one; a bare React Native project has no provider concept and stays purely local, with no network call at all.
--no-build-cache on either command looks nothing up -- neither level -- and builds fresh. It still stores the result (replacing the entry it was told not to trust) and still uploads it, and the --json payload says cacheSkipped: true so you can tell that run from an ordinary miss. Reach for it when a cached artifact is suspect, not as a habit: a needless full build costs every parallel agent the machine's cores.
cacheHit in the payload is a LEVEL, not a boolean: "local", "remote", or false.
A cache-miss build can outlive your shell tool's timeout. A cold native build is 5-25 minutes; most agent harnesses cap a single command well under that. Run the build in the background and re-attach by simply running npx rn-iso ios --json again: it is idempotent and single-flight, so the re-run joins the in-progress build (or reports the finished result) instead of starting a second compile. The heartbeat lines on stderr are the liveness signal while you wait.
Also normal: npx may re-download rn-iso on every invocation (npm warn exec ... will be installed) in some environments. It costs a few seconds and nothing else -- do not "fix" it by installing globally.
Two agents on the same commit no longer both compile it. The cache only helps the second workspace once the first has finished, so agents that start within the same minute all miss it. When both levels miss, ios / android take a lock on the fingerprint: exactly one workspace compiles, and the others wait for its artifact and install that (build waited 12m41s for /w/app-412's build -> installed from cache), reporting cacheHit: "local" plus waitedForBuild: {pid, ms}. Waiting is not a hang, and it cannot deadlock -- the lock is held by a pid, so a builder that crashes, is killed, or whose build fails frees it and one of the waiters takes over and builds. A progress line naming the builder and its log appears every 30s. Don't reach for --no-build-cache to escape a wait: it never waits and never takes the lock, which is exactly how you get the duplicate 19-minute compile back. npx rn-iso gc lists any build in progress, and clears the locks whose builder is gone.
rn-iso needs no setup edits to run. Runtime state, logs, pidfiles and Xcode DerivedData live under $RN_ISO_HOME/workspaces/<project>--<digest>/ (default ~/.rn-iso/workspaces/...). Gradle keeps its normal project build directories but uses the shared build cache via rn-iso's command line. rn-iso supplies its caches on its own command lines, and npx rn-iso doctor reports the handful of things it cannot handle.
These caches never evict themselves. Every npx rn-iso gc run reports what they have grown to -- each one tagged registered or detected -- and gc --delete --older-than <days> trims the entries nothing has used. Trim rather than empty: gc --delete --all empties them whole -- the only way to clear an index-backed cache like Xcode's CAS -- and costs the next build in every project the time the cache was saving.
Runtime workspace and fingerprint dependencies
Runtime state is stored outside the project tree under
$RN_ISO_HOME/workspaces/<readable-project-slug>--<16hex-path-digest>/
(default ~/.rn-iso/workspaces/...). workspace.json records the canonical
absolute project root, and no .gitignore change is needed. The fingerprint
implementation is loaded from rn-iso's direct @expo/fingerprint dependency;
it does not resolve a project copy at runtime.
Command surface
Ten commands: doctor (what is silently costing build time) · worktree create|remove · start (dev server under a supervisor, blocks until healthy) · stop (the inverse of start) · ios · android (build/install/launch, from cache when nothing native changed) · logs (query the captured NDJSON timeline) · status (devices, ports, supervisor health, last build, error counts) · gc (dead entries, orphaned devices, records of devices that no longer exist, and the shared build caches) · guide
That is all of it. There is no init and no separate init skill -- rn-iso supplies its own caches and global workspace storage, while settings remain files and destruction is consolidated into worktree remove and gc --delete. There is also no up, no release, no shutdown, no config, no build-cache, and no worktree list; status covers listing and the build commands own the cache flow.
When things go wrong
All paths described as logs below are relative to the global workspace directory