diff --git a/CHANGELOG.md b/CHANGELOG.md index 535d256..5010ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,138 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [Unreleased] +### Added +- **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..cb2002d 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/` ## 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..2a172de 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.** --- @@ -204,7 +204,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 +219,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 +281,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 +334,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 +394,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: ` |

/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 (` |