xcodebuild-axi
v0.4.1
Published
AXI-compliant xcodebuild wrapper — turns multi-megabyte Xcode build and test transcripts into a few lines of TOON
Maintainers
Readme
xcodebuild tells an agent everything and therefore nothing. One passing test
run of a single app in a real iOS workspace prints 549,290 bytes across 6,595
lines — 767 of which say a test passed, and none of which say anything went
wrong. A full verify run of the same repo prints 2.5 MB. An agent that has to
read that to learn one number pays for it on every change, several times over.
xcodebuild-axi runs the same build and reports the answer.
$ xcodebuild-axi test --scheme MyApp --device "iPhone 17 Pro"
test: passed
scheme: MyApp
destination: iPhone 17 Pro · iOS Simulator 26.5
tests: 767 passed / 0 failed / 0 skipped
duration: 15m34s
log: ~/Library/Caches/xcodebuild-axi/MyApps-1a2b3c4d/MyApp-iPhone-17-Pro-26-5-test-4821.log
result: ~/Library/Caches/xcodebuild-axi/MyApps-1a2b3c4d/MyApp-iPhone-17-Pro-26-5-test-4821.xcresultThe whole transcript still lands in log, so nothing is lost — it just stops
being the default answer. A run that is still going after 30 seconds prints
that log: line on stderr straight away, so a hung run can be tail -f'd while
it hangs, and --live streams the transcript itself to stderr as it arrives.
Why it can be this small
The transcript is not the best record of a build. The .xcresult bundle
xcodebuild writes alongside it is: a few KB of JSON carrying pass/fail counts,
the device the run actually landed on, and every diagnostic with a precise
source location. xcodebuild-axi streams the transcript straight to a log file
it never reads into memory, then reports from the bundle. (One exception: the
bundle places an error inside #expect in the compiler's temporary expansion
file, so for those the log is streamed once to find the line it came from.)
That also makes it more accurate than grepping. A failed build reports the real error with the real line:
$ xcodebuild-axi build --scheme Probe
build: failed
scheme: Probe
destination: My Mac · macOS 26.6.1
duration: 4.6s
errors[1]{file,line,col,type,message}:
Sources/Probe/Probe.swift,2,25,swift,Cannot convert value of type 'String' to specified type 'Int'
log: ~/Library/Caches/xcodebuild-axi/Probe-9f8e7d6c/Probe-My-Mac-build-5102.log21,636 bytes of transcript, one line of answer.
Measured
Every number below is produced by npm run benchmark, which asks raw
xcodebuild and xcodebuild-axi the same question against a real 12-scheme
iOS workspace with 16 local Swift packages, and tokenizes both answers.
| Question | xcodebuild | xcodebuild-axi | Saved |
| --------------------------------------- | -------------- | ---------------- | ---------- |
| what can I build? | 498 tok | 97 tok | 80.52% |
| what can I run it on? | 1,720 tok | 248 tok | 85.58% |
| what is the bundle id? | 12,222 tok | 26 tok | 99.79% |
| which targets have index settings? | 61,850 tok | 42 tok | 99.93% |
| which SDKs are installed? | 244 tok | 70 tok | 71.31% |
| which test plans does this scheme have? | 445 tok | 42 tok | 90.56% |
| all 6 together | 76,979 tok | 525 tok | 99.32% |
Token counts are GPT-4o BPE via gpt-tokenizer — Anthropic's tokenizer is not public, so this is a stand-in, and the ratios are what matter rather than the absolute numbers. Both stdout and stderr are counted, because that is what an agent running the command in a shell actually reads. Measured by npm run benchmark against one app of a real multi-scheme iOS workspace on 2026-09-24.
Those are the read-only commands, and they win anyway, because xcodebuild
reprints its invocation, Resolve Package Graph, and the full resolved package
list on every call — about 1.2 KB of identical preamble in front of a
349-byte answer.
The build and test rows are where the margin is widest and are not in the
table above: they drive a cold build per side and want a quiet machine. Add
them with
npm run benchmark -- --project ~/YourApp --scheme YourScheme --only build --resume --write
npm run benchmark -- --project ~/YourApp --scheme YourScheme --only test --resume --writeFor scale in the meantime: one build of the scheme above wrote a 4.7 MB
transcript to its log, and a test run of another app in the same workspace
wrote 432 KB. Both are reported in well under 400 bytes.
Reproduce any of it yourself:
npm run benchmark -- --project ~/YourApp --scheme YourScheme # everything
npm run benchmark -- --project ~/YourApp --scheme YourScheme --quick # skip build and testBuilds and tests get a separate derived-data directory per side, wiped before
each run, so neither side gets an incremental-build advantage over the other.
Every scenario is checkpointed as it finishes, so --resume picks up whatever
already ran.
Install
npm install -g xcodebuild-axiOr run it without installing:
npx -y xcodebuild-axiRequires macOS with Xcode installed, and Node 20+.
Commands
Running it with no arguments shows the project in front of you, not a manual:
$ xcodebuild-axi
bin: ~/.local/bin/xcodebuild-axi
description: Agent-ergonomic wrapper around xcodebuild.
workspace: MyApps
scheme_count: 12
schemes[12]: Analytics,Checkout,DesignSystem,Feed,MyApp,MyApp-Widget,...
last: Test - Checkout on iPhone 17 Pro · iOS Simulator 26.5 — 89 passed (4m ago)
help[2]:
Run `xcodebuild-axi build --scheme <name>` to build
Run `xcodebuild-axi test --scheme <name>` to run tests| Command | What it does |
| -------------- | ------------------------------------------------------------------------------------------------ |
| (none) | Dashboard: what is here, what can be built, how the last run went |
| build | Build a scheme; report only errors, with file,line,col; --for-testing compiles its tests too |
| run | Build, install and launch the app, with its console in a file |
| test | Run tests; report counts and only the failures |
| tests | Enumerate the tests a scheme defines, without running them |
| clean | Clean a scheme's build products |
| analyze | Run the static analyzer; report only what it found |
| archive | Archive a scheme and report the archive's bundle id and version |
| export | Export an archive, writing the export options plist for you |
| schemes | List the schemes in the workspace or project |
| destinations | List the destinations a scheme can actually run on |
| testplans | List a scheme's test plans |
| settings | Read named build settings instead of dumping all 400 |
| packages | Read the pinned Swift package versions; resolve them on request |
| info | Xcode version, SDKs, and what this tool is pointed at |
| result | Re-read a previous run's .xcresult without rebuilding |
| coverage | Code coverage from a result bundle, per target or per file |
| sim | Drive simulators: screenshots, logs, permissions, pushes, and more |
| platforms | Installed runtimes, and the downloads that add more |
| localize | Export and import XLIFF localization catalogs |
| xcframework | Bundle built frameworks or libraries into an .xcframework |
| find | Resolve an executable or library to its toolchain path |
| migrate | Report the project file format, and convert it to a newer one |
| setup | Install session-start hooks for Claude Code, Codex, and OpenCode |
To check that tests compile without running them — no simulator is booted —
build --for-testing compiles the scheme's test bundles, and its report says
the test --without-building line that runs what it built.
Every command takes --help.
How much of xcodebuild
Coverage: 100% of the 161 leaves xcodebuild documents — every option, build action, export options key, -create-xcframework argument, and the second forms that only a usage line mentions.
A leaf is one switch you could type. Counting options alone says 100% (117/117), which was true and hid every gap below: an option is one thing, and -exportOptionsPlist alone opens eighteen more.
| Surface | Leaves | Covered |
| ------------------------------------------------------ | ------- | -------------- |
| xcodebuild -help options | 117 | 117 (100%) |
| build actions | 10 | 10 (100%) |
| second forms (-version <infoitem>, -license check) | 8 | 8 (100%) |
| -exportOptionsPlist keys | 18 | 18 (100%) |
| -create-xcframework options | 8 | 8 (100%) |
| total | 161 | 161 (100%) |
Reach: 99.5% of the 369 command-and-option pairs. The same options, counted once per command xcodebuild accepts them on — because -target exposed on build and missing from settings is not covered for anyone asking settings. 0 pairs are open.
108 options map to an xcodebuild-axi flag. The other 9 are reachable without one:
| Option | How |
| ------------------------------- | ------------------------------------------------------------------------------- |
| -json | every read-only query asks for JSON, then reports TOON |
| -project | set from the .xcodeproj found in the working directory |
| -skipMacroValidation | macro trust is an interactive prompt in disguise, and an agent cannot answer it |
| -test-enumeration-format | always json, so tests can parse it |
| -test-enumeration-output-path | written to the tool's cache and read back, never printed |
| -test-enumeration-style | always flat; tests does its own grouping by target and suite |
| -workspace | set from the .xcworkspace found in the working directory |
| -help | xcodebuild-axi --help, which answers it in a fraction of the tokens |
| -usage | xcodebuild-axi <command> --help, per command rather than all 117 at once |
Companion tools
xcresulttool, xccov and simctl are not xcodebuild, so they are not in the number above — but this tool wraps all three, and an agent that has to shell out to one directly has dropped back down. 70.4% of 71 leaves, counted the same way:
| Tool | Leaves | Covered |
| -------------- | ------ | ---------- |
| xcresulttool | 21 | 17 (81%) |
| xccov | 9 | 9 (100%) |
| simctl | 41 | 24 (58.5%) |
The denominator is read from xcodebuild -help rather than hand-maintained, and this table is written against Xcode 27.0 — the option list moves between releases. npm run coverage:check fails on that Xcode if an option here is unclassified or has been dropped, and reports the difference without failing on any other.
Destinations you do not have to spell
The -destination specifier is the thing agents most reliably get wrong
against raw xcodebuild, and a miss costs a whole failed invocation. Pass a
name, or pass nothing:
xcodebuild-axi test --scheme MyApp --device "iPhone 17 Pro" # matched for you
xcodebuild-axi test --scheme MyApp # booted simulator, else newest
xcodebuild-axi test --scheme MyApp --destination "platform=iOS Simulator,id=…"Names are resolved to a simulator udid before the run, because two runtimes routinely publish the same device name and a name-based specifier silently picks whichever xcodebuild sees first. The reported destination is the one the run actually landed on, read back out of the result bundle.
One run per simulator
Two runs installing onto the same simulator kill each other's app mid-test,
and each reports the other's damage as its own failure. So test and run
hold a lock on the simulator they install onto, and a second run aimed at the
same one is refused before it builds anything:
error: iPhone 17 Pro · 26.5 is in use by another run
code: DEVICE_BUSY
udid: 6F1E2D3C-8A9B-4C5D-9E0F-1A2B3C4D5E6F
holder:
pid: 48213
command: test
scheme: MyApp
project: ~/src/MyApps.xcworkspace
held: 3m12s
lock: ~/Library/Caches/xcodebuild-axi/device-locks/6F1E2D3C-8A9B-4C5D-9E0F-1A2B3C4D5E6F.json
help[3]:
Add `--wait 900` to wait up to 15 minutes for it
Pass `--device <name>` for another simulator
`--no-device-lock` goes ahead anyway, and the two runs will overwrite each other's installs--wait <secs> queues behind the holder instead, printing one waiting: line
on stderr. A run that picks its own simulator steers around a busy one rather
than queueing for it — including two runs started at the same moment, where
the one that loses the race picks again. A raw xcodebuild … test against
the same udid is noticed too, and named as such. build never locks — it
installs nothing — and the sim verbs that would disturb a running app (install, launch,
terminate, erase, …) check the lock without taking it.
The lock is a file, so other tools can take part:
~/Library/Caches/xcodebuild-axi/device-locks/<UDID>.json. The device is busy
while that file exists and the process named by its pid is alive. Create it
atomically (write elsewhere, then link or rename into place) with at least
pid and started (ISO 8601), and remove it when done.
Seeing the app
run is the loop from a change to a running app: it builds, boots the
simulator, installs, launches, and sends the app's print() output to a file.
The sim commands then look at it without a name, as long as one simulator is
booted:
xcodebuild-axi run --scheme MyApp --env API_URL=http://localhost:8080
xcodebuild-axi sim screenshot
xcodebuild-axi sim logs --scheme MyApp --last 2m # the app's own log lines
xcodebuild-axi sim open booted myapp://checkoutsim logs asks the unified log only for lines the app's own binary wrote —
filtering on the process instead returns hundreds of system lines for every
one the app logged. --system asks for those too.
Reading a run again
result with no path re-reads the last run in the current project, so nothing
needs re-running — or copying out of a report — to be re-read:
xcodebuild-axi result --failures --fullThat is also how to look at a screen several taps deep: drive it with a
throwaway UI test that attaches screenshots, then export them. Questions only a
test run can answer (--export attachments, --tests, --against, …) skip
past a later build to the last test run, and the report says which bundle it
read and how old it is.
xcodebuild-axi test --scheme MyApp --only MyAppUITests/ProbeTests
xcodebuild-axi result --export attachments --filter '*.png'A bundle from anywhere else — CI, or a run given --artifacts-dir — takes its
path: xcodebuild-axi result build/MyApp.xcresult.
Ambient context
Two ways to get this in front of an agent before it reaches for raw
xcodebuild. You only need one.
Session hooks — the project's schemes and last run become context at the start of every session:
xcodebuild-axi setup hooks # your home directory
xcodebuild-axi setup hooks --project # just this repository
xcodebuild-axi setup hooks --status # report, writing nothingCovers Claude Code, Codex, and OpenCode. Installs are idempotent and repair a stale path.
A skill — loads on demand instead of on every session, and works in any agent that reads the skill format:
npx skills add alexrrouse/xcodebuild-axi --skill xcodebuild-axiConventions
- Output is TOON on stdout, ~40% cheaper than the equivalent JSON.
- stdout is only the answer. Anything for watching a run — its log path
once it has gone 30 seconds, the transcript under
--live— goes to stderr, so stdout is the same whichever you ask for. - Errors are data. They go to stdout in the same shape as an answer, with a
codeand ahelp[]that names the command that fixes it. - Exit codes:
0success,1the build or tests failed,2usage error. A failed build still prints its full report — the exit code is for your&&, the report is for the agent. A test run in which nothing ran, or an--onlymatched no test, fails: xcodebuild calls both a success. - Unknown flags fail loudly, by name, with the nearest valid flag and the valid set listed inline. A silently dropped filter is worse than an error.
- A raw guess gets translated, not refused. Type what you would have typed
at
xcodebuildorsimctl—xcodebuild-axi -showsdks,xcodebuild-axi xcodebuild -scheme MyApp test,xcodebuild-axi simctl io booted screenshot— and the error names this tool's command for it. Where a switch really is not wrapped, the error prints the exact raw command to run instead, so an agent that falls back does so on purpose rather than because the first guess failed. - Nothing is written to your repository. Logs and result bundles live under
~/Library/Caches/xcodebuild-axi/, keyed by project path — one pair per run, so two runs of one scheme never read each other's. Each run clears out older finished runs of the same kind, keeping the one before it. - No interactive prompts, ever. Code signing is off by default so simulator
builds need no team; pass
--signwhen you mean it.
Environment
| Variable | Effect |
| ---------------- | ------------------------------------------------ |
| XCODEBUILD_BIN | Override the wrapped xcodebuild binary |
| DEVELOPER_DIR | Select an Xcode, as xcodebuild itself reads it |
Development
npm install
npm run dev -- destinations --scheme MyApp # run from source
npm testEverything CI checks, in order:
npm run format:check && npm run lint && npx tsc --noEmit && npm test
npm run build && npm run build:skill -- --check && npm run coverage:checkThree committed files are generated and fail CI when stale: the skill
(npm run build:skill, from the CLI's own help text), the coverage table
(npm run coverage, from src/surface.ts), and the benchmark table
(npm run benchmark -- --write). Regenerate rather than editing them.
Adding a flag means three edits — the command, src/surface.ts, and
npm run coverage — and the flags[N]: count in a help block is asserted by
test/help.test.ts, so a forgotten count fails the suite.
Examples in help text, tests, and this README use a fictional project
vocabulary (MyApp, MyApps, MyApps-1a2b3c4d); see AGENTS.md.
Built on
AXI — the design standard for agent-facing CLIs — via
axi-sdk-js.
License
MIT
