A Model Context Protocol (MCP) server and CLI for Bazel-based Apple platform development.
- Ships 112 tools across 19 workflow categories covering iOS, macOS, tvOS, watchOS,
+ Ships 125 tools across 21 workflow categories covering iOS, macOS, tvOS, watchOS,
visionOS, and Swift Package Manager.
- [](https://github.com/XcodeBazelMCP/XcodeBazelMCP/actions/workflows/ci.yml)
- [](https://www.npmjs.com/package/xcodebazelmcp)
- [](https://github.com/XcodeBazelMCP/XcodeBazelMCP/blob/main/LICENSE)
- [](https://nodejs.org/)
- [](https://developer.apple.com/xcode/)
- [](https://github.com/XcodeBazelMCP/XcodeBazelMCP)
- [](https://modelcontextprotocol.io)
- [](https://codecov.io/gh/XcodeBazelMCP/XcodeBazelMCP)
- [](https://badge.socket.dev/npm/package/xcodebazelmcp/0.1.0)
- [](https://deepwiki.com/XcodeBazelMCP/XcodeBazelMCP)
+[](https://github.com/XcodeBazelMCP/XcodeBazelMCP/actions/workflows/ci.yml)
+[](https://www.npmjs.com/package/xcodebazelmcp)
+[](https://github.com/XcodeBazelMCP/XcodeBazelMCP/blob/main/LICENSE)
+[](https://nodejs.org/)
+[](https://developer.apple.com/xcode/)
+[](https://github.com/XcodeBazelMCP/XcodeBazelMCP)
+[](https://modelcontextprotocol.io)
+[](https://codecov.io/gh/XcodeBazelMCP/XcodeBazelMCP)
+[](https://badge.socket.dev/npm/package/xcodebazelmcp/0.1.0)
+[](https://deepwiki.com/XcodeBazelMCP/XcodeBazelMCP)
+
@@ -147,7 +148,7 @@ npm run build
XcodeBazelMCP needs to know which Bazel workspace to operate on. There are several ways to set it, in order of precedence:
-1. **MCP tool at runtime** — `bazel_ios_set_workspace` (or CLI `set-defaults --target //app:app`)
+1. **MCP tool at runtime** — `bazel_ios_set_workspace` (sets the workspace for later tool calls)
2. **CLI flag** — `xcodebazelmcp mcp --workspace /path/to/workspace`
3. **Environment variable** — `BAZEL_IOS_WORKSPACE=/path/to/workspace`
4. **Config file** — `.xcodebazelmcp/config.yaml` in the workspace root (supports profiles)
@@ -158,16 +159,31 @@ For multi-workspace setups, use **profiles** in `config.yaml`:
```yaml
profiles:
app:
- target: '//app:app'
- platform: simulator
- buildMode: debug
+ defaultTarget: '//app:app'
+ defaultPlatform: simulator
+ defaultBuildMode: debug
mac:
- target: '//mac:mac'
- platform: macos
+ defaultTarget: '//mac:mac'
+ defaultPlatform: macos
```
Then switch at runtime: `xcodebazelmcp set-defaults --profile app`
+## Environment Variables
+
+| Variable | Purpose |
+| ------------------------------ | ----------------------------------------------------------------------- |
+| `BAZEL_IOS_WORKSPACE` | Bazel workspace root (highest-precedence workspace source). |
+| `BAZEL_PATH` / `MCP_BAZEL_PATH`| Path to the Bazel/Bazelisk binary (defaults to `bazel` on `PATH`). |
+| `BAZEL_IOS_STARTUP_ARGS` | Bazel startup args prepended to every invocation (quote-aware). |
+| `BAZEL_IOS_MCP_MAX_OUTPUT` | Max captured command output in characters (default 200000). |
+| `BAZEL_IOS_SIMULATOR_CPU` | Override the iOS simulator CPU (`sim_arm64` / `x86_64`; default: host). |
+| `BAZEL_IOS_DISCOVER_SCOPE` | Default `discover` query scope for non-monorepo workspaces (e.g. `//...`). |
+| `BAZEL_IOS_COMMAND_LOG` | Path to the persistent NDJSON command log. |
+| `BAZEL_IOS_COMMAND_LOG_MAX_BYTES` | Rotate the command log past this size (default 5 MiB). |
+| `BAZEL_IOS_COMMAND_LOG_DISABLE`| Set to `1` to disable command logging. |
+| `IDB_PATH` | Path to the `idb` binary for UI automation (else discovered on `PATH`). |
+
## CLI Examples
```sh
@@ -207,31 +223,33 @@ xcodebazelmcp set-defaults --target //app:app --simulator-name "iPhone 16 Pro"
xcodebazelmcp workflows
```
-## Workflow Categories (112 tools)
+## Workflow Categories (125 tools)
Workflows control which tools are advertised to MCP clients. Smart defaults enable the most common workflows; use `toggle-workflow` to customize.
-| Category | Tools | Description |
-| ----------------- | ----- | ----------------------------------------------------- |
-| **build** | 2 | Build iOS targets for simulator or device |
-| **test** | 2 | Run iOS tests with optional coverage |
-| **simulator** | 10 | Manage simulator lifecycle and settings |
-| **app_lifecycle** | 5 | Install, launch, stop apps on simulator |
-| **capture** | 5 | Screenshot, video recording, log capture (simulator) |
-| **ui_automation** | 9 | Tap, swipe, type, drag, accessibility snapshot |
-| **deep_links** | 2 | Open URLs and send push notifications |
-| **device** | 13 | Physical device build, deploy, test, screenshot, logs |
-| **lldb** | 10 | LLDB debugger: breakpoints, variables, stepping |
-| **macos** | 13 | macOS build, run, test, discover |
-| **tvos** | 4 | tvOS build, run, test, discover |
-| **watchos** | 4 | watchOS build, run, test, discover |
-| **visionos** | 4 | visionOS build, run, test, discover |
-| **spm** | 7 | Swift Package Manager operations |
-| **project** | 6 | Target discovery, query, deps, rdeps |
-| **scaffold** | 2 | Generate new Bazel projects |
-| **session** | 7 | Workspace, defaults, profiles, health |
-| **daemon** | 3 | Background daemon management |
-| **update** | 2 | Self-update and version check |
+| Category | Tools | Description |
+| ----------------- | ----- | -------------------------------------------------------------------------------------- |
+| **build** | 2 | Build iOS targets for simulator or device |
+| **test** | 2 | Run iOS tests with optional coverage |
+| **simulator** | 12 | Manage simulator lifecycle, settings, media, and containers |
+| **app_lifecycle** | 6 | Install, launch, stop, uninstall apps on simulator |
+| **capture** | 5 | Screenshot, video recording, log capture (simulator) |
+| **agent_debug** | 4 | NDJSON agent debug logs (Cursor DEBUG MODE) |
+| **ui_automation** | 9 | Tap, swipe, type, drag, accessibility snapshot |
+| **deep_links** | 2 | Open URLs and send push notifications |
+| **device** | 16 | Physical device build, deploy, test, screenshot, logs, uninstall, list apps |
+| **lldb** | 10 | LLDB debugger: breakpoints, variables, stepping |
+| **macos** | 13 | macOS build, run, test, discover |
+| **tvos** | 4 | tvOS build, run, test, discover |
+| **watchos** | 4 | watchOS build, run, test, discover |
+| **visionos** | 4 | visionOS build, run, test, discover |
+| **spm** | 7 | Swift Package Manager operations |
+| **project** | 6 | Target discovery, query, deps, rdeps |
+| **scaffold** | 2 | Generate new Bazel projects |
+| **session** | 7 | Workspace, defaults, profiles, health |
+| **daemon** | 3 | Background daemon management |
+| **update** | 2 | Self-update and version check |
+| **xcode** | 3 | Apple-native Xcode MCP detection, DeviceHub, skills ([docs](docs/xcode-native-mcp.md)) |
## iOS 17+ Device Notes
diff --git a/docs/xcode-native-mcp.md b/docs/xcode-native-mcp.md
new file mode 100644
index 0000000..cb892b8
--- /dev/null
+++ b/docs/xcode-native-mcp.md
@@ -0,0 +1,92 @@
+# Xcode Native MCP Integration (Xcode 26.3+)
+
+Apple ships a native Model Context Protocol (MCP) integration inside Xcode and
+LLDB. XcodeBazelMCP **detects and bridges** this tooling so an agent can use
+Xcode's own tools alongside the Bazel/simctl/devicectl flows — without replacing
+anything. Everything here is additive and degrades gracefully on older Xcode.
+
+> Status: `mcpbridge` ships in **Xcode 26.3 (stable)**. `lldb-mcp` and
+> `DeviceHub.app` currently ship only in **Xcode-beta / Xcode 27**. The
+> integration is treated as **beta**: detection is always safe, and the existing
+> XcodeBazelMCP tools remain the supported path.
+
+## What Apple provides
+
+| Tool | Where | What it does |
+|------|-------|--------------|
+| `xcrun mcpbridge` | Xcode 26.3+ (`/usr/bin/mcpbridge`) | STDIO MCP bridge between an MCP client and a **running Xcode**'s tool service. Auto-selects the Xcode via `MCP_XCODE_PID` or `xcode-select`. |
+| `xcrun mcpbridge run-agent ` | Xcode 26.3+ | Launch a coding agent (e.g. `claude`) pre-wired with Xcode's MCP tools, auth, and env. `--dry-run` prints the resolved command; `--no-xcode-tools` excludes them. |
+| `xcrun mcpbridge run-agent skills export` | Xcode 26.3+ | Export Xcode's globally available `SKILL.md` bundles (e.g. `device-interaction`, `swiftui-whats-new-27`). |
+| `lldb-mcp` | Xcode-beta / 27 (`/usr/bin/lldb-mcp`) | LLDB MCP server for debugging. |
+| `DeviceHub.app` | Xcode-beta / 27 (`/Contents/Applications/DeviceHub.app`, bundle `com.apple.dt.Devices`) | GUI device manager — the successor to Xcode's Devices window. |
+
+### Xcode's native device-interaction MCP tools
+
+When connected through `mcpbridge` to a running Xcode 27, the agent gains
+Xcode's own device tools (surfaced by the `device-interaction` skill):
+
+```
+DeviceInteractionStartSession → start a background device/sim session
+ DeviceInteractionInstallAndRun → build + install + launch (commandLineArguments, environmentVariables)
+ DeviceEventSynthesize → perform a touch/UI interaction and observe state (repeatable)
+ DeviceInteractionEndSession → tear down (sessions are resource-heavy)
+```
+
+These complement XcodeBazelMCP's `bazel_ios_*` UI-automation/device tools, which
+remain available for Bazel-built apps and pre-27 Xcode.
+
+## What XcodeBazelMCP adds
+
+Three opt-in tools (workflow `xcode`) and matching CLI commands:
+
+| MCP tool | CLI | Purpose |
+|----------|-----|---------|
+| `bazel_xcode_native_mcp_status` | `xcodebazelmcp xcode-mcp-status` | Detect installs, `mcpbridge`/`lldb-mcp`/DeviceHub availability, running Xcode PIDs, and emit a ready-to-paste MCP client config. |
+| `bazel_xcode_open_device_hub` | `xcodebazelmcp devicehub` | Open DeviceHub.app for device interaction (clear guidance when not installed). |
+| `bazel_xcode_export_skills` | `xcodebazelmcp xcode-export-skills [--output-dir ] [--replace-existing]` | Export Xcode's agent skill bundles (needs a running Xcode). |
+
+Enable the workflow for MCP clients (it is **off by default**, like other
+optional categories):
+
+```sh
+xcodebazelmcp toggle-workflow xcode on
+```
+
+The CLI commands work regardless of workflow filtering.
+
+## Wiring Xcode's native tools into your MCP client
+
+`xcode-mcp-status` prints a drop-in snippet. Add it next to XcodeBazelMCP so the
+agent sees both tool sets (Bazel tools from XcodeBazelMCP, Xcode IDE/device tools
+from `mcpbridge`):
+
+```json
+{
+ "mcpServers": {
+ "xcodebazelmcp": { "command": "npx", "args": ["xcodebazelmcp", "mcp"] },
+ "xcode-native": { "command": "xcrun", "args": ["mcpbridge"] }
+ }
+}
+```
+
+- `mcpbridge` requires a **running Xcode**. With multiple Xcodes installed it
+ uses `xcode-select`; pin one with `MCP_XCODE_PID=` or
+ `env: { "DEVELOPER_DIR": "/Applications/Xcode-beta.app/Contents/Developer" }`.
+- To target the beta toolchain for `lldb-mcp`/DeviceHub, set `DEVELOPER_DIR`
+ before invoking, or select Xcode-beta via `xcode-select`.
+
+## Backwards compatibility
+
+- Detection never throws and never shells out destructively.
+- On Xcode < 26.3 (no `mcpbridge`), `xcode-mcp-status` reports it's unavailable
+ and the Bazel/simctl/devicectl tools continue to be the supported path.
+- `bazel_xcode_open_device_hub` returns actionable guidance (use
+ `bazel_ios_list_devices` / `bazel_ios_device_info`) when DeviceHub is absent.
+
+## Quick check
+
+```sh
+xcodebazelmcp xcode-mcp-status
+# → lists Xcode.app / Xcode-beta.app with mcpbridge/lldb-mcp/DeviceHub flags,
+# running Xcode PIDs, and the client-config snippet.
+```
diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs
index a4c5e21..349ec23 100644
--- a/scripts/smoke.mjs
+++ b/scripts/smoke.mjs
@@ -1,4 +1,6 @@
-import { existsSync, readFileSync } from 'node:fs';
+import { execFileSync } from 'node:child_process';
+import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
const root = resolve(new URL('..', import.meta.url).pathname);
@@ -31,4 +33,28 @@ for (const toolName of ['bazel_ios_build', 'bazel_ios_test', 'bazel_ios_discover
}
}
+// Runtime smoke: boot the CLI (no Bazel/Xcode needed) and exercise scaffolding.
+const cli = join(root, 'src/cli.ts');
+const runCli = (args) =>
+ execFileSync('npx', ['tsx', cli, ...args], { cwd: root, encoding: 'utf8', timeout: 60_000 });
+
+const toolsOut = runCli(['tools']);
+const toolCount = toolsOut.split('\n').filter((l) => /^[a-z_]+$/.test(l)).length;
+if (toolCount !== 125) {
+ throw new Error(`Expected 125 tools, CLI listed ${toolCount}`);
+}
+for (const t of ['bazel_ios_uninstall_app', 'bazel_ios_device_uninstall_app', 'bazel_ios_device_list_apps']) {
+ if (!toolsOut.includes(t)) throw new Error(`tools output missing ${t}`);
+}
+
+const scaffoldDir = mkdtempSync(join(tmpdir(), 'xbmcp-smoke-'));
+try {
+ runCli(['new', 'ios_app', 'SmokeApp', '-o', scaffoldDir]);
+ for (const f of ['MODULE.bazel', '.bazelrc', 'SmokeApp/BUILD.bazel', 'SmokeApp/Info.plist']) {
+ if (!existsSync(join(scaffoldDir, f))) throw new Error(`scaffold missing ${f}`);
+ }
+} finally {
+ rmSync(scaffoldDir, { recursive: true, force: true });
+}
+
console.log('smoke ok');
diff --git a/src/cli.test.ts b/src/cli.test.ts
index 2af1f1a..6d89de8 100644
--- a/src/cli.test.ts
+++ b/src/cli.test.ts
@@ -17,6 +17,7 @@ describe('CLI help', () => {
expect(out).toContain('Usage:');
expect(out).toContain('xcodebazelmcp mcp');
expect(out).toContain('Build & Run:');
+ expect(out).toContain('--launch-env KEY=VAL');
expect(out).toContain('Query & Inspect:');
expect(out).toContain('Config & Defaults:');
expect(out).toContain('Simulator:');
@@ -40,13 +41,34 @@ describe('CLI help', () => {
});
});
+describe('CLI error handling', () => {
+ it('prints a clean error (no stack trace) for an invalid label', () => {
+ let stdout = '';
+ let stderr = '';
+ let exitCode = 0;
+ try {
+ stdout = run(['deps', 'not a label']);
+ } catch (e) {
+ const err = e as { status?: number; stdout?: string; stderr?: string };
+ exitCode = err.status ?? 1;
+ stdout = err.stdout ?? '';
+ stderr = err.stderr ?? '';
+ }
+ expect(exitCode).toBe(1);
+ const combined = stdout + stderr;
+ expect(combined).toContain('Error: target must be a Bazel label');
+ expect(combined).not.toContain('at requireLabel');
+ expect(combined).not.toContain('.js:');
+ });
+});
+
describe('CLI tools', () => {
- it('lists all 117 tools', () => {
+ it('lists all 125 tools', () => {
const out = run(['tools']);
const toolLines = out
.split('\n')
.filter((line) => line.match(/^[a-z_]+$/));
- expect(toolLines.length).toBe(117);
+ expect(toolLines.length).toBe(125);
expect(out).toContain('bazel_ios_build');
expect(out).toContain('bazel_macos_build');
expect(out).toContain('bazel_tvos_build');
diff --git a/src/cli.ts b/src/cli.ts
index 2aa7e56..350b422 100644
--- a/src/cli.ts
+++ b/src/cli.ts
@@ -17,7 +17,7 @@ import {
} from './cli/parsers.js';
import {
printTool, runSkillInit, runUpgrade, runDaemon,
- runVideoRecord, runLogStream, runSetupWizard,
+ runVideoRecord, runLogStream, runSetupWizard, runCheckUpdate,
} from './cli/commands.js';
import { printHelp } from './cli/help.js';
@@ -25,6 +25,17 @@ const argv = process.argv.slice(2);
const command = argv[0] || 'help';
switch (command) {
+ case '-h':
+ case '--help':
+ printHelp();
+ break;
+ case 'version':
+ case '--version':
+ case '-v': {
+ const { getCurrentVersion } = await import('./core/upgrade.js');
+ console.log(getCurrentVersion());
+ break;
+ }
case 'mcp':
await startMcpServer();
break;
@@ -111,9 +122,29 @@ switch (command) {
case 'last-command':
await printTool('bazel_ios_last_command', {});
break;
+ case 'command-log': {
+ const { readRecentCommands, commandLogPath } = await import('./core/command-log.js');
+ const limitIdx = argv.indexOf('--limit');
+ const limit = limitIdx >= 0 ? Number(argv[limitIdx + 1]) || 20 : 20;
+ const entries = readRecentCommands(limit);
+ if (entries.length === 0) {
+ console.log(`No commands logged yet. Log file: ${commandLogPath()}`);
+ } else {
+ console.log(`Recent commands (${entries.length}) — ${commandLogPath()}\n`);
+ for (const e of entries) {
+ const when = new Date(e.timestamp).toISOString();
+ const status = e.exitCode === 0 ? 'OK' : `FAIL(${e.failureKind || e.exitCode})`;
+ console.log(`${when} [${e.id || '--------'}] ${status} ${(e.durationMs / 1000).toFixed(1)}s ${e.argv.join(' ')}`);
+ }
+ }
+ break;
+ }
case 'stop':
await printTool('bazel_ios_stop_app', parseStopApp(argv.slice(1)));
break;
+ case 'uninstall':
+ await printTool('bazel_ios_uninstall_app', parseStopApp(argv.slice(1)));
+ break;
case 'app-path':
await printTool('bazel_ios_get_app_path', parseTargetInfo(argv.slice(1)));
break;
@@ -146,6 +177,18 @@ switch (command) {
case 'ui-dump':
await printTool('bazel_ios_ui_dump', parseSimSelector(argv.slice(1)));
break;
+ case 'add-media': {
+ const mediaPaths = argv.slice(1).filter((a) => !a.startsWith('--'));
+ await printTool('bazel_ios_add_media', { paths: mediaPaths, ...parseSimSelector(argv.slice(1)) });
+ break;
+ }
+ case 'app-container':
+ await printTool('bazel_ios_get_app_container', {
+ bundleId: argv[1],
+ kind: argv.includes('--kind') ? argv[argv.indexOf('--kind') + 1] : undefined,
+ ...parseSimSelector(argv.slice(2)),
+ });
+ break;
case 'tap':
await printTool('bazel_ios_tap', { x: Number(argv[1]), y: Number(argv[2]), ...parseSimSelector(argv.slice(3)) });
break;
@@ -190,12 +233,21 @@ switch (command) {
case 'device-stop':
await printTool('bazel_ios_device_stop_app', parseDeviceStop(argv.slice(1)));
break;
+ case 'device-uninstall':
+ await printTool('bazel_ios_device_uninstall_app', parseDeviceStop(argv.slice(1)));
+ break;
+ case 'device-list-apps':
+ await printTool('bazel_ios_device_list_apps', parseDeviceSelector(argv.slice(1)));
+ break;
case 'device-test':
await printTool('bazel_ios_device_test', parseDeviceTest(argv.slice(1)));
break;
case 'device-screenshot':
await printTool('bazel_ios_device_screenshot', parseDeviceScreenshot(argv.slice(1)));
break;
+ case 'device-app-path':
+ await printTool('bazel_ios_device_get_app_path', parseTargetInfo(argv.slice(1)));
+ break;
case 'device-log-start':
await printTool('bazel_ios_device_log_start', parseDeviceSelector(argv.slice(1)));
break;
@@ -380,7 +432,7 @@ switch (command) {
await runUpgrade(argv.slice(1));
break;
case 'check-update':
- await printTool('bazel_check_update', {});
+ await runCheckUpdate(argv.includes('--exit-code'));
break;
case 'workflows':
await printTool('bazel_list_workflows', {});
@@ -388,6 +440,19 @@ switch (command) {
case 'toggle-workflow':
await printTool('bazel_toggle_workflow', { id: argv[1], enabled: argv[2] !== 'off' && argv[2] !== 'false' && argv[2] !== 'disable' });
break;
+ case 'xcode-mcp-status':
+ await printTool('bazel_xcode_native_mcp_status', {});
+ break;
+ case 'devicehub':
+ case 'device-hub':
+ await printTool('bazel_xcode_open_device_hub', {});
+ break;
+ case 'xcode-export-skills':
+ await printTool('bazel_xcode_export_skills', {
+ outputDir: argv.includes('--output-dir') ? argv[argv.indexOf('--output-dir') + 1] : undefined,
+ replaceExisting: argv.includes('--replace-existing'),
+ });
+ break;
case 'setup':
await runSetupWizard();
break;
diff --git a/src/cli/commands.ts b/src/cli/commands.ts
index ce72db3..5c81fdb 100644
--- a/src/cli/commands.ts
+++ b/src/cli/commands.ts
@@ -15,18 +15,26 @@ export function extractText(result: Awaited>):
}
export async function printTool(name: string, args: JsonObject): Promise {
- if (args.streaming) {
- const result = await callBazelToolStreaming(name, args, (chunk) => {
- process.stdout.write(chunk);
- });
- console.log('');
+ try {
+ if (args.streaming) {
+ let streamedAny = false;
+ const result = await callBazelToolStreaming(name, args, (chunk) => {
+ streamedAny = true;
+ process.stdout.write(chunk);
+ });
+ if (streamedAny) console.log(''); // separate streamed progress from the summary
+ console.log(extractText(result));
+ if (result.isError) process.exitCode = 1;
+ return;
+ }
+ const result = await callBazelTool(name, args);
console.log(extractText(result));
if (result.isError) process.exitCode = 1;
- return;
+ } catch (err) {
+ // Surface a clean message to CLI users instead of a raw Node stack trace.
+ console.error(`Error: ${(err as Error).message}`);
+ process.exitCode = 1;
}
- const result = await callBazelTool(name, args);
- console.log(extractText(result));
- if (result.isError) process.exitCode = 1;
}
function bundledSkillsDir(): string | null {
@@ -147,6 +155,24 @@ xcodebazelmcp coverage //tests:tests
installBundledSkills();
}
+/**
+ * Print update status. With `withExitCode`, set a scripting-friendly exit code:
+ * 0 = up to date / unknown, 1 = a newer version is available.
+ */
+export async function runCheckUpdate(withExitCode = false): Promise {
+ const { checkForUpdate, upgradeHint } = await import('../core/upgrade.js');
+ const info = await checkForUpdate();
+ console.log(`Current version: ${info.current}`);
+ console.log(`Latest version: ${info.latest || '(unable to fetch)'}`);
+ console.log(`Install method: ${info.installMethod}`);
+ if (info.updateAvailable) {
+ console.log(`\nUpdate available! Run: ${upgradeHint(info.installMethod)}`);
+ if (withExitCode) process.exitCode = 1;
+ } else {
+ console.log('\nYou are up to date.');
+ }
+}
+
export async function runUpgrade(args: string[]): Promise {
const { checkForUpdate, performUpgrade, upgradeHint } = await import('../core/upgrade.js');
let method: import('../core/upgrade.js').InstallMethod | undefined;
@@ -164,9 +190,7 @@ export async function runUpgrade(args: string[]): Promise {
return;
}
- if (info.updateAvailable) {
- console.log(`\nUpgrading via: ${upgradeHint(method || info.installMethod)}`);
- }
+ console.log(`\nUpgrading via: ${upgradeHint(method || info.installMethod)}`);
const result = await performUpgrade(method);
console.log(result.output);
@@ -190,27 +214,49 @@ export async function runDaemon(args: string[]): Promise {
console.log(`Socket: ${info.socketPath}`);
console.log('Press Ctrl+C to stop.');
}
- await new Promise(() => {});
+ await new Promise((resolve) => {
+ process.once('SIGINT', () => {
+ if (!process.env.XBMCP_DAEMON) console.log('\nDaemon stopping.');
+ resolve();
+ });
+ process.once('SIGTERM', resolve);
+ });
}
-export async function runVideoRecord(args: JsonObject): Promise {
- if (typeof args.outputPath !== 'string') {
- console.error('Usage: xcodebazelmcp video-record [--simulator-name "..."]');
- process.exit(1);
- }
+/**
+ * Resolve a booted simulator UDID from --simulator-id / --simulator-name, or
+ * fall back to the first booted device. Exits the process with a clear message
+ * if none can be found. Shared by the streaming CLI commands.
+ */
+async function resolveBootedSimulatorUdid(args: JsonObject): Promise {
const result = await callBazelTool('bazel_ios_list_simulators', { onlyBooted: true });
- const text = extractText(result);
let devices: Array<{ udid: string; name: string }> = [];
try {
- devices = JSON.parse(text);
+ devices = JSON.parse(extractText(result));
} catch {
/* empty */
}
- const udid = (args.simulatorId as string) || devices[0]?.udid;
+ let udid = args.simulatorId as string | undefined;
+ if (!udid && typeof args.simulatorName === 'string') {
+ const match = devices.find(
+ (d) => d.name.toLowerCase() === (args.simulatorName as string).toLowerCase(),
+ );
+ if (match) udid = match.udid;
+ }
+ if (!udid) udid = devices[0]?.udid;
if (!udid) {
- console.error('No booted simulator found. Boot one first or pass --simulator-id.');
+ console.error('No booted simulator found. Boot one first or pass --simulator-id / --simulator-name.');
+ process.exit(1);
+ }
+ return udid;
+}
+
+export async function runVideoRecord(args: JsonObject): Promise {
+ if (typeof args.outputPath !== 'string') {
+ console.error('Usage: xcodebazelmcp video-record [--simulator-name "..."]');
process.exit(1);
}
+ const udid = await resolveBootedSimulatorUdid(args);
console.log(`Recording video from simulator ${udid}...`);
console.log(`Output: ${args.outputPath}`);
@@ -231,40 +277,26 @@ export async function runVideoRecord(args: JsonObject): Promise {
}
export async function runLogStream(args: JsonObject): Promise {
- const result = await callBazelTool('bazel_ios_list_simulators', { onlyBooted: true });
- const text = extractText(result);
- let devices: Array<{ udid: string; name: string }> = [];
- try {
- devices = JSON.parse(text);
- } catch {
- /* empty */
- }
-
- let udid = args.simulatorId as string | undefined;
- if (!udid && typeof args.simulatorName === 'string') {
- const match = devices.find(
- (d) => d.name.toLowerCase() === (args.simulatorName as string).toLowerCase(),
- );
- if (match) udid = match.udid;
- }
- if (!udid) udid = devices[0]?.udid;
- if (!udid) {
- console.error('No booted simulator found. Boot one first or pass --simulator-id / --simulator-name.');
- process.exit(1);
- }
+ const udid = await resolveBootedSimulatorUdid(args);
const logArgs = ['simctl', 'spawn', udid, 'log', 'stream', '--style', 'compact'];
if (typeof args.level === 'string') logArgs.push('--level', args.level);
- const predicates: string[] = [];
- if (typeof args.processName === 'string') predicates.push(`process == "${args.processName}"`);
- if (typeof args.subsystem === 'string') predicates.push(`subsystem == "${args.subsystem}"`);
- if (predicates.length > 0) {
- logArgs.push('--predicate', predicates.join(' OR '));
+ const { buildLogPredicate } = await import('../core/simulators.js');
+ let predicate: string | undefined;
+ try {
+ predicate = buildLogPredicate({
+ processName: typeof args.processName === 'string' ? args.processName : undefined,
+ subsystem: typeof args.subsystem === 'string' ? args.subsystem : undefined,
+ });
+ } catch (err) {
+ console.error(`Error: ${(err as Error).message}`);
+ process.exit(1);
}
+ if (predicate) logArgs.push('--predicate', predicate);
console.log(`Streaming logs from simulator ${udid}...`);
- if (predicates.length > 0) console.log(`Filter: ${predicates.join(' OR ')}`);
+ if (predicate) console.log(`Filter: ${predicate}`);
console.log('Press Ctrl+C to stop.\n');
const child = spawn('xcrun', logArgs, { stdio: ['ignore', 'inherit', 'inherit'] });
@@ -311,6 +343,14 @@ export async function runSetupWizard(): Promise {
writeFileSync(configPath, lines.join('\n') + '\n');
console.log(`\nConfig written to ${configPath}`);
- console.log('\nTo use with MCP, add to your agent config:');
- console.log(` "command": "npx", "args": ["xcodebazelmcp", "mcp"]`);
+ console.log('\nTo use with MCP, add this to your agent config (e.g. .mcp.json / mcp.json):');
+ console.log(JSON.stringify({
+ mcpServers: {
+ XcodeBazelMCP: {
+ command: 'npx',
+ args: ['-y', 'xcodebazelmcp', 'mcp'],
+ env: { BAZEL_IOS_WORKSPACE: resolve(workspacePath) },
+ },
+ },
+ }, null, 2));
}
diff --git a/src/cli/help.ts b/src/cli/help.ts
index 27490a3..9dd4af9 100644
--- a/src/cli/help.ts
+++ b/src/cli/help.ts
@@ -16,8 +16,9 @@ Build & Run:
xcodebazelmcp build [--debug|--release] [--simulator|--device] [--stream]
xcodebazelmcp run [--debug] [--simulator-name "iPhone 16 Pro"] [--stream]
xcodebazelmcp install [--simulator-id ]
- xcodebazelmcp launch [--simulator-id ] [--launch-arg ...]
+ xcodebazelmcp launch [--simulator-id ] [--simulator-name "..."] [--launch-arg ...] [--launch-env KEY=VAL]
xcodebazelmcp stop [--simulator-name "..."]
+ xcodebazelmcp uninstall [--simulator-name "..."]
xcodebazelmcp test [--filter XCTestFilter] [--minimize-simulator] [--shutdown-simulator] [--stream]
xcodebazelmcp coverage [--filter XCTestFilter] [--minimize-simulator] [--shutdown-simulator]
xcodebazelmcp clean [--expunge] [--stream]
@@ -32,10 +33,12 @@ Query & Inspect:
xcodebazelmcp rdeps [--scope //Apps/...]
xcodebazelmcp bsp-status [--query-targets]
xcodebazelmcp last-command
+ xcodebazelmcp command-log [--limit N] Tail the persistent NDJSON command log (postmortems)
Config & Defaults:
xcodebazelmcp defaults
- xcodebazelmcp set-defaults [--target //app:app] [--simulator-name "..."] [--build-mode debug] [--profile name] [--clear]
+ xcodebazelmcp set-defaults [--target //app:app] [--simulator-name "..."] [--simulator-id ]
+ [--build-mode debug] [--platform simulator|device|none] [--profile name] [--clear]
xcodebazelmcp profiles
Simulator:
@@ -45,21 +48,26 @@ Simulator:
xcodebazelmcp sim-erase --simulator-id
xcodebazelmcp sim-location --latitude 37.7749 --longitude -122.4194
xcodebazelmcp sim-appearance --appearance dark
- xcodebazelmcp sim-open [--simulator-id ]
+ xcodebazelmcp sim-open [--simulator-id ] [--simulator-name "..."]
xcodebazelmcp screenshot [--simulator-name "..."] [--mask alpha|black|ignored]
xcodebazelmcp video-record [--simulator-name "..."]
Records until Ctrl+C.
xcodebazelmcp status-bar [--time "9:41"] [--battery-level 100] [--network wifi] [--clear]
xcodebazelmcp ui-dump [--simulator-name "..."]
+ xcodebazelmcp add-media [--simulator-name "..."] Add photos/videos to the photo library
+ xcodebazelmcp app-container [--kind app|data|groups] [--simulator-name "..."]
Device:
xcodebazelmcp devices [--all]
xcodebazelmcp device-run [--debug|--release] [--device-id ] [--device-name "..."] [--stream]
xcodebazelmcp device-install [--device-id ]
- xcodebazelmcp device-launch [--device-id ] [--launch-arg ...]
+ xcodebazelmcp device-launch [--device-id ] [--launch-arg ...] [--launch-env KEY=VAL]
xcodebazelmcp device-stop [--device-id ]
- xcodebazelmcp device-test [--filter ...] [--device-id ] [--stream]
+ xcodebazelmcp device-uninstall [--device-id ]
+ xcodebazelmcp device-list-apps [--device-id ]
+ xcodebazelmcp device-test [--filter ...] [--device-id ] [--timeout ] [--stream]
xcodebazelmcp device-screenshot [--device-id ]
+ xcodebazelmcp device-app-path Locate the built .app for a device target
xcodebazelmcp device-log-start [--device-id ] [--process ]
xcodebazelmcp device-log-stop
xcodebazelmcp device-info [--device-id ]
@@ -100,6 +108,12 @@ visionOS:
xcodebazelmcp visionos-test [--filter XCTestFilter] [--stream]
xcodebazelmcp visionos-discover [--scope //vision/...] [--kind visionos_apps|visionos_tests|visionos_all]
+Xcode Native MCP (Xcode 26.3+):
+ xcodebazelmcp xcode-mcp-status Detect Apple's native Xcode MCP (mcpbridge), lldb-mcp, DeviceHub
+ xcodebazelmcp devicehub Open DeviceHub.app (Xcode-beta / Xcode 27) for device interaction
+ xcodebazelmcp xcode-export-skills [--output-dir ] [--replace-existing]
+ Export Xcode's agent SKILL.md bundles (needs a running Xcode)
+
Daemon:
xcodebazelmcp daemon [--workspace ] Start the background daemon (foreground, Ctrl+C to stop)
xcodebazelmcp daemon-start Ensure daemon is running (spawns in background)
diff --git a/src/cli/parsers.test.ts b/src/cli/parsers.test.ts
new file mode 100644
index 0000000..74b58c3
--- /dev/null
+++ b/src/cli/parsers.test.ts
@@ -0,0 +1,266 @@
+import { describe, it, expect } from 'vitest';
+import {
+ parseLaunch,
+ parseDeviceLaunch,
+ parseDeviceBuildAndRun,
+ parseBuild,
+ parseBuildAndRun,
+ parseTest,
+ parseQuery,
+ parseDeps,
+ parseSwipe,
+ parseSimAppearance,
+ parseSpmBuild,
+ parseSpmTest,
+ parseSpmRun,
+ parsePrivacy,
+ parseScaffold,
+ parseDeviceTest,
+ parseStatusBar,
+ parseLldbAttach,
+ parseLldbBreakpoint,
+} from './parsers.js';
+
+describe('parseLaunch', () => {
+ it('parses bundleId positional', () => {
+ expect(parseLaunch(['com.example.App'])).toEqual({ bundleId: 'com.example.App' });
+ });
+
+ it('supports --simulator-name (regression: previously ignored)', () => {
+ expect(parseLaunch(['com.example.App', '--simulator-name', 'iPhone 16 Pro'])).toEqual({
+ bundleId: 'com.example.App',
+ simulatorName: 'iPhone 16 Pro',
+ });
+ });
+
+ it('supports --simulator-id and --launch-arg', () => {
+ expect(parseLaunch(['com.x', '--simulator-id', 'UDID', '--launch-arg', '-a', '--launch-arg', '-b'])).toEqual({
+ bundleId: 'com.x',
+ simulatorId: 'UDID',
+ launchArgs: ['-a', '-b'],
+ });
+ });
+
+ it('parses --launch-env KEY=VAL into launchEnv object', () => {
+ expect(parseLaunch(['com.x', '--launch-env', 'FOO=bar', '--launch-env', 'BAZ=qux'])).toEqual({
+ bundleId: 'com.x',
+ launchEnv: { FOO: 'bar', BAZ: 'qux' },
+ });
+ });
+
+ it('keeps "=" in launch-env values', () => {
+ expect(parseLaunch(['com.x', '--launch-env', 'URL=a=b'])).toEqual({
+ bundleId: 'com.x',
+ launchEnv: { URL: 'a=b' },
+ });
+ });
+});
+
+describe('parseDeviceLaunch', () => {
+ it('parses --launch-env for device parity', () => {
+ expect(parseDeviceLaunch(['com.x', '--device-id', 'D1', '--launch-env', 'FOO=bar'])).toEqual({
+ bundleId: 'com.x',
+ deviceId: 'D1',
+ launchEnv: { FOO: 'bar' },
+ });
+ });
+});
+
+describe('parseDeviceBuildAndRun', () => {
+ it('forces device platform and parses launch-env', () => {
+ expect(parseDeviceBuildAndRun(['//a', '--launch-env', 'A=1'])).toMatchObject({
+ target: '//a',
+ platform: 'device',
+ launchEnv: { A: '1' },
+ });
+ });
+});
+
+describe('parseBuild', () => {
+ it('defaults buildMode/platform to none', () => {
+ expect(parseBuild(['//app:app'])).toEqual({ target: '//app:app', buildMode: 'none', platform: 'none' });
+ });
+
+ it('parses --release and --device', () => {
+ const out = parseBuild(['//app:app', '--release', '--device']);
+ expect(out.buildMode).toBe('release');
+ expect(out.platform).toBe('device');
+ });
+
+ it('parses --stream into streaming flag', () => {
+ expect(parseBuild(['//a', '--stream'])).toMatchObject({ streaming: true });
+ });
+});
+
+describe('parseBuildAndRun', () => {
+ it('defaults platform to simulator', () => {
+ expect(parseBuildAndRun(['//app:app'])).toMatchObject({ platform: 'simulator', buildMode: 'none' });
+ });
+
+ it('collects repeated --config and --launch-arg', () => {
+ const out = parseBuildAndRun(['//a', '--config', 'foo', '--config', 'bar', '--launch-arg', 'x']);
+ expect(out.configs).toEqual(['foo', 'bar']);
+ expect(out.launchArgs).toEqual(['x']);
+ });
+});
+
+describe('parseTest', () => {
+ it('parses filter and simulator flags', () => {
+ expect(parseTest(['//t', '--filter', 'MyTest', '--minimize-simulator'])).toMatchObject({
+ target: '//t',
+ testFilter: 'MyTest',
+ minimizeSimulator: true,
+ });
+ });
+});
+
+describe('parseQuery', () => {
+ it('joins expression parts', () => {
+ expect(parseQuery(['deps(//a)', '+', 'deps(//b)']).expression).toBe('deps(//a) + deps(//b)');
+ });
+
+ it('extracts --output without polluting expression', () => {
+ const out = parseQuery(['kind(x,//...)', '--output', 'label']);
+ expect(out.output).toBe('label');
+ expect(out.expression).toBe('kind(x,//...)');
+ });
+});
+
+describe('parseDeps', () => {
+ it('parses --depth as number', () => {
+ expect(parseDeps(['//a', '--depth', '3'])).toMatchObject({ target: '//a', depth: 3 });
+ });
+});
+
+describe('parseSwipe', () => {
+ it('reads positional direction', () => {
+ expect(parseSwipe(['up'])).toMatchObject({ direction: 'up' });
+ });
+
+ it('explicit --direction overrides positional', () => {
+ expect(parseSwipe(['up', '--direction', 'down'])).toMatchObject({ direction: 'down' });
+ });
+});
+
+describe('parseSimAppearance', () => {
+ it('reads positional dark/light', () => {
+ expect(parseSimAppearance(['dark'])).toMatchObject({ appearance: 'dark' });
+ });
+ it('reads --appearance flag', () => {
+ expect(parseSimAppearance(['--appearance', 'light'])).toMatchObject({ appearance: 'light' });
+ });
+});
+
+describe('parseSpmBuild', () => {
+ it('handles --release', () => {
+ expect(parseSpmBuild(['--release'])).toMatchObject({ configuration: 'release' });
+ });
+ it('handles -c release', () => {
+ expect(parseSpmBuild(['-c', 'release'])).toMatchObject({ configuration: 'release' });
+ });
+ it('handles -c debug (regression)', () => {
+ expect(parseSpmBuild(['-c', 'debug'])).toMatchObject({ configuration: 'debug' });
+ });
+});
+
+describe('parseSpmTest', () => {
+ it('handles --release, -c release, and -c debug for parity with build', () => {
+ expect(parseSpmTest(['--release'])).toMatchObject({ configuration: 'release' });
+ expect(parseSpmTest(['-c', 'release'])).toMatchObject({ configuration: 'release' });
+ expect(parseSpmTest(['-c', 'debug'])).toMatchObject({ configuration: 'debug' });
+ expect(parseSpmTest(['--debug'])).toMatchObject({ configuration: 'debug' });
+ });
+ it('still parses --filter and --path', () => {
+ expect(parseSpmTest(['--filter', 'MyTests', '--path', '/pkg'])).toMatchObject({
+ filter: 'MyTests',
+ packagePath: '/pkg',
+ });
+ });
+});
+
+describe('parseSpmRun', () => {
+ it('reads the executable without mistaking -c values for it', () => {
+ expect(parseSpmRun(['MyTool', '-c', 'release'])).toMatchObject({
+ executable: 'MyTool',
+ configuration: 'release',
+ });
+ });
+ it('handles -c release with no executable', () => {
+ expect(parseSpmRun(['-c', 'release'])).toMatchObject({ configuration: 'release' });
+ expect(parseSpmRun(['-c', 'release']).executable).toBeUndefined();
+ });
+ it('forwards --run-arg and --stream', () => {
+ expect(parseSpmRun(['MyTool', '--run-arg', '--verbose', '--stream'])).toMatchObject({
+ executable: 'MyTool',
+ runArgs: ['--verbose'],
+ streaming: true,
+ });
+ });
+});
+
+describe('parsePrivacy', () => {
+ it('reads positional action/service/bundleId', () => {
+ expect(parsePrivacy(['grant', 'photos', 'com.x'])).toMatchObject({
+ action: 'grant',
+ service: 'photos',
+ bundleId: 'com.x',
+ });
+ });
+});
+
+describe('parseScaffold', () => {
+ it('defaults outputPath to name', () => {
+ expect(parseScaffold(['ios_app', 'MyApp'])).toMatchObject({
+ template: 'ios_app',
+ name: 'MyApp',
+ outputPath: 'MyApp',
+ });
+ });
+ it('respects explicit -o', () => {
+ expect(parseScaffold(['ios_app', 'MyApp', '-o', 'out'])).toMatchObject({ outputPath: 'out' });
+ });
+ it('parses --bazel-version', () => {
+ expect(parseScaffold(['ios_app', 'MyApp', '--bazel-version', '8.0.0'])).toMatchObject({ bazelVersion: '8.0.0' });
+ });
+});
+
+describe('parseDeviceTest', () => {
+ it('parses --timeout as number', () => {
+ expect(parseDeviceTest(['//t', '--timeout', '120'])).toMatchObject({ timeoutSeconds: 120 });
+ });
+});
+
+describe('parseStatusBar', () => {
+ it('parses --clear', () => {
+ expect(parseStatusBar(['--clear'])).toMatchObject({ clear: true });
+ });
+ it('parses numeric battery level', () => {
+ expect(parseStatusBar(['--battery-level', '80'])).toMatchObject({ batteryLevel: 80 });
+ });
+ it('parses --operator (carrier name)', () => {
+ expect(parseStatusBar(['--operator', 'Carrier'])).toMatchObject({ operatorName: 'Carrier' });
+ });
+});
+
+describe('parseLldbAttach', () => {
+ it('treats bare number as pid', () => {
+ expect(parseLldbAttach(['1234'])).toMatchObject({ pid: 1234 });
+ });
+ it('treats bare string as processName', () => {
+ expect(parseLldbAttach(['MyApp'])).toMatchObject({ processName: 'MyApp' });
+ });
+ it('--device sets device target', () => {
+ expect(parseLldbAttach(['MyApp', '--device'])).toMatchObject({ target: 'device' });
+ });
+});
+
+describe('parseLldbBreakpoint', () => {
+ it('parses set with file+line', () => {
+ expect(parseLldbBreakpoint(['set', '--session', 's1', '--file', 'A.swift', '--line', '42'])).toMatchObject({
+ action: 'set',
+ sessionId: 's1',
+ file: 'A.swift',
+ line: 42,
+ });
+ });
+});
diff --git a/src/cli/parsers.ts b/src/cli/parsers.ts
index b7cc05a..c5b2b6a 100644
--- a/src/cli/parsers.ts
+++ b/src/cli/parsers.ts
@@ -4,6 +4,13 @@ function append(value: unknown, next: string): string[] {
return [...(Array.isArray(value) ? (value as string[]) : []), next];
}
+function appendEnv(existing: unknown, pair: string | undefined): Record {
+ const env = (existing as Record) ?? {};
+ const eq = pair?.indexOf('=') ?? -1;
+ if (pair && eq > 0) env[pair.slice(0, eq)] = pair.slice(eq + 1);
+ return env;
+}
+
export function parseDiscover(args: string[]): JsonObject {
const parsed: JsonObject = {};
for (let index = 0; index < args.length; index += 1) {
@@ -164,7 +171,9 @@ export function parseLaunch(args: string[]): JsonObject {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--simulator-id') parsed.simulatorId = args[++index];
+ else if (arg === '--simulator-name') parsed.simulatorName = args[++index];
else if (arg === '--launch-arg') parsed.launchArgs = append(parsed.launchArgs, args[++index]);
+ else if (arg === '--launch-env') parsed.launchEnv = appendEnv(parsed.launchEnv, args[++index]);
}
return parsed;
}
@@ -306,6 +315,7 @@ export function parseStatusBar(args: string[]): JsonObject {
else if (arg === '--battery-level') parsed.batteryLevel = Number(args[++index]);
else if (arg === '--battery-state') parsed.batteryState = args[++index];
else if (arg === '--network') parsed.networkType = args[++index];
+ else if (arg === '--operator') parsed.operatorName = args[++index];
else if (arg === '--wifi-bars') parsed.wifiBars = Number(args[++index]);
else if (arg === '--cellular-bars') parsed.cellularBars = Number(args[++index]);
else if (arg === '--clear') parsed.clear = true;
@@ -384,6 +394,8 @@ export function parseScaffold(args: string[]): JsonObject {
else if (arg === '--bundle-id') parsed.bundleId = args[++index];
else if (arg === '--minimum-os') parsed.minimumOs = args[++index];
else if (arg === '--rules-version') parsed.rulesVersion = args[++index];
+ else if (arg === '--bazel-version') parsed.bazelVersion = args[++index];
+ else if (arg === '--family') parsed.families = append(parsed.families, args[++index]);
}
if (!parsed.outputPath && parsed.name) {
parsed.outputPath = parsed.name;
@@ -406,8 +418,8 @@ export function parseSpmBuild(args: string[]): JsonObject {
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--path' || arg === '--package-path') parsed.packagePath = args[++index];
- else if (arg === '--release' || arg === '-c' && args[index + 1] === 'release') { parsed.configuration = 'release'; if (arg === '-c') index++; }
- else if (arg === '--debug') parsed.configuration = 'debug';
+ else if (arg === '--release' || (arg === '-c' && args[index + 1] === 'release')) { parsed.configuration = 'release'; if (arg === '-c') index++; }
+ else if (arg === '--debug' || (arg === '-c' && args[index + 1] === 'debug')) { parsed.configuration = 'debug'; if (arg === '-c') index++; }
else if (arg === '--target') parsed.target = args[++index];
else if (arg === '--arg') parsed.extraArgs = append(parsed.extraArgs, args[++index]);
else if (arg === '--stream') parsed.streaming = true;
@@ -421,7 +433,8 @@ export function parseSpmTest(args: string[]): JsonObject {
const arg = args[index];
if (arg === '--path' || arg === '--package-path') parsed.packagePath = args[++index];
else if (arg === '--filter') parsed.filter = args[++index];
- else if (arg === '--release') parsed.configuration = 'release';
+ else if (arg === '--release' || (arg === '-c' && args[index + 1] === 'release')) { parsed.configuration = 'release'; if (arg === '-c') index++; }
+ else if (arg === '--debug' || (arg === '-c' && args[index + 1] === 'debug')) { parsed.configuration = 'debug'; if (arg === '-c') index++; }
else if (arg === '--arg') parsed.extraArgs = append(parsed.extraArgs, args[++index]);
else if (arg === '--stream') parsed.streaming = true;
}
@@ -430,14 +443,16 @@ export function parseSpmTest(args: string[]): JsonObject {
export function parseSpmRun(args: string[]): JsonObject {
const parsed: JsonObject = {};
- const positional = args.find((arg) => !arg.startsWith('--'));
- if (positional) parsed.executable = positional;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === '--path' || arg === '--package-path') parsed.packagePath = args[++index];
- else if (arg === '--release') parsed.configuration = 'release';
+ else if (arg === '--release' || (arg === '-c' && args[index + 1] === 'release')) { parsed.configuration = 'release'; if (arg === '-c') index++; }
+ else if (arg === '--debug' || (arg === '-c' && args[index + 1] === 'debug')) { parsed.configuration = 'debug'; if (arg === '-c') index++; }
else if (arg === '--arg') parsed.extraArgs = append(parsed.extraArgs, args[++index]);
else if (arg === '--run-arg') parsed.runArgs = append(parsed.runArgs, args[++index]);
+ else if (arg === '--stream') parsed.streaming = true;
+ // First bare token (not a flag, not a flag value consumed above) is the executable.
+ else if (!arg.startsWith('-') && parsed.executable === undefined) parsed.executable = arg;
}
return parsed;
}
@@ -611,6 +626,7 @@ export function parseDeviceBuildAndRun(args: string[]): JsonObject {
else if (arg === '--arg') parsed.extraArgs = append(parsed.extraArgs, args[++index]);
else if (arg === '--startup-arg') parsed.startupArgs = append(parsed.startupArgs, args[++index]);
else if (arg === '--launch-arg') parsed.launchArgs = append(parsed.launchArgs, args[++index]);
+ else if (arg === '--launch-env') parsed.launchEnv = appendEnv(parsed.launchEnv, args[++index]);
else if (arg === '--stream') parsed.streaming = true;
}
return parsed;
@@ -637,6 +653,7 @@ export function parseDeviceLaunch(args: string[]): JsonObject {
if (arg === '--device-id') parsed.deviceId = args[++index];
else if (arg === '--device-name') parsed.deviceName = args[++index];
else if (arg === '--launch-arg') parsed.launchArgs = append(parsed.launchArgs, args[++index]);
+ else if (arg === '--launch-env') parsed.launchEnv = appendEnv(parsed.launchEnv, args[++index]);
}
return parsed;
}
diff --git a/src/core/agent-debug-log.test.ts b/src/core/agent-debug-log.test.ts
index ceb547f..877b221 100644
--- a/src/core/agent-debug-log.test.ts
+++ b/src/core/agent-debug-log.test.ts
@@ -61,6 +61,18 @@ describe('agent-debug-log', () => {
expect(result.hypothesisStatusHints.B).toBe('REJECTED');
});
+ it('hypothesis status uses the latest verdict (REJECTED after CONFIRMED wins)', () => {
+ writeFileSync(
+ logPath,
+ [
+ '{"hypothesisId":"H","message":"CONFIRMED early"}',
+ '{"hypothesisId":"H","message":"REJECTED later, was wrong"}',
+ ].join('\n'),
+ );
+ const result = readAgentDebugLog({ logPath });
+ expect(result.hypothesisStatusHints.H).toBe('REJECTED');
+ });
+
it('readAgentDebugLog filters by hypothesisId and runId', () => {
writeFileSync(
logPath,
diff --git a/src/core/agent-debug-log.ts b/src/core/agent-debug-log.ts
index 4240770..e621e7d 100644
--- a/src/core/agent-debug-log.ts
+++ b/src/core/agent-debug-log.ts
@@ -49,10 +49,14 @@ export function agentDebugLaunchEnv(logPath: string, sessionId: string): Record<
};
}
-export function clearAgentDebugLog(logPath: string): { logPath: string; cleared: boolean; existed: boolean } {
+export function clearAgentDebugLog(logPath: string): { logPath: string; cleared: boolean; existed: boolean; error?: string } {
const existed = existsSync(logPath);
if (existed) {
- unlinkSync(logPath);
+ try {
+ unlinkSync(logPath);
+ } catch (err) {
+ return { logPath, cleared: false, existed, error: (err as Error).message };
+ }
}
return { logPath, cleared: true, existed };
}
@@ -80,7 +84,10 @@ export function parseAgentDebugNdjson(content: string): {
}
function inferHypothesisStatus(entries: AgentDebugLogEntry[]): 'CONFIRMED' | 'REJECTED' | 'INCONCLUSIVE' | 'UNKNOWN' {
- for (const entry of entries) {
+ // Scan newest-first so a later verdict (e.g. REJECTED after an earlier
+ // CONFIRMED) wins instead of whichever appeared first.
+ for (let i = entries.length - 1; i >= 0; i--) {
+ const entry = entries[i];
const blob = `${entry.message ?? ''} ${JSON.stringify(entry.data ?? {})}`.toUpperCase();
if (blob.includes('CONFIRMED')) return 'CONFIRMED';
if (blob.includes('REJECTED')) return 'REJECTED';
@@ -165,7 +172,11 @@ export function discoverAgentDebugLogPaths(searchRoots: string[]): string[] {
const logPath = resolve(cursorDir, entry);
if (seen.has(logPath)) continue;
seen.add(logPath);
- discovered.push({ path: logPath, mtimeMs: statSync(logPath).mtimeMs });
+ try {
+ discovered.push({ path: logPath, mtimeMs: statSync(logPath).mtimeMs });
+ } catch {
+ // file vanished between readdir and stat — skip it
+ }
}
}
@@ -231,6 +242,7 @@ export async function pullAgentDebugLogFromSimulator(
simulatorId: string;
containerPath: string;
sourcePath: string;
+ sourceExists: boolean;
destPath?: string;
read: AgentDebugReadResult;
commandOutput: string;
@@ -250,6 +262,7 @@ export async function pullAgentDebugLogFromSimulator(
const containerPath = result.output.trim();
const sourcePath = join(containerPath, simRelPath);
+ const sourceExists = existsSync(sourcePath);
let read: AgentDebugReadResult;
let destPath: string | undefined;
@@ -258,7 +271,7 @@ export async function pullAgentDebugLogFromSimulator(
destPath = options.destPath;
const parent = dirname(destPath);
if (!existsSync(parent)) mkdirSync(parent, { recursive: true });
- if (existsSync(sourcePath)) {
+ if (sourceExists) {
copyFileSync(sourcePath, destPath);
} else {
writeFileSync(destPath, '');
@@ -273,6 +286,7 @@ export async function pullAgentDebugLogFromSimulator(
simulatorId: options.simulatorId,
containerPath,
sourcePath,
+ sourceExists,
destPath,
read,
commandOutput: result.output.trim(),
diff --git a/src/core/bazel.test.ts b/src/core/bazel.test.ts
index 93cd84a..f3eee53 100644
--- a/src/core/bazel.test.ts
+++ b/src/core/bazel.test.ts
@@ -9,12 +9,17 @@ import {
buildCommandArgs,
configArgs,
discoverExpression,
+ defaultDiscoverScope,
+ iosSimulatorCpu,
+ iosSimulatorCpuArg,
modeArgs,
+ parseTargetLabels,
platformArgs,
requireLabel,
sanitizeQueryExpression,
simulatorArgs,
testFilterArgs,
+ tokenizeArgs,
runBazel,
runBazelStreaming,
getLastCommand,
@@ -40,6 +45,34 @@ vi.mock('./workspace.js', () => ({
const mockRunCommand = vi.mocked(runCommand);
const mockRunCommandStreaming = vi.mocked(runCommandStreaming);
+describe('parseTargetLabels', () => {
+ it('keeps only label-like lines and drops bazel noise', () => {
+ const output = [
+ 'INFO: Invocation ID: abc',
+ 'Loading: 0 packages loaded',
+ 'DEBUG: /Users/x/foo.bzl:34:18: unknown handled dep @swiftpkg_foo//:Bar',
+ 'WARNING: something happened',
+ '//Apps/Consumer/ConsumerApp:DoorDash',
+ '//Apps/Consumer/ConsumerApp:Caviar',
+ '@rules_swift//swift:foo',
+ '',
+ ].join('\n');
+ expect(parseTargetLabels(output)).toEqual([
+ '//Apps/Consumer/ConsumerApp:Caviar',
+ '//Apps/Consumer/ConsumerApp:DoorDash',
+ '@rules_swift//swift:foo',
+ ]);
+ });
+
+ it('dedupes and sorts labels', () => {
+ expect(parseTargetLabels('//b:b\n//a:a\n//b:b\n')).toEqual(['//a:a', '//b:b']);
+ });
+
+ it('returns empty array for no labels', () => {
+ expect(parseTargetLabels('INFO: Empty results\nLoading: 0 packages loaded')).toEqual([]);
+ });
+});
+
let tempDir: string;
beforeEach(() => {
@@ -157,6 +190,32 @@ describe('Bazel argument helpers', () => {
expect(platformArgs('visionos')).toEqual(['--platforms=@build_bazel_apple_support//platforms:visionos_sim_arm64', '--visionos_cpus=sim_arm64']);
});
+ it('tokenizeArgs splits on whitespace and honors quotes', () => {
+ expect(tokenizeArgs('--batch --noautodetect_server_javabase')).toEqual(['--batch', '--noautodetect_server_javabase']);
+ expect(tokenizeArgs('--output_base="/path with space" --batch')).toEqual(['--output_base=/path with space', '--batch']);
+ expect(tokenizeArgs("--x='a b'")).toEqual(['--x=a b']);
+ expect(tokenizeArgs(' ')).toEqual([]);
+ });
+
+ it('iosSimulatorCpu defaults to host arch and respects BAZEL_IOS_SIMULATOR_CPU', () => {
+ const expected = process.arch === 'x64' ? 'x86_64' : 'sim_arm64';
+ expect(iosSimulatorCpu()).toBe(expected);
+ expect(iosSimulatorCpuArg()).toBe(`--ios_multi_cpus=${expected}`);
+
+ process.env.BAZEL_IOS_SIMULATOR_CPU = 'x86_64';
+ expect(iosSimulatorCpu()).toBe('x86_64');
+ expect(platformArgs('simulator')).toEqual(['--platforms=@build_bazel_apple_support//platforms:ios_x86_64', '--ios_multi_cpus=x86_64']);
+ delete process.env.BAZEL_IOS_SIMULATOR_CPU;
+ });
+
+ it('defaultDiscoverScope is overridable via BAZEL_IOS_DISCOVER_SCOPE', () => {
+ expect(defaultDiscoverScope()).toBe('(//Apps/... union //Packages/...)');
+ process.env.BAZEL_IOS_DISCOVER_SCOPE = '//...';
+ expect(defaultDiscoverScope()).toBe('//...');
+ expect(discoverExpression('apps')).toBe('kind("ios_application rule", //...)');
+ delete process.env.BAZEL_IOS_DISCOVER_SCOPE;
+ });
+
it('maps simulator and config options to Bazel flags', () => {
expect(
simulatorArgs({
@@ -485,6 +544,7 @@ describe('runBazel', () => {
cwd: tempDir,
timeoutSeconds: undefined,
maxOutput: expect.any(Number),
+ id: expect.any(String),
});
});
@@ -542,6 +602,7 @@ describe('runBazelStreaming', () => {
cwd: tempDir,
timeoutSeconds: undefined,
maxOutput: expect.any(Number),
+ id: expect.any(String),
});
expect(chunks).toHaveLength(2);
});
diff --git a/src/core/bazel.ts b/src/core/bazel.ts
index 19af5b3..c95bf0b 100644
--- a/src/core/bazel.ts
+++ b/src/core/bazel.ts
@@ -1,10 +1,93 @@
+import { randomUUID } from 'node:crypto';
import { getConfig } from '../runtime/config.js';
import type { BuildArgs, BuildMode, BuildPlatform, CommandResult, TargetKind } from '../types/index.js';
import { runCommand, runCommandStreaming, type StreamChunk } from '../utils/process.js';
+import { logCommand } from './command-log.js';
import { assertBazelWorkspace } from './workspace.js';
let lastCommand: CommandResult | null = null;
+/**
+ * Split a shell-like string into argv tokens, honoring single/double quotes so
+ * a startup arg containing spaces (e.g. `--output_base="/path with space"`)
+ * survives as one token. Falls back to whitespace splitting for unquoted input.
+ */
+export function tokenizeArgs(input: string): string[] {
+ const tokens: string[] = [];
+ let cur = '';
+ let quote: '"' | "'" | null = null;
+ let hasToken = false;
+ for (const ch of input) {
+ if (quote) {
+ if (ch === quote) quote = null;
+ else cur += ch;
+ } else if (ch === '"' || ch === "'") {
+ quote = ch;
+ hasToken = true; // even an empty "" is a token
+ } else if (/\s/.test(ch)) {
+ if (hasToken) { tokens.push(cur); cur = ''; hasToken = false; }
+ } else {
+ cur += ch;
+ hasToken = true;
+ }
+ }
+ if (hasToken) tokens.push(cur);
+ return tokens;
+}
+
+function resolveStartupArgs(startupArgs: string[]): { envStartupArgs: string[]; all: string[] } {
+ const envStartupArgs = process.env.BAZEL_IOS_STARTUP_ARGS
+ ? tokenizeArgs(process.env.BAZEL_IOS_STARTUP_ARGS)
+ : [];
+ return { envStartupArgs, all: [...envStartupArgs, ...startupArgs] };
+}
+
+/**
+ * iOS simulator CPU token. Defaults to the host architecture (`sim_arm64` on
+ * Apple silicon, `x86_64` on Intel) so simulator builds work on both. Override
+ * with `BAZEL_IOS_SIMULATOR_CPU`.
+ */
+export function iosSimulatorCpu(): string {
+ const override = process.env.BAZEL_IOS_SIMULATOR_CPU?.trim();
+ if (override) return override;
+ return process.arch === 'x64' ? 'x86_64' : 'sim_arm64';
+}
+
+/** `--ios_multi_cpus=` for simulator builds/tests, host-arch aware. */
+export function iosSimulatorCpuArg(): string {
+ return `--ios_multi_cpus=${iosSimulatorCpu()}`;
+}
+
+/**
+ * Attach resolved-config context (surfaced on failure) and record the
+ * invocation to the persistent command log with a correlation id.
+ */
+function finalizeBazelResult(
+ result: CommandResult,
+ ctx: { id: string; bazelPath: string; workspacePath: string; allStartupArgs: string[] },
+): CommandResult {
+ result.context = {
+ bazel: ctx.bazelPath,
+ workspace: ctx.workspacePath,
+ startupArgs: ctx.allStartupArgs.length ? ctx.allStartupArgs.join(' ') : '(none)',
+ };
+ logCommand({
+ id: ctx.id,
+ timestamp: Date.now(),
+ argv: [ctx.bazelPath, ...result.args],
+ cwd: ctx.workspacePath,
+ envOverrides: ctx.allStartupArgs.length ? ctx.allStartupArgs : undefined,
+ exitCode: result.exitCode,
+ durationMs: result.durationMs,
+ truncated: result.truncated || undefined,
+ failureKind: result.failureKind,
+ timedOut: result.timedOut,
+ bytesDropped: result.bytesDropped,
+ });
+ lastCommand = result;
+ return result;
+}
+
export function getLastCommand(): CommandResult | null {
return lastCommand;
}
@@ -32,7 +115,7 @@ export function sanitizeQueryExpression(expression: unknown): string {
throw new Error('expression is required.');
}
if (/[;&|`$<>]/.test(expression)) {
- throw new Error('expression contains shell-like control characters.');
+ throw new Error('expression contains shell-like control characters (none of ; & | ` $ < > are valid in a bazel query).');
}
return expression;
}
@@ -71,8 +154,11 @@ export function platformArgs(platform?: BuildPlatform): string[] {
switch (p) {
case 'none':
return [];
- case 'simulator':
- return ['--platforms=@build_bazel_apple_support//platforms:ios_sim_arm64', '--ios_multi_cpus=sim_arm64'];
+ case 'simulator': {
+ const cpu = iosSimulatorCpu();
+ const platformName = cpu === 'sim_arm64' ? 'ios_sim_arm64' : `ios_${cpu}`;
+ return [`--platforms=@build_bazel_apple_support//platforms:${platformName}`, `--ios_multi_cpus=${cpu}`];
+ }
case 'device':
return ['--platforms=@build_bazel_apple_support//platforms:ios_arm64', '--ios_multi_cpus=arm64'];
case 'macos':
@@ -109,8 +195,33 @@ export function configArgs(value: unknown): string[] {
});
}
+/**
+ * Extract Bazel target labels from `bazel query` output. Bazel writes
+ * progress/INFO/WARNING/DEBUG lines to stderr which `runCommand` merges into the
+ * combined output, so we keep only lines that look like a bare label/pattern.
+ */
+export function parseTargetLabels(output: string): string[] {
+ const seen = new Set();
+ for (const rawLine of output.split('\n')) {
+ const line = rawLine.trim();
+ if (!line || line.includes(' ') || line.includes('\t')) continue;
+ if (!line.includes('//')) continue;
+ if (line.startsWith('//') || line.startsWith('@')) seen.add(line);
+ }
+ return [...seen].sort();
+}
+
+/**
+ * Default bazel query scope for target discovery. Overridable via
+ * `BAZEL_IOS_DISCOVER_SCOPE` so non-monorepo workspaces (without //Apps and
+ * //Packages) can point discovery at e.g. `//...`.
+ */
+export function defaultDiscoverScope(): string {
+ return process.env.BAZEL_IOS_DISCOVER_SCOPE?.trim() || '(//Apps/... union //Packages/...)';
+}
+
export function discoverExpression(kind: TargetKind = 'all', scope?: string): string {
- const queryScope = scope || '(//Apps/... union //Packages/...)';
+ const queryScope = scope || defaultDiscoverScope();
switch (kind) {
case 'apps':
return `kind("ios_application rule", ${queryScope})`;
@@ -156,16 +267,20 @@ export async function runBazel(
): Promise {
const config = getConfig();
assertBazelWorkspace(config.workspacePath);
- const envStartupArgs = process.env.BAZEL_IOS_STARTUP_ARGS
- ? process.env.BAZEL_IOS_STARTUP_ARGS.split(/\s+/).filter(Boolean)
- : [];
- const result = await runCommand(config.bazelPath, [...envStartupArgs, ...startupArgs, ...args], {
+ const { all: allStartupArgs } = resolveStartupArgs(startupArgs);
+ const id = randomUUID().slice(0, 8);
+ const result = await runCommand(config.bazelPath, [...allStartupArgs, ...args], {
cwd: config.workspacePath,
timeoutSeconds,
maxOutput: config.maxOutput,
+ id,
+ });
+ return finalizeBazelResult(result, {
+ id,
+ bazelPath: config.bazelPath,
+ workspacePath: config.workspacePath,
+ allStartupArgs,
});
- lastCommand = result;
- return result;
}
export async function* runBazelStreaming(
@@ -175,26 +290,28 @@ export async function* runBazelStreaming(
): AsyncGenerator {
const config = getConfig();
assertBazelWorkspace(config.workspacePath);
- const envStartupArgs = process.env.BAZEL_IOS_STARTUP_ARGS
- ? process.env.BAZEL_IOS_STARTUP_ARGS.split(/\s+/).filter(Boolean)
- : [];
+ const { all: allStartupArgs } = resolveStartupArgs(startupArgs);
+ const id = randomUUID().slice(0, 8);
- let finalResult: CommandResult | undefined;
for await (const chunk of runCommandStreaming(
config.bazelPath,
- [...envStartupArgs, ...startupArgs, ...args],
+ [...allStartupArgs, ...args],
{
cwd: config.workspacePath,
timeoutSeconds,
maxOutput: config.maxOutput,
+ id,
},
)) {
if ('stream' in chunk) {
yield chunk;
} else {
- finalResult = chunk;
- lastCommand = finalResult;
- yield finalResult;
+ yield finalizeBazelResult(chunk, {
+ id,
+ bazelPath: config.bazelPath,
+ workspacePath: config.workspacePath,
+ allStartupArgs,
+ });
}
}
}
diff --git a/src/core/command-log.test.ts b/src/core/command-log.test.ts
new file mode 100644
index 0000000..9dcad10
--- /dev/null
+++ b/src/core/command-log.test.ts
@@ -0,0 +1,87 @@
+import { afterEach, beforeEach, describe, it, expect } from 'vitest';
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { commandLogPath, logCommand, readRecentCommands } from './command-log.js';
+
+let dir: string;
+const prevEnv = process.env.BAZEL_IOS_COMMAND_LOG;
+
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'cmdlog-'));
+ process.env.BAZEL_IOS_COMMAND_LOG = join(dir, 'commands.ndjson');
+ delete process.env.BAZEL_IOS_COMMAND_LOG_DISABLE;
+});
+
+afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+ if (prevEnv === undefined) delete process.env.BAZEL_IOS_COMMAND_LOG;
+ else process.env.BAZEL_IOS_COMMAND_LOG = prevEnv;
+});
+
+describe('command-log', () => {
+ it('uses the env override for the log path', () => {
+ expect(commandLogPath()).toBe(join(dir, 'commands.ndjson'));
+ });
+
+ it('appends NDJSON entries and reads them back', () => {
+ logCommand({ id: 'aaa', timestamp: 1, argv: ['bazel', 'build', '//a'], exitCode: 0, durationMs: 10 });
+ logCommand({ id: 'bbb', timestamp: 2, argv: ['bazel', 'test', '//b'], exitCode: 1, durationMs: 20, failureKind: 'nonzero-exit' });
+
+ const raw = readFileSync(commandLogPath(), 'utf8').trim().split('\n');
+ expect(raw).toHaveLength(2);
+ expect(JSON.parse(raw[0]).id).toBe('aaa');
+
+ const entries = readRecentCommands();
+ expect(entries.map((e) => e.id)).toEqual(['aaa', 'bbb']);
+ expect(entries[1].failureKind).toBe('nonzero-exit');
+ });
+
+ it('respects the limit (newest last)', () => {
+ for (let i = 0; i < 5; i++) {
+ logCommand({ timestamp: i, argv: ['bazel', String(i)], exitCode: 0, durationMs: 1 });
+ }
+ const entries = readRecentCommands(2);
+ expect(entries.map((e) => e.argv[1])).toEqual(['3', '4']);
+ });
+
+ it('returns empty when no log exists', () => {
+ expect(readRecentCommands()).toEqual([]);
+ expect(existsSync(commandLogPath())).toBe(false);
+ });
+
+ it('does not write when disabled', () => {
+ process.env.BAZEL_IOS_COMMAND_LOG_DISABLE = '1';
+ logCommand({ timestamp: 1, argv: ['bazel'], exitCode: 0, durationMs: 1 });
+ expect(existsSync(commandLogPath())).toBe(false);
+ });
+
+ it('reads across the rotated .1 file so recent entries survive rotation', () => {
+ const path = commandLogPath();
+ // Older generation lives in .1, newer in the active file.
+ writeFileSync(`${path}.1`, [
+ JSON.stringify({ id: 'old1', timestamp: 1, argv: ['bazel', '1'], exitCode: 0, durationMs: 1 }),
+ JSON.stringify({ id: 'old2', timestamp: 2, argv: ['bazel', '2'], exitCode: 0, durationMs: 1 }),
+ ].join('\n') + '\n');
+ writeFileSync(path, JSON.stringify({ id: 'new1', timestamp: 3, argv: ['bazel', '3'], exitCode: 0, durationMs: 1 }) + '\n');
+
+ expect(readRecentCommands().map((e) => e.id)).toEqual(['old1', 'old2', 'new1']);
+ // The limit applies across both files, keeping the newest.
+ expect(readRecentCommands(2).map((e) => e.id)).toEqual(['old2', 'new1']);
+ });
+
+ it('rotates to .1 once the size cap is exceeded (bounded growth)', () => {
+ process.env.BAZEL_IOS_COMMAND_LOG_MAX_BYTES = '200';
+ try {
+ for (let i = 0; i < 50; i++) {
+ logCommand({ id: `id-${i}`, timestamp: i, argv: ['bazel', 'build', `//pkg:t${i}`], exitCode: 0, durationMs: 1 });
+ }
+ // The active log was rotated at least once, so a .1 sibling exists...
+ expect(existsSync(`${commandLogPath()}.1`)).toBe(true);
+ // ...and the active log stays bounded (not all 50 entries pile up).
+ expect(readRecentCommands(1000).length).toBeLessThan(50);
+ } finally {
+ delete process.env.BAZEL_IOS_COMMAND_LOG_MAX_BYTES;
+ }
+ });
+});
diff --git a/src/core/command-log.ts b/src/core/command-log.ts
new file mode 100644
index 0000000..3bd3175
--- /dev/null
+++ b/src/core/command-log.ts
@@ -0,0 +1,74 @@
+import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { homedir } from 'node:os';
+
+/** Rotate the log to `.1` once it grows past this size, keeping ≤ 2x on disk. */
+const DEFAULT_MAX_LOG_BYTES = 5 * 1024 * 1024;
+
+function maxLogBytes(): number {
+ const override = Number(process.env.BAZEL_IOS_COMMAND_LOG_MAX_BYTES);
+ return Number.isFinite(override) && override > 0 ? override : DEFAULT_MAX_LOG_BYTES;
+}
+
+export interface CommandLogEntry {
+ id?: string;
+ timestamp: number;
+ argv: string[];
+ cwd?: string;
+ envOverrides?: string[];
+ exitCode: number;
+ durationMs: number;
+ truncated?: boolean;
+ failureKind?: string;
+ timedOut?: boolean;
+ bytesDropped?: number;
+}
+
+/**
+ * Path of the persistent NDJSON command log. Override with
+ * `BAZEL_IOS_COMMAND_LOG`. Defaults to `~/.xcodebazelmcp/commands.ndjson`.
+ */
+export function commandLogPath(): string {
+ return process.env.BAZEL_IOS_COMMAND_LOG || join(homedir(), '.xcodebazelmcp', 'commands.ndjson');
+}
+
+/**
+ * Append one command invocation to the persistent NDJSON log. Best-effort: a
+ * logging failure must never break the actual command. A postmortem is then a
+ * `tail` away, surviving restarts (unlike the in-memory lastCommand).
+ */
+export function logCommand(entry: CommandLogEntry): void {
+ if (process.env.BAZEL_IOS_COMMAND_LOG_DISABLE === '1') return;
+ try {
+ const path = commandLogPath();
+ mkdirSync(dirname(path), { recursive: true });
+ if (existsSync(path) && statSync(path).size >= maxLogBytes()) {
+ renameSync(path, `${path}.1`); // overwrites a previous .1; bounds disk to ~2x
+ }
+ appendFileSync(path, JSON.stringify(entry) + '\n');
+ } catch {
+ /* best effort — never throw from logging */
+ }
+}
+
+/** Read the most recent command log entries (newest last). */
+export function readRecentCommands(limit = 20): CommandLogEntry[] {
+ const path = commandLogPath();
+ const entries: CommandLogEntry[] = [];
+ // Read the rotated file first (older entries), then the active file, so the
+ // most-recent N span the rotation boundary instead of being truncated to
+ // whatever survived the last rename.
+ for (const p of [`${path}.1`, path]) {
+ if (!existsSync(p)) continue;
+ for (const raw of readFileSync(p, 'utf8').split('\n')) {
+ const line = raw.trim();
+ if (!line) continue;
+ try {
+ entries.push(JSON.parse(line) as CommandLogEntry);
+ } catch {
+ /* skip malformed line */
+ }
+ }
+ }
+ return limit > 0 ? entries.slice(-limit) : entries;
+}
diff --git a/src/core/devices.ts b/src/core/devices.ts
index 1cef45b..4a5be0a 100644
--- a/src/core/devices.ts
+++ b/src/core/devices.ts
@@ -1,9 +1,29 @@
-import { existsSync } from 'node:fs';
+import { existsSync, mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { dirname, join } from 'node:path';
import { spawn, type ChildProcess } from 'node:child_process';
import type { CommandResult } from '../types/index.js';
import { runCommand } from '../utils/process.js';
import { readBundleId } from './simulators.js';
+/**
+ * Create a JSON output path inside a fresh private temp directory (0700 via
+ * mkdtemp). Avoids predictable `/tmp/...-.json` names that are prone
+ * to symlink/TOCTOU races (CWE-377).
+ */
+function makeTempJsonPath(tag: string): string {
+ const dir = mkdtempSync(join(tmpdir(), 'xcodebazelmcp-'));
+ return join(dir, `${tag}.json`);
+}
+
+function cleanupTempJson(path: string): void {
+ try {
+ rmSync(dirname(path), { recursive: true, force: true });
+ } catch {
+ /* best effort */
+ }
+}
+
interface PhysicalDevice {
udid: string;
name: string;
@@ -19,7 +39,7 @@ export async function listDevices(): Promise<{
command: CommandResult;
devices: PhysicalDevice[];
}> {
- const jsonOutputPath = `/tmp/xcodebazelmcp-devices-${Date.now()}.json`;
+ const jsonOutputPath = makeTempJsonPath('devices');
const command = await runCommand(
'xcrun',
['devicectl', 'list', 'devices', '--json-output', jsonOutputPath],
@@ -64,7 +84,7 @@ export async function listDevices(): Promise<{
} catch {
// JSON parse or read failed — return empty list with the command result
} finally {
- try { const { unlinkSync } = await import('node:fs'); unlinkSync(jsonOutputPath); } catch { /* best effort */ }
+ cleanupTempJson(jsonOutputPath);
}
return { command, devices };
@@ -89,7 +109,7 @@ export async function resolveDevice(options: {
if (options.deviceName) {
const normalize = (s: string) =>
- s.toLowerCase().replace(/[\u2018\u2019\u201A\u201B]/g, "'").replace(/[\u201C\u201D\u201E\u201F]/g, '"');
+ s.trim().toLowerCase().replace(/[\u2018\u2019\u201A\u201B]/g, "'").replace(/[\u201C\u201D\u201E\u201F]/g, '"');
const needle = normalize(options.deviceName);
const match = connected.find(
(d) => normalize(d.name) === needle,
@@ -155,7 +175,7 @@ export async function terminateAppOnDevice(
bundleId: string,
): Promise {
// First try to find PID via `devicectl device info apps`
- const appsJsonPath = `/tmp/xcodebazelmcp-apps-${Date.now()}.json`;
+ const appsJsonPath = makeTempJsonPath('apps');
const appsResult = await runCommand(
'xcrun',
['devicectl', 'device', 'info', 'apps', '--device', deviceId, '--json-output', appsJsonPath],
@@ -164,7 +184,7 @@ export async function terminateAppOnDevice(
let executableName: string | undefined;
try {
- const { readFileSync, unlinkSync } = await import('node:fs');
+ const { readFileSync } = await import('node:fs');
if (appsResult.exitCode === 0) {
const raw = readFileSync(appsJsonPath, 'utf8');
const parsed = JSON.parse(raw) as {
@@ -187,13 +207,13 @@ export async function terminateAppOnDevice(
}
}
}
- try { unlinkSync(appsJsonPath); } catch { /* best effort */ }
+ cleanupTempJson(appsJsonPath);
} catch {
- try { const { unlinkSync } = await import('node:fs'); unlinkSync(appsJsonPath); } catch { /* best effort */ }
+ cleanupTempJson(appsJsonPath);
}
// Now get process list and match
- const jsonPath = `/tmp/xcodebazelmcp-procs-${Date.now()}.json`;
+ const jsonPath = makeTempJsonPath('procs');
const listResult = await runCommand(
'xcrun',
['devicectl', 'device', 'info', 'processes', '--device', deviceId, '--json-output', jsonPath],
@@ -206,9 +226,9 @@ export async function terminateAppOnDevice(
let pid: number | undefined;
try {
- const { readFileSync, unlinkSync } = await import('node:fs');
+ const { readFileSync } = await import('node:fs');
const raw = readFileSync(jsonPath, 'utf8');
- unlinkSync(jsonPath);
+ cleanupTempJson(jsonPath);
const parsed = JSON.parse(raw) as {
result?: {
runningProcesses?: Array<{
@@ -238,7 +258,7 @@ export async function terminateAppOnDevice(
pid = proc?.processIdentifier;
} catch {
- try { const { unlinkSync } = await import('node:fs'); unlinkSync(jsonPath); } catch { /* best effort */ }
+ cleanupTempJson(jsonPath);
}
if (!pid) {
@@ -249,6 +269,7 @@ export async function terminateAppOnDevice(
exitCode: 1,
durationMs: 0,
truncated: false,
+ failureKind: 'nonzero-exit',
};
}
@@ -259,6 +280,27 @@ export async function terminateAppOnDevice(
);
}
+export async function uninstallAppOnDevice(
+ deviceId: string,
+ bundleId: string,
+): Promise {
+ return runCommand(
+ 'xcrun',
+ ['devicectl', 'device', 'uninstall', 'app', '--device', deviceId, bundleId],
+ { cwd: process.cwd(), timeoutSeconds: 120, maxOutput: 100_000 },
+ );
+}
+
+export async function listAppsOnDevice(
+ deviceId: string,
+): Promise {
+ return runCommand(
+ 'xcrun',
+ ['devicectl', 'device', 'info', 'apps', '--device', deviceId],
+ { cwd: process.cwd(), timeoutSeconds: 60, maxOutput: 500_000 },
+ );
+}
+
export async function screenshotDevice(
deviceId: string,
outputPath: string,
@@ -315,6 +357,9 @@ export async function screenshotDevice(
return result;
}
+/** Keep at most this many chars of streamed device-log output (sliding tail). */
+const DEVICE_LOG_MAX = 500_000;
+
interface DeviceLogCapture {
child: ChildProcess;
getCaptured: () => string;
@@ -338,11 +383,15 @@ export async function startDeviceLogCapture(
let captured = '';
let stderrBuf = '';
- child.stdout?.on('data', (chunk: Buffer) => { captured += chunk.toString(); });
+ const appendCapped = (text: string) => {
+ captured += text;
+ if (captured.length > DEVICE_LOG_MAX) captured = captured.slice(-DEVICE_LOG_MAX);
+ };
+ child.stdout?.on('data', (chunk: Buffer) => appendCapped(chunk.toString()));
child.stderr?.on('data', (chunk: Buffer) => {
const text = chunk.toString();
stderrBuf += text;
- captured += text;
+ appendCapped(text);
});
// Wait briefly to detect fast failures (no tunneld, device not found, etc.)
@@ -366,8 +415,12 @@ export async function startDeviceLogCapture(
const child = spawn('idevicesyslog', logArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
let captured = '';
- child.stdout?.on('data', (chunk: Buffer) => { captured += chunk.toString(); });
- child.stderr?.on('data', (chunk: Buffer) => { captured += chunk.toString(); });
+ const appendCapped = (text: string) => {
+ captured += text;
+ if (captured.length > DEVICE_LOG_MAX) captured = captured.slice(-DEVICE_LOG_MAX);
+ };
+ child.stdout?.on('data', (chunk: Buffer) => appendCapped(chunk.toString()));
+ child.stderr?.on('data', (chunk: Buffer) => appendCapped(chunk.toString()));
return { child, getCaptured: () => captured, tool: 'idevicesyslog' };
}
@@ -448,7 +501,11 @@ export async function countSigningIdentities(): Promise<{ count: number; command
{ cwd: process.cwd(), timeoutSeconds: 15, maxOutput: 50_000 },
);
const match = command.output.match(/(\d+) valid identities found/);
- return { count: match ? parseInt(match[1], 10) : 0, command };
+ if (match) return { count: parseInt(match[1], 10), command };
+ // Fallback for `security` phrasings without the summary line: count the
+ // enumerated ` 1) ""` identity lines.
+ const lineCount = (command.output.match(/^\s*\d+\)\s+[0-9A-F]{20,}/gim) || []).length;
+ return { count: lineCount, command };
}
export { readBundleId };
diff --git a/src/core/diagnostics.test.ts b/src/core/diagnostics.test.ts
new file mode 100644
index 0000000..4caca7f
--- /dev/null
+++ b/src/core/diagnostics.test.ts
@@ -0,0 +1,94 @@
+import { describe, it, expect } from 'vitest';
+import { classifyFailure, extractInvocationUrl } from './diagnostics.js';
+import type { CommandResult } from '../types/index.js';
+
+function res(partial: Partial): CommandResult {
+ return {
+ command: 'bazel',
+ args: ['build', '//x'],
+ exitCode: 1,
+ durationMs: 100,
+ output: '',
+ truncated: false,
+ ...partial,
+ };
+}
+
+describe('extractInvocationUrl', () => {
+ it('pulls a BuildBuddy invocation URL', () => {
+ const text = 'INFO: Streaming build results to: https://app.buildbuddy.io/invocation/abc-123\nLoading...';
+ expect(extractInvocationUrl(text)).toBe('https://app.buildbuddy.io/invocation/abc-123');
+ });
+
+ it('trims trailing punctuation', () => {
+ expect(extractInvocationUrl('see (https://x.buildbuddy.io/invocation/zzz).')).toBe(
+ 'https://x.buildbuddy.io/invocation/zzz',
+ );
+ });
+
+ it('returns undefined when absent', () => {
+ expect(extractInvocationUrl('no url here')).toBeUndefined();
+ });
+});
+
+describe('classifyFailure', () => {
+ it('returns undefined for success', () => {
+ expect(classifyFailure(res({ exitCode: 0, failureKind: 'ok' }))).toBeUndefined();
+ });
+
+ it('classifies timeouts', () => {
+ const c = classifyFailure(res({ timedOut: true, failureKind: 'timeout' }));
+ expect(c?.category).toBe('timeout');
+ });
+
+ it('classifies spawn errors', () => {
+ const c = classifyFailure(res({ exitCode: -1, failureKind: 'spawn-error', spawnErrorCode: 'ENOENT', output: 'Error: spawn bazel ENOENT' }));
+ expect(c?.category).toBe('spawn_error');
+ expect(c?.diagnostics[0]).toContain('ENOENT');
+ });
+
+ it('classifies analysis errors', () => {
+ const c = classifyFailure(res({ stderr: 'ERROR: Analysis of target //a:b failed; build aborted' }));
+ expect(c?.category).toBe('analysis_error');
+ });
+
+ it('classifies BUILD file errors', () => {
+ const c = classifyFailure(res({ stderr: "ERROR: no such package '//missing': BUILD file not found" }));
+ expect(c?.category).toBe('build_file_error');
+ });
+
+ it('classifies missing dependency / visibility errors', () => {
+ const c = classifyFailure(res({ stderr: "ERROR: target '//a:b' is not visible to target '//c:d'" }));
+ expect(c?.category).toBe('missing_dependency');
+ });
+
+ it('classifies link errors', () => {
+ const c = classifyFailure(res({ stderr: 'Undefined symbols for architecture arm64:\n "_foo"\nld: symbol(s) not found' }));
+ expect(c?.category).toBe('link_error');
+ });
+
+ it('classifies compile errors', () => {
+ const c = classifyFailure(res({ stderr: '/path/Foo.swift:12:5: error: cannot find "bar" in scope' }));
+ expect(c?.category).toBe('compile_error');
+ expect(c?.diagnostics.some((d) => d.includes('error:'))).toBe(true);
+ });
+
+ it('classifies test failures', () => {
+ const c = classifyFailure(res({ stderr: 'Test Suite "MyTests" failed\nExecuted 3 tests, with 1 failure' }));
+ expect(c?.category).toBe('test_failure');
+ });
+
+ it('prefers stderr and surfaces the invocation URL', () => {
+ const c = classifyFailure(res({
+ stderr: 'INFO: Streaming build results to: https://app.buildbuddy.io/invocation/xyz\n/a.swift:1:1: error: boom',
+ output: 'progress noise',
+ }));
+ expect(c?.category).toBe('compile_error');
+ expect(c?.invocationUrl).toBe('https://app.buildbuddy.io/invocation/xyz');
+ });
+
+ it('falls back to unknown for unrecognized failures', () => {
+ const c = classifyFailure(res({ stderr: 'something went sideways' }));
+ expect(c?.category).toBe('unknown');
+ });
+});
diff --git a/src/core/diagnostics.ts b/src/core/diagnostics.ts
new file mode 100644
index 0000000..dccb679
--- /dev/null
+++ b/src/core/diagnostics.ts
@@ -0,0 +1,114 @@
+import type { CommandResult } from '../types/index.js';
+
+export type FailureCategory =
+ | 'timeout'
+ | 'spawn_error'
+ | 'analysis_error'
+ | 'build_file_error'
+ | 'missing_dependency'
+ | 'link_error'
+ | 'compile_error'
+ | 'test_failure'
+ | 'unknown';
+
+export interface FailureDiagnostics {
+ category: FailureCategory;
+ diagnostics: string[];
+ invocationUrl?: string;
+}
+
+const INVOCATION_RE = /https?:\/\/\S*invocation\/\S+/i;
+
+/** Pull a BuildBuddy / BES invocation URL out of Bazel output (one click to full remote logs). */
+export function extractInvocationUrl(text: string): string | undefined {
+ const match = text.match(INVOCATION_RE);
+ if (!match) return undefined;
+ // Trim trailing punctuation that often follows a URL in prose.
+ return match[0].replace(/[)\].,;'"]+$/, '');
+}
+
+const DIAGNOSTIC_LINE_RE =
+ /(ERROR:|error:|fatal error:|FAILED:|Undefined symbols?|duplicate symbol|ld: |linker command failed|no such target|no such package|not declared|is not visible|\*\* (BUILD|TEST) FAILED \*\*|Analysis (of|failed))/;
+
+function pickDiagnosticLines(text: string, limit = 12): string[] {
+ const seen = new Set();
+ const lines: string[] = [];
+ for (const raw of text.split('\n')) {
+ const line = raw.trim();
+ if (!line || !DIAGNOSTIC_LINE_RE.test(line)) continue;
+ if (seen.has(line)) continue;
+ seen.add(line);
+ lines.push(line);
+ if (lines.length >= limit) break;
+ }
+ return lines;
+}
+
+function categorize(text: string): FailureCategory {
+ if (/ERROR:.*[Aa]nalysis of target/.test(text) || /[Aa]nalysis failed/.test(text)) {
+ return 'analysis_error';
+ }
+ if (/no such package/i.test(text) || /error loading package/i.test(text) || /\/BUILD(\.bazel)?:\d+/.test(text)) {
+ return 'build_file_error';
+ }
+ if (/no such target/i.test(text) || /is not visible to target/i.test(text) || /which is not visible/i.test(text) || /missing dependency/i.test(text) || /undeclared inclusion/i.test(text)) {
+ return 'missing_dependency';
+ }
+ if (/Undefined symbols?/i.test(text) || /duplicate symbol/i.test(text) || /\bld: /.test(text) || /linker command failed/i.test(text)) {
+ return 'link_error';
+ }
+ if (/\.swift:\d+:\d+: error:/.test(text) || /\berror: /.test(text) || /fatal error:/.test(text)) {
+ return 'compile_error';
+ }
+ if (/Executed \d+ tests?/i.test(text) || /Test Suite .* failed/i.test(text) || /\*\* TEST FAILED \*\*/.test(text) || /\d+ (?:test )?fails?\b/i.test(text) || /XCTAssert/.test(text)) {
+ return 'test_failure';
+ }
+ return 'unknown';
+}
+
+type ClassifiableResult = Pick<
+ CommandResult,
+ 'exitCode' | 'stdout' | 'stderr' | 'output' | 'timedOut' | 'failureKind' | 'spawnErrorCode'
+>;
+
+// formatCommandResult and structuredCommandResult are usually called on the same
+// result object; cache by reference so the regex scan over (potentially large)
+// output runs at most once per result.
+const classifyCache = new WeakMap