ios-simmer
v0.2.0
Published
Fast terminal loop for building, installing, launching, and streaming logs from iOS apps — no Xcode window required.
Readme
simmer
A fast terminal loop for building, installing, launching, and streaming logs from iOS apps — no Xcode window required.
pick target → build → install → launch → stream logs → [r/l/c/f/d/s/v/q]Install
Published as ios-simmer on the
public npm registry (the package is named ios-simmer; the command it
installs is simmer):
npm install -g ios-simmersimmer is now on your PATH. Re-run the same command to upgrade to the
latest published version. No auth needed — it's a public package.
Global installs straight from the git repo (
npm install -g git+ssh://...) don't work reliably — npm's git-dependency install path can leave a global install symlinked into its own cache tmp dir, which gets cleaned up right after, breaking the binary. Hence a real npm publish instead.
Local development
cd ios-simmer
npm install
npm run dev -- <args> # runs straight from src/ via tsx, no build neededOr npm link after npm run build to get a simmer binary backed by your
working tree.
Releasing a new version
- Bump
versioninpackage.json(semver). npm run buildand commit the updateddist/alongside your source changes — CI fails ifdist/drifts fromsrc/.- Tag and push:
git tag vX.Y.Z && git push origin vX.Y.Z. This triggers.github/workflows/publish.yml, which builds andnpm publishes to the public registry using the repo'sNPM_TOKENsecret (an npm automation token — create one at npmjs.com → Access Tokens and add it as a repo secret namedNPM_TOKEN).
Use
From an iOS project root (a directory containing a .xcworkspace or
.xcodeproj):
simmerFirst run in a project prompts for:
- Scheme — auto-picked if there's only one, otherwise a numbered list.
- Bundle identifier — no reliable way to read this from
xcodebuild -list, so it's asked once and cached. - Target — booted/bootable simulators and paired devices in one numbered list, most recently booted first.
All three are saved to .simmerrc in the project root so subsequent runs
don't re-prompt. Override any of them per-invocation:
simmer --scheme mobile-staging --bundle-id com.halftone.mobile.dev --target <udid>Flags always win over .simmerrc.
Keys while logs are streaming
| Key | Action |
|---|---|
| r | Rebuild, reinstall, relaunch |
| l | Relaunch the already-installed binary — no rebuild, for resetting app state fast |
| c | Wipe DerivedData and force a full rebuild |
| f | Toggle log filter: app-only ↔ everything (simulator only — see caveats) |
| d | Prompt for a URL and deep-link into the running app |
| s | Screenshot the active simulator (simulator only — see caveats) |
| v | Start/stop serve-sim on the active simulator for agent-driven e2e control |
| q / Ctrl-C | Terminate the app, kill the log stream, exit cleanly |
Builds stay quiet: a braille spinner + elapsed timer replace the usual
xcodebuild flood. On failure, only extracted compilation errors are
printed, then the keypress loop resumes — it never installs/launches stale
output, and it never kills the whole session.
Where things go
- DerivedData —
~/.cache/simmer/derived-data/<project-slug>-<hash>, keyed by the project's absolute path. This is deliberately outside the project tree: it stays stable across runs, and because two git worktree checkouts of the same repo have distinct absolute paths, each worktree gets an isolated build directory automatically — no cross-worktree stomping, no.gitignorechanges needed for this one. - Logs —
<project>/logs/<YYYY-MM-DD-HHmm>.log, one file per run, always written in full regardless of which filter mode is echoed to the terminal.simmerappendslogs/(and a couple of its own housekeeping files) to the project's.gitignorethe first time it runs there, if a.gitignoreexists — additive only, never touches existing lines. - Screenshots —
<project>/screenshots/<timestamp>.png. - Config —
<project>/.simmerrc(JSON: scheme, bundle id, workspace/ project file, last-used target).
Platform caveats (read before relying on these)
A few "device equivalent" commands in the original spec don't have a public
1:1 match in devicectl, and I didn't want to fabricate flags that only
look right. Rather than silently degrading, these throw a clear error on
device targets:
- Screenshots (
s) — simulator-only. There is nodevicectlsubcommand that dumps a screenshot PNG for a physical device; screen mirroring is only exposed through Xcode's Devices window / QuickTime screen recording. - "Everything" log mode (
f→ all) — simulator-only. It shells out tosimctl spawn <udid> log stream; there's no devicectl equivalent for a physical device's unified log. - Deep links (
d) on device — usesdevicectl device process launch --payload-url, which is real and documented, but unlikesimctl openurlI haven't verified it against a real device + app. Simulator deep linking (simctl openurl) is solid.
None of this affects the simulator path, which is what the core loop was
built and tested against end-to-end (build → install → launch → live log
stream → clean SIGTERM shutdown, verified against photo-journal-ios).
serve-sim integration
v shells out to npx serve-sim <simulator-name> against whichever
simulator is currently active, and --kill <name> on v again or on quit.
Two things worth knowing:
serve-simis simulator-only (it captures the sim's framebuffer viasimctl io) —verrors out on a device target.- Its CLI matches a simulator by display name (e.g.
"iPhone 17"), not UDID, since that's the interface it exposes. This is a best-effort match: if you ever have two booted simulators with the same name, it's ambiguous.
Install serve-sim's Agent Skill separately per its own docs
(bunx add-skill EvanBacon/serve-sim or the Claude Code plugin) — simmer
only sequences the handoff, it doesn't reimplement the streaming/skill.
Not implemented from the original scope
- Arrow-key interactive picker (numbered stdin selection only).
- Full-screen
inkapp (lightweight ANSI spinner + status line instead).
Layout
simmer/
├── package.json
├── src/
│ ├── cli.ts # commander wiring, orchestration, state machine
│ ├── config.ts # .simmerrc, DerivedData/logs path resolution
│ ├── discover.ts # find .xcworkspace/.xcodeproj, list schemes
│ ├── picker.ts # simulator + device listing, numbered picker
│ ├── build.ts # quiet xcodebuild wrapper + error extraction
│ ├── launch.ts # install/launch/openurl/screenshot (sim+device)
│ ├── logs.ts # console stream + optional system log, tee to file
│ ├── keypress-loop.ts # raw-mode single-keypress dispatch
│ ├── status.ts # status line rendering
│ ├── tui.ts # ANSI styles, loading indicator
│ ├── serve-sim.ts # serve-sim handoff
│ ├── prompt.ts # readline helpers used outside raw-mode
│ └── types.ts
└── bin/simmer