Skip to content

Commit bb06baf

Browse files
thymikeeclaude
andauthored
feat: add ensure-simulator command for scoped iOS device sets (#179)
* feat: add ensure-simulator command for scoped iOS device sets (#169) Adds a new first-class `ensure-simulator` command that ensures an iOS simulator exists (and optionally boots it) inside a scoped device set, without requiring custom simctl scripting outside agent-device. Usage: agent-device ensure-simulator --device "iPhone 16" [--runtime <id>] [--boot] [--ios-simulator-device-set <path>] JSON output includes udid, device, runtime, ios_simulator_device_set, and whether the simulator was created vs reused. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: document ensure-simulator in commands reference and skill Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 427093d commit bb06baf

7 files changed

Lines changed: 284 additions & 1 deletion

File tree

skills/agent-device/SKILL.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,8 @@ curl -sS http://127.0.0.1:${AGENT_DEVICE_DAEMON_HTTP_PORT}/rpc \
9595
agent-device devices
9696
agent-device devices --platform ios --ios-simulator-device-set /tmp/tenant-a/simulators
9797
agent-device devices --platform android --android-device-allowlist emulator-5554,device-1234
98+
agent-device ensure-simulator --device "iPhone 16" --ios-simulator-device-set /tmp/tenant-a/simulators
99+
agent-device ensure-simulator --device "iPhone 16" --runtime com.apple.CoreSimulator.SimRuntime.iOS-18-4 --ios-simulator-device-set /tmp/tenant-a/simulators --boot
98100
agent-device open [app|url] [url]
99101
agent-device open [app] --relaunch
100102
agent-device close [app]
@@ -114,6 +116,12 @@ Isolation scoping quick reference:
114116
- Scope is applied before selectors (`--device`, `--udid`, `--serial`); out-of-scope selectors fail with `DEVICE_NOT_FOUND`.
115117
- With iOS simulator-set scope enabled, iOS physical devices are not enumerated.
116118

119+
Simulator provisioning quick reference:
120+
- Use `ensure-simulator` to create or reuse a named iOS simulator inside a device set before starting a session.
121+
- `--device <name>` is required (e.g. `"iPhone 16 Pro"`). `--runtime <id>` pins the runtime; omit to use the newest compatible one.
122+
- `--boot` boots it immediately. Returns `udid`, `device`, `runtime`, `ios_simulator_device_set`, `created`, `booted`.
123+
- Idempotent: safe to call repeatedly; reuses an existing matching simulator by default.
124+
117125
TV quick reference:
118126
- AndroidTV: `open`/`apps` use TV launcher discovery automatically.
119127
- TV target selection works on emulators/simulators and connected physical devices (AndroidTV + AppleTV).

src/cli.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,20 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
245245
if (logTailStopper) logTailStopper();
246246
return;
247247
}
248+
if (command === 'ensure-simulator') {
249+
const data = response.data as Record<string, unknown> | undefined;
250+
const udid = typeof data?.udid === 'string' ? data.udid : 'unknown';
251+
const device = typeof data?.device === 'string' ? data.device : 'unknown';
252+
const runtime = typeof data?.runtime === 'string' ? data.runtime : '';
253+
const created = data?.created === true;
254+
const booted = data?.booted === true;
255+
const action = created ? 'Created' : 'Reused';
256+
const bootedSuffix = booted ? ' (booted)' : '';
257+
process.stdout.write(`${action}: ${device} ${udid}${bootedSuffix}\n`);
258+
if (runtime) process.stdout.write(`Runtime: ${runtime}\n`);
259+
if (logTailStopper) logTailStopper();
260+
return;
261+
}
248262
if (command === 'logs') {
249263
const data = response.data as Record<string, unknown> | undefined;
250264
const pathOut = typeof data?.path === 'string' ? data.path : '';

src/daemon.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,11 @@ const leaseRegistry = new LeaseRegistry({
4949
});
5050
const version = readVersion();
5151
const token = crypto.randomBytes(24).toString('hex');
52-
const selectorValidationExemptCommands = new Set(['session_list', 'devices']);
52+
const selectorValidationExemptCommands = new Set(['session_list', 'devices', 'ensure-simulator']);
5353
const leaseAdmissionExemptCommands = new Set([
5454
'session_list',
5555
'devices',
56+
'ensure-simulator',
5657
'lease_allocate',
5758
'lease_heartbeat',
5859
'lease_release',

src/daemon/handlers/session.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
parseSelectorWaitPositionals,
4444
} from './session-replay-heal.ts';
4545
import { parseReplayScript, writeReplayScript } from './session-replay-script.ts';
46+
import { ensureSimulatorExists } from '../../platforms/ios/ensure-simulator.ts';
4647

4748
type ReinstallOps = {
4849
ios: (device: DeviceInfo, app: string, appPath: string) => Promise<{ bundleId: string }>;
@@ -757,6 +758,42 @@ export async function handleSessionCommands(params: {
757758
return { ok: true, data };
758759
}
759760

761+
if (command === 'ensure-simulator') {
762+
try {
763+
const flags = req.flags ?? {};
764+
const deviceName = flags.device;
765+
const runtime = flags.runtime;
766+
const iosSimulatorSetPath = resolveIosSimulatorDeviceSetPath(flags.iosSimulatorDeviceSet);
767+
if (!deviceName) {
768+
return { ok: false, error: { code: 'INVALID_ARGS', message: 'ensure-simulator requires --device <name>' } };
769+
}
770+
const shouldBoot = flags.boot === true;
771+
const reuseExisting = flags.reuseExisting !== false;
772+
const result = await ensureSimulatorExists({
773+
deviceName,
774+
runtime,
775+
simulatorSetPath: iosSimulatorSetPath,
776+
reuseExisting,
777+
boot: shouldBoot,
778+
ensureReady,
779+
});
780+
return {
781+
ok: true,
782+
data: {
783+
udid: result.udid,
784+
device: result.device,
785+
runtime: result.runtime,
786+
ios_simulator_device_set: iosSimulatorSetPath ?? null,
787+
created: result.created,
788+
booted: result.booted,
789+
},
790+
};
791+
} catch (err) {
792+
const appErr = asAppError(err);
793+
return { ok: false, error: { code: appErr.code, message: appErr.message, details: appErr.details } };
794+
}
795+
}
796+
760797
if (command === 'devices') {
761798
try {
762799
const devices: DeviceInfo[] = [];
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { AppError } from '../../utils/errors.ts';
2+
import { runCmd } from '../../utils/exec.ts';
3+
import type { DeviceInfo } from '../../utils/device.ts';
4+
import { buildSimctlArgs } from './simctl.ts';
5+
import { IOS_SIMCTL_LIST_TIMEOUT_MS } from './config.ts';
6+
7+
type SimctlDeviceRecord = {
8+
name: string;
9+
udid: string;
10+
state: string;
11+
isAvailable: boolean;
12+
};
13+
14+
type SimctlListPayload = {
15+
devices: Record<string, SimctlDeviceRecord[]>;
16+
};
17+
18+
export type EnsureSimulatorResult = {
19+
udid: string;
20+
device: string;
21+
runtime: string;
22+
created: boolean;
23+
booted: boolean;
24+
};
25+
26+
type EnsureSimulatorOptions = {
27+
deviceName: string;
28+
runtime?: string;
29+
simulatorSetPath?: string | null;
30+
reuseExisting: boolean;
31+
boot: boolean;
32+
ensureReady: (device: DeviceInfo) => Promise<void>;
33+
};
34+
35+
export async function ensureSimulatorExists(options: EnsureSimulatorOptions): Promise<EnsureSimulatorResult> {
36+
const { deviceName, runtime, simulatorSetPath, reuseExisting, boot, ensureReady } = options;
37+
38+
if (process.platform !== 'darwin') {
39+
throw new AppError('UNSUPPORTED_PLATFORM', 'ensure-simulator is only available on macOS');
40+
}
41+
42+
const simctlOpts = { simulatorSetPath: simulatorSetPath ?? undefined };
43+
let udid: string;
44+
let resolvedRuntime: string;
45+
let created: boolean;
46+
47+
if (reuseExisting) {
48+
const existing = await findExistingSimulator({ deviceName, runtime, simctlOpts });
49+
if (existing) {
50+
udid = existing.udid;
51+
resolvedRuntime = existing.runtime;
52+
created = false;
53+
} else {
54+
const result = await createSimulator({ deviceName, runtime, simctlOpts });
55+
udid = result.udid;
56+
resolvedRuntime = await resolveSimulatorRuntime(udid, simctlOpts);
57+
created = true;
58+
}
59+
} else {
60+
const result = await createSimulator({ deviceName, runtime, simctlOpts });
61+
udid = result.udid;
62+
resolvedRuntime = await resolveSimulatorRuntime(udid, simctlOpts);
63+
created = true;
64+
}
65+
66+
let booted = false;
67+
if (boot) {
68+
const device: DeviceInfo = {
69+
platform: 'ios',
70+
id: udid,
71+
name: deviceName,
72+
kind: 'simulator',
73+
target: 'mobile',
74+
...(simulatorSetPath ? { simulatorSetPath } : {}),
75+
};
76+
await ensureReady(device);
77+
booted = true;
78+
}
79+
80+
return { udid, device: deviceName, runtime: resolvedRuntime, created, booted };
81+
}
82+
83+
type FindOptions = {
84+
deviceName: string;
85+
runtime?: string;
86+
simctlOpts: { simulatorSetPath?: string };
87+
};
88+
89+
async function findExistingSimulator(
90+
options: FindOptions,
91+
): Promise<{ udid: string; runtime: string } | null> {
92+
const { deviceName, runtime, simctlOpts } = options;
93+
const result = await runCmd('xcrun', buildSimctlArgs(['list', 'devices', '-j'], simctlOpts), {
94+
allowFailure: true,
95+
timeoutMs: IOS_SIMCTL_LIST_TIMEOUT_MS,
96+
});
97+
if (result.exitCode !== 0) return null;
98+
99+
try {
100+
const payload = JSON.parse(String(result.stdout ?? '')) as SimctlListPayload;
101+
for (const [runtimeKey, devices] of Object.entries(payload.devices ?? {})) {
102+
if (runtime && !normalizeRuntime(runtimeKey).includes(normalizeRuntime(runtime))) continue;
103+
for (const device of devices) {
104+
if (!device.isAvailable) continue;
105+
if (device.name.toLowerCase() === deviceName.toLowerCase()) {
106+
return { udid: device.udid, runtime: runtimeKey };
107+
}
108+
}
109+
}
110+
return null;
111+
} catch {
112+
return null;
113+
}
114+
}
115+
116+
type CreateOptions = {
117+
deviceName: string;
118+
runtime?: string;
119+
simctlOpts: { simulatorSetPath?: string };
120+
};
121+
122+
async function createSimulator(options: CreateOptions): Promise<{ udid: string }> {
123+
const { deviceName, runtime, simctlOpts } = options;
124+
const createArgs = runtime
125+
? ['create', deviceName, deviceName, runtime]
126+
: ['create', deviceName, deviceName];
127+
128+
const result = await runCmd('xcrun', buildSimctlArgs(createArgs, simctlOpts), {
129+
allowFailure: true,
130+
});
131+
132+
if (result.exitCode !== 0) {
133+
throw new AppError('COMMAND_FAILED', 'Failed to create iOS simulator', {
134+
deviceName,
135+
runtime,
136+
stdout: String(result.stdout ?? ''),
137+
stderr: String(result.stderr ?? ''),
138+
exitCode: result.exitCode,
139+
hint: 'Ensure the device type and runtime identifiers are valid. Run `xcrun simctl list devicetypes` and `xcrun simctl list runtimes` to see available options.',
140+
});
141+
}
142+
143+
const udid = String(result.stdout ?? '').trim();
144+
if (!udid) {
145+
throw new AppError('COMMAND_FAILED', 'simctl create returned no UDID', {
146+
deviceName,
147+
runtime,
148+
stdout: String(result.stdout ?? ''),
149+
stderr: String(result.stderr ?? ''),
150+
});
151+
}
152+
return { udid };
153+
}
154+
155+
async function resolveSimulatorRuntime(
156+
udid: string,
157+
simctlOpts: { simulatorSetPath?: string },
158+
): Promise<string> {
159+
const result = await runCmd('xcrun', buildSimctlArgs(['list', 'devices', '-j'], simctlOpts), {
160+
allowFailure: true,
161+
timeoutMs: IOS_SIMCTL_LIST_TIMEOUT_MS,
162+
});
163+
if (result.exitCode !== 0) return '';
164+
try {
165+
const payload = JSON.parse(String(result.stdout ?? '')) as SimctlListPayload;
166+
for (const [runtimeKey, devices] of Object.entries(payload.devices ?? {})) {
167+
if (devices.some((d) => d.udid === udid)) return runtimeKey;
168+
}
169+
return '';
170+
} catch {
171+
return '';
172+
}
173+
}
174+
175+
function normalizeRuntime(runtime: string): string {
176+
return runtime.toLowerCase().replace(/[._-]/g, '');
177+
}

src/utils/command-schema.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ export type CliFlags = {
1818
androidDeviceAllowlist?: string;
1919
out?: string;
2020
session?: string;
21+
runtime?: string;
22+
boot?: boolean;
23+
reuseExisting?: boolean;
2124
verbose?: boolean;
2225
snapshotInteractiveOnly?: boolean;
2326
snapshotCompact?: boolean;
@@ -191,6 +194,27 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [
191194
usageLabel: '--headless',
192195
usageDescription: 'Boot: launch Android emulator without a GUI window',
193196
},
197+
{
198+
key: 'runtime',
199+
names: ['--runtime'],
200+
type: 'string',
201+
usageLabel: '--runtime <id>',
202+
usageDescription: 'ensure-simulator: CoreSimulator runtime identifier (e.g. com.apple.CoreSimulator.SimRuntime.iOS-18-0)',
203+
},
204+
{
205+
key: 'boot',
206+
names: ['--boot'],
207+
type: 'boolean',
208+
usageLabel: '--boot',
209+
usageDescription: 'ensure-simulator: boot the simulator after ensuring it exists',
210+
},
211+
{
212+
key: 'reuseExisting',
213+
names: ['--reuse-existing'],
214+
type: 'boolean',
215+
usageLabel: '--reuse-existing',
216+
usageDescription: 'ensure-simulator: reuse an existing simulator (default: true)',
217+
},
194218
{
195219
key: 'iosSimulatorDeviceSet',
196220
names: ['--ios-simulator-device-set'],
@@ -508,6 +532,12 @@ const COMMAND_SCHEMAS: Record<string, CommandSchema> = {
508532
positionalArgs: ['kind'],
509533
allowedFlags: [...SNAPSHOT_FLAGS],
510534
},
535+
'ensure-simulator': {
536+
description: 'Ensure an iOS simulator exists in a device set (create if missing)',
537+
positionalArgs: [],
538+
allowedFlags: ['runtime', 'boot', 'reuseExisting'],
539+
skipCapabilityCheck: true,
540+
},
511541
devices: {
512542
description: 'List available devices',
513543
positionalArgs: [],

website/docs/docs/commands.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,22 @@ agent-device devices --platform android --android-device-allowlist emulator-5554
5454
- Android: `AGENT_DEVICE_ANDROID_DEVICE_ALLOWLIST` (compat: `ANDROID_DEVICE_ALLOWLIST`)
5555
- CLI scope flags override environment values.
5656

57+
## Simulator provisioning
58+
59+
```bash
60+
agent-device ensure-simulator --device "iPhone 16" --platform ios
61+
agent-device ensure-simulator --device "iPhone 16" --runtime com.apple.CoreSimulator.SimRuntime.iOS-18-4 --ios-simulator-device-set /tmp/tenant-a/simulators
62+
agent-device ensure-simulator --device "iPhone 16" --ios-simulator-device-set /tmp/tenant-a/simulators --boot
63+
```
64+
65+
- `ensure-simulator` ensures a named iOS simulator exists inside a device set, creating it via `simctl create` if missing.
66+
- Requires `--device <name>` (the simulator name / device type, e.g. `"iPhone 16 Pro"`).
67+
- `--runtime <id>` pins a specific CoreSimulator runtime (e.g. `com.apple.CoreSimulator.SimRuntime.iOS-18-4`). Omit to use the newest compatible runtime.
68+
- `--boot` boots the simulator after ensuring it exists.
69+
- Reuse of an existing matching simulator is the default; the command is idempotent.
70+
- JSON output includes `udid`, `device`, `runtime`, `ios_simulator_device_set`, `created`, and `booted`.
71+
- Does not require an active session — safe to call before `open`.
72+
5773
## TV targets
5874

5975
```bash

0 commit comments

Comments
 (0)