diff --git a/CHANGELOG.md b/CHANGELOG.md index 535d256..2238c17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,139 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [Unreleased] +### Added +- **`xcforge skill install`** writes the xcforge agent skill (embedded in the binary) into the project's `.claude/skills`, or with `--global` into `~/.claude/skills` (`$CLAUDE_CONFIG_DIR/skills` when set), or any `--dir`. Re-running refreshes a stale copy and drops files a newer xcforge no longer ships. `skill status` reports where it's installed and whether it's current, `skill show` prints a file, `skill uninstall` removes it +- **Shared-Mac options for every xcodebuild call:** `--derived-data-path`, `--result-bundle-path`, repeatable `--xcodebuild-arg`, `--lock`, `--lock-wait` and `--min-free-gb` on `build compile|run|clean`, `build-test` and `test run|failures|list|rerun-failed`, with matching MCP arguments, env vars and `.xcforge.yaml` keys (`derivedDataPath`, `buildLock`, `artifactDir`, `minFreeGB`). See `Skills/xcforge/references/shared-mac.md` +- **Build lock with a first-come, first-served queue.** Takes the same `flock` as macOS `lockf(1)`, so it queues next to existing `lockf` wrappers. `xcforge lock status` / `build_lock_status` show the holder, the queue and wait times +- `--isolated-sim` / `isolatedSimulator` on `build-test`/`build_and_test` and `test run`/`test_sim`: run on a fresh simulator created for the run and deleted afterwards +- Preflight warnings for low disk and heavy swap before a build +- **Idle timeout for builds and test runs.** xcodebuild is killed after 600s with no output (`--idle-timeout`, `idleTimeoutSeconds`, `XCFORGE_IDLE_TIMEOUT`, `.xcforge.yaml idleTimeout`; 0 disables), so a hung runner is caught without cutting off a slow cold build. Failures say whether the idle or total limit fired (`failureReason: timeout_idle|timeout_total`) +- `test-without-building` is retried once when the test runner never started ("Test runner hung before establishing connection", "Early unexpected exit…", "Failed to launch…" with no test begun) +- `test list --testplan` / `list_tests testplan`: lists via `-test-enumeration-format json`, which includes Swift Testing tests and reports how many the scheme or plan disables +- `test_plan_inspect` shows skipped tests, selected-only targets and every tag filter in the plan, and warns when a multi-tag filter may require all tags +- **UI automation on physical devices.** `xcforge wda start|status|stop` and MCP `wda_start`/`wda_stop` build xcforgeWDA for the device, sign it with your team (`--team`, `XCFORGE_WDA_TEAM`), launch it with `test-without-building`, find it over the CoreDevice tunnel, and record its URL in `~/.xcforge/wda/`. `XCFORGE_DEVICE=` points `xcforge ui` commands at it. Locked device, UI Automation off, Developer Mode off, pairing and signing failures each get a one-line explanation +- `device screenshot` / `device_screenshot` (devicectl capture, falling back to the device's WDA) +- `device launch --url --env KEY=VALUE --arg A` and matching `url`/`env` on `device_launch` +- **MCP progress notifications.** When a `tools/call` carries a `progressToken`, xcforge sends `notifications/progress` every 10s with the elapsed time and the latest output line, so clients that time out silent requests keep waiting for long builds and tests +- Each build and test tool result ends with `project: `, so a build of the wrong checkout or worktree is visible +- `.xcforge.yaml` problems (unknown keys with a "did you mean", unparseable values) are listed by `set_defaults show` / `defaults show` and once at the end of the first tool result that uses the file +- **Xcode 27 Device Hub support.** xcforge opens Device Hub when the selected Xcode has no Simulator.app, and screen capture, the accessibility bridge and window scripting accept either app +- **Test run options:** `retries`, `iterations`, `untilFailure`, `parallel`, `testTimeoutSeconds`, `skipBuild`, `includeConsole`, `env` and `timeoutSeconds` on `test_sim` and `build_and_test`, with matching flags (`--retries`, `--iterations`, `--until-failure`, `--parallel/--no-parallel`, `--test-timeout`, `--no-build`, `--include-console`, `--env`, `--timeout-seconds`) on `test run`, `build-test` and `test rerun-failed` +- Tests that fail and then pass on retry are listed as flaky (`flaky` in agent JSON) +- `test rerun-failed` reuses the recorded project, test plan, configuration and env, and skips build-for-testing when no source file changed since the last one (`--build` forces it) +- **Launches report a crash at startup.** `launch_app`, `build_run_sim`, `sim launch` and `build run` watch the app for 8s after launch (`XCFORGE_LAUNCH_WATCH_SECONDS`) and wait up to 15s for its crash report (`XCFORGE_CRASH_REPORT_WAIT_SECONDS`); when it dies, they fail with the exception, reason, crashed thread's top frames and the `.ips` report path +- Launch arguments, environment and a deep link: `args`, `env` (KEY=VALUE), `url` and `terminate` on `launch_app` and `build_run_sim`; `--arg`, `--env`, `--url` and `--no-terminate` on `sim launch` and `build run`; `--env` on `console launch` +- `open_url` / `sim openurl` open a URL or deep link on a simulator +- `clean` / `build clean` take `configuration` and `derivedData: true` / `--derived-data`, which deletes this project's DerivedData folder (and only that one) +- **Every UI tool takes `simulator`, and each simulator has its own WebDriverAgent** on its own port (8100 up, saved in `~/.xcforge/wda/simulator-ports.json`), so with two simulators booted the screenshot and the taps hit the same one. `XCFORGE_SIMULATOR` does the same for CLI `ui` commands +- **Actions can wait for the result:** `wait_for`, `until_gone` and `timeout` on taps, swipes, typing and `find_element`, instead of sleeps +- `find_element` reports how many elements matched, with each one's label and frame, and takes `index` +- `type_text` types into the focused field; `key` sends return, delete, tab and other named keys, `dismiss_keyboard` hides the keyboard, and `secure` keeps the text out of the result (`ui type --key`, `--dismiss-keyboard`, `--secure`) +- `alert_action` (accept/dismiss) on `wda_create_session` and `XCFORGE_ALERT_ACTION` let permission alerts be handled automatically; "not found" errors mention a visible alert +- `screenshot` takes `crop` (x,y,width,height in device points) and `max_dimension` (longest side in pixels, with how many points one pixel covers); `screenshot capture --crop`, `--max-dimension` +- Simulator setup tools: `sim_content_size`, `sim_locale`, `app_container`, `sim_push` and `sim_privacy` (grant/revoke/reset), and `sim content-size|locale|container|push|privacy`. Setters report the value they replaced + +- **`build typecheck ` / `build_typecheck`** compiles one target for the simulator (its own scheme when it has one, else `-target` in the project that defines it), reusing the workspace's DerivedData +- **`--from-snapshot` / `fromSnapshot`** on `build compile` and `build typecheck` builds a snapshot of the working tree (a git worktree per repo under `~/.xcforge/snapshots`, `XCFORGE_SNAPSHOT_DIR`), so edits other agents make mid-build don't break it. Errors name the real files +- **`lsp setup` / `lsp_setup`** writes `buildServer.json` with xcode-build-server and points its `build_root` at the DerivedData xcforge builds into, so SourceKit-LSP stops reporting "No such module" +- `--jobs` / `jobs` (also `XCFORGE_JOBS`, `.xcforge.yaml jobs`) passes `-jobs N` to compiling xcodebuild calls and `-j N` to SwiftPM +- `--all-errors` / `allErrors` builds in a separate diagnostic DerivedData slot (`diagnosticDerivedDataPath`, `XCFORGE_DIAGNOSTIC_DERIVED_DATA_PATH`; default a per-project folder beside Xcode's) and keeps going after errors, leaving the main cache alone +- **SwiftPM tools find the package:** `path`, then `.xcforge.yaml packagePath`, then the current folder, then the only `Package.swift` in the repo. `swift_package_build`/`swift_package_test` results list compiler errors with file:line, failing tests (XCTest and Swift Testing) and test counts, keeping only the end of the raw output when nothing parses. They take the build lock and the idle timeout + +- `test_sim` takes `rerunFailed`: the MCP form of `test rerun-failed`, reusing the last run's settings and skipping the build when nothing changed +- **`XCFORGE_TOOL_GROUPS` and `.xcforge.yaml toolGroups`** pick the tool groups the server lists at start (`+diagnose`, `-git`, `build,test,ui`, `all`), and `tool_groups` changes now send `notifications/tools/list_changed` +- Read-only tools carry `readOnlyHint`, so clients can run them without asking +- `xcforge --version` +- `plan decide` works from the CLI: `plan run` saves a suspended plan to `~/.xcforge/plan-sessions/` for an hour, and the time spent waiting for the decision doesn't count against the plan's timeout + +### Changed +- **Merged tools.** `tap` replaces `click_element`, `tap_by_id`, `tap_by`, `tap_coordinates`, `double_tap`, `long_press`, `ui_tap_pixel` and `indigo_tap`; `swipe` takes `hid` (was `indigo_swipe`), `get_source` takes `format: list` (was `list_elements`), `find_element` takes `all` (was `find_elements`), and `screenshot` takes `device` (was `device_screenshot`). The old names are no longer listed but still work for one release, with a note naming the replacement +- **The diagnose workflow tools are off by default** (`XCFORGE_TOOL_GROUPS=+diagnose` or `tool_groups` turns them on); calling one while off says how. The server lists 104 tools instead of 118 +- **Every argument is listed in camelCase** (`includeConsole`, `waitFor`, `elementId`); snake_case spellings are still accepted +- **Build and test tools return compact JSON by default over MCP** (`build_compile`, `build_sim`, `build_typecheck`, `test_sim`, `build_and_test`): `ok`, `summary`, errors with full paths, the result bundle. `for: "human"` gives the text report. Diagnose and plan JSON are no longer indented +- `defaults set` finds the project before saving and exits non-zero when nothing was saved; it used to print success without writing in a fresh process +- A saved default simulator that no longer exists is ignored with a warning instead of failing the build +- Last build and test records are keyed on the absolute, symlink-resolved project path (a relative `App.xcodeproj` in two worktrees no longer shares one record) and written under a lock +- CLI JSON errors go to stdout like JSON results +- **One test ID format.** Results, `list_tests`, last-failures and known-failures all use `Target/Suite/test()` (Swift Testing) or `Target/Class/testMethod` (XCTest), and filters accept it as printed. The target is added to short IDs from the scheme's or test plan's test targets instead of guessed, and the `test()()` spelling xcodebuild needs is written for you +- Failures carry every message with file and line, the argument or repetition it came from, and their attachments; identical messages are grouped, and text output is capped at 20 failures with a count of the rest +- A test failure is no longer reported as "test target build failed". When the build or runner fails before any test reports, agent JSON says why (`reason`) and `rerun-failed` refuses instead of running nothing +- Agent JSON `failed` counts every failure, not only the ones listed +- Agent JSON includes the result bundle (`xcresult`) and, for a timed-out run, which limit fired and how to raise it (`reason`), even when some failures were read +- CLI `test run` shows a failed test build as compile errors with file and line, not as failing tests +- `test failures` / `test_failures` never run tests: without `xcresultPath` they read the project's last test run, say so when there is none, and return the compile errors when that run's build-for-testing failed (instead of the previous run's failures) +- build-for-testing uses the test plan and, for a filter whose IDs all name a test target, builds only those targets +- `build compile`, build-for-testing, `test` and `test list` pass the same build flags, so switching between them doesn't rebuild everything +- The hang watchdog samples at 300s and 540s instead of 60s and 120s, and a snapshot is only reported for a timeout or `diagnose` +- `build compile` / `build_compile` skip the `-showBuildSettings` call and no longer need a booted simulator (they compile for a booted one if any, else the newest iPhone simulator) +- Simulator names resolve the same way everywhere: exact name, the booted match first, else the newest OS; two matches on the same OS ask for a UDID. Prefix matches (`iPhone 16` → `iPhone 16 Pro`) are gone +- A filter that matches no test lists suggestions from the last build instead of building again +- `build run` exits non-zero when the app wasn't launched +- `build_run_sim` boots the simulator once the build succeeds (not alongside it), waits for the boot to finish before installing, and installs the scheme's application target from `-showBuildSettings -json` instead of whichever product was listed last or a `.app` found anywhere in DerivedData +- **CLI log and console capture run in the background.** `log start` and `console launch` keep streaming to `~/.xcforge/capture/` after the command exits (`XCFORGE_CAPTURE_DIR` moves it), so `log read`, `log wait`, `console read` and the `stop` commands in later invocations see it. Before, the capture died with the `start` command +- `read_logs`, `read_app_console`, `log read` and `console read` return the newest 200 lines by default (`last: 0` for all) and say how many earlier lines were left out; over-long output keeps the end instead of the start +- `wait_for_log` no longer clears the buffer `read_logs` reads +- The `crashes` log topic matches error and fault lines; it used to match default-level (`Df`) lines, which is most of the log +- Auto-detect ignores the `project.xcworkspace` inside every `.xcodeproj`, picks the scheme named after the project (or the only non-test scheme) when there are several, and gives `xcodebuild -list` 60s instead of 15s +- `clean` cleans the configuration and simulator build folder xcforge builds into, and is limited by the idle timeout instead of a fixed 60s +- A new CLI process picks up the last build's bundle ID and app path, so `sim launch` and `sim install` work right after `build run` without passing them +- **Cancelling stops the build.** An MCP cancel, a client disconnect, Ctrl-C, SIGTERM or SIGHUP now stops the xcodebuild (or other command) xcforge started, together with every process it spawned. Timeouts kill the whole process tree too, so compiler and test-runner children no longer outlive the call and hold DerivedData ("database is locked" on the next run) +- Two MCP calls that would run xcodebuild on the same DerivedData folder (or the same project's default one) at once now run one after the other, first come first served +- An explicit `project` in another repo or worktree uses the `.xcforge.yaml` next to it, for both session defaults (scheme, simulator, test plan) and xcodebuild options (DerivedData, lock, idle timeout) +- `.xcforge.yaml` values may be quoted and may carry a trailing `# comment` +- **Unknown MCP arguments are an error** naming the closest real argument (`derived_data_path` → `derivedDataPath`) instead of being ignored +- **Auto-promotion is opt-in.** Repeated explicit values no longer silently become session defaults unless `.xcforge.yaml` sets `autoPromote: true` +- `--no-default-flags` / `defaultFlags: false` / `XCFORGE_DEFAULT_FLAGS=0` / `.xcforge.yaml defaultFlags: false` drop the flags xcforge adds to builds (`-skipMacroValidation`, `-parallelizeTargets`, `COMPILATION_CACHE_ENABLE_CACHING=YES`) +- `lldb_continue` / `debug continue` wait 30s by default and take `timeoutSeconds` / `--timeout` (was a fixed 10s); simulator app launch waits up to 60s (was 15s) +- `bless` no longer prints a suggested commit message +- Tool descriptions and the skill drop "preferred", "call this first" and marketing copy; pitfalls are stated as facts rather than rules +- UI tools no longer run simulator recovery (terminate, rebuild, redeploy) when WDA is on a physical device; they report `device_runner_not_reachable` and point at `wda start` +- xcforgeWDA's iOS deployment target is 15.0 (Xcode 27 no longer builds for 13) +- devicectl output is read from Xcode 27's `properties` dictionary as well as the older split keys; `device info` adds tunnel, pairing and Developer Mode state +- **Default total time limit for builds and tests is 1800s** (was 180s); `--long` / `long: true` raises it to 7200s. Hangs are caught by the idle timeout instead +- **Builds continue after the first error** (`-IDEBuildingContinueBuildingAfterErrors=YES`), so one run reports every error. Opt out with `--no-continue-after-errors` / `continueAfterErrors: false` +- Build errors are read from stdout as well as stderr (xcodebuild prints compiler diagnostics on stdout), duplicates are dropped, and up to 50 are shown +- A run where zero tests executed is a failure even without a filter, when xcforge could read the result summary +- `build-test` and `test rerun-failed` honour `.xcforge.yaml` `testPlan` and `configuration` like `test run` does +- Coverage lookups use `xccov --only-targets` when only a yes/no is needed, and fall back to per-target coverage instead of truncated JSON for very large reports +- **`simRecovery` defaults to `off` everywhere** (was `auto` for `build_and_test`, `build-test` and `bless`). `auto` now only reboots; erasing a simulator needs the new `erase` mode. Invalid values are an error instead of silently becoming `auto`. `build-test` gains `--sim-recovery` +- Result bundles and diagnostic snapshots get collision-free names (`xcf----`), so two sessions starting in the same second no longer delete each other's bundles. `XCFORGE_ARTIFACT_DIR` / `artifactDir` moves them out of `/tmp` +- `build diagnose`, `test failures` and `test coverage` read this project's last recorded result bundle instead of the newest bundle any session left in `/tmp`. `test failures` without `--xcresult-path` reuses that bundle instead of re-running the whole suite +- `list_elements` and `get_source` drop wrapper containers, hidden and off-screen elements, and add `value`, `disabled` and `selected` +- `get_source` and `list_elements` start WebDriverAgent when it isn't running, and fail clearly instead of reading the Simulator app's own accessibility tree +- Screenshots report the device's point size, not the Simulator window's, and the fallback writes a new file per capture instead of one shared `/tmp` file; CLI `screenshot capture` writes a new file unless `--output` is given + +### Fixed +- UI commands no longer boot a shut-down simulator to start WebDriverAgent (xcodebuild did this, leaving two simulators running and exhausting memory on small Macs). They fail with "Simulator is shutdown; boot it first" and name any simulator already booted +- `hid: true` taps and swipes no longer report "via IndigoHID" on Xcode 27, where the events are sent but never reach the app. HID is off there for now and WDA is used, with "HID is not yet supported on Xcode 27, used WDA" in the result (`XCFORGE_FORCE_HID=1` tries HID anyway). The HID screen-size table now knows the iPhone 16 Pro, iPhone 17 family and iPhone Air +- `wda start` reports a busy port on the device ("Unable to start web server") at once with a `--port` hint instead of timing out, prints its errors as JSON under `--json`, and builds into the configured DerivedData folder (`--derived-data-path`, `XCFORGE_DERIVED_DATA_PATH`, `.xcforge.yaml`) +- Starting or restarting the simulator WebDriverAgent stops any xcforgeWDA runner already on that simulator first, so two XCTest sessions can't run at once and kill the app under test. A restart now waits up to 60s before rebuilding +- `ui source --format list` prints the flat element listing (as `get_source format: list` does); unknown formats are rejected up front with the valid ones +- `ui tap` and other HID input no longer crash (SIGSEGV) on Xcode 27: SimulatorKit's `lookup:error:` returns a mach port, not an object, and is now called by its real signature. An unexpected signature is an error, not a crash +- Screenshots of a landscape app report landscape point sizes even though the simulator framebuffer stays portrait (orientation read from WDA when it is running) +- `build typecheck --target` works with a configured DerivedData folder: `-target` builds use SYMROOT/OBJROOT inside it instead of `-derivedDataPath`, which xcodebuild rejects. An xcodebuild usage error is reported as `xcodebuild_usage`, not a compile failure +- Coordinate taps and swipes use HID input on Xcode 27, which moved SimulatorKit to `Contents/SharedFrameworks`. When they fall back to WDA the result says why HID wasn't used +- Launches watch the app for 8s (was 2s) and wait up to 15s for a late crash report; an exit without a report says how long after launch it died and where reports land +- A relative `--project` path resolves against the working directory +- The first `wda start` gets 15 minutes to build xcforgeWDA and 3 minutes (`XCFORGE_WDA_START_SECONDS`) for the runner to answer, and a failure names the cause, such as an xcforgeWDA copy too old for this Xcode. A second call waits for a deploy in progress instead of failing +- `lock status` no longer lists queued xcforge processes as holders +- A timed-out test run doesn't say the build was skipped or count xcodebuild's own timeout entries as failed tests +- Watchdog snapshots that aren't reported are deleted, and xcforge's own result bundles, snapshots and logs older than two days are pruned from the artifact folder +- Landscape screenshots report landscape point sizes +- All `ui` commands take `--simulator`, and `set_orientation` takes `simulator` +- With `--json` (or piped output), errors are printed as a JSON envelope on stdout +- `bless` help no longer promises a commit message +- With Xcode 27, `device list` (and anything resolving a phone by name) no longer lists simulators, which Device Hub's `devicectl` now includes, and the state column shows the tunnel state (connected, disconnected, unavailable) instead of `devicectl`'s display hint +- A timed-out `build-for-testing` is reported as a build failure instead of continuing to `test-without-building` against stale products +- `test_plan_inspect` finds plans inside `App.xcodeproj/xcshareddata/xctestplans` and decodes Xcode's string-form `skippedTests` and plans with missing sections +- A hung build's fallback no longer runs `pkill -x xcodebuild`, which killed every session's build on the Mac. Hang snapshots sample the xcodebuild matched by this run's result bundle path instead of the newest xcodebuild on the system +- WDA port cleanup only kills WebDriverAgent runner processes, uses the port from `WDA_BASE_URL`, skips remote WDA hosts, and calls `lsof` at its real path (`/usr/sbin/lsof`) +- A WebDriverAgent session recreated after a hiccup no longer relaunches the app (`forceAppLaunch: false`) +- Restarting xcforgeWDA reruns its last build with `test-without-building` instead of opening the runner app (which never starts the server), so recovery no longer falls through to a full rebuild; the runner is no longer stopped after an hour +- The accessibility-tree fast path is used only with exactly one simulator booted and no device attached, and reads that simulator's windows, not Simulator's menus +- Coordinate taps and swipes (`indigo_tap`, `indigo_swipe`, plan steps) account for landscape and upside-down orientation +- `accessibility_check`, `localization_check` and `multi_device_check` put back the text size, language and appearance they found instead of resetting to large, the first locale and light + ## [1.6.1] - 2026-05-19 ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index ecf174b..b622ca0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,10 +14,12 @@ swift run xcforge build --help # CLI mode ``` **Test timeout defaults** (`build-test`, `test run`, `test_sim`, `build_and_test`): -- Default timeout: **180s**. Pass `--long` (CLI) or `long: true` (MCP) to use 1800s. +- Default total limit: **1800s** (`--long` / `long: true`: 7200s). Builds and test runs are also killed + after **600s with no output** (`--idle-timeout` / `idleTimeoutSeconds`, 0 disables). - Pass `--diagnose` / `diagnose: true` to capture a diagnostic snapshot even on success. - On timeout, the result includes a `/tmp/xcf-diag--.txt` path and a summary line. -- `build_sim` and `build_run_sim` share the same watchdog design; `build` (build-only) remains at 1800s. +- `build_sim` and `build_run_sim` share the same watchdog design. The watchdog samples at 300s and 540s and + only reports a snapshot for a timeout or `diagnose`. **Formatting** (CI enforces strict lint): ```bash @@ -37,7 +39,7 @@ bun run lint && bun run format # Biome lint/format **Dual-mode single binary**: no args → MCP server (stdio JSON-RPC); with args → CLI (ArgumentParser). Mode detection in `Sources/XCForgeCLI/Main.swift`. **Two targets** (see `Package.swift`): -- `XCForgeKit` — shared library containing all 102+ MCP tools +- `XCForgeKit` — shared library containing all 110+ MCP tools - `XCForgeCLI` — CLI layer wrapping the library with ArgumentParser commands ### Tool Provider System @@ -73,7 +75,7 @@ MCP tools use snake_case (`build_sim`, `ui_tap`). CLI commands mirror them as su 2. Register it in `ProviderRegistry` 3. Add a matching CLI command in `Sources/XCForgeCLI/Commands/` 4. Add tests in `Tests/XCForgeKitTests/` -5. Update skill reference in `.claude/skills/xcforge/references/` +5. Update skill reference in `Skills/xcforge/references/`, then run `python3 scripts/embed-skill.py` so the binary's copy (installed by `xcforge skill install`) matches ## Dependencies diff --git a/Package.swift b/Package.swift index cd52bce..253002e 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ let package = Package( name: "xcforge", platforms: [.macOS(.v13)], dependencies: [ - .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.11.0"), + .package(url: "https://github.com/modelcontextprotocol/swift-sdk.git", from: "0.12.0"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.5.0"), ], diff --git a/README.md b/README.md index 4bd6b63..809d043 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ An MCP server and CLI for iOS development — build, test, automate, and diagnose from any AI agent or terminal. -**109 MCP tools. 19 CLI command groups. Single native binary (~8 MB stripped, ~18 MB with debug symbols). Zero external runtime dependencies.** +**104 MCP tools. 19 CLI command groups. Single native binary (~8 MB stripped, ~18 MB with debug symbols). Zero external runtime dependencies.** --- @@ -191,12 +191,18 @@ File: `~/.config/zed/settings.json` (or `.zed/settings.json` per-project) ## Claude Code Skill -xcforge includes a Claude Code skill that loads the full tool reference into context when you're working on iOS tasks. Install it globally with: +xcforge ships an agent skill: the tool reference an agent loads when it works on iOS tasks. The binary carries it, so installing it needs nothing else: ```bash -npx skills justinthevoid/xcforge +xcforge skill install # this repo: .claude/skills/xcforge (commit it to share with the team) +xcforge skill install --global # every project: ~/.claude/skills/xcforge +xcforge skill install --dir # any other agent's skills directory +xcforge skill status # where it's installed and whether it matches this xcforge +xcforge skill show references/test-tools.md # print one file without installing ``` +Re-run `xcforge skill install` after upgrading xcforge to refresh the copy; only changed files are rewritten. It won't overwrite an `xcforge` skill directory it didn't create unless you pass `--force`. `xcforge skill uninstall` removes it. + Once installed, Claude will automatically load the right reference files when you use xcforge tools — exact parameters, return values, and usage patterns for each category (build, test, simulator, UI automation, logs, LLDB, diagnosis, and more). --- @@ -204,7 +210,7 @@ Once installed, Claude will automatically load the right reference files when yo ## Two Modes, Same Tools ```bash -xcforge # MCP server (stdio JSON-RPC, 109 tools) +xcforge # MCP server (stdio JSON-RPC, 104 tools) xcforge build --scheme MyApp # CLI mode (19 command groups) ``` @@ -219,7 +225,7 @@ Every tool available over MCP has a matching CLI command. Every CLI command supp | **Build** | 6 | `build_sim`, `build_compile` (fast compile-only), `build_run_sim`, `clean`, project/scheme discovery | | **Test** | 7 | `test_sim` with xcresult parsing, `test_failures`, `test_coverage`, `list_tests`, `test_plan_inspect`, agent output mode, known-failures gating | | **Simulator** | 18 | Full lifecycle + video recording, location simulation, dark mode toggle, status bar override, info | -| **Physical Devices** | 7 | Via `devicectl` — list, install, launch, screenshot, pair | +| **Physical Devices** | 10 | Via `devicectl` — list, install, launch (deep links, env), screenshot, WebDriverAgent on device | | **UI Automation** | 20 | WebDriverAgent + native AX bridge — find, tap (point or pixel), swipe, drag, type, alerts | | **Screenshots** | 3 | Framebuffer capture (0.3s), point-space coordinate alignment, optional grid overlay | | **Visual Regression** | 3 | Pixel-diff baselines, multi-device checks (Dark Mode, Landscape, iPad), `bless` workflow | @@ -281,11 +287,11 @@ Use alongside logs and screenshots for systematic root-cause analysis. ### Diagnosis Workflows -10 tools that chain together into structured diagnostic pipelines — start a session, build, launch, capture runtime signals, collect evidence (screenshots, logs, accessibility state), compare against previous runs, and verify fixes. Designed for agents to systematically debug issues across multiple iterations. +10 tools (off by default: turn them on with `XCFORGE_TOOL_GROUPS=+diagnose` or `tool_groups`) that chain together into structured diagnostic pipelines — start a session, build, launch, capture runtime signals, collect evidence (screenshots, logs, accessibility state), compare against previous runs, and verify fixes. Designed for agents to systematically debug issues across multiple iterations. ### Physical Device Support -7 tools wrapping Apple's `devicectl` for real devices — list connected devices, install/launch/terminate apps, take screenshots, and manage pairing. +Tools wrapping Apple's `devicectl` for real devices: list connected devices, install, launch (with deep links and environment variables) and terminate apps, and take screenshots. `xcforge wda start --device --team ` (MCP `wda_start`) builds, signs and starts WebDriverAgent on the device so the UI automation tools work there too; see [device-tools.md](Skills/xcforge/references/device-tools.md). --- @@ -334,7 +340,7 @@ There are several iOS-focused MCP servers worth knowing about: | Server | Stars | Scope | Build | Test | UI Automation | Screenshots | Visual Regression | Accessibility | Physical Devices | SPM | Git | Logs | | -------------------------------------------------------------------- | ----- | --------- | ------- | ---- | ------------- | ----------- | ----------------- | ------------- | ---------------- | --- | --- | ---- | -| **xcforge** | — | 109 tools | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| **xcforge** | — | 104 tools | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | | [XcodeBuildMCP](https://github.com/getsentry/XcodeBuildMCP) | ~5k | ~15 tools | Yes | Yes | Partial | No | No | No | No | No | No | No | | [ios-simulator-mcp](https://github.com/joshuayoes/ios-simulator-mcp) | ~1.8k | ~10 tools | No | No | Yes | Yes | No | No | No | No | No | No | | [xcode-mcp-server](https://github.com/r-huijts/xcode-mcp-server) | ~370 | ~8 tools | Partial | No | No | No | No | No | No | No | No | No | @@ -394,7 +400,7 @@ Then add `XCFORGE_WDA_DIR` to your MCP config: } ``` -xcforge builds and deploys WDA automatically on the first UI tool call (~30–60s on first run, instant thereafter). See the [full UI Automation Setup guide](https://xcforge.dev/docs/guides/ui-automation-setup) for alternative configurations, custom ports, and troubleshooting. +xcforge builds and deploys WDA automatically on the first UI tool call on a simulator (~30–60s on first run, instant thereafter). For a physical device run `xcforge wda start --device --team ` (MCP `wda_start`) first, with the device unlocked and Settings > Developer > Enable UI Automation on. See the [full UI Automation Setup guide](https://xcforge.dev/docs/guides/ui-automation-setup) for alternative configurations, custom ports, and troubleshooting. --- diff --git a/Skills/xcforge/SKILL.md b/Skills/xcforge/SKILL.md index 4c3bc62..71e0811 100644 --- a/Skills/xcforge/SKILL.md +++ b/Skills/xcforge/SKILL.md @@ -1,34 +1,23 @@ --- name: xcforge -description: Complete reference for xcforge — 109 MCP tools + 113 CLI commands for iOS development. Covers build, test, simulator, physical devices, SPM, UI automation, screenshots, logs, git, visual regression, accessibility, localization, session profiles, diagnosis workflows, plan execution, LLDB debugger integration, bless workflow, agent-optimized test output, and full CLI parity across 19 tool groups. Use when working with iOS simulators, physical devices, Xcode builds, Swift packages, UI testing, TDD workflows, debugging running apps with LLDB, iterating with visual poses, or any xcforge tool. +description: Complete reference for xcforge — 104 MCP tools (114 with the diagnose group) + 113 CLI commands for iOS development. Covers build, test, simulator, physical devices, SPM, UI automation, screenshots, logs, git, visual regression, accessibility, localization, session profiles, diagnosis workflows, plan execution, LLDB debugger integration, bless workflow, agent-optimized test output, and a CLI covering nearly every tool across 19 command groups. Use when working with iOS simulators, physical devices, Xcode builds, Swift packages, UI testing, TDD workflows, debugging running apps with LLDB, iterating with visual poses, or any xcforge tool. --- # xcforge — iOS Development MCP Server & CLI -xcforge is a native Swift MCP server and CLI for iOS development. 109 MCP tools, 113 CLI commands across 19 groups, zero runtime dependencies. It provides build, test, simulator management, physical device support via devicectl, Swift package workflows, UI automation via WebDriverAgent with native HID fallback, ultra-fast screenshots, clipboard access, video recording, location simulation, appearance control, status bar overrides, smart log filtering, visual regression, multi-device checks, accessibility/localization layout checks, session profiles, structured diagnosis workflows, multi-step plan execution, visual pose iteration, bless (baseline+test+diff in one call), agent-optimized test output with known-failures gating, and test plan inspection. Every MCP tool has a CLI equivalent. +xcforge is a native Swift MCP server and CLI for iOS development. 104 MCP tools (114 with the off-by-default diagnose group), 113 CLI commands across 19 groups, zero runtime dependencies. It provides build, test, simulator management, physical device support via devicectl, Swift package workflows, UI automation via WebDriverAgent with native HID fallback, ultra-fast screenshots, clipboard access, video recording, location simulation, appearance control, status bar overrides, smart log filtering, visual regression, multi-device checks, accessibility/localization layout checks, session profiles, structured diagnosis workflows, multi-step plan execution, visual pose iteration, bless (baseline+test+diff in one call), agent-optimized test output with known-failures gating, and test plan inspection. Nearly every MCP tool has a CLI equivalent; session profiles, clipboard, `multi_device_check` and `tool_groups` are MCP-only. -**Key advantages over alternatives:** -- Screenshots in 0.3s (44x faster) via CoreSimulator IOSurface API -- Native HID taps/swipes in <5ms (bypasses WDA when available) -- Structured xcresult parsing — pass/fail counts, failure file:line, failure screenshots -- One-call alert batching (`accept_all`) handles 3+ permission dialogs -- Topic-filtered logs — 90% fewer tokens, 8 topics with line counts -- Element-to-element drag & drop in 1 call -- Auto-scroll to off-screen elements (3-tier fallback) -- View hierarchy in ~20ms (750x faster) -- Physical device support — install, launch, terminate, list apps via devicectl -- Session profiles — save/switch named default sets for quick context switching +## References -## Reference Loading Guide - -**Load reference files when using or advising on any xcforge tool.** Each reference covers one tool category with exact parameters, return values, and usage patterns. +Each reference covers one tool category with exact parameters, return values, and examples. Open the one you need. | Reference | Load When | |-----------|-----------| | **[Build Tools](references/build-tools.md)** | Building, running, cleaning, discovering projects, listing schemes | | **[Test Tools](references/test-tools.md)** | Running tests, analyzing failures, checking code coverage, build diagnostics | +| **[Shared Mac](references/shared-mac.md)** | DerivedData/result-bundle paths, extra xcodebuild args, build lock queue, isolated simulators, simulator recovery modes | | **[Simulator Tools](references/simulator-tools.md)** | Managing simulators — boot, shutdown, install, launch, clone, erase, delete, orientation, video recording, location, appearance, status bar | -| **[Device Tools](references/device-tools.md)** | Physical iOS devices — list, info, install, uninstall, launch, terminate, list apps via devicectl | +| **[Device Tools](references/device-tools.md)** | Physical iOS devices — list, info, install, launch (deep links, env), screenshot, and WebDriverAgent on the device for UI automation | | **[SPM Tools](references/spm-tools.md)** | Swift packages — build, test, run, list dependencies, clean | | **[UI Automation](references/ui-automation.md)** | Finding/clicking elements, alerts, typing, gestures, drag & drop, view hierarchy, clipboard, native HID taps/swipes | | **[Screenshot & Visual](references/screenshot-visual.md)** | Taking screenshots, saving baselines, comparing visual regressions, multi-device checks, bless workflow | @@ -37,6 +26,7 @@ xcforge is a native Swift MCP server and CLI for iOS development. 109 MCP tools, | **[Git Tools](references/git-tools.md)** | Git status, diff, log, commit, branch operations | | **[Diagnosis Workflows](references/diagnosis-workflows.md)** | Running structured diagnosis: start, build, test, runtime, status, evidence, inspect, verify, compare, result | | **[Plan Execution](references/plan-execution.md)** | Multi-step UI automation plans: run_plan, run_plan_decide, step types, variable binding, verification, suspend/resume | +| **[Tool Surface](references/tool-surface.md)** | Tool groups (`XCFORGE_TOOL_GROUPS`), merged tools and their old names, argument casing, JSON results | | **[Auto-Detection & Defaults](references/auto-detection.md)** | Understanding parameter resolution, setting defaults, session profiles | | **[LLDB Debugger](references/lldb-debugger.md)** | Attach LLDB to running simulator processes — breakpoints, variable inspection, stack traces, step execution, arbitrary commands; 8 MCP tools + `xcforge debug` CLI | | **[Pose & Visual Iteration](references/pose.md)** | Build → install → launch + screenshot with named poses for visual design iteration. Supports app-side argument routing and pixel-coordinate tapping for layout work. | @@ -55,7 +45,7 @@ All tools that accept `project`, `scheme`, `simulator`, `bundle_id`, or `app_pat **Repo config beats persisted defaults** (committed `.xcforge.yaml` outranks the personal machine-global file — like git `local` > `global`). `.xcforge.yaml` also supports repo-only `configuration` and `testPlan` keys. -**Auto-promotion:** 3 consecutive calls with the same explicit value auto-promotes it to a session default. +**Auto-promotion** (opt-in with `.xcforge.yaml autoPromote: true`): 3 consecutive calls with the same explicit value save it as a session default. Most tools work with zero parameters for single-project repos with one booted simulator. @@ -68,7 +58,7 @@ build_run_sim() → builds + boots + installs + launches in parallel (~9s faste ### Test → Fix → Verify (MCP) ``` -build_and_test() → build + test in one call (preferred) +build_and_test() → build + test in one call build_and_test(filter: "testFoo") → build + run specific test (auto-resolves target) test_failures(include_console: true) → error messages + file:line + console # fix the code... @@ -84,7 +74,7 @@ test_sim(filter: "MyClass/testFoo") → run specific test (auto-resolv ### TDD Loop (CLI) ```bash xcforge build run → Cmd+R equivalent: build + boot + install + launch -xcforge build-test → build + test in one step (preferred for TDD) +xcforge build-test → build + test in one step xcforge build-test --filter "testFoo" → build + run specific test xcforge build-test --env BLESS_BASELINE=1 → inject TEST_RUNNER_BLESS_BASELINE=1 into test process xcforge build-test --for agent → slim ≤10-field JSON (agent-safe output) @@ -110,7 +100,7 @@ bless(baseline: "login-screen", tests: "UITests/LoginTests") build_run_sim() → app running in simulator handle_alert(action: "accept_all") → dismiss permission dialogs find_element(using: "accessibility id", value: "Save", scroll: true) -click_element(element_id: "...") +tap(elementId: "...") → or tap(id: "Save") in one call screenshot() → verify result ``` @@ -199,6 +189,8 @@ device_install(device: "iPhone", app_path: "/path/to/App.app") device_launch(device: "iPhone", bundle_id: "com.app.id") device_apps(device: "iPhone") → list installed apps device_terminate(device: "iPhone", identifier: "com.app.id") +screenshot(device: "iPhone") +wda_start(device: "iPhone", team: "ABCDE12345") → UI tools now drive the phone ``` ### Swift Package Workflow (MCP) @@ -223,7 +215,7 @@ xcforge spm clean → clean artifacts ``` lldb_attach(bundleId: "com.example.App") → { sessionId, pid, status: "stopped" } lldb_set_breakpoint(sessionId: "...", file: "Foo.swift", line: 42) -lldb_continue(sessionId: "...", mode: "continue") → runs until breakpoint; 10s timeout +lldb_continue(sessionId: "...", mode: "continue") → runs until breakpoint lldb_backtrace(sessionId: "...", threadIndex: 0) → structured frames lldb_inspect_variable(sessionId: "...", expression: "self.count") lldb_run_command(sessionId: "...", command: "thread list") @@ -246,11 +238,11 @@ clipboard_set(text: "test data") → write to pasteboard clipboard_get() → read pasteboard content ``` -## Common Mistakes +## Pitfalls 1. **Starting log capture after the event** — `start_log_capture` only captures from the moment it's called. Start capture BEFORE reproducing the issue. -2. **Not using `accept_all` after fresh install** — First app launch shows 2-3 permission dialogs. Call `handle_alert(action: "accept_all")` right after `launch_app` or `build_run_sim`. +2. **Permission dialogs after a fresh install** — the first launch can show several. `handle_alert(action: "accept_all")` clears them in one call. 3. **Manual swipe loops to find elements** — Use `find_element(scroll: true)` instead. It handles auto-scrolling with 3-tier fallback (scrollToVisible, calculated drag, iterative with stall detection). @@ -258,11 +250,9 @@ clipboard_get() → read pasteboard content 5. **Ignoring topic menu in `read_logs`** — The topic menu shows line counts. Use `include` to add specific topics rather than reading everything. -6. **Not setting defaults for repeated operations** — Call `set_defaults(project: "...", scheme: "...", simulator: "...")` at the start of a session to avoid repeating parameters. - -7. **Using `test_sim` then parsing output for failures** — Use `test_failures` for detailed failure info with file:line and screenshots. `test_sim` gives the summary; `test_failures` gives the details. +6. **Using `test_sim` then parsing output for failures** — Use `test_failures` for detailed failure info with file:line and screenshots. `test_sim` gives the summary; `test_failures` gives the details. -8. **Forgetting WDA session** — UI automation tools require WebDriverAgent running on the simulator. If `wda_status` fails, WDA needs to be started. `build_run_sim` does NOT start WDA automatically. +7. **WebDriverAgent** — UI automation tools start WDA on a simulator themselves on first use. On a physical device run `wda_start` (CLI `xcforge wda start`) first; the device must be unlocked with Settings > Developer > Enable UI Automation on. **Sub-pitfall: WDA can't see SwiftUI sheets / alerts / `fullScreenCover`.** Those mount in a secondary window owned by the app. Queries reach them only when the WDA session is bound to the app's bundle id. Run `xcforge ui session --bundle-id ` once — `WDAClient` persists the binding across recreates from then on, and the CLI verifies the binding via `GET /session/`, exiting non-zero on mismatch (no more silent no-binds). If `ui find` / `tap-by-id` 404s on an id you can see in `ui ls`, this is almost always why. @@ -270,8 +260,8 @@ clipboard_get() → read pasteboard content **Sub-pitfall: `xcforge pose --screenshot` capturing the launch zoom animation.** `pose` now waits 1.5s by default before capturing so the iOS launch zoom can settle. Override with `--screenshot-delay ` (use `0` for legacy immediate-capture). -9. **Not clearing status bar overrides** — `sim_statusbar` overrides persist until cleared. Always call `sim_statusbar_clear()` after capturing screenshots with overrides. +8. **Status bar overrides persist** — `sim_statusbar` overrides stay until `sim_statusbar_clear()`. -10. **Confusing simulator vs device tools** — Simulator tools (`sim`, `build_sim`, etc.) don't work with physical devices. Use `device_*` tools (`list_devices`, `device_install`, etc.) for physical iOS devices connected via USB/WiFi. +9. **Simulator vs device tools** — simulator tools (`sim`, `build_sim`, etc.) don't target physical devices. Use the `device_*` tools and `wda_start` for devices connected over USB or Wi-Fi. -11. **Using `build_sim` for Swift packages** — Swift packages don't have .xcodeproj files. Use `swift_package_build` / `swift_package_test` instead. +10. **Using `build_sim` for Swift packages** — Swift packages don't have .xcodeproj files. Use `swift_package_build` / `swift_package_test` instead. diff --git a/Skills/xcforge/references/auto-detection.md b/Skills/xcforge/references/auto-detection.md index de1f882..33039e5 100644 --- a/Skills/xcforge/references/auto-detection.md +++ b/Skills/xcforge/references/auto-detection.md @@ -5,7 +5,7 @@ All tools that accept `project`, `scheme`, or `simulator` resolve values in this order: 1. **Explicit parameter** — value passed directly in the tool call (highest priority) -2. **In-session value** — set this session via `set_defaults`/`profile_switch`, auto-promoted (3× rule, unless `autoPromote: false`), or already resolved earlier this session +2. **In-session value** — set this session via `set_defaults`/`profile_switch`, auto-promoted (3× rule, only with `autoPromote: true`), or already resolved earlier this session 3. **Repo config** — `.xcforge.yaml` at the repo root (committed team config; see below) 4. **Persisted defaults (per active project)** — the active project's record in `~/.xcforge/defaults.json` 5. **Auto-detect** — runtime detection (see below) @@ -49,8 +49,11 @@ file is warned and skipped (never crashes). | `simulator` | Default simulator name or UDID. | shared with defaults model | | `configuration` | Build configuration for `build_sim`/`test_sim`/`build_and_test`/`build_and_diagnose` when no `--configuration`/`configuration` arg is given. Default `Debug`. | **repo-only — never written to `defaults.json` or profiles** | | `testPlan` | Default `.xctestplan` for `test_sim`/`build_and_test` when no `--testplan`/`testplan` arg is given. | **repo-only — never written to `defaults.json` or profiles** | -| `testTimeout` | Per-project default test timeout, positive integer seconds. Precedence: explicit `timeoutSeconds` > `testTimeout` > `--long`/180s default. Non-positive values rejected with warning. | **repo-only** | -| `autoPromote` | `true` (default) keeps the 3-rep auto-promotion. `false` disables it (streak counter held at zero) so explicit values stay explicit across repeated iterative runs. | **repo-only** | +| `testTimeout` | Per-project default test timeout, positive integer seconds. Precedence: explicit `timeoutSeconds` > `testTimeout` > `--long` (7200s) > 1800s default. Non-positive values rejected with warning. | **repo-only** | +| `autoPromote` | `true` turns on 3-rep auto-promotion of explicit values to session defaults. Off (`false`) by default. | **repo-only** | +| `toolGroups` | Which MCP tool groups the server lists at start, e.g. `+diagnose` or `build,test,ui`. See [Tool surface](tool-surface.md). `XCFORGE_TOOL_GROUPS` wins over it. | **repo-only** | + +The shared-Mac keys (`derivedDataPath`, `buildLock`, `jobs`, ...) are listed in [Shared Mac](shared-mac.md); `packagePath` in [SPM Tools](spm-tools.md). `configuration`, `testPlan`, `testTimeout`, and `autoPromote` are repo-only by design and never flow into the machine-global persisted JSON or named profiles. @@ -77,12 +80,13 @@ xcforge init --force # overwrite an existing file - Scans working directory for `.xcodeproj` and `.xcworkspace` files - If exactly one found, uses it automatically - If multiple found, returns the list and asks for selection -- Prefers `.xcworkspace` over `.xcodeproj` when both exist (CocoaPods, SPM workspace) +- Prefers `.xcworkspace` over `.xcodeproj` when both exist (CocoaPods, SPM workspace). The `project.xcworkspace` inside every `.xcodeproj` doesn't count ### scheme - Queries `xcodebuild -list` for the resolved project - If exactly one scheme, uses it -- If multiple, returns the list +- If several, uses the one named after the project, else the only one that isn't a test or `Pods-` scheme +- Otherwise returns the list ### simulator - Finds currently booted simulator via `simctl list devices` @@ -100,11 +104,9 @@ xcforge init --force # overwrite an existing file ## Auto-Promotion -When the same explicit value is passed **3 consecutive times** for a parameter, xcforge auto-promotes it to a session default. This avoids the need to call `set_defaults` explicitly for repeated workflows. - -Example: calling `build_sim(scheme: "MyApp")` 3 times in a row auto-saves "MyApp" as the default scheme. +Off by default. With `autoPromote: true` in `.xcforge.yaml`, passing the same explicit value **3 consecutive times** for a parameter saves it as a session default, so later calls can omit it. -**Opt out:** set `autoPromote: false` in `.xcforge.yaml`. The streak counter then stays at zero, so explicit values never get promoted — useful for iterative test loops where you want each call to stand on its own. Auto-promotion is also no longer sticky after the fact: `set_defaults` resets the matching field's streak so an old explicit value can't ambush a later run after you've overridden it. +Example: with `autoPromote: true`, calling `build_sim(scheme: "MyApp")` 3 times in a row saves "MyApp" as the default scheme. `defaults show` notes when a value was auto-promoted. Auto-promotion is also no longer sticky after the fact: `set_defaults` resets the matching field's streak so an old explicit value can't ambush a later run after you've overridden it. ## set_defaults (MCP tool) @@ -191,6 +193,8 @@ xcforge defaults clear # Clear ONLY the active project's recor xcforge defaults clear --all # Wipe every project's record on this machine ``` +`set` finds the project first (as a build would) and exits non-zero without writing when there is none: pass `--project`. A saved simulator that no longer exists is ignored with a warning and auto-detection is used instead. + `clear` (no `--all`) auto-resolves the active project before clearing; if no project can be detected from cwd, it reports that and exits cleanly without touching disk. See [CLI Commands](cli-commands.md) for full details. diff --git a/Skills/xcforge/references/build-tools.md b/Skills/xcforge/references/build-tools.md index a5ba9f4..2cda6b1 100644 --- a/Skills/xcforge/references/build-tools.md +++ b/Skills/xcforge/references/build-tools.md @@ -1,4 +1,4 @@ -# Build Tools (6 tools) +# Build Tools (8 tools) > **Defaults note:** the `Default` column shows the *fallback*. `project`, > `scheme`, `simulator`, and `configuration` are resolved through the parameter @@ -8,7 +8,7 @@ ## build_compile -Fast compile-only build without install or launch (~5s vs ~20s for full pipeline). Reuses the standard build infrastructure, skips the simulator boot/install/launch chain. +Compile-only build: no boot, install or launch, and no extra build-settings lookup. Needs no booted simulator: without one configured or booted it compiles for the newest available iPhone simulator. | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| @@ -16,7 +16,11 @@ Fast compile-only build without install or launch (~5s vs ~20s for full pipeline | `scheme` | No | Auto-detect | Scheme name | | `simulator` | No | Auto-detect (booted) | Simulator name or UDID (for SDK selection) | | `configuration` | No | Debug | Build configuration (Debug/Release) | -| `long` | No | false | Use 1800s timeout instead of default 180s | +| `long` | No | false | Raise the total time limit from 1800s to 7200s | +| `fromSnapshot` | No | false | Build a snapshot of the working tree (git worktree under `~/.xcforge/snapshots`), unaffected by edits made during the build. Errors name the real files | +| `jobs` | No | — | `-jobs N` for compiling. Also `XCFORGE_JOBS` and yaml `jobs` | +| `for` | No | `agent` | `agent` returns compact JSON (`ok`, `summary`, `errors` with full paths, `warnings`, `xcresult`, ...); `human` returns the text report. Also on `build_sim` and `build_typecheck` | +| `allErrors` | No | false | Build in the diagnostic DerivedData slot (`diagnosticDerivedDataPath`) and report every error, leaving the main cache alone | **Returns:** Bundle ID, app path, build duration, warnings count. On failure: structured errors with file:line from xcresult. @@ -26,6 +30,33 @@ Fast compile-only build without install or launch (~5s vs ~20s for full pipeline --- +## build_typecheck + +Compile one target for the simulator: its own scheme when it has one (sharing the workspace's DerivedData), else `-project

-target ` in the project that defines it. Seconds instead of a full scheme build when checking one changed file. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `target` | Yes | — | Target or package product, e.g. `ShutterCoachShared` | +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | +| `simulator` | No | Booted, else newest iPhone | Simulator for SDK selection | +| `configuration` | No | Debug | Build configuration | +| `fromSnapshot` | No | false | As on `build_compile` | + +An unknown target fails with the schemes and targets that exist. + +--- + +## lsp_setup + +Run `xcode-build-server config` for the project and scheme, then set `build_root` in `buildServer.json` to the DerivedData folder xcforge builds into (`derivedDataPath` when configured, else the scheme's own). Needs `brew install xcode-build-server`. Build the scheme once afterwards so the index exists, then restart the language server. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | +| `scheme` | No | Auto-detect | Scheme to index | + +--- + ## build_sim Build for iOS Simulator. Returns structured errors from xcresult (not raw xcodebuild stderr). Caches bundle ID and app path for subsequent tools. @@ -45,7 +76,7 @@ Build for iOS Simulator. Returns structured errors from xcresult (not raw xcodeb ## build_run_sim -Build + boot + install + launch in one call. Runs a parallel 2-phase pipeline: build and boot happen simultaneously, then install and launch. ~9s faster than calling each tool sequentially. +Build, boot, install and launch in one call (Xcode's Cmd+R). The simulator boots once the build succeeds; the app installed is the scheme's application target (from `-showBuildSettings -json`), not an extension or framework. | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| @@ -53,21 +84,24 @@ Build + boot + install + launch in one call. Runs a parallel 2-phase pipeline: b | `scheme` | No | Auto-detect | Scheme name | | `simulator` | No | Auto-detect (booted) | Simulator name or UDID | | `configuration` | No | Debug | Build configuration | +| `args` | No | — | Launch arguments for the app | +| `env` | No | — | Environment for the app, `KEY=VALUE` strings | +| `url` | No | — | URL or deep link to open once the app is running | -**Returns:** Bundle ID, app path, simulator UDID, build duration. On failure: structured build errors. - -This is the **Cmd+R equivalent** — the single most common tool call for iOS development. +**Returns:** Bundle ID, app path, PID, timings. On failure: structured build errors, or, when the app dies within 8s of launch, `App running: false` with the exception, reason, top frames of the crashed thread and the crash report path. --- ## clean -Clean build artifacts (DerivedData for the project). +Clean the scheme's simulator build products for a configuration. `derivedData: true` also deletes this project's DerivedData folder, found from its `BUILD_ROOT` (never the whole DerivedData directory); that is the fix for "database is locked" or a stale index. | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| | `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | | `scheme` | No | Auto-detect | Scheme name | +| `configuration` | No | Debug | Build configuration | +| `derivedData` | No | false | Also delete this project's DerivedData folder | --- diff --git a/Skills/xcforge/references/cli-commands.md b/Skills/xcforge/references/cli-commands.md index 6ee64a6..ba17a6c 100644 --- a/Skills/xcforge/references/cli-commands.md +++ b/Skills/xcforge/references/cli-commands.md @@ -25,8 +25,11 @@ xcforge plan ... # CLI mode xcforge pose ... # CLI mode xcforge bless ... # CLI mode xcforge debug ... # CLI mode +xcforge --version # Version (the MCP server reports the same) ``` +With `--json` (or when stdout isn't a terminal), errors are a JSON object `{"error": ..., "code": ...}` on stdout, like results. + --- ## xcforge build @@ -52,9 +55,12 @@ xcforge build --json # Machine-readable JSON output | `--simulator ` | Simulator name or UDID. Auto-detected from booted simulator | | `--configuration ` | Build configuration (Debug/Release). Default: Debug | | `--diagnose` | Build-only with structured diagnostics (skips boot/install/launch) | +| `--arg ` | Launch argument for the app (repeatable) | +| `--env KEY=VALUE` | Environment variable for the app (repeatable) | +| `--url ` | URL or deep link to open once the app is running | | `--json` | Machine-readable JSON output | -**Pipeline behavior:** On build success, automatically boots the simulator (if not already booted), installs the app, and launches it. On build failure, stops immediately with build errors. Persists `bundleId` and `appPath` to `defaults.json` so subsequent `sim install` / `sim launch` calls auto-detect across process boundaries. +**Pipeline behavior:** On build success, boots the simulator (if not already booted) and waits for it, installs the scheme's application target, launches it, and watches it for 8s (`XCFORGE_LAUNCH_WATCH_SECONDS`). A crash at launch fails the command with the crash reason and frames. On build failure, stops immediately with build errors. Persists `bundleId` and `appPath` to `defaults.json` so subsequent `sim install` / `sim launch` calls auto-detect across process boundaries. **JSON output:** With `--json`, emits a `BuildRunResult` with `build`, `boot`, `install`, `launch` phase statuses plus `appPid` and `appRunning` fields. @@ -83,8 +89,10 @@ Fast compile-only build without simulator boot/install/launch. Reuses the standa xcforge build compile # Compile-only, auto-detect everything xcforge build compile --project MyApp.xcodeproj --scheme MyApp xcforge build compile --configuration Release -xcforge build compile --long # 1800s timeout instead of 180s +xcforge build compile --long # 7200s total limit instead of 1800s xcforge build compile --json +xcforge build compile --from-snapshot # Build a snapshot; edits made meanwhile don't matter +xcforge build compile --all-errors --jobs 4 # Every error, in the diagnostic DerivedData slot ``` | Flag | Description | @@ -93,11 +101,20 @@ xcforge build compile --json | `--scheme ` | Xcode scheme name. Auto-detected if omitted | | `--simulator ` | Simulator name or UDID (for SDK selection). Auto-detected if omitted | | `--configuration ` | Build configuration (Debug/Release). Default: Debug | -| `--long` | Use 1800s timeout instead of default 180s | +| `--long` | Raise the total time limit from 1800s to 7200s | | `--json` | Machine-readable JSON output | **Use case:** Rapid code iteration without deployment latency. Does not boot simulator or install app. +### build typecheck + +Compile one target (its scheme, else `-target`), reusing the workspace's DerivedData. A `-target` build writes into the configured `derivedDataPath` (or the scheme's DerivedData) through SYMROOT/OBJROOT, since xcodebuild rejects `-derivedDataPath` with `-target`. + +```bash +xcforge build typecheck ShutterCoachShared +xcforge build typecheck Widget --from-snapshot +``` + ### build clean Clean Xcode build artifacts for a project/scheme. @@ -105,6 +122,7 @@ Clean Xcode build artifacts for a project/scheme. ```bash xcforge build clean xcforge build clean --project MyApp.xcodeproj --scheme MyApp +xcforge build clean --derived-data # Also delete this project's DerivedData folder xcforge build clean --json ``` @@ -186,7 +204,7 @@ xcforge test rerun-failed --json | `--simulator ` | Auto-detected if omitted | | `--configuration ` | Build configuration. Default: Debug | | `--testplan ` | Test plan name | -| `--long` | Use 1800s timeout instead of 180s | +| `--long` | Raise the total time limit from 1800s to 7200s | | `--for ` | `human` (default) or `agent` | | `--gate` | Apply known-failures gate to the rerun result | | `--json` | Machine-readable JSON output | @@ -327,7 +345,9 @@ xcforge sim info --simulator "iPhone 16 Pro" # Get metrics for specific simu xcforge sim boot "iPhone 16 Pro" # Boot a simulator xcforge sim shutdown "iPhone 16 Pro" # Shutdown (or "all") xcforge sim install --app-path /path/to/App.app # Install app (auto-detects sim) -xcforge sim launch --bundle-id com.app.id # Launch app (auto-detects sim) +xcforge sim launch --bundle-id com.app.id # Launch app; fails with the crash if it dies within 2s +xcforge sim launch --arg -UITest --env API=stub --url myapp://screen # Args, env, deep link +xcforge sim openurl myapp://settings # Open a URL or deep link xcforge sim terminate --bundle-id com.app.id # Terminate app xcforge sim clone "iPhone 16 Pro" --name "Clone" # Clone simulator xcforge sim erase "iPhone 16 Pro" # Erase to factory state @@ -341,6 +361,11 @@ xcforge sim location-reset # Clear GPS override xcforge sim appearance --appearance dark # Set light/dark mode xcforge sim statusbar --time "9:41" --battery-level 100 # Override status bar xcforge sim statusbar-clear # Restore default status bar +xcforge sim content-size accessibility-large # Dynamic Type size (prints the old one) +xcforge sim locale fr_FR # Locale + language (relaunch the app to apply) +xcforge sim container --container data # App container path (last built app) +xcforge sim push '{"aps":{"alert":"Hi"}}' # Deliver a push notification +xcforge sim privacy grant photos # Grant a permission before the app asks ``` All subcommands support `--json`. `install`, `launch`, `terminate` auto-detect simulator and bundle ID from session state. @@ -386,7 +411,18 @@ xcforge spm list # Show dependency tree (JSON) xcforge spm clean # Clean build artifacts ``` -All subcommands support `--json`. `--path` defaults to current directory. +All subcommands support `--json`. Without `--path`, the package is `.xcforge.yaml packagePath`, else the current folder, else the only `Package.swift` in the repo. + +--- + +## xcforge lsp + +```bash +xcforge lsp setup # buildServer.json for the auto-detected project and scheme +xcforge lsp setup --scheme ShutterCoachDev +``` + +Runs `xcode-build-server config` and points `build_root` at the DerivedData xcforge builds into. --- @@ -395,7 +431,7 @@ All subcommands support `--json`. `--path` defaults to current directory. Stream, read, and wait on simulator logs. Four subcommands — `read` is the default. ```bash -xcforge log start # Start capture (smart mode, debug level) +xcforge log start # Start a background capture (smart mode, debug level) xcforge log start --mode app # App-only logs + crashes xcforge log start --mode verbose # Unfiltered system logs xcforge log start --process MyApp # Filter by process name @@ -403,13 +439,14 @@ xcforge log start --subsystem com.myapp # Filter by subsystem xcforge log read # Read with topic filtering (app + crashes) xcforge log read --include network # Add network topic xcforge log read --include lifecycle --last 50 # Last 50 lifecycle + app lines +xcforge log read --last 0 # Everything (default: newest 200 lines) xcforge log read --clear # Clear buffer after reading xcforge log stop # Stop capture xcforge log wait --pattern "error.*timeout" # Wait for regex pattern xcforge log wait --pattern "launched" --timeout 10 ``` -All subcommands support `--json`. +All subcommands support `--json`. The capture keeps running in the background after `log start` returns, writing to `~/.xcforge/capture/`, until `log stop`. --- @@ -421,13 +458,14 @@ Launch, read, and stop app console output capture (print/NSLog). Three subcomman xcforge console launch # Launch app with console capture xcforge console launch --bundle-id com.app.id # Explicit bundle ID xcforge console launch --args "--verbose" # Pass launch args to app +xcforge console launch --env API=stub # Environment for the app (repeatable) xcforge console read # Read stdout + stderr xcforge console read --stream stdout --last 20 # Last 20 stdout lines xcforge console read --clear # Clear buffer after reading xcforge console stop # Stop capture and terminate app ``` -All subcommands support `--json`. Auto-detects simulator and bundle ID from session state. +All subcommands support `--json`. Auto-detects simulator and bundle ID from session state. The console keeps streaming in the background after `console launch` returns, until `console stop`; reads return the newest 200 lines per stream by default. --- @@ -437,7 +475,9 @@ Capture simulator screenshots and manage visual baselines. Three subcommands — ```bash xcforge screenshot # Same as `xcforge screenshot capture` -xcforge screenshot capture # Capture to /tmp/xcforge-screenshot.png +xcforge screenshot capture # Capture to a new temp file (path printed) +xcforge screenshot capture --max-dimension 800 # Smaller image for a quick look +xcforge screenshot capture --crop 0,100,390,200 # One area, in device points xcforge screenshot capture --format jpeg --output /path/to/file.jpeg xcforge screenshot capture --grid # Include point-coordinate grid overlay xcforge screenshot baseline --name login-screen # Save as named baseline @@ -485,13 +525,14 @@ xcforge wait-ready --for a11y:x --json UI automation via WebDriverAgent. 17 subcommands — `status` is the default. +Every subcommand takes `--simulator `; each simulator has its own WDA, and the last one named is used when it's omitted. The first WDA start on a Mac builds xcforgeWDA (up to 15 minutes) and then waits up to 180s for the runner to answer (`XCFORGE_WDA_START_SECONDS`). + ```bash xcforge ui status # Check WDA health xcforge ui session # Create WDA session xcforge ui session --bundle-id com.app.id # Bind session to app — verifies CFBundleIdentifier -xcforge ui ls # Flat element list — auto picks WDA when sim is booted -xcforge ui ls --source wda # Force WDA (iOS app tree); use this if sheets aren't visible -xcforge ui ls --source axp # Force macOS Accessibility (Simulator.app chrome) +xcforge ui ls # Flat element list (WDA; started when needed) +xcforge ui ls --source axp # macOS Accessibility tree of the one booted simulator xcforge ui ls --scope home.drawer.root # Restrict to a11y-id and its descendants xcforge ui find --using "accessibility id" --value "Save" xcforge ui find --using "accessibility id" --value "Save" --scroll @@ -508,9 +549,13 @@ xcforge ui pinch --center-x 200 --center-y 400 --scale 2.0 xcforge ui drag --from-x 100 --from-y 200 --to-x 300 --to-y 400 xcforge ui type --text "hello world" xcforge ui type --text "hello" --element-id --clear-first +xcforge ui type --text "secret" --secure --key return # Redacted, then press return +xcforge ui type --dismiss-keyboard +XCFORGE_SIMULATOR="iPhone 16" xcforge ui tap --x 200 --y 400 # Drive a specific simulator xcforge ui get-text --element-id xcforge ui source # Full view hierarchy (JSON) xcforge ui source --format xml +xcforge ui source --format list # One line per element: id | label | type | frame xcforge ui alert --action accept_all # Handle all alerts xcforge ui alert --action dismiss --button-label "Cancel" ``` @@ -857,7 +902,7 @@ cat plan.json | xcforge plan run --stdin ### plan decide -Resume a suspended plan with a decision. +Resume a plan that `plan run` suspended, from a new process. Sessions are saved in `~/.xcforge/plan-sessions/` for an hour and used once. ```bash xcforge plan decide --session-id --decision accept @@ -916,7 +961,7 @@ See the [Pose & Visual Iteration](pose.md) reference for app-side routing patter ## xcforge bless -Save a visual baseline, run tests, compare visual output, and suggest a commit message — all in one call. +Save a visual baseline, run tests and compare visual output in one call. ```bash xcforge bless --baseline login-screen --tests "UITests/LoginTests" @@ -931,7 +976,7 @@ xcforge bless --baseline home-dark --tests "SnapshotTests/HomeTests" --project M | `--scheme ` | Auto-detected if omitted | | `--simulator ` | Auto-detected if omitted | -**Steps performed:** save baseline → run tests → compare visual → suggest `[bless] ` commit message. +**Steps performed:** save baseline → run tests → compare visual. **Exit code:** 0 when all tests pass and diff is within threshold, 1 otherwise. diff --git a/Skills/xcforge/references/device-tools.md b/Skills/xcforge/references/device-tools.md index 4852c42..03a3cb0 100644 --- a/Skills/xcforge/references/device-tools.md +++ b/Skills/xcforge/references/device-tools.md @@ -1,4 +1,4 @@ -# Device Tools (7 tools) +# Device Tools (10 tools) Physical iOS/iPadOS device management via `xcrun devicectl`. All tools require a connected device (USB or WiFi). @@ -64,6 +64,10 @@ Launch an app on a connected physical device. | `terminate_existing` | No | true | Terminate existing instance before launching | | `timeout` | No | 30 | Console timeout in seconds | | `arguments` | No | — | Array of arguments passed to the app | +| `url` | No | — | Deep link or universal link opened in the app at launch | +| `env` | No | — | Object of environment variables for the app | + +CLI: `xcforge device launch --device [--url myapp://x] [--env KEY=VALUE] [--arg A]`. **Returns:** Launch confirmation and optional console output (if `console: true`). @@ -91,3 +95,43 @@ List apps installed on a connected physical device. | `bundle_id` | No | — | Filter to a specific bundle ID | **Returns:** Array of apps with bundle ID, name, and version. + +--- + +## device_screenshot (deprecated: `screenshot` with `device`) + +Over MCP, call `screenshot(device: ...)`; `device_screenshot` still works for one release. Save a PNG of the device screen. Uses `devicectl device capture screenshot` (Xcode 26.6 and later), then the device's WebDriverAgent when `wda_start` has one running. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `device` | **Yes** | — | Device name or UDID | +| `path` | No | artifact dir | Where to write the PNG | + +CLI: `xcforge device screenshot --device [--output shot.png]`. + +--- + +## `wda_start` / `wda_stop` (UI automation on a real device) + +`wda_start` builds xcforgeWDA for the device, signs it with your team (`-allowProvisioningUpdates`), launches it with `xcodebuild test-without-building` in the background, and finds its URL (the CoreDevice tunnel address, else the URL WDA logs). The MCP session's UI tools (`find_element`, `tap`, `type_text`, ...) then target the device. A runner that already answers is reused. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `device` | **Yes** | — | Device name or UDID | +| `team` | No | `XCFORGE_WDA_TEAM` | Development team ID for signing | +| `bundle_id` | No | `com.xcforge.wda..runner` | Runner bundle id | +| `port` | No | 8100 | Port WDA listens on | + +CLI: + +```bash +xcforge wda start --device --team ABCDE12345 +export XCFORGE_DEVICE= # ui commands now use the device's runner +xcforge ui tap --label "Sign In" +xcforge wda status +xcforge wda stop --device +``` + +Before starting, the device must be unlocked, in Developer Mode, and have **Settings > Developer > Enable UI Automation** on. Failures name which of these (or signing, pairing, an untrusted certificate) is the problem. UI tools never run simulator recovery against a device: when its runner stops answering they say so and point at `wda start`. + +State lives in `~/.xcforge/wda/.json` (URL, runner pid, log path), so other sessions on the Mac reuse the same runner. The build goes through the shared build lock when one is configured. diff --git a/Skills/xcforge/references/diagnosis-workflows.md b/Skills/xcforge/references/diagnosis-workflows.md index edb1d24..860d6b8 100644 --- a/Skills/xcforge/references/diagnosis-workflows.md +++ b/Skills/xcforge/references/diagnosis-workflows.md @@ -1,5 +1,7 @@ # Diagnosis Workflow Tools (10 MCP tools) +**Off by default.** The diagnose group is not listed until you turn it on: `XCFORGE_TOOL_GROUPS=+diagnose`, `toolGroups: +diagnose` in `.xcforge.yaml`, or `tool_groups(enable: ["diagnose"])`. For most work `build_and_diagnose`, `test_sim` and `test_failures` cover the same ground. + These tools provide structured diagnosis workflows via MCP. They mirror the `xcforge diagnose` CLI commands but are callable from any MCP client. Each tool corresponds to a workflow phase. A diagnosis "run" tracks state across phases. diff --git a/Skills/xcforge/references/log-console.md b/Skills/xcforge/references/log-console.md index fccd01a..0c8e132 100644 --- a/Skills/xcforge/references/log-console.md +++ b/Skills/xcforge/references/log-console.md @@ -32,7 +32,9 @@ Start capturing OS logs from the simulator. start_log_capture(subsystem: "com.apple.SwiftUI") ``` -**Important:** Start capture BEFORE reproducing the issue. Logs are not retroactive. +Logs are not retroactive: only what happens after the capture starts is recorded. + +From the CLI, `xcforge log start` runs the capture in the background, writing to `~/.xcforge/capture/` (`XCFORGE_CAPTURE_DIR` moves it), until `xcforge log stop`; `log read` and `log wait` in later commands read it. --- @@ -51,7 +53,7 @@ Read buffered logs with topic filtering. Returns a topic menu showing available | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| | `include` | No | `["app", "crashes"]` | Array of topic names to include | -| `last` | No | — | Only return last N lines (applied after topic filtering) | +| `last` | No | 200 | Return the last N lines, after topic filtering; 0 returns all. The header says how many earlier lines were left out | | `clear` | No | false | Clear buffer after reading | **Response format:** @@ -68,7 +70,7 @@ Hint: include=["network"] to add SSL/TLS + background transfer logs | Topic | Always On | Matches | Use Case | |-------|-----------|---------|----------| | `app` | Yes | subsystem == bundleId OR process == appName | Your app's os_log, print(), NSLog() | -| `crashes` | Yes | Fault-level logs from any process | Crash detection | +| `crashes` | Yes | Error and fault lines from any process | Crash detection | | `network` | No | trustd, nsurlsessiond | SSL/TLS certificates, background transfers | | `lifecycle` | No | runningboardd, com.apple.runningboard.* | Jetsam, memory pressure, app kills | | `springboard` | No | SpringBoard process | Push notifications, app state changes | @@ -91,7 +93,7 @@ Wait for a specific log pattern with timeout. Eliminates sleep() hacks. | `simulator` | No | Auto-detect | Simulator (used if log capture not already running) | | `subsystem` | No | — | Filter by subsystem (used if log capture not already running) | -**Returns:** The matching log line, or timeout error. +**Returns:** The matching lines that arrived after the call started, or a timeout error. It doesn't clear what `read_logs` returns. **Use case:** Wait for "app launched" signal, wait for network request completion, wait for crash after specific action. @@ -121,7 +123,7 @@ Read buffered console output. | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| -| `last` | No | — | Only return last N lines per stream | +| `last` | No | 200 | Return the last N lines per stream; 0 returns all | | `clear` | No | false | Clear buffer after reading | | `stream` | No | `both` | Which stream: `stdout`, `stderr`, or `both` | diff --git a/Skills/xcforge/references/plan-execution.md b/Skills/xcforge/references/plan-execution.md index fef33eb..0d88b71 100644 --- a/Skills/xcforge/references/plan-execution.md +++ b/Skills/xcforge/references/plan-execution.md @@ -115,7 +115,7 @@ These steps pause execution and return a `session_id`. Call `run_plan_decide` to 3. `run_plan` returns `session_id`, `suspendQuestion`, and screenshot 4. Agent analyzes and calls `run_plan_decide(session_id, decision)` 5. Execution resumes from the next step -6. Sessions expire after 5 minutes +6. MCP sessions live in the server's memory for 5 minutes. CLI sessions (`xcforge plan run`) are saved to `~/.xcforge/plan-sessions/` (`XCFORGE_PLAN_SESSION_DIR`) so `xcforge plan decide` in a new process can resume them for an hour; time spent waiting for the decision doesn't count against the plan's timeout ## Example: Complete Login Flow diff --git a/Skills/xcforge/references/screenshot-visual.md b/Skills/xcforge/references/screenshot-visual.md index ed4ce9e..8693073 100644 --- a/Skills/xcforge/references/screenshot-visual.md +++ b/Skills/xcforge/references/screenshot-visual.md @@ -1,4 +1,4 @@ -# Screenshot & Visual Tools (5 tools) +# Screenshot & Visual Tools (7 tools) ## screenshot @@ -7,8 +7,17 @@ Take a simulator screenshot. 0.3s latency — 44x faster than alternatives. | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| | `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `device` | No | — | A physical device's name or UDID: captures it instead (was `device_screenshot`) | | `format` | No | jpeg | Image format: `png` or `jpeg` | | `grid` | No | false | Overlay a point-coordinate grid on the image | +| `crop` | No | — | `x,y,width,height` in device points (the coordinates taps use) | +| `maxDimension` | No | — | Shrink so the longer side is at most this many pixels | +| `waitFor` / `timeout` | No | — | Readiness signal to wait for before capturing | + +**Token budget.** A full-resolution iPhone screenshot costs several thousand tokens. Pass +`maxDimension: 800` for a look at the screen, and `crop` to see one area at full detail. The +result line gives the device's point size, the crop, the output pixels and, when scaled, how +many points one pixel covers, so coordinates read off the image map back to taps. **3-tier capture strategy:** 1. **Burst** — native CoreSimulator IOSurface framebuffer access (~10ms) @@ -17,7 +26,10 @@ Take a simulator screenshot. 0.3s latency — 44x faster than alternatives. **Grid overlay:** When `grid: true`, overlays a transparent grid with 50pt minor lines and 100pt labeled divisions. Displays both X and Y axis labels at 100pt intervals. Falls back to ungridded image with a warning if overlay allocation fails. -**Returns:** Inline base64 image + metadata (resolution, byte size, capture method). +**Returns:** Inline base64 image + metadata: device size in points (from the simulator's +screen, not the Simulator window), pixel size, byte size, capture method. The CLI +(`xcforge screenshot capture`, with `--crop` and `--max-dimension`) writes a new file per capture +unless `--output` is given. Use `jpeg` (default) for fastest transfer. Use `png` for pixel-perfect visual regression baselines. Use `grid: true` for coordinate verification during layout work or UI automation scripting. @@ -92,8 +104,38 @@ Codify the baseline-write → test → diff → commit cycle in one call. Saves 1. Saves a visual baseline via `save_visual_baseline` 2. Runs tests via `build_and_test` with the provided filter 3. Compares current screenshot against the saved baseline -4. Suggests a commit message: `[bless] ` -**Returns:** Pass/fail status, baseline path, test result summary, visual diff result, and suggested commit message. Returns failure if any test fails. +**Returns:** Pass/fail status, baseline path, test result summary, and visual diff result. Returns failure if any test fails. **CLI:** `xcforge bless --baseline --tests ` + +--- + +## accessibility_check + +Screenshot the current screen at several Dynamic Type sizes and compare each with the first, to +spot truncation and layout breaks. Restores the text size it found. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `sizes` | No | XS, L, XXXL, AccessibilityXXXL | Comma-separated content size categories, or `all` | +| `threshold` | No | 5.0 | Max diff % against the base size | +| `settleTime` | No | 1.5 | Seconds to wait after each size change | + +--- + +## localization_check + +Relaunch the app in several languages (including right-to-left ones) and compare each screenshot +with the first. Relaunches without the locale arguments afterwards. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `bundleId` | No | Last build | App to relaunch | +| `locales` | No | en, de, ja, ar, he | Comma-separated locales, or `all` for 10 | +| `threshold` | No | 10.0 | Max diff % against the base locale | +| `settleTime` | No | 3.0 | Seconds to wait after each relaunch | + +CLI: `xcforge accessibility dynamic-type` and `xcforge accessibility localization`. diff --git a/Skills/xcforge/references/shared-mac.md b/Skills/xcforge/references/shared-mac.md new file mode 100644 index 0000000..02c4668 --- /dev/null +++ b/Skills/xcforge/references/shared-mac.md @@ -0,0 +1,65 @@ +# Sharing one Mac between sessions + +Every xcodebuild call xcforge makes goes through one entry point, which applies the options below. None of them are on by default; with nothing set, xcforge behaves as a single-user tool. + +| Option | CLI flag | MCP argument | Env var | `.xcforge.yaml` | +| --- | --- | --- | --- | --- | +| DerivedData folder | `--derived-data-path` | `derivedDataPath` | `XCFORGE_DERIVED_DATA_PATH` | `derivedDataPath` | +| Exact result bundle | `--result-bundle-path` | `resultBundlePath` | | | +| Extra xcodebuild args | `--xcodebuild-arg` (repeatable) | `xcodebuildArgs` | | | +| Build lock file | `--lock` | `buildLock` | `XCFORGE_BUILD_LOCK` | `buildLock` | +| Max wait for the lock | `--lock-wait` | `lockWaitSeconds` | `XCFORGE_LOCK_WAIT` | | +| Refuse below free disk (GB) | `--min-free-gb` | `minFreeGB` | `XCFORGE_MIN_FREE_GB` | `minFreeGB` | +| Artifact folder | | | `XCFORGE_ARTIFACT_DIR` | `artifactDir` | +| Parallel compile jobs | `--jobs` | `jobs` | `XCFORGE_JOBS` | `jobs` | +| Report every error in a separate cache | `--all-errors` | `allErrors` | | | +| DerivedData for `--all-errors` | | | `XCFORGE_DIAGNOSTIC_DERIVED_DATA_PATH` | `diagnosticDerivedDataPath` | +| Swift package for `spm` tools | | | | `packagePath` | + +Precedence: flag/argument, then env var, then `.xcforge.yaml`. Relative yaml paths resolve against the yaml's folder. + +They apply to `build compile|run|clean`, `build-test`, `test run|failures|list|rerun-failed` and the MCP tools `build_sim`, `build_run_sim`, `build_compile`, `clean`, `test_sim`, `test_failures`, `test_coverage`, `build_and_diagnose`, `build_and_test`, `list_tests`, `bless`. + +On a 16 GB Mac, `jobs: 4` keeps a build from pushing other sessions into swap. `--all-errors` builds go to their own DerivedData so a "show me every error" run doesn't throw away the main incremental cache. `--from-snapshot` builds share one worktree per repo and take turns on its lock. The worktree stays registered in the repo (`git worktree list` shows it under `~/.xcforge/snapshots`) so the next snapshot build is incremental; remove it with `git worktree remove --force ` when you no longer need it. + +## Build lock + +The lock is a plain `flock` on the file, the same lock macOS `lockf(1)` takes, so xcforge queues correctly next to shell wrappers such as `lockf /tmp/ios.lock xcodebuild ...`. xcforge waiters are served first-come, first-served through tickets in `.queue/`. The lock is held only while xcodebuild compiles or runs tests; `-showBuildSettings`, `-list` and WebDriverAgent are not locked. + +The queue orders xcforge waiters only. `flock` itself is not fair, so a blocking `lockf` waiter can take the lock ahead of a queued xcforge process when the holder releases it. If most builds on the Mac go through `lockf` wrappers, a queued xcforge command can wait through several of them; run those builds through xcforge with the same `--lock` path so everyone shares the queue. + +```bash +xcforge lock status --lock /tmp/ios.lock # holder, queue, wait times +xcforge build-test --lock /tmp/ios.lock # wait in line, then build and test +``` + +MCP: `build_lock_status`. + +## Isolated simulator + +`--isolated-sim` (CLI, `build-test` and `test run`) or `isolatedSimulator: true` (MCP, `build_and_test` and `test_sim`) creates a fresh simulator of the same model and OS, boots it, runs, then deletes it. Costs one cold boot; avoids "failed to launch / No such process" when another session shuts down or reconfigures the shared simulator. + +## Simulator recovery + +`simRecovery` / `--sim-recovery` defaults to `off` everywhere. `auto` reboots a simulator that isn't Booted. `erase` also erases it when a reboot didn't help. Unknown values are an error. + +## Long calls, cancellation and parallel calls + +- MCP clients that send a `progressToken` get a progress notification every 10s with the latest output line. +- Cancelling a call (MCP cancel, client disconnect, Ctrl-C, SIGTERM) stops the xcodebuild xcforge started and every process it spawned. A CLI agent whose shell tool kills `xcforge` after its own timeout no longer leaves an orphaned build behind. +- Within one MCP server, xcodebuild calls on the same DerivedData folder (or the same project's default one) run one at a time, in arrival order. This is always on; `buildLock` adds the same guarantee across processes. + +## Which `.xcforge.yaml` applies + +The one nearest the project being built, walking up to its repo root. Passing `project` from another worktree uses that worktree's file. Values may be quoted and may end in a `# comment`. Unknown keys and bad values are listed in `defaults show` and noted once in the first tool result that uses the file. + +## What xcforge never does on a shared Mac + +- Kill an xcodebuild it didn't start. Hang diagnostics sample the process matched by this run's result bundle path. +- Kill a non-WebDriverAgent process holding the WDA port. +- Read another session's result bundle. `build diagnose`, `test failures` and `test coverage` read this project's last recorded run (`~/.xcforge/last-results/`); `test failures` no longer re-runs the suite when a recorded test bundle exists. +- Delete a result bundle it didn't just create. Generated names are unique per process. + +## Preflight + +Before a build xcforge warns on stderr when the DerivedData volume has under 5 GB free or swap is over 85% used. `minFreeGB` turns low disk into a refusal. diff --git a/Skills/xcforge/references/simulator-tools.md b/Skills/xcforge/references/simulator-tools.md index f33109c..06c5bea 100644 --- a/Skills/xcforge/references/simulator-tools.md +++ b/Skills/xcforge/references/simulator-tools.md @@ -49,12 +49,27 @@ Install a .app bundle on a simulator. ## launch_app -Launch an installed app. +Launch an installed app, then watch it for 8s (`XCFORGE_LAUNCH_WATCH_SECONDS`). When it dies, the call fails with the exception, reason, crashed thread's top frames and the `.ips` crash report path. | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| | `simulator` | No | Auto-detect (booted) | Simulator name or UDID | | `bundle_id` | No | Auto-detect (last build) | App bundle identifier | +| `args` | No | — | Launch arguments for the app | +| `env` | No | — | Environment for the app, `KEY=VALUE` strings | +| `url` | No | — | URL or deep link to open once the app is running | +| `terminate` | No | true | Terminate a running copy first | + +--- + +## open_url + +Open a URL or deep link on a simulator. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `url` | **Yes** | — | URL or deep link | +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | --- @@ -164,6 +179,78 @@ Set simulator appearance to light or dark mode. --- +## sim_content_size + +Read or set the Dynamic Type text size. Returns the size it replaced, so you can put it back. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `size` | No | — (read) | `extra-small` … `extra-extra-extra-large`, `accessibility-medium` … `accessibility-extra-extra-extra-large`, `increment`, `decrement` | + +CLI: `xcforge sim content-size [size]` + +--- + +## sim_locale + +Read or set the simulator's locale (`AppleLocale`) and preferred language (`AppleLanguages`). +Returns the previous values. Apps pick up a change on their next launch. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `locale` | No | — (read) | e.g. `fr_FR`, `ar_SA` | +| `language` | No | From the locale | e.g. `fr`, `pt-BR` | + +CLI: `xcforge sim locale [locale] --language ` + +--- + +## app_container + +Path of an installed app's container. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `bundle_id` | No | Last built app | App bundle ID | +| `container` | No | `data` | `app`, `data`, `groups`, or an app group identifier | + +CLI: `xcforge sim container --bundle-id --container data` + +--- + +## sim_push + +Deliver a push notification. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `bundle_id` | No | Last built app | App bundle ID | +| `payload` | **Yes** | — | APNs JSON (`{"aps":{"alert":"Hi"}}`) or plain alert text | + +CLI: `xcforge sim push ''` (or a path to a `.json`/`.apns` file) + +--- + +## sim_privacy + +Grant, revoke or reset a privacy permission so permission alerts don't block a flow. Changing a +permission may terminate the app. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `action` | **Yes** | — | `grant`, `revoke`, `reset` | +| `service` | **Yes** | — | `all`, `calendar`, `contacts`, `contacts-limited`, `location`, `location-always`, `photos`, `photos-add`, `media-library`, `microphone`, `motion`, `reminders`, `siri` | +| `bundle_id` | No | Last built app | App to change; `reset` without one resets every app | + +CLI: `xcforge sim privacy grant photos --bundle-id ` + +--- + ## sim_statusbar Override simulator status bar values for clean screenshots. @@ -201,6 +288,7 @@ Set device orientation via WebDriverAgent. | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| | `orientation` | **Yes** | — | One of: `PORTRAIT`, `LANDSCAPE`, `LANDSCAPE_LEFT`, `LANDSCAPE_RIGHT` | +| `simulator` | No | last one used | Simulator name or UDID; each simulator has its own WDA | **Requires:** WDA running on the simulator. @@ -230,4 +318,4 @@ Get simulator display metrics. } ``` -**Use case:** Determine screen resolution and pixel scale for coordinate conversion in UI automation (e.g., converting pixel coordinates to point coordinates for `tap_coordinates`). +**Use case:** Determine screen resolution and pixel scale for coordinate conversion in UI automation (e.g., converting pixel coordinates to point coordinates for `tap`). diff --git a/Skills/xcforge/references/spm-tools.md b/Skills/xcforge/references/spm-tools.md index a93f888..d23a336 100644 --- a/Skills/xcforge/references/spm-tools.md +++ b/Skills/xcforge/references/spm-tools.md @@ -1,6 +1,10 @@ # Swift Package Tools (5 tools) -Build, test, and manage Swift packages. All tools use `/usr/bin/swift` and require a `Package.swift` in the working directory. +Build, test, and manage Swift packages with `/usr/bin/swift`. + +**Finding the package:** `path` when given (must hold a `Package.swift`), else `.xcforge.yaml packagePath`, else the current folder when it has a `Package.swift`, else the only package in the repo (build output, `.build`, `node_modules` and `Pods` skipped). With several packages and none chosen, the error lists them. + +**Results:** build and test list compiler errors with file:line, failing XCTest and Swift Testing tests with their messages, and test counts. The raw output is kept only as its last 40 lines, and only when a failure parsed to nothing. Build and test take the build lock, `jobs` (`-j N`) and the 600s idle timeout like xcodebuild calls. ## swift_package_build @@ -8,7 +12,7 @@ Run `swift build` in a Swift package directory. | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| -| `path` | No | Current directory | Working directory containing Package.swift | +| `path` | No | Found as above | Folder containing Package.swift | | `configuration` | No | — | `debug` or `release` | **Timeout:** 600 seconds. @@ -21,7 +25,7 @@ Run `swift test` in a Swift package directory. | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| -| `path` | No | Current directory | Working directory containing Package.swift | +| `path` | No | Found as above | Folder containing Package.swift | | `filter` | No | — | Test filter passed as `--filter` (e.g., `"MyTests"` or `"MyTests/testFoo"`) | | `parallel` | No | — | Run tests in parallel with `--parallel` | @@ -35,7 +39,7 @@ Run `swift run` to execute a target in a Swift package. | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| -| `path` | No | Current directory | Working directory containing Package.swift | +| `path` | No | Found as above | Folder containing Package.swift | | `executable` | No | — | Executable target name (omit if package has a single executable) | | `arguments` | No | — | Array of arguments passed to the executable after `--` | @@ -49,7 +53,7 @@ List package dependencies as JSON using `swift package show-dependencies`. | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| -| `path` | No | Current directory | Working directory containing Package.swift | +| `path` | No | Found as above | Folder containing Package.swift | **Timeout:** 30 seconds. @@ -61,6 +65,6 @@ Clean Swift package build artifacts using `swift package clean`. | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| -| `path` | No | Current directory | Working directory containing Package.swift | +| `path` | No | Found as above | Folder containing Package.swift | **Timeout:** 30 seconds. diff --git a/Skills/xcforge/references/test-tools.md b/Skills/xcforge/references/test-tools.md index d287df1..006356b 100644 --- a/Skills/xcforge/references/test-tools.md +++ b/Skills/xcforge/references/test-tools.md @@ -9,9 +9,11 @@ All test tools parse `.xcresult` bundles for structured results — no raw xcode > See [auto-detection.md](auto-detection.md). > > **Timeout note:** `timeoutSeconds` precedence is explicit arg > `.xcforge.yaml -> testTimeout` > `--long`/`long: true` (1800s) > default 180s. Non-positive +> testTimeout` > `--long`/`long: true` (7200s) > default 1800s. Non-positive > values are rejected on both the explicit and the YAML path and fall through to -> the next layer with a warning. +> the next layer with a warning. Separately, a build or test run that prints +> nothing for `idleTimeoutSeconds` (default 600, 0 disables) is killed and +> reported with `timeout: idle`. ## test_sim @@ -26,22 +28,53 @@ Run tests and return structured xcresult summary. | `testplan` | No | — | Test plan name (if project uses test plans) | | `filter` | No | — | Test filter — accepts relaxed formats (see below) | | `coverage` | No | false | Enable code coverage collection | -| `for` | No | `human` | Output audience: `human` preserves full JSON; `agent` returns a slim ≤10-field projection | +| `for` | No | `agent` | `agent` returns slim JSON (≤10 fields); `human` returns the full text report | +| `rerunFailed` | No | false | Rerun only the last run's failures with its project, scheme, simulator, plan, configuration and env (unless given); skips the build when nothing changed. MCP form of `xcforge test rerun-failed` | | `gate` | No | false | Subtract IDs in `.xcforge/known-failures.yaml` when computing `succeeded`. Raw failure list unchanged | -**Filter auto-resolution:** The test target prefix is auto-resolved when omitted. All these formats work: -- `testMethodName` → auto-prefixes `TestTarget/testMethodName` -- `TestClass/testMethodName` → auto-prefixes `TestTarget/TestClass/testMethodName` -- `TestTarget/TestClass/testMethodName` → passed through as-is +**Test IDs:** one format everywhere: `Target/Suite/test()` for Swift Testing, +`Target/Class/testMethod` for XCTest. `list_tests`, failure lists, `.xcforge/last-failures.json` +and `known-failures.yaml` all use it, and `filter` accepts it as is. Shorter forms work too: +- `Suite/test()` or `Suite` → the target is added when the scheme or test plan has one test target + (with several, pass the target) +- `Target/Suite/test()` → passed through. xcforge writes the `test()()` spelling xcodebuild needs + for Swift Testing; pass the plain form **Filter typo suggestions:** If the filter doesn't match any test ID, xcforge runs a Levenshtein fuzzy match and surfaces "Did you mean: ...?" suggestions in the result. Use `list_tests` to discover available identifiers if unsure. +**Run options** (`test_sim` and `build_and_test`; CLI flags on `test run`, `build-test`, `test rerun-failed`): + +| Parameter | CLI | Description | +|-----------|-----|-------------| +| `timeoutSeconds` | `--timeout-seconds` | Total limit for the run | +| `env` | `--env KEY=VALUE` | Environment for the tests. Keys are prefixed with `TEST_RUNNER_` (Xcode strips it inside tests) | +| `skipBuild` | `--no-build` | Test the last build-for-testing without building. `rerun-failed` does this on its own when no file changed since that build; `--build` forces a build | +| `retries` | `--retries N` | Retry each failing test up to N times. A test that fails then passes is reported under `flaky` | +| `iterations` | `--iterations N` | Run each test N times | +| `untilFailure` | `--until-failure` | With `iterations`, stop at the first failure | +| `parallel` | `--parallel` / `--no-parallel` | Turn parallel testing on or off (default: the scheme's setting) | +| `testTimeoutSeconds` | `--test-timeout` | Per-test time allowance; a test running longer fails | +| `includeConsole` | `--include-console` | Add each failing test's last 40 console lines to its failure | + +`retries` can't be combined with `iterations` or `untilFailure`. + +**Test build scope:** build-for-testing uses the test plan, and a filter whose IDs all name a +test target builds only those targets. The build uses the same flags as `build_compile`, so +switching between them doesn't rebuild everything. + +**Failure output:** each failure lists every message with `file:line` and, for parameterized or +repeated tests, the argument or repetition it came from. Failures with the same message are grouped +("N tests, same message"). Text output shows 20 failures and says how many more there are; +`test_failures` lists them all. When the build or the test runner fails before any test reports, +the result says so (`reason` in agent JSON) and `rerun-failed` refuses instead of rerunning nothing. + **Returns:** - Total/passed/failed/skipped counts - Duration -- Per-failure: test name, error message, file:line +- Per-failure: full test ID, each message with file:line, attachments +- Flaky tests (failed, then passed on retry) - Failure screenshot paths (auto-exported from xcresult) - xcresult path (reusable with `test_failures` and `test_coverage`) - Device info (name, OS version) @@ -55,12 +88,10 @@ Get detailed failure information with optional console output per failed test. | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| | `xcresult_path` | No* | — | Path to .xcresult bundle | -| `project` | No* | Auto-detect | Alternative: re-derive from project | -| `scheme` | No* | Auto-detect | Used with project | -| `simulator` | No* | Auto-detect | Used with project | +| `project` | No* | Auto-detect | Read this project's last test run | | `include_console` | No | false | Include console output captured during each failed test | -*Provide either `xcresult_path` OR `project`/`scheme`/`simulator`. The xcresult_path from a previous `test_sim` call is preferred. +*Without `xcresult_path`, reads the project's last test run (`test_sim`, `build_and_test`, `xcforge test run`). It never runs tests; with no recorded run it says so. When that run's build-for-testing failed, it returns the compile errors with file and line instead of failures. **Returns per failure:** - Test class and method name @@ -108,7 +139,7 @@ Build and return structured errors/warnings from xcresult. Unlike `build_sim`, t ## build_and_test -Build then test in one call. Short-circuits on build failure with structured diagnostics. **Preferred over separate `build_and_diagnose` + `test_sim` calls.** +Build then test in one call. Stops on a build failure with structured diagnostics. Takes the same run options as `test_sim`. | Parameter | Required | Default | Description | |-----------|----------|---------|-------------| @@ -119,8 +150,7 @@ Build then test in one call. Short-circuits on build failure with structured dia | `testplan` | No | — | Test plan name | | `filter` | No | — | Test filter — accepts relaxed formats (auto-resolves target prefix) | | `coverage` | No | false | Enable code coverage collection | -| `env` | No | — | Array of `KEY=VALUE` strings. Each key is auto-prefixed with `TEST_RUNNER_` before xcodebuild runs (Xcode strips the prefix inside tests). `TEST_RUNNER_XCFORGE_REPO_ROOT` is always injected. | -| `for` | No | `human` | Output audience: `human` preserves full JSON; `agent` returns a slim ≤10-field projection | +| `for` | No | `agent` | `agent` returns slim JSON (≤10 fields); `human` returns the full text report | | `gate` | No | false | Subtract IDs in `.xcforge/known-failures.yaml` when computing `succeeded`. Raw failure list unchanged | **Behavior:** @@ -130,7 +160,7 @@ Build then test in one call. Short-circuits on build failure with structured dia **Returns:** Phase indicator (`build` or `test`), build elapsed time, build diagnostics (on failure), test execution result (on success). -**Failure persistence:** On a failing run, failure IDs are saved to `.xcforge/last-failures.json` (cleared on green). Use `xcforge test rerun-failed` to replay exactly those IDs. +**Failure persistence:** On a failing run, failure IDs and the run settings (project, test plan, configuration, env) are saved to `.xcforge/last-failures.json` (cleared on green). `xcforge test rerun-failed` (or `test_sim` with `rerunFailed: true`) replays exactly those IDs with the same settings, and skips the build when nothing changed. --- @@ -144,7 +174,7 @@ List available test identifiers for a scheme. Use to discover the correct filter | `scheme` | No | Auto-detect | Scheme name | | `simulator` | No | Auto-detect (booted) | Simulator name or UDID | -**Returns:** List of test identifiers in `Target/Class/method` format, grouped by target and class. Includes counts of targets, classes, and test methods. +**Returns:** List of test identifiers in `Target/Suite/test()` / `Target/Class/method` format, grouped by target and class. Includes counts of targets, classes, and test methods. **Note:** Requires a build-for-testing step (does not run tests). First call may take time to build. diff --git a/Skills/xcforge/references/tool-surface.md b/Skills/xcforge/references/tool-surface.md new file mode 100644 index 0000000..f626f5b --- /dev/null +++ b/Skills/xcforge/references/tool-surface.md @@ -0,0 +1,66 @@ +# Tool surface: groups, names, arguments and results + +How the MCP tool list looks to an agent, and how to make it smaller. + +## Tool groups + +Tools come in groups (`build`, `test`, `ui`, `sim`, `git`, `diagnose`, ...; `tool_groups` lists them). The `diagnose` group +starts **off**: its ten workflow tools mostly repeat `build_and_diagnose`, `test_sim` and +`test_failures`. Calling one while it is off says how to turn it on. + +Choose the starting set with `XCFORGE_TOOL_GROUPS` or the `.xcforge.yaml` key `toolGroups` +(the environment variable wins): + +| Value | Effect | +|-------|--------| +| `+diagnose` | Defaults plus the diagnose group | +| `-git,-visual` | Defaults without these groups | +| `build,test,ui` | Only these groups (and `session-state`, which can't be turned off) | +| `all` | Every group | + +At runtime, `tool_groups` lists groups and enables or disables them (`enable: ["diagnose"]`). +After a change xcforge sends `notifications/tools/list_changed`, so clients that follow it fetch the +new list. + +## Merged tools and deprecated names + +| Old name (works for one release) | Use instead | +|----------------------------------|-------------| +| `click_element` | `tap` with `elementId` | +| `tap_by_id` | `tap` with `id` | +| `tap_by` | `tap` with `using`, `value` | +| `tap_coordinates` | `tap` with `x`, `y` | +| `double_tap` | `tap` with `x`, `y`, `count: 2` | +| `long_press` | `tap` with `x`, `y`, `durationMs` | +| `ui_tap_pixel` | `tap` with `x`, `y`, `pixels: true` | +| `indigo_tap` | `tap` with `x`, `y`, `hid: true` | +| `indigo_swipe` | `swipe` with `hid: true` | +| `list_elements` | `get_source` with `format: list` | +| `find_elements` | `find_element` with `all: true` | +| `device_screenshot` | `screenshot` with `device` | + +Old names are not listed but still run, and their result ends with a note naming the replacement. +The log tools (`start_log_capture`/`read_logs`, the app console tools) stay separate: one reads the +system log, the other the app's own stdout and stderr. + +## Argument names + +Every argument is listed in camelCase (`includeConsole`, `derivedDataPath`, `waitFor`). The +snake_case spelling (`include_console`) is accepted too, so older prompts keep working. An argument +that matches nothing is rejected with the closest real name. + +## Results + +- Build and test tools (`build_compile`, `build_sim`, `build_typecheck`, `test_sim`, + `build_and_test`) return compact JSON by default over MCP: `ok`, a one-line `summary`, errors with + full paths, and where to look next. Pass `for: "human"` for the text report. The CLI keeps text + unless `--json` or `--for agent` is given. +- Diagnose and plan tools return compact JSON (no indentation). +- `isError` is true when the call didn't do what was asked: the build or tests failed, a check + failed its threshold, a wait timed out, or the arguments were wrong. +- Read-only tools (status, lists, logs, screenshots, `get_source`, `git_status`, ...) carry + `readOnlyHint`, so clients can run them without asking. + +## Version + +`xcforge --version` prints the version the MCP server also reports in its `initialize` response. diff --git a/Skills/xcforge/references/ui-automation.md b/Skills/xcforge/references/ui-automation.md index 3c77128..1087fb2 100644 --- a/Skills/xcforge/references/ui-automation.md +++ b/Skills/xcforge/references/ui-automation.md @@ -1,9 +1,29 @@ -# UI Automation Tools (20 tools) +# UI Automation Tools (13 tools) All UI tools communicate directly with WebDriverAgent via HTTP — no Appium, no Node.js, no Python. **Prerequisite:** WebDriverAgent must be running on the target simulator. Check with `wda_status`. +**Which screen.** Every UI tool takes `simulator` (name or UDID). Each simulator gets its own +WebDriverAgent on its own port (8100 up, saved in `~/.xcforge/wda/simulator-ports.json`), so with +two simulators booted the screenshot and the taps go to the same one. The last simulator named is +used when a call names none; until then calls go to the booted simulator. From the CLI, set +`XCFORGE_SIMULATOR=` (`XCFORGE_DEVICE` for a phone). + +**Waiting instead of sleeping.** Taps, swipes, typing and `handle_alert` take `waitFor` (an +accessibility id or label to appear), `untilGone` (one to disappear) and `timeout` (seconds, +default 10). The result says whether the wait held; a wait that times out marks the call as an error. + +**Shut-down simulators are never booted.** If the target simulator is shut down, UI commands fail with "Simulator is shutdown; boot it first" (and name any simulator that is already booted) instead of booting it to start WebDriverAgent. Boot it yourself, or target the booted one with `--simulator`. + +**Merged tools.** `tap` replaces eight tap tools, `swipe` takes `hid`, `get_source` takes +`format: list` (was `list_elements`), and `find_element` takes `all` (was `find_elements`). The old +names still work for one release and say what replaces them. + +**Permission alerts.** `alert_action: accept|dismiss` on `wda_create_session` (or +`XCFORGE_ALERT_ACTION`) has WDA answer system alerts by itself. `sim_privacy grant` grants a +permission before the app asks. A "not found" error says when an alert is in the way. + ## wda_status Check if WebDriverAgent is running and reachable. @@ -22,6 +42,10 @@ Create a new WDA session, optionally activating an app. |-----------|----------|---------|-------------| | `bundle_id` | No | — | App to activate (optional) | | `wda_url` | No | http://localhost:8100 | Custom WDA URL | +| `alert_action` | No | `none` | `accept` or `dismiss` system alerts automatically for this session | + +A session xcforge recreates by itself (after a WDA hiccup or restart) never relaunches the app +(`forceAppLaunch: false`), so screen state and launch arguments survive. **Bundle id binding (v1.4.1+).** When `bundle_id` is provided, `WDAClient` persists it as the active bundle id and threads it through every subsequent recreate @@ -79,23 +103,6 @@ additive (`encodeIfPresent`; old consumers ignore unknown keys). --- -## list_elements (CLI: `xcforge ui ls`) - -Flat one-line-per-element listing. Format: ` |

/xcforge.") + var dir: String? + + func skillsDirectory() throws -> (scope: String, path: String) { + if let dir { + let expanded = (dir as NSString).expandingTildeInPath + let absolute = URL(fileURLWithPath: expanded).standardizedFileURL.path + return ("custom", absolute) + } + let scope: SkillScope = global ? .global : .project + return (scope.rawValue, try SkillInstaller.skillsDirectory(scope: scope)) + } + + func validate() throws { + if global && dir != nil { + throw ValidationError("Pass either --global or --dir, not both.") + } + } +} + +struct SkillInstall: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "install", + abstract: "Write the xcforge skill into the project's or your Claude Code skills directory.", + discussion: """ + Without flags the skill goes in .claude/skills/xcforge at the git repo root, so everyone \ + who clones the repo gets it. --global writes ~/.claude/skills/xcforge (or \ + $CLAUDE_CONFIG_DIR/skills/xcforge) for every project on this Mac. Re-run after upgrading \ + xcforge to refresh it; files that did not change are left alone. + """ + ) + + @OptionGroup var location: SkillLocationOptions + + @Flag(help: "Replace an existing xcforge skill directory that this command did not write.") + var force = false + + @Flag(name: .customLong("dry-run"), help: "Show what would change without writing anything.") + var dryRun = false + + @Flag(help: "Emit the result as machine-readable JSON.") + var json = false + + mutating func run() async throws { + let (_, skillsDir) = try location.skillsDirectory() + let result: SkillInstallResult + do { + result = try SkillInstaller.install(skillsDirectory: skillsDir, force: force, dryRun: dryRun) + } catch { + try rethrowOrJSONError(error, json: shouldOutputJSON(flag: json)) + return + } + if shouldOutputJSON(flag: json) { + print(try WorkflowJSONRenderer.renderJSON(result)) + } else { + print(result.message) + for path in result.removed { print(" removed \(path)") } + if result.status == "installed" && !location.global && location.dir == nil { + print("Commit .claude/skills/xcforge so agents in other checkouts get it too.") + } + } + if !result.succeeded { throw ExitCode.failure } + } +} + +struct SkillStatusCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "status", + abstract: "Show where the xcforge skill is installed and whether it matches this xcforge version." + ) + + @OptionGroup var location: SkillLocationOptions + + @Flag(help: "Emit the result as machine-readable JSON.") + var json = false + + mutating func run() async throws { + var targets: [(scope: String, path: String)] = [] + if location.global || location.dir != nil { + targets.append(try location.skillsDirectory()) + } else { + // No flag: report both standard locations. + for scope in SkillScope.allCases { + if let path = try? SkillInstaller.skillsDirectory(scope: scope) { + targets.append((scope.rawValue, path)) + } + } + } + let statuses = targets.map { SkillInstaller.status($0.path, scope: $0.scope) } + if shouldOutputJSON(flag: json) { + print(try WorkflowJSONRenderer.renderJSON(statuses)) + return + } + for status in statuses { + print("\(status.scope): \(status.state.rawValue) \(status.path)") + if status.state == .stale || status.state == .unmanaged, !status.changedFiles.isEmpty { + print(" differs from this xcforge: \(status.changedFiles.joined(separator: ", "))") + } + } + if statuses.contains(where: { $0.state == .stale }) { + print("Run `xcforge skill install` (add --global for the user copy) to refresh.") + } + } +} + +struct SkillUninstall: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "uninstall", + abstract: "Remove the xcforge skill from the project's or your skills directory." + ) + + @OptionGroup var location: SkillLocationOptions + + @Flag(help: "Remove the directory even if this command did not install it.") + var force = false + + mutating func run() async throws { + let (_, skillsDir) = try location.skillsDirectory() + do { + if let removed = try SkillInstaller.uninstall(skillsDirectory: skillsDir, force: force) { + print("Removed \(removed)") + } else { + print("No xcforge skill in \(skillsDir).") + } + } catch let error as SkillInstallerError { + fputs("\(error.description)\n", stderr) + throw ExitCode.failure + } + } +} + +struct SkillShow: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "show", + abstract: "Print a skill file to stdout (SKILL.md by default), for agents without an installed skill." + ) + + @Argument(help: "File inside the skill, e.g. references/test-tools.md.") + var file = "SKILL.md" + + @Flag(help: "List the skill's files instead.") + var list = false + + mutating func run() async throws { + if list { + SkillBundle.files.forEach { print($0.path) } + return + } + guard let skillFile = SkillBundle.file(file) else { + let available = SkillBundle.files.map(\.path).joined(separator: "\n ") + fputs("No skill file \(file). Available:\n \(available)\n", stderr) + throw ExitCode.failure + } + print(skillFile.contents, terminator: "") + } +} diff --git a/Sources/XCForgeCLI/Commands/Test/TestCommand.swift b/Sources/XCForgeCLI/Commands/Test/TestCommand.swift index 45db7f9..98a82f1 100644 --- a/Sources/XCForgeCLI/Commands/Test/TestCommand.swift +++ b/Sources/XCForgeCLI/Commands/Test/TestCommand.swift @@ -70,21 +70,27 @@ struct TestRun: AsyncParsableCommand { @Option(help: "Test plan name.") var testplan: String? - @Option(help: "Test filter, e.g. 'MyTests/testFoo' or 'MyTests'.") + @Option(help: "Tests to run, comma-separated: 'Target/Suite/test()', 'Suite/test()' or 'Suite'.") var filter: String? @Flag(help: "Enable code coverage collection.") var coverage = false - @Flag(help: "Use 1800s timeout instead of the default 180s (for long integration suites).") + @Flag(help: "Raise the total time limit from 1800s to 7200s. Hangs are caught by --idle-timeout either way.") var long = false @Flag(help: "Capture a diagnostic snapshot even when the test run succeeds.") var diagnose = false - @Option(help: "Simulator recovery mode (auto|off). Default: off.") + @Option( + help: + "Simulator recovery: off (default), auto (reboot if not Booted), erase (also erase if a reboot didn't help)." + ) var simRecovery: String = "off" + @Flag(help: "Run on a fresh simulator of the same model and OS, deleted afterwards.") + var isolatedSim = false + @Flag(help: "Emit the result as machine-readable JSON.") var json = false @@ -100,12 +106,21 @@ struct TestRun: AsyncParsableCommand { ) var gate = false + @OptionGroup var testRun: TestRunOptionGroup + + @OptionGroup var xcodebuild: XcodebuildOptionGroup + mutating func run() async throws { + let command = self + try await xcodebuild.scoped { try await command.execute() } + } + + func execute() async throws { let useJSON = shouldOutputJSON(flag: json) || forMode == .agent let env = Environment.live let configuration = await env.session.resolveConfiguration(self.configuration) let resolvedTestplan = await env.session.resolveTestPlan(testplan) - let recoveryMode = SimRecoveryMode(rawValue: simRecovery) ?? .off + let recoveryMode = try SimRecoveryMode.parse(simRecovery) let execution = try await TestTools.executeTest( project: project, @@ -118,8 +133,14 @@ struct TestRun: AsyncParsableCommand { long: long, diagnose: diagnose, simRecovery: recoveryMode, + timeoutSeconds: testRun.timeout, + envEntries: testRun.env, gate: gate, forMode: forMode, + isolatedSimulator: isolatedSim, + skipBuild: testRun.build == false, + testOptions: testRun.testOptions, + includeConsole: testRun.includeConsole, env: env ) @@ -159,7 +180,14 @@ struct TestFailures: AsyncParsableCommand { @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var xcodebuild: XcodebuildOptionGroup + mutating func run() async throws { + let command = self + try await xcodebuild.scoped { try await command.execute() } + } + + func execute() async throws { let useJSON = shouldOutputJSON(flag: json) let result = try await TestTools.extractFailures( xcresultPath: xcresultPath, @@ -270,15 +298,26 @@ struct TestList: AsyncParsableCommand { @Option(help: "Simulator name or UDID. Auto-detected from booted simulator if omitted.") var simulator: String? + @Option(help: "List the tests this test plan runs. Default: .xcforge.yaml testPlan, else the scheme's.") + var testplan: String? + @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var xcodebuild: XcodebuildOptionGroup + mutating func run() async throws { + let command = self + try await xcodebuild.scoped { try await command.execute() } + } + + func execute() async throws { let useJSON = shouldOutputJSON(flag: json) let result = try await TestTools.executeListTests( project: project, scheme: scheme, - simulator: simulator + simulator: simulator, + testplan: testplan ) if useJSON { diff --git a/Sources/XCForgeCLI/Commands/Test/TestRenderer.swift b/Sources/XCForgeCLI/Commands/Test/TestRenderer.swift index 87759e0..1ebeaa8 100644 --- a/Sources/XCForgeCLI/Commands/Test/TestRenderer.swift +++ b/Sources/XCForgeCLI/Commands/Test/TestRenderer.swift @@ -5,6 +5,18 @@ enum TestRenderer { static func renderTest(_ execution: TestTools.TestExecution) -> String { var lines: [String] = [] + // A test build that failed is compile errors, not failing tests. + if execution.buildFailed { + lines.append("TEST BUILD FAILED in \(execution.elapsed)s, no test ran") + lines += TestFailureText.buildErrorLines(execution.failures, indent: "") + if let path = execution.hangDiagnosticPath { + lines.append("Diagnostic snapshot: \(path)") + } + lines.append("Scheme: \(execution.scheme)") + if !execution.xcresultPath.isEmpty { lines.append("xcresult: \(execution.xcresultPath)") } + return lines.joined(separator: "\n") + } + let icon = execution.succeeded ? "PASSED" : "FAILED" lines.append("Tests \(icon) in \(execution.elapsed)s") @@ -19,12 +31,9 @@ enum TestRenderer { } lines.append(statParts.joined(separator: ", ")) - // Inline failure summaries - for failure in execution.failures.prefix(20) { - lines.append("FAIL: \(failure.testName)") - if !failure.message.isEmpty { - lines.append(" \(failure.message)") - } + lines += TestFailureText.lines(execution.failures, indent: "") + if !execution.flakyTests.isEmpty { + lines.append("Flaky (failed, then passed on retry): \(execution.flakyTests.joined(separator: ", "))") } // Device info @@ -53,46 +62,28 @@ enum TestRenderer { if result.failures.isEmpty { return "No test failures found.\nxcresult: \(result.xcresultPath)" } + if result.buildFailed { + var lines = ["The last test run failed to build, so no test ran. Build errors:"] + lines += TestFailureText.buildErrorLines(result.failures, indent: "") + lines.append("xcresult: \(result.xcresultPath)") + return lines.joined(separator: "\n") + } var lines: [String] = [] lines.append("\(result.failures.count) test failure(s):") lines.append("") + lines += TestFailureText.lines(result.failures, indent: "") - for failure in result.failures { - lines.append("FAIL: \(failure.testName) [\(failure.testIdentifier)]") - if !failure.message.isEmpty { - lines.append(" " + failure.message.replacingOccurrences(of: "\n", with: "\n ")) - } - - // Matching screenshots - let matchingScreenshots = result.screenshots.filter { - $0.testName.contains(failure.testIdentifier) || failure.testIdentifier.contains($0.testName) - } - for screenshot in matchingScreenshots { - lines.append(" Screenshot: \(screenshot.path)") - } - - // Console output - let funcName = - failure.testIdentifier.split(separator: "/").last.map(String.init) ?? failure.testIdentifier - if let console = result.consoleByTest[funcName] - ?? result.consoleByTest[failure.testIdentifier] - { - lines.append(" Console:") - lines.append(" " + console.replacingOccurrences(of: "\n", with: "\n ")) - } - + // Attachments that couldn't be tied to one failure + let attached = Set(result.failures.flatMap { $0.attachments ?? [] }) + let loose = result.screenshots.map(\.path).filter { !attached.contains($0) } + if !loose.isEmpty { lines.append("") + lines.append("Other failure attachments (\(loose.count)):") + lines += loose.prefix(20).map { " \($0)" } } - // All screenshots at the end - if !result.screenshots.isEmpty { - lines.append("Failure screenshots (\(result.screenshots.count)):") - for att in result.screenshots { - lines.append(" \(att.path)") - } - } - + lines.append("") lines.append("xcresult: \(result.xcresultPath)") return lines.joined(separator: "\n") } @@ -120,12 +111,16 @@ enum TestRenderer { lines.append( "\(result.testCount) tests in \(result.targetCount) target(s), \(result.classCount) class(es)" ) + if let disabled = result.disabledTestCount, disabled > 0 { + lines.append("\(disabled) more disabled by the scheme or test plan") + } lines.append("") // Group by target/class var grouped: [String: [String: [String]]] = [:] for test in result.tests { - grouped[test.target, default: [:]][test.className, default: []].append(test.methodName) + let method = TestIDs.components(test.fullIdentifier).last ?? test.methodName + grouped[test.target, default: [:]][test.className, default: []].append(method) } for (target, classes) in grouped.sorted(by: { $0.key < $1.key }) { @@ -140,9 +135,9 @@ enum TestRenderer { lines.append("") lines.append("Filter examples:") - lines.append(" xcforge test --filter \"Target/Class/method\"") - lines.append(" xcforge test --filter \"Class/method\" (auto-resolves target)") - lines.append(" xcforge test --filter \"Class\" (all tests in class)") + lines.append(" xcforge test --filter \"Target/Suite/test()\"") + lines.append(" xcforge test --filter \"Suite/test()\" (the target is added)") + lines.append(" xcforge test --filter \"Suite\" (every test in the suite)") return lines.joined(separator: "\n") } diff --git a/Sources/XCForgeCLI/Commands/Test/TestRerunFailedCommand.swift b/Sources/XCForgeCLI/Commands/Test/TestRerunFailedCommand.swift index b733ca6..64d1ca1 100644 --- a/Sources/XCForgeCLI/Commands/Test/TestRerunFailedCommand.swift +++ b/Sources/XCForgeCLI/Commands/Test/TestRerunFailedCommand.swift @@ -24,7 +24,7 @@ struct TestRerunFailed: AsyncParsableCommand { @Option(help: "Test plan name.") var testplan: String? - @Flag(help: "Use 1800s timeout instead of the default 180s.") + @Flag(help: "Raise the total time limit from 1800s to 7200s.") var long = false @Flag(help: "Capture a diagnostic snapshot even when the test run succeeds.") @@ -42,43 +42,87 @@ struct TestRerunFailed: AsyncParsableCommand { @Flag(help: "Apply known-failures gate to the rerun result.") var gate = false + @OptionGroup var testRun: TestRunOptionGroup + + @OptionGroup var xcodebuild: XcodebuildOptionGroup + mutating func run() async throws { + let command = self + try await xcodebuild.scoped { try await command.execute() } + } + + func execute() async throws { + let env = Environment.live + let session = env.session + // Failures are recorded in the repo of the project that was tested. let cwd = FileManager.default.currentDirectoryPath - let repoRoot = RepoRoot.discover(from: cwd) ?? cwd + var repoRoot = RepoRoot.discover(from: cwd) ?? cwd + if let project, let resolved = try? await session.resolveProject(project) { + repoRoot = RepoRoot.discover(from: (resolved as NSString).deletingLastPathComponent) ?? repoRoot + } guard let payload = LastFailuresStore.read(at: repoRoot) else { fputs("no prior failures recorded — run `test run` first\n", stderr) throw ExitCode(2) } + if let reason = payload.infraFailure { + fputs( + "the last run failed before any test reported a result (\(reason)); there are no failures to rerun." + + " Fix that and run the tests again.\n", stderr) + throw ExitCode(2) + } if payload.failures.isEmpty { print("no failures to rerun") return } let useJSON = shouldOutputJSON(flag: json) || forMode == .agent - let configuration = self.configuration ?? "Debug" + let recorded = payload.run + let resolvedProject = project ?? recorded?.project + let configuration = await session.resolveConfiguration(self.configuration ?? recorded?.configuration) + let resolvedTestplan = await session.resolveTestPlan(testplan ?? recorded?.testPlan) + let envEntries = testRun.env.isEmpty ? (recorded?.env ?? []) : testRun.env + + // Without --build/--no-build, skip the build when nothing changed since the last one. + let skipBuild: Bool + if let build = testRun.build { + skipBuild = !build + } else { + skipBuild = await TestTools.lastTestBuildIsCurrent( + project: resolvedProject, scheme: scheme ?? payload.scheme, simulator: simulator ?? payload.simulator, + configuration: configuration, testplan: resolvedTestplan, testIDs: payload.failures, env: env) + } // Pass failure IDs as a pre-split list so IDs containing commas (e.g. // parameterized Swift Testing arguments) survive the trip to xcodebuild // intact — no comma-join + re-split round-trip. let execution = try await TestTools.executeTest( - project: project, + project: resolvedProject, scheme: scheme ?? payload.scheme, simulator: simulator ?? payload.simulator, configuration: configuration, - testplan: testplan, + testplan: resolvedTestplan, filter: nil, filterIDs: payload.failures, coverage: false, long: long, diagnose: diagnose, simRecovery: .off, + timeoutSeconds: testRun.timeout, + envEntries: envEntries, gate: gate, - forMode: forMode + forMode: forMode, + skipBuild: skipBuild, + testOptions: testRun.testOptions, + includeConsole: testRun.includeConsole, + env: env ) if useJSON { print(try WorkflowJSONRenderer.renderTestJSON(execution, forAgent: forMode == .agent)) } else { + if skipBuild && testRun.build == nil { + print("Build skipped: no source changed since the last build (pass --build to force one).") + } print(TestRenderer.renderTest(execution)) } diff --git a/Sources/XCForgeCLI/Commands/Test/TestRunOptionGroup.swift b/Sources/XCForgeCLI/Commands/Test/TestRunOptionGroup.swift new file mode 100644 index 0000000..0c38899 --- /dev/null +++ b/Sources/XCForgeCLI/Commands/Test/TestRunOptionGroup.swift @@ -0,0 +1,55 @@ +import ArgumentParser +import Foundation +import XCForgeKit + +/// Options shared by `test run`, `test rerun-failed` and `build-test`, so a rerun can be +/// invoked exactly like the run it repeats. +struct TestRunOptionGroup: ParsableArguments { + @Option( + help: + "Total time limit in seconds for each xcodebuild step. Takes precedence over --long. Default: 1800s (7200s with --long)." + ) + var timeoutSeconds: Int? + + @Option( + name: .long, parsing: .singleValue, + help: ArgumentHelp( + "Environment variable for the test runner (repeatable). Format: KEY=VALUE. The key is auto-prefixed with TEST_RUNNER_; Xcode strips the prefix inside the test process, so 'BLESS_BASELINE=1' surfaces as ProcessInfo.environment[\"BLESS_BASELINE\"]. TEST_RUNNER_XCFORGE_REPO_ROOT is always injected (override with --env XCFORGE_REPO_ROOT=...).", + valueName: "KEY=VALUE" + ) + ) + var env: [String] = [] + + @Flag( + inversion: .prefixedNo, + help: + "Build for testing first. --no-build tests the last build's products. Default: build (rerun-failed: skip when no source changed since the last build)." + ) + var build: Bool? + + @Option(help: "Rerun a failing test up to this many more times; tests that then pass are reported as flaky.") + var retries: Int? + + @Option(help: "Run every test this many times.") + var iterations: Int? + + @Flag(help: "Repeat the tests until one fails (capped by --iterations when given).") + var untilFailure = false + + @Flag(inversion: .prefixedNo, help: "Turn parallel testing on or off. Default: the scheme's or test plan's setting.") + var parallel: Bool? + + @Option(help: "Time allowance per test in seconds (XCTest; Swift Testing uses .timeLimit in code).") + var testTimeout: Int? + + @Flag(help: "Attach the last lines each failing test printed to its failure.") + var includeConsole = false + + var testOptions: TestTools.TestRunOptions { + TestTools.TestRunOptions( + retries: retries, iterations: iterations, untilFailure: untilFailure, parallel: parallel, + testTimeoutSeconds: testTimeout) + } + + var timeout: TimeInterval? { timeoutSeconds.map { TimeInterval($0) } } +} diff --git a/Sources/XCForgeCLI/Commands/UI/UICommand.swift b/Sources/XCForgeCLI/Commands/UI/UICommand.swift index 5df86c4..ac9b348 100644 --- a/Sources/XCForgeCLI/Commands/UI/UICommand.swift +++ b/Sources/XCForgeCLI/Commands/UI/UICommand.swift @@ -87,7 +87,10 @@ struct UIStatus: AsyncParsableCommand { @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let env = Environment.live let healthy = await env.wdaClient.isHealthy() @@ -193,7 +196,10 @@ struct UISession: AsyncParsableCommand { } } + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let env = Environment.live if let url = wdaUrl { @@ -281,7 +287,10 @@ struct UIFind: AsyncParsableCommand { @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let env = Environment.live do { @@ -335,7 +344,10 @@ struct UIFindAll: AsyncParsableCommand { @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let env = Environment.live do { @@ -381,7 +393,10 @@ struct UIClick: AsyncParsableCommand { @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let env = Environment.live do { @@ -430,7 +445,10 @@ struct UITap: AsyncParsableCommand { @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let env = Environment.live do { @@ -476,7 +494,10 @@ struct UIDoubleTap: AsyncParsableCommand { @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let env = Environment.live do { @@ -523,7 +544,10 @@ struct UILongPress: AsyncParsableCommand { @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let env = Environment.live do { @@ -576,7 +600,10 @@ struct UISwipe: AsyncParsableCommand { @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let env = Environment.live do { @@ -627,7 +654,10 @@ struct UIPinch: AsyncParsableCommand { @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let env = Environment.live do { @@ -690,7 +720,10 @@ struct UIDrag: AsyncParsableCommand { @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let hasSource = sourceElement != nil || (fromX != nil && fromY != nil) let hasTarget = targetElement != nil || (toX != nil && toY != nil) @@ -745,56 +778,46 @@ struct UIDrag: AsyncParsableCommand { struct UIType: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "type", - abstract: "Type text into the currently focused element or a specified element." + abstract: "Type into the focused field or an element, press a key, or dismiss the keyboard." ) @Option(help: "Text to type.") - var text: String + var text: String? - @Option(help: "Optional element ID to type into.") + @Option(help: "Element ID to type into. Default: the field with keyboard focus.") var elementId: String? @Flag(help: "Clear existing text first.") var clearFirst = false + @Option(help: "Key to press after the text: return, delete, tab or escape.") + var key: String? + + @Flag(help: "Dismiss the keyboard afterwards.") + var dismissKeyboard = false + + @Flag(help: "Don't echo the text, even outside a secure field.") + var secure = false + @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) - let env = Environment.live - do { - if let eid = elementId { - if clearFirst { - try await env.wdaClient.clearElement(elementId: eid) - } - try await env.wdaClient.setValue(elementId: eid, text: text) - } else { - _ = try await env.wdaClient.ensureSession() - let (eid, _) = try await env.wdaClient.findElement( - using: "class name", value: "XCUIElementTypeTextField") - if clearFirst { - try await env.wdaClient.clearElement(elementId: eid) - } - try await env.wdaClient.setValue(elementId: eid, text: text) - } - let message = "Typed '\(text)'" - if useJSON { - print( - try WorkflowJSONRenderer.renderJSON( - UIResult(succeeded: true, message: message, elementId: elementId, elementCount: nil))) - } else { - print(message) - } - } catch { - let message = "Type failed: \(error)" - if useJSON { - print( - try WorkflowJSONRenderer.renderJSON( - UIResult(succeeded: false, message: message, elementId: nil, elementCount: nil))) - } else { - print(message) - } + let (succeeded, message) = await xcforgeTypeText( + text: text, elementId: elementId, clearFirst: clearFirst, key: key, dismissKeyboard: dismissKeyboard, + secure: secure, env: Environment.live) + if useJSON { + print( + try WorkflowJSONRenderer.renderJSON( + UIResult(succeeded: succeeded, message: message, elementId: elementId, elementCount: nil))) + } else { + print(message) + } + if !succeeded { throw ExitCode.failure } } @@ -814,7 +837,10 @@ struct UIGetText: AsyncParsableCommand { @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let env = Environment.live do { @@ -849,17 +875,36 @@ struct UISource: AsyncParsableCommand { abstract: "Get the full view hierarchy (source tree) of the current screen." ) - @Option(help: "Format: json, xml, or description. Default: json.") + @Option(help: "Format: json, xml, description, or list (one line per element). Default: json.") var format: String = "json" @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + + func validate() throws { + if let problem = WDAClient.sourceFormatProblem(format) { throw ValidationError(problem) } + } + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let env = Environment.live do { let start = CFAbsoluteTimeGetCurrent() + if format.lowercased() == "list" { + let listing = try await UIElementListing.render(env: env) + let message = "Elements (\(listing.source), \(listing.count)):\n\(listing.body)" + if useJSON { + print( + try WorkflowJSONRenderer.renderJSON( + UIResult(succeeded: true, message: message, elementId: nil, elementCount: listing.count))) + } else { + print(message) + } + return + } let source = try await env.wdaClient.getSource(format: format) let elapsed = String(format: "%.1f", CFAbsoluteTimeGetCurrent() - start) let truncated = @@ -904,7 +949,10 @@ struct UIAlert: AsyncParsableCommand { @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let env = Environment.live var message: String @@ -1019,7 +1067,10 @@ struct UILs: AsyncParsableCommand { @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let env = Environment.live do { @@ -1062,7 +1113,10 @@ struct UITapByID: AsyncParsableCommand { @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let env = Environment.live do { @@ -1120,7 +1174,10 @@ struct UITapBy: AsyncParsableCommand { @Flag(help: "Emit the result as machine-readable JSON.") var json = false + @OptionGroup var target: UISimulatorTarget + mutating func run() async throws { + await target.select(env: Environment.live) let useJSON = shouldOutputJSON(flag: json) let env = Environment.live do { @@ -1214,3 +1271,17 @@ struct UITapPixel: AsyncParsableCommand { } } } + +/// `--simulator` on UI commands: which simulator's WebDriverAgent to use. Without it the CLI +/// uses `XCFORGE_SIMULATOR`, else the booted simulator. +struct UISimulatorTarget: ParsableArguments { + @Option(help: "Simulator name or UDID whose WebDriverAgent to use. Default: XCFORGE_SIMULATOR, else booted.") + var simulator: String? + + func select(env: Environment) async { + guard let simulator, simulator != "booted", + let udid = try? await SimTools.resolveSimulator(simulator, env: env) + else { return } + await env.wdaClient.selectSimulator(udid: udid) + } +} diff --git a/Sources/XCForgeCLI/Commands/WDA/WDACommand.swift b/Sources/XCForgeCLI/Commands/WDA/WDACommand.swift new file mode 100644 index 0000000..d56bc14 --- /dev/null +++ b/Sources/XCForgeCLI/Commands/WDA/WDACommand.swift @@ -0,0 +1,132 @@ +import ArgumentParser +import Foundation +import XCForgeKit + +struct WDA: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "wda", + abstract: "Run WebDriverAgent on a physical device so `xcforge ui` commands can drive it.", + discussion: """ + `wda start` builds and signs the runner for the device, launches it, and records its URL. + Then point UI commands at the device with XCFORGE_DEVICE= (or WDA_BASE_URL=). + The device must be unlocked, in Developer Mode, with Settings > Developer > Enable UI + Automation turned on. + """, + subcommands: [WDAStart.self, WDAStatus.self, WDAStop.self], + defaultSubcommand: WDAStatus.self + ) +} + +struct WDAStart: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Build, sign and start WebDriverAgent on a device (reuses a runner that already answers)." + ) + + @Option(help: "Device name or UDID.") + var device: String + + @Option(help: "Apple development team ID for signing. Default: XCFORGE_WDA_TEAM.") + var team: String? + + @Option(help: "Runner bundle id. Default: com.xcforge.wda..runner.") + var bundleId: String? + + @Option(help: "Port WDA listens on.") + var port: Int = DeviceWDA.defaultPort + + @Option(help: "Seconds to wait for the runner to answer after launch.") + var startTimeout: Int = 120 + + @Flag(help: "Emit the result as machine-readable JSON.") + var json = false + + @OptionGroup var xcodebuild: XcodebuildOptionGroup + + mutating func run() async throws { + let command = self + try await xcodebuild.scoped { try await command.execute() } + } + + func execute() async throws { + let state: DeviceWDA.State + do { + state = try await DeviceWDA.start( + device: device, team: team, bundleID: bundleId, port: port, + startTimeout: TimeInterval(startTimeout), env: Environment.live) + } catch { + if shouldOutputJSON(flag: json) { try rethrowOrJSONError(error, json: true) } + fputs("\(error)\n", stderr) + throw ExitCode.failure + } + if shouldOutputJSON(flag: json) { + print(try WorkflowJSONRenderer.renderJSON(state)) + } else { + print("WebDriverAgent running on \(state.name ?? state.udid) at \(state.url)") + print("Use it from UI commands with:") + print(" export XCFORGE_DEVICE=\(state.udid)") + print("Runner log: \(state.logPath)") + } + } +} + +struct WDAStatus: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "status", + abstract: "Show device runners started with `wda start` and whether they answer." + ) + + @Option(help: "Only this device (name or UDID).") + var device: String? + + @Flag(help: "Emit the result as machine-readable JSON.") + var json = false + + struct Entry: Codable { + let udid: String + let name: String? + let url: String + let pid: Int32 + let healthy: Bool + let logPath: String + } + + mutating func run() async throws { + var states = DeviceWDA.all() + if let device { states = states.filter { $0.udid == device || $0.name == device } } + var entries: [Entry] = [] + for state in states { + entries.append( + Entry( + udid: state.udid, name: state.name, url: state.url, pid: state.pid, + healthy: await DeviceWDA.isHealthy(state.url), logPath: state.logPath)) + } + if shouldOutputJSON(flag: json) { + print(try WorkflowJSONRenderer.renderJSON(entries)) + return + } + if entries.isEmpty { + print("No device runners recorded. Start one with `xcforge wda start --device `.") + return + } + for e in entries { + print("\(e.name ?? e.udid): \(e.healthy ? "answering" : "NOT answering") at \(e.url) (pid \(e.pid))") + } + } +} + +struct WDAStop: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "stop", + abstract: "Stop the WebDriverAgent runner started on a device." + ) + + @Option(help: "Device name or UDID.") + var device: String + + mutating func run() async throws { + let known = DeviceWDA.load(device: device) != nil + await DeviceWDA.stop(device: device, env: Environment.live) + print(known ? "Stopped WebDriverAgent on \(device)" : "No WebDriverAgent recorded for \(device)") + } +} diff --git a/Sources/XCForgeCLI/Main.swift b/Sources/XCForgeCLI/Main.swift index ed4bb5c..c2c88c2 100644 --- a/Sources/XCForgeCLI/Main.swift +++ b/Sources/XCForgeCLI/Main.swift @@ -1,33 +1,121 @@ import ArgumentParser +import Dispatch +import Foundation import Logging import MCP import XCForgeKit @main struct Main { + /// `XCForgeCLI.main()`, except that an error a command throws in JSON mode (`--json`, or + /// stdout not a terminal) is printed as a JSON envelope on stdout like other JSON results. + static func runCLI() async { + let command: ParsableCommand + do { + command = try XCForgeCLI.parseAsRoot() + } catch { + XCForgeCLI.exit(withError: error) + } + do { + if var asyncCommand = command as? AsyncParsableCommand { + try await asyncCommand.run() + } else { + var syncCommand = command + try syncCommand.run() + } + } catch { + let json = CommandLine.arguments.contains("--json") || shouldOutputJSON(flag: false) + if json, printJSONError(error) { XCForgeCLI.exit(withError: ExitCode.failure) } + XCForgeCLI.exit(withError: error) + } + } + static func main() async throws { + // Ctrl-C, a shell tool's timeout kill or a client hang-up must not leave xcodebuild + // running with DerivedData locked. Stop our children before exiting. + let signalSources = installTerminationHandlers() + defer { signalSources.forEach { $0.cancel() } } + if CommandLine.arguments.count > 1 { - await XCForgeCLI.main() + await runCLI() } else { let logger = Logger(label: "com.xcforge.mcp") let server = Server( name: "xcforge", - version: "1.6.1", + version: XCForgeVersion.current, capabilities: .init(tools: .init(listChanged: true)) ) + // tool_groups changes the list; tell clients so they fetch it again. + ToolRegistry.onToolListChanged { + try? await server.notify(ToolListChangedNotification.message()) + } + await server.withMethodHandler(ListTools.self) { _ in .init(tools: ToolRegistry.allTools) } await server.withMethodHandler(CallTool.self) { params in - await ToolRegistry.dispatch(params.name, params.arguments) + guard let token = params._meta?.progressToken else { + return await ToolRegistry.dispatch(params.name, params.arguments) + } + return await ProgressHeartbeat.run(token: token, server: server) { + await ToolRegistry.dispatch(params.name, params.arguments) + } } let transport = StdioTransport(logger: logger) try await server.start(transport: transport) await server.waitUntilCompleted() + // The client closed stdin: nothing will read the results of calls still running. + ChildProcesses.terminateAll() + } + } + + /// Forward SIGINT, SIGTERM and SIGHUP to running children, then exit with the usual + /// 128 + signal status. + static func installTerminationHandlers() -> [DispatchSourceSignal] { + [SIGINT, SIGTERM, SIGHUP].map { sig in + signal(sig, SIG_IGN) + let source = DispatchSource.makeSignalSource(signal: sig, queue: .global()) + source.setEventHandler { + ChildProcesses.terminateAll(grace: 1) + // Give SIGTERM'd children a moment to exit before we go. + Thread.sleep(forTimeInterval: ChildProcesses.running.isEmpty ? 0 : 1.2) + exit(128 + sig) + } + source.resume() + return source + } + } +} + +/// Sends MCP progress notifications while a tool call runs, so clients that time out +/// silent requests keep waiting for long builds and tests. +enum ProgressHeartbeat { + /// Seconds between notifications. + static let interval: UInt64 = 10 + + static func run( + token: ProgressToken, server: Server, _ body: @Sendable () async -> CallTool.Result + ) async -> CallTool.Result { + let activity = ToolActivity() + let started = Date() + let heartbeat = Task { + var beats = 0.0 + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: interval * 1_000_000_000) + if Task.isCancelled { break } + beats += 1 + let elapsed = Int(Date().timeIntervalSince(started)) + let message = activity.lastLine.map { "\(elapsed)s: \($0)" } ?? "\(elapsed)s elapsed" + try? await server.notify( + ProgressNotification.message( + .init(progressToken: token, progress: beats, message: message))) + } } + defer { heartbeat.cancel() } + return await ToolActivity.$current.withValue(activity) { await body() } } } diff --git a/Sources/XCForgeCLI/XCForgeCLI.swift b/Sources/XCForgeCLI/XCForgeCLI.swift index a720303..a94aa9d 100644 --- a/Sources/XCForgeCLI/XCForgeCLI.swift +++ b/Sources/XCForgeCLI/XCForgeCLI.swift @@ -6,10 +6,12 @@ struct XCForgeCLI: AsyncParsableCommand { static let configuration = CommandConfiguration( commandName: "xcforge", abstract: "CLI-first workflow entrypoints for xcforge.", + version: XCForgeVersion.current, subcommands: [ Build.self, Test.self, BuildTest.self, Sim.self, Device.self, Diagnose.self, Defaults.self, Console.self, Git.self, Logs.self, Screenshot.self, UI.self, Accessibility.self, Plan.self, - SPM.self, Debug.self, Pose.self, Bless.self, WaitReady.self, Init.self, + SPM.self, Debug.self, Pose.self, Bless.self, WaitReady.self, Init.self, Lock.self, WDA.self, LSP.self, + Skill.self, ] ) } @@ -57,9 +59,20 @@ struct DefaultsSet: AsyncParsableCommand { } let env = Environment.live - await env.session.setDefaults( - project: project, scheme: scheme, simulator: simulator - ) + // Defaults are saved per project, so find it first; without one nothing would be written. + if project == nil { + do { + _ = try await env.session.resolveProject(nil) + } catch { + print("Not saved: couldn't find a project (\(error)). Pass --project.") + throw ExitCode.failure + } + } + let saved = await env.session.setDefaults(project: project, scheme: scheme, simulator: simulator) + guard saved else { + print("Not saved: no project is active. Pass --project.") + throw ExitCode.failure + } print(await env.session.showDefaults()) } } @@ -133,11 +146,26 @@ func rethrowOrJSONError(_ error: Error, json: Bool) throws { if let data = try? JSONEncoder().encode(envelope), let jsonString = String(data: data, encoding: .utf8) { - fputs(jsonString + "\n", stderr) + // On stdout like JSON results, so a caller parsing stdout sees the error too. + print(jsonString) } throw ExitCode.failure } +/// Print `error` as the JSON error envelope on stdout. False for errors that aren't failures +/// to report (an exit code a command already reported, help, `--version`). +func printJSONError(_ error: Error) -> Bool { + if error is ExitCode || error is CleanExit { return false } + // Help (`--help`, `help `) and `--version` arrive as errors with a success exit code. + if XCForgeCLI.exitCode(for: error) == .success { return false } + let envelope = CLIErrorEnvelope(error: "\(error)", code: errorCode(for: error)) + guard let data = try? JSONEncoder().encode(envelope), let jsonString = String(data: data, encoding: .utf8) else { + return false + } + print(jsonString) + return true +} + struct CLIErrorEnvelope: Encodable { let error: String let code: String diff --git a/Sources/XCForgeKit/Clients/AXPBridge.swift b/Sources/XCForgeKit/Clients/AXPBridge.swift index be87053..c967b38 100644 --- a/Sources/XCForgeKit/Clients/AXPBridge.swift +++ b/Sources/XCForgeKit/Clients/AXPBridge.swift @@ -190,7 +190,18 @@ public actor AXPBridge { private static func buildTree(pid: pid_t) throws -> [AXElement] { let app = AXUIElementCreateApplication(pid) var elements: [AXElement] = [] - traverse(element: app, into: &elements, depth: 0, maxDepth: 40, maxElements: 5000) + // Windows only: the menu bar's items ("Device", "Features", ...) would otherwise + // satisfy text lookups meant for the app. + var windowsRef: CFTypeRef? + if AXUIElementCopyAttributeValue(app, kAXWindowsAttribute as CFString, &windowsRef) == .success, + let windows = windowsRef as? [AXUIElement], !windows.isEmpty + { + for window in windows { + traverse(element: window, into: &elements, depth: 0, maxDepth: 40, maxElements: 5000) + } + } else { + traverse(element: app, into: &elements, depth: 0, maxDepth: 40, maxElements: 5000) + } return elements } @@ -300,7 +311,7 @@ public actor AXPBridge { let ws = NSWorkspace.shared guard let app = ws.runningApplications.first(where: { - $0.bundleIdentifier == "com.apple.iphonesimulator" + SimulatorApp.isSimulatorApp(bundleID: $0.bundleIdentifier) }) else { throw AXPError.simulatorNotRunning @@ -320,7 +331,7 @@ public actor AXPBridge { var description: String { switch self { case .simulatorNotRunning: - return "Simulator.app not running" + return "Simulator.app (Device Hub on Xcode 27) is not running" case .noBootedDevice: return "Simulator.app is running but no device is booted. Boot a device with: xcrun simctl boot " diff --git a/Sources/XCForgeKit/Clients/AgentClient.swift b/Sources/XCForgeKit/Clients/AgentClient.swift index 20a47ba..3ebfec2 100644 --- a/Sources/XCForgeKit/Clients/AgentClient.swift +++ b/Sources/XCForgeKit/Clients/AgentClient.swift @@ -30,8 +30,79 @@ public enum WDABackend: String, Sendable { public actor WDAClient { public init() {} - private var baseURL = - ProcessInfo.processInfo.environment["WDA_BASE_URL"] ?? "http://localhost:8100" + /// `WDA_BASE_URL` wins; otherwise `XCFORGE_DEVICE` selects the URL `xcforge wda start` + /// saved for that device; otherwise the simulator default. + private var baseURL = WDAClient.defaultBaseURL() + + /// True when the URL was chosen (env, a device's WDA, a custom URL) rather than derived + /// from the target simulator; selecting a simulator then leaves it alone. + private var pinned = WDAClient.environmentPinsURL() + + /// The simulator UI calls go to. Each simulator gets its own WDA port (`WDAPorts`). + /// `XCFORGE_SIMULATOR` (a UDID) picks it for a CLI process. + private var targetSimulator = WDAClient.environmentSimulator() + + static func environmentSimulator( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String { + guard let simulator = environment["XCFORGE_SIMULATOR"], !simulator.isEmpty else { return "booted" } + return simulator + } + + static func environmentPinsURL( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> Bool { + if let explicit = environment["WDA_BASE_URL"], !explicit.isEmpty { return true } + if let device = environment["XCFORGE_DEVICE"], !device.isEmpty, DeviceWDA.load(device: device) != nil { + return true + } + return false + } + + /// Point UI calls at `udid`'s own WebDriverAgent. A different simulator means a different + /// runner, so the session and its app binding are dropped. No-op when WDA is pinned to a + /// URL (a phone, `WDA_BASE_URL`). + public func selectSimulator(udid: String) { + guard !pinned, !udid.isEmpty, udid != "booted", udid != targetSimulator else { return } + targetSimulator = udid + baseURL = "http://localhost:\(WDAPorts.port(for: udid))" + sessionId = nil + activeBundleId = nil + } + + /// The simulator UI calls currently go to ("booted" until one is selected). + public func getTargetSimulator() -> String { targetSimulator } + + /// Stop using a device's WDA and go back to simulators. + public func useSimulators() { + pinned = false + baseURL = + targetSimulator == "booted" + ? "http://localhost:\(WDAPorts.basePort)" : "http://localhost:\(WDAPorts.port(for: targetSimulator))" + sessionId = nil + } + + static func defaultBaseURL( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> String { + if let explicit = environment["WDA_BASE_URL"], !explicit.isEmpty { return explicit } + if let device = environment["XCFORGE_DEVICE"], !device.isEmpty, + let state = DeviceWDA.load(device: device) + { + return state.url + } + if let simulator = environment["XCFORGE_SIMULATOR"], !simulator.isEmpty { + return "http://localhost:\(WDAPorts.port(for: simulator))" + } + return "http://localhost:8100" + } + + /// True when WDA runs somewhere other than this Mac (a physical device). Simulator + /// recovery (terminate, rebuild, redeploy) never applies there. + public var isRemote: Bool { + guard let host = URL(string: baseURL)?.host else { return false } + return !["localhost", "127.0.0.1", "::1"].contains(host) + } private var sessionId: String? private var knownSessionIds: [String] = [] // Track all created sessions @@ -61,7 +132,22 @@ public actor WDAClient { // MARK: - Configuration public func setBaseURL(_ url: String) { + let simulatorURL = + targetSimulator == "booted" + ? "http://localhost:\(WDAPorts.basePort)" : "http://localhost:\(WDAPorts.port(for: targetSimulator))" self.baseURL = url + self.pinned = url != simulatorURL + } + + /// Default action WDA takes on system alerts ("accept" or "dismiss"), sent with every + /// session so a permission prompt doesn't block a flow. `XCFORGE_ALERT_ACTION` sets it. + private var defaultAlertAction: String? = { + let value = ProcessInfo.processInfo.environment["XCFORGE_ALERT_ACTION"]?.lowercased() + return value == "accept" || value == "dismiss" ? value : nil + }() + + public func setDefaultAlertAction(_ action: String?) { + defaultAlertAction = action } public func getBaseURL() -> String { @@ -154,8 +240,28 @@ public actor WDAClient { } } - /// Restart current backend by terminating and relaunching the xctrunner on the given simulator. - func restartWDA(simulator: String = "booted") async throws { + /// Restart the current backend. xcforgeWDA restarts from its last build with + /// test-without-building (opening the runner app doesn't start the server); the original + /// WDA is relaunched. + func restartWDA(simulator: String? = nil) async throws { + let simulator = simulator ?? targetSimulator + if backend == .xcForgeWDA { + guard let xctestrun = await findXctestrun(derivedData: Self.wdaDerivedData) else { + throw WDAError.wdaRestart("xcforgeWDA has not been built yet") + } + await cleanupWDAProcesses(simulator: simulator) + await startRunner(xctestrun: xctestrun, udid: await resolveSimulatorUDID(simulator)) + // A loaded Mac can take well over 15s to bring the runner back. + for _ in 0..<120 { + try await Task.sleep(nanoseconds: 500_000_000) + if await isHealthy() { + sessionId = nil + knownSessionIds.removeAll() + return + } + } + throw WDAError.wdaRestart("xcforgeWDA did not become ready within 60s after restart") + } let bid = backend.bundleId // Kill any lingering WDA process let _ = try? await Shell.xcrun(timeout: 5, "simctl", "terminate", simulator, bid) @@ -185,20 +291,43 @@ public actor WDAClient { "\(backend.displayName) did not become ready within 10s after restart") } - /// Kill any process holding port 8100 to prevent binding conflicts. + /// Free the local WDA port, killing only WebDriverAgent runner processes listening on it. + /// The port comes from the configured base URL. Nothing is killed for a remote WDA + /// (a phone over the CoreDevice tunnel) or for an unrelated process on the port. private func cleanupPort8100() async { - if let result = try? await Shell.run("/usr/bin/lsof", arguments: ["-ti", ":8100"], timeout: 5), + guard let url = URL(string: baseURL), let host = url.host, + ["localhost", "127.0.0.1", "::1"].contains(host) + else { return } + let port = url.port ?? 8100 + guard + let result = try? await Shell.run( + "/usr/sbin/lsof", arguments: ["-ti", "tcp:\(port)", "-sTCP:LISTEN"], timeout: 5), result.succeeded, !result.stdout.isEmpty - { - for pidStr in result.stdout.split(separator: "\n") { - if let pid = Int32(pidStr.trimmingCharacters(in: .whitespaces)) { - kill(pid, SIGKILL) - } + else { return } + var killed = false + for pidStr in result.stdout.split(separator: "\n") { + guard let pid = Int32(pidStr.trimmingCharacters(in: .whitespaces)) else { continue } + let ps = try? await Shell.run( + "/bin/ps", arguments: ["-o", "comm=", "-p", String(pid)], timeout: 5) + let command = ps?.stdout ?? "" + guard Self.isWDARunnerCommand(command) else { + Log.warn("Port \(port) is held by pid \(pid) (\(command)), not a WDA runner — leaving it alone") + continue } + kill(pid, SIGKILL) + killed = true + } + if killed { try? await Task.sleep(nanoseconds: 300_000_000) // 0.3s for port release } } + /// True when a process command path looks like a WebDriverAgent test runner. + static func isWDARunnerCommand(_ command: String) -> Bool { + let name = (command.trimmingCharacters(in: .whitespacesAndNewlines) as NSString).lastPathComponent + return name.contains("WebDriverAgent") || name.contains("WDARunner") || name.hasSuffix("-Runner") + } + /// Kill any WDA process on port 8100 and terminate known runners. private func cleanupWDAProcesses(simulator: String) async { let _ = try? await Shell.xcrun( @@ -211,20 +340,19 @@ public actor WDAClient { /// Deploy xcforgeWDA to the simulator: build-for-testing + start via xcodebuild test. /// Returns true if deploy succeeded and server is healthy, false otherwise. /// If a deploy is already in progress, waits for it instead of starting a new one. - func deployXCForgeWDA(simulator: String = "booted") async -> Bool { + func deployXCForgeWDA(simulator: String? = nil) async -> Bool { + let simulator = simulator ?? targetSimulator // H3 fix: If another deploy is in progress, wait for it instead of triggering premature fallback. // Actor isolation guarantees isDeploying is checked atomically (no await before the set). if isDeploying { Log.warn("deployXCForgeWDA: concurrent call detected, waiting for existing deploy") - for i in 1...15 { + // The other deploy may be building, so wait as long as a build plus a runner start. + let deadline = Date().addingTimeInterval(900 + Self.runnerStartSeconds) + while Date() < deadline, isDeploying { try? await Task.sleep(nanoseconds: 2_000_000_000) - if await isHealthy() { - Log.warn("deployXCForgeWDA: existing deploy succeeded after \(i * 2)s wait") - return true - } + if await isHealthy() { return true } } - Log.warn("deployXCForgeWDA: existing deploy did not succeed within 30s") - return false + return await isHealthy() } isDeploying = true @@ -235,35 +363,17 @@ public actor WDAClient { deployTask = nil await cleanupWDAProcesses(simulator: simulator) - // Find xcforgeWDA project — check env var first, then common relative location - let projectDir: String - if let envDir = ProcessInfo.processInfo.environment["XCFORGE_WDA_DIR"], - FileManager.default.fileExists(atPath: envDir + "/xcforgeWDA.xcodeproj") - { - projectDir = envDir - } else { - // Search order: CWD-relative (developer running from a clone) → Homebrew share dirs. - let candidates = [ - FileManager.default.currentDirectoryPath + "/xcforgeWDA", - FileManager.default.currentDirectoryPath + "/../xcforgeWDA", - "/opt/homebrew/share/xcforge/xcforgeWDA", - "/usr/local/share/xcforge/xcforgeWDA", - ] - guard - let found = candidates.first(where: { - FileManager.default.fileExists(atPath: $0 + "/xcforgeWDA.xcodeproj") - }) - else { - return false - } - projectDir = found + lastDeployFailure = nil + guard let projectDir = Self.locateXCForgeWDAProject() else { + lastDeployFailure = "xcforgeWDA.xcodeproj not found. Set XCFORGE_WDA_DIR to an xcforgeWDA checkout." + return false } // Resolve UDID for xcodebuild destination let udid = await resolveSimulatorUDID(simulator) // Deterministic DerivedData so we know where xctestrun lands - let derivedData = NSHomeDirectory() + "/Library/Developer/Xcode/DerivedData/xcforgeWDA-deploy" + let derivedData = Self.wdaDerivedData // Step 1: build-for-testing with -scheme (resolves SPM) + -sdk (Xcode 26 // workaround: -scheme can't find iOS Simulator destinations for UI testing @@ -278,13 +388,16 @@ public actor WDAClient { ] let buildResult: ShellResult do { - buildResult = try await Shell.run("/usr/bin/xcrun", arguments: buildArgs, timeout: 120) + // A cold build on a busy Mac takes minutes. + buildResult = try await Shell.run("/usr/bin/xcrun", arguments: buildArgs, timeout: 900) } catch { Log.warn("deployXCForgeWDA build error: \(error)") + lastDeployFailure = "building xcforgeWDA from \(projectDir) failed: \(error)" return false } guard buildResult.succeeded else { Log.warn("deployXCForgeWDA build failed: \(buildResult.stderr.prefix(500))") + lastDeployFailure = Self.explainWDABuildFailure(Xcodebuild.combinedOutput(buildResult), projectDir: projectDir) return false } @@ -292,26 +405,107 @@ public actor WDAClient { let xctestrun = await findXctestrun(derivedData: derivedData) guard let xctestrun else { Log.warn("deployXCForgeWDA: no xctestrun file found in \(derivedData)") + lastDeployFailure = "xcforgeWDA built but no .xctestrun was found in \(derivedData)/Build/Products" return false } + await startRunner(xctestrun: xctestrun, udid: udid) + + // Step 3: Poll for server readiness. The first start installs the runner on the + // simulator, which takes well over 30s on a loaded Mac. + let deadline = Date().addingTimeInterval(Self.runnerStartSeconds) + while Date() < deadline { + try? await Task.sleep(nanoseconds: 2_000_000_000) // 2s + if await isHealthy() { + return true + } + } + lastDeployFailure = + "the xcforgeWDA runner didn't answer on port \(port) within \(Int(Self.runnerStartSeconds))s. " + + "It may still be starting: retry, or raise XCFORGE_WDA_START_SECONDS." + return false + } + + /// Why the last xcforgeWDA deploy failed, for the error the caller sees. + private var lastDeployFailure: String? + + /// How long to wait for a freshly started runner. `XCFORGE_WDA_START_SECONDS` overrides it. + static var runnerStartSeconds: TimeInterval { + AppLiveness.seconds("XCFORGE_WDA_START_SECONDS", default: 180) + } + + /// A build failure of xcforgeWDA in one sentence. An older copy (such as Homebrew's share + /// folder) can fail on a newer Xcode with "Supported platforms ... is empty". + static func explainWDABuildFailure(_ output: String, projectDir: String) -> String { + if output.contains("Supported platforms for the buildables in the current scheme is empty") { + return "the xcforgeWDA copy at \(projectDir) doesn't build with this Xcode (it is probably older than " + + "your xcforge). Upgrade xcforge, or set XCFORGE_WDA_DIR to the xcforgeWDA folder of a current checkout." + } + let firstError = output.split(separator: "\n").first { $0.contains("error:") }.map(String.init) + return "building xcforgeWDA from \(projectDir) failed: \(firstError ?? "see the xcodebuild output")" + } + + static let wdaDerivedData = NSHomeDirectory() + "/Library/Developer/Xcode/DerivedData/xcforgeWDA-deploy" + + /// The port this client's WDA listens on. + private var port: Int { URL(string: baseURL)?.port ?? WDAPorts.basePort } + + /// Start the xcforgeWDA runner on `udid` from a built test run, listening on this + /// client's port. It runs until stopped; there is no time cap. + private func startRunner(xctestrun: String, udid: String) async { + await stopRunners(udid: udid) let testArgs = [ "xcodebuild", "test-without-building", "-xctestrun", xctestrun, "-destination", "id=\(udid)", ] + // xcodebuild hands TEST_RUNNER_-prefixed variables to the runner; WDA binds USE_PORT. + let environment = ["TEST_RUNNER_USE_PORT": String(port)] deployTask = Task.detached { - _ = try? await Shell.run("/usr/bin/xcrun", arguments: testArgs, timeout: 3600) + // The runner outlives this call by design; don't stop it when xcforge exits. + _ = try? await ChildProcesses.$untracked.withValue(true) { + try await Shell.run( + "/usr/bin/xcrun", arguments: testArgs, environment: environment, timeout: Self.runnerLifetime) + } } + } - // Step 3: Poll for server readiness (up to 30s) - for _ in 1...15 { - try? await Task.sleep(nanoseconds: 2_000_000_000) // 2s - if await isHealthy() { - return true - } + /// Stop every xcforgeWDA runner (`xcodebuild test-without-building`) on `udid`, from this + /// process or another one. A second XCTest session on the same simulator kills the app + /// under test, so a start or restart must never leave the old runner behind. + private func stopRunners(udid: String) async { + deployTask?.cancel() + deployTask = nil + let pattern = Self.runnerPattern(udid: udid) + let killed = try? await Shell.run("/usr/bin/pkill", arguments: ["-TERM", "-f", pattern], timeout: 5) + // pkill exits 0 when it signalled something; give the runner a moment to go. + if killed?.exitCode == 0 { try? await Task.sleep(nanoseconds: 1_000_000_000) } + } + + /// `pkill -f` pattern for an xcforgeWDA runner on `udid`. + static func runnerPattern(udid: String) -> String { + "test-without-building -xctestrun .*xcforgeWDA.* -destination id=\(udid)" + } + + /// How long a runner may live: effectively unlimited (30 days). + static let runnerLifetime: TimeInterval = 30 * 24 * 3600 + + /// Folder containing `xcforgeWDA.xcodeproj`: `XCFORGE_WDA_DIR`, then a clone next to the + /// working directory, then the Homebrew share directories. + public static func locateXCForgeWDAProject() -> String? { + let fm = FileManager.default + if let envDir = ProcessInfo.processInfo.environment["XCFORGE_WDA_DIR"], + fm.fileExists(atPath: envDir + "/xcforgeWDA.xcodeproj") + { + return envDir } - return false + let candidates = [ + fm.currentDirectoryPath + "/xcforgeWDA", + fm.currentDirectoryPath + "/../xcforgeWDA", + "/opt/homebrew/share/xcforge/xcforgeWDA", + "/usr/local/share/xcforge/xcforgeWDA", + ] + return candidates.first { fm.fileExists(atPath: $0 + "/xcforgeWDA.xcodeproj") } } /// Find the xctestrun file in DerivedData/Build/Products. @@ -358,13 +552,55 @@ public actor WDAClient { return simulator } + /// Why WDA can't be started on `simulator` without booting it, or nil when it is booted + /// (or its state can't be read). + private func simulatorNotBootedProblem(_ simulator: String) async -> String? { + let udid = await resolveSimulatorUDID(simulator) + guard let result = try? await Shell.xcrun(timeout: 15, "simctl", "list", "devices", "-j"), + result.succeeded, let data = result.stdout.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + return Self.notBootedProblem(udid: udid, listJSON: json) + } + + /// The error for a simulator that isn't booted, naming any simulator that is. Nil when + /// `udid` is booted or isn't in the list. + static func notBootedProblem(udid: String, listJSON: [String: Any]) -> String? { + guard let runtimes = listJSON["devices"] as? [String: [[String: Any]]] else { return nil } + let devices = runtimes.values.flatMap { $0 } + guard let target = devices.first(where: { $0["udid"] as? String == udid }) else { return nil } + let state = target["state"] as? String ?? "unknown" + if state == "Booted" { return nil } + let name = target["name"] as? String ?? udid + let others = devices.filter { $0["state"] as? String == "Booted" }.compactMap { $0["name"] as? String } + var message = + "Simulator \(name) (\(udid)) is \(state.lowercased()); boot it first (`xcrun simctl boot \(udid)`). " + + "xcforge doesn't boot simulators for UI commands." + if !others.isEmpty { + let booted = others.joined(separator: ", ") + message += " Already booted: \(booted). Use it with --simulator, or shut it down first: " + message += "two booted simulators can exhaust memory on a small Mac." + } + return message + } + /// Health-check with auto-restart and fallback chain. /// Always tries in fixed order: healthy? → restart current → deploy xcforgeWDA → fallback Original WDA. /// H1 fix: Backend state is only updated AFTER confirming which backend is actually running. - public func ensureWDARunning(simulator: String = "booted") async throws { + public func ensureWDARunning(simulator: String? = nil) async throws { + let simulator = simulator ?? targetSimulator // 1. Already healthy? Done — whatever backend is active, it works. if await isHealthy() { return } + // A device runner can't be restarted from here; say what to do instead. + if isRemote { throw WDAError.remoteNotResponding(baseURL) } + + // Starting the runner with xcodebuild boots a shut-down simulator, and a second booted + // simulator can exhaust a small Mac's memory. Refuse instead of booting. + if let problem = await simulatorNotBootedProblem(simulator) { + throw WDAError.simulatorNotBooted(problem) + } + // 2. Try restarting current backend do { try await restartWDA(simulator: simulator) @@ -395,7 +631,7 @@ public actor WDAClient { } // 5. Nothing works - throw WDAError.noBackendAvailable + throw WDAError.noBackendAvailable(lastDeployFailure) } // MARK: - Session Management @@ -423,10 +659,8 @@ public actor WDAClient { bundleId?.trimmingCharacters(in: .whitespaces).isEmpty == false ? bundleId : nil let effectiveBundleId = normalizedRequested ?? activeBundleId - var capabilities: [String: Any] = [:] - if let bid = effectiveBundleId { - capabilities["bundleId"] = bid - } + let capabilities = Self.sessionCapabilities( + bundleId: effectiveBundleId, recreating: normalizedRequested == nil, alertAction: defaultAlertAction) let body: [String: Any] = [ "capabilities": [ @@ -456,6 +690,19 @@ public actor WDAClient { return sessionId } + /// Capabilities for a new session. A session recreated behind the agent's back (after a + /// WDA hiccup) binds the app without relaunching it: WDA's default `forceAppLaunch` would + /// restart the app and lose its screen and launch arguments. + static func sessionCapabilities(bundleId: String?, recreating: Bool, alertAction: String?) -> [String: Any] { + var capabilities: [String: Any] = [:] + if let bundleId { + capabilities["bundleId"] = bundleId + if recreating { capabilities["forceAppLaunch"] = false } + } + if let alertAction { capabilities["defaultAlertAction"] = alertAction } + return capabilities + } + /// Returns the CFBundleIdentifier reported by WDA for the active session, or nil if /// none. Use after `createSession(bundleId:)` to confirm the binding actually took /// effect — WDA accepts the capability silently even when activation fails. @@ -661,6 +908,28 @@ public actor WDAClient { ) } + /// The element with keyboard focus, or nil when none has it. + public func activeElementId() async throws -> String? { + let sid = try await ensureSession() + let (data, status) = try await request(method: "GET", path: "/session/\(sid)/element/active") + guard status < 400, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let element = json["value"] as? [String: Any] + else { return nil } + return element["ELEMENT"] as? String ?? element.values.first as? String + } + + /// Type into whatever has keyboard focus (named keys included: "\n", "\u{8}"). + public func typeKeys(_ text: String) async throws { + let sid = try await ensureSession() + _ = try await jsonRequest( + method: "POST", path: "/session/\(sid)/wda/keys", body: ["value": Array(text).map(String.init)]) + } + + public func dismissKeyboard() async throws { + let sid = try await ensureSession() + _ = try await jsonRequest(method: "POST", path: "/session/\(sid)/wda/keyboard/dismiss", body: [:]) + } + public func clearElement(elementId: String) async throws { let sid = try await ensureSession() _ = try await jsonRequest(method: "POST", path: "/session/\(sid)/element/\(elementId)/clear") @@ -986,12 +1255,24 @@ public actor WDAClient { // MARK: - View Hierarchy + /// Formats WDA's `/source` accepts. `list` is xcforge's flat element listing, handled by callers. + public static let sourceFormats = ["json", "xml", "description"] + + /// Why `format` can't be used for a source request, or nil when it can. + public static func sourceFormatProblem(_ format: String) -> String? { + let lower = format.lowercased() + if sourceFormats.contains(lower) || lower == "list" { return nil } + return "Unknown source format '\(format)'. Use one of: \((sourceFormats + ["list"]).joined(separator: ", "))." + } + public func getSource(format: String = "json") async throws -> String { - // Health check first — getSource bypasses session management, so fail fast if WDA is dead - guard await isHealthy() else { - throw WDAError.wdaNotResponding + if let problem = Self.sourceFormatProblem(format) { throw WDAError.invalidResponse(problem) } + // getSource bypasses session management, so start WDA here like find_element does. + let healthy = await isHealthy() + if !healthy { + try await ensureWDARunning() } - let (data, statusCode) = try await request(method: "GET", path: "/source?format=\(format)") + let (data, statusCode) = try await request(method: "GET", path: "/source?format=\(format.lowercased())") guard statusCode < 400 else { throw WDAError.invalidResponse("Source request failed with status \(statusCode)") } @@ -1099,6 +1380,7 @@ public enum WDASessionCause: String, Codable, Sendable { case noBootedSimulator = "no_booted_simulator" case bundleNotInstalled = "bundle_not_installed" case sessionBindRejected = "session_bind_rejected" + case deviceRunnerNotReachable = "device_runner_not_reachable" case unknown = "unknown" /// A copy-pasteable remediation command for this cause. @@ -1116,6 +1398,8 @@ public enum WDASessionCause: String, Codable, Sendable { case .sessionBindRejected: return "Verify the bundle id is installed and runnable on the booted simulator, then retry" + case .deviceRunnerNotReachable: + return "xcforge wda start --device # unlock the device and enable UI Automation first" case .unknown: return "xcforge ui status # inspect WDA; attach the raw detail when filing an issue" } @@ -1148,7 +1432,9 @@ enum WDAError: Error, CustomStringConvertible { case elementNotFound(String, String) case wdaRestart(String) case wdaNotResponding - case noBackendAvailable + case noBackendAvailable(String?) + case remoteNotResponding(String) + case simulatorNotBooted(String) var description: String { switch self { @@ -1160,9 +1446,16 @@ enum WDAError: Error, CustomStringConvertible { case .wdaRestart(let msg): return "WDA restart failed: \(msg)" case .wdaNotResponding: return "WDA not responding (timeout >10s). Try: wda_create_session or restart the simulator." - case .noBackendAvailable: + case .noBackendAvailable(let reason?): + return "WebDriverAgent didn't start: \(reason)" + case .noBackendAvailable(nil): return "No WDA backend available. Neither xcforgeWDA nor Original WDA could be started. Install xcforgeWDA or start WebDriverAgent." + case .simulatorNotBooted(let msg): return msg + case .remoteNotResponding(let url): + return "WebDriverAgent at \(url) is not responding. Start it with `xcforge wda start --device ` " + + "(MCP: wda_start). If it was running: unlock the device, check Settings > Developer > " + + "Enable UI Automation, and check the device is still connected." } } } diff --git a/Sources/XCForgeKit/Clients/FramebufferCapture.swift b/Sources/XCForgeKit/Clients/FramebufferCapture.swift index 53e4e07..07c6d93 100644 --- a/Sources/XCForgeKit/Clients/FramebufferCapture.swift +++ b/Sources/XCForgeKit/Clients/FramebufferCapture.swift @@ -143,7 +143,7 @@ enum FramebufferCapture { static func findSimulatorWindow(simulator: String = "booted") async throws -> SCWindow { let content = try await SCShareableContent.current let simWindows = content.windows.filter { - $0.owningApplication?.bundleIdentifier == "com.apple.iphonesimulator" + SimulatorApp.isSimulatorApp(bundleID: $0.owningApplication?.bundleIdentifier) && $0.frame.width > 100 && $0.frame.height > 100 } diff --git a/Sources/XCForgeKit/Clients/IndigoHIDClient.swift b/Sources/XCForgeKit/Clients/IndigoHIDClient.swift index 1feef92..c94fc1e 100644 --- a/Sources/XCForgeKit/Clients/IndigoHIDClient.swift +++ b/Sources/XCForgeKit/Clients/IndigoHIDClient.swift @@ -8,15 +8,60 @@ actor IndigoHIDClient { static let shared = IndigoHIDClient() - /// Whether the required frameworks can be loaded. + /// Whether HID input can be used: SimulatorKit loads and this Xcode is one where taps are + /// known to land. On Xcode 27 the events are sent without error but never reach the app, + /// so HID is off there and callers use WDA. `XCFORGE_FORCE_HID=1` turns it on anyway. nonisolated static let isAvailable: Bool = { - guard simKitHandle != nil else { - Log.warn("IndigoHIDClient unavailable — SimulatorKit.framework not found.") + guard simKitHandle != nil, supportedXcode else { + Log.warn("IndigoHIDClient unavailable — \(unavailableReason)") return false } return true }() + nonisolated private static let supportedXcode: Bool = { + if ProcessInfo.processInfo.environment["XCFORGE_FORCE_HID"] == "1" { return true } + return hidSupported(xcodeMajor: xcodeMajorVersion(developerDir: developerDir)) + }() + + /// HID taps are verified up to Xcode 26. An unknown version counts as supported. + nonisolated static func hidSupported(xcodeMajor: Int?) -> Bool { + guard let xcodeMajor else { return true } + return xcodeMajor < 27 + } + + /// The major version of the Xcode at `developerDir`, from `Contents/version.plist`. + nonisolated static func xcodeMajorVersion(developerDir: String) -> Int? { + let contents = (developerDir as NSString).deletingLastPathComponent + let plist = (contents as NSString).appendingPathComponent("version.plist") + guard let dict = NSDictionary(contentsOfFile: plist), + let version = dict["CFBundleShortVersionString"] as? String + else { return nil } + return version.split(separator: ".").first.flatMap { Int($0) } + } + + /// Why HID input can't be used, for results that fall back to WDA. + nonisolated static var unavailableReason: String { + if simKitHandle != nil, !supportedXcode { + return "HID is not yet supported on Xcode 27, used WDA (XCFORGE_FORCE_HID=1 tries HID anyway)" + } + let folders = simulatorKitCandidates(developerDir: developerDir).map { + (($0 as NSString).deletingLastPathComponent as NSString).deletingLastPathComponent + } + return "SimulatorKit.framework not found in \(folders.joined(separator: " or "))" + } + + /// Where SimulatorKit lives: `Contents/SharedFrameworks` from Xcode 27, + /// `Contents/Developer/Library/PrivateFrameworks` before. + nonisolated static func simulatorKitCandidates(developerDir: String) -> [String] { + let contents = (developerDir as NSString).deletingLastPathComponent + return [ + (contents as NSString).appendingPathComponent("SharedFrameworks/SimulatorKit.framework/SimulatorKit"), + (developerDir as NSString).appendingPathComponent( + "Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit"), + ] + } + // MARK: - Framework Loading private static let developerDir: String = { @@ -39,8 +84,10 @@ actor IndigoHIDClient { }() nonisolated(unsafe) private static let simKitHandle: UnsafeMutableRawPointer? = { - dlopen( - "\(developerDir)/Library/PrivateFrameworks/SimulatorKit.framework/SimulatorKit", RTLD_NOW) + for path in simulatorKitCandidates(developerDir: developerDir) { + if let handle = dlopen(path, RTLD_NOW) { return handle } + } + return nil }() // MARK: - Cached State @@ -60,9 +107,9 @@ actor IndigoHIDClient { // MARK: - Public API /// Tap at point coordinates (in points, same coordinate space as WDA). - func tap(x: Double, y: Double, simulator: String = "booted") async throws { + func tap(x: Double, y: Double, simulator: String = "booted", orientation: Orientation = .portrait) async throws { let port = try await resolvePort(simulator: simulator) - let (xRatio, yRatio) = try normalizeCoordinates(x: x, y: y) + let (xRatio, yRatio) = try normalizeCoordinates(x: x, y: y, orientation: orientation) try await sendOnHIDQueue { try Self.sendTouch(port: port, xRatio: xRatio, yRatio: yRatio, direction: .down) @@ -74,13 +121,15 @@ actor IndigoHIDClient { } /// Double-tap at point coordinates. - func doubleTap(x: Double, y: Double, simulator: String = "booted") async throws { + func doubleTap(x: Double, y: Double, simulator: String = "booted", orientation: Orientation = .portrait) + async throws + { while gestureInProgress { try await Task.sleep(nanoseconds: 1_000_000) } gestureInProgress = true defer { gestureInProgress = false } let port = try await resolvePort(simulator: simulator) - let (xRatio, yRatio) = try normalizeCoordinates(x: x, y: y) + let (xRatio, yRatio) = try normalizeCoordinates(x: x, y: y, orientation: orientation) // First tap try await sendOnHIDQueue { @@ -104,11 +153,11 @@ actor IndigoHIDClient { } /// Long press at point coordinates. - func longPress(x: Double, y: Double, durationMs: Int = 1000, simulator: String = "booted") - async throws - { + func longPress( + x: Double, y: Double, durationMs: Int = 1000, simulator: String = "booted", orientation: Orientation = .portrait + ) async throws { let port = try await resolvePort(simulator: simulator) - let (xRatio, yRatio) = try normalizeCoordinates(x: x, y: y) + let (xRatio, yRatio) = try normalizeCoordinates(x: x, y: y, orientation: orientation) try await sendOnHIDQueue { try Self.sendTouch(port: port, xRatio: xRatio, yRatio: yRatio, direction: .down) @@ -125,15 +174,16 @@ actor IndigoHIDClient { startX: Double, startY: Double, endX: Double, endY: Double, durationMs: Int = 300, - simulator: String = "booted" + simulator: String = "booted", + orientation: Orientation = .portrait ) async throws { while gestureInProgress { try await Task.sleep(nanoseconds: 1_000_000) } gestureInProgress = true defer { gestureInProgress = false } let port = try await resolvePort(simulator: simulator) - let (sxR, syR) = try normalizeCoordinates(x: startX, y: startY) - let (exR, eyR) = try normalizeCoordinates(x: endX, y: endY) + let (sxR, syR) = try normalizeCoordinates(x: startX, y: startY, orientation: orientation) + let (exR, eyR) = try normalizeCoordinates(x: endX, y: endY, orientation: orientation) // Calculate steps (~10px per step in point space) let dx = endX - startX @@ -284,15 +334,24 @@ actor IndigoHIDClient { // iPhone SE (3rd gen) case let m where m.contains("iPhone-SE"): return (375, 667, 2.0) + // iPhone 16/17 Pro Max + case let m where (m.contains("iPhone-16") || m.contains("iPhone-17")) && m.contains("Pro-Max"): + return (440, 956, 3.0) + // iPhone Air + case let m where m.contains("iPhone-Air"): + return (420, 912, 3.0) + // iPhone 17, iPhone 16 Pro and 17 Pro + case let m where m.contains("iPhone-17") && !m.contains("Plus"), + let m where m.contains("iPhone-16-Pro"): + return (402, 874, 3.0) // iPhone 14/15/16 standard case let m where m.contains("iPhone-14") && !m.contains("Pro") && !m.contains("Plus"), let m where m.contains("iPhone-15") && !m.contains("Pro") && !m.contains("Plus"), let m where m.contains("iPhone-16") && !m.contains("Pro") && !m.contains("Plus"): return (390, 844, 3.0) - // iPhone 14/15/16 Pro + // iPhone 14/15 Pro case let m where m.contains("iPhone-14-Pro") && !m.contains("Max"), - let m where m.contains("iPhone-15-Pro") && !m.contains("Max"), - let m where m.contains("iPhone-16-Pro") && !m.contains("Max"): + let m where m.contains("iPhone-15-Pro") && !m.contains("Max"): return (393, 852, 3.0) // iPhone Pro Max / Plus case let m where m.contains("Pro-Max") || m.contains("Plus"): @@ -306,9 +365,9 @@ actor IndigoHIDClient { // iPad Pro 12.9" / 13" case let m where m.contains("iPad-Pro") && (m.contains("12") || m.contains("13")): return (1024, 1366, 2.0) - // Fallback: iPhone 16 Pro dimensions + // Fallback: iPhone 16 Pro / 17 dimensions default: - return (393, 852, 3.0) + return (402, 874, 3.0) } } @@ -346,13 +405,56 @@ actor IndigoHIDClient { // MARK: - Coordinate Normalization - private func normalizeCoordinates(x: Double, y: Double) throws -> (Float, Float) { + /// The interface orientation taps are given in. The digitizer is fixed in portrait, so + /// points in a rotated interface have to be turned back before they're sent. + enum Orientation: Sendable, Equatable { + case portrait, portraitUpsideDown, landscapeLeft, landscapeRight + + /// From WebDriverAgent's orientation value. + init(wdaValue: String) { + let value = wdaValue.uppercased() + if value.contains("UPSIDE") { + self = .portraitUpsideDown + } else if value.contains("LANDSCAPERIGHT") || value == "LANDSCAPE_RIGHT" { + self = .landscapeRight + } else if value.contains("LANDSCAPE") { + self = .landscapeLeft + } else { + self = .portrait + } + } + } + + private func normalizeCoordinates(x: Double, y: Double, orientation: Orientation) throws -> (Float, Float) { guard cachedScreenWidth > 0, cachedScreenHeight > 0 else { throw IndigoHIDError.screenInfoUnavailable } - let xRatio = Float(x / cachedScreenWidth) - let yRatio = Float(y / cachedScreenHeight) - return (min(max(xRatio, 0), 1), min(max(yRatio, 0), 1)) + return Self.normalize( + x: x, y: y, portraitWidth: cachedScreenWidth, portraitHeight: cachedScreenHeight, orientation: orientation) + } + + /// A point in the interface's own coordinates as 0...1 ratios of the portrait digitizer. + /// (inferred) Landscape left is the device turned counter-clockwise, home edge on the right. + static func normalize( + x: Double, y: Double, portraitWidth: Double, portraitHeight: Double, orientation: Orientation + ) -> (Float, Float) { + let xRatio: Double + let yRatio: Double + switch orientation { + case .portrait: + xRatio = x / portraitWidth + yRatio = y / portraitHeight + case .portraitUpsideDown: + xRatio = 1 - x / portraitWidth + yRatio = 1 - y / portraitHeight + case .landscapeLeft: + xRatio = 1 - y / portraitWidth + yRatio = x / portraitHeight + case .landscapeRight: + xRatio = y / portraitWidth + yRatio = 1 - x / portraitHeight + } + return (Float(min(max(xRatio, 0), 1)), Float(min(max(yRatio, 0), 1))) } // MARK: - Mach IPC @@ -470,29 +572,8 @@ actor IndigoHIDClient { else { continue } // Get the device's HID port via lookup: service - let portObj: NSObject? = - device.perform( - Selector(("lookup:error:")), - with: "PurpleWorkspacePort" as NSString, - with: nil)? - .takeUnretainedValue() as? NSObject - - if let portObj = portObj { - // The result should be an NSMachPort or similar — extract the port number - if let machPort = portObj as? NSMachPort { - let port = machPort.machPort - if port != mach_port_t(MACH_PORT_NULL) { - return port - } - } - // Try extracting as NSNumber - if let num = portObj as? NSNumber { - let port = mach_port_t(num.uint32Value) - if port != mach_port_t(MACH_PORT_NULL) { - return port - } - } - } + let port = try lookupPort(on: device, name: "PurpleWorkspacePort") + if port != mach_port_t(MACH_PORT_NULL) { return port } throw IndigoHIDError.portLookupFailed( "PurpleWorkspacePort lookup failed for \(udid): port object not usable" @@ -502,6 +583,50 @@ actor IndigoHIDClient { throw IndigoHIDError.noBootedSimulator } + /// What a `lookup:error:` method returns, from its Objective-C return type encoding. + enum LookupReturn: Equatable { + case machPort + case object + } + + /// `I`/`i` (and the other integer encodings) is a raw `mach_port_t`; `@` is an object. Type + /// qualifiers such as `r` or `V` come first and are skipped. + static func lookupReturn(encoding: String) -> LookupReturn? { + let type = encoding.drop { "rnNoORV".contains($0) } + guard let first = type.first else { return nil } + if first == "@" { return .object } + if "IiLlQqSs".contains(first) { return .machPort } + return nil + } + + /// Call `-[SimDevice lookup:error:]`. It returns a `mach_port_t`, not an object, so it can't go + /// through `perform(_:with:with:)`, which would retain the port number as a pointer and crash. + private static func lookupPort(on device: NSObject, name: String) throws -> mach_port_t { + let selector = Selector(("lookup:error:")) + guard let method = class_getInstanceMethod(type(of: device), selector) else { + throw IndigoHIDError.portLookupFailed("SimDevice has no lookup:error: method") + } + let rawEncoding = method_copyReturnType(method) + let encoding = String(cString: rawEncoding) + free(rawEncoding) + let implementation = method_getImplementation(method) + switch lookupReturn(encoding: encoding) { + case .machPort: + typealias Lookup = @convention(c) (AnyObject, Selector, NSString, UnsafeMutableRawPointer?) -> UInt32 + let lookup = unsafeBitCast(implementation, to: Lookup.self) + return mach_port_t(lookup(device, selector, name as NSString, nil)) + case .object: + typealias Lookup = @convention(c) (AnyObject, Selector, NSString, UnsafeMutableRawPointer?) -> AnyObject? + let lookup = unsafeBitCast(implementation, to: Lookup.self) + let result = lookup(device, selector, name as NSString, nil) + if let machPort = result as? NSMachPort { return machPort.machPort } + if let number = result as? NSNumber { return mach_port_t(number.uint32Value) } + return mach_port_t(MACH_PORT_NULL) + case nil: + throw IndigoHIDError.portLookupFailed("lookup:error: returns an unexpected type '\(encoding)'") + } + } + /// Find the UDID of the first booted simulator. private static func findBootedUDID() throws -> String { let process = Process() diff --git a/Sources/XCForgeKit/Persistence/DefaultsStore.swift b/Sources/XCForgeKit/Persistence/DefaultsStore.swift index e157e4d..ab126c7 100644 --- a/Sources/XCForgeKit/Persistence/DefaultsStore.swift +++ b/Sources/XCForgeKit/Persistence/DefaultsStore.swift @@ -580,12 +580,35 @@ public enum RepoConfig { public var configuration: String? public var testPlan: String? /// Default test watchdog timeout in seconds, applied when no explicit - /// `timeoutSeconds` is passed. Overrides the built-in 180s/1800s bimodal. + /// `timeoutSeconds` is passed. Overrides the built-in 1800s/7200s defaults. public var testTimeout: Int? - /// When `false`, suppresses the 3-strikes silent promotion of explicit - /// values to sticky session defaults. Defaults to `true` (legacy behavior) - /// when the key is omitted. + /// When `true`, three consecutive uses of the same explicit value promote it to a + /// sticky session default. Off when the key is omitted. public var autoPromote: Bool? + /// `-derivedDataPath` for every xcodebuild call. Relative paths resolve against the yaml's folder. + public var derivedDataPath: String? + /// Lock file held around every xcodebuild call (same flock as `lockf(1)`). + public var buildLock: String? + /// Directory for generated result bundles and diagnostics. + public var artifactDir: String? + /// Refuse to build below this much free disk (GB). Absent means warn only. + public var minFreeGB: Double? + /// Seconds of xcodebuild silence before it is treated as hung. 0 disables. + public var idleTimeout: TimeInterval? + /// False drops the flags xcforge adds to every build. + public var defaultFlags: Bool? + /// `-jobs` for compiling actions. + public var jobs: Int? + /// DerivedData for `allErrors` builds, separate from the main cache. + public var diagnosticDerivedDataPath: String? + /// Swift package used by the SwiftPM tools when none is passed (e.g. a shared logic package). + public var packagePath: String? + /// Which MCP tool groups the server lists at start, e.g. `-diagnose,+git` or `build,test,ui`. + public var toolGroups: String? + /// Problems found while reading the file (unknown keys, bad values). Shown to agents. + public var warnings: [String] = [] + /// The `.xcforge.yaml` these values came from. + public var sourcePath: String? public init( project: String? = nil, @@ -594,7 +617,17 @@ public enum RepoConfig { configuration: String? = nil, testPlan: String? = nil, testTimeout: Int? = nil, - autoPromote: Bool? = nil + autoPromote: Bool? = nil, + derivedDataPath: String? = nil, + buildLock: String? = nil, + artifactDir: String? = nil, + minFreeGB: Double? = nil, + idleTimeout: TimeInterval? = nil, + defaultFlags: Bool? = nil, + jobs: Int? = nil, + diagnosticDerivedDataPath: String? = nil, + packagePath: String? = nil, + toolGroups: String? = nil ) { self.project = project self.scheme = scheme @@ -603,12 +636,25 @@ public enum RepoConfig { self.testPlan = testPlan self.testTimeout = testTimeout self.autoPromote = autoPromote + self.derivedDataPath = derivedDataPath + self.buildLock = buildLock + self.artifactDir = artifactDir + self.minFreeGB = minFreeGB + self.idleTimeout = idleTimeout + self.defaultFlags = defaultFlags + self.jobs = jobs + self.diagnosticDerivedDataPath = diagnosticDerivedDataPath + self.packagePath = packagePath + self.toolGroups = toolGroups } /// True when every field is nil (nothing to apply). public var isEmpty: Bool { project == nil && scheme == nil && simulator == nil && configuration == nil && testPlan == nil && testTimeout == nil && autoPromote == nil + && derivedDataPath == nil && buildLock == nil && artifactDir == nil && minFreeGB == nil + && idleTimeout == nil && defaultFlags == nil && jobs == nil && diagnosticDerivedDataPath == nil + && packagePath == nil && toolGroups == nil } } @@ -643,6 +689,12 @@ public enum RepoConfig { return nil } + var warnings: [String] = [] + func warn(_ message: String) { + warnings.append(message) + Log.warn(message) + } + var dict: [String: String] = [:] let normalized = contents.replacingOccurrences(of: "\r\n", with: "\n") .replacingOccurrences(of: "\r", with: "\n") @@ -651,20 +703,29 @@ public enum RepoConfig { if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } guard let colonIdx = trimmed.firstIndex(of: ":") else { continue } let key = trimmed[trimmed.startIndex.. = [ "project", "scheme", "simulator", "configuration", "testPlan", - "testTimeout", "autoPromote", + "testTimeout", "autoPromote", "derivedDataPath", "buildLock", "artifactDir", "minFreeGB", + "idleTimeout", "defaultFlags", "jobs", "diagnosticDerivedDataPath", "packagePath", + "toolGroups", ] - for key in dict.keys where !allowedKeys.contains(key) { - Log.warn("\(RepoConfig.fileName): ignoring unknown key '\(key)'") + for key in dict.keys.sorted() where !allowedKeys.contains(key) { + let suggestion = + allowedKeys.first { $0.lowercased() == key.lowercased() } + ?? FuzzyMatch.fuzzyRank(needle: key, candidates: Array(allowedKeys), maxResults: 1).first? + .candidate + let hint = suggestion.map { " (did you mean '\($0)'?)" } ?? "" + warn("\(RepoConfig.fileName): ignoring unknown key '\(key)'\(hint)") } // Resolve relative project path against config file directory @@ -675,7 +736,7 @@ public enum RepoConfig { if FileManager.default.fileExists(atPath: normalized) { project = normalized } else { - Log.warn( + warn( "\(RepoConfig.fileName): project path '\(p)' resolved to '\(normalized)' which does not exist — ignoring" ) project = nil @@ -689,8 +750,7 @@ public enum RepoConfig { if let parsed = Int(raw), parsed > 0 { testTimeout = parsed } else { - Log.warn( - "\(RepoConfig.fileName): ignoring non-numeric or non-positive testTimeout '\(raw)'") + warn("\(RepoConfig.fileName): ignoring non-numeric or non-positive testTimeout '\(raw)'") } } @@ -701,8 +761,42 @@ public enum RepoConfig { case "true": autoPromote = true case "false": autoPromote = false default: - Log.warn( - "\(RepoConfig.fileName): ignoring non-boolean autoPromote '\(raw)' (expected true/false)") + warn("\(RepoConfig.fileName): ignoring non-boolean autoPromote '\(raw)' (expected true/false)") + } + } + + // Path keys resolve relative to the yaml's folder; they need not exist yet. + func resolvedPath(_ key: String) -> String? { + guard let raw = dict[key] else { return nil } + let expanded = (raw as NSString).expandingTildeInPath + if expanded.hasPrefix("/") { return expanded } + return ((configDir as NSString).appendingPathComponent(expanded) as NSString).standardizingPath + } + + var minFreeGB: Double? + if let raw = dict["minFreeGB"] { + if let parsed = Double(raw), parsed >= 0 { + minFreeGB = parsed + } else { + warn("\(RepoConfig.fileName): ignoring non-numeric minFreeGB '\(raw)'") + } + } + + var idleTimeout: TimeInterval? + if let raw = dict["idleTimeout"] { + if let parsed = TimeInterval(raw), parsed >= 0 { + idleTimeout = parsed + } else { + warn("\(RepoConfig.fileName): ignoring non-numeric idleTimeout '\(raw)'") + } + } + + var jobs: Int? + if let raw = dict["jobs"] { + if let parsed = Int(raw), parsed > 0 { + jobs = parsed + } else { + warn("\(RepoConfig.fileName): ignoring non-numeric or non-positive jobs '\(raw)'") } } @@ -713,9 +807,47 @@ public enum RepoConfig { configuration: dict["configuration"], testPlan: dict["testPlan"], testTimeout: testTimeout, - autoPromote: autoPromote + autoPromote: autoPromote, + derivedDataPath: resolvedPath("derivedDataPath"), + buildLock: resolvedPath("buildLock"), + artifactDir: resolvedPath("artifactDir"), + minFreeGB: minFreeGB, + idleTimeout: idleTimeout, + defaultFlags: dict["defaultFlags"].flatMap(XcodebuildOptions.parseBool), + jobs: jobs, + diagnosticDerivedDataPath: resolvedPath("diagnosticDerivedDataPath"), + packagePath: resolvedPath("packagePath"), + toolGroups: dict["toolGroups"] ) - return result.isEmpty ? nil : result + var withWarnings = result + withWarnings.warnings = warnings + withWarnings.sourcePath = fileLabel + return result.isEmpty && warnings.isEmpty ? nil : withWarnings + } + + /// A yaml scalar without surrounding quotes or a trailing `# comment`. + /// `"My App" # dev` → `My App`; `iPhone 16 # dev` → `iPhone 16`; `a#b` stays `a#b`. + static func cleanValue(_ raw: String) -> String { + let value = raw.trimmingCharacters(in: .whitespaces) + if let quote = value.first, quote == "\"" || quote == "'" { + let afterOpen = value.index(after: value.startIndex) + if let close = value[afterOpen...].firstIndex(of: quote) { + return String(value[afterOpen.. value.startIndex, + value[value.index(before: index)].isWhitespace + { + cut = index + break + } + index = value.index(after: index) + } + return value[.. Bool { @@ -50,7 +80,8 @@ public enum LastFailuresStore { let target = URL(fileURLWithPath: path(at: repoRoot)) let tmp = URL(fileURLWithPath: target.path + ".tmp") let payload = Payload( - failures: failures, scheme: scheme, simulator: simulator, recordedAt: now) + failures: failures, scheme: scheme, simulator: simulator, recordedAt: now, run: run, + infraFailure: infraFailure) let encoder = JSONEncoder() encoder.outputFormatting = [.prettyPrinted, .sortedKeys] diff --git a/Sources/XCForgeKit/Persistence/LastResultStore.swift b/Sources/XCForgeKit/Persistence/LastResultStore.swift new file mode 100644 index 0000000..2c1b6f3 --- /dev/null +++ b/Sources/XCForgeKit/Persistence/LastResultStore.swift @@ -0,0 +1,171 @@ +import Foundation + +/// Remembers the result bundle of the most recent build and test run per project, so +/// `build diagnose`, `test failures` and `test coverage` read this project's last run +/// instead of the newest bundle any session left in /tmp. +/// +/// One small JSON file per project under `~/.xcforge/last-results/` (or +/// `XCFORGE_LAST_RESULTS_DIR`). Writes are best-effort and never fail a build. +public enum LastResultStore { + public enum Kind: String, Codable, Sendable { + case build + case test + } + + public struct Record: Codable, Sendable, Equatable { + public var build: String? + public var test: String? + public var updatedAt: Date + /// When build-for-testing last succeeded, and for which scheme, configuration and platform. + public var testBuiltAt: Date? + public var testBuildKey: String? + /// The test targets that build covered; nil when it built every target in the scheme or plan. + public var testBuildTargets: [String]? + } + + static func directory() -> String { + if let override = ProcessInfo.processInfo.environment["XCFORGE_LAST_RESULTS_DIR"], + !override.isEmpty + { + return (override as NSString).expandingTildeInPath + } + return NSHomeDirectory() + "/.xcforge/last-results" + } + + /// The project as an absolute path with symlinks resolved, so `MyApp.xcodeproj` passed from + /// two worktrees gets two records, and one project reached two ways gets one. + static func canonicalProject(_ project: String, cwd: String = FileManager.default.currentDirectoryPath) -> String { + let expanded = (project as NSString).expandingTildeInPath + let absolute = expanded.hasPrefix("/") ? expanded : (cwd as NSString).appendingPathComponent(expanded) + return ((absolute as NSString).standardizingPath as NSString).resolvingSymlinksInPath + } + + static func fileName(for project: String) -> String { + let canonical = canonicalProject(project) + let mapped = canonical.map { ch -> Character in + ch.isLetter || ch.isNumber || ch == "." || ch == "-" ? ch : "_" + } + var safe = String(mapped) + // File names stop at 255 bytes; keep the end of a long path and a hash of all of it. + if safe.utf8.count > 200 { + var hash: UInt64 = 5381 + for byte in canonical.utf8 { hash = (hash &* 33) &+ UInt64(byte) } + safe = String(safe.suffix(160)) + "-" + String(hash, radix: 16) + } + return safe + ".json" + } + + static func filePath(for project: String) -> String { + (directory() as NSString).appendingPathComponent(fileName(for: project)) + } + + /// Record `bundlePath` as the latest result of `kind` for `project`. + public static func record(project: String, kind: Kind, bundlePath: String, now: Date = Date()) { + update(project: project, now: now) { record in + switch kind { + case .build: record.build = bundlePath + case .test: record.test = bundlePath + } + record.updatedAt = now + } + } + + /// Read, change and write a project's record under an exclusive lock, so two processes + /// finishing at once don't drop one another's update. + private static func update(project: String, now: Date, _ change: (inout Record) -> Void) { + let path = filePath(for: project) + try? FileManager.default.createDirectory( + atPath: directory(), withIntermediateDirectories: true, attributes: nil) + let fd = open(path + ".lock", O_CREAT | O_RDWR, 0o644) + if fd >= 0 { flock(fd, LOCK_EX) } + defer { + if fd >= 0 { + flock(fd, LOCK_UN) + close(fd) + } + } + var record = load(path) ?? Record(build: nil, test: nil, updatedAt: now) + change(&record) + save(record, to: path) + } + + /// What a test build depends on besides the sources and the test targets. + static func testBuildKey( + scheme: String, configuration: String, coverage: Bool, physicalDevice: Bool, testPlan: String? = nil + ) -> String { + [ + scheme, configuration, coverage ? "coverage" : "", physicalDevice ? "device" : "simulator", + testPlan ?? "", + ] + .joined(separator: "|") + } + + /// Note that build-for-testing just succeeded for `project` with `key`, covering `targets` + /// (nil: every test target). + static func recordTestBuild(project: String, key: String, targets: [String]? = nil, now: Date = Date()) { + update(project: project, now: now) { record in + record.testBuiltAt = now + record.testBuildKey = key + record.testBuildTargets = targets?.sorted() + } + } + + /// True when the last successful build-for-testing for `project` used `key`, built every + /// target in `targets` (nil: needs every target), and nothing under `sourceRoot` changed + /// after it, so its products can be tested as they are. + static func testBuildIsCurrent( + project: String, key: String, targets: [String]? = nil, sourceRoot: String + ) -> Bool { + guard let record = load(filePath(for: project)), record.testBuildKey == key, + let builtAt = record.testBuiltAt + else { return false } + if let built = record.testBuildTargets { + guard let targets, Set(targets).isSubset(of: Set(built)) else { return false } + } + return !SourceChanges.anyModified(under: sourceRoot, after: builtAt) + } + + private static func save(_ record: Record, to path: String) { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(record) else { return } + try? data.write(to: URL(fileURLWithPath: path), options: .atomic) + } + + /// The latest recorded bundle of `kind` for `project`, if it still exists on disk. + public static func latest(project: String, kind: Kind) -> String? { + guard let record = load(filePath(for: project)) else { return nil } + let path: String? + switch kind { + case .build: path = record.build + case .test: path = record.test + } + guard let path, FileManager.default.fileExists(atPath: path) else { return nil } + return path + } + + private static func load(_ path: String) -> Record? { + guard let data = FileManager.default.contents(atPath: path) else { return nil } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return try? decoder.decode(Record.self, from: data) + } + + /// Record the result bundle named in an xcodebuild argument list, if any. + /// + /// A failed build-for-testing counts as the latest test run, so `test failures` reports + /// its errors instead of an earlier run's failures. + static func recordFromArguments(_ args: [String], succeeded: Bool = true) { + guard let bundleIndex = args.firstIndex(of: "-resultBundlePath"), bundleIndex + 1 < args.count + else { return } + let bundle = args[bundleIndex + 1] + let projectIndex = args.firstIndex(of: "-workspace") ?? args.firstIndex(of: "-project") + guard let projectIndex, projectIndex + 1 < args.count else { return } + let project = args[projectIndex + 1] + let testActions: Set = ["test", "test-without-building"] + let failedTestBuild = !succeeded && args.contains("build-for-testing") + let kind: Kind = (failedTestBuild || args.contains { testActions.contains($0) }) ? .test : .build + record(project: project, kind: kind, bundlePath: bundle) + } +} diff --git a/Sources/XCForgeKit/Plan/PlanSessionFile.swift b/Sources/XCForgeKit/Plan/PlanSessionFile.swift new file mode 100644 index 0000000..892129b --- /dev/null +++ b/Sources/XCForgeKit/Plan/PlanSessionFile.swift @@ -0,0 +1,97 @@ +import Foundation +import MCP + +/// Suspended plans saved to disk, so `xcforge plan decide` in a new process can resume what +/// `xcforge plan run` paused. The MCP server keeps its sessions in memory (`PlanSessionStore`). +public enum PlanSessionFile { + /// How long a saved session can be resumed. + public static let ttl: TimeInterval = 3600 + + struct Saved: Codable { + let steps: Value + let pauseIndex: Int + let question: String + let completedResults: [StepResult] + let variableBindings: [String: VariableStore.ElementBinding] + let errorStrategy: String + let timeoutSeconds: Double + let startTime: Double + let savedAt: Date + } + + public static func directory(environment: [String: String] = ProcessInfo.processInfo.environment) -> String { + if let override = environment["XCFORGE_PLAN_SESSION_DIR"], !override.isEmpty { return override } + return NSHomeDirectory() + "/.xcforge/plan-sessions" + } + + static func path(for id: String, in directory: String) -> String? { + // Session IDs are UUIDs; anything else must not become a path. + guard UUID(uuidString: id) != nil else { return nil } + return (directory as NSString).appendingPathComponent("\(id).json") + } + + /// Save a suspended plan with the raw steps it was parsed from. Returns the session ID. + public static func save(_ plan: SuspendedPlan, steps: [Value], directory: String = directory()) throws -> String { + let id = UUID().uuidString + let saved = Saved( + steps: .array(steps), pauseIndex: plan.pauseIndex, question: plan.question, + completedResults: plan.completedResults, variableBindings: plan.variableBindings, + errorStrategy: plan.errorStrategy.rawValue, timeoutSeconds: plan.timeoutSeconds, + startTime: plan.startTime, savedAt: Date()) + try FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true) + let data = try JSONEncoder().encode(saved) + guard let path = path(for: id, in: directory) else { return id } + try data.write(to: URL(fileURLWithPath: path), options: .atomic) + return id + } + + public enum LoadError: Error, CustomStringConvertible { + case notFound(String) + case expired(String) + case unreadable(String) + + public var description: String { + switch self { + case .notFound(let id): return "Session '\(id)' not found. Re-run the plan." + case .expired(let id): return "Session '\(id)' expired (1-hour limit). Re-run the plan." + case .unreadable(let detail): return "Saved session can't be read: \(detail). Re-run the plan." + } + } + } + + /// Load and delete a saved session, with the raw steps for saving it again if it pauses once + /// more. The time it spent waiting for a decision doesn't count against the plan's timeout. + public static func consume(_ id: String, directory: String = directory(), now: Date = Date()) throws -> Resumed { + guard let path = path(for: id, in: directory), let data = FileManager.default.contents(atPath: path) else { + throw LoadError.notFound(id) + } + try? FileManager.default.removeItem(atPath: path) + let saved: Saved + do { + saved = try JSONDecoder().decode(Saved.self, from: data) + } catch { + throw LoadError.unreadable("\(error)") + } + let waited = now.timeIntervalSince(saved.savedAt) + guard waited < ttl else { throw LoadError.expired(id) } + guard case .array(let steps) = saved.steps else { throw LoadError.unreadable("steps are not an array") } + let parsed: [PlanStep] + do { + parsed = try PlanParser.parse(steps) + } catch { + throw LoadError.unreadable("\(error)") + } + let plan = SuspendedPlan( + steps: parsed, pauseIndex: saved.pauseIndex, question: saved.question, + completedResults: saved.completedResults, variableBindings: saved.variableBindings, + errorStrategy: ErrorStrategy(rawValue: saved.errorStrategy) ?? .abortWithScreenshot, + timeoutSeconds: saved.timeoutSeconds, startTime: saved.startTime + max(waited, 0), screenshotBase64: nil) + return Resumed(plan: plan, steps: steps) + } + + public struct Resumed: Sendable { + public let plan: SuspendedPlan + /// The raw steps, for saving the plan again if it pauses once more. + public let steps: [Value] + } +} diff --git a/Sources/XCForgeKit/Plan/RunEngine.swift b/Sources/XCForgeKit/Plan/RunEngine.swift index ec19368..c288370 100644 --- a/Sources/XCForgeKit/Plan/RunEngine.swift +++ b/Sources/XCForgeKit/Plan/RunEngine.swift @@ -179,7 +179,8 @@ public final class PlanExecutor: Sendable { let rect = try await wdaClient.getElementRect(elementId: eid) let cx = rect.x + rect.width / 2 let cy = rect.y + rect.height / 2 - try await IndigoHIDClient.shared.tap(x: cx, y: cy) + let (sim, orientation) = await hidTarget(wdaClient) + try await IndigoHIDClient.shared.tap(x: cx, y: cy, simulator: sim, orientation: orientation) return .result( .passed, detail: "Clicked \(targetDescription(target)) (indigo)", screenshot: nil) } catch { @@ -200,7 +201,8 @@ public final class PlanExecutor: Sendable { // IndigoHID fast-path if IndigoHIDClient.isAvailable { do { - try await IndigoHIDClient.shared.doubleTap(x: cx, y: cy) + let (sim, orientation) = await hidTarget(wdaClient) + try await IndigoHIDClient.shared.doubleTap(x: cx, y: cy, simulator: sim, orientation: orientation) return .result( .passed, detail: "Double-tapped \(targetDescription(target)) (indigo)", screenshot: nil) @@ -224,7 +226,9 @@ public final class PlanExecutor: Sendable { // IndigoHID fast-path if IndigoHIDClient.isAvailable { do { - try await IndigoHIDClient.shared.longPress(x: cx, y: cy, durationMs: dur) + let (sim, orientation) = await hidTarget(wdaClient) + try await IndigoHIDClient.shared.longPress( + x: cx, y: cy, durationMs: dur, simulator: sim, orientation: orientation) return .result( .passed, detail: "Long-pressed \(targetDescription(target)) (indigo)", screenshot: nil ) @@ -247,8 +251,9 @@ public final class PlanExecutor: Sendable { // IndigoHID fast-path for swipe if IndigoHIDClient.isAvailable { do { + let (sim, orientation) = await hidTarget(wdaClient) try await IndigoHIDClient.shared.swipe( - startX: sx, startY: sy, endX: ex, endY: ey, durationMs: dur + startX: sx, startY: sy, endX: ex, endY: ey, durationMs: dur, simulator: sim, orientation: orientation ) _ = target return .result(.passed, detail: "Swiped \(direction) (indigo)", screenshot: nil) @@ -267,10 +272,10 @@ public final class PlanExecutor: Sendable { let eid = try await variables.resolveTarget(t, wdaClient: wdaClient) try await wdaClient.setValue(elementId: eid, text: text) } else { - // Type into currently focused element - let (eid, _) = try await wdaClient.findElement( - using: "class name", value: "XCUIElementTypeTextField" - ) + // Type into the focused field; there may be several text fields. + guard let eid = try await wdaClient.activeElementId() else { + return .result(.failed, detail: "No field has keyboard focus; give typeText a target", screenshot: nil) + } try await wdaClient.setValue(elementId: eid, text: text) } return .result( @@ -404,6 +409,12 @@ public final class PlanExecutor: Sendable { } } + /// The simulator WebDriverAgent targets and its interface orientation, so a direct HID touch + /// lands on the same screen, the right way up. + private func hidTarget(_ wdaClient: WDAClient) async -> (String, IndigoHIDClient.Orientation) { + (await wdaClient.getTargetSimulator(), await UITools.hidOrientation(wdaClient: wdaClient)) + } + private func swipeCoordinates(direction: String, width: Double, height: Double) -> ( Double, Double, Double, Double ) { diff --git a/Sources/XCForgeKit/ProviderRegistry.swift b/Sources/XCForgeKit/ProviderRegistry.swift index a6730d4..fc28225 100644 --- a/Sources/XCForgeKit/ProviderRegistry.swift +++ b/Sources/XCForgeKit/ProviderRegistry.swift @@ -1,4 +1,6 @@ +import Foundation import MCP +import os /// Central registry of all tools. Each module self-registers via ToolProvider conformance. public enum ToolRegistry { @@ -27,8 +29,20 @@ public enum ToolRegistry { // MARK: - Tool Group Management (runtime-only, not persisted) - /// Groups currently disabled. Access is nonisolated(unsafe) because MCP requests are serial. - nonisolated(unsafe) private static var disabledGroups: Set = [] + /// Groups disabled at start (`XCFORGE_TOOL_GROUPS`, yaml `toolGroups`, else the diagnose group) + /// and problems with that setting. + private static let startup = ToolSurface.disabledGroups(spec: ToolSurface.startupSpec(), allGroups: allGroups) + + /// Groups currently disabled. MCP requests run concurrently, so access goes through a lock. + private static let disabledGroupsLock = OSAllocatedUnfairLock(initialState: startup.disabled) + static var disabledGroups: Set { disabledGroupsLock.withLock { $0 } } + + /// Called after tool_groups changes the list, to send `notifications/tools/list_changed`. + private static let listChangedHandler = OSAllocatedUnfairLock<(@Sendable () async -> Void)?>(initialState: nil) + + public static func onToolListChanged(_ handler: @escaping @Sendable () async -> Void) { + listChangedHandler.withLock { $0 = handler } + } /// All valid group names derived from registered providers. public static var allGroups: [String] { @@ -37,11 +51,21 @@ public enum ToolRegistry { /// Active (non-disabled) providers. private static var activeProviders: [any ToolProvider.Type] { - providers.filter { !disabledGroups.contains($0.group) } + let disabled = disabledGroups + return providers.filter { !disabled.contains($0.group) } } + /// The shared arguments every tool of a kind takes (build options, simulator, waits). + static func augment(_ tool: Tool) -> Tool { + BuildTools.augmentFor(UIWait.augment(UITarget.augment(XcodebuildOptions.augment(tool)))) + } + + /// The tools listed to clients: enabled groups, without deprecated aliases, every argument in + /// camelCase, and read-only tools marked so clients can approve them without asking. public static var allTools: [Tool] { var tools = activeProviders.flatMap { $0.tools } + .filter { ToolSurface.deprecatedAliases[$0.name] == nil } + .map { ToolSurface.annotate(ToolSurface.canonicalize(augment($0))) } // Always include the tool_groups management tool tools.append(toolGroupsTool) assert( @@ -50,21 +74,129 @@ public enum ToolRegistry { return tools } + /// A registered tool with the argument names its implementation reads, enabled or not and + /// including deprecated aliases. + static func declaredTool(_ name: String) -> Tool? { + if name == toolGroupsTool.name { return toolGroupsTool } + for provider in providers { + if let tool = provider.tools.first(where: { $0.name == name }) { return augment(tool) } + } + return nil + } + + /// The group a registered tool belongs to. + static func group(of name: String) -> String? { + providers.first { provider in provider.tools.contains { $0.name == name } }?.group + } + public static func dispatch(_ name: String, _ args: [String: Value]?, env: Environment = .live) async -> CallTool.Result { // Handle built-in tool_groups before checking providers - if name == "tool_groups" { return handleToolGroups(args) } + if name == "tool_groups" { return await handleToolGroups(args) } + + if let group = group(of: name), disabledGroups.contains(group) { + return .fail( + "\(name) is in the \(group) tool group, which is off. Turn it on with tool_groups (enable: [\"\(group)\"]) or XCFORGE_TOOL_GROUPS=+\(group)." + ) + } - for provider in activeProviders { - if let result = await provider.dispatch(name, args, env: env) { - return result + // Either casing is accepted: includeConsole or include_console, derivedDataPath or derived_data_path. + let arguments = declaredTool(name).map { ToolSurface.normalize(args, declared: $0.argumentNames) } ?? args + + // A misspelled argument would otherwise be ignored and the call would quietly use a default. + if let rejection = unknownArgumentsError(name, arguments) { return rejection } + + // Per-call xcodebuild options (derivedDataPath, buildLock, ...) ride in a task-local so + // every xcodebuild call made while serving this tool sees them. + let options = + XcodebuildOptions.mcpToolNames.contains(name) + ? XcodebuildOptions.fromMCPArguments(arguments) : XcodebuildOptions() + await UITarget.select(for: name, args: arguments, env: env) + let result = await XcodebuildOptions.$current.withValue(options) { () async -> CallTool.Result? in + for provider in activeProviders { + if let result = await provider.dispatch(name, arguments, env: env) { + return result + } } + return nil + } + if let result { + let waited = await UIWait.afterAction(result, tool: name, args: arguments, env: env) + return await withNotes(waited, tool: name, env: env) } Log.warn("Unknown tool: \(name)") return .fail("Unknown tool: \(name)") } + /// `.xcforge.yaml` files whose warnings this process already showed. + private static let shownConfigWarnings = OSAllocatedUnfairLock(initialState: Set()) + + /// Add short trailing notes: which project a build or test ran against (so work in a second + /// worktree is visible), and `.xcforge.yaml` problems the first time each file is used. + static func withNotes(_ result: CallTool.Result, tool: String, env: Environment) async -> CallTool.Result { + var notes: [String] = [] + if XcodebuildOptions.mcpToolNames.contains(tool), let project = await env.session.activeProject { + notes.append("project: \(absolutePath(project))") + } + if let deprecation = ToolSurface.deprecationNote(for: tool) { notes.append(deprecation) } + let warnings = await env.session.configWarnings + if !warnings.isEmpty, let path = await env.session.configPath, + shownConfigWarnings.withLock({ $0.insert(path).inserted }) + { + notes += warnings.map { "config warning: \($0)" } + } + guard !notes.isEmpty else { return result } + let note = Tool.Content.text(text: notes.joined(separator: "\n"), annotations: nil, _meta: nil) + return CallTool.Result( + content: result.content + [note], structuredContent: result.structuredContent, + isError: result.isError, _meta: result._meta) + } + + private static func absolutePath(_ path: String) -> String { + let expanded = (path as NSString).expandingTildeInPath + guard !expanded.hasPrefix("/") else { return (expanded as NSString).standardizingPath } + let cwd = FileManager.default.currentDirectoryPath as NSString + return (cwd.appendingPathComponent(expanded) as NSString).standardizingPath + } + + // MARK: - Argument validation + + /// An error naming arguments the tool doesn't declare, with close matches, or nil when all + /// are known. Tools whose schema allows extra properties are not checked. + static func unknownArgumentsError(_ name: String, _ args: [String: Value]?) -> CallTool.Result? { + guard let args, !args.isEmpty, + let tool = declaredTool(name), + case .object(let schema) = tool.inputSchema, + schema["additionalProperties"]?.boolValue != true, + let properties = schema["properties"]?.objectValue + else { return nil } + let known = Array(properties.keys) + let unknown = args.keys.filter { properties[$0] == nil }.sorted() + guard !unknown.isEmpty else { return nil } + var lines: [String] = [] + for key in unknown { + if let close = closestArgument(key, in: known) { + lines.append("Unknown argument '\(key)'; did you mean '\(close)'?") + } else { + lines.append("Unknown argument '\(key)'.") + } + } + let valid = known.isEmpty ? "(no arguments)" : known.map(ToolSurface.camelCase).sorted().joined(separator: ", ") + lines.append("\(name) accepts: \(valid)") + return .fail(lines.joined(separator: "\n")) + } + + /// `derived_data_path` → `derivedDataPath` (same letters ignoring case and underscores), + /// else the nearest name by edit distance. + private static func closestArgument(_ key: String, in known: [String]) -> String? { + let folded = key.replacingOccurrences(of: "_", with: "").lowercased() + if let same = known.first(where: { $0.replacingOccurrences(of: "_", with: "").lowercased() == folded }) { + return same + } + return FuzzyMatch.fuzzyRank(needle: key, candidates: known, maxResults: 1).first?.candidate + } + // MARK: - tool_groups Tool private static let toolGroupsTool = Tool( @@ -98,11 +230,12 @@ public enum ToolRegistry { let disable: [String]? } - private static func handleToolGroups(_ args: [String: Value]?) -> CallTool.Result { + private static func handleToolGroups(_ args: [String: Value]?) async -> CallTool.Result { switch ToolInput.decode(ToolGroupsInput.self, from: args) { case .failure(let err): return err case .success(let input): let validGroups = Set(allGroups) + let before = disabledGroups // Process enable if let toEnable = input.enable { @@ -112,7 +245,7 @@ public enum ToolRegistry { "Unknown group(s): \(unknown.joined(separator: ", ")). Valid: \(allGroups.joined(separator: ", "))" ) } - for g in toEnable { disabledGroups.remove(g) } + disabledGroupsLock.withLock { $0.subtract(toEnable) } } // Process disable (protect session-state group — it provides set_defaults and profile tools) @@ -123,23 +256,30 @@ public enum ToolRegistry { "Unknown group(s): \(unknown.joined(separator: ", ")). Valid: \(allGroups.joined(separator: ", "))" ) } - let protected: Set = ["session-state"] + let protected = ToolSurface.protectedGroups let blocked = toDisable.filter { protected.contains($0) } if !blocked.isEmpty { return .fail( "Cannot disable protected group(s): \(blocked.joined(separator: ", ")). These provide essential session management tools." ) } - for g in toDisable { disabledGroups.insert(g) } + disabledGroupsLock.withLock { $0.formUnion(toDisable) } } // Always return current status var lines = ["Tool groups:"] + let disabled = disabledGroups for group in allGroups { - let status = disabledGroups.contains(group) ? "disabled" : "enabled" - let toolCount = providers.filter { $0.group == group }.flatMap { $0.tools }.count + let status = disabled.contains(group) ? "disabled" : "enabled" + let toolCount = providers.filter { $0.group == group }.flatMap { $0.tools } + .filter { ToolSurface.deprecatedAliases[$0.name] == nil }.count lines.append(" \(group): \(status) (\(toolCount) tools)") } + lines += startup.warnings + if disabled != before, let handler = listChangedHandler.withLock({ $0 }) { + await handler() + lines.append("Clients that follow notifications/tools/list_changed now see the new list.") + } return .ok(lines.joined(separator: "\n")) } } diff --git a/Sources/XCForgeKit/Skill/EmbeddedSkill.swift b/Sources/XCForgeKit/Skill/EmbeddedSkill.swift new file mode 100644 index 0000000..2ea9d40 --- /dev/null +++ b/Sources/XCForgeKit/Skill/EmbeddedSkill.swift @@ -0,0 +1,4266 @@ +// swift-format-ignore-file +// Generated by scripts/embed-skill.py from Skills/xcforge. Do not edit by hand: +// change Skills/xcforge, then run `python3 scripts/embed-skill.py`. + +extension SkillBundle { + static let embeddedFiles: [SkillFile] = [ + SkillFile(path: "SKILL.md", contents: #""" +--- +name: xcforge +description: Complete reference for xcforge — 104 MCP tools (114 with the diagnose group) + 113 CLI commands for iOS development. Covers build, test, simulator, physical devices, SPM, UI automation, screenshots, logs, git, visual regression, accessibility, localization, session profiles, diagnosis workflows, plan execution, LLDB debugger integration, bless workflow, agent-optimized test output, and a CLI covering nearly every tool across 19 command groups. Use when working with iOS simulators, physical devices, Xcode builds, Swift packages, UI testing, TDD workflows, debugging running apps with LLDB, iterating with visual poses, or any xcforge tool. +--- + +# xcforge — iOS Development MCP Server & CLI + +xcforge is a native Swift MCP server and CLI for iOS development. 104 MCP tools (114 with the off-by-default diagnose group), 113 CLI commands across 19 groups, zero runtime dependencies. It provides build, test, simulator management, physical device support via devicectl, Swift package workflows, UI automation via WebDriverAgent with native HID fallback, ultra-fast screenshots, clipboard access, video recording, location simulation, appearance control, status bar overrides, smart log filtering, visual regression, multi-device checks, accessibility/localization layout checks, session profiles, structured diagnosis workflows, multi-step plan execution, visual pose iteration, bless (baseline+test+diff in one call), agent-optimized test output with known-failures gating, and test plan inspection. Nearly every MCP tool has a CLI equivalent; session profiles, clipboard, `multi_device_check` and `tool_groups` are MCP-only. + +## References + +Each reference covers one tool category with exact parameters, return values, and examples. Open the one you need. + +| Reference | Load When | +|-----------|-----------| +| **[Build Tools](references/build-tools.md)** | Building, running, cleaning, discovering projects, listing schemes | +| **[Test Tools](references/test-tools.md)** | Running tests, analyzing failures, checking code coverage, build diagnostics | +| **[Shared Mac](references/shared-mac.md)** | DerivedData/result-bundle paths, extra xcodebuild args, build lock queue, isolated simulators, simulator recovery modes | +| **[Simulator Tools](references/simulator-tools.md)** | Managing simulators — boot, shutdown, install, launch, clone, erase, delete, orientation, video recording, location, appearance, status bar | +| **[Device Tools](references/device-tools.md)** | Physical iOS devices — list, info, install, launch (deep links, env), screenshot, and WebDriverAgent on the device for UI automation | +| **[SPM Tools](references/spm-tools.md)** | Swift packages — build, test, run, list dependencies, clean | +| **[UI Automation](references/ui-automation.md)** | Finding/clicking elements, alerts, typing, gestures, drag & drop, view hierarchy, clipboard, native HID taps/swipes | +| **[Screenshot & Visual](references/screenshot-visual.md)** | Taking screenshots, saving baselines, comparing visual regressions, multi-device checks, bless workflow | +| **[Accessibility & Localization](references/cli-commands.md#xcforge-accessibility)** | Dynamic Type size checks, localization layout checks, RTL rendering validation | +| **[Log & Console](references/log-console.md)** | Capturing logs, topic filtering, waiting for patterns, stdout/stderr capture | +| **[Git Tools](references/git-tools.md)** | Git status, diff, log, commit, branch operations | +| **[Diagnosis Workflows](references/diagnosis-workflows.md)** | Running structured diagnosis: start, build, test, runtime, status, evidence, inspect, verify, compare, result | +| **[Plan Execution](references/plan-execution.md)** | Multi-step UI automation plans: run_plan, run_plan_decide, step types, variable binding, verification, suspend/resume | +| **[Tool Surface](references/tool-surface.md)** | Tool groups (`XCFORGE_TOOL_GROUPS`), merged tools and their old names, argument casing, JSON results | +| **[Auto-Detection & Defaults](references/auto-detection.md)** | Understanding parameter resolution, setting defaults, session profiles | +| **[LLDB Debugger](references/lldb-debugger.md)** | Attach LLDB to running simulator processes — breakpoints, variable inspection, stack traces, step execution, arbitrary commands; 8 MCP tools + `xcforge debug` CLI | +| **[Pose & Visual Iteration](references/pose.md)** | Build → install → launch + screenshot with named poses for visual design iteration. Supports app-side argument routing and pixel-coordinate tapping for layout work. | +| **[CLI Commands](references/cli-commands.md)** | Using xcforge from terminal: `build`, `build-test`, `test`, `sim`, `device`, `spm`, `log`, `console`, `screenshot`, `ui`, `git`, `accessibility`, `defaults`, `diagnose`, `plan`, `pose`, `debug` | + +## Auto-Detection — How Parameters Resolve + +All tools that accept `project`, `scheme`, `simulator`, `bundle_id`, or `app_path` follow this resolution order: + +1. **Explicit parameter** — highest priority +2. **In-session value** — set this session via `set_defaults`/`profile_switch`, auto-promoted, or already resolved +3. **Repo config** — `.xcforge.yaml` at the repo root (committed team config; `xcforge init` scaffolds it) +4. **Persisted defaults** — machine-global `~/.xcforge/defaults.json` +5. **Auto-detect** — scans working directory for .xcodeproj/.xcworkspace, queries xcodebuild for schemes, finds booted simulator +6. **Error with options** — lists available choices if detection fails + +**Repo config beats persisted defaults** (committed `.xcforge.yaml` outranks the personal machine-global file — like git `local` > `global`). `.xcforge.yaml` also supports repo-only `configuration` and `testPlan` keys. + +**Auto-promotion** (opt-in with `.xcforge.yaml autoPromote: true`): 3 consecutive calls with the same explicit value save it as a session default. + +Most tools work with zero parameters for single-project repos with one booted simulator. + +## Common Workflows + +### Build & Launch (Cmd+R equivalent) +``` +build_run_sim() → builds + boots + installs + launches in parallel (~9s faster) +``` + +### Test → Fix → Verify (MCP) +``` +build_and_test() → build + test in one call +build_and_test(filter: "testFoo") → build + run specific test (auto-resolves target) +test_failures(include_console: true) → error messages + file:line + console +# fix the code... +build_and_test(filter: "testFoo") → verify fix +``` + +### Discover Tests → Run Specific Test (MCP) +``` +list_tests() → enumerate Target/Class/method identifiers +test_sim(filter: "MyClass/testFoo") → run specific test (auto-resolves target) +``` + +### TDD Loop (CLI) +```bash +xcforge build run → Cmd+R equivalent: build + boot + install + launch +xcforge build-test → build + test in one step +xcforge build-test --filter "testFoo" → build + run specific test +xcforge build-test --env BLESS_BASELINE=1 → inject TEST_RUNNER_BLESS_BASELINE=1 into test process +xcforge build-test --for agent → slim ≤10-field JSON (agent-safe output) +xcforge build-test --gate → subtract .xcforge/known-failures.yaml from pass/fail +xcforge test list → list all test identifiers +xcforge test --filter "MyTests/testFoo" → run specific test (auto-resolves target) +xcforge test rerun-failed → replay only failures from last run (.xcforge/last-failures.json) +xcforge test plan inspect --plan AllTests → inspect .xctestplan configurations + targets +xcforge test failures --include-console → drill into failures +xcforge test coverage --file Foo.swift → per-function coverage +xcforge build diagnose → structured diagnostics from last build's xcresult +``` + +### Bless Workflow (baseline + tests + diff in one call) +```bash +xcforge bless --baseline login-screen --tests "UITests/LoginTests" +# MCP: +bless(baseline: "login-screen", tests: "UITests/LoginTests") +``` + +### UI Automation Flow +``` +build_run_sim() → app running in simulator +handle_alert(action: "accept_all") → dismiss permission dialogs +find_element(using: "accessibility id", value: "Save", scroll: true) +tap(elementId: "...") → or tap(id: "Save") in one call +screenshot() → verify result +``` + +### Multi-Step UI Plan (1 call replaces 15+ round-trips) +``` +run_plan(steps: [ + {"find": "Login", "as": "$loginBtn"}, + {"click": "$loginBtn"}, + {"waitFor": "Welcome", "timeout": 5}, + {"verify": {"screenContains": "Dashboard"}}, + {"screenshot": "after_login"} +]) +# If a judge step suspends: +run_plan_decide(session_id: "...", decision: "accept") +``` + +### Multi-Step UI Plan (CLI) +```bash +xcforge plan run --file login-flow.json → execute plan from file +xcforge plan run --file login-flow.json --json → structured JSON output +xcforge plan decide --session-id UUID --decision skip → resume suspended plan +``` + +### Debug with Logs +``` +start_log_capture(mode: "smart") +# reproduce the issue... +read_logs() → app + crashes + topic menu +read_logs(include: ["network"]) → add network topic +wait_for_log(pattern: "error.*timeout", timeout: 10) +``` + +### Visual Regression +``` +screenshot() → save_visual_baseline(name: "login-screen") +# make changes... +compare_visual(name: "login-screen") → pixel diff + match % +``` + +### Multi-Device Check +``` +multi_device_check( + app_path: "/path/to/App.app", + simulators: "iPhone 16,iPad Pro 13-inch (M4)", + dark_mode: true, landscape: true +) +``` + +### Video Recording +``` +record_video_start() → starts .mov recording +# perform actions... +record_video_stop() → returns file path +``` + +### Screenshot Prep (Clean Status Bar) +``` +sim_statusbar(time: "9:41", battery_level: 100, battery_state: "charged", cellular_bars: 4, wifi_bars: 3) +set_sim_appearance(appearance: "light") +screenshot() +sim_statusbar_clear() +``` + +### Location Testing +``` +set_sim_location(latitude: 37.7749, longitude: -122.4194) → San Francisco +# test location features... +reset_sim_location() +``` + +### Session Profiles (MCP) +``` +set_defaults(project: "MyApp.xcodeproj", scheme: "MyApp", simulator: "iPhone 16 Pro") +profile_save(name: "iphone-debug") +set_defaults(simulator: "iPad Pro 13-inch (M4)") +profile_save(name: "ipad-debug") +profile_switch(name: "iphone-debug") → switch context instantly +profile_list() → see all saved profiles +``` + +### Physical Device Workflow +``` +list_devices() → connected devices +device_info(device: "iPhone") → detailed info +device_install(device: "iPhone", app_path: "/path/to/App.app") +device_launch(device: "iPhone", bundle_id: "com.app.id") +device_apps(device: "iPhone") → list installed apps +device_terminate(device: "iPhone", identifier: "com.app.id") +screenshot(device: "iPhone") +wda_start(device: "iPhone", team: "ABCDE12345") → UI tools now drive the phone +``` + +### Swift Package Workflow (MCP) +``` +swift_package_build() → build in current directory +swift_package_test(filter: "MyTests") → run specific tests +swift_package_run(executable: "mytool") → run an executable target +swift_package_list() → show dependency tree +swift_package_clean() → clean build artifacts +``` + +### Swift Package Workflow (CLI) +```bash +xcforge spm build → build package +xcforge spm test --filter "MyTests" → run filtered tests +xcforge spm run mytool -- --verbose → run with args +xcforge spm list → dependency tree +xcforge spm clean → clean artifacts +``` + +### LLDB Debugging (MCP — session-based) +``` +lldb_attach(bundleId: "com.example.App") → { sessionId, pid, status: "stopped" } +lldb_set_breakpoint(sessionId: "...", file: "Foo.swift", line: 42) +lldb_continue(sessionId: "...", mode: "continue") → runs until breakpoint +lldb_backtrace(sessionId: "...", threadIndex: 0) → structured frames +lldb_inspect_variable(sessionId: "...", expression: "self.count") +lldb_run_command(sessionId: "...", command: "thread list") +lldb_detach(sessionId: "...") +``` + +### LLDB Debugging (CLI — one-shot) +```bash +xcforge debug attach --bundle-id com.example.App → prints sessionId +xcforge debug backtrace --bundle-id com.example.App +xcforge debug inspect --bundle-id com.example.App --expression "self.count" +xcforge debug breakpoint set --bundle-id com.example.App --file Foo.swift --line 42 +xcforge debug continue --bundle-id com.example.App --mode step-over +xcforge debug run --bundle-id com.example.App --command "thread list" +``` + +### Clipboard Access +``` +clipboard_set(text: "test data") → write to pasteboard +clipboard_get() → read pasteboard content +``` + +## Pitfalls + +1. **Starting log capture after the event** — `start_log_capture` only captures from the moment it's called. Start capture BEFORE reproducing the issue. + +2. **Permission dialogs after a fresh install** — the first launch can show several. `handle_alert(action: "accept_all")` clears them in one call. + +3. **Manual swipe loops to find elements** — Use `find_element(scroll: true)` instead. It handles auto-scrolling with 3-tier fallback (scrollToVisible, calculated drag, iterative with stall detection). + +4. **Using `get_source` for every element lookup** — `find_element` is faster and more reliable. Use `get_source` only when you need the full hierarchy for analysis. + +5. **Ignoring topic menu in `read_logs`** — The topic menu shows line counts. Use `include` to add specific topics rather than reading everything. + +6. **Using `test_sim` then parsing output for failures** — Use `test_failures` for detailed failure info with file:line and screenshots. `test_sim` gives the summary; `test_failures` gives the details. + +7. **WebDriverAgent** — UI automation tools start WDA on a simulator themselves on first use. On a physical device run `wda_start` (CLI `xcforge wda start`) first; the device must be unlocked with Settings > Developer > Enable UI Automation on. + + **Sub-pitfall: WDA can't see SwiftUI sheets / alerts / `fullScreenCover`.** Those mount in a secondary window owned by the app. Queries reach them only when the WDA session is bound to the app's bundle id. Run `xcforge ui session --bundle-id ` once — `WDAClient` persists the binding across recreates from then on, and the CLI verifies the binding via `GET /session/`, exiting non-zero on mismatch (no more silent no-binds). If `ui find` / `tap-by-id` 404s on an id you can see in `ui ls`, this is almost always why. + + **Sub-pitfall: `xcforge ui ls` returning 300+ macOS menubar elements.** That's AXP returning Simulator.app's chrome instead of the iOS app tree. `ui ls --source auto` (default) flips to WDA when an iOS sim is booted — pre-1.4.1 it always preferred AXP. If you still get the wrong tree, force it: `--source wda`. + + **Sub-pitfall: `xcforge pose --screenshot` capturing the launch zoom animation.** `pose` now waits 1.5s by default before capturing so the iOS launch zoom can settle. Override with `--screenshot-delay ` (use `0` for legacy immediate-capture). + +8. **Status bar overrides persist** — `sim_statusbar` overrides stay until `sim_statusbar_clear()`. + +9. **Simulator vs device tools** — simulator tools (`sim`, `build_sim`, etc.) don't target physical devices. Use the `device_*` tools and `wda_start` for devices connected over USB or Wi-Fi. + +10. **Using `build_sim` for Swift packages** — Swift packages don't have .xcodeproj files. Use `swift_package_build` / `swift_package_test` instead. + +"""#), + SkillFile(path: "references/auto-detection.md", contents: #""" +# Auto-Detection, Session Defaults & Profiles + +## Parameter Resolution Order + +All tools that accept `project`, `scheme`, or `simulator` resolve values in this order: + +1. **Explicit parameter** — value passed directly in the tool call (highest priority) +2. **In-session value** — set this session via `set_defaults`/`profile_switch`, auto-promoted (3× rule, only with `autoPromote: true`), or already resolved earlier this session +3. **Repo config** — `.xcforge.yaml` at the repo root (committed team config; see below) +4. **Persisted defaults (per active project)** — the active project's record in `~/.xcforge/defaults.json` +5. **Auto-detect** — runtime detection (see below) +6. **Error with options** — if auto-detect fails, returns available choices + +> **Repo config beats persisted defaults.** The committed `.xcforge.yaml` is the +> team's source of truth for *this* repo, so it outranks the personal +> machine-global `~/.xcforge/defaults.json` — the same model as git +> (`local` config beats `global`). `set_defaults`/`profile_switch` still take +> effect *within the running session* (step 2), but no longer silently stick +> across restarts when the repo file specifies the same field. `set_defaults +> action: show` labels each value's source (`repo-config` vs `persisted`). + +> **There is no global "persisted project" fallback.** Project identity is +> resolved from step 1, 3, or 5 — never from `defaults.json`. The persisted +> store is keyed by canonical project path (`version: 2` envelope), so once a +> project is identified, *that* project's record supplies its scheme/simulator +> fallback and remembered build products. Each project on the machine has its +> own isolated record; building App A no longer leaks its `bundleId`/`appPath` +> into a later session for App B. + +> **`bundle_id` and `app_path` are not repo-config keys.** They resolve via +> explicit parameter → the cache from the last successful `build_sim`/ +> `build_run_sim` for the **active project's record only**. They are never +> read from `.xcforge.yaml`, never from another project's record, and +> `.xcforge.yaml` has no key for them. + +## Repo-Level Config (`.xcforge.yaml`) + +A committed, repo-scoped config file discovered by walking up from the working +directory to the `.git` boundary. Optional — its absence changes nothing. + +Flat `key: value` syntax only (no YAML library — no nesting, no quoting). Lines +starting with `#` are comments. Unknown keys are warned and ignored; a malformed +file is warned and skipped (never crashes). + +| Key | Effect | Persisted? | +|-----|--------|------------| +| `project` | Default `.xcodeproj`/`.xcworkspace`. Relative paths resolve from the file's directory. | shared with defaults model | +| `scheme` | Default scheme. | shared with defaults model | +| `simulator` | Default simulator name or UDID. | shared with defaults model | +| `configuration` | Build configuration for `build_sim`/`test_sim`/`build_and_test`/`build_and_diagnose` when no `--configuration`/`configuration` arg is given. Default `Debug`. | **repo-only — never written to `defaults.json` or profiles** | +| `testPlan` | Default `.xctestplan` for `test_sim`/`build_and_test` when no `--testplan`/`testplan` arg is given. | **repo-only — never written to `defaults.json` or profiles** | +| `testTimeout` | Per-project default test timeout, positive integer seconds. Precedence: explicit `timeoutSeconds` > `testTimeout` > `--long` (7200s) > 1800s default. Non-positive values rejected with warning. | **repo-only** | +| `autoPromote` | `true` turns on 3-rep auto-promotion of explicit values to session defaults. Off (`false`) by default. | **repo-only** | +| `toolGroups` | Which MCP tool groups the server lists at start, e.g. `+diagnose` or `build,test,ui`. See [Tool surface](tool-surface.md). `XCFORGE_TOOL_GROUPS` wins over it. | **repo-only** | + +The shared-Mac keys (`derivedDataPath`, `buildLock`, `jobs`, ...) are listed in [Shared Mac](shared-mac.md); `packagePath` in [SPM Tools](spm-tools.md). + +`configuration`, `testPlan`, `testTimeout`, and `autoPromote` are repo-only by +design and never flow into the machine-global persisted JSON or named profiles. + +### `xcforge init` (CLI) + +Scaffold a documented `.xcforge.yaml` at the repo root: + +```bash +xcforge init # write commented .xcforge.yaml with detected values +xcforge init --force # overwrite an existing file +``` + +- Writes at the git repo root (CWD if there is no `.git`). +- Pre-fills detected `project`/`scheme`/`simulator`; undetected keys are emitted + as commented placeholders so the file is valid as written. +- Refuses to overwrite an existing `.xcforge.yaml` unless `--force` (prints the + existing path and exits non-zero). +- CLI-only — there is no MCP `init` tool. + +## Auto-Detection Logic + +### project +- Scans working directory for `.xcodeproj` and `.xcworkspace` files +- If exactly one found, uses it automatically +- If multiple found, returns the list and asks for selection +- Prefers `.xcworkspace` over `.xcodeproj` when both exist (CocoaPods, SPM workspace). The `project.xcworkspace` inside every `.xcodeproj` doesn't count + +### scheme +- Queries `xcodebuild -list` for the resolved project +- If exactly one scheme, uses it +- If several, uses the one named after the project, else the only one that isn't a test or `Pods-` scheme +- Otherwise returns the list + +### simulator +- Finds currently booted simulator via `simctl list devices` +- If exactly one booted, uses it +- If none booted, returns available simulators +- If multiple booted, uses the first one + +### bundle_id +- Cached from the last successful `build_sim` or `build_run_sim` call +- Parsed from the build output (Info.plist of the built .app) + +### app_path +- Cached from the last successful `build_sim` or `build_run_sim` call +- Points to the .app bundle in DerivedData + +## Auto-Promotion + +Off by default. With `autoPromote: true` in `.xcforge.yaml`, passing the same explicit value **3 consecutive times** for a parameter saves it as a session default, so later calls can omit it. + +Example: with `autoPromote: true`, calling `build_sim(scheme: "MyApp")` 3 times in a row saves "MyApp" as the default scheme. `defaults show` notes when a value was auto-promoted. Auto-promotion is also no longer sticky after the fact: `set_defaults` resets the matching field's streak so an old explicit value can't ambush a later run after you've overridden it. + +## set_defaults (MCP tool) + +Manage session defaults. Supports set, show, and clear actions. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `action` | No | `set` | Action: `set` (update defaults), `show` (display current), `clear` (remove all) | +| `project` | No | — | Default project path (used with `set` action) | +| `scheme` | No | — | Default scheme name (used with `set` action) | +| `simulator` | No | — | Default simulator name or UDID (used with `set` action) | + +All parameters are optional — set only what you want to change. + +**Persistence:** Defaults survive across MCP sessions in a `version: 2` envelope at `~/.xcforge/defaults.json` keyed by canonical project path — each project has its own isolated record so build products from App A can't leak into a session for App B. `set_defaults` writes into the **active project's record only**; if no project is resolved yet, it logs a warning and applies in-memory only. The store is machine-global and ranked *below* a repo's committed `.xcforge.yaml` — for repo-scoped team defaults, prefer `xcforge init` and `.xcforge.yaml`. + +Old flat files (single record, no `version` field) auto-migrate on first read when they carry a `project:` field; unrecognized or forward-version (`v3+`) files are backed up to `defaults.json.unrecognized-` instead of being overwritten. + +**Best practice:** Call `set_defaults` at the start of a session to avoid repeating parameters: +``` +set_defaults(project: "MyApp.xcodeproj", scheme: "MyApp", simulator: "iPhone 16 Pro") +build_sim() # uses defaults +test_sim() # uses defaults +screenshot() # uses defaults +``` + +## Session Profiles (4 MCP tools) + +Save and switch between named sets of defaults. Useful when working across multiple targets or device configurations. + +### profile_save + +Save current session defaults as a named profile. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `name` | **Yes** | — | Profile name (kebab-case, max 32 chars, e.g., `"iphone-debug"`) | + +**Validation:** Names must be lowercase alphanumeric with hyphens only, cannot start or end with hyphens. + +### profile_switch + +Switch session defaults to a previously saved profile. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `name` | **Yes** | — | Profile name to activate | + +**Returns:** Confirmation with profile contents, or error listing available profiles. + +### profile_list + +List all saved session profiles. No parameters. + +### profile_delete + +Delete a saved session profile. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `name` | **Yes** | — | Profile name to delete | + +### Profile Workflow + +``` +set_defaults(project: "MyApp.xcodeproj", scheme: "MyApp", simulator: "iPhone 16 Pro") +profile_save(name: "iphone-debug") + +set_defaults(simulator: "iPad Pro 13-inch (M4)") +profile_save(name: "ipad-debug") + +profile_switch(name: "iphone-debug") # instant context switch +profile_list() # see all saved profiles +``` + +## xcforge defaults (CLI) + +Same functionality from the terminal: + +```bash +xcforge defaults show # Active project's record + repo-config keys +xcforge defaults set --scheme MyApp # Set the active project's default scheme +xcforge defaults clear # Clear ONLY the active project's record +xcforge defaults clear --all # Wipe every project's record on this machine +``` + +`set` finds the project first (as a build would) and exits non-zero without writing when there is none: pass `--project`. A saved simulator that no longer exists is ignored with a warning and auto-detection is used instead. + +`clear` (no `--all`) auto-resolves the active project before clearing; if no project can be detected from cwd, it reports that and exits cleanly without touching disk. + +See [CLI Commands](cli-commands.md) for full details. + +**Note:** Session profiles (save/switch/list/delete) are MCP-only — there are no CLI equivalents. + +"""#), + SkillFile(path: "references/build-tools.md", contents: #""" +# Build Tools (8 tools) + +> **Defaults note:** the `Default` column shows the *fallback*. `project`, +> `scheme`, `simulator`, and `configuration` are resolved through the parameter +> precedence chain — a committed `.xcforge.yaml` (`configuration:` / +> `scheme:` / …) overrides the listed fallback when the parameter is omitted. +> See [auto-detection.md](auto-detection.md). + +## build_compile + +Compile-only build: no boot, install or launch, and no extra build-settings lookup. Needs no booted simulator: without one configured or booted it compiles for the newest available iPhone simulator. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | +| `scheme` | No | Auto-detect | Scheme name | +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID (for SDK selection) | +| `configuration` | No | Debug | Build configuration (Debug/Release) | +| `long` | No | false | Raise the total time limit from 1800s to 7200s | +| `fromSnapshot` | No | false | Build a snapshot of the working tree (git worktree under `~/.xcforge/snapshots`), unaffected by edits made during the build. Errors name the real files | +| `jobs` | No | — | `-jobs N` for compiling. Also `XCFORGE_JOBS` and yaml `jobs` | +| `for` | No | `agent` | `agent` returns compact JSON (`ok`, `summary`, `errors` with full paths, `warnings`, `xcresult`, ...); `human` returns the text report. Also on `build_sim` and `build_typecheck` | +| `allErrors` | No | false | Build in the diagnostic DerivedData slot (`diagnosticDerivedDataPath`) and report every error, leaving the main cache alone | + +**Returns:** Bundle ID, app path, build duration, warnings count. On failure: structured errors with file:line from xcresult. + +**Use case:** Rapid iteration on code without deployment latency. Does not boot simulator or install app. + +**Build flags applied:** `parallelizeTargets`, `COMPILATION_CACHE_ENABLE_CACHING=YES` for speed. + +--- + +## build_typecheck + +Compile one target for the simulator: its own scheme when it has one (sharing the workspace's DerivedData), else `-project

-target ` in the project that defines it. Seconds instead of a full scheme build when checking one changed file. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `target` | Yes | — | Target or package product, e.g. `ShutterCoachShared` | +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | +| `simulator` | No | Booted, else newest iPhone | Simulator for SDK selection | +| `configuration` | No | Debug | Build configuration | +| `fromSnapshot` | No | false | As on `build_compile` | + +An unknown target fails with the schemes and targets that exist. + +--- + +## lsp_setup + +Run `xcode-build-server config` for the project and scheme, then set `build_root` in `buildServer.json` to the DerivedData folder xcforge builds into (`derivedDataPath` when configured, else the scheme's own). Needs `brew install xcode-build-server`. Build the scheme once afterwards so the index exists, then restart the language server. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | +| `scheme` | No | Auto-detect | Scheme to index | + +--- + +## build_sim + +Build for iOS Simulator. Returns structured errors from xcresult (not raw xcodebuild stderr). Caches bundle ID and app path for subsequent tools. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | +| `scheme` | No | Auto-detect | Scheme name | +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `configuration` | No | Debug | Build configuration (Debug/Release) | + +**Returns:** Bundle ID, app path, build duration, warnings count. On failure: structured errors with file:line from xcresult. + +**Build flags applied:** `parallelizeTargets`, `COMPILATION_CACHE_ENABLE_CACHING=YES` for speed. + +--- + +## build_run_sim + +Build, boot, install and launch in one call (Xcode's Cmd+R). The simulator boots once the build succeeds; the app installed is the scheme's application target (from `-showBuildSettings -json`), not an extension or framework. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | +| `scheme` | No | Auto-detect | Scheme name | +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `configuration` | No | Debug | Build configuration | +| `args` | No | — | Launch arguments for the app | +| `env` | No | — | Environment for the app, `KEY=VALUE` strings | +| `url` | No | — | URL or deep link to open once the app is running | + +**Returns:** Bundle ID, app path, PID, timings. On failure: structured build errors, or, when the app dies within 8s of launch, `App running: false` with the exception, reason, top frames of the crashed thread and the crash report path. + +--- + +## clean + +Clean the scheme's simulator build products for a configuration. `derivedData: true` also deletes this project's DerivedData folder, found from its `BUILD_ROOT` (never the whole DerivedData directory); that is the fix for "database is locked" or a stale index. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | +| `scheme` | No | Auto-detect | Scheme name | +| `configuration` | No | Debug | Build configuration | +| `derivedData` | No | false | Also delete this project's DerivedData folder | + +--- + +## discover_projects + +Find .xcodeproj and .xcworkspace files in a directory tree. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `path` | **Yes** | — | Directory to search | + +**Returns:** List of project/workspace paths found. + +--- + +## list_schemes + +List available schemes for a project. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | + +**Returns:** Array of scheme names. + +"""#), + SkillFile(path: "references/cli-commands.md", contents: #""" +# xcforge CLI Commands + +xcforge operates in two modes: +- **No arguments** → MCP server mode (stdio transport, 106 tools) +- **With arguments** → CLI mode (ArgumentParser-based terminal commands) + +## Mode Detection + +```bash +xcforge # MCP server mode +xcforge build ... # CLI mode — 19 command groups +xcforge test ... # CLI mode +xcforge sim ... # CLI mode +xcforge device ... # CLI mode +xcforge spm ... # CLI mode +xcforge log ... # CLI mode +xcforge console ... # CLI mode +xcforge screenshot ... # CLI mode +xcforge ui ... # CLI mode +xcforge git ... # CLI mode +xcforge accessibility ... # CLI mode +xcforge defaults ... # CLI mode +xcforge diagnose ... # CLI mode +xcforge plan ... # CLI mode +xcforge pose ... # CLI mode +xcforge bless ... # CLI mode +xcforge debug ... # CLI mode +xcforge --version # Version (the MCP server reports the same) +``` + +With `--json` (or when stdout isn't a terminal), errors are a JSON object `{"error": ..., "code": ...}` on stdout, like results. + +--- + +## xcforge build + +Build, clean, and inspect Xcode projects. Six subcommands — `run` is the default. + +### build run (default subcommand) + +Build, install, and launch an iOS app on a simulator. Chains the full pipeline: build → boot sim → install → launch. This is the **Cmd+R equivalent**. With `--diagnose`, runs build-only with structured diagnostics (skips the pipeline). + +```bash +xcforge build # Same as `xcforge build run` +xcforge build --scheme MyApp --simulator "iPhone 16 Pro" +xcforge build --configuration Release +xcforge build --diagnose # Build-only with structured diagnostics +xcforge build --json # Machine-readable JSON output (includes pipeline phase statuses) +``` + +| Flag | Description | +|------|-------------| +| `--project ` | Path to .xcodeproj or .xcworkspace. Auto-detected if omitted | +| `--scheme ` | Xcode scheme name. Auto-detected if omitted | +| `--simulator ` | Simulator name or UDID. Auto-detected from booted simulator | +| `--configuration ` | Build configuration (Debug/Release). Default: Debug | +| `--diagnose` | Build-only with structured diagnostics (skips boot/install/launch) | +| `--arg ` | Launch argument for the app (repeatable) | +| `--env KEY=VALUE` | Environment variable for the app (repeatable) | +| `--url ` | URL or deep link to open once the app is running | +| `--json` | Machine-readable JSON output | + +**Pipeline behavior:** On build success, boots the simulator (if not already booted) and waits for it, installs the scheme's application target, launches it, and watches it for 8s (`XCFORGE_LAUNCH_WATCH_SECONDS`). A crash at launch fails the command with the crash reason and frames. On build failure, stops immediately with build errors. Persists `bundleId` and `appPath` to `defaults.json` so subsequent `sim install` / `sim launch` calls auto-detect across process boundaries. + +**JSON output:** With `--json`, emits a `BuildRunResult` with `build`, `boot`, `install`, `launch` phase statuses plus `appPid` and `appRunning` fields. + +### build diagnose + +Show structured diagnostics from the last build's xcresult bundle. Useful for inspecting errors and warnings after a build without re-building. + +```bash +xcforge build diagnose # Auto-detect most recent xcresult +xcforge build diagnose --xcresult /path/to/result.xcresult +xcforge build diagnose --errors-only # Suppress warnings +xcforge build diagnose --json +``` + +| Flag | Description | +|------|-------------| +| `--xcresult ` | Path to .xcresult bundle. Auto-detected from /tmp if omitted | +| `--errors-only` | Show only errors, suppressing warnings | +| `--json` | Machine-readable JSON output | + +### build compile + +Fast compile-only build without simulator boot/install/launch. Reuses the standard build infrastructure. + +```bash +xcforge build compile # Compile-only, auto-detect everything +xcforge build compile --project MyApp.xcodeproj --scheme MyApp +xcforge build compile --configuration Release +xcforge build compile --long # 7200s total limit instead of 1800s +xcforge build compile --json +xcforge build compile --from-snapshot # Build a snapshot; edits made meanwhile don't matter +xcforge build compile --all-errors --jobs 4 # Every error, in the diagnostic DerivedData slot +``` + +| Flag | Description | +|------|-------------| +| `--project ` | Path to .xcodeproj or .xcworkspace. Auto-detected if omitted | +| `--scheme ` | Xcode scheme name. Auto-detected if omitted | +| `--simulator ` | Simulator name or UDID (for SDK selection). Auto-detected if omitted | +| `--configuration ` | Build configuration (Debug/Release). Default: Debug | +| `--long` | Raise the total time limit from 1800s to 7200s | +| `--json` | Machine-readable JSON output | + +**Use case:** Rapid code iteration without deployment latency. Does not boot simulator or install app. + +### build typecheck + +Compile one target (its scheme, else `-target`), reusing the workspace's DerivedData. A `-target` build writes into the configured `derivedDataPath` (or the scheme's DerivedData) through SYMROOT/OBJROOT, since xcodebuild rejects `-derivedDataPath` with `-target`. + +```bash +xcforge build typecheck ShutterCoachShared +xcforge build typecheck Widget --from-snapshot +``` + +### build clean + +Clean Xcode build artifacts for a project/scheme. + +```bash +xcforge build clean +xcforge build clean --project MyApp.xcodeproj --scheme MyApp +xcforge build clean --derived-data # Also delete this project's DerivedData folder +xcforge build clean --json +``` + +### build discover + +Find .xcodeproj and .xcworkspace files in a directory. + +```bash +xcforge build discover # Search current directory +xcforge build discover --path /path/to/projects +xcforge build discover --json +``` + +### build schemes + +List available schemes for a project. + +```bash +xcforge build schemes +xcforge build schemes --project MyApp.xcodeproj +xcforge build schemes --json +``` + +**Exit code:** 0 on success, 1 on failure. + +--- + +## xcforge test + +Run tests, inspect failures, and report coverage. Three subcommands — `run` is the default. + +### test run (default subcommand) + +Run xcodebuild test on simulator. Same logic as the `test_sim` MCP tool. + +```bash +xcforge test # Same as `xcforge test run` +xcforge test run # Auto-detect everything +xcforge test --scheme MyApp --filter "MyTests/testLogin" +xcforge test --testplan AllTests +xcforge test --coverage # Enable code coverage collection +xcforge test --for agent # Slim ≤10-field JSON output for agents +xcforge test --gate # Subtract known-failures.yaml from pass/fail +xcforge test --json # Machine-readable JSON output +``` + +| Flag | Description | +|------|-------------| +| `--project ` | Path to .xcodeproj or .xcworkspace. Auto-detected if omitted | +| `--scheme ` | Xcode scheme name. Auto-detected if omitted | +| `--simulator ` | Simulator name or UDID. Auto-detected from booted simulator | +| `--configuration ` | Build configuration (Debug/Release). Default: Debug | +| `--testplan ` | Test plan name | +| `--filter ` | Test filter — accepts `testMethod`, `Class/testMethod`, or `Target/Class/testMethod` (target auto-resolved). Typos surface "Did you mean: ...?" suggestions | +| `--coverage` | Enable code coverage collection | +| `--for ` | Output audience: `human` (default) or `agent`. `agent` implies `--json` with a slim shape | +| `--gate` | Subtract IDs in `.xcforge/known-failures.yaml` from `succeeded` count | +| `--json` | Machine-readable JSON output | + +**Output:** Pass/fail/skip/expected-failure counts, elapsed time, device info, failure summaries with test names, screenshot paths. xcresult path for follow-up commands. + +**Exit code:** 0 when all tests pass, 1 when any test fails. + +### test rerun-failed + +Rerun only the tests that failed in the most recent run. Reads failure IDs from `.xcforge/last-failures.json` (written automatically by `test run` and `build-test`). + +```bash +xcforge test rerun-failed +xcforge test rerun-failed --scheme MyApp --simulator "iPhone 16 Pro" +xcforge test rerun-failed --for agent --gate +xcforge test rerun-failed --json +``` + +| Flag | Description | +|------|-------------| +| `--project ` | Auto-detected if omitted | +| `--scheme ` | Auto-detected if omitted | +| `--simulator ` | Auto-detected if omitted | +| `--configuration ` | Build configuration. Default: Debug | +| `--testplan ` | Test plan name | +| `--long` | Raise the total time limit from 1800s to 7200s | +| `--for ` | `human` (default) or `agent` | +| `--gate` | Apply known-failures gate to the rerun result | +| `--json` | Machine-readable JSON output | + +**Exit code:** 2 if no prior failures are recorded. 0 if rerun passes, 1 if any fail. + +### test plan inspect + +Parse and summarize a `.xctestplan` file without running tests. + +```bash +xcforge test plan inspect --plan AllTests +xcforge test plan inspect --plan AllTests --project MyApp.xcodeproj +xcforge test plan inspect --plan AllTests --json +``` + +| Flag | Description | +|------|-------------| +| `--plan ` | **Required** — test plan name, with or without `.xctestplan` extension | +| `--project ` | Auto-detected if omitted | +| `--json` | Machine-readable JSON output | + +**Output:** Test plan name, version, default options, configurations, test targets with parallelizable flag and skipped-test counts. + +### test failures + +Extract failed tests with error messages, console output, and screenshots. Same logic as the `test_failures` MCP tool. Provide `--xcresult-path` to analyze an existing result, or omit to run tests first. + +```bash +xcforge test failures --xcresult-path /tmp/xcf-test-1234.xcresult +xcforge test failures --include-console # Include print/NSLog output per failed test +xcforge test failures --json +``` + +| Flag | Description | +|------|-------------| +| `--xcresult-path ` | Path to existing .xcresult bundle. Skips running tests | +| `--project ` | Auto-detected if omitted | +| `--scheme ` | Auto-detected if omitted | +| `--simulator ` | Auto-detected if omitted | +| `--include-console` | Include console output (print/NSLog) for each failed test | +| `--json` | Machine-readable JSON output | + +**Output:** Per-failure: test name, identifier, error message, screenshot path, console output. Summary screenshot list. + +**Exit code:** 0 when no failures, 1 when failures exist. + +### test coverage + +Show code coverage report. Without `--file`: per-file overview. With `--file`: per-function detail. Same logic as the `test_coverage` MCP tool. + +```bash +xcforge test coverage # Overview — per-target/file coverage % +xcforge test coverage --xcresult-path /tmp/xcf-test-1234.xcresult +xcforge test coverage --min-coverage 80 # Only show files below 80% +xcforge test coverage --file LoginViewModel.swift # Per-function drill-down +xcforge test coverage --json +``` + +| Flag | Description | +|------|-------------| +| `--file ` | Drill into a specific file for per-function coverage | +| `--xcresult-path ` | Path to existing .xcresult bundle (must have coverage enabled) | +| `--project ` | Auto-detected if omitted | +| `--scheme ` | Auto-detected if omitted | +| `--simulator ` | Auto-detected if omitted | +| `--min-coverage ` | Only show files below this coverage %. Default: 100 (show all) | +| `--json` | Machine-readable JSON output | + +**Output (overview):** Overall coverage %, per-target coverage, per-file coverage sorted ascending. +**Output (--file):** File coverage %, per-function coverage with line numbers, execution counts, UNTESTED markers, untested function summary. + +### test list + +List available test identifiers (Target/Class/method) for a scheme. Use to discover the correct filter format. Same logic as the `list_tests` MCP tool. + +```bash +xcforge test list # List all tests +xcforge test list --scheme MyApp # Specific scheme +xcforge test list --json # Machine-readable JSON +``` + +| Flag | Description | +|------|-------------| +| `--project ` | Auto-detected if omitted | +| `--scheme ` | Auto-detected if omitted | +| `--simulator ` | Auto-detected if omitted | +| `--json` | Machine-readable JSON output | + +**Output:** Test identifiers grouped by target and class. Includes filter usage examples. + +--- + +## xcforge build-test + +Build then test in one step. Short-circuits on build failure with structured diagnostics. Same logic as the `build_and_test` MCP tool. **Preferred for TDD workflows.** + +```bash +xcforge build-test # Build + test all +xcforge build-test --filter "MyTests/testFoo" # Build + run specific test +xcforge build-test --coverage # With code coverage +xcforge build-test --env BLESS_BASELINE=1 # Inject TEST_RUNNER_BLESS_BASELINE=1 into test process +xcforge build-test --for agent # Slim ≤10-field JSON (agent-safe output) +xcforge build-test --gate # Subtract known-failures.yaml from pass/fail +xcforge build-test --json # Machine-readable JSON +``` + +| Flag | Description | +|------|-------------| +| `--project ` | Auto-detected if omitted | +| `--scheme ` | Auto-detected if omitted | +| `--simulator ` | Auto-detected if omitted | +| `--configuration ` | Build configuration (Debug/Release). Default: Debug | +| `--testplan ` | Test plan name | +| `--filter ` | Test filter — accepts relaxed formats (auto-resolves target prefix). Typos surface "Did you mean: ...?" suggestions | +| `--coverage` | Enable code coverage collection | +| `--env ` | Repeatable. Each key is auto-prefixed with `TEST_RUNNER_` before xcodebuild. `TEST_RUNNER_XCFORGE_REPO_ROOT` is always injected; override via `--env XCFORGE_REPO_ROOT=...` | +| `--for ` | `human` (default) or `agent`. `agent` implies `--json` with a slim shape | +| `--gate` | Subtract IDs in `.xcforge/known-failures.yaml` from `succeeded` count | +| `--json` | Machine-readable JSON output | + +**Output on build failure:** Build elapsed time, structured errors with file:line, warnings. Tests are NOT run. +**Output on test failure:** Build OK time, test pass/fail counts, failure details, screenshot paths, xcresult path. +**Exit code:** 0 when build and all tests pass, 1 otherwise. + +--- + +## xcforge sim + +Manage iOS simulators. 18 subcommands — `list` is the default. + +```bash +xcforge sim # Same as `xcforge sim list` +xcforge sim list # List all simulators with state/UDID +xcforge sim list --filter iPhone # Filter by name/state +xcforge sim info # Get display metrics for booted simulator +xcforge sim info --simulator "iPhone 16 Pro" # Get metrics for specific simulator +xcforge sim boot "iPhone 16 Pro" # Boot a simulator +xcforge sim shutdown "iPhone 16 Pro" # Shutdown (or "all") +xcforge sim install --app-path /path/to/App.app # Install app (auto-detects sim) +xcforge sim launch --bundle-id com.app.id # Launch app; fails with the crash if it dies within 2s +xcforge sim launch --arg -UITest --env API=stub --url myapp://screen # Args, env, deep link +xcforge sim openurl myapp://settings # Open a URL or deep link +xcforge sim terminate --bundle-id com.app.id # Terminate app +xcforge sim clone "iPhone 16 Pro" --name "Clone" # Clone simulator +xcforge sim erase "iPhone 16 Pro" # Erase to factory state +xcforge sim delete "Clone" # Permanently delete +xcforge sim orientation LANDSCAPE # Set orientation via WDA +xcforge sim record-start # Start video recording +xcforge sim record-start --path /tmp/demo.mov # Custom output path +xcforge sim record-stop # Stop recording, get file path +xcforge sim location --latitude 37.7749 --longitude -122.4194 # Set GPS +xcforge sim location-reset # Clear GPS override +xcforge sim appearance --appearance dark # Set light/dark mode +xcforge sim statusbar --time "9:41" --battery-level 100 # Override status bar +xcforge sim statusbar-clear # Restore default status bar +xcforge sim content-size accessibility-large # Dynamic Type size (prints the old one) +xcforge sim locale fr_FR # Locale + language (relaunch the app to apply) +xcforge sim container --container data # App container path (last built app) +xcforge sim push '{"aps":{"alert":"Hi"}}' # Deliver a push notification +xcforge sim privacy grant photos # Grant a permission before the app asks +``` + +All subcommands support `--json`. `install`, `launch`, `terminate` auto-detect simulator and bundle ID from session state. + +--- + +## xcforge device + +Manage physical iOS/iPadOS devices via devicectl. 7 subcommands — `list` is the default. + +```bash +xcforge device # Same as `xcforge device list` +xcforge device list # List connected devices +xcforge device list --filter iPhone # Filter by name/UDID/OS +xcforge device info "iPhone" # Detailed device info +xcforge device install /path/to/App.app --device "iPhone" # Install app +xcforge device uninstall com.app.id --device "iPhone" # Uninstall app +xcforge device launch com.app.id --device "iPhone" # Launch app +xcforge device launch com.app.id --device "iPhone" --console --timeout 30 # With console +xcforge device terminate com.app.id --device "iPhone" # Terminate app +xcforge device apps --device "iPhone" # List installed apps +xcforge device apps --device "iPhone" --include-system # Include system apps +``` + +All subcommands support `--json`. + +--- + +## xcforge spm + +Swift package management. 5 subcommands — `build` is the default. + +```bash +xcforge spm build # Build package in current dir +xcforge spm build --configuration release # Release build +xcforge spm build --path /path/to/package # Custom path +xcforge spm test # Run all tests +xcforge spm test --filter "MyTests/testFoo" # Filter tests +xcforge spm test --parallel # Parallel execution +xcforge spm run # Run single executable target +xcforge spm run mytool -- --verbose # Run named target with args +xcforge spm list # Show dependency tree (JSON) +xcforge spm clean # Clean build artifacts +``` + +All subcommands support `--json`. Without `--path`, the package is `.xcforge.yaml packagePath`, else the current folder, else the only `Package.swift` in the repo. + +--- + +## xcforge lsp + +```bash +xcforge lsp setup # buildServer.json for the auto-detected project and scheme +xcforge lsp setup --scheme ShutterCoachDev +``` + +Runs `xcode-build-server config` and points `build_root` at the DerivedData xcforge builds into. + +--- + +## xcforge log + +Stream, read, and wait on simulator logs. Four subcommands — `read` is the default. + +```bash +xcforge log start # Start a background capture (smart mode, debug level) +xcforge log start --mode app # App-only logs + crashes +xcforge log start --mode verbose # Unfiltered system logs +xcforge log start --process MyApp # Filter by process name +xcforge log start --subsystem com.myapp # Filter by subsystem +xcforge log read # Read with topic filtering (app + crashes) +xcforge log read --include network # Add network topic +xcforge log read --include lifecycle --last 50 # Last 50 lifecycle + app lines +xcforge log read --last 0 # Everything (default: newest 200 lines) +xcforge log read --clear # Clear buffer after reading +xcforge log stop # Stop capture +xcforge log wait --pattern "error.*timeout" # Wait for regex pattern +xcforge log wait --pattern "launched" --timeout 10 +``` + +All subcommands support `--json`. The capture keeps running in the background after `log start` returns, writing to `~/.xcforge/capture/`, until `log stop`. + +--- + +## xcforge console + +Launch, read, and stop app console output capture (print/NSLog). Three subcommands — `read` is the default. + +```bash +xcforge console launch # Launch app with console capture +xcforge console launch --bundle-id com.app.id # Explicit bundle ID +xcforge console launch --args "--verbose" # Pass launch args to app +xcforge console launch --env API=stub # Environment for the app (repeatable) +xcforge console read # Read stdout + stderr +xcforge console read --stream stdout --last 20 # Last 20 stdout lines +xcforge console read --clear # Clear buffer after reading +xcforge console stop # Stop capture and terminate app +``` + +All subcommands support `--json`. Auto-detects simulator and bundle ID from session state. The console keeps streaming in the background after `console launch` returns, until `console stop`; reads return the newest 200 lines per stream by default. + +--- + +## xcforge screenshot + +Capture simulator screenshots and manage visual baselines. Three subcommands — `capture` is the default. + +```bash +xcforge screenshot # Same as `xcforge screenshot capture` +xcforge screenshot capture # Capture to a new temp file (path printed) +xcforge screenshot capture --max-dimension 800 # Smaller image for a quick look +xcforge screenshot capture --crop 0,100,390,200 # One area, in device points +xcforge screenshot capture --format jpeg --output /path/to/file.jpeg +xcforge screenshot capture --grid # Include point-coordinate grid overlay +xcforge screenshot baseline --name login-screen # Save as named baseline +xcforge screenshot baseline --name login-screen --baseline-dir ./baselines +xcforge screenshot compare --name login-screen # Pixel diff against baseline +xcforge screenshot compare --name login-screen --threshold 1.0 +``` + +All subcommands support `--json`. `--format` defaults to png. `--threshold` defaults to 0.5%. `--grid` overlays 50pt minor grid lines and 100pt labeled divisions for coordinate verification. + +`screenshot capture` also accepts `--wait-for ` / `--timeout ` (same grammar as `wait-ready`/`pose`): gate the capture on a real signal instead of capturing whatever is on screen. Warn-only — a missed gate prints a warning and captures anyway, it never fails the command. `--timeout 0` skips the gate (pass-through) and captures immediately. The JSON result carries an `appForeground` field (`true`/`false`) whenever WDA can resolve the foreground bundle; it is **null/omitted only** when WDA is unreachable or the foreground bundle is unresolvable (never a false `false`). It is additive (`encodeIfPresent`), so existing JSON consumers that ignore unknown keys are unaffected. + +```bash +xcforge screenshot capture --output /tmp/s.png --wait-for a11y:home.title +xcforge screenshot capture --wait-for "launch-complete" --timeout 25 --json +``` + +--- + +## xcforge wait-ready + +Standalone launch-readiness gate. Blocks until the screen is *actually* ready, gating on a real signal instead of a blind sleep. Script-friendly: **exits non-zero on timeout**, so `xcforge wait-ready --for a11y:home.title && xcforge screenshot capture` composes correctly. + +```bash +xcforge wait-ready # default: --for launch-complete, 20s +xcforge wait-ready --for a11y:home.newspaperCard # element-presence gate +xcforge wait-ready --for "launch-complete,text:Welcome" # all must hold +xcforge wait-ready --for a11y:x --timeout 30 --poll-ms 100 +xcforge wait-ready --for a11y:x --json +``` + +| Flag | Description | +|------|-------------| +| `--for ` | Signal(s), comma-separated, **all must hold**: `launch-complete` (app foreground, WDA-derived), `a11y:` (element present — the real "right screen is up"), `text:` (any label/value contains it). Default: `launch-complete`. | +| `--timeout ` | Ceiling. Capture-equivalent fires the instant the signal holds, so a high ceiling costs nothing on the fast path. `0` skips the gate (pass-through): the command reports `ready` and **exits 0**, so `wait-ready --timeout 0 && …` composes. Default `20`. | +| `--poll-ms ` | Poll cadence. Default `150` (mirrors the proven `pollForActiveBundleId` contract). | +| `--simulator ` | Scope AXP/WDA queries. Auto-detected if omitted. | +| `--json` | Emit `{ ready, mode, elapsedMs, satisfied, signals, reason }`. | + +**Detection order:** AXP first (reads Simulator.app's accessibility tree directly — **no WDA session required**), WDA fallback (`findElement` / `verifyActiveBundleId`), then a reported degraded wait. `mode` (`axp` \| `wda` \| `degraded`) tells an agent whether it got a real gate or a degraded sleep — `degraded` always prints a one-line `reason` (never silently degrade). `a11y:`/`text:` are the strong signals; `launch-complete` only proves the app is foreground (true at the launch splash, *before* a deep-link nav push). MCP tool: `wait_ready` (params `simulator`, `for`, `timeout`). + +--- + +## xcforge ui + +UI automation via WebDriverAgent. 17 subcommands — `status` is the default. + +Every subcommand takes `--simulator `; each simulator has its own WDA, and the last one named is used when it's omitted. The first WDA start on a Mac builds xcforgeWDA (up to 15 minutes) and then waits up to 180s for the runner to answer (`XCFORGE_WDA_START_SECONDS`). + +```bash +xcforge ui status # Check WDA health +xcforge ui session # Create WDA session +xcforge ui session --bundle-id com.app.id # Bind session to app — verifies CFBundleIdentifier +xcforge ui ls # Flat element list (WDA; started when needed) +xcforge ui ls --source axp # macOS Accessibility tree of the one booted simulator +xcforge ui ls --scope home.drawer.root # Restrict to a11y-id and its descendants +xcforge ui find --using "accessibility id" --value "Save" +xcforge ui find --using "accessibility id" --value "Save" --scroll +xcforge ui find-all --using "class name" --value "XCUIElementTypeButton" +xcforge ui tap-by-id home.drawer.cancel # Atomic find + tap by a11y-id +xcforge ui tap-by --using "accessibility id" --value "Save" +xcforge ui click --element-id +xcforge ui tap --x 200 --y 400 # Tap at point coordinates +xcforge ui tap-pixel --x 1080 --y 2400 # Tap at pixel coordinates +xcforge ui double-tap --x 200 --y 400 +xcforge ui long-press --x 200 --y 400 --duration-ms 2000 +xcforge ui swipe --start-x 200 --start-y 600 --end-x 200 --end-y 200 +xcforge ui pinch --center-x 200 --center-y 400 --scale 2.0 +xcforge ui drag --from-x 100 --from-y 200 --to-x 300 --to-y 400 +xcforge ui type --text "hello world" +xcforge ui type --text "hello" --element-id --clear-first +xcforge ui type --text "secret" --secure --key return # Redacted, then press return +xcforge ui type --dismiss-keyboard +XCFORGE_SIMULATOR="iPhone 16" xcforge ui tap --x 200 --y 400 # Drive a specific simulator +xcforge ui get-text --element-id +xcforge ui source # Full view hierarchy (JSON) +xcforge ui source --format xml +xcforge ui source --format list # One line per element: id | label | type | frame +xcforge ui alert --action accept_all # Handle all alerts +xcforge ui alert --action dismiss --button-label "Cancel" +``` + +**WDA session binding.** `ui session --bundle-id ` binds the WDA session to a +specific app. This is the right hammer when WDA queries (`find`, `tap-by-id`, +class-chain) can't reach SwiftUI sheets, alerts, or `fullScreenCover` — those +mount in a secondary window owned by the app, and only sessions bound to the +app's bundle id walk every window. `xcforge ui *` commands auto-create a session, +and the bound bundle id is now persisted across recreates (mid-call retry, +WDA restart). `ui session --bundle-id` verifies binding via `GET /session/` +and exits non-zero if WDA reports a different `CFBundleIdentifier`. + +**Structured session errors + bounded auto-heal.** A failing `ui session` no +longer emits the opaque `Session creation failed: ExitCode(rawValue: 1)`. The +result now carries a structured envelope: `error: "wda_session_create_failed"`, +an enumerated `cause` (`wda_runner_not_running`, `wda_runner_build_failed`, +`no_booted_simulator`, `bundle_not_installed`, `session_bind_rejected`, +`unknown`), a human `detail`, and a copy-pasteable `remediation`. On a +**recoverable** cause (runner not running / build failed) it attempts **exactly +one** bounded rebuild+relaunch+rebind via the existing WDA orchestrator and +reports `recovered: true` (or fails with `cause: wda_runner_build_failed` + the +exact repair command). The CLI still exits non-zero with the readable cause. + +| Flag | Description | +|------|-------------| +| `--no-autoheal` | Disable the bounded auto-heal — preserve today's fail-fast for debugging. The structured error is still emitted. | +| `--relaunch-app` | Allow auto-heal to relaunch the user app (off by default — the agent may have intentional app state; runner repair is safe, app relaunch is not). | + +**`appForeground` on interaction results.** `ui tap-by-id`, `ui tap-by`, +`ui click`, `ui find`, and `screenshot capture` JSON include `appForeground` +(`true`/`false`) **whenever WDA can resolve the foreground bundle** — so an +agent can tell from a command result that the app it thinks it is driving is +actually backgrounded, without inferring it from a screenshot. It is +**null/omitted only** when WDA is unreachable or the foreground bundle is +unresolvable (never a false `false`). The field is additive (`encodeIfPresent`; +old consumers ignore unknown keys), keeping existing JSON shapes +backward-compatible. + +**ui ls source selection.** `--source auto` (default) picks WDA when any iOS sim +is booted, AXP otherwise. Use `--source wda` to force the iOS app's tree when +auto-detection misfires; use `--source axp` for macOS workflows. AXP returns +Simulator.app's menubar/dock chrome — almost never what you want when +automating an iOS app. + +All subcommands support `--json`. + +--- + +## xcforge git + +Git operations for a repository. Five subcommands — `status` is the default. + +```bash +xcforge git # Same as `xcforge git status` +xcforge git status # Porcelain status (current dir) +xcforge git status --path /path/to/repo +xcforge git diff # Unstaged changes +xcforge git diff --staged # Staged changes +xcforge git diff --file src/MyFile.swift +xcforge git log # Last 10 commits (oneline) +xcforge git log --count 20 --no-oneline # Detailed format +xcforge git commit --message "fix: bug" # Commit staged changes +xcforge git commit --message "feat: new" --add-all +xcforge git branch # List branches +xcforge git branch --action create --name feature/x +xcforge git branch --action switch --name main +``` + +All subcommands support `--json`. `--path` defaults to current directory. + +--- + +## xcforge defaults + +Manage persisted workflow defaults (project, scheme, simulator). Defaults are used by both MCP tools and CLI commands when explicit parameters are omitted. The store at `~/.xcforge/defaults.json` is **keyed by canonical project path** — each project on this machine has its own isolated record, so building App A in one directory cannot bleed `bundleId`/`appPath` into a session for App B. + +### show (default subcommand) +```bash +xcforge defaults # Same as `xcforge defaults show` +xcforge defaults show # Display active project's record + repo-config keys +``` + +Surfaces the active project key, its persisted fields, any `.xcforge.yaml` keys (incl. `testTimeout`, `autoPromote`), and a note when a value was auto-promoted. + +### set +```bash +xcforge defaults set --project /path/to/MyApp.xcodeproj +xcforge defaults set --scheme MyApp +xcforge defaults set --simulator "iPhone 16 Pro" +xcforge defaults set --project MyApp.xcodeproj --scheme MyApp --simulator "iPhone 16 Pro" +``` + +`set` writes into the **active project's record** (auto-resolved from cwd or `.xcforge.yaml`). If no project can be resolved, it warns and applies in-memory only. + +| Flag | Description | +|------|-------------| +| `--project ` | Default .xcodeproj or .xcworkspace path | +| `--scheme ` | Default scheme name | +| `--simulator ` | Default simulator name or UDID | + +### clear +```bash +xcforge defaults clear # Clear ONLY the active project's record +xcforge defaults clear --all # Wipe every project's record on this machine +``` + +`clear` (no `--all`) auto-resolves the active project before clearing; if no project can be detected from cwd, it reports that and exits cleanly without touching disk. + +| Flag | Description | +|------|-------------| +| `--all` | Wipe every project's persisted record (the pre-v1.6.1 default behavior) | + +> `xcforge defaults` writes the machine-global `~/.xcforge/defaults.json`, +> which is ranked **below** a repo's committed `.xcforge.yaml`. For repo-scoped +> team defaults use `xcforge init` / `.xcforge.yaml` instead. See +> [Auto-Detection](auto-detection.md) for the full resolution order. + +--- + +## xcforge init + +Scaffold a documented, repo-scoped `.xcforge.yaml` at the git repo root (CWD if +no `.git`). Pre-fills detected `project`/`scheme`/`simulator`; undetected keys +are commented placeholders. Supports `configuration`, `testPlan`, `testTimeout`, +and `autoPromote` (all repo-only) in addition to project/scheme/simulator. + +```bash +xcforge init # write .xcforge.yaml with detected values + comments +xcforge init --force # overwrite an existing .xcforge.yaml +``` + +| Flag | Description | +|------|-------------| +| `--force` | Overwrite an existing `.xcforge.yaml` (default: refuse + exit non-zero) | + +Repo config outranks the machine-global persisted defaults. CLI-only — there is +no MCP `init` tool. + +--- + +## xcforge diagnose + +Structured diagnosis workflows for CI/CD and debugging. 10 subcommands mapping to workflow phases. Each subcommand outputs human-readable terminal UI by default, or structured JSON with `--json`. + +### diagnose start + +Initialize a diagnosis run with resolved context (project, scheme, simulator). + +```bash +xcforge diagnose start +xcforge diagnose start --project MyApp.xcodeproj --scheme MyApp --simulator "iPhone 16 Pro" +xcforge diagnose start --reuse-run-id abc123 +xcforge diagnose start --configuration Release +xcforge diagnose start --json +``` + +| Flag | Required | Description | +|------|----------|-------------| +| `--project ` | No | Auto-detected if omitted | +| `--scheme ` | No | Auto-detected if omitted | +| `--simulator ` | No | Auto-detected if omitted | +| `--reuse-run-id ` | No | Reuse context from a previous run | +| `--configuration ` | No | Build configuration (Debug/Release) | +| `--json` | No | Machine-readable JSON output | + +**Returns:** Run ID + resolved context (schema version, workflow, phase, status). + +### diagnose build + +Diagnose build for an active run. If `--run-id` is omitted, auto-resolves to the newest active run (or newest recent one). + +```bash +xcforge diagnose build # Auto-resolve run ID +xcforge diagnose build --run-id +xcforge diagnose build --json +``` + +### diagnose test + +Diagnose test run for an active run. If `--run-id` is omitted, auto-resolves to the newest active run. + +```bash +xcforge diagnose test # Auto-resolve run ID +xcforge diagnose test --run-id +xcforge diagnose test --json +``` + +### diagnose runtime + +Launch app and capture runtime signals (crashes, memory pressure, logs). If `--run-id` is omitted, auto-resolves to the newest active run. + +```bash +xcforge diagnose runtime # Auto-resolve run ID +xcforge diagnose runtime --run-id --capture-screenshot +xcforge diagnose runtime --json +``` + +### diagnose status + +Inspect status of an active or recent run. Omit `--run-id` to use the newest. + +```bash +xcforge diagnose status +xcforge diagnose status --run-id +xcforge diagnose status --json +``` + +### diagnose evidence + +Inspect all available evidence (screenshots, logs, crashes) for a run. + +```bash +xcforge diagnose evidence +xcforge diagnose evidence --run-id +xcforge diagnose evidence --json +``` + +### diagnose inspect + +Consolidated troubleshooting view — correlates action timeline, evidence, and terminal classification. + +```bash +xcforge diagnose inspect +xcforge diagnose inspect --run-id +xcforge diagnose inspect --json +``` + +### diagnose verify + +Rerun validation with optional overrides. If `--run-id` is omitted, auto-resolves to the newest active run. + +```bash +xcforge diagnose verify # Auto-resolve run ID +xcforge diagnose verify --run-id +xcforge diagnose verify --run-id --scheme MyAppFixed +xcforge diagnose verify --run-id --simulator "iPhone SE" +xcforge diagnose verify --json +``` + +| Flag | Required | Description | +|------|----------|-------------| +| `--run-id ` | No | Run to re-verify. Auto-resolves to newest active/recent if omitted | +| `--project ` | No | Override project | +| `--scheme ` | No | Override scheme | +| `--simulator ` | No | Override simulator | +| `--configuration ` | No | Override build config | +| `--json` | No | Machine-readable output | + +### diagnose compare + +Compare original result vs latest rerun. Use `--compact` for agent-friendly output with only outcome, changed evidence, and unchanged blockers. + +```bash +xcforge diagnose compare +xcforge diagnose compare --run-id +xcforge diagnose compare --compact # Agent-friendly: outcome + changed evidence only +xcforge diagnose compare --json +``` + +### diagnose result + +Return final proof-oriented result for the run. + +```bash +xcforge diagnose result +xcforge diagnose result --run-id +xcforge diagnose result --json +``` + +--- + +## xcforge accessibility + +Check Dynamic Type and localization layout compliance. Two subcommands — `dynamic-type` is the default. + +### accessibility dynamic-type (default subcommand) + +Render the current screen across Dynamic Type content size categories and detect truncation. + +```bash +xcforge accessibility # Same as `xcforge accessibility dynamic-type` +xcforge accessibility dynamic-type --sizes all +xcforge accessibility dynamic-type --sizes "XS,XXXL,AccessibilityXXXL" +xcforge accessibility dynamic-type --threshold 3.0 --settle-time 2.0 +xcforge accessibility dynamic-type --json +``` + +| Flag | Description | +|------|-------------| +| `--simulator ` | Simulator name or UDID. Auto-detected from booted simulator | +| `--sizes ` | Comma-separated size categories. Use 'all' for all 12. Default: XS,L,XXXL,AccessibilityXXXL | +| `--threshold ` | Max allowed diff % from base size. Default: 5.0 | +| `--settle-time ` | Wait time after changing size. Default: 1.5 | +| `--json` | Machine-readable JSON output | + +Size categories can be short names (XS, L, XXXL, AccessibilityXXXL) or full names (UICTContentSizeCategoryXS). + +### accessibility localization + +Render the current screen across locales including RTL languages. + +```bash +xcforge accessibility localization --bundle-id com.app.MyApp +xcforge accessibility localization --locales "en,de,ja,ar,he" +xcforge accessibility localization --locales all +xcforge accessibility localization --threshold 15.0 --settle-time 4.0 +xcforge accessibility localization --json +``` + +| Flag | Description | +|------|-------------| +| `--simulator ` | Simulator name or UDID. Auto-detected from booted simulator | +| `--bundle-id ` | App bundle identifier. Auto-detected from last build | +| `--locales ` | Comma-separated locale identifiers. Use 'all' for 10 common. Default: en,de,ja,ar,he | +| `--threshold ` | Max allowed diff % from base locale. Default: 10.0 | +| `--settle-time ` | Wait time after relaunching with new locale. Default: 3.0 | +| `--json` | Machine-readable JSON output | + +--- + +## xcforge plan + +Execute multi-step UI automation plans. Two subcommands. + +### plan run + +Execute a plan from a JSON file or stdin. + +```bash +xcforge plan run --file login-flow.json +xcforge plan run --file plan.json --error-strategy continue --timeout 60 +xcforge plan run --file plan.json --json +cat plan.json | xcforge plan run --stdin +``` + +| Flag | Description | +|------|-------------| +| `--file ` | Path to JSON file containing plan steps array | +| `--stdin` | Read plan JSON from stdin | +| `--error-strategy ` | `abort_with_screenshot` (default), `abort`, `continue` | +| `--timeout ` | Max execution time. Default: 120 | +| `--json` | Machine-readable JSON output | + +### plan decide + +Resume a plan that `plan run` suspended, from a new process. Sessions are saved in `~/.xcforge/plan-sessions/` for an hour and used once. + +```bash +xcforge plan decide --session-id --decision accept +xcforge plan decide --session-id --decision skip --json +xcforge plan decide --session-id --decision abort +``` + +| Flag | Description | +|------|-------------| +| `--session-id ` | Session ID from suspended plan run | +| `--decision ` | `accept`, `dismiss`, `skip`, `abort`, or freeform | +| `--json` | Machine-readable JSON output | + +--- + +## xcforge pose + +Launch an app into a named visual state for rapid design iteration. Internally runs build → install → launch with custom arguments, optionally followed by screenshot. + +```bash +xcforge pose dark-theme # Build + install + launch with -pose dark-theme +xcforge pose onboarding-step-2 # Another pose +xcforge pose dark-theme --project MyApp.xcodeproj --scheme MyApp +xcforge pose dark-theme --configuration Release +xcforge pose dark-theme --screenshot /tmp/pose.png # Capture (waits 1.5s for launch zoom to settle) +xcforge pose dark-theme --screenshot /tmp/pose.png --screenshot-delay 0 # Immediate capture (legacy) +xcforge pose dark-theme --key "--debug-state" # Custom argument key +xcforge pose dark-theme --key=-NookPose # `=` form required for values starting with '-' +xcforge pose dark-theme --json +``` + +| Flag | Description | +|------|-------------| +| `` | **Required** — Pose name to pass to the app | +| `--project ` | Path to .xcodeproj or .xcworkspace. Auto-detected if omitted | +| `--scheme ` | Xcode scheme name. Auto-detected if omitted | +| `--simulator ` | Simulator name or UDID. Auto-detected if omitted | +| `--configuration ` | Build configuration (Debug/Release). Default: Debug | +| `--key ` | Argument key to prepend to pose name. Default: `-pose`. Use `--key=-myValue` for values starting with `-` (bare `--key -myValue` is parsed as a missing value). | +| `--screenshot ` | Optional file path to capture screenshot after launch | +| `--screenshot-delay ` | Seconds to wait after launch before capturing, so the iOS launch zoom can settle. Default `1.5`; pass `0` for legacy immediate-capture. **Unchanged** when `--wait-for` is not passed. | +| `--wait-for ` | Readiness signal(s), comma-separated (all must hold): `launch-complete`, `a11y:`, `text:`. Replaces the `--screenshot-delay` poll/sleep with a real element-presence gate (AXP-first, WDA fallback). Capture fires the instant the signal holds — stop racing cold launch onto the splash/Home. Warn-only: never fails the pose. | +| `--timeout ` | Ceiling for `--wait-for`. Default `20`. A high ceiling is free on the fast path. | +| `--json` | Machine-readable JSON output | + +```bash +xcforge pose daily-calendar --screenshot /tmp/p.png --wait-for a11y:dailyCalendar.title +xcforge pose home --screenshot /tmp/p.png --wait-for "launch-complete,text:Welcome" --timeout 30 +``` + +**Returns:** Build status, install confirmation, launch status, app PID. If `--screenshot` provided, also returns image data. With `--wait-for`, an unsatisfied gate adds a warning naming the readiness `mode` (`axp` \| `wda` \| `degraded`). + +See the [Pose & Visual Iteration](pose.md) reference for app-side routing patterns and visual iteration workflows. + +--- + +## xcforge bless + +Save a visual baseline, run tests and compare visual output in one call. + +```bash +xcforge bless --baseline login-screen --tests "UITests/LoginTests" +xcforge bless --baseline home-dark --tests "SnapshotTests/HomeTests" --project MyApp.xcodeproj +``` + +| Flag | Description | +|------|-------------| +| `--baseline ` | **Required** — Name for the visual baseline | +| `--tests ` | **Required** — Test filter passed to `build-test` (e.g., `MyTarget/MyTests`) | +| `--project ` | Auto-detected if omitted | +| `--scheme ` | Auto-detected if omitted | +| `--simulator ` | Auto-detected if omitted | + +**Steps performed:** save baseline → run tests → compare visual. + +**Exit code:** 0 when all tests pass and diff is within threshold, 1 otherwise. + +--- + +## Typical CLI Workflows + +### TDD Loop +```bash +# Set defaults once +xcforge defaults set --project MyApp.xcodeproj --scheme MyApp --simulator "iPhone 16 Pro" + +# Build +xcforge build + +# Run tests +xcforge test + +# Filter to specific tests +xcforge test --filter "LoginTests/testValidCredentials" + +# Agent-optimized output +xcforge test --for agent --gate + +# Rerun only failures +xcforge test rerun-failed + +# Inspect failures with console output +xcforge test failures --include-console + +# Check coverage +xcforge test coverage --min-coverage 80 +xcforge test coverage --file LoginViewModel.swift + +# Bless a visual baseline + run tests in one call +xcforge bless --baseline login-screen --tests "UITests/LoginTests" +``` + +### Simulator + App Workflow +```bash +# List and boot +xcforge sim list --filter iPhone +xcforge sim boot "iPhone 16 Pro" + +# Build, install, launch (full pipeline) +xcforge build run # Builds + boots + installs + launches + +# Capture logs while using app +xcforge log start --mode app +# use the app... +xcforge log read +xcforge log read --include network +xcforge log stop + +# Screenshot for visual check +xcforge screenshot capture --output ./screenshot.png +``` + +### UI Automation Workflow +```bash +# Build and launch (full pipeline) +xcforge build run + +# Handle permission dialogs +xcforge ui alert --action accept_all + +# Find and interact with elements +xcforge ui find --using "accessibility id" --value "emailField" +xcforge ui type --text "user@example.com" --element-id +xcforge ui find --using "accessibility id" --value "Login" --scroll +xcforge ui click --element-id + +# Verify result +xcforge screenshot capture +xcforge ui source --format xml +``` + +### Visual Regression Workflow +```bash +# Save baselines +xcforge screenshot baseline --name login-screen +xcforge screenshot baseline --name home-screen + +# Make changes, then compare +xcforge screenshot compare --name login-screen --threshold 1.0 +``` + +### Structured Diagnosis +```bash +xcforge diagnose start # → Run ID: abc123 +xcforge diagnose build # Auto-resolves to active run +xcforge diagnose test +xcforge diagnose runtime --capture-screenshot +xcforge diagnose inspect +xcforge diagnose verify # After fix +xcforge diagnose compare --compact # Agent-friendly summary +xcforge diagnose result +``` + +"""#), + SkillFile(path: "references/device-tools.md", contents: #""" +# Device Tools (10 tools) + +Physical iOS/iPadOS device management via `xcrun devicectl`. All tools require a connected device (USB or WiFi). + +## list_devices + +List connected physical iOS/iPadOS devices. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `filter` | No | — | Filter by name, UDID, or OS version (case-insensitive) | + +**Returns:** Array of devices with name, UDID, OS version, state, connection type. + +--- + +## device_info + +Get detailed information about a connected physical device. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `device` | **Yes** | — | Device name, UDID, or serial number | + +**Returns:** Name, OS version, UDID, model, platform, connection type. + +--- + +## device_install + +Install an .app bundle on a connected physical device. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `device` | **Yes** | — | Device name or UDID | +| `app_path` | **Yes** | — | Path to the .app bundle | + +**Timeout:** 120 seconds. + +**Returns:** Bundle ID of installed app. + +--- + +## device_uninstall + +Uninstall an app from a connected physical device by bundle ID. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `device` | **Yes** | — | Device name or UDID | +| `bundle_id` | **Yes** | — | Bundle identifier of the app to uninstall | + +--- + +## device_launch + +Launch an app on a connected physical device. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `device` | **Yes** | — | Device name or UDID | +| `bundle_id` | **Yes** | — | Bundle identifier of the app | +| `console` | No | false | Attach console and wait for app exit | +| `terminate_existing` | No | true | Terminate existing instance before launching | +| `timeout` | No | 30 | Console timeout in seconds | +| `arguments` | No | — | Array of arguments passed to the app | +| `url` | No | — | Deep link or universal link opened in the app at launch | +| `env` | No | — | Object of environment variables for the app | + +CLI: `xcforge device launch --device [--url myapp://x] [--env KEY=VALUE] [--arg A]`. + +**Returns:** Launch confirmation and optional console output (if `console: true`). + +--- + +## device_terminate + +Terminate a running process on a connected physical device. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `device` | **Yes** | — | Device name or UDID | +| `identifier` | **Yes** | — | Bundle ID or PID of the process to terminate | + +--- + +## device_apps + +List apps installed on a connected physical device. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `device` | **Yes** | — | Device name or UDID | +| `include_system` | No | false | Include system/built-in apps | +| `bundle_id` | No | — | Filter to a specific bundle ID | + +**Returns:** Array of apps with bundle ID, name, and version. + +--- + +## device_screenshot (deprecated: `screenshot` with `device`) + +Over MCP, call `screenshot(device: ...)`; `device_screenshot` still works for one release. Save a PNG of the device screen. Uses `devicectl device capture screenshot` (Xcode 26.6 and later), then the device's WebDriverAgent when `wda_start` has one running. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `device` | **Yes** | — | Device name or UDID | +| `path` | No | artifact dir | Where to write the PNG | + +CLI: `xcforge device screenshot --device [--output shot.png]`. + +--- + +## `wda_start` / `wda_stop` (UI automation on a real device) + +`wda_start` builds xcforgeWDA for the device, signs it with your team (`-allowProvisioningUpdates`), launches it with `xcodebuild test-without-building` in the background, and finds its URL (the CoreDevice tunnel address, else the URL WDA logs). The MCP session's UI tools (`find_element`, `tap`, `type_text`, ...) then target the device. A runner that already answers is reused. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `device` | **Yes** | — | Device name or UDID | +| `team` | No | `XCFORGE_WDA_TEAM` | Development team ID for signing | +| `bundle_id` | No | `com.xcforge.wda..runner` | Runner bundle id | +| `port` | No | 8100 | Port WDA listens on | + +CLI: + +```bash +xcforge wda start --device --team ABCDE12345 +export XCFORGE_DEVICE= # ui commands now use the device's runner +xcforge ui tap --label "Sign In" +xcforge wda status +xcforge wda stop --device +``` + +Before starting, the device must be unlocked, in Developer Mode, and have **Settings > Developer > Enable UI Automation** on. Failures name which of these (or signing, pairing, an untrusted certificate) is the problem. UI tools never run simulator recovery against a device: when its runner stops answering they say so and point at `wda start`. + +State lives in `~/.xcforge/wda/.json` (URL, runner pid, log path), so other sessions on the Mac reuse the same runner. The build goes through the shared build lock when one is configured. + +"""#), + SkillFile(path: "references/diagnosis-workflows.md", contents: #""" +# Diagnosis Workflow Tools (10 MCP tools) + +**Off by default.** The diagnose group is not listed until you turn it on: `XCFORGE_TOOL_GROUPS=+diagnose`, `toolGroups: +diagnose` in `.xcforge.yaml`, or `tool_groups(enable: ["diagnose"])`. For most work `build_and_diagnose`, `test_sim` and `test_failures` cover the same ground. + +These tools provide structured diagnosis workflows via MCP. They mirror the `xcforge diagnose` CLI commands but are callable from any MCP client. + +Each tool corresponds to a workflow phase. A diagnosis "run" tracks state across phases. + +## Workflow Phases + +``` +start → build → test → runtime → [status/inspect/evidence] → verify → compare → result +``` + +Not all phases are required. You can skip directly to `result` after any phase. + +--- + +## diagnose_start + +Create a new diagnosis run with resolved context. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | +| `scheme` | No | Auto-detect | Scheme name | +| `simulator` | No | Auto-detect | Simulator name or UDID | +| `reuse_run_id` | No | — | Reuse context from a previous run | +| `configuration` | No | Debug | Build configuration | + +**Returns:** `runId`, `resolvedContext` (schemaVersion, workflow, phase, status, project, scheme, simulator). + +--- + +## diagnose_build + +Diagnose build for an active run. Builds the project and parses xcresult for structured errors/warnings. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `run_id` | No | Newest active/recent | Active run ID from `diagnose_start`. Auto-resolves if omitted | + +**Returns:** Build status, error count, warning count, structured issues with file:line, xcresult path. + +--- + +## diagnose_test + +Diagnose test run. Runs tests and parses xcresult for structured failures. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `run_id` | No | Newest active/recent | Active run ID. Auto-resolves if omitted | + +**Returns:** Test status, pass/fail/skip counts, structured failures with file:line, xcresult path. + +--- + +## diagnose_runtime + +Launch app and capture runtime signals (crashes, memory pressure, logs). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `run_id` | No | Newest active/recent | Active run ID. Auto-resolves if omitted | +| `capture_screenshot` | No | false | Take screenshot during runtime inspection | + +**Returns:** Runtime observations, crash signals, memory pressure, optional screenshot. + +--- + +## diagnose_status + +Inspect the current status of an active or recent run. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `run_id` | No | Newest active/recent | Run ID (omit to auto-select) | + +**Returns:** Run status, current phase, completed phases, timestamps. + +--- + +## diagnose_evidence + +Inspect all available evidence collected during the run. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `run_id` | No | Newest active/recent | Run ID | + +**Returns:** Evidence catalog: screenshots, log snapshots, crash reports, xcresult paths, timeline entries. + +--- + +## diagnose_inspect + +Consolidated troubleshooting view. Correlates action timeline, evidence, and terminal classification with context provenance. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `run_id` | No | Newest active/recent | Run ID | + +**Returns:** Correlated timeline, evidence summary, classification (pass/fail/inconclusive), provenance chain. + +**Use case:** Single-call overview for investigating false positives or understanding why a run was classified a certain way. + +--- + +## diagnose_verify + +Rerun validation with optional overrides. Used after fixing an issue to verify the fix works. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `run_id` | No | Newest active/recent | Run to re-verify. Auto-resolves if omitted | +| `project` | No | From original run | Override project | +| `scheme` | No | From original run | Override scheme | +| `simulator` | No | From original run | Override simulator | +| `configuration` | No | From original run | Override build configuration | + +**Returns:** Verification result, comparison with original run classification. + +--- + +## diagnose_compare + +Compare original diagnosis result vs latest rerun (from `diagnose_verify`). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `run_id` | No | Newest active/recent | Run ID | + +**Returns:** Side-by-side comparison of original vs rerun: status, issues, test results, evidence diffs. + +--- + +## diagnose_result + +Return the final proof-oriented result for a diagnosis run. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `run_id` | No | Newest active/recent | Run ID | + +**Returns:** Final classification (pass/fail/inconclusive), evidence summary, recommended actions, proof chain. + +"""#), + SkillFile(path: "references/git-tools.md", contents: #""" +# Git Tools (5 tools) + +## git_status + +Show git status in porcelain format. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `path` | **Yes** | — | Repository path | + +--- + +## git_diff + +Show git diff (staged, unstaged, or specific file). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `path` | **Yes** | — | Repository path | +| `staged` | No | false | Show only staged changes | +| `file` | No | — | Diff a specific file only | + +--- + +## git_log + +Show recent commits. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `path` | **Yes** | — | Repository path | +| `count` | No | 10 | Number of commits to show | +| `oneline` | No | false | One-line format | + +--- + +## git_commit + +Commit staged changes. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `path` | **Yes** | — | Repository path | +| `message` | **Yes** | — | Commit message | +| `add_all` | No | false | Stage all changes before committing | + +**Hook retry:** If a pre-commit hook reformats files (e.g., swift-format), `git_commit` detects the modified files via `git status --porcelain`, auto-restages them, and retries the commit once before surfacing the failure. + +--- + +## git_branch + +List, create, or switch branches. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `path` | **Yes** | — | Repository path | +| `action` | No | list | `list`, `create`, or `switch` | +| `name` | No | — | Branch name (required for create/switch) | + +"""#), + SkillFile(path: "references/lldb-debugger.md", contents: #""" +# LLDB Debugger Integration + +Attach to running simulator processes and debug interactively — inspect variables, set breakpoints, step through code, and view stack traces. + +**Availability:** MCP server (persistent sessions) and CLI (one-shot commands) +**Platforms:** Simulator processes only (iOS 26.0+ in Xcode 15+) +**Requirements:** Xcode installed (`xcrun lldb` available) + +--- + +## Core Design + +**Session-based debugging (MCP):** Call `lldb_attach` once to get a `sessionId`, then use it with all other tools. Sessions persist for 30 minutes of inactivity and are keyed by UUID. When you're done, call `lldb_detach` to clean up. + +**One-shot debugging (CLI):** Each `xcforge debug` command attaches → runs one operation → detaches automatically. No session ID needed for CLI commands. + +--- + +## MCP Tools (8 total) + +### lldb_attach + +**Attach LLDB to a running simulator process.** + +``` +Inputs: + bundleId (string, optional) -- App bundle ID (e.g. com.example.App) + pid (integer, optional) -- Process ID + + Note: Provide either bundleId or pid, not both. + +Outputs: + { + "sessionId": "uuid-string", + "pid": 12345, + "status": "stopped" + } + +Errors: + "App not running on booted simulator" (bundleId not found) + "Process ID not found" (pid doesn't exist) + "Attach failed: ..." (LLDB attach error) +``` + +**Usage pattern (MCP):** + +``` +1. lldb_attach with bundleId: "com.example.App" + → sessionId = "8f3c-..." + +2. [Run any other tools with sessionId = "8f3c-..."] +3. lldb_detach with sessionId: "8f3c-..." +``` + +--- + +### lldb_detach + +**Detach from a session and clean up the LLDB subprocess.** + +``` +Inputs: + sessionId (string, required) -- Session ID from lldb_attach + +Outputs: + { + "detached": true + } + +Errors: + None (silently succeeds even if session is already gone) +``` + +--- + +### lldb_set_breakpoint + +**Set a breakpoint by source file+line or by function name.** + +``` +Inputs: + sessionId (string, required) -- Session ID from lldb_attach + file (string, optional) -- Source file name (e.g. Foo.swift) + line (integer, optional) -- Line number (used with file) + function (string, optional) -- Function/method name (e.g. -[FooVC viewDidLoad]) + + Note: Provide either (file + line) or function, not both. + +Outputs: + { + "breakpointId": 1, + "resolved": true // false if file not found in binary + } + +Errors: + "Session not found or expired" + "Invalid input: ... (newlines not allowed)" + "No breakpoint created — check file path or function name" +``` + +**Example:** + +File+line breakpoint: +``` +Input: sessionId="8f3c-...", file="ViewController.swift", line=42 +Output: { "breakpointId": 1, "resolved": true } +``` + +Function breakpoint (Objective-C): +``` +Input: sessionId="8f3c-...", function="-[LoginVC viewDidLoad]" +Output: { "breakpointId": 2, "resolved": true } +``` + +Function breakpoint (Swift): +``` +Input: sessionId="8f3c-...", function="MyApp.ViewController.handleTap()" +Output: { "breakpointId": 3, "resolved": true } +``` + +--- + +### lldb_remove_breakpoint + +**Remove a breakpoint by ID.** + +``` +Inputs: + sessionId (string, required) -- Session ID from lldb_attach + breakpointId (integer, required) -- Breakpoint ID from lldb_set_breakpoint + +Outputs: + { + "removed": true + } + +Errors: + "Breakpoint ID N not found" + "Session not found or expired" +``` + +--- + +### lldb_inspect_variable + +**Evaluate an expression in the current stack frame. Process must be stopped.** + +``` +Inputs: + sessionId (string, required) -- Session ID from lldb_attach + expression (string, required) -- Expression to evaluate (e.g. self.count) + +Outputs: + { + "expression": "self.count", + "type": "Int", + "value": "42", + "summary": "42 values" // Optional summary from LLDB + } + +Errors: + "Process is not stopped at a frame" + "Session not found or expired" + "Expression error: ..." (invalid expression or evaluation failed) +``` + +**Example:** + +``` +Input: sessionId="8f3c-...", expression="self.items.count" +Output: { + "expression": "self.items.count", + "type": "Int", + "value": "5", + "summary": "5" +} +``` + +--- + +### lldb_backtrace + +**Get the stack trace for the current or specified thread. Process must be stopped.** + +``` +Inputs: + sessionId (string, required) -- Session ID from lldb_attach + threadIndex (integer, optional) -- Thread index (default 0) + +Outputs: + [ + { + "frameIndex": 0, + "address": "0x1005a8c4c", + "symbol": "MyApp.ViewController.handleTap()", + "file": "ViewController.swift", + "line": 42 + }, + { + "frameIndex": 1, + "address": "0x10059a0c8", + "symbol": "UIKit.UIButton.sendAction(...)", + "file": null, + "line": null + } + // ... more frames ... + ] + +Errors: + "Process is running (not stopped at a frame)" + "Session not found or expired" +``` + +--- + +### lldb_continue + +**Resume, step over, step into, or step out of the current frame. Timeout: 10 seconds.** + +``` +Inputs: + sessionId (string, required) -- Session ID from lldb_attach + mode (string, optional) -- Execution mode (default: "continue") + Valid: "continue", "step_over", "step_into", "step_out" + +Outputs: + { + "stopReason": "breakpoint", // or "step", "signal", etc. + "threadIndex": 0, + "frameIndex": 0 + } + +Errors: + "Process is running (already executing)" + "Timeout: Process did not stop within 10 seconds" + "Session not found or expired" +``` + +**Mode explanation:** + +| Mode | LLDB Command | Behavior | +|------|-------------|----------| +| `continue` | `continue` | Resume until next breakpoint or end | +| `step_over` | `next` | Execute current line, stop at next line (skip function calls) | +| `step_into` | `step` | Execute current line, stop inside function calls | +| `step_out` | `finish` | Execute until current function returns | + +**Example (hit a breakpoint, then step):** + +``` +1. lldb_continue with mode="step_over" + → { "stopReason": "step", "threadIndex": 0, "frameIndex": 0 } + +2. lldb_continue with mode="step_into" + → { "stopReason": "step", "threadIndex": 0, "frameIndex": 0 } +``` + +--- + +### lldb_run_command + +**Run an arbitrary LLDB command and get raw output. Advanced use only.** + +``` +Inputs: + sessionId (string, required) -- Session ID from lldb_attach + command (string, required) -- Raw LLDB command (e.g. "memory read 0x...") + +Outputs: + { + "output": "" + } + +Errors: + "Invalid input: newlines not allowed in command" + "Session not found or expired" +``` + +**Example (read process memory):** + +``` +Input: sessionId="8f3c-...", command="memory read 0x104c8a000 0x104c8a100" +Output: { + "output": "0x104c8a000: 00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f ................" +} +``` + +--- + +## CLI Commands (8 total) + +All CLI commands are one-shot (attach → operation → detach). Simulator must be booted. + +### debug attach + +```bash +xcforge debug attach [--pid N | --bundle-id X] [--json] +``` + +Attach and create a persistent MCP session (CLI shows session ID for later use). + +``` +$ xcforge debug attach --bundle-id com.example.App +Attached to com.example.App (PID 1234) +Session ID: 8f3c-4d1f-... + +$ xcforge debug attach --pid 5678 --json +{ + "sessionId": "8f3c-4d1f-...", + "pid": 5678, + "status": "stopped" +} +``` + +--- + +### debug detach + +```bash +xcforge debug detach --session [--json] +``` + +Detach from a session (created by MCP `lldb_attach` or CLI `debug attach`). + +``` +$ xcforge debug detach --session 8f3c-4d1f-... +Detached from session 8f3c-4d1f-... + +$ xcforge debug detach --session 8f3c-4d1f-... --json +{ + "detached": true +} +``` + +--- + +### debug backtrace + +```bash +xcforge debug backtrace [--pid N | --bundle-id X] [--thread N] [--json] +``` + +One-shot: show stack trace without attaching a persistent session. + +``` +$ xcforge debug backtrace --bundle-id com.example.App +Frame 0: -[LoginVC handleTap] (LoginViewController.swift:42) +Frame 1: -[UIButton sendAction:to:forEvent:] (UIKit) +Frame 2: -[UIApplication sendAction:to:from:forEvent:] (UIKit) + +$ xcforge debug backtrace --bundle-id com.example.App --json +[ + { + "frameIndex": 0, + "address": "0x1005a8c4c", + "symbol": "MyApp.LoginVC.handleTap()", + "file": "LoginViewController.swift", + "line": 42 + }, + ... +] +``` + +--- + +### debug inspect + +```bash +xcforge debug inspect [--pid N | --bundle-id X] --expression [--json] +``` + +One-shot: evaluate an expression in the current frame. + +``` +$ xcforge debug inspect --bundle-id com.example.App --expression "self.count" +self.count = 42 (Int) + +$ xcforge debug inspect --bundle-id com.example.App --expression "self.count" --json +{ + "expression": "self.count", + "type": "Int", + "value": "42", + "summary": "42" +} +``` + +--- + +### debug breakpoint set + +```bash +xcforge debug breakpoint set [--pid N | --bundle-id X] [--file F --line N | --function FN] [--json] +``` + +One-shot: set a breakpoint without creating a persistent session. + +``` +$ xcforge debug breakpoint set --bundle-id com.example.App --file LoginVC.swift --line 42 +Breakpoint 1: file = 'LoginVC.swift', line = 42, locations = 1 + +$ xcforge debug breakpoint set --bundle-id com.example.App --function "LoginVC.handleTap()" --json +{ + "breakpointId": 1, + "resolved": true +} +``` + +--- + +### debug breakpoint remove + +```bash +xcforge debug breakpoint remove [--pid N | --bundle-id X] --breakpoint-id N [--json] +``` + +One-shot: remove a breakpoint. + +``` +$ xcforge debug breakpoint remove --bundle-id com.example.App --breakpoint-id 1 +Removed breakpoint 1 + +$ xcforge debug breakpoint remove --bundle-id com.example.App --breakpoint-id 1 --json +{ + "removed": true +} +``` + +--- + +### debug continue + +```bash +xcforge debug continue [--pid N | --bundle-id X] [--mode continue|step-over|step-into|step-out] [--json] +``` + +One-shot: resume, step over, step into, or step out. + +``` +$ xcforge debug continue --bundle-id com.example.App --mode step-over +Stopped at frame 0: -[LoginVC handleTap] (LoginVC.swift:43) +Stop reason: step + +$ xcforge debug continue --bundle-id com.example.App --mode step-into --json +{ + "stopReason": "step", + "threadIndex": 0, + "frameIndex": 0 +} +``` + +--- + +### debug run + +```bash +xcforge debug run [--pid N | --bundle-id X] --command [--json] +``` + +One-shot: execute an arbitrary LLDB command. + +``` +$ xcforge debug run --bundle-id com.example.App --command "memory read 0x104c8a000" +0x104c8a000: 48 8b 05 35 6f 00 00 48 8b c8 48 ff 25 34 6f 00 00 + +$ xcforge debug run --bundle-id com.example.App --command "p self.model.name" --json +{ + "output": "(String) \"User 123\"" +} +``` + +--- + +## Workflow Examples + +### Example 1: Debug a Crash (MCP) + +``` +1. lldb_attach(bundleId: "com.example.App") + → { sessionId: "abc-123-...", pid: 2345, status: "stopped" } + +2. lldb_set_breakpoint(sessionId, file: "AppDelegate.swift", line: 15) + → { breakpointId: 1, resolved: true } + +3. lldb_continue(sessionId, mode: "continue") + → { stopReason: "breakpoint", threadIndex: 0, frameIndex: 0 } + +4. lldb_backtrace(sessionId) + → [ + { frameIndex: 0, symbol: "AppDelegate.application(...)", file: "AppDelegate.swift", line: 15 }, + { frameIndex: 1, symbol: "UIApplication.main(...)", file: null, line: null } + ] + +5. lldb_inspect_variable(sessionId, expression: "self.count") + → { expression: "self.count", type: "Int", value: "0" } + +6. lldb_detach(sessionId) + → { detached: true } +``` + +### Example 2: Inspect State at Breakpoint (CLI) + +```bash +# Set a breakpoint and examine the frame +xcforge debug breakpoint set --bundle-id com.example.App --file LoginVC.swift --line 42 + +# Now the app is stopped. Inspect a variable +xcforge debug inspect --bundle-id com.example.App --expression "self.user?.email" + +# View the stack +xcforge debug backtrace --bundle-id com.example.App + +# Step to the next line +xcforge debug continue --bundle-id com.example.App --mode step-over +``` + +### Example 3: Watch Loop (MCP with polling) + +Monitor a counter by stepping repeatedly: + +``` +1. Attach and set breakpoint at the loop body +2. lldb_continue(mode: "continue") — stops at breakpoint +3. lldb_inspect_variable(expression: "i") +4. lldb_continue(mode: "step_over") — advance one line +5. Repeat steps 3–4 until done +6. lldb_detach +``` + +--- + +## Common Pitfalls + +**Session expired:** If you don't call a tool for 30+ minutes, the session is cleaned up. Call `lldb_attach` again. + +**Process not stopped:** Tools like `lldb_inspect_variable`, `lldb_backtrace` require the process to be paused at a breakpoint or after a step. If you call them while the process is running, they fail with "not stopped". + +**Unresolved breakpoint:** If you set a breakpoint by function name and it shows `resolved: false`, the function may not be in the binary (e.g. Swift name mangling issue, or the function is inlined). Try file+line instead. + +**Bundle ID vs PID:** Use `bundleId` if the app is visible in the booted simulator; use `pid` if you already know the process ID (via `ps aux` or from system logs). + +**CLI one-shot semantics:** Each `xcforge debug` command creates a fresh attach, runs the operation, and detaches. If you need to inspect multiple frames or variables, use MCP with a persistent session instead. + +--- + +## Connection to Diagnosis Workflows + +The LLDB tools integrate with `diagnose` workflows: + +``` +1. xcforge diagnose start --scheme MyApp + → Creates a diagnosis session, builds, and launches the app + +2. [App hits a breakpoint or crashes] + +3. lldb_attach(bundleId: "com.example.App") + → Attach debugger to understand the state + +4. [Inspect variables, view backtrace, step through code] + +5. xcforge diagnose verify --criteria + → Confirm the fix +``` + +--- + +## See Also + +- [Logs & Console](log-console.md) — Capture app logs while debugging +- [Diagnosis Workflows](diagnosis-workflows.md) — Multi-step debugging pipelines +- [Auto-Detection](auto-detection.md) — Session profiles for repeated debug workflows + +"""#), + SkillFile(path: "references/log-console.md", contents: #""" +# Log & Console Tools (7 tools) + +## Log Capture (4 tools) + +xcforge's log system uses 4 filter layers to reduce noise by 90%: + +1. **Stream-side noise exclusion** — 15 known noise processes removed before buffering (79% I/O reduction) +2. **3 capture modes** — smart (default), app, verbose +3. **Read-time topic filtering** — 8 topics with line counts, agent picks what matters +4. **Buffer deduplication** — 60 identical heartbeat lines become 2 entries + +### start_log_capture + +Start capturing OS logs from the simulator. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `mode` | No | `smart` | Capture mode (see below) | +| `process` | No | — | Filter by process name (efficient server-side in logd). Bypasses mode logic. | +| `subsystem` | No | — | Filter by subsystem, e.g. `com.myapp`. Bypasses mode logic. | +| `predicate` | No | — | Custom NSPredicate filter. Bypasses mode logic. | +| `level` | No | `debug` | Log level: `default`, `info`, `debug` | + +**Capture modes:** +- **`smart`** (default) — Broad stream with topic filtering enabled. Best for general debugging — captures everything, then `read_logs` filters by topic. +- **`app`** — Tight stream, auto-detected bundle ID + process name. Lower volume. Best for production monitoring or when you only care about your app. +- **`verbose`** — Unfiltered. Full `log stream`. Very high volume — use sparingly. + +**Custom predicate example:** +``` +start_log_capture(subsystem: "com.apple.SwiftUI") +``` + +Logs are not retroactive: only what happens after the capture starts is recorded. + +From the CLI, `xcforge log start` runs the capture in the background, writing to `~/.xcforge/capture/` (`XCFORGE_CAPTURE_DIR` moves it), until `xcforge log stop`; `log read` and `log wait` in later commands read it. + +--- + +### stop_log_capture + +Stop log capture and clear the buffer. + +No parameters. + +--- + +### read_logs + +Read buffered logs with topic filtering. Returns a topic menu showing available data. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `include` | No | `["app", "crashes"]` | Array of topic names to include | +| `last` | No | 200 | Return the last N lines, after topic filtering; 0 returns all. The header says how many earlier lines were left out | +| `clear` | No | false | Clear buffer after reading | + +**Response format:** +``` +--- 230 buffered, 42 shown [app, crashes] --- +Topics: app(35) crashes(2) | network(87) lifecycle(12) springboard(8) widgets(0) background(3) system(83) +Hint: include=["network"] to add SSL/TLS + background transfer logs +--- +[42 filtered log lines] +``` + +**8 Topics:** + +| Topic | Always On | Matches | Use Case | +|-------|-----------|---------|----------| +| `app` | Yes | subsystem == bundleId OR process == appName | Your app's os_log, print(), NSLog() | +| `crashes` | Yes | Error and fault lines from any process | Crash detection | +| `network` | No | trustd, nsurlsessiond | SSL/TLS certificates, background transfers | +| `lifecycle` | No | runningboardd, com.apple.runningboard.* | Jetsam, memory pressure, app kills | +| `springboard` | No | SpringBoard process | Push notifications, app state changes | +| `widgets` | No | chronod | WidgetKit timeline, refresh budget | +| `background` | No | com.apple.xpc.activity.* | BGTaskScheduler, background fetch | +| `system` | No | Everything else | WARNING: high volume, use only when needed | + +**Workflow:** Call `read_logs()` first to see the topic menu, then call again with specific topics based on line counts. + +--- + +### wait_for_log + +Wait for a specific log pattern with timeout. Eliminates sleep() hacks. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `pattern` | **Yes** | — | Regex pattern to match | +| `timeout` | No | 30 | Timeout in seconds | +| `simulator` | No | Auto-detect | Simulator (used if log capture not already running) | +| `subsystem` | No | — | Filter by subsystem (used if log capture not already running) | + +**Returns:** The matching lines that arrived after the call started, or a timeout error. It doesn't clear what `read_logs` returns. + +**Use case:** Wait for "app launched" signal, wait for network request completion, wait for crash after specific action. + +--- + +## Console Capture (3 tools) + +Console tools capture stdout/stderr (print(), NSLog()) by launching the app through xcforge's process wrapper. Different from log capture which uses Apple's `log stream`. + +### launch_app_console + +Launch app with stdout/stderr capture enabled. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `bundle_id` | No | Auto-detect (last build) | App bundle identifier | +| `args` | No | — | Space-separated launch arguments string | + +**Note:** This replaces a running instance of the app. Cannot use alongside `launch_app`. + +--- + +### read_app_console + +Read buffered console output. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `last` | No | 200 | Return the last N lines per stream; 0 returns all | +| `clear` | No | false | Clear buffer after reading | +| `stream` | No | `both` | Which stream: `stdout`, `stderr`, or `both` | + +--- + +### stop_app_console + +Stop console capture and terminate the app. + +No parameters. + +"""#), + SkillFile(path: "references/plan-execution.md", contents: #""" +# Plan Execution Tools + +Server-side multi-step UI automation. Replaces 15+ sequential MCP round-trips with 1-2 calls. + +## `run_plan` + +Execute a plan — an array of UI automation steps — server-side with variable binding, verification, and adaptive suspend/resume. + +**Parameters:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `steps` | array | yes | Array of step objects (see Step Types below) | +| `error_strategy` | string | no | `abort_with_screenshot` (default), `abort`, `continue` | +| `timeout` | number | no | Max execution time in seconds. Default: 120 | + +**Returns:** `PlanReport` JSON with per-step results, timing, screenshots, and optional `session_id` if suspended. + +## `run_plan_decide` + +Resume a suspended plan after a `judge` or `handleUnexpected` step. + +**Parameters:** +| Param | Type | Required | Description | +|-------|------|----------|-------------| +| `session_id` | string | yes | From the suspended `run_plan` response | +| `decision` | string | yes | `accept`, `dismiss`, `skip`, `abort`, or freeform guidance | + +**Returns:** Merged `PlanReport` covering all steps before and after the suspend point. + +## Step Types + +Each step is a JSON object with one action key. The key IS the type (first-key-wins parsing). + +### Navigation +```json +{"navigate": "com.example.MyApp"} // Launch/activate app by bundle ID +{"navigateBack": true} // Tap the Back button +``` + +### Finding Elements +```json +{"find": "Login"} // Find by label or accessibility ID +{"find": "Login", "as": "$loginBtn"} // Find and bind to variable +{"find": "btnID", "using": "accessibility id"} // Explicit strategy +{"findAll": ["Save", "Cancel"]} // Find multiple elements +{"findAll": [{"label": "Save", "as": "$save"}, {"label": "Cancel", "as": "$cancel"}]} +``` + +**Strategies:** `accessibility id`, `class name`, `predicate string`, `class chain`. Default: auto-predicate on label OR identifier. + +### Interactions +```json +{"click": "$loginBtn"} // Click a $variable reference +{"click": "Submit"} // Click by label +{"doubleTap": "$cell"} // Double-tap element +{"longPress": "$item", "duration_ms": 1500} // Long press (default 1000ms) +{"swipe": "up"} // Swipe: up, down, left, right +{"swipe": "left", "on": "$carousel"} // Swipe on specific element +{"typeText": "hello@test.com", "into": "$emailField"} // Type into element +{"typeText": "search term"} // Type into focused field +``` + +### Waiting +```json +{"wait": 2} // Wait 2 seconds +{"waitFor": "Welcome", "timeout": 5} // Wait for text to appear (default 10s) +{"waitFor": "Loading", "condition": "disappears", "timeout": 10} // Wait for text to vanish +``` + +### Verification +```json +{"verify": {"screenContains": "Dashboard"}} // Check screen text +{"verify": {"elementExists": {"value": "Save"}}} +{"verify": {"elementNotExists": {"using": "accessibility id", "value": "Error"}}} +{"verify": {"elementLabel": {"id": "title", "expected": "Welcome", "op": "contains"}}} +{"verify": {"elementCount": {"using": "class name", "value": "XCUIElementTypeCell", "expected": 5, "op": "gte"}}} +{"verify": "Dashboard"} // Shorthand for screenContains +``` + +### Conditionals +```json +{"ifElementExists": "Permission Dialog", "using": "accessibility id", "then": [ + {"click": "Allow"} +]} +``` + +### Adaptive (Suspend) +```json +{"judge": "Is the login screen showing the correct branding?"} +{"handleUnexpected": "An alert appeared that wasn't in the test plan"} +``` + +These steps pause execution and return a `session_id`. Call `run_plan_decide` to continue. + +## Variable Binding + +- `{"find": "Login", "as": "$loginBtn"}` — binds the found element +- `{"click": "$loginBtn"}` — resolves the variable +- Variables are scoped to one plan execution +- Undefined variable errors list all available `$names` +- Variables survive suspend/resume + +## Error Strategies + +| Strategy | On Failure | +|----------|-----------| +| `abort_with_screenshot` | Stop, capture diagnostic screenshot (default) | +| `abort` | Stop immediately | +| `continue` | Log failure, proceed to next step | + +## Suspend/Resume Flow + +1. Plan hits `judge` or `handleUnexpected` step +2. Executor snapshots full state (steps, index, variables, results) +3. `run_plan` returns `session_id`, `suspendQuestion`, and screenshot +4. Agent analyzes and calls `run_plan_decide(session_id, decision)` +5. Execution resumes from the next step +6. MCP sessions live in the server's memory for 5 minutes. CLI sessions (`xcforge plan run`) are saved to `~/.xcforge/plan-sessions/` (`XCFORGE_PLAN_SESSION_DIR`) so `xcforge plan decide` in a new process can resume them for an hour; time spent waiting for the decision doesn't count against the plan's timeout + +## Example: Complete Login Flow + +```json +[ + {"find": "Email", "as": "$email"}, + {"typeText": "user@test.com", "into": "$email"}, + {"find": "Password", "as": "$pwd"}, + {"typeText": "secret123", "into": "$pwd"}, + {"find": "Sign In", "as": "$signIn"}, + {"click": "$signIn"}, + {"waitFor": "Dashboard", "timeout": 10}, + {"verify": {"screenContains": "Welcome back"}}, + {"screenshot": "after_login"} +] +``` + +## CLI Equivalents + +```bash +# Run a plan from file +xcforge plan run --file login-flow.json + +# Run with options +xcforge plan run --file plan.json --error-strategy continue --timeout 60 + +# JSON output (same structure as MCP tool) +xcforge plan run --file plan.json --json + +# Read plan from stdin +cat plan.json | xcforge plan run --stdin + +# Resume a suspended plan +xcforge plan decide --session-id --decision accept + +# Abort a suspended plan +xcforge plan decide --session-id --decision abort --json +``` + +"""#), + SkillFile(path: "references/pose.md", contents: #""" +# Pose & Visual Iteration + +Build + install + launch with optional named poses for visual design iteration. + +## pose + +Launch an app into a named visual state (pose) for rapid design iteration. Internally runs the full pipeline: build → install → launch with custom arguments, optionally followed by screenshot. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `name` | **Yes** | — | Pose name to pass to the app (e.g., `"dark-theme"`, `"onboarding-step-2"`) | +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | +| `scheme` | No | Auto-detect | Scheme name | +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `configuration` | No | Debug | Build configuration | +| `key` | No | `-pose` | Argument key to prepend to pose name (e.g., `-pose dark-theme` or `--state onboarding-step-2`). For values starting with `-`, use `--key=-myValue` (ArgumentParser parses bare `--key -myValue` as a missing value). | +| `screenshot` | No | — | Optional file path to capture screenshot after launch | +| `screenshotDelay` (`--screenshot-delay`) | No | `1.5` | Seconds to wait after launch before capturing the screenshot, so the iOS launch-zoom animation can settle. Pass `0` for legacy immediate-capture. Clamped to `[0, 60]`; NaN/inf are treated as `0`. **Unchanged** — the default path still polls WDA for the active bundle id then sleeps the residual budget. | +| `waitFor` (`--wait-for`) | No | — | Readiness signal(s), comma-separated (all must hold): `launch-complete`, `a11y:`, `text:`. When set, **replaces** the `screenshotDelay` poll/sleep with a real element-presence gate (AXP-first — no WDA session needed — then WDA fallback, then a reported degraded wait). Capture happens the instant the signal holds, so it never races a slow cold launch onto the splash/Home. Warn-only: a readiness failure degrades and reports `mode` in the warning, it **never** fails the pose. | +| `timeout` (`--timeout`) | No | `20` | Ceiling in seconds for `waitFor`. A high ceiling costs nothing on the fast path because capture fires the moment the signal holds. `0` skips the gate (pass-through) — capture proceeds immediately without waiting (it never fails the pose). | + +**Returns:** Build status, install confirmation, launch status, app PID. If `screenshot` provided, also returns image data (capture failure only warns, does not fail the pose). When `--wait-for` is set and the gate is not satisfied, the `screenshotWarning` reports the readiness `mode` (`axp` \| `wda` \| `degraded`) and elapsed ms. + +> **Readiness, not a blind sleep.** Prefer `--wait-for a11y:` over a larger `--screenshot-delay`. `a11y:`/`text:` are the strong signals (the real "right screen is up"); `launch-complete` only proves the app is foreground (true at the launch splash, *before* a deep-link nav push). See `wait-ready` in `cli-commands.md`. + +**MCP shape example:** +```json +{ + "tool": "pose", + "input": { + "name": "dark-theme", + "project": "App.xcodeproj", + "scheme": "App", + "simulator": "iPhone 16 Pro", + "screenshot": "/tmp/dark-theme.png" + } +} +``` + +**CLI shape example:** +```bash +xcforge pose dark-theme --project App.xcodeproj --scheme App --simulator "iPhone 16 Pro" --screenshot dark-theme.png +``` + +--- + +## Argument Convention + +Apps with a debug router can read `ProcessInfo.arguments` to implement pose-based navigation: + +```swift +// Example: App-side pose router (no specific app name) +import Foundation + +func routeToDebugPose() { + let args = ProcessInfo.processInfo.arguments + // args[0] = executable path + // args[1...] = launch arguments + + if let poseIndex = args.firstIndex(of: "-pose"), + poseIndex < args.count - 1 { + let poseName = args[poseIndex + 1] + switch poseName { + case "dark-theme": + applyDarkTheme() + navigateToSettings() + case "onboarding-step-2": + skipOnboardingStep1() + presentOnboardingStep2() + case "profile-editing": + setUserID(12345) + navigateToProfileEditor() + default: + break + } + } +} +``` + +Custom key via `key` parameter: +```bash +xcforge pose onboarding-step-3 --key "--debug-state" # Passes --debug-state onboarding-step-3 +``` + +--- + +## Visual Iteration Loop + +Combine `pose`, `screenshot --grid`, and `ui tap-pixel` for efficient layout work: + +1. **Pose to named state:** + ```bash + xcforge pose "profile-dark" --screenshot profile-dark.png --grid + ``` + +2. **View screenshot with grid overlay:** + - Identify target position from grid (e.g., tap point 450, 600) + +3. **Tap at point using grid coordinates:** + ```bash + xcforge ui tap-pixel --x 450 --y 600 + ``` + +4. **Verify result:** + ```bash + xcforge screenshot --grid + ``` + +5. **Iterate:** + - Repeat pose → screenshot → tap → verify until layout is correct + +This loop avoids the overhead of recompiling or re-navigating to the same screen state. + +--- + +## Common Patterns + +### Multi-Step Navigation + +```swift +// App-side: support comma-separated pose names +func routeToDebugPose() { + if let poseIndex = ProcessInfo.processInfo.arguments.firstIndex(of: "-pose"), + poseIndex < ProcessInfo.processInfo.arguments.count - 1 { + let poses = ProcessInfo.processInfo.arguments[poseIndex + 1] + .split(separator: ",") + .map(String.init) + + for pose in poses { + navigateToFlow(named: pose) + } + } +} +``` + +Then launch into nested state: +```bash +xcforge pose "onboarding,profile-setup,dark-theme" --screenshot result.png +``` + +### Screenshot Naming Convention + +Combine pose name with metadata: +```bash +xcforge pose "accessibility-large-text" --screenshot "accessibility-large-text-light.png" +xcforge set_sim_appearance appearance: "dark" +xcforge pose "accessibility-large-text" --screenshot "accessibility-large-text-dark.png" +``` + +### Parallel Multi-Device Iteration + +After establishing a working pose on one device: +```bash +xcforge pose "feature-beta" --simulator "iPhone SE" --screenshot "feature-beta-se.png" +xcforge pose "feature-beta" --simulator "iPhone 16 Pro Max" --screenshot "feature-beta-max.png" +xcforge pose "feature-beta" --simulator "iPad Pro 13-inch (M4)" --screenshot "feature-beta-ipad.png" +``` + +Then visually compare all three screenshots. + +"""#), + SkillFile(path: "references/screenshot-visual.md", contents: #""" +# Screenshot & Visual Tools (7 tools) + +## screenshot + +Take a simulator screenshot. 0.3s latency — 44x faster than alternatives. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `device` | No | — | A physical device's name or UDID: captures it instead (was `device_screenshot`) | +| `format` | No | jpeg | Image format: `png` or `jpeg` | +| `grid` | No | false | Overlay a point-coordinate grid on the image | +| `crop` | No | — | `x,y,width,height` in device points (the coordinates taps use) | +| `maxDimension` | No | — | Shrink so the longer side is at most this many pixels | +| `waitFor` / `timeout` | No | — | Readiness signal to wait for before capturing | + +**Token budget.** A full-resolution iPhone screenshot costs several thousand tokens. Pass +`maxDimension: 800` for a look at the screen, and `crop` to see one area at full detail. The +result line gives the device's point size, the crop, the output pixels and, when scaled, how +many points one pixel covers, so coordinates read off the image map back to taps. + +**3-tier capture strategy:** +1. **Burst** — native CoreSimulator IOSurface framebuffer access (~10ms) +2. **Stream** — ScreenCaptureKit fallback (~20ms) +3. **Safe** — simctl io screenshot last resort (~320ms) + +**Grid overlay:** When `grid: true`, overlays a transparent grid with 50pt minor lines and 100pt labeled divisions. Displays both X and Y axis labels at 100pt intervals. Falls back to ungridded image with a warning if overlay allocation fails. + +**Returns:** Inline base64 image + metadata: device size in points (from the simulator's +screen, not the Simulator window), pixel size, byte size, capture method. The CLI +(`xcforge screenshot capture`, with `--crop` and `--max-dimension`) writes a new file per capture +unless `--output` is given. + +Use `jpeg` (default) for fastest transfer. Use `png` for pixel-perfect visual regression baselines. Use `grid: true` for coordinate verification during layout work or UI automation scripting. + +--- + +## save_visual_baseline + +Save a screenshot as a named baseline for later comparison. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `name` | **Yes** | — | Baseline name (e.g., `"login-screen"`, `"settings-dark"`) | +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `baseline_dir` | No | `visual-baselines/` | Directory to store baselines (relative to working directory) | + +**Returns:** Baseline file path, resolution. + +--- + +## compare_visual + +Compare current screenshot against a saved baseline. Returns pixel-level diff. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `name` | **Yes** | — | Baseline name to compare against | +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `threshold` | No | 0.5 | Acceptable diff percentage (0.0 = exact match) | +| `baseline_dir` | No | `visual-baselines/` | Directory containing baselines (relative to working directory) | + +**Returns:** Match status (pass/fail), diff percentage, diff image path (highlights changed pixels). + +--- + +## multi_device_check + +Run visual checks across multiple simulators in parallel. Installs and launches the app on each device, optionally with Dark Mode and Landscape variants. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `app_path` | **Yes** | — | Path to .app bundle | +| `bundle_id` | No | Auto-detect | App bundle identifier | +| `simulators` | **Yes** | — | Comma-separated simulator names (e.g., `"iPhone 16,iPad Pro 13-inch (M4)"`) | +| `dark_mode` | No | false | Also test Dark Mode appearance | +| `landscape` | No | false | Also test Landscape orientation | +| `settle_time` | No | 3 | Seconds to wait after launch before screenshot | +| `threshold` | No | 1.0 | Pixel diff threshold percentage for same-resolution pairs | + +**Returns per device (and variant):** +- Inline screenshot +- Device info +- Pixel diff percentage (for same-resolution pairs) +- Layout Score (consistency metric across devices) + +**Use case:** Verify a UI change looks correct on iPhone SE, iPhone 16 Pro Max, and iPad simultaneously with Dark Mode variants. + +--- + +## bless + +Codify the baseline-write → test → diff → commit cycle in one call. Saves a visual baseline, runs the specified tests, compares the result against the saved baseline, and suggests a git commit message. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `baseline` | **Yes** | — | Name to use for the visual baseline | +| `tests` | **Yes** | — | Test filter, e.g. `MyTarget/MyTests`. Passed to `build_and_test` | +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | +| `scheme` | No | Auto-detect | Scheme name | +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | + +**Steps performed:** +1. Saves a visual baseline via `save_visual_baseline` +2. Runs tests via `build_and_test` with the provided filter +3. Compares current screenshot against the saved baseline + +**Returns:** Pass/fail status, baseline path, test result summary, and visual diff result. Returns failure if any test fails. + +**CLI:** `xcforge bless --baseline --tests ` + +--- + +## accessibility_check + +Screenshot the current screen at several Dynamic Type sizes and compare each with the first, to +spot truncation and layout breaks. Restores the text size it found. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `sizes` | No | XS, L, XXXL, AccessibilityXXXL | Comma-separated content size categories, or `all` | +| `threshold` | No | 5.0 | Max diff % against the base size | +| `settleTime` | No | 1.5 | Seconds to wait after each size change | + +--- + +## localization_check + +Relaunch the app in several languages (including right-to-left ones) and compare each screenshot +with the first. Relaunches without the locale arguments afterwards. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `bundleId` | No | Last build | App to relaunch | +| `locales` | No | en, de, ja, ar, he | Comma-separated locales, or `all` for 10 | +| `threshold` | No | 10.0 | Max diff % against the base locale | +| `settleTime` | No | 3.0 | Seconds to wait after each relaunch | + +CLI: `xcforge accessibility dynamic-type` and `xcforge accessibility localization`. + +"""#), + SkillFile(path: "references/shared-mac.md", contents: #""" +# Sharing one Mac between sessions + +Every xcodebuild call xcforge makes goes through one entry point, which applies the options below. None of them are on by default; with nothing set, xcforge behaves as a single-user tool. + +| Option | CLI flag | MCP argument | Env var | `.xcforge.yaml` | +| --- | --- | --- | --- | --- | +| DerivedData folder | `--derived-data-path` | `derivedDataPath` | `XCFORGE_DERIVED_DATA_PATH` | `derivedDataPath` | +| Exact result bundle | `--result-bundle-path` | `resultBundlePath` | | | +| Extra xcodebuild args | `--xcodebuild-arg` (repeatable) | `xcodebuildArgs` | | | +| Build lock file | `--lock` | `buildLock` | `XCFORGE_BUILD_LOCK` | `buildLock` | +| Max wait for the lock | `--lock-wait` | `lockWaitSeconds` | `XCFORGE_LOCK_WAIT` | | +| Refuse below free disk (GB) | `--min-free-gb` | `minFreeGB` | `XCFORGE_MIN_FREE_GB` | `minFreeGB` | +| Artifact folder | | | `XCFORGE_ARTIFACT_DIR` | `artifactDir` | +| Parallel compile jobs | `--jobs` | `jobs` | `XCFORGE_JOBS` | `jobs` | +| Report every error in a separate cache | `--all-errors` | `allErrors` | | | +| DerivedData for `--all-errors` | | | `XCFORGE_DIAGNOSTIC_DERIVED_DATA_PATH` | `diagnosticDerivedDataPath` | +| Swift package for `spm` tools | | | | `packagePath` | + +Precedence: flag/argument, then env var, then `.xcforge.yaml`. Relative yaml paths resolve against the yaml's folder. + +They apply to `build compile|run|clean`, `build-test`, `test run|failures|list|rerun-failed` and the MCP tools `build_sim`, `build_run_sim`, `build_compile`, `clean`, `test_sim`, `test_failures`, `test_coverage`, `build_and_diagnose`, `build_and_test`, `list_tests`, `bless`. + +On a 16 GB Mac, `jobs: 4` keeps a build from pushing other sessions into swap. `--all-errors` builds go to their own DerivedData so a "show me every error" run doesn't throw away the main incremental cache. `--from-snapshot` builds share one worktree per repo and take turns on its lock. The worktree stays registered in the repo (`git worktree list` shows it under `~/.xcforge/snapshots`) so the next snapshot build is incremental; remove it with `git worktree remove --force ` when you no longer need it. + +## Build lock + +The lock is a plain `flock` on the file, the same lock macOS `lockf(1)` takes, so xcforge queues correctly next to shell wrappers such as `lockf /tmp/ios.lock xcodebuild ...`. xcforge waiters are served first-come, first-served through tickets in `.queue/`. The lock is held only while xcodebuild compiles or runs tests; `-showBuildSettings`, `-list` and WebDriverAgent are not locked. + +The queue orders xcforge waiters only. `flock` itself is not fair, so a blocking `lockf` waiter can take the lock ahead of a queued xcforge process when the holder releases it. If most builds on the Mac go through `lockf` wrappers, a queued xcforge command can wait through several of them; run those builds through xcforge with the same `--lock` path so everyone shares the queue. + +```bash +xcforge lock status --lock /tmp/ios.lock # holder, queue, wait times +xcforge build-test --lock /tmp/ios.lock # wait in line, then build and test +``` + +MCP: `build_lock_status`. + +## Isolated simulator + +`--isolated-sim` (CLI, `build-test` and `test run`) or `isolatedSimulator: true` (MCP, `build_and_test` and `test_sim`) creates a fresh simulator of the same model and OS, boots it, runs, then deletes it. Costs one cold boot; avoids "failed to launch / No such process" when another session shuts down or reconfigures the shared simulator. + +## Simulator recovery + +`simRecovery` / `--sim-recovery` defaults to `off` everywhere. `auto` reboots a simulator that isn't Booted. `erase` also erases it when a reboot didn't help. Unknown values are an error. + +## Long calls, cancellation and parallel calls + +- MCP clients that send a `progressToken` get a progress notification every 10s with the latest output line. +- Cancelling a call (MCP cancel, client disconnect, Ctrl-C, SIGTERM) stops the xcodebuild xcforge started and every process it spawned. A CLI agent whose shell tool kills `xcforge` after its own timeout no longer leaves an orphaned build behind. +- Within one MCP server, xcodebuild calls on the same DerivedData folder (or the same project's default one) run one at a time, in arrival order. This is always on; `buildLock` adds the same guarantee across processes. + +## Which `.xcforge.yaml` applies + +The one nearest the project being built, walking up to its repo root. Passing `project` from another worktree uses that worktree's file. Values may be quoted and may end in a `# comment`. Unknown keys and bad values are listed in `defaults show` and noted once in the first tool result that uses the file. + +## What xcforge never does on a shared Mac + +- Kill an xcodebuild it didn't start. Hang diagnostics sample the process matched by this run's result bundle path. +- Kill a non-WebDriverAgent process holding the WDA port. +- Read another session's result bundle. `build diagnose`, `test failures` and `test coverage` read this project's last recorded run (`~/.xcforge/last-results/`); `test failures` no longer re-runs the suite when a recorded test bundle exists. +- Delete a result bundle it didn't just create. Generated names are unique per process. + +## Preflight + +Before a build xcforge warns on stderr when the DerivedData volume has under 5 GB free or swap is over 85% used. `minFreeGB` turns low disk into a refusal. + +"""#), + SkillFile(path: "references/simulator-tools.md", contents: #""" +# Simulator Tools (18 tools) + +## list_sims + +List available iOS simulators with state and UDID. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `filter` | No | — | Filter by name substring (case-insensitive) | + +**Returns:** Array of simulators with name, UDID, state (Booted/Shutdown), runtime (e.g., iOS 18.2). + +--- + +## boot_sim + +Boot a simulator. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | **Yes** | — | Simulator name or UDID | + +**Note:** If the simulator is already booted, this is a no-op (not an error). + +--- + +## shutdown_sim + +Shut down a running simulator. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | **Yes** | — | Simulator name, UDID, or `"all"` to shut down all booted simulators | + +--- + +## install_app + +Install a .app bundle on a simulator. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `app_path` | No | Auto-detect (last build) | Path to .app bundle | + +**Auto-detection:** Uses the app path cached from the last `build_sim` or `build_run_sim` call. + +--- + +## launch_app + +Launch an installed app, then watch it for 8s (`XCFORGE_LAUNCH_WATCH_SECONDS`). When it dies, the call fails with the exception, reason, crashed thread's top frames and the `.ips` crash report path. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `bundle_id` | No | Auto-detect (last build) | App bundle identifier | +| `args` | No | — | Launch arguments for the app | +| `env` | No | — | Environment for the app, `KEY=VALUE` strings | +| `url` | No | — | URL or deep link to open once the app is running | +| `terminate` | No | true | Terminate a running copy first | + +--- + +## open_url + +Open a URL or deep link on a simulator. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `url` | **Yes** | — | URL or deep link | +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | + +--- + +## terminate_app + +Terminate a running app. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `bundle_id` | No | Auto-detect (last build) | App bundle identifier | + +--- + +## clone_sim + +Clone a simulator to snapshot its current state (apps, data, settings). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | **Yes** | — | Source simulator name or UDID | +| `name` | **Yes** | — | Name for the cloned simulator | + +**Use case:** Create a pre-configured simulator with test data installed, then clone it for fresh test runs. + +--- + +## erase_sim + +Factory reset a simulator — removes all apps, data, and settings. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | **Yes** | — | Simulator name, UDID, or `"all"` | + +**Warning:** Destructive operation. Cannot be undone. + +--- + +## delete_sim + +Permanently delete a simulator device. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | **Yes** | — | Simulator name or UDID | + +**Warning:** Destructive and irreversible. The simulator must be shut down first. + +--- + +## record_video_start + +Start recording simulator screen to a .mov video file. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `path` | No | `/tmp/xcforge-recording-.mov` | Output file path | + +**Returns:** Output file path. Only one recording can be active at a time. + +**Note:** Call `record_video_stop` to finish recording and finalize the file. + +--- + +## record_video_stop + +Stop an active video recording and return the file path. + +No parameters. + +**Returns:** File path of the completed recording, or error if no recording is active. + +--- + +## set_sim_location + +Set simulated GPS location on a simulator. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `latitude` | **Yes** | — | Latitude coordinate (e.g., 37.7749) | +| `longitude` | **Yes** | — | Longitude coordinate (e.g., -122.4194) | + +--- + +## reset_sim_location + +Reset simulator location to default (removes any simulated GPS override). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | + +--- + +## set_sim_appearance + +Set simulator appearance to light or dark mode. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `appearance` | **Yes** | — | `light` or `dark` | + +--- + +## sim_content_size + +Read or set the Dynamic Type text size. Returns the size it replaced, so you can put it back. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `size` | No | — (read) | `extra-small` … `extra-extra-extra-large`, `accessibility-medium` … `accessibility-extra-extra-extra-large`, `increment`, `decrement` | + +CLI: `xcforge sim content-size [size]` + +--- + +## sim_locale + +Read or set the simulator's locale (`AppleLocale`) and preferred language (`AppleLanguages`). +Returns the previous values. Apps pick up a change on their next launch. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `locale` | No | — (read) | e.g. `fr_FR`, `ar_SA` | +| `language` | No | From the locale | e.g. `fr`, `pt-BR` | + +CLI: `xcforge sim locale [locale] --language ` + +--- + +## app_container + +Path of an installed app's container. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `bundle_id` | No | Last built app | App bundle ID | +| `container` | No | `data` | `app`, `data`, `groups`, or an app group identifier | + +CLI: `xcforge sim container --bundle-id --container data` + +--- + +## sim_push + +Deliver a push notification. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `bundle_id` | No | Last built app | App bundle ID | +| `payload` | **Yes** | — | APNs JSON (`{"aps":{"alert":"Hi"}}`) or plain alert text | + +CLI: `xcforge sim push ''` (or a path to a `.json`/`.apns` file) + +--- + +## sim_privacy + +Grant, revoke or reset a privacy permission so permission alerts don't block a flow. Changing a +permission may terminate the app. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `action` | **Yes** | — | `grant`, `revoke`, `reset` | +| `service` | **Yes** | — | `all`, `calendar`, `contacts`, `contacts-limited`, `location`, `location-always`, `photos`, `photos-add`, `media-library`, `microphone`, `motion`, `reminders`, `siri` | +| `bundle_id` | No | Last built app | App to change; `reset` without one resets every app | + +CLI: `xcforge sim privacy grant photos --bundle-id ` + +--- + +## sim_statusbar + +Override simulator status bar values for clean screenshots. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `time` | No | — | Time string to display (e.g., `"9:41"`) | +| `battery_level` | No | — | Battery level percentage (0-100) | +| `battery_state` | No | — | `charging`, `charged`, or `discharging` | +| `cellular_bars` | No | — | Cellular signal bars (0-4) | +| `wifi_bars` | No | — | WiFi signal bars (0-3) | +| `operator_name` | No | — | Carrier name to display | + +At least one override parameter is required. + +**Use case:** Set consistent status bar for App Store screenshots. + +--- + +## sim_statusbar_clear + +Clear all status bar overrides and restore default values. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | + +--- + +## set_orientation + +Set device orientation via WebDriverAgent. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `orientation` | **Yes** | — | One of: `PORTRAIT`, `LANDSCAPE`, `LANDSCAPE_LEFT`, `LANDSCAPE_RIGHT` | +| `simulator` | No | last one used | Simulator name or UDID; each simulator has its own WDA | + +**Requires:** WDA running on the simulator. + +--- + +## sim_info + +Get simulator display metrics. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | + +**Returns:** +```json +{ + "udid": "ABCD1234-EF56-7890-ABCD-EF1234567890", + "scale": 2.0, + "pixelSize": { + "width": 2796, + "height": 1290 + }, + "pointSize": { + "width": 1398, + "height": 645 + } +} +``` + +**Use case:** Determine screen resolution and pixel scale for coordinate conversion in UI automation (e.g., converting pixel coordinates to point coordinates for `tap`). + +"""#), + SkillFile(path: "references/spm-tools.md", contents: #""" +# Swift Package Tools (5 tools) + +Build, test, and manage Swift packages with `/usr/bin/swift`. + +**Finding the package:** `path` when given (must hold a `Package.swift`), else `.xcforge.yaml packagePath`, else the current folder when it has a `Package.swift`, else the only package in the repo (build output, `.build`, `node_modules` and `Pods` skipped). With several packages and none chosen, the error lists them. + +**Results:** build and test list compiler errors with file:line, failing XCTest and Swift Testing tests with their messages, and test counts. The raw output is kept only as its last 40 lines, and only when a failure parsed to nothing. Build and test take the build lock, `jobs` (`-j N`) and the 600s idle timeout like xcodebuild calls. + +## swift_package_build + +Run `swift build` in a Swift package directory. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `path` | No | Found as above | Folder containing Package.swift | +| `configuration` | No | — | `debug` or `release` | + +**Timeout:** 600 seconds. + +--- + +## swift_package_test + +Run `swift test` in a Swift package directory. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `path` | No | Found as above | Folder containing Package.swift | +| `filter` | No | — | Test filter passed as `--filter` (e.g., `"MyTests"` or `"MyTests/testFoo"`) | +| `parallel` | No | — | Run tests in parallel with `--parallel` | + +**Timeout:** 600 seconds. + +--- + +## swift_package_run + +Run `swift run` to execute a target in a Swift package. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `path` | No | Found as above | Folder containing Package.swift | +| `executable` | No | — | Executable target name (omit if package has a single executable) | +| `arguments` | No | — | Array of arguments passed to the executable after `--` | + +**Timeout:** 300 seconds. + +--- + +## swift_package_list + +List package dependencies as JSON using `swift package show-dependencies`. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `path` | No | Found as above | Folder containing Package.swift | + +**Timeout:** 30 seconds. + +--- + +## swift_package_clean + +Clean Swift package build artifacts using `swift package clean`. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `path` | No | Found as above | Folder containing Package.swift | + +**Timeout:** 30 seconds. + +"""#), + SkillFile(path: "references/test-tools.md", contents: #""" +# Test Tools (7 tools) + +All test tools parse `.xcresult` bundles for structured results — no raw xcodebuild output parsing. + +> **Defaults note:** the `Default` column shows the *fallback*. `configuration` +> and `testplan` (and `project`/`scheme`/`simulator`) are resolved through the +> parameter precedence chain — a committed `.xcforge.yaml` (`configuration:` / +> `testPlan:` / …) overrides the listed fallback when the parameter is omitted. +> See [auto-detection.md](auto-detection.md). +> +> **Timeout note:** `timeoutSeconds` precedence is explicit arg > `.xcforge.yaml +> testTimeout` > `--long`/`long: true` (7200s) > default 1800s. Non-positive +> values are rejected on both the explicit and the YAML path and fall through to +> the next layer with a warning. Separately, a build or test run that prints +> nothing for `idleTimeoutSeconds` (default 600, 0 disables) is killed and +> reported with `timeout: idle`. + +## test_sim + +Run tests and return structured xcresult summary. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | +| `scheme` | No | Auto-detect | Scheme name | +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `configuration` | No | Debug | Build configuration | +| `testplan` | No | — | Test plan name (if project uses test plans) | +| `filter` | No | — | Test filter — accepts relaxed formats (see below) | +| `coverage` | No | false | Enable code coverage collection | +| `for` | No | `agent` | `agent` returns slim JSON (≤10 fields); `human` returns the full text report | +| `rerunFailed` | No | false | Rerun only the last run's failures with its project, scheme, simulator, plan, configuration and env (unless given); skips the build when nothing changed. MCP form of `xcforge test rerun-failed` | +| `gate` | No | false | Subtract IDs in `.xcforge/known-failures.yaml` when computing `succeeded`. Raw failure list unchanged | + +**Test IDs:** one format everywhere: `Target/Suite/test()` for Swift Testing, +`Target/Class/testMethod` for XCTest. `list_tests`, failure lists, `.xcforge/last-failures.json` +and `known-failures.yaml` all use it, and `filter` accepts it as is. Shorter forms work too: +- `Suite/test()` or `Suite` → the target is added when the scheme or test plan has one test target + (with several, pass the target) +- `Target/Suite/test()` → passed through. xcforge writes the `test()()` spelling xcodebuild needs + for Swift Testing; pass the plain form + +**Filter typo suggestions:** If the filter doesn't match any test ID, xcforge runs a Levenshtein fuzzy match and surfaces "Did you mean: ...?" suggestions in the result. + +Use `list_tests` to discover available identifiers if unsure. + +**Run options** (`test_sim` and `build_and_test`; CLI flags on `test run`, `build-test`, `test rerun-failed`): + +| Parameter | CLI | Description | +|-----------|-----|-------------| +| `timeoutSeconds` | `--timeout-seconds` | Total limit for the run | +| `env` | `--env KEY=VALUE` | Environment for the tests. Keys are prefixed with `TEST_RUNNER_` (Xcode strips it inside tests) | +| `skipBuild` | `--no-build` | Test the last build-for-testing without building. `rerun-failed` does this on its own when no file changed since that build; `--build` forces a build | +| `retries` | `--retries N` | Retry each failing test up to N times. A test that fails then passes is reported under `flaky` | +| `iterations` | `--iterations N` | Run each test N times | +| `untilFailure` | `--until-failure` | With `iterations`, stop at the first failure | +| `parallel` | `--parallel` / `--no-parallel` | Turn parallel testing on or off (default: the scheme's setting) | +| `testTimeoutSeconds` | `--test-timeout` | Per-test time allowance; a test running longer fails | +| `includeConsole` | `--include-console` | Add each failing test's last 40 console lines to its failure | + +`retries` can't be combined with `iterations` or `untilFailure`. + +**Test build scope:** build-for-testing uses the test plan, and a filter whose IDs all name a +test target builds only those targets. The build uses the same flags as `build_compile`, so +switching between them doesn't rebuild everything. + +**Failure output:** each failure lists every message with `file:line` and, for parameterized or +repeated tests, the argument or repetition it came from. Failures with the same message are grouped +("N tests, same message"). Text output shows 20 failures and says how many more there are; +`test_failures` lists them all. When the build or the test runner fails before any test reports, +the result says so (`reason` in agent JSON) and `rerun-failed` refuses instead of rerunning nothing. + +**Returns:** +- Total/passed/failed/skipped counts +- Duration +- Per-failure: full test ID, each message with file:line, attachments +- Flaky tests (failed, then passed on retry) +- Failure screenshot paths (auto-exported from xcresult) +- xcresult path (reusable with `test_failures` and `test_coverage`) +- Device info (name, OS version) + +--- + +## test_failures + +Get detailed failure information with optional console output per failed test. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `xcresult_path` | No* | — | Path to .xcresult bundle | +| `project` | No* | Auto-detect | Read this project's last test run | +| `include_console` | No | false | Include console output captured during each failed test | + +*Without `xcresult_path`, reads the project's last test run (`test_sim`, `build_and_test`, `xcforge test run`). It never runs tests; with no recorded run it says so. When that run's build-for-testing failed, it returns the compile errors with file and line instead of failures. + +**Returns per failure:** +- Test class and method name +- Error message +- File path and line number +- Failure screenshot path +- Console output (if `include_console: true`) — shows print/NSLog during that test + +--- + +## test_coverage + +Get code coverage per file, sorted by coverage percentage. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | +| `scheme` | No | Auto-detect | Scheme name | +| `simulator` | No | Auto-detect | Simulator name or UDID | +| `configuration` | No | Debug | Build configuration | +| `min_coverage` | No | 100 | Only show files below this threshold (0-100). Default 100 = show all files. | +| `file` | No | — | Drill into a specific file: shows per-function coverage + execution counts (e.g., `LoginViewModel.swift`) | +| `xcresult_path` | No | — | Reuse existing .xcresult bundle (must have been built with coverage enabled) | + +**Returns:** Overall coverage percentage, per-target coverage, per-file coverage sorted ascending (lowest coverage first). Files below `min_coverage` are highlighted. + +**Note:** Either provide `xcresult_path` to reuse existing results, or `project`/`scheme` to run tests with coverage enabled. + +--- + +## build_and_diagnose + +Build and return structured errors/warnings from xcresult. Unlike `build_sim`, this is optimized for diagnosing build failures — it always parses the xcresult even on success to surface warnings. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | +| `scheme` | No | Auto-detect | Scheme name | +| `simulator` | No | Auto-detect | Simulator name or UDID | +| `configuration` | No | Debug | Build configuration | + +**Returns:** Build status (success/failure), structured errors with file:line, structured warnings with file:line, xcresult path. + +--- + +## build_and_test + +Build then test in one call. Stops on a build failure with structured diagnostics. Takes the same run options as `test_sim`. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | +| `scheme` | No | Auto-detect | Scheme name | +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | +| `configuration` | No | Debug | Build configuration | +| `testplan` | No | — | Test plan name | +| `filter` | No | — | Test filter — accepts relaxed formats (auto-resolves target prefix) | +| `coverage` | No | false | Enable code coverage collection | +| `for` | No | `agent` | `agent` returns slim JSON (≤10 fields); `human` returns the full text report | +| `gate` | No | false | Subtract IDs in `.xcforge/known-failures.yaml` when computing `succeeded`. Raw failure list unchanged | + +**Behavior:** +1. Builds with structured diagnostics (Phase 1) +2. If build fails → returns build errors with file:line, **tests are NOT run** +3. If build succeeds → runs tests (Phase 2), returns pass/fail summary + +**Returns:** Phase indicator (`build` or `test`), build elapsed time, build diagnostics (on failure), test execution result (on success). + +**Failure persistence:** On a failing run, failure IDs and the run settings (project, test plan, configuration, env) are saved to `.xcforge/last-failures.json` (cleared on green). `xcforge test rerun-failed` (or `test_sim` with `rerunFailed: true`) replays exactly those IDs with the same settings, and skips the build when nothing changed. + +--- + +## list_tests + +List available test identifiers for a scheme. Use to discover the correct filter format before running `test_sim` or `build_and_test`. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | +| `scheme` | No | Auto-detect | Scheme name | +| `simulator` | No | Auto-detect (booted) | Simulator name or UDID | + +**Returns:** List of test identifiers in `Target/Suite/test()` / `Target/Class/method` format, grouped by target and class. Includes counts of targets, classes, and test methods. + +**Note:** Requires a build-for-testing step (does not run tests). First call may take time to build. + +--- + +## test_plan_inspect + +Parse and summarize a `.xctestplan` file without running tests. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `plan` | **Yes** | — | Test plan name, with or without `.xctestplan` extension | +| `project` | No | Auto-detect | Path to .xcodeproj or .xcworkspace | + +**Returns:** Test plan name, version, default options (coverage, sanitizers), configurations list, test targets with parallelizable flag and skipped-test counts. Searches `xcshareddata/xctestplans/` and recursively under the project directory. + +"""#), + SkillFile(path: "references/tool-surface.md", contents: #""" +# Tool surface: groups, names, arguments and results + +How the MCP tool list looks to an agent, and how to make it smaller. + +## Tool groups + +Tools come in groups (`build`, `test`, `ui`, `sim`, `git`, `diagnose`, ...; `tool_groups` lists them). The `diagnose` group +starts **off**: its ten workflow tools mostly repeat `build_and_diagnose`, `test_sim` and +`test_failures`. Calling one while it is off says how to turn it on. + +Choose the starting set with `XCFORGE_TOOL_GROUPS` or the `.xcforge.yaml` key `toolGroups` +(the environment variable wins): + +| Value | Effect | +|-------|--------| +| `+diagnose` | Defaults plus the diagnose group | +| `-git,-visual` | Defaults without these groups | +| `build,test,ui` | Only these groups (and `session-state`, which can't be turned off) | +| `all` | Every group | + +At runtime, `tool_groups` lists groups and enables or disables them (`enable: ["diagnose"]`). +After a change xcforge sends `notifications/tools/list_changed`, so clients that follow it fetch the +new list. + +## Merged tools and deprecated names + +| Old name (works for one release) | Use instead | +|----------------------------------|-------------| +| `click_element` | `tap` with `elementId` | +| `tap_by_id` | `tap` with `id` | +| `tap_by` | `tap` with `using`, `value` | +| `tap_coordinates` | `tap` with `x`, `y` | +| `double_tap` | `tap` with `x`, `y`, `count: 2` | +| `long_press` | `tap` with `x`, `y`, `durationMs` | +| `ui_tap_pixel` | `tap` with `x`, `y`, `pixels: true` | +| `indigo_tap` | `tap` with `x`, `y`, `hid: true` | +| `indigo_swipe` | `swipe` with `hid: true` | +| `list_elements` | `get_source` with `format: list` | +| `find_elements` | `find_element` with `all: true` | +| `device_screenshot` | `screenshot` with `device` | + +Old names are not listed but still run, and their result ends with a note naming the replacement. +The log tools (`start_log_capture`/`read_logs`, the app console tools) stay separate: one reads the +system log, the other the app's own stdout and stderr. + +## Argument names + +Every argument is listed in camelCase (`includeConsole`, `derivedDataPath`, `waitFor`). The +snake_case spelling (`include_console`) is accepted too, so older prompts keep working. An argument +that matches nothing is rejected with the closest real name. + +## Results + +- Build and test tools (`build_compile`, `build_sim`, `build_typecheck`, `test_sim`, + `build_and_test`) return compact JSON by default over MCP: `ok`, a one-line `summary`, errors with + full paths, and where to look next. Pass `for: "human"` for the text report. The CLI keeps text + unless `--json` or `--for agent` is given. +- Diagnose and plan tools return compact JSON (no indentation). +- `isError` is true when the call didn't do what was asked: the build or tests failed, a check + failed its threshold, a wait timed out, or the arguments were wrong. +- Read-only tools (status, lists, logs, screenshots, `get_source`, `git_status`, ...) carry + `readOnlyHint`, so clients can run them without asking. + +## Version + +`xcforge --version` prints the version the MCP server also reports in its `initialize` response. + +"""#), + SkillFile(path: "references/ui-automation.md", contents: #""" +# UI Automation Tools (13 tools) + +All UI tools communicate directly with WebDriverAgent via HTTP — no Appium, no Node.js, no Python. + +**Prerequisite:** WebDriverAgent must be running on the target simulator. Check with `wda_status`. + +**Which screen.** Every UI tool takes `simulator` (name or UDID). Each simulator gets its own +WebDriverAgent on its own port (8100 up, saved in `~/.xcforge/wda/simulator-ports.json`), so with +two simulators booted the screenshot and the taps go to the same one. The last simulator named is +used when a call names none; until then calls go to the booted simulator. From the CLI, set +`XCFORGE_SIMULATOR=` (`XCFORGE_DEVICE` for a phone). + +**Waiting instead of sleeping.** Taps, swipes, typing and `handle_alert` take `waitFor` (an +accessibility id or label to appear), `untilGone` (one to disappear) and `timeout` (seconds, +default 10). The result says whether the wait held; a wait that times out marks the call as an error. + +**Shut-down simulators are never booted.** If the target simulator is shut down, UI commands fail with "Simulator is shutdown; boot it first" (and name any simulator that is already booted) instead of booting it to start WebDriverAgent. Boot it yourself, or target the booted one with `--simulator`. + +**Merged tools.** `tap` replaces eight tap tools, `swipe` takes `hid`, `get_source` takes +`format: list` (was `list_elements`), and `find_element` takes `all` (was `find_elements`). The old +names still work for one release and say what replaces them. + +**Permission alerts.** `alert_action: accept|dismiss` on `wda_create_session` (or +`XCFORGE_ALERT_ACTION`) has WDA answer system alerts by itself. `sim_privacy grant` grants a +permission before the app asks. A "not found" error says when an alert is in the way. + +## wda_status + +Check if WebDriverAgent is running and reachable. + +No parameters. + +**Returns:** WDA status, session info, device info. + +--- + +## wda_create_session + +Create a new WDA session, optionally activating an app. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `bundle_id` | No | — | App to activate (optional) | +| `wda_url` | No | http://localhost:8100 | Custom WDA URL | +| `alert_action` | No | `none` | `accept` or `dismiss` system alerts automatically for this session | + +A session xcforge recreates by itself (after a WDA hiccup or restart) never relaunches the app +(`forceAppLaunch: false`), so screen state and launch arguments survive. + +**Bundle id binding (v1.4.1+).** When `bundle_id` is provided, `WDAClient` persists +it as the active bundle id and threads it through every subsequent recreate +(mid-call session-dead retry, WDA restart). Auto-bootstrapped sessions therefore +stay bound to the app under test rather than reverting to whatever app WDA picks. +The CLI form (`xcforge ui session --bundle-id `) verifies the binding via +`GET /session/` and exits non-zero on a `CFBundleIdentifier` mismatch — silent +no-binds become explicit failures. Empty/whitespace bundle ids are rejected. + +**WDA can't see SwiftUI sheets — improved in v1.4.1.** Earlier releases needed +the bundle-id binding above and still missed `.sheet` / `fullScreenCover` +content because `XCUIApplication.snapshotWithError:` only walks the keyWindow. +As of v1.4.1, WDA reads sheet-window content automatically for source dumps +and coordinate taps — `/source` (json/xml/description) merges per-window +snapshots when the app exposes more than one `UIWindow`, and coordinate +`/wda/tap` re-roots on the topmost hittable window. Element find retries the +sheet window only when the keyWindow query returns no matches, so an id that +exists in *both* windows resolves to the keyWindow element. If `ui ls` still +misses sheet elements, file a bug with `--source wda` output attached. + +**Structured failure + bounded auto-heal.** A failed default-path +`wda_create_session` no longer returns the opaque +`Session creation failed: ExitCode(rawValue: 1)`. It returns a structured body: + +- `error: "wda_session_create_failed"` +- `cause` — one of `wda_runner_not_running`, `wda_runner_build_failed`, + `no_booted_simulator`, `bundle_not_installed`, `session_bind_rejected`, + `unknown` (with raw stderr in `detail`) +- `detail` — human explanation +- `remediation` — a copy-pasteable command +- `recovered` — `true` if the bounded one-shot auto-heal succeeded +- `appForeground` — `true`/`false` whenever WDA can resolve the foreground + bundle; null/omitted only when WDA is unreachable or the foreground bundle is + unresolvable (never a false negative). Additive (`encodeIfPresent`) + +Classification uses cheap state probes (booted simulator? `:8100` reachable? +`xcforgeWDA-deploy` DerivedData present?). On a recoverable runner cause it +attempts **exactly one** bounded rebuild+relaunch+rebind via the existing +`ensureWDARunning()` orchestrator (its policy is *not* widened — this is only a +one-shot wrapper at the `ui session` level, never inside the element-path +`ensureSession()`). A `session_bind_rejected` (WDA bound a different bundle than +requested) is **not** auto-recovered — the app/bundle is the problem, not the +runner — and fails fast with the structured detail. The CLI form +(`xcforge ui session`) supports `--no-autoheal` (preserve fail-fast) and +`--relaunch-app` (opt-in; auto-heal never relaunches the user app otherwise). + +**`appForeground` on interaction results.** `tap_by_id`, `tap_by`, +`click_element`, `find_element`, and `screenshot` append/emit an +`appForeground` token (`true`/`false`) **whenever WDA can resolve the +foreground bundle** — derived from `verifyActiveBundleId()` vs the recorded +active bundle, **not** WDA `/status` (which reflects the WDA process, not the +target app). It is null/omitted only when WDA is unreachable or the foreground +bundle is unresolvable, so an agent never sees a false negative. The field is +additive (`encodeIfPresent`; old consumers ignore unknown keys). + +--- + +## handle_alert + +The smartest alert handler available. Handles system permission dialogs, ContactsUI dialogs, and in-app alerts. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `action` | **Yes** | — | `accept`, `dismiss`, `get_text`, `accept_all`, `dismiss_all` | +| `button_label` | No | Smart default | Specific button text to tap | + +**3-tier alert search order:** +1. **Springboard** — system dialogs (Location, Camera, Notifications, Tracking) +2. **ContactsUI** — iOS 18+ Contacts "Limited Access" dialog (separate process) +3. **Active app** — in-app UIAlertController + +**Smart button defaults:** +- Accept: Allow > Allow While Using App > OK > Continue > last button +- Dismiss: Don't Allow > Cancel > Not Now > first button + +**Batch modes:** `accept_all` / `dismiss_all` loop server-side through all visible alerts. Returns details of every handled alert. One HTTP roundtrip instead of N. + +**Best practice:** Call `handle_alert(action: "accept_all")` immediately after first app launch to clear all permission dialogs in one call. + +--- + +## tap + +Tap one target. Pass exactly one of `elementId`, `id`, `using` + `value`, or `x` + `y`. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `elementId` | One target | — | Element ID from `find_element` | +| `id` | One target | — | Accessibility id; found and tapped in one call, re-found and retried once if stale | +| `using` + `value` | One target | — | Any WDA query (`accessibility id`, `class name`, `predicate string`, `class chain`), same retry | +| `x` + `y` | One target | — | Point coordinates (screenshot pixels with `pixels: true`) | +| `count` | No | 1 | 2 double-taps. Needs `x`, `y` | +| `durationMs` | No | — | Hold this long (long press). Needs `x`, `y` | +| `pixels` | No | false | `x`, `y` are screenshot pixels, divided by the simulator's scale | +| `hid` | No | false | Native HID on a simulator (sub-5ms, bypasses WDA, falls back to it). Not yet supported on Xcode 27: WDA is used and the result says so | + +Coordinates are in the interface's own points. With `hid`, WDA's orientation is read first so taps +land correctly in landscape and upside down (inferred mapping; check on a Mac). + +**Replaces** (still callable for one release, with a note in the result): `click_element` +(`elementId`), `tap_by_id` (`id`), `tap_by` (`using`, `value`), `tap_coordinates` (`x`, `y`), +`double_tap` (`count: 2`), `long_press` (`durationMs`), `ui_tap_pixel` (`pixels: true`), +`indigo_tap` (`hid: true`). + +--- + +## find_element + +Find a single UI element. Supports auto-scrolling to off-screen elements. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `using` | **Yes** | — | Strategy: `"accessibility id"`, `"class name"`, `"predicate string"`, `"class chain"` | +| `value` | **Yes** | — | Search value matching the strategy | +| `scroll` | No | false | Enable auto-scroll to find off-screen elements | +| `direction` | No | auto | Scroll direction: `auto` (smart — detects boundaries, reverses automatically), `up`, `down`, `left`, `right` | +| `maxSwipes` | No | 10 | Maximum scroll attempts | +| `index` | No | 0 | Which match to use when several match | +| `timeout` | No | — | Seconds to wait for the element to appear | +| `untilGone` | No | false | Wait for the element to disappear instead | +| `all` | No | false | Return every match's element ID, rect, label and type (counting list items) | + +When several elements match, the result says how many and lists up to 10 with their label and +frame, so you can pick one with `index` or tighten the query. + +**Auto-scroll 3-tier fallback:** +1. `scrollToVisible` — WDA native scroll (works with UIKit) +2. Calculated drag — computed from screen geometry +3. Iterative swipe — with stall detection and automatic direction reversal + +**Returns:** Element ID (use with `tap`, `get_text`, etc.), element rect, label, type. + +### Strategy Guide + +| Strategy | Example Value | Best For | +|----------|--------------|----------| +| `accessibility id` | `"Save"`, `"login_button"` | Elements with accessibility identifiers (most reliable) | +| `predicate string` | `"label == 'Submit' AND type == 'XCUIElementTypeButton'"` | Complex queries with multiple conditions | +| `class chain` | `"**/XCUIElementTypeCell[\`label CONTAINS 'Item'\`]"` | Hierarchical queries, table cells | +| `class name` | `"XCUIElementTypeButton"` | Find by element type (least specific) | + +--- + +## swipe + +Swipe from one point to another. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `startX` | **Yes** | — | Start X (points) | +| `startY` | **Yes** | — | Start Y (points) | +| `endX` | **Yes** | — | End X (points) | +| `endY` | **Yes** | — | End Y (points) | +| `durationMs` | No | 300 | Swipe duration in milliseconds | +| `hid` | No | false | Native HID on a simulator (sub-5ms per step, bypasses WDA, falls back to it). Not yet supported on Xcode 27: WDA is used and the result says so | + +--- + +## pinch + +Pinch/zoom gesture. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `center_x` | **Yes** | — | Center X coordinate | +| `center_y` | **Yes** | — | Center Y coordinate | +| `scale` | **Yes** | — | Scale factor: >1 = zoom in, <1 = zoom out | +| `duration_ms` | No | 500 | Gesture duration | + +--- + +## drag_and_drop + +Element-to-element or coordinate-based drag and drop. 1 call instead of 3. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `source_element` | No* | — | Source element ID | +| `from_x` | No* | — | Source X coordinate | +| `from_y` | No* | — | Source Y coordinate | +| `target_element` | No* | — | Target element ID | +| `to_x` | No* | — | Target X coordinate | +| `to_y` | No* | — | Target Y coordinate | +| `press_duration_ms` | No | 1000 | How long to press before dragging | +| `hold_duration_ms` | No | 300 | How long to hold over target before dropping | + +*Provide either `source_element` OR `from_x`/`from_y` for source. Same for target. Can mix element and coordinate modes. + +**Use cases:** Reorderable lists, Kanban boards, sliders, canvas objects. + +--- + +## type_text + +Type text into the focused element, or a specified one. Fails when nothing has focus and no +element is given (tap the field first). + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `text` | No | — | Text to type | +| `element_id` | No | Currently focused | Target element | +| `clear_first` | No | false | Clear existing text before typing | +| `key` | No | — | Named key to press after the text: `return`, `delete`, `tab`, `escape` | +| `dismiss_keyboard` | No | false | Hide the keyboard afterwards | +| `secure` | No | auto | Keep the text out of the result (automatic for secure fields) | + +--- + +## get_text + +Get text content of an element. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `element_id` | **Yes** | — | Element ID | + +--- + +## get_source + +The on-screen view hierarchy. Starts WebDriverAgent when it isn't running. + +| Parameter | Required | Default | Description | +|-----------|----------|---------|-------------| +| `format` | No | json | `list`, `json`, `xml` or `description` | +| `scope` | No | — | `format: list` only: a11y-id to restrict the listing to, with its descendants | +| `source` | No | `auto` | `format: list` only: `auto` (WDA when a simulator is booted, else AXP), `wda`, `axp` | + +**`format: list`** (CLI: `xcforge ui ls`) is the cheap one: one line per element, +` |

/xcf----.`. + /// Two runs started in the same second (or by two processes) never share a path. + public static func uniqueArtifactPath(prefix: String, extension ext: String) -> String { + let ts = Int(Date().timeIntervalSince1970) + let pid = ProcessInfo.processInfo.processIdentifier + let rand = UUID().uuidString.prefix(6).lowercased() + let directory = artifactDirectory() + ArtifactPruning.pruneOnce(directory: directory) + return "\(directory)/xcf-\(prefix)-\(ts)-\(pid)-\(rand).\(ext)" + } + + /// Result bundle path for one phase. When the caller fixed `resultBundlePath`, the + /// final phases (`build`, `test`, `fail`) use it exactly and earlier phases get a + /// sibling path, so both bundles survive. + public static func resultBundlePath(prefix: String) -> String { + if let fixed = current.resultBundlePath, !fixed.isEmpty { + let finalPrefixes: Set = ["build", "test", "fail"] + if finalPrefixes.contains(prefix) { return fixed } + let base = fixed.hasSuffix(".xcresult") ? String(fixed.dropLast(".xcresult".count)) : fixed + return "\(base)-\(prefix).xcresult" + } + return uniqueArtifactPath(prefix: prefix, extension: "xcresult") + } + + // MARK: - MCP plumbing + + /// Tools whose xcodebuild calls honour these options. Their schemas advertise the keys. + public static let mcpToolNames: Set = [ + "build_sim", "build_run_sim", "build_compile", "build_typecheck", "clean", + "test_sim", "test_failures", "test_coverage", "build_and_diagnose", "build_and_test", + "list_tests", "bless", + ] + + /// JSON-schema properties added to each tool in `mcpToolNames`. + public static let mcpSchemaProperties: [String: Value] = [ + "derivedDataPath": .object([ + "type": .string("string"), + "description": .string("Passed to xcodebuild as -derivedDataPath. Default: Xcode's shared DerivedData."), + ]), + "resultBundlePath": .object([ + "type": .string("string"), + "description": .string( + "Exact .xcresult path for the final build or test phase. Replaced if it already exists."), + ]), + "xcodebuildArgs": .object([ + "type": .string("array"), + "items": .object(["type": .string("string")]), + "description": .string( + "Extra xcodebuild arguments inserted before the action, e.g. [\"-jobs\", \"6\"] or [\"SWIFT_TREAT_WARNINGS_AS_ERRORS=NO\"]." + ), + ]), + "buildLock": .object([ + "type": .string("string"), + "description": .string( + "Lock file to hold while xcodebuild runs. Same flock as macOS lockf(1); waiters queue first-come, first-served." + ), + ]), + "lockWaitSeconds": .object([ + "type": .string("integer"), + "description": .string("Give up after waiting this long for buildLock. Default: 3600."), + ]), + "minFreeGB": .object([ + "type": .string("number"), + "description": .string( + "Refuse to build when the DerivedData volume has less free space than this. Default: warn only."), + ]), + "idleTimeoutSeconds": .object([ + "type": .string("integer"), + "description": .string( + "Kill xcodebuild after this many seconds with no output. Default: 600. 0 disables it."), + ]), + "continueAfterErrors": .object([ + "type": .string("boolean"), + "description": .string( + "Keep building after the first error so one run reports every error. Default: true."), + ]), + "jobs": .object([ + "type": .string("integer"), + "description": .string("Passed to xcodebuild as -jobs for compiling actions. Default: one per core."), + ]), + "allErrors": .object([ + "type": .string("boolean"), + "description": .string( + "Build in a separate diagnostic DerivedData slot and report every error, without invalidating the main cache." + ), + ]), + "defaultFlags": .object([ + "type": .string("boolean"), + "description": .string( + "Set false to drop the flags xcforge adds (-skipMacroValidation, -parallelizeTargets, COMPILATION_CACHE_ENABLE_CACHING=YES). Default: true." + ), + ]), + ] + + /// Parse the option keys out of an MCP argument dictionary. + public static func fromMCPArguments(_ args: [String: Value]?) -> XcodebuildOptions { + guard let args else { return XcodebuildOptions() } + func number(_ v: Value?) -> Double? { + guard let v else { return nil } + if let d = v.doubleValue { return d } + if let i = v.intValue { return Double(i) } + if let s = v.stringValue { return Double(s) } + return nil + } + let extra = args["xcodebuildArgs"]?.arrayValue?.compactMap { $0.stringValue } ?? [] + return XcodebuildOptions( + derivedDataPath: args["derivedDataPath"]?.stringValue, + resultBundlePath: args["resultBundlePath"]?.stringValue, + extraArgs: extra, + lockPath: args["buildLock"]?.stringValue, + lockWaitSeconds: number(args["lockWaitSeconds"]), + minFreeGB: number(args["minFreeGB"]), + artifactDir: nil, + idleTimeoutSeconds: number(args["idleTimeoutSeconds"]), + continueAfterErrors: args["continueAfterErrors"]?.boolValue, + defaultFlags: args["defaultFlags"]?.boolValue, + jobs: number(args["jobs"]).map { Int($0) }, + allErrors: args["allErrors"]?.boolValue + ) + } + + /// Return `tool` with the option properties merged into its input schema. + static func augment(_ tool: Tool) -> Tool { + guard mcpToolNames.contains(tool.name), + case .object(var schema) = tool.inputSchema + else { return tool } + var properties = schema["properties"]?.objectValue ?? [:] + for (key, value) in mcpSchemaProperties where properties[key] == nil { + properties[key] = value + } + schema["properties"] = .object(properties) + return Tool( + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: .object(schema), + annotations: tool.annotations, + outputSchema: tool.outputSchema, + icons: tool.icons, + _meta: tool._meta + ) + } +} + +/// Result bundles and diagnostic snapshots xcforge writes pile up in the artifact directory +/// (2-3 GB a day on a busy Mac). The first artifact path a process asks for removes xcforge's +/// own artifacts older than `maxAge`. +enum ArtifactPruning { + /// Two days: long enough for `test failures` and coverage to read yesterday's run. + static let maxAge: TimeInterval = 2 * 24 * 3600 + + private static let pruned = OSAllocatedUnfairLock(initialState: Set()) + + static func pruneOnce(directory: String) { + guard pruned.withLock({ $0.insert(directory).inserted }) else { return } + prune(directory: directory, now: Date()) + } + + /// Remove `xcf-*` result bundles, snapshots and logs older than `maxAge`. Returns the names removed. + @discardableResult + static func prune(directory: String, now: Date, maxAge: TimeInterval = maxAge) -> [String] { + let fm = FileManager.default + guard let names = try? fm.contentsOfDirectory(atPath: directory) else { return [] } + var removed: [String] = [] + for name in names where isXCForgeArtifact(name) { + let path = (directory as NSString).appendingPathComponent(name) + guard let modified = (try? fm.attributesOfItem(atPath: path))?[.modificationDate] as? Date, + now.timeIntervalSince(modified) > maxAge + else { continue } + if (try? fm.removeItem(atPath: path)) != nil { removed.append(name) } + } + return removed + } + + /// `xcf----.`, as `uniqueArtifactPath` names them. + static func isXCForgeArtifact(_ name: String) -> Bool { + guard name.hasPrefix("xcf-") else { return false } + return [".xcresult", ".txt", ".log"].contains { name.hasSuffix($0) } + } +} diff --git a/Sources/XCForgeKit/Tools/AccessibilityTools.swift b/Sources/XCForgeKit/Tools/AccessibilityTools.swift index fada399..b5705ce 100644 --- a/Sources/XCForgeKit/Tools/AccessibilityTools.swift +++ b/Sources/XCForgeKit/Tools/AccessibilityTools.swift @@ -226,15 +226,8 @@ public enum AccessibilityTools { } } - // Restore original size - if let original = originalSize { - _ = try? await env.shell.xcrun(timeout: 10, "simctl", "ui", sim, "content_size", original) - } else { - _ = try? await env.shell.xcrun( - timeout: 10, "simctl", "ui", sim, - "content_size", "UICTContentSizeCategoryL" - ) - } + // Restore the original size (an unreadable value means the system default, large) + _ = try? await env.shell.xcrun(timeout: 10, "simctl", "ui", sim, "content_size", originalSize ?? "large") guard screenshots.count >= 2 else { return .fail( @@ -430,14 +423,10 @@ public enum AccessibilityTools { )) } - // Relaunch with first locale to restore + // Relaunch without locale arguments, so the app is back in the simulator's own language + // rather than left in whichever locale ran first. _ = try? await env.shell.xcrun(timeout: 5, "simctl", "terminate", sim, bundleId) - let baseLang = locales[0].split(separator: "-").first.map(String.init) ?? locales[0] - _ = try? await env.shell.xcrun( - timeout: 15, "simctl", "launch", sim, bundleId, - "-AppleLanguages", "(\(baseLang))", - "-AppleLocale", locales[0] - ) + _ = try? await env.shell.xcrun(timeout: 15, "simctl", "launch", sim, bundleId) let elapsed = String(format: "%.1f", CFAbsoluteTimeGetCurrent() - start) let allPassed = results.allSatisfy(\.passed) @@ -514,7 +503,11 @@ public enum AccessibilityTools { // MARK: - Helpers private static func getCurrentContentSize(sim: String, env: Environment) async -> String? { - // Try to read the current content size preference + // `simctl ui content_size` with no value prints the current one. + if let result = try? await env.shell.xcrun(timeout: 5, "simctl", "ui", sim, "content_size"), result.succeeded { + let value = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + if !value.isEmpty, !value.contains(" ") { return value } + } guard let result = try? await env.shell.xcrun( timeout: 5, "simctl", "spawn", sim, diff --git a/Sources/XCForgeKit/Tools/BlessProvider.swift b/Sources/XCForgeKit/Tools/BlessProvider.swift index f8fbd49..17d9666 100644 --- a/Sources/XCForgeKit/Tools/BlessProvider.swift +++ b/Sources/XCForgeKit/Tools/BlessProvider.swift @@ -115,12 +115,6 @@ public enum BlessTools: ToolProvider { lines.append("Visual diff: skipped (baseline path not captured from save output)") } - // Step 4: Suggest commit message - let slug = commitSlug(from: input.tests) - lines.append("") - lines.append("Suggested commit:") - lines.append(" git commit -m \"[bless] \(slug)\"") - return testPassed ? .ok(lines.joined(separator: "\n")) : .fail(lines.joined(separator: "\n")) } @@ -142,14 +136,4 @@ public enum BlessTools: ToolProvider { } return nil } - - static func commitSlug(from filter: String) -> String { - let slug = - filter - .lowercased() - .replacingOccurrences(of: "/", with: "-") - .replacingOccurrences(of: " ", with: "-") - let trimmed = String(slug.prefix(40)) - return trimmed.trimmingCharacters(in: .init(charactersIn: "-")) - } } diff --git a/Sources/XCForgeKit/Tools/BuildAgentResult.swift b/Sources/XCForgeKit/Tools/BuildAgentResult.swift new file mode 100644 index 0000000..47bc0ff --- /dev/null +++ b/Sources/XCForgeKit/Tools/BuildAgentResult.swift @@ -0,0 +1,84 @@ +import Foundation +import MCP + +/// The slim JSON build tools return over MCP unless `for: "human"` asks for the text report: +/// what happened, the errors with full paths, and where to look next. +extension BuildTools { + struct AgentBuildResult: Codable, Equatable { + var ok: Bool + var summary: String + var errors: [String]? + var warnings: Int? + var failureReason: String? + var xcresult: String? + var bundleId: String? + var appPath: String? + var diagnostic: String? + } + + /// Build tools that return `AgentBuildResult` by default and take `for`. + static let agentJSONTools: Set = ["build_compile", "build_sim", "build_typecheck"] + + static let forProperty: Value = .object([ + "type": .string("string"), + "enum": .array([.string("agent"), .string("human")]), + "description": .string("'agent' (default) returns slim JSON; 'human' returns the text report."), + ]) + + /// Add `for` to the build tools that return agent JSON. + static func augmentFor(_ tool: Tool) -> Tool { + guard agentJSONTools.contains(tool.name), case .object(var schema) = tool.inputSchema, + case .object(var properties)? = schema["properties"], properties["for"] == nil + else { return tool } + properties["for"] = forProperty + schema["properties"] = .object(properties) + return tool.replacing(inputSchema: .object(schema)) + } + + /// True unless the call asked for the text report. + static func wantsAgentJSON(_ args: [String: Value]?) -> Bool { + args?["for"]?.stringValue?.lowercased() != "human" + } + + static func agentResult(_ execution: BuildExecution, action: String) -> AgentBuildResult { + var errors: [String] = [] + if let issues = execution.issues, issues.contains(where: { $0.severity == .error }) { + errors = issues.filter { $0.severity == .error }.map(fullIssueText) + } else if let structured = execution.structuredErrors, !structured.isEmpty { + errors = structured + } else { + errors = execution.errors + } + let shown = Array(errors.prefix(SwiftPMOutput.listLimit * 2)) + let warnings = execution.warningCount ?? execution.issues?.filter { $0.severity == .warning }.count + var summary = "\(action) \(execution.succeeded ? "succeeded" : "failed") in \(execution.elapsed)s" + if !execution.succeeded, !errors.isEmpty { + summary += ": \(errors.count) error\(errors.count == 1 ? "" : "s")" + if errors.count > shown.count { summary += " (first \(shown.count) listed)" } + } + return AgentBuildResult( + ok: execution.succeeded, summary: summary, errors: shown.isEmpty ? nil : shown, + warnings: warnings.flatMap { $0 > 0 ? $0 : nil }, failureReason: execution.failureReason, + xcresult: execution.xcresultPath, bundleId: execution.bundleId, appPath: execution.appPath, + diagnostic: execution.hangDiagnosticPath) + } + + /// `/full/path/File.swift:12:5: message`, so the agent can open the file without searching. + static func fullIssueText(_ issue: TestTools.BuildIssueObservation) -> String { + guard let location = issue.location else { return issue.message } + var place = location.filePath + if let line = location.line { + place += ":\(line)" + if let column = location.column { place += ":\(column)" } + } + return "\(place): \(issue.message)" + } + + static func agentJSON(_ execution: BuildExecution, action: String) -> CallTool.Result { + let result = agentResult(execution, action: action) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .withoutEscapingSlashes] + let json = (try? encoder.encode(result)).flatMap { String(data: $0, encoding: .utf8) } ?? result.summary + return result.ok ? .ok(json) : .fail(json) + } +} diff --git a/Sources/XCForgeKit/Tools/BuildProvider.swift b/Sources/XCForgeKit/Tools/BuildProvider.swift index 43ef0d6..eb64d4e 100644 --- a/Sources/XCForgeKit/Tools/BuildProvider.swift +++ b/Sources/XCForgeKit/Tools/BuildProvider.swift @@ -80,6 +80,26 @@ public enum BuildTools { } /// Classify the reason a build failed from xcodebuild stderr. + /// SYMROOT/OBJROOT that put a `-target` build where a scheme build into `derivedData` puts + /// its products and intermediates. + static func targetBuildRoots(derivedData: String) -> [String] { + let root = (derivedData as NSString).expandingTildeInPath + return [ + "SYMROOT=\((root as NSString).appendingPathComponent("Build/Products"))", + "OBJROOT=\((root as NSString).appendingPathComponent("Build/Intermediates.noindex"))", + ] + } + + /// xcodebuild rejecting its own arguments ("xcodebuild: error: The flag -scheme ... is + /// required ..."), which is not a compile error. + static func xcodebuildUsageError(_ output: String) -> String? { + for line in output.split(separator: "\n") { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("xcodebuild: error:") { return trimmed } + } + return nil + } + static func classifyFailureReason(stderr: String) -> String { let lower = stderr.lowercased() if isInfrastructureMessage(stderr) { @@ -119,7 +139,7 @@ public enum BuildTools { let errorIssues = issues.filter { $0.severity == .error } if !errorIssues.isEmpty { return Array( - errorIssues.prefix(20).map { issue in + errorIssues.prefix(50).map { issue in if let loc = issue.location { var s = loc.filePath if let line = loc.line { s += ":\(line)" } @@ -135,7 +155,9 @@ public enum BuildTools { return tail.isEmpty ? [] : [tail] } - public static let tools: [Tool] = [ + public static let tools: [Tool] = coreTools + typecheckTools + + static let coreTools: [Tool] = [ Tool( name: "build_sim", description: """ @@ -168,7 +190,7 @@ public enum BuildTools { "long": .object([ "type": .string("boolean"), "description": .string( - "Use 1800s timeout instead of the default 180s for large projects." + "Raise the total time limit from 1800s to 7200s. Hangs are caught by idleTimeoutSeconds either way." ), ]), "diagnose": .object([ @@ -183,9 +205,9 @@ public enum BuildTools { Tool( name: "build_run_sim", description: """ - Build, install, and launch an iOS app on a simulator in one call. \ - Runs build, settings extraction, simulator boot, and Simulator.app \ - in parallel for maximum speed. Equivalent to Xcode's Cmd+R. \ + Build, install, and launch an iOS app on a simulator in one call (Xcode's Cmd+R). \ + Boots the simulator once the build succeeds, then reports whether the app is still \ + running 2s after launch, with the crash reason and top frames when it isn't. \ Project, scheme, and simulator are auto-detected if omitted. """, inputSchema: .object([ @@ -214,7 +236,7 @@ public enum BuildTools { "long": .object([ "type": .string("boolean"), "description": .string( - "Use 1800s timeout instead of the default 180s for large projects that take longer to build." + "Raise the total time limit from 1800s to 7200s. Hangs are caught by idleTimeoutSeconds either way." ), ]), "diagnose": .object([ @@ -223,6 +245,18 @@ public enum BuildTools { "Capture a diagnostic snapshot on completion even without a hang, for baseline inspection." ), ]), + "args": .object([ + "type": .string("array"), "items": .object(["type": .string("string")]), + "description": .string("Launch arguments passed to the app."), + ]), + "env": .object([ + "type": .string("array"), "items": .object(["type": .string("string")]), + "description": .string("Environment for the app, as KEY=VALUE strings."), + ]), + "url": .object([ + "type": .string("string"), + "description": .string("URL or deep link to open once the app is running."), + ]), ]), ]) ), @@ -260,7 +294,7 @@ public enum BuildTools { "long": .object([ "type": .string("boolean"), "description": .string( - "Use 1800s timeout instead of the default 180s for large projects." + "Raise the total time limit from 1800s to 7200s. Hangs are caught by idleTimeoutSeconds either way." ), ]), "diagnose": .object([ @@ -269,14 +303,21 @@ public enum BuildTools { "Capture a diagnostic snapshot on completion even without a hang." ), ]), + "fromSnapshot": .object([ + "type": .string("boolean"), + "description": .string( + "Build a snapshot of the working tree (a git worktree under ~/.xcforge/snapshots), so edits made during the build don't affect it. Errors name the real files." + ), + ]), ]), ]) ), Tool( name: "clean", description: """ - Clean Xcode build artifacts for a project/scheme. \ - Project and scheme are auto-detected if omitted. + Clean the scheme's simulator build products. derivedData: true also deletes this \ + project's DerivedData folder (only this project's), the fix for "database is locked" \ + or a stale index. Project and scheme are auto-detected if omitted. """, inputSchema: .object([ "type": .string("object"), @@ -289,6 +330,29 @@ public enum BuildTools { "type": .string("string"), "description": .string("Xcode scheme name. Auto-detected if omitted."), ]), + "configuration": .object([ + "type": .string("string"), + "description": .string("Build configuration (Debug/Release). Default: Debug"), + ]), + "derivedData": .object([ + "type": .string("boolean"), + "description": .string("Also delete this project's DerivedData folder. Default: false"), + ]), + ]), + ]) + ), + Tool( + name: "build_lock_status", + description: """ + Show who holds the shared build lock and who is queued behind it, with wait times. The lock path comes from the 'lock' argument, XCFORGE_BUILD_LOCK, or .xcforge.yaml buildLock. + """, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "lock": .object([ + "type": .string("string"), + "description": .string("Lock file path. Defaults to the configured build lock."), + ]) ]), ]) ), @@ -332,11 +396,17 @@ public enum BuildTools { let configuration: String? let long: Bool? let diagnose: Bool? + var args: [String]? = nil + var env: [String]? = nil + var url: String? = nil + var fromSnapshot: Bool? = nil } struct CleanInput: Decodable { let project: String? let scheme: String? + var configuration: String? = nil + var derivedData: Bool? = nil } struct DiscoverInput: Decodable { @@ -356,39 +426,89 @@ public enum BuildTools { configuration: String = "Debug", long: Bool = false, diagnose: Bool = false, + compileOnly: Bool = false, + target: String? = nil, + fromSnapshot: Bool = false, env: Environment = .live ) async throws -> BuildExecution { - let resolvedProject = try await env.session.resolveProject(project) - let resolvedScheme = try await env.session.resolveScheme(scheme, project: resolvedProject) - let resolvedSimulator = try await env.session.resolveSimulator(simulator) + guard fromSnapshot else { + return try await runBuild( + project: project, scheme: scheme, simulator: simulator, configuration: configuration, long: long, + diagnose: diagnose, compileOnly: compileOnly, target: target, buildProject: nil, env: env) + } + // Build a frozen copy of the tree so other agents' edits mid-build don't land in it, and + // report file paths in the real tree, where the agent edits. + let source = try await env.session.resolveProject(project) + let handle = try await SourceSnapshot.prepare(project: source, env: env) + defer { handle.release() } + let execution = try await runBuild( + project: source, scheme: scheme, simulator: simulator, configuration: configuration, long: long, + diagnose: diagnose, compileOnly: compileOnly, target: target, buildProject: handle.project, env: env) + return SourceSnapshot.remap(execution, from: handle.worktree, to: handle.repoRoot) + } + /// `buildProject` is the project actually built (a snapshot's copy) when it isn't `project`. + private static func runBuild( + project: String?, scheme: String?, simulator: String?, configuration: String, long: Bool, diagnose: Bool, + compileOnly: Bool, target: String?, buildProject: String?, env: Environment + ) async throws -> BuildExecution { + let sourceProject = try await env.session.resolveProject(project) + let resolvedProject = buildProject ?? sourceProject let isWorkspace = resolvedProject.hasSuffix(".xcworkspace") let projectFlag = isWorkspace ? "-workspace" : "-project" + // One target compiles through its own scheme, or `-target` when it has none. + let resolvedScheme: String + let selector: [String] + if let target { + selector = try await resolveTargetSelector(target, project: resolvedProject, env: env) + resolvedScheme = target + } else { + resolvedScheme = try await env.session.resolveScheme(scheme, project: sourceProject) + selector = [projectFlag, resolvedProject, "-scheme", resolvedScheme] + } + let resolvedSimulator: String + do { + resolvedSimulator = try await env.session.resolveSimulator(simulator) + } catch { + // Compiling needs a simulator platform, not a particular booted device. + guard compileOnly, simulator == nil, let fallback = await AutoDetect.simulatorForCompile() else { + throw error + } + resolvedSimulator = fallback + } + let destination = await AutoDetect.buildDestination(resolvedSimulator) // Always generate an xcresult bundle for structured diagnostics let resultPath = TestTools.xcresultPath(prefix: "build") _ = try? await env.shell.run("/bin/rm", arguments: ["-rf", resultPath], timeout: 5) - var buildArgs = [ - projectFlag, resolvedProject, - "-scheme", resolvedScheme, - "-configuration", configuration, - "-destination", destination, - "-skipMacroValidation", - "-parallelizeTargets", - "-resultBundlePath", resultPath, - "build", - ] - buildArgs += ["COMPILATION_CACHE_ENABLE_CACHING=YES"] + var buildArgs = selector + ["-configuration", configuration] + if selector.contains("-target") { + // -target builds take an SDK, not a destination. xcodebuild refuses -derivedDataPath + // without a scheme, so the build goes into the scheme's DerivedData (the configured one, + // else the default scheme's) through SYMROOT/OBJROOT, reusing the dependencies built there. + buildArgs += ["-sdk", "iphonesimulator", "ONLY_ACTIVE_ARCH=YES"] + let projectDir = (resolvedProject as NSString).deletingLastPathComponent + var derivedData = XcodebuildOptions.effective(cwd: projectDir).derivedDataPath + if derivedData == nil, let defaultScheme = try? await env.session.resolveScheme(nil, project: sourceProject) { + derivedData = await schemeDerivedData(project: resolvedProject, scheme: defaultScheme, env: env) + } + if let derivedData { buildArgs += targetBuildRoots(derivedData: derivedData) } + } else { + buildArgs += ["-destination", destination] + } + buildArgs.append("-skipMacroValidation") + buildArgs += TestTools.compileFlags + buildArgs += ["-resultBundlePath", resultPath, "build"] + TestTools.compileSettings let start = CFAbsoluteTimeGetCurrent() let buildTimeout = await TestTools.resolveTestTimeout(long: long, env: env) let snapshotPath = TestTools.diagnosticSnapshotPath() let watchdog = HangWatchdog( - udid: resolvedSimulator, snapshotPath: snapshotPath, sampleAt: [60, 120], env: env) - let result = try await env.shell.run( - "/usr/bin/xcodebuild", arguments: buildArgs, timeout: buildTimeout) + udid: resolvedSimulator, snapshotPath: snapshotPath, sampleAt: HangWatchdog.defaultSampleAt, + processMatch: resultPath, env: env) + let result = try await Xcodebuild.run(buildArgs, timeout: buildTimeout, env: env) watchdog.cancel() let watchdogCapture = await watchdog.latestResult let diagResult: DiagnosticSnapshot.Result? @@ -397,17 +517,19 @@ public enum BuildTools { diagResult = captured } else { diagResult = await DiagnosticSnapshot.capture( - udid: resolvedSimulator, snapshotPath: snapshotPath, env: env) + udid: resolvedSimulator, snapshotPath: snapshotPath, processMatch: resultPath, env: env) } } else if diagnose { if let captured = watchdogCapture { diagResult = captured } else { diagResult = await DiagnosticSnapshot.capture( - udid: resolvedSimulator, snapshotPath: snapshotPath, env: env) + udid: resolvedSimulator, snapshotPath: snapshotPath, processMatch: resultPath, env: env) } } else { - diagResult = watchdogCapture + // A sample of a healthy build is noise; only a timeout or `diagnose` reports one. + diagResult = nil + try? FileManager.default.removeItem(atPath: snapshotPath) } let elapsed = String(format: "%.1f", CFAbsoluteTimeGetCurrent() - start) @@ -415,11 +537,15 @@ public enum BuildTools { let xcresultIssues = await extractIssuesFromXcresult(resultPath, env: env) if result.succeeded { - let buildInfo = await extractBuildInfo( - project: resolvedProject, scheme: resolvedScheme, - simulator: resolvedSimulator, configuration: configuration, - env: env - ) + // Finding the app costs another xcodebuild call; a compile check doesn't need it. + var buildInfo: (bundleId: String?, appPath: String?) = (nil, nil) + if !compileOnly { + buildInfo = await extractBuildInfo( + project: resolvedProject, scheme: resolvedScheme, + simulator: resolvedSimulator, configuration: configuration, + env: env + ) + } if let bid = buildInfo.bundleId { await env.session.setBuildInfo( @@ -451,21 +577,29 @@ public enum BuildTools { var warningCount = xcresultIssues.warningCount if issues.isEmpty { - issues = TestTools.fallbackBuildIssues(stderr: result.stderr) + issues = TestTools.fallbackBuildIssues(stderr: Xcodebuild.combinedOutput(result)) errorCount = issues.filter { $0.severity == .error }.count warningCount = issues.filter { $0.severity == .warning }.count } // Classify failure from xcresult issues first, then stderr + let output = Xcodebuild.combinedOutput(result) let reason: String - if !issues.isEmpty { + if let timeout = Xcodebuild.timeoutKind(result) { + reason = "timeout_\(timeout.rawValue)" + } else if xcodebuildUsageError(output) != nil, issues.allSatisfy({ $0.location == nil }) { + reason = "xcodebuild_usage" + } else if !issues.isEmpty { reason = classifyFailureFromIssues(issues) } else { - reason = classifyFailureReason(stderr: result.stderr) + reason = classifyFailureReason(stderr: output) } - let structured = extractStructuredErrors(stderr: result.stderr, failureReason: reason) - let errors = extractLegacyErrors(from: result.stderr) + var structured = extractStructuredErrors(stderr: output, failureReason: reason) + if let explanation = Xcodebuild.timeoutExplanation(result) { + structured.insert(explanation, at: 0) + } + let errors = extractLegacyErrors(from: output) return BuildExecution( succeeded: false, @@ -541,8 +675,11 @@ public enum BuildTools { configuration: await env.session.resolveConfiguration(input.configuration), long: input.long ?? false, diagnose: input.diagnose ?? false, + compileOnly: true, + fromSnapshot: input.fromSnapshot ?? false, env: env ) + if wantsAgentJSON(args) { return agentJSON(execution, action: "Compile") } if execution.succeeded { var output = "Compile succeeded in \(execution.elapsed)s" @@ -602,6 +739,7 @@ public enum BuildTools { diagnose: input.diagnose ?? false, env: env ) + if wantsAgentJSON(args) { return agentJSON(execution, action: "Build") } if execution.succeeded { var output = @@ -650,7 +788,8 @@ public enum BuildTools { private static func extractLegacyErrors(from stderr: String) -> [String] { let errorLines = stderr.split(separator: "\n") .filter { $0.contains(": error:") } - .prefix(20) + .reduce(into: [Substring]()) { if !$0.contains($1) { $0.append($1) } } + .prefix(50) .map(String.init) let stderrTail = String(stderr.suffix(2000)) return errorLines.isEmpty @@ -664,6 +803,11 @@ public enum BuildTools { if let reason = execution.failureReason { lines.append("Failure reason: \(reason)") + // The timeout explanation is always the first structured line; show it even when + // xcresult issues take over the error list below. + if reason.hasPrefix("timeout"), let note = execution.structuredErrors?.first { + lines.append(note) + } } lines.append("Scheme: \(execution.scheme)") @@ -677,9 +821,10 @@ public enum BuildTools { if !errors.isEmpty { lines.append("") lines.append("Errors (\(errors.count)):") - for issue in errors.prefix(20) { + for issue in errors.prefix(50) { lines.append(" \(formatIssue(issue))") } + if errors.count > 50 { lines.append(" ... \(errors.count - 50) more in the xcresult") } } if !warnings.isEmpty { lines.append("") @@ -724,16 +869,22 @@ public enum BuildTools { return issue.message } - /// Find the most recent xcresult bundle from a build in /tmp. - public static func findRecentBuildXcresult(env: Environment = .live) async -> String? { + /// Find this project's most recent build result bundle. Falls back to the newest + /// xcforge build bundle in the artifact directory when no project can be resolved. + public static func findRecentBuildXcresult(project: String? = nil, env: Environment = .live) + async -> String? + { + if let resolved = try? await env.session.resolveProject(project) { + return LastResultStore.latest(project: resolved, kind: .build) + } + let dir = XcodebuildOptions.artifactDirectory() do { - let result = try await env.shell.run( - "/bin/ls", arguments: ["-1t", "/tmp/"], timeout: 5) + let result = try await env.shell.run("/bin/ls", arguments: ["-1t", dir], timeout: 5) guard result.succeeded else { return nil } let candidates = result.stdout.split(separator: "\n") .map(String.init) .filter { $0.hasPrefix("xcf-build-") && $0.hasSuffix(".xcresult") } - return candidates.first.map { "/tmp/\($0)" } + return candidates.first.map { (dir as NSString).appendingPathComponent($0) } } catch { return nil } @@ -764,28 +915,17 @@ public enum BuildTools { switch ToolInput.decode(CleanInput.self, from: args) { case .failure(let err): return err case .success(let input): - let project: String - let scheme: String do { - project = try await env.session.resolveProject(input.project) - scheme = try await env.session.resolveScheme(input.scheme, project: project) + let execution = try await executeClean( + project: input.project, scheme: input.scheme, configuration: input.configuration, + deleteDerivedData: input.derivedData ?? false, env: env) + guard execution.succeeded else { return .fail("Clean failed: \(execution.error ?? "unknown error")") } + var message = "Clean succeeded (\(execution.scheme))" + if let removed = execution.removedDerivedData { message += "\nDeleted DerivedData: \(removed)" } + return .ok(message) } catch { return .fail("\(error)") } - - let isWorkspace = project.hasSuffix(".xcworkspace") - let projectFlag = isWorkspace ? "-workspace" : "-project" - - do { - let result = try await env.shell.run( - "/usr/bin/xcodebuild", - arguments: [ - projectFlag, project, "-scheme", scheme, "clean", - ], timeout: 60) - return result.succeeded ? .ok("Clean succeeded") : .fail("Clean failed: \(result.stderr)") - } catch { - return .fail("Clean error: \(error)") - } } } @@ -801,6 +941,7 @@ public enum BuildTools { "(", "-name", "*.xcodeproj", "-o", "-name", "*.xcworkspace", ")", "-not", "-path", "*/Pods/*", "-not", "-path", "*/.build/*", + "-not", "-path", "*.xcodeproj/*", ], timeout: 15) return .ok(result.stdout.isEmpty ? "No projects found" : result.stdout) } catch { @@ -850,41 +991,35 @@ public enum BuildTools { let resultPath = TestTools.xcresultPath(prefix: "build") _ = try? await env.shell.run("/bin/rm", arguments: ["-rf", resultPath], timeout: 5) - let buildArgs = [ + var args = [ projectFlag, project, "-scheme", scheme, "-configuration", configuration, "-destination", destination, "-skipMacroValidation", - "-parallelizeTargets", - "-resultBundlePath", resultPath, - "build", - "COMPILATION_CACHE_ENABLE_CACHING=YES", ] + args += TestTools.compileFlags + args += ["-resultBundlePath", resultPath, "build"] + TestTools.compileSettings + let buildArgs = args let settingsArgs = [ projectFlag, project, "-scheme", scheme, "-configuration", configuration, "-destination", destination, - "-showBuildSettings", + "-showBuildSettings", "-json", ] - // ── Phase 1: Parallel ── - // Build is the critical path (~10-60s). Settings extraction, simulator boot, - // and Simulator.app launch run concurrently — they complete while the build - // is still compiling, adding zero wall-clock overhead. + // ── Phase 1: build, with settings extraction alongside ── + // The simulator boots only once the build succeeds: a failed build shouldn't cost a + // booted simulator's memory on a shared Mac. let buildTimeout = await TestTools.resolveTestTimeout(long: input.long ?? false, env: env) let buildSnapshotPath = TestTools.diagnosticSnapshotPath() let buildWatchdog = HangWatchdog( - udid: udid, snapshotPath: buildSnapshotPath, sampleAt: [60, 120], env: env) - async let buildTask = env.shell.run("/usr/bin/xcodebuild", arguments: buildArgs, timeout: buildTimeout) - async let settingsTask = env.shell.run( - "/usr/bin/xcodebuild", arguments: settingsArgs, timeout: 30) - async let bootTask = env.shell.run( - "/usr/bin/xcrun", arguments: ["simctl", "boot", udid], timeout: 60) - async let openTask = env.shell.run( - "/usr/bin/open", arguments: ["-a", "Simulator"], timeout: 10) + udid: udid, snapshotPath: buildSnapshotPath, sampleAt: HangWatchdog.defaultSampleAt, processMatch: resultPath, + env: env) + async let buildTask = Xcodebuild.run(buildArgs, timeout: buildTimeout, env: env) + async let settingsTask = Xcodebuild.run(settingsArgs, timeout: 60, env: env) // Await build first (critical — abort if it fails) let buildResult: ShellResult @@ -896,13 +1031,16 @@ public enum BuildTools { } buildWatchdog.cancel() let buildDiagResult: DiagnosticSnapshot.Result? - if let captured = await buildWatchdog.latestResult { + // A sample of a healthy build is noise; only a timeout or `diagnose` reports one. + if buildResult.exitCode != -1 && !(input.diagnose ?? false) { + buildDiagResult = nil + _ = await buildWatchdog.latestResult + try? FileManager.default.removeItem(atPath: buildSnapshotPath) + } else if let captured = await buildWatchdog.latestResult { buildDiagResult = captured - } else if input.diagnose ?? false { - buildDiagResult = await DiagnosticSnapshot.capture( - udid: udid, snapshotPath: buildSnapshotPath, env: env) } else { - buildDiagResult = nil + buildDiagResult = await DiagnosticSnapshot.capture( + udid: udid, snapshotPath: buildSnapshotPath, processMatch: resultPath, env: env) } let buildElapsed = String(format: "%.1f", CFAbsoluteTimeGetCurrent() - totalStart) @@ -914,16 +1052,21 @@ public enum BuildTools { var warningCount = xcresultIssues.warningCount if issues.isEmpty { - issues = TestTools.fallbackBuildIssues(stderr: buildResult.stderr) + issues = TestTools.fallbackBuildIssues(stderr: Xcodebuild.combinedOutput(buildResult)) errorCount = issues.filter { $0.severity == .error }.count warningCount = issues.filter { $0.severity == .warning }.count } + let output = Xcodebuild.combinedOutput(buildResult) let reason = - !issues.isEmpty - ? classifyFailureFromIssues(issues) : classifyFailureReason(stderr: buildResult.stderr) - let structured = extractStructuredErrors(stderr: buildResult.stderr, failureReason: reason) - let legacyErrors = extractLegacyErrors(from: buildResult.stderr) + Xcodebuild.timeoutKind(buildResult).map { "timeout_\($0.rawValue)" } + ?? (!issues.isEmpty + ? classifyFailureFromIssues(issues) : classifyFailureReason(stderr: output)) + var structured = extractStructuredErrors(stderr: output, failureReason: reason) + if let explanation = Xcodebuild.timeoutExplanation(buildResult) { + structured.insert(explanation, at: 0) + } + let legacyErrors = extractLegacyErrors(from: output) let execution = BuildExecution( succeeded: false, @@ -948,100 +1091,48 @@ public enum BuildTools { return .fail(failMsg) } - // Await settings — 3-tier fallback for app path + bundle ID: - // 1. -showBuildSettings (parallel, fastest when it works) - // 2. Parse build stdout for .app paths - // 3. Search DerivedData with find - // Bundle ID always via PlistBuddy once we have the .app path. - var appPath: String? - var infoSource = "showBuildSettings" - - // Tier 1: -showBuildSettings - if let settingsResult = try? await settingsTask, settingsResult.succeeded { - var builtProductsDir: String? - var fullProductName: String? - - for line in settingsResult.stdout.split(separator: "\n") { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("BUILT_PRODUCTS_DIR = ") { - builtProductsDir = String(trimmed.dropFirst("BUILT_PRODUCTS_DIR = ".count)) - } else if trimmed.hasPrefix("FULL_PRODUCT_NAME = ") { - fullProductName = String(trimmed.dropFirst("FULL_PRODUCT_NAME = ".count)) - } - } - - if let dir = builtProductsDir, let name = fullProductName { - appPath = "\(dir)/\(name)" - } - } - - // Tier 2: Parse build stdout for .app path - if appPath == nil { - let suffix = "/\(configuration)-iphonesimulator/" - for line in buildResult.stdout.split(separator: "\n").reversed() { - let s = String(line) - if let range = s.range(of: suffix) { - let afterConfig = s[range.upperBound...] - if let appEnd = afterConfig.range(of: ".app") { - let fullLine = String(s[s.startIndex..: " on stdout - let appPid = launchResult.stdout.trimmingCharacters(in: .whitespacesAndNewlines) - .split(separator: ":").last - .map { String($0).trimmingCharacters(in: .whitespaces) } + let appPid = launch.message.split(separator: "\n").lazy + .compactMap { AppLiveness.pid(fromLaunchOutput: String($0)) }.first let totalElapsed = String(format: "%.1f", CFAbsoluteTimeGetCurrent() - totalStart) @@ -1092,17 +1169,14 @@ public enum BuildTools { output += "\nScheme: \(scheme) | Simulator: \(simulator)" output += "\nBundle ID: \(bundleId)" output += "\nApp path: \(finalAppPath)" - if let pid = appPid, !pid.isEmpty { - output += "\nApp PID: \(pid)" - } + if let appPid { output += "\nApp PID: \(appPid)" } output += "\nApp running: true" + if let url = input.url { output += "\nOpened: \(url)" } output += "\n" output += "\n Build: \(buildElapsed)s" - output += "\n App info: \(infoSource) (parallel)" - output += "\n Boot: \(bootStatus) (parallel)" + output += "\n Boot: \(bootStatus) (\(bootElapsed)s)" output += "\n Install: \(installElapsed)s" output += "\n Launch: OK" - output += "\n Simulator: opened" // Surface build warnings from xcresult if any let xcresultIssues = await extractIssuesFromXcresult(resultPath, env: env) @@ -1132,15 +1206,14 @@ public enum BuildTools { let projectFlag = isWorkspace ? "-workspace" : "-project" let destination = await AutoDetect.buildDestination(simulator) - let result = try await env.shell.run( - "/usr/bin/xcodebuild", - arguments: [ + let result = try await Xcodebuild.run( + [ projectFlag, project, "-scheme", scheme, "-configuration", configuration, "-destination", destination, - "-showBuildSettings", - ], timeout: 30) + "-showBuildSettings", "-json", + ], timeout: 60, env: env) guard result.succeeded else { let details = result.stderr.isEmpty ? result.stdout : result.stderr @@ -1148,31 +1221,29 @@ public enum BuildTools { "Unable to resolve app context for \(scheme): \(details.trimmingCharacters(in: .whitespacesAndNewlines))" ) } - - var bundleId: String? - var builtProductsDir: String? - var fullProductName: String? - - for line in result.stdout.split(separator: "\n") { - let trimmed = line.trimmingCharacters(in: .whitespaces) - if trimmed.hasPrefix("PRODUCT_BUNDLE_IDENTIFIER = ") { - bundleId = String(trimmed.dropFirst("PRODUCT_BUNDLE_IDENTIFIER = ".count)) - } else if trimmed.hasPrefix("BUILT_PRODUCTS_DIR = ") { - builtProductsDir = String(trimmed.dropFirst("BUILT_PRODUCTS_DIR = ".count)) - } else if trimmed.hasPrefix("FULL_PRODUCT_NAME = ") { - fullProductName = String(trimmed.dropFirst("FULL_PRODUCT_NAME = ".count)) - } + guard let product = appProduct(fromSettings: result.stdout) else { + throw BuildSettingsError("Scheme \(scheme) builds no application target, so there is no app to install") } + return product + } - guard let bundleId else { - throw BuildSettingsError( - "Build settings did not contain PRODUCT_BUNDLE_IDENTIFIER for \(scheme)") + /// The application target's product in `-showBuildSettings -json` output. Schemes also + /// build extensions, frameworks and test bundles; only the app can be installed and + /// launched, whichever target the output lists last. + static func appProduct(fromSettings output: String) -> BuildProductInfo? { + guard let data = output.data(using: .utf8), + let entries = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] + else { return nil } + for entry in entries { + guard let settings = entry["buildSettings"] as? [String: String], + settings["PRODUCT_TYPE"] == "com.apple.product-type.application" || settings["WRAPPER_EXTENSION"] == "app", + let bundleId = settings["PRODUCT_BUNDLE_IDENTIFIER"], + let directory = settings["BUILT_PRODUCTS_DIR"], + let name = settings["FULL_PRODUCT_NAME"] + else { continue } + return BuildProductInfo(bundleId: bundleId, appPath: "\(directory)/\(name)") } - guard let builtProductsDir, let fullProductName else { - throw BuildSettingsError("Build settings did not contain an app product path for \(scheme)") - } - - return BuildProductInfo(bundleId: bundleId, appPath: "\(builtProductsDir)/\(fullProductName)") + return nil } private static func extractBuildInfo( @@ -1246,31 +1317,84 @@ public enum BuildTools { public let project: String public let scheme: String public let error: String? + /// The project's DerivedData folder, when `deleteDerivedData` removed it. + public var removedDerivedData: String? = nil } + /// Clean the scheme's products for the simulator build xcforge runs (same configuration, + /// same build folder), and optionally delete this project's DerivedData folder, the fix + /// for a corrupted build database or index. public static func executeClean( project: String? = nil, scheme: String? = nil, + configuration: String? = nil, + deleteDerivedData: Bool = false, env: Environment = .live ) async throws -> CleanExecution { let resolvedProject = try await env.session.resolveProject(project) let resolvedScheme = try await env.session.resolveScheme(scheme, project: resolvedProject) + let resolvedConfiguration = await env.session.resolveConfiguration(configuration) let isWorkspace = resolvedProject.hasSuffix(".xcworkspace") let projectFlag = isWorkspace ? "-workspace" : "-project" + let base = [ + projectFlag, resolvedProject, "-scheme", resolvedScheme, "-configuration", resolvedConfiguration, + "-destination", "generic/platform=iOS Simulator", + ] - let result = try await Shell.run( - "/usr/bin/xcodebuild", - arguments: [ - projectFlag, resolvedProject, "-scheme", resolvedScheme, "clean", - ], timeout: 60) + // Look the folder up before cleaning: the settings call needs a working project. + var derivedData: String? + if deleteDerivedData { + let settings = try await Xcodebuild.run(base + ["-showBuildSettings", "-json"], timeout: 120, env: env) + derivedData = settings.succeeded ? derivedDataFolder(fromSettings: settings.stdout) : nil + } - return CleanExecution( + // A clean is killed after the usual stretch of silence, not a fixed 60s. + let result = try await Xcodebuild.run(base + ["clean"], timeout: 1800, env: env) + var execution = CleanExecution( succeeded: result.succeeded, project: resolvedProject, scheme: resolvedScheme, - error: result.succeeded ? nil : result.stderr + error: result.succeeded ? nil : Xcodebuild.combinedOutput(result) ) + guard deleteDerivedData else { return execution } + guard let derivedData else { + return CleanExecution( + succeeded: false, project: resolvedProject, scheme: resolvedScheme, + error: "Couldn't find this project's DerivedData folder from its build settings; nothing deleted.") + } + do { + if FileManager.default.fileExists(atPath: derivedData) { + try FileManager.default.removeItem(atPath: derivedData) + } + execution.removedDerivedData = derivedData + } catch { + return CleanExecution( + succeeded: false, project: resolvedProject, scheme: resolvedScheme, + error: "Deleting \(derivedData) failed: \(error)") + } + return execution + } + + /// This project's DerivedData folder: BUILD_ROOT is `/Build/Products`. Anything + /// that doesn't look like that, or is too short a path to be one project's folder, is + /// refused rather than deleted. + static func derivedDataFolder(fromSettings output: String) -> String? { + guard let data = output.data(using: .utf8), + let entries = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]], + let settings = entries.first?["buildSettings"] as? [String: String], + let root = settings["BUILD_ROOT"] + else { return nil } + return derivedDataFolder(fromBuildRoot: root) + } + + static func derivedDataFolder(fromBuildRoot root: String) -> String? { + let suffix = "/Build/Products" + guard root.hasPrefix("/"), root.hasSuffix(suffix) else { return nil } + let folder = String(root.dropLast(suffix.count)) + let depth = folder.split(separator: "/").count + guard depth >= 3, folder != NSHomeDirectory() else { return nil } + return folder } public struct DiscoverExecution: Codable, Sendable { @@ -1286,6 +1410,7 @@ public enum BuildTools { "(", "-name", "*.xcodeproj", "-o", "-name", "*.xcworkspace", ")", "-not", "-path", "*/Pods/*", "-not", "-path", "*/.build/*", + "-not", "-path", "*.xcodeproj/*", ], timeout: 15) let projects = result.stdout @@ -1349,16 +1474,29 @@ public enum BuildTools { } extension BuildTools: ToolProvider { + static func buildLockStatus(_ args: [String: Value]?, env: Environment) async -> CallTool.Result { + let explicit = args?["lock"]?.stringValue + guard let path = BuildLock.configuredPath(explicit: explicit, cwd: env.currentDirectoryPath()) + else { + return .fail("No build lock configured. Pass 'lock', set XCFORGE_BUILD_LOCK, or add buildLock to .xcforge.yaml.") + } + let status = await BuildLock.status(path: path, env: env) + return .ok(BuildLock.format(status)) + } + public static func dispatch(_ name: String, _ args: [String: Value]?, env: Environment) async -> CallTool.Result? { switch name { case "build_sim": return await buildSim(args, env: env) case "build_compile": return await buildCompile(args, env: env) + case "build_typecheck": return await buildTypecheck(args, env: env) + case "lsp_setup": return await lspSetup(args, env: env) case "build_run_sim": return await buildRunSim(args, env: env) case "clean": return await clean(args, env: env) case "discover_projects": return await discoverProjects(args, env: env) case "list_schemes": return await listSchemes(args, env: env) + case "build_lock_status": return await buildLockStatus(args, env: env) default: return nil } } diff --git a/Sources/XCForgeKit/Tools/BuildTypecheck.swift b/Sources/XCForgeKit/Tools/BuildTypecheck.swift new file mode 100644 index 0000000..9eab998 --- /dev/null +++ b/Sources/XCForgeKit/Tools/BuildTypecheck.swift @@ -0,0 +1,250 @@ +import Foundation +import MCP + +/// Compiling one target and pointing the editor at the build: the inner-loop checks a +/// subagent runs on its own file without a full scheme build. +extension BuildTools { + /// The schemes and targets `xcodebuild -list -json` reports for a project or workspace. + struct ProjectListing: Sendable, Equatable { + var schemes: [String] = [] + var targets: [String] = [] + + static func parse(_ json: String) -> ProjectListing? { + guard let data = json.data(using: .utf8), + let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let info = (object["project"] ?? object["workspace"]) as? [String: Any] + else { return nil } + return ProjectListing( + schemes: info["schemes"] as? [String] ?? [], targets: info["targets"] as? [String] ?? []) + } + } + + static func listing(_ project: String, env: Environment) async -> ProjectListing? { + let flag = project.hasSuffix(".xcworkspace") ? "-workspace" : "-project" + // Package resolution on a cold checkout can take most of a minute. + let arguments = [flag, project, "-list", "-json"] + guard let result = try? await env.shell.run(Xcodebuild.executable, arguments: arguments, timeout: 90), + result.succeeded + else { return nil } + return ProjectListing.parse(result.stdout) + } + + /// The xcodebuild arguments that build only `target`: its own scheme when there is one (so + /// the build shares the workspace's DerivedData), else `-project

-target `. + /// `projects` are the `.xcodeproj` files searched for the target, with their listings. + static func targetSelector( + _ target: String, project: String, listing: ProjectListing, projects: [(path: String, listing: ProjectListing)] + ) -> [String]? { + let flag = project.hasSuffix(".xcworkspace") ? "-workspace" : "-project" + if listing.schemes.contains(target) { return [flag, project, "-scheme", target] } + for candidate in projects where candidate.listing.targets.contains(target) { + return ["-project", candidate.path, "-target", target] + } + return nil + } + + /// The `.xcodeproj` files a workspace sits beside (and the project itself when it is one). + static func projectFiles(for project: String) -> [String] { + if project.hasSuffix(".xcodeproj") { return [project] } + let dir = (project as NSString).deletingLastPathComponent + let entries = (try? FileManager.default.contentsOfDirectory(atPath: dir)) ?? [] + return entries.filter { $0.hasSuffix(".xcodeproj") }.sorted().map { (dir as NSString).appendingPathComponent($0) } + } + + public struct TypecheckSelectionError: Error, CustomStringConvertible { + public let description: String + } + + static func resolveTargetSelector(_ target: String, project: String, env: Environment) async throws -> [String] { + guard let listing = await listing(project, env: env) else { + throw TypecheckSelectionError(description: "Couldn't list schemes and targets of \(project)") + } + var projects: [(path: String, listing: ProjectListing)] = [] + if !listing.schemes.contains(target) { + for path in projectFiles(for: project) { + if path == project { + projects.append((path, listing)) + } else if let other = await self.listing(path, env: env) { + projects.append((path, other)) + } + } + } + if let selector = targetSelector(target, project: project, listing: listing, projects: projects) { + return selector + } + let targets = projects.flatMap(\.listing.targets) + throw TypecheckSelectionError( + description: + "No scheme or target named \(target). Schemes: \(listing.schemes.joined(separator: ", ")). Targets: \(targets.joined(separator: ", "))" + ) + } + + /// This scheme's DerivedData folder, from its build settings. Nil when the settings can't be read. + static func schemeDerivedData(project: String, scheme: String, env: Environment) async -> String? { + let flag = project.hasSuffix(".xcworkspace") ? "-workspace" : "-project" + let args = [ + flag, project, "-scheme", scheme, "-destination", "generic/platform=iOS Simulator", "-showBuildSettings", "-json", + ] + guard let settings = try? await Xcodebuild.run(args, timeout: 120, env: env), settings.succeeded else { + return nil + } + return derivedDataFolder(fromSettings: settings.stdout) + } + + // MARK: - lsp setup + + public struct LSPSetupExecution: Codable, Sendable { + public let succeeded: Bool + public let message: String + public let configPath: String? + public let buildRoot: String? + } + + /// Point `buildServer.json` (xcode-build-server's config) at the build root xcforge's builds + /// actually use, so SourceKit-LSP finds modules instead of reporting "No such module". + static func patchBuildServer(_ json: [String: Any], buildRoot: String) -> [String: Any] { + var patched = json + patched["build_root"] = buildRoot + return patched + } + + public static func executeLSPSetup(project: String?, scheme: String?, env: Environment) async -> LSPSetupExecution { + func fail(_ message: String) -> LSPSetupExecution { + LSPSetupExecution(succeeded: false, message: message, configPath: nil, buildRoot: nil) + } + do { + let resolvedProject = try await env.session.resolveProject(project) + let resolvedScheme = try await env.session.resolveScheme(scheme, project: resolvedProject) + let which = try await env.shell.run( + "/usr/bin/env", arguments: ["which", "xcode-build-server"], workingDirectory: nil, environment: nil, + timeout: 10) + let server = which.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + guard which.succeeded, !server.isEmpty else { + return fail("xcode-build-server is not installed: `brew install xcode-build-server`, then rerun.") + } + let dir = (resolvedProject as NSString).deletingLastPathComponent + let flag = resolvedProject.hasSuffix(".xcworkspace") ? "-workspace" : "-project" + let config = try await env.shell.run( + server, arguments: ["config", flag, resolvedProject, "-scheme", resolvedScheme], workingDirectory: dir, + environment: nil, timeout: 180) + guard config.succeeded else { + return fail("xcode-build-server config failed: \(config.stderr.isEmpty ? config.stdout : config.stderr)") + } + let configPath = (dir as NSString).appendingPathComponent("buildServer.json") + // With a custom DerivedData folder, xcode-build-server would read Xcode's default one. + var buildRoot = XcodebuildOptions.effective(cwd: dir).derivedDataPath + if buildRoot == nil { + buildRoot = await schemeDerivedData(project: resolvedProject, scheme: resolvedScheme, env: env) + } + if let buildRoot, let data = FileManager.default.contents(atPath: configPath), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + { + let patched = patchBuildServer(json, buildRoot: buildRoot) + let output = try JSONSerialization.data(withJSONObject: patched, options: [.prettyPrinted, .sortedKeys]) + try output.write(to: URL(fileURLWithPath: configPath), options: .atomic) + } + return LSPSetupExecution( + succeeded: true, + message: + "Wrote \(configPath) for scheme \(resolvedScheme). Build the scheme once (xcforge build compile) so the index exists, then restart the language server.", + configPath: configPath, buildRoot: buildRoot) + } catch { + return fail("\(error)") + } + } + + static func lspSetup(_ args: [String: Value]?, env: Environment) async -> CallTool.Result { + let result = await executeLSPSetup( + project: args?["project"]?.stringValue, scheme: args?["scheme"]?.stringValue, env: env) + var text = result.message + if let root = result.buildRoot { text += "\nbuild_root: \(root)" } + return result.succeeded ? .ok(text) : .fail(text) + } + + static let typecheckTools: [Tool] = [ + Tool( + name: "build_typecheck", + description: """ + Compile one target for the simulator (its own scheme when it has one, else -target), \ + reusing the workspace's DerivedData. Seconds instead of a full scheme build when checking \ + the file you just changed. + """, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "target": .object([ + "type": .string("string"), + "description": .string("Target (or package product) to compile, e.g. ShutterCoachShared."), + ]), + "project": .object([ + "type": .string("string"), + "description": .string("Path to .xcodeproj or .xcworkspace. Auto-detected if omitted."), + ]), + "simulator": .object([ + "type": .string("string"), + "description": .string("Simulator name or UDID. Default: booted, else the newest iPhone."), + ]), + "configuration": .object([ + "type": .string("string"), + "description": .string("Build configuration. Default: Debug"), + ]), + "fromSnapshot": .object([ + "type": .string("boolean"), + "description": .string("Compile a snapshot of the working tree, unaffected by edits made meanwhile."), + ]), + ]), + "required": .array([.string("target")]), + ]) + ), + Tool( + name: "lsp_setup", + description: """ + Write buildServer.json (via xcode-build-server) for the project and scheme, pointed at the \ + DerivedData xcforge builds into, so SourceKit-LSP editors stop reporting "No such module". + """, + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "project": .object([ + "type": .string("string"), + "description": .string("Path to .xcodeproj or .xcworkspace. Auto-detected if omitted."), + ]), + "scheme": .object([ + "type": .string("string"), + "description": .string("Scheme to index. Auto-detected if omitted."), + ]), + ]), + ]) + ), + ] + + struct TypecheckInput: Decodable { + let target: String + let project: String? + let simulator: String? + let configuration: String? + var fromSnapshot: Bool? = nil + } + + static func buildTypecheck(_ args: [String: Value]?, env: Environment) async -> CallTool.Result { + switch ToolInput.decode(TypecheckInput.self, from: args) { + case .failure(let err): return err + case .success(let input): + do { + let execution = try await executeBuild( + project: input.project, simulator: input.simulator, + configuration: await env.session.resolveConfiguration(input.configuration), compileOnly: true, + target: input.target, fromSnapshot: input.fromSnapshot ?? false, env: env) + if wantsAgentJSON(args) { return agentJSON(execution, action: "Typecheck of \(input.target)") } + if execution.succeeded { + var output = "\(input.target) compiled in \(execution.elapsed)s" + if let warnings = execution.warningCount, warnings > 0 { output += " (\(warnings) warnings)" } + return .ok(output) + } + return .fail(formatBuildFailure(execution)) + } catch { + return .fail("\(error)") + } + } + } +} diff --git a/Sources/XCForgeKit/Tools/CaptureProvider.swift b/Sources/XCForgeKit/Tools/CaptureProvider.swift index 64b3e59..6628446 100644 --- a/Sources/XCForgeKit/Tools/CaptureProvider.swift +++ b/Sources/XCForgeKit/Tools/CaptureProvider.swift @@ -18,10 +18,14 @@ enum ScreenshotTools { Tool( name: "screenshot", description: - "Take a screenshot of a booted simulator and return the image inline. Automatically selects the fastest available capture method (native framebuffer <10ms, ScreenCaptureKit ~20ms, or simctl ~320ms fallback). Use after any UI interaction to verify the result visually. Simulator is auto-detected if omitted.", + "Take a screenshot of a booted simulator and return the image inline. Automatically selects the fastest available capture method (native framebuffer <10ms, ScreenCaptureKit ~20ms, or simctl ~320ms fallback). Use after any UI interaction to verify the result visually. Simulator is auto-detected if omitted. device takes a physical device's screenshot instead.", inputSchema: .object([ "type": .string("object"), "properties": .object([ + "device": .object([ + "type": .string("string"), + "description": .string("Physical device name or UDID. Captures that device instead of a simulator."), + ]), "simulator": .object([ "type": .string("string"), "description": .string( @@ -49,6 +53,18 @@ enum ScreenshotTools { "Ceiling in seconds for `waitFor`. Capture happens the instant the signal holds. Default 20." ), ]), + "crop": .object([ + "type": .string("string"), + "description": .string( + "Region to return, as x,y,width,height in device points (the coordinates taps use), e.g. 0,100,390,200." + ), + ]), + "max_dimension": .object([ + "type": .string("integer"), + "description": .string( + "Shrink the image so its longer side is at most this many pixels. Saves tokens; the result says how many points one pixel covers." + ), + ]), ]), ]) ) @@ -62,6 +78,13 @@ enum ScreenshotTools { let grid: Bool? let waitFor: String? let timeout: Double? + let crop: String? + let maxDimension: Int? + + enum CodingKeys: String, CodingKey { + case simulator, format, grid, waitFor, timeout, crop + case maxDimension = "max_dimension" + } } static func screenshot(_ args: [String: Value]?, env: Environment) async -> CallTool.Result { @@ -82,6 +105,14 @@ enum ScreenshotTools { } let format = input.format ?? "jpeg" let wantGrid = input.grid ?? false + var crop: ScreenshotShaping.PointRect? + if let spec = input.crop { + guard let parsed = ScreenshotShaping.parseCrop(spec) else { return .fail(ScreenshotShaping.cropFormatHelp) } + crop = parsed + } + if let maxDimension = input.maxDimension, maxDimension < ScreenshotShaping.minimumDimension { + return .fail("max_dimension must be at least \(ScreenshotShaping.minimumDimension)") + } // Optional readiness gate — warn-only, NEVER hard-fails the screenshot. var readinessNote = "" @@ -114,45 +145,74 @@ enum ScreenshotTools { return " | appForeground:\(appForeground)" }() let trailing = readinessNote + fgNote + let landscape = await interfaceLandscape(env: env) let start = CFAbsoluteTimeGetCurrent() - // Grid mode: capture CGImage → overlay → encode → return inline. - // On any failure during the overlay path, fall back to the ungridded path - // and surface a warning rather than failing the call. - if wantGrid { + // Grid, crop or downscale: capture a CGImage at device pixels, then shape it. Grid alone + // falls back to the ungridded fast path on failure; a crop or size the caller relies on + // fails instead of silently returning something else. + let shaping = crop != nil || input.maxDimension != nil + if wantGrid || shaping { do { let udid = try await SimTools.resolveSimulator(sim, env: env) let info = try await SimTools.fetchScreenInfo(udid: udid, env: env) - let cgImage = try await VisualTools.captureCGImage(simulator: udid, env: env) - if let gridded = drawPointGrid( - on: cgImage, - pointWidth: info.pointSize.width, - pointHeight: info.pointSize.height, - scale: info.scale - ), let encoded = encodeImage(gridded, format: format) { - let elapsed = String(format: "%.0f", (CFAbsoluteTimeGetCurrent() - start) * 1000) - let mimeType = format.hasPrefix("jp") ? "image/jpeg" : "image/png" - let pxW = gridded.width - let pxH = gridded.height - let ptW = Int(info.pointSize.width) - let ptH = Int(info.pointSize.height) - return .init(content: [ - .image( - data: encoded.base64EncodedString(), mimeType: mimeType, - annotations: nil, _meta: nil), - .text( - text: - "\(ptW)×\(ptH) pt | \(pxW)x\(pxH) px | \(encoded.count / 1024)KB | \(elapsed)ms (grid)\(trailing)", - annotations: nil, _meta: nil), - ]) + var image = try await VisualTools.captureCGImage(simulator: udid, env: env) + let capturedWidth = image.width + let capturedHeight = image.height + let points = ScreenshotShaping.orientedPointSize( + width: info.pointSize.width, height: info.pointSize.height, pixelWidth: image.width, + pixelHeight: image.height) + if wantGrid { + if let gridded = drawPointGrid( + on: image, pointWidth: points.width, pointHeight: points.height, scale: info.scale) + { + image = gridded + } else { + Log.warn("Grid overlay failed; returning the image without it") + } } - Log.warn("Grid overlay or encode failed; returning ungridded image") + image = ScreenshotShaping.shape(image, crop: crop, scale: info.scale, maxDimension: input.maxDimension) + guard let encoded = encodeImage(image, format: format) else { + throw NSError( + domain: "ScreenshotTools", code: 1, userInfo: [NSLocalizedDescriptionKey: "image encoding failed"]) + } + let elapsed = String(format: "%.0f", (CFAbsoluteTimeGetCurrent() - start) * 1000) + let mimeType = format.hasPrefix("jp") ? "image/jpeg" : "image/png" + let shown = ScreenshotShaping.interfacePointSize( + width: info.pointSize.width, height: info.pointSize.height, pixelWidth: capturedWidth, + pixelHeight: capturedHeight, interfaceLandscape: landscape) + let size = ScreenshotShaping.pointSizeText( + width: Int(shown.width), height: Int(shown.height), imageRotated: shown.imageRotated) + var parts = ["\(size) screen"] + var shownPointWidth = points.width + if let crop { + parts.append("crop \(crop.description) pt") + shownPointWidth = min(crop.width, points.width - crop.x) + } + parts.append("\(image.width)x\(image.height) px") + if input.maxDimension != nil { + let ratio = ScreenshotShaping.pointsPerPixel(outputWidth: image.width, croppedPointWidth: shownPointWidth) + parts.append(String(format: "1 px = %.2f pt", ratio)) + } + let steps = [wantGrid ? "grid" : nil, crop != nil ? "crop" : nil, input.maxDimension != nil ? "scaled" : nil] + let mode = steps.compactMap { $0 }.joined(separator: ", ") + parts.append("\(encoded.count / 1024)KB") + parts.append("\(elapsed)ms (\(mode))") + return .init(content: [ + .image(data: encoded.base64EncodedString(), mimeType: mimeType, annotations: nil, _meta: nil), + .text(text: parts.joined(separator: " | ") + trailing, annotations: nil, _meta: nil), + ]) } catch { + if shaping { return .fail("Screenshot failed: \(error)") } Log.warn("Grid overlay failed (\(error)); returning ungridded image") } } + // Device points come from the simulator's screen, not the Simulator window, whose size + // depends on its zoom. + let screenInfo = await ScreenInfoCache.shared.info(for: sim, env: env) + // Fast path: inline capture (macOS 14+) if #available(macOS 14.0, *) { do { @@ -162,7 +222,17 @@ enum ScreenshotTools { let elapsed = String( format: "%.0f", (CFAbsoluteTimeGetCurrent() - start) * 1000) let mimeType = format.hasPrefix("jp") ? "image/jpeg" : "image/png" - let ptInfo = result.pointWidth > 0 ? "\(result.pointWidth)×\(result.pointHeight) pt | " : "" + let ptInfo: String + if let screenInfo { + let shown = ScreenshotShaping.interfacePointSize( + width: screenInfo.pointSize.width, height: screenInfo.pointSize.height, pixelWidth: result.width, + pixelHeight: result.height, interfaceLandscape: landscape) + let size = ScreenshotShaping.pointSizeText( + width: Int(shown.width), height: Int(shown.height), imageRotated: shown.imageRotated) + ptInfo = "\(size) | " + } else { + ptInfo = result.pointWidth > 0 ? "\(result.pointWidth)×\(result.pointHeight) pt | " : "" + } return .init(content: [ .image( data: result.base64, mimeType: mimeType, annotations: nil, _meta: nil), @@ -174,20 +244,33 @@ enum ScreenshotTools { } catch { Log.warn("Inline screenshot failed, falling back to simctl: \(error)") return await simctlScreenshot( - sim: sim, format: format, start: start, trailing: trailing, env: env) + sim: sim, format: format, start: start, trailing: trailing, screenInfo: screenInfo, + landscape: landscape, env: env) } } return await simctlScreenshot( - sim: sim, format: format, start: start, trailing: trailing, env: env) + sim: sim, format: format, start: start, trailing: trailing, screenInfo: screenInfo, landscape: landscape, + env: env) + } + + /// Whether the app's interface is landscape, asked of WDA only when it is already running + /// (a screenshot never starts WDA). Nil when unknown. + static func interfaceLandscape(env: Environment) async -> Bool? { + guard await env.wdaClient.isHealthy(), let value = try? await env.wdaClient.getOrientation() else { + return nil + } + let orientation = IndigoHIDClient.Orientation(wdaValue: value) + return orientation == .landscapeLeft || orientation == .landscapeRight } // MARK: - simctl Fallback (writes to file, returns path) private static func simctlScreenshot( - sim: String, format: String, start: CFAbsoluteTime, trailing: String, env: Environment + sim: String, format: String, start: CFAbsoluteTime, trailing: String, screenInfo: SimTools.ScreenInfo?, + landscape: Bool?, env: Environment ) async -> CallTool.Result { - let outputPath = "/tmp/xcf-screenshot.\(format)" + let outputPath = ScreenshotShaping.uniquePath(format: format) do { let result = try await env.shell.xcrun( timeout: 15, "simctl", "io", sim, "screenshot", @@ -201,9 +284,19 @@ enum ScreenshotTools { // Read file and return inline if let data = FileManager.default.contents(atPath: outputPath) { let mimeType = format.hasPrefix("jp") ? "image/jpeg" : "image/png" - // Best-effort point dimensions from Simulator window + defer { try? FileManager.default.removeItem(atPath: outputPath) } var ptInfo = "" - if #available(macOS 14.0, *) { + if let screenInfo { + let pixels = CGImageSourceCreateWithData(data as CFData, nil).flatMap { + CGImageSourceCreateImageAtIndex($0, 0, nil) + } + let shown = ScreenshotShaping.interfacePointSize( + width: screenInfo.pointSize.width, height: screenInfo.pointSize.height, + pixelWidth: pixels?.width ?? 0, pixelHeight: pixels?.height ?? 1, interfaceLandscape: landscape) + let size = ScreenshotShaping.pointSizeText( + width: Int(shown.width), height: Int(shown.height), imageRotated: shown.imageRotated) + ptInfo = "\(size) | " + } else if #available(macOS 14.0, *) { if let window = try? await FramebufferCapture.findSimulatorWindow(simulator: sim) { let ptW = Int(window.frame.width) let ptH = Int(window.frame.height) @@ -527,7 +620,13 @@ extension ScreenshotTools: ToolProvider { -> CallTool.Result? { switch name { - case "screenshot": return await screenshot(args, env: env) + case "screenshot": + // A physical device: devicectl capture, falling back to the device's WDA. + if let device = args?["device"] { + return await DeviceTools.dispatch("device_screenshot", ["device": device], env: env) + ?? .fail("device screenshots are unavailable") + } + return await screenshot(args, env: env) default: return nil } } diff --git a/Sources/XCForgeKit/Tools/ConsoleProvider.swift b/Sources/XCForgeKit/Tools/ConsoleProvider.swift index da97dec..6f50ce6 100644 --- a/Sources/XCForgeKit/Tools/ConsoleProvider.swift +++ b/Sources/XCForgeKit/Tools/ConsoleProvider.swift @@ -173,7 +173,7 @@ enum ConsoleTools { "properties": .object([ "last": .object([ "type": .string("number"), - "description": .string("Only return last N lines per stream"), + "description": .string("Return the last N lines per stream. Default: 200; 0 returns all."), ]), "clear": .object([ "type": .string("boolean"), @@ -247,28 +247,14 @@ enum ConsoleTools { let clear = input.clear ?? false let stream = input.stream ?? "both" - let output = await AppConsole.shared.read(last: last, clear: clear) + let output = await AppConsole.shared.read(last: nil, clear: clear) var sections: [String] = [] - - if stream == "stdout" || stream == "both" { - if output.stdout.isEmpty { - sections.append("=== STDOUT (empty) ===") - } else { - let text = output.stdout.joined(separator: "\n") - let truncated = text.count > 30000 ? String(text.prefix(30000)) + "\n... [truncated]" : text - sections.append("=== STDOUT (\(output.stdout.count) lines) ===\n\(truncated)") - } - } - - if stream == "stderr" || stream == "both" { - if output.stderr.isEmpty { - sections.append("=== STDERR (empty) ===") - } else { - let text = output.stderr.joined(separator: "\n") - let truncated = text.count > 30000 ? String(text.prefix(30000)) + "\n... [truncated]" : text - sections.append("=== STDERR (\(output.stderr.count) lines) ===\n\(truncated)") - } + for (name, lines, wanted) in [ + ("STDOUT", output.stdout, stream == "stdout" || stream == "both"), + ("STDERR", output.stderr, stream == "stderr" || stream == "both"), + ] where wanted { + sections.append(ConsoleTools.section(name, lines: lines, last: last)) } let status = output.isRunning ? "running" : "stopped" @@ -277,6 +263,16 @@ enum ConsoleTools { return .ok(header + "\n\n" + sections.joined(separator: "\n\n")) } + /// One stream's newest lines, with how many earlier ones were left out. + static func section(_ name: String, lines: [String], last: Int?) -> String { + let lines = lines.filter { !$0.isEmpty } + guard !lines.isEmpty else { return "=== \(name) (empty) ===" } + let (kept, omitted) = CaptureTail.tail(lines, last: last) + var header = "=== \(name) (\(lines.count) lines) ===" + if let note = CaptureTail.omittedNote(omitted) { header += " \(note)" } + return header + "\n" + CaptureTail.keepEnd(kept.joined(separator: "\n"), limit: 30000) + } + static func stopAppConsole(_ args: [String: Value]?) async -> CallTool.Result { await AppConsole.shared.stop() return .ok("App console stopped") diff --git a/Sources/XCForgeKit/Tools/DebuggerProvider.swift b/Sources/XCForgeKit/Tools/DebuggerProvider.swift index 043c054..223b0da 100644 --- a/Sources/XCForgeKit/Tools/DebuggerProvider.swift +++ b/Sources/XCForgeKit/Tools/DebuggerProvider.swift @@ -235,6 +235,11 @@ extension DebuggerProvider { "type": .string("string"), "description": .string("Execution mode: continue, step_over, step_into, step_out (default: continue)."), ]), + "timeoutSeconds": .object([ + "type": .string("integer"), + "description": .string( + "How long to wait for the process to stop again (breakpoint, step done). Default: 30."), + ]), ]), "required": .array([.string("sessionId")]), ]) @@ -295,6 +300,7 @@ extension DebuggerProvider { struct ContinueInput: Decodable { let sessionId: String let mode: String? + let timeoutSeconds: Int? } struct RunCommandInput: Decodable { @@ -551,8 +557,8 @@ extension DebuggerProvider { } do { - // 10-second timeout per spec I/O matrix - let output = try await session.sendCommand(command, timeout: 10) + let timeout = TimeInterval(max(1, input.timeoutSeconds ?? 30)) + let output = try await session.sendCommand(command, timeout: timeout) let result = parseContinueOutput(output) let data = try JSONEncoder().encode(result) return .ok(String(data: data, encoding: .utf8) ?? "{}") diff --git a/Sources/XCForgeKit/Tools/DeviceProvider.swift b/Sources/XCForgeKit/Tools/DeviceProvider.swift index dc4ad15..ece862e 100644 --- a/Sources/XCForgeKit/Tools/DeviceProvider.swift +++ b/Sources/XCForgeKit/Tools/DeviceProvider.swift @@ -129,6 +129,15 @@ public enum DeviceTools { "items": .object(["type": .string("string")]), "description": .string("Arguments to pass to the launched app"), ]), + "url": .object([ + "type": .string("string"), + "description": .string("Open this URL (deep link or universal link) in the app at launch."), + ]), + "env": .object([ + "type": .string("object"), + "additionalProperties": .object(["type": .string("string")]), + "description": .string("Environment variables for the launched app."), + ]), ]), "required": .array([.string("device"), .string("bundle_id")]), ]) @@ -171,6 +180,63 @@ public enum DeviceTools { "required": .array([.string("device")]), ]) ), + Tool( + name: "device_screenshot", + description: + "Save a screenshot of a connected physical device as PNG. Uses devicectl, or the device's WebDriverAgent when devicectl can't capture.", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "device": .object([ + "type": .string("string"), "description": .string("Device name or UDID"), + ]), + "path": .object([ + "type": .string("string"), + "description": .string("Where to write the PNG. Default: a new file in the artifact directory."), + ]), + ]), + "required": .array([.string("device")]), + ]) + ), + Tool( + name: "wda_start", + description: + "Build, sign and start WebDriverAgent on a connected physical device, then point this server's UI tools (find_element, tap, ...) at it. Reuses a runner that is already answering.", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "device": .object([ + "type": .string("string"), "description": .string("Device name or UDID"), + ]), + "team": .object([ + "type": .string("string"), + "description": .string("Apple development team ID for signing. Default: XCFORGE_WDA_TEAM."), + ]), + "bundle_id": .object([ + "type": .string("string"), + "description": .string( + "Runner bundle id. Default: com.xcforge.wda..runner (or com.xcforge.wda.runner without a team)."), + ]), + "port": .object([ + "type": .string("integer"), "description": .string("Port WDA listens on. Default: 8100."), + ]), + ]), + "required": .array([.string("device")]), + ]) + ), + Tool( + name: "wda_stop", + description: "Stop WebDriverAgent started on a physical device with wda_start.", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "device": .object([ + "type": .string("string"), "description": .string("Device name or UDID"), + ]) + ]), + "required": .array([.string("device")]), + ]) + ), ] // MARK: - Input Structs @@ -200,6 +266,20 @@ public enum DeviceTools { let terminate_existing: Bool? let timeout: Int? let arguments: [String]? + let url: String? + let env: [String: String]? + } + + private struct WDAStartInput: Decodable { + let device: String + let team: String? + let bundle_id: String? + let port: Int? + } + + private struct ScreenshotInput: Decodable { + let device: String + let path: String? } private struct TerminateInput: Decodable { @@ -258,29 +338,9 @@ public enum DeviceTools { ) } - var devices: [DeviceEntry] = [] - for device in deviceList { - let properties = device["deviceProperties"] as? [String: Any] ?? [:] - let connectionProperties = device["connectionProperties"] as? [String: Any] ?? [:] - let name = properties["name"] as? String ?? "Unknown" - let osVersion = - (device["deviceProperties"] as? [String: Any])?["osVersionNumber"] as? String ?? "Unknown" - let udid = - (device["hardwareProperties"] as? [String: Any])?["udid"] as? String - ?? (device["identifier"] as? String) - ?? "Unknown" - let state = (device["visibilityClass"] as? String) ?? "available" - let connectionType = connectionProperties["transportType"] as? String ?? "unknown" - - devices.append( - DeviceEntry( - name: name, - udid: udid, - osVersion: osVersion, - state: state, - connectionType: connectionType - )) - } + // Xcode 27's devicectl (Device Hub) lists simulators too; this tool is for phones and tablets. + let simulatorUDIDs = await simulatorUDIDs(env: env) + let devices = deviceList.compactMap { physicalEntry($0, simulatorUDIDs: simulatorUDIDs) } if let filter = filter?.lowercased(), !filter.isEmpty { let filtered = devices.filter { @@ -322,24 +382,35 @@ public enum DeviceTools { ) } - let deviceInfo = resultObj["deviceProperties"] as? [String: Any] ?? resultObj var lines: [String] = [] - if let name = deviceInfo["name"] as? String { lines.append("Name: \(name)") } - if let osVersion = deviceInfo["osVersionNumber"] as? String { + if let name = property(resultObj, "name") as? String { lines.append("Name: \(name)") } + if let osVersion = property(resultObj, "osVersionNumber") as? String { lines.append("OS: \(osVersion)") } - - let hw = resultObj["hardwareProperties"] as? [String: Any] ?? [:] - if let udid = hw["udid"] as? String { lines.append("UDID: \(udid)") } - if let model = hw["marketingName"] as? String ?? hw["productType"] as? String { + if let udid = property(resultObj, "udid") as? String { lines.append("UDID: \(udid)") } + if let model = property(resultObj, "marketingName") as? String + ?? property(resultObj, "productType") as? String + { lines.append("Model: \(model)") } - if let platform = hw["platform"] as? String { lines.append("Platform: \(platform)") } - - let conn = resultObj["connectionProperties"] as? [String: Any] ?? [:] - if let transport = conn["transportType"] as? String { + if let platform = property(resultObj, "platform") as? String { + lines.append("Platform: \(platform)") + } + if let transport = property(resultObj, "transportType") as? String { lines.append("Connection: \(transport)") } + if let tunnel = property(resultObj, "tunnelState") as? String { + lines.append("Tunnel: \(tunnel)") + } + if let address = property(resultObj, "tunnelIPAddress") as? String { + lines.append("Tunnel IP: \(address)") + } + if let pairing = property(resultObj, "pairingState") as? String { + lines.append("Pairing: \(pairing)") + } + if let devMode = property(resultObj, "developerModeStatus") as? String { + lines.append("Developer Mode: \(devMode)") + } return DeviceResult( succeeded: true, @@ -411,6 +482,8 @@ public enum DeviceTools { terminateExisting: Bool, timeout: Int, arguments: [String]?, + url: String? = nil, + environment: [String: String]? = nil, env: Environment ) async -> DeviceResult { do { @@ -422,6 +495,15 @@ public enum DeviceTools { if terminateExisting { args.append("--terminate-existing") } + if let url, !url.isEmpty { + args += ["--payload-url", url] + } + if let environment, !environment.isEmpty, + let data = try? JSONSerialization.data(withJSONObject: environment, options: [.sortedKeys]), + let json = String(data: data, encoding: .utf8) + { + args += ["--environment-variables", json] + } args.append(bundleId) @@ -538,17 +620,121 @@ public enum DeviceTools { // MARK: - Physical Device UDID Detection + /// Capture the device screen to `path` (PNG). devicectl first (Xcode 26.6 and later), then + /// the device's WebDriverAgent when `xcforge wda start` has one running. + public static func executeDeviceScreenshot(device: String, path: String?, env: Environment) async + -> DeviceResult + { + let output = path ?? XcodebuildOptions.uniqueArtifactPath(prefix: "device-shot", extension: "png") + var devicectlError = "" + do { + let (result, json) = try await runDevicectl( + arguments: ["device", "capture", "screenshot", "--device", device, "--destination", output], + timeout: 60, env: env) + if result.succeeded, FileManager.default.fileExists(atPath: output) { + return DeviceResult(succeeded: true, message: "Screenshot: \(output)") + } + devicectlError = json.flatMap { extractError(from: $0) } ?? result.stderr + } catch { + devicectlError = "\(error)" + } + + if let state = DeviceWDA.load(device: device), + let data = await DeviceWDA.screenshot(baseURL: state.url) + { + do { + try data.write(to: URL(fileURLWithPath: output)) + return DeviceResult(succeeded: true, message: "Screenshot (via WebDriverAgent): \(output)") + } catch { + return DeviceResult(succeeded: false, message: "Could not write \(output): \(error)") + } + } + let reason = DeviceWDA.explainFailure(devicectlError) ?? devicectlError + return DeviceResult( + succeeded: false, + message: + "Screenshot failed: \(reason.isEmpty ? "devicectl returned no image" : reason)\n" + + "devicectl screen capture needs Xcode 26.6 or later. Alternatively start WebDriverAgent " + + "on the device with `xcforge wda start --device \(device)` and retry.") + } + + /// A device from `devicectl list devices` JSON, or nil when it is a simulator. + static func physicalEntry(_ device: [String: Any], simulatorUDIDs: Set = []) -> DeviceEntry? { + let udid = + property(device, "udid") as? String + ?? (device["identifier"] as? String) + ?? "Unknown" + if isSimulatorEntry(device) || simulatorUDIDs.contains(udid.uppercased()) { return nil } + if let identifier = device["identifier"] as? String, simulatorUDIDs.contains(identifier.uppercased()) { + return nil + } + return DeviceEntry( + name: property(device, "name") as? String ?? "Unknown", + udid: udid, + osVersion: property(device, "osVersionNumber") as? String ?? "Unknown", + state: connectionState(device), + connectionType: property(device, "transportType") as? String ?? "unknown") + } + + /// devicectl marks hardware as `reality: physical` and simulators as `simulated`; older or + /// partial entries are caught by the simulator visibility class, provider, platform or type. + static func isSimulatorEntry(_ device: [String: Any]) -> Bool { + if let reality = property(device, "reality") as? String, reality.lowercased() != "physical" { return true } + if (device["visibilityClass"] as? String)?.lowercased() == "simulators" { return true } + if let provider = property(device, "provider") as? String, provider.contains("CoreSimulator") { return true } + for key in ["platform", "deviceType", "transportType"] { + if let value = property(device, key) as? String, value.lowercased().contains("simulator") { return true } + } + return false + } + + /// Connected, disconnected or unavailable from the tunnel; else pairing or boot state. + /// `visibilityClass` is a display hint, not a state. + static func connectionState(_ device: [String: Any]) -> String { + for key in ["tunnelState", "pairingState", "bootState"] { + if let value = property(device, key) as? String, !value.isEmpty { return value } + } + return "unknown" + } + + /// Upper-cased UDIDs of every simulator, so a simulator devicectl doesn't label is still dropped. + private static func simulatorUDIDs(env: Environment) async -> Set { + guard let result = try? await env.shell.xcrun(timeout: 10, arguments: ["simctl", "list", "devices", "-j"]), + result.succeeded, + let json = try? JSONSerialization.jsonObject(with: Data(result.stdout.utf8)) as? [String: Any], + let runtimes = json["devices"] as? [String: [[String: Any]]] + else { return [] } + return Set(runtimes.values.flatMap { $0 }.compactMap { ($0["udid"] as? String)?.uppercased() }) + } + public static func isConnectedPhysicalDevice(_ identifier: String, env: Environment) async -> Bool { let list = await executeListDevices(filter: nil, env: env) return list.devices.contains { $0.udid == identifier || $0.name == identifier } } + /// Read a device field from devicectl JSON. Xcode 27 groups fields under `properties` + /// (`properties.hardware.udid`, `properties.state.name`, ...); earlier versions split them + /// across `deviceProperties`, `hardwareProperties` and `connectionProperties`, which Xcode 27 + /// still writes. Both layouts are read. + static func property(_ device: [String: Any], _ key: String) -> Any? { + if let properties = device["properties"] as? [String: Any] { + if let value = properties[key], !(value is [String: Any]) { return value } + for group in ["hardware", "state", "connection", "device"] { + if let dict = properties[group] as? [String: Any], let value = dict[key] { return value } + } + } + for container in ["deviceProperties", "hardwareProperties", "connectionProperties"] { + if let dict = device[container] as? [String: Any], let value = dict[key] { return value } + } + return device[key] + } + // MARK: - Formatting Helpers private static func formatDeviceList(_ devices: [DeviceEntry]) -> String { var lines: [String] = ["\(devices.count) device(s):"] for device in devices { - lines.append(" \(device.name) — \(device.osVersion) [\(device.connectionType)]") + lines.append(" \(device.name) — \(device.osVersion) [\(device.connectionType), \(device.state)]") lines.append(" UDID: \(device.udid)") } return lines.joined(separator: "\n") @@ -625,9 +811,47 @@ extension DeviceTools: ToolProvider { terminateExisting: input.terminate_existing ?? true, timeout: input.timeout ?? 30, arguments: input.arguments, + url: input.url, + environment: input.env, env: env )) } + case "wda_start": + switch ToolInput.decode(WDAStartInput.self, from: args) { + case .failure(let err): return err + case .success(let input): + do { + let state = try await DeviceWDA.start( + device: input.device, team: input.team, bundleID: input.bundle_id, + port: input.port ?? DeviceWDA.defaultPort, env: env) + await env.wdaClient.setBaseURL(state.url) + return .ok( + "WebDriverAgent running on \(state.name ?? state.udid) at \(state.url)\n" + + "UI tools in this session now target the device. Runner log: \(state.logPath)") + } catch { + return .fail("\(error)") + } + } + case "wda_stop": + switch ToolInput.decode(DeviceInput.self, from: args) { + case .failure(let err): return err + case .success(let input): + let state = DeviceWDA.load(device: input.device) + await DeviceWDA.stop(device: input.device, env: env) + if let state, await env.wdaClient.getBaseURL() == state.url { + await env.wdaClient.useSimulators() + } + return .ok( + state == nil + ? "No WebDriverAgent recorded for \(input.device)" : "Stopped WebDriverAgent on \(input.device)") + } + case "device_screenshot": + switch ToolInput.decode(ScreenshotInput.self, from: args) { + case .failure(let err): return err + case .success(let input): + return dispatchResult( + await executeDeviceScreenshot(device: input.device, path: input.path, env: env)) + } case "device_terminate": switch ToolInput.decode(TerminateInput.self, from: args) { case .failure(let err): return err diff --git a/Sources/XCForgeKit/Tools/DiagnoseTools.swift b/Sources/XCForgeKit/Tools/DiagnoseTools.swift index f70c667..4223f9f 100644 --- a/Sources/XCForgeKit/Tools/DiagnoseTools.swift +++ b/Sources/XCForgeKit/Tools/DiagnoseTools.swift @@ -446,7 +446,7 @@ enum DiagnoseTools { private static func encodeResult(_ value: T, isError: Bool) -> CallTool.Result { do { - let json = try WorkflowJSONRenderer.renderJSON(value) + let json = try WorkflowJSONRenderer.renderJSON(value, compact: true) return .init(content: [.text(text: json, annotations: nil, _meta: nil)], isError: isError) } catch { return .fail("JSON encoding error: \(error)") diff --git a/Sources/XCForgeKit/Tools/InteractionProvider.swift b/Sources/XCForgeKit/Tools/InteractionProvider.swift index 64001d6..fcd8356 100644 --- a/Sources/XCForgeKit/Tools/InteractionProvider.swift +++ b/Sources/XCForgeKit/Tools/InteractionProvider.swift @@ -2,11 +2,11 @@ import Foundation import MCP enum UITools { - public static let tools: [Tool] = [ + static let coreTools: [Tool] = [ Tool( name: "wda_status", description: - "Check if WebDriverAgent (WDA) is running and reachable. Call this first to verify the UI automation backend is available before using find_element, click_element, or other WDA-dependent tools.", + "Check whether WebDriverAgent (WDA) is running and reachable, and which URL UI tools use. UI tools start WDA on a simulator by themselves; for a physical device use wda_start.", inputSchema: .object(["type": .string("object"), "properties": .object([:])]) ), Tool( @@ -35,7 +35,7 @@ enum UITools { Tool( name: "wda_create_session", description: - "Create a new WebDriverAgent session, optionally targeting a specific app by bundle ID. A session is required before using UI interaction tools (find_element, click_element, type_text, etc.). Sessions are auto-created by most tools, so this is only needed to explicitly switch the target app.", + "Create a new WebDriverAgent session, optionally targeting a specific app by bundle ID. A session is required before using UI interaction tools (find_element, tap, type_text, etc.). Sessions are auto-created by most tools, so this is only needed to explicitly switch the target app.", inputSchema: .object([ "type": .string("object"), "properties": .object([ @@ -46,16 +46,26 @@ enum UITools { "type": .string("string"), "description": .string("WDA base URL. Default: http://localhost:8100"), ]), + "alert_action": .object([ + "type": .string("string"), + "description": .string( + "What WDA does with system alerts (permission prompts) from now on: 'accept', 'dismiss' or 'none'. XCFORGE_ALERT_ACTION sets the default." + ), + ]), ]), ]) ), Tool( name: "find_element", description: - "Find a single UI element matching a query. Returns the element ID for use with click_element, get_text, etc. With scroll: true, automatically scrolls the nearest ScrollView/List until the element appears (one call, no manual swipe loop needed). Use find_elements instead when you need all matches (e.g. counting list items).", + "Find a single UI element matching a query. Returns the element ID for use with tap, get_text, etc. With scroll: true, automatically scrolls the nearest ScrollView/List until the element appears (one call, no manual swipe loop needed). all: true returns every match (e.g. counting list items).", inputSchema: .object([ "type": .string("object"), "properties": .object([ + "all": .object([ + "type": .string("boolean"), + "description": .string("Return every matching element's ID instead of one."), + ]), "using": .object([ "type": .string("string"), "description": .string( @@ -75,6 +85,19 @@ enum UITools { "max_swipes": .object([ "type": .string("number"), "description": .string("Max scroll attempts. Default: 10"), ]), + "index": .object([ + "type": .string("number"), + "description": .string( + "Which match to use when several match (0-based). Several matches are listed with labels and frames."), + ]), + "timeout": .object([ + "type": .string("number"), + "description": .string("Seconds to wait for the element to appear. Default: 0 (look once)"), + ]), + "until_gone": .object([ + "type": .string("boolean"), + "description": .string("Wait until nothing matches instead (needs timeout). Default: false"), + ]), ]), "required": .array([.string("using"), .string("value")]), ]) @@ -168,6 +191,11 @@ enum UITools { "duration_ms": .object([ "type": .string("number"), "description": .string("Swipe duration in ms. Default: 300"), ]), + "hid": .object([ + "type": .string("boolean"), + "description": .string( + "Swipe with native HID events on a simulator (falls back to WDA; not yet supported on Xcode 27)."), + ]), ]), "required": .array([ .string("start_x"), .string("start_y"), .string("end_x"), .string("end_y"), @@ -239,20 +267,34 @@ enum UITools { ), Tool( name: "type_text", - description: "Type text into the currently focused element or a specified element.", + description: """ + Type into the field that has keyboard focus, or into element_id. Also presses a key \ + (return, delete, tab, escape) or dismisses the keyboard. Secure fields' text is never echoed. + """, inputSchema: .object([ "type": .string("object"), "properties": .object([ "text": .object(["type": .string("string"), "description": .string("Text to type")]), "element_id": .object([ - "type": .string("string"), "description": .string("Optional element ID to type into"), + "type": .string("string"), "description": .string("Element to type into. Default: the focused field"), ]), "clear_first": .object([ "type": .string("boolean"), "description": .string("Clear existing text first. Default: false"), ]), + "key": .object([ + "type": .string("string"), + "description": .string("Key to press after the text: return, delete, tab or escape"), + ]), + "dismiss_keyboard": .object([ + "type": .string("boolean"), + "description": .string("Dismiss the keyboard afterwards. Default: false"), + ]), + "secure": .object([ + "type": .string("boolean"), + "description": .string("Don't echo the text, even outside a secure field. Default: false"), + ]), ]), - "required": .array([.string("text")]), ]) ), Tool( @@ -272,14 +314,25 @@ enum UITools { Tool( name: "get_source", description: - "Get the full view hierarchy (source tree) of the current screen. Returns all elements with their types, labels, frames, and accessibility identifiers. Use to discover element identifiers before using find_element, or to debug layout issues.", + "Get the view hierarchy of the current screen. format: list gives one line per element (` |