npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

exagent

v0.0.8

Published

Agent-native CLI on top of the Expo CLI family

Downloads

1,001

Readme

exagent

Agent-native CLI on top of the Expo CLI family. exagent gives coding agents (and humans) deterministic, machine-readable entry points into Expo workflows. It invokes expo, eas-cli, expo-doctor, and friends as subprocesses — it does not import their internals.

Design documents: see llp/0001-agentic-cli-on-expo-cli.rfc.md and its child LLPs in this package.

Commands

  • exagent new <directory> — create a project with no TTY: run create-expo with every prompt answered, set the display name (--name), initialize a repository when there is none, and print what to run next (--json, --no-install, --no-git).
  • exagent deploy — ship the project: the web app to EAS Hosting (expo export --platform web then eas deploy), the native app through launch.expo.dev (create-launch --json, with --native and --upload-root <dir> for an app inside a monorepo). Prints the deployment URL and the launch URL, --json for the machine-readable report. With no target flag, a project that has web support deploys its web app.
  • exagent inspect:build-log — read a native build log and report the one line that says why it failed: the phase, a stable signature, the line number, the quoted context, a confidence and the command to run next. --file <path> or --stdin, which is implied when stdin is not a terminal, so npx expo run:ios 2>&1 | npx exagent inspect:build-log --json works as it reads. Deterministic extraction from a capped table of rules that ships in this repository — no model, no API key, no network. Logs are streamed, so a 100 MB Xcode log costs a bounded amount of memory. A log no rule matched is still a report: failure: null, the last lines under logTail, exit 0. --platform, --context, --all, --json. The <build-id> form is reserved and does not work yet, because eas-cli has no build:logs.
  • exagent agents:setup — set a project up for coding agents: link the agent skills of the installed packages, and maintain a managed block in the project's AGENTS.md describing the project and the commands that answer in a machine-readable shape.
  • exagent skills:sync / skills:list / skills:show / skills:clean — discover agent skills shipped inside installed Expo modules (skills/*/SKILL.md) and link them into agent skill directories (.claude/skills, .agents/skills, ...). Bare exagent skills is skills:sync.
  • exagent status — where the project is now and what would happen next: what the project is, whether Expo Go can run it, whether the last development build still matches, whether a dev server is running with an app connected, which agent skills are linked, and the command that would get the app onto a device. It also says what a change costsjs-only, dev-client-compatible or needs-native-build — on every run, because stale is a fact and that is what to do about it; the diff and the classification run in process from two fingerprints already in memory, so the line is free. Always exits 0. --json adds the raw project probe under probe — the machine-readable project brief, which exagent context used to print on its own. --explain is the deep dive and the one flag that costs anything: the sources that changed one by one, whether an update published now would reach the installed builds (runtimeVersion), and a fresh answer from EAS about whether it already has a finished build for this exact fingerprint — which can be eas build:downloaded instead of rebuilt. That last answer is cached against the project fingerprint, so it is reported for free until the project changes. To gate on the class in CI rather than read it, add --assert <class>: it exits 20 when the change costs more than the class named and 22 when nothing could be measured, and leaves the report on stdout either way. Without the flag the command still always exits 0. --explain --build <id> compares against a specific EAS build instead of the local record, which is the answer for a build made in the cloud. There is no separate exagent impact command: this is it.
  • exagent inspect:config-plugins — what the config plugins actually produced for each platform, read from expo config --type introspect --json: the native files per platform, the plugins that ran and which of them the app config declared, the autolinked modules, and the mods introspection never covers. Read-only; nothing is written to the project. --platform, --file <name> for one native file, --json for every value. exagent config on its own is still expo config, which this CLI forwards.
  • exagent doctor:check — run expo-doctor and normalize its report: the checks that failed, what each found, and the advice it gave. expo-doctor has no --json, so the parse is best-effort and says so in a parse field, with its full text under raw. The exit code is expo-doctor's own. Bare exagent doctor is doctor:check.
  • exagent typecheck — run the project's own tsc --noEmit and report its diagnostics as data: file, line, column, code and message, one entry each. Exit 0 when the project type-checks, 20 when it does not, 1 when the compiler could not be run. A project with no typescript or no tsconfig.json reports checked: false with a reason and exits 0. No compiler is ever fetched: a type check is a function of the project's own compiler, config and @types. This is the gate the others cannot be — a type error is neither a syntax error nor a throw, so neither smoke nor runtime:errors can see it.
  • exagent install <pkg> — run expo install, then sync the installed package's skills.
  • exagent dev — get the app onto a device: probe the project, print the plan it decided on (expo start, expo prebuild, expo run:ios/run:android, or eas build), then run it, with agent-friendly output and skills sync. --plan prints the plan and exits, --yes answers the confirmation a terminal is asked for before a plan that builds, --json prints the plan as one object. --eas/--local name where the native build runs, and --go/--dev-client name which app to run the project in.
  • exagent startexpo start and nothing else: every argument is forwarded untouched, and the agent skills of the installed packages are synced once the dev server is up.
  • exagent runtime:eval / runtime:errors — read and drive the running app over the dev server's debugger connection: evaluate JavaScript in it, or collect the runtime errors it reports over a window. Without --dev-server-url they find the project's dev server themselves, starting from the lock exagent start holds. App-originated output is fenced in untrusted-content markers.
  • exagent navigate <route> — open a route as a deep link on the booted simulator or attached device. The route is checked against the project's routes first, so a route that does not exist fails here instead of putting the app on the router's Unmatched Route screen and reporting success (--no-route-check skips it). --print-url resolves the URL and opens nothing, for a device this machine cannot drive — a phone, a teammate's laptop. --cloud opens it on this project's EAS Simulator session, which is also what a machine with no local device falls back to. --scheme, --ios/--android, --json.
  • exagent runtime:reload — put the running app back on the code that is on disk, and prove that it went. Fast Refresh cannot recover an app whose component threw while rendering, so after the fix a bundle check goes green while the app keeps running the old JavaScript; this is the command that ends that state. --route lands it on a route afterwards, --method pins how it reloads, --json.
  • exagent runtime:tree / runtime:tap / runtime:type[experimental] drive the app by testID: list what is on the screen, tap an element, type into an input. They walk React's own component tree through the DevTools hook a development bundle installs, and call the onPress or onChangeText the app wrote — so this calls props, it does not touch the screen. runtime:tree defaults to the focused screen and to the nodes you can act on (--all, --all-screens, --max-nodes, --testID); runtime:tap <testID> refuses a disabled element unless --force and proves its own effect with --verify; runtime:type "<text>" --testID <id> calls onChangeText, and --submit calls onSubmitEditing after it.
  • exagent runtime:stop — stop the app on the booted simulator or attached device: the counterpart of navigate, which starts one. --cloud stops it on this project's EAS Simulator session instead, through the session controller's close verb — the app, never the session, which keeps billing until eas simulator:stop. Works out which app to stop — Expo Go or a development build — and says which evidence named it (--app-id, --ios/--android, --json).
  • exagent smoke[experimental] the whole gate in one command: find the dev server, wait for its bundler and check it serves this project, build the entry bundle, wait for an app and open one if there is none, open a route, evaluate in the runtime, collect errors over a window, and photograph the screen. Eight phases, each reported with its status and how long it took, and one exit code over all of them: 0 passed, 20 failed (the app threw, the entry bundle does not compile, or the dev server belongs to another project), 22 inconclusive (a wait expired, no app connected, or the runtime cannot be read), 1 the command could not do its job. --start lets it start a dev server rather than fail for want of one; --route, --window, --ios/--android, --json. --platform web is refused: a browser registers nothing in the debugger target list, so there is no runtime there to read.
  • exagent dev:logs — read what a dev server started with exagent dev --detach has printed. --tail <n> for the last lines, --json.
  • exagent dev:stop — stop this project's dev server, without composing an lsof. --port looks at a port when no lock answers, --force stops a dev server this CLI did not start, --signal, --json.

A capability with several actions is one group with one command per action, spelled group:action the way eas-cli spells its own: runtime:eval, skills:list, dev:logs. The space form resolves to the same command, so exagent skills list is exagent skills:list. A bare group prints its actions, except where one action is the obvious one: exagent skills syncs, exagent doctor checks, and exagent dev runs the plan engine. An action a group does not have fails with the listing of the ones it does; it is never forwarded to expo.

Some commands are marked [experimental] in --help, and the section they are in says so: they may change or vanish. The mark is per command, never per group — inspect is a group a stable action can join.

The expo commands exagent does not wrap are forwarded to the project's expo CLI — run, run:ios, run:android, prebuild, config, export, export:web, export:embed, serve, customize, lint, login, logout, register, whoami — so exagent prebuild --clean runs expo prebuild --clean, with the arguments, the output, the errors and the exit code all left to the Expo CLI. That set is fixed, not a fallback: a name in neither list is a command neither CLI has, and it fails saying so instead of becoming an expo invocation that could not have meant anything. A command that shares its name with an expo command behaves like that command; the capabilities only exagent has get verbs of their own (llp/0006). expo add is expo install under another name, so exagent add is the exagent install wrapper — skill sync and impact report included — rather than a bare forward.

deploy --native delegates to create-launch, run as a subprocess with --json — the same process boundary the rest of the Expo CLI family is behind (llp/0001). That CLI packs the project source, uploads it as the signed-in Expo user (npx expo login, or EXPO_TOKEN on a machine that cannot sign in), and answers with a launch URL that exagent hands over. Opening that URL is a required step, not a suggestion: the store account, the signing and the submission for iOS and Android happen in the browser, and the link expires in 8 hours. There is no platform flag and no build profile — one launch covers both platforms. --upload-root <dir> names the directory to upload for a monorepo; the launch CLI is then run from there with --project <app>. It is resolved from the project's node_modules/.bin, then PATH, then npx create-launch@latest.

While exagent start or exagent dev runs a dev server, it holds a dev-server lock for the project: a unix socket at .expo/exagent-dev-server.sock (a named pipe derived from the project path on Windows) that answers one JSON line naming the URL, the port and the PID. status and the runtime commands ask it before they scan ports, so a dev server that had to walk past a busy 8081 is still found. The lock is a socket and not a file on purpose: nothing answers unless the process holding it is alive right now, so there is no record to go out of date and no PID to check. What it names is still probed, never trusted — a lock proves the wrapper is running, and the probe proves the dev server behind it is. A dev server started by expo start directly holds no lock; those are found the way they always were, from the port in .expo/dev/logs/start.log and then a short scan. --dev-server-url skips all of it.

runtime:reload broadcasts on the dev server's client command socketws://<host>:<port>/message, the same channel the interactive r keypress uses — so it needs no simulator tooling, no application id, and no knowledge of which device the app is on, and works the same on iOS and Android. Every frame of that protocol carries a version stamp, and a dev server that speaks another version drops one without an error, so the command asks getpeers first: an answer proves the version and names the connected clients. A reload is reported only when it was observed — the app's connection has to be replaced by a new one, and an app has to be attached again afterwards. When no app answers there, runtime:reload falls back to stopping the app on the device and opening it again. Exit codes: 0 reloaded and back, 20 not reloaded, 22 reloaded but not reconnected before --timeout.

dev:stop reads the dev-server lock, not a port. The line the lock answers with carries the PID of the exagent process holding it, and both spawn paths forward terminal signals to the expo start child — so one SIGTERM takes the bundler with it, and there is no port to guess at. Live it takes about 170 ms. A port that something listens on with no lock behind it is a dev server this CLI did not start, most often a second project's: it is reported with its PID and left running, and dev:stop exits 20. --force stops it only when two independent things agree — the port answers packager-status:running, and the process on it looks like one that runs a dev server. Either alone can be wrong about which process owns the port at that moment. Nothing running is exit 0, because that is the state the caller asked for.

runtime:tree, runtime:tap and runtime:type address elements by the testID in the app's own JSX, and three rules make that work on a real screen. A testID written once lands on every fiber that forwards props down to a host view — 17 fibers for 4 elements on the screen this was measured against — so a match is an element, a fiber no ancestor of which carries the same testID, and --index is needed only when two real elements share one. Inside that element, the shallowest handler wins: react-native-gesture-handler's RectButton puts onPress on six fibers of one group, and only the outermost is the app's own function. And an app keeps the screens you are not looking at mounted, so the default is the screen React Navigation reports as focused; where focus cannot be read the report says focusedScreen: null and contains everything, which is honest rather than an error, and --all-screens asks for the whole tree on purpose.

The honest limits ride in each command's --help rather than in a footnote. This calls props, not touches: there is no press timing, no responder chain, no gesture recognition, and the handler gets a synthetic event whose coordinates are zero. A button behind a modal or at zero opacity is indistinguishable from a visible one, because this walks the component tree and has no geometry. A disabled button keeps its onPress — React Native disables the press at the responder level, which this never goes through — so a disabled element is refused with exit 20 rather than tapped, and --force is the override. Nothing works on Expo Go for Android, which ships no debugger; a production bundle installs no DevTools hook and is refused with RUNTIME_TREE_UNSUPPORTED rather than answered with an empty screen. --verify is the only proof on offer: it walks the tree again a second after the tap and reports what appeared, vanished or changed text — and changed: false is printed as "the handler ran and nothing in the component tree moved", never as "the tap did nothing".

runtime:stop's hard part is not the device command, it is the name: Expo Go and a development build are different applications, and a project moves between them. The evidence is ranked — --app-id, then the app actually connected to the dev server, then ios.bundleIdentifier / android.package from the app config, then Expo Go — and bundleIdSource in --json says which rung answered, so a stop that hit the wrong app is diagnosable. The dev server outranks the app config because the config describes a build of this project and the dev server describes what is running. An app that was not running is a success with a note, not a failure.

navigate reads the project's routes from the files under the router directory, the way Expo Router reads them: nothing the dev server serves answers "what routes does this app have" for a native target, and the app's own _sitemap screen cannot be asked when the app is the thing that is broken. A dynamic route matches as a pattern, so /users/42 resolves against app/users/[id].tsx. The check answers whether the project has a route, not whether the app's navigator can display it — a navigator that declares its screens explicitly, such as NativeTabs, may stay where it is for a route outside that set. A project with no router directory is not judged at all.

A device somewhere else. navigate drives a booted simulator or an attached device, and the device the app runs on is not always one this machine has: a cloud simulator, a phone, a teammate. navigate <route> --print-url resolves the whole URL — route check, scheme, Expo Go vs development build, the tunnel host — and prints it for whatever can open it, together with the URL that points an app at this dev server in the first place: exp://<host> for Expo Go, <scheme>://expo-development-client/?url=… for a development build, and both labelled when nothing establishes which is running. When a dev server was started with --tunnel, that URL leads with the tunnel host rather than a LAN address nothing off this network can reach; the host is read from the Waiting on <url> line the dev server writes into the log of a dev --detach run. Suggestions that need a local device are dropped on a machine that has none, and replaced by the URL to open elsewhere.

A device in the cloud. When the device is an EAS Simulator session, exagent drives it rather than describing it: navigate <route> --cloud opens the route on the session, and a machine with no booted simulator and no attached device falls back to one when the project has it. The session is found by asking the service — eas simulator:list --status in-progress — rather than by looking for a file: .env.eas-simulator outlives the session it names and is not written at all by every way of starting one, so it is kept as the preference between several live sessions rather than as proof that any exist. Only agent-device sessions are candidates, and the pick between several is deterministic: the session the dotenv names, then the platform asked for, then the most recently created. The link goes through eas simulator:exec, which is how the EAS CLI runs the session's controller. The rest of the loop follows the same device: runtime:stop --cloud ends the app with the controller's close verb, and runtime:reload --cloud uses that plus the relaunch for its device fallback — the reload broadcast itself already reaches a session, over the tunnel it has to have. One honest limit rides with that: close reports success whatever application id it is given, so runtime:stop --cloud reports wasRunning: null rather than claiming the app it named had been running. Reload is unaffected, because it proves the reload independently instead of trusting the stop. deviceBackend in --json says which of the three backends acted, on navigate and on smoke, because platform no longer answers that: a session runs iOS too. A cloud simulator needs a tunnelled dev server, since exp://127.0.0.1:<port> names the loopback of whatever resolves it; a LAN or localhost URL is refused rather than opened onto an error screen. It needs a signed-in Expo account, and a run without one exits 7 naming the login. The eas simulator:* commands are experimental. Every invocation exagent makes is gated behind one module, and has been run against a live session on [email protected] — the listing, the availability check, open, close, screenshot, and the session lifecycle — with the real payloads recorded in src/__fixtures__/eas/ and parsed by the tests. What that short session could not reach, on a simulator with no app installed, is still unverified.

Where a build runs is decided before the plan is printed. A native build happens in one of two places: local, on this machine with Xcode or the Android SDK, or eas, in the cloud with an Expo account. exagent dev probes this machine for the target platform's toolchain and puts the answer in the plan's steps — a machine with no Xcode gets eas build --platform ios --profile development rather than a local plan and a warning that it will fail. The choice happens at planning time and never mid-run, so the plan a driving agent approves is the plan that runs (llp/0008). The plan's Build: line says which place and why, in the same sentence the --json payload carries under buildLocation.selection, and exagent status prints it on a build line. Two cases read differently on purpose: a host that cannot have the toolchain (iOS on Linux or Windows) is told that no install would change it, and a host that merely has not installed it is told how. A probe that could not run leaves the build here — nothing was established, and a build queue is the wrong place to send somebody over a question nobody could answer.

The developer decides, and the config decides before the machine does. --eas and --local name the place for one run; --go and --dev-client name the app to run the project in, so a project Expo Go could run is planned as a development build when --dev-client says so. The project may say the same thing once, in package.json under expo.exagent — beside expo.install and expo.doctor, which is where Expo tooling configuration already lives:

{
  "expo": {
    "exagent": {
      "target": "dev-build",
      "buildBackend": "eas",
      "android": { "buildBackend": "local" }
    }
  }
}

target is expo-go or dev-build; buildBackend is local or eas, and an ios or android key overrides it for that platform. A flag beats the config, and the config beats detection. Every decision a preference changed is labelled in the plan's Why list and in status, so a plan the config moved is never mistaken for one it did not. A key or a value this CLI does not know is an error rather than a warning: a preference that was silently dropped leaves you approving a plan you did not ask for.

The runtime commands need a runtime that speaks the Chrome DevTools Protocol. Expo Go on iOS does. Expo Go for Android ships a JavaScript engine built without the CDP debugger, so runtime:eval there fails with RUNTIME_EVALUATE_UNSUPPORTED and the two reading commands connect but report an empty window; use a development build to drive an app on Android. runtime:tree, runtime:tap and runtime:type need one thing more — React's DevTools hook, which a development bundle installs and a production bundle is expected not to — and refuse with RUNTIME_TREE_UNSUPPORTED where it is absent.

Status

Experimental. Commands and output formats may change.