diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index 9c024f02a..7935b029c 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -1088,6 +1088,7 @@ function createStubClient(params: { command, devices: { list: async () => [], + capabilities: unexpectedCommandCall, boot: unexpectedCommandCall, shutdown: unexpectedCommandCall, }, diff --git a/src/__tests__/test-utils/index.ts b/src/__tests__/test-utils/index.ts index 20fa05516..56fd18592 100644 --- a/src/__tests__/test-utils/index.ts +++ b/src/__tests__/test-utils/index.ts @@ -20,6 +20,8 @@ export { export { makeSnapshotState } from './snapshot-builders.ts'; +export { makeSessionStore } from './store-factory.ts'; + export { withNoColor } from './color.ts'; export { diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 3a5a5f3da..213b438be 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -152,6 +152,7 @@ Command shape: Bootstrap: agent-device devices --platform ios + agent-device capabilities --platform android agent-device apps --platform android agent-device open MyApp --platform ios --device "iPhone 17 Pro" agent-device open --session checkout --platform android @@ -160,7 +161,7 @@ Bootstrap: agent-device install-from-source --github-actions-artifact org/repo:app-debug --platform android agent-device open com.example.app --platform android --relaunch agent-device prepare ios-runner --platform ios --timeout 240000 - If app id is unknown, plan devices, apps, then open . Discovery is not enough when the task asks to open/start the app. + If app id is unknown, plan devices, apps, then open . Use capabilities only when a dynamic integration needs the command names supported by the selected target; normal app-driving loops do not need it. Discovery is not enough when the task asks to open/start the app. Install arguments are app/package id then artifact path. If the task says install, use install; use reinstall only when explicitly requested. Fresh runtime state is open --relaunch after install. In Apple CI, run prepare ios-runner after boot/install and before replay/test. prepare ios-runner builds/reuses the XCTest runner, health-checks it with a lightweight command, and retries one stuck/non-connecting runner launch before the first snapshot pays that setup cost. It is not a recovery step for "runner already owned by another agent-device daemon"; stop the owning daemon on the Mac with simulator access instead. If the replay/test step starts a separate daemon, stop the prepare daemon before replay/test so the prepared runner does not keep a live lease owned by that daemon. CI may cache ~/.agent-device/apple-runner/derived with an exact key that includes the agent-device package and Xcode version. Avoid broad restore-key fallbacks; prepare ios-runner already recovers bad restored runner artifacts and one retryable non-connecting runner launch. Runner build/start output is written to the session's runner.log; daemon.log is for daemon lifecycle/startup issues. diff --git a/src/client/client-shared.ts b/src/client/client-shared.ts index 17c905f57..21adc2bf1 100644 --- a/src/client/client-shared.ts +++ b/src/client/client-shared.ts @@ -81,6 +81,7 @@ export function serializeSessionListEntry(session: AgentDeviceSession): Record { return { platform: device.platform, + ...(device.appleOs ? { appleOs: device.appleOs } : {}), id: device.id, name: device.name, kind: device.kind, diff --git a/src/client/client-types.ts b/src/client/client-types.ts index c253a71ad..24191ad90 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -178,6 +178,11 @@ export type AgentDeviceDevice = { }; }; +export type AgentDeviceCapabilitiesResult = { + device: AgentDeviceDevice; + availableCommands: string[]; +}; + export type AgentDeviceSessionDevice = { platform: PublicPlatform; target: DeviceTarget; @@ -989,6 +994,9 @@ export type AgentDeviceClient = { list: ( options?: AgentDeviceRequestOverrides & AgentDeviceSelectionOptions, ) => Promise; + capabilities: ( + options?: AgentDeviceRequestOverrides & AgentDeviceSelectionOptions, + ) => Promise; boot: (options?: DeviceBootOptions) => Promise>; shutdown: (options?: DeviceShutdownOptions) => Promise>; }; diff --git a/src/client/client.ts b/src/client/client.ts index 9a4538df8..10f3181aa 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -134,6 +134,18 @@ export function createAgentDeviceClient( const devices = Array.isArray(data.devices) ? data.devices : []; return devices.map(normalizeDevice); }, + capabilities: async (options = {}) => { + const data = await executeCommand>('capabilities', options); + const availableCommands = Array.isArray(data.availableCommands) + ? data.availableCommands.filter( + (command): command is string => typeof command === 'string', + ) + : []; + return { + device: normalizeDevice(data.device), + availableCommands, + }; + }, boot: async (options = {}) => await executeCommand>('boot', options), shutdown: async (options = {}) => await executeCommand>('shutdown', options), diff --git a/src/command-catalog.ts b/src/command-catalog.ts index ef5bb4925..843288e3a 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -8,6 +8,7 @@ export const PUBLIC_COMMANDS = { back: 'back', batch: 'batch', boot: 'boot', + capabilities: 'capabilities', click: 'click', close: 'close', clipboard: 'clipboard', @@ -127,6 +128,7 @@ const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet( PUBLIC_COMMANDS.appState, PUBLIC_COMMANDS.prepare, PUBLIC_COMMANDS.batch, + PUBLIC_COMMANDS.capabilities, PUBLIC_COMMANDS.devices, PUBLIC_COMMANDS.doctor, PUBLIC_COMMANDS.gesture, diff --git a/src/commands/management/device.ts b/src/commands/management/device.ts index 1ace4695c..c9b1f784b 100644 --- a/src/commands/management/device.ts +++ b/src/commands/management/device.ts @@ -10,6 +10,12 @@ import { managementCliOutputFormatters } from './output.ts'; const devicesCommandMetadata = defineFieldCommandMetadata('devices', 'List available devices.', {}); +const capabilitiesCommandMetadata = defineFieldCommandMetadata( + 'capabilities', + 'List commands supported by the selected device.', + {}, +); + const bootCommandMetadata = defineFieldCommandMetadata( 'boot', 'Boot or prepare a selected device without using CLI positional arguments.', @@ -28,6 +34,11 @@ const devicesCommandDefinition = defineExecutableCommand(devicesCommandMetadata, client.devices.list(input), ); +const capabilitiesCommandDefinition = defineExecutableCommand( + capabilitiesCommandMetadata, + (client, input) => client.devices.capabilities(input), +); + const bootCommandDefinition = defineExecutableCommand(bootCommandMetadata, (client, input) => client.devices.boot(input), ); @@ -42,6 +53,12 @@ const bootCliSchema = { allowedFlags: ['headless'], } as const satisfies CommandSchemaOverride; +const capabilitiesCliSchema = { + summary: 'List supported commands for the selected device', + helpDescription: + 'List command names supported by the selected session device or explicit --platform/--device/--udid/--serial target.', +} as const satisfies CommandSchemaOverride; + const shutdownCliSchema = { summary: 'Shutdown target simulator/emulator', } as const satisfies CommandSchemaOverride; @@ -54,6 +71,7 @@ const bootCliReader: CliReader = (_positionals, flags) => ({ }); const devicesDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.devices); +const capabilitiesDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.capabilities); const bootDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.boot); const shutdownDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.shutdown); @@ -66,6 +84,16 @@ const devicesCommandFacet = defineCommandFacet({ cliOutputFormatter: managementCliOutputFormatters.devices, }); +const capabilitiesCommandFacet = defineCommandFacet({ + name: 'capabilities', + metadata: capabilitiesCommandMetadata, + definition: capabilitiesCommandDefinition, + cliSchema: capabilitiesCliSchema, + cliReader: commonCliReader, + daemonWriter: capabilitiesDaemonWriter, + cliOutputFormatter: managementCliOutputFormatters.capabilities, +}); + const bootCommandFacet = defineCommandFacet({ name: 'boot', metadata: bootCommandMetadata, @@ -88,6 +116,7 @@ const shutdownCommandFacet = defineCommandFacet({ export const deviceManagementCommandFacets = [ devicesCommandFacet, + capabilitiesCommandFacet, bootCommandFacet, shutdownCommandFacet, ] as const; diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index 6313d9ccc..32914f98d 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -7,6 +7,7 @@ import { serializeSessionListEntry, } from '../../client/client-shared.ts'; import type { + AgentDeviceCapabilitiesResult, AgentDeviceDevice, AgentDeviceSession, AppCloseResult, @@ -40,6 +41,20 @@ function devicesCliOutput(result: AgentDeviceDevice[]): CliOutput { return { data, text: result.map(formatDeviceLine).join('\n') }; } +function capabilitiesCliOutput(result: AgentDeviceCapabilitiesResult): CliOutput { + const data = { + device: serializeDevice(result.device), + availableCommands: result.availableCommands, + }; + return { + data, + text: [ + `${formatDeviceLine(result.device)} supports ${result.availableCommands.length} commands:`, + result.availableCommands.join(' '), + ].join('\n'), + }; +} + function appsCliOutput(params: { result: string[]; appsFilter?: 'user-installed' | 'all'; @@ -163,6 +178,7 @@ export const managementCliOutputFormatters = { boot: resultOutput(bootCliOutput), shutdown: resultOutput(shutdownCliOutput), devices: resultOutput(devicesCliOutput), + capabilities: resultOutput(capabilitiesCliOutput), doctor: resultOutput(doctorCliOutput), apps: ({ input, result }) => appsCliOutput({ diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 81b146769..6c7e162b4 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -32,6 +32,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.appState, PUBLIC_COMMANDS.artifacts, PUBLIC_COMMANDS.batch, + PUBLIC_COMMANDS.capabilities, PUBLIC_COMMANDS.devices, PUBLIC_COMMANDS.doctor, PUBLIC_COMMANDS.gesture, diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index b666c0990..3b4ec5f62 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -153,6 +153,18 @@ const RAW_COMMAND_DESCRIPTORS = [ timeoutPolicy: DEFAULT_TIMEOUT_POLICY, batchable: true, }, + { + name: PUBLIC_COMMANDS.capabilities, + daemon: { + route: 'session', + sessionKind: 'inventory', + lockPolicySelectorOverride: true, + preferExplicitDeviceOverExistingSession: true, + ...REQUEST_EXECUTION_EXEMPT, + }, + timeoutPolicy: DEFAULT_TIMEOUT_POLICY, + batchable: true, + }, { name: PUBLIC_COMMANDS.doctor, daemon: { diff --git a/src/daemon/__tests__/daemon-command-registry.test.ts b/src/daemon/__tests__/daemon-command-registry.test.ts index 9c94f9eb0..8e5832ca7 100644 --- a/src/daemon/__tests__/daemon-command-registry.test.ts +++ b/src/daemon/__tests__/daemon-command-registry.test.ts @@ -40,6 +40,7 @@ test('daemon command registry owns specialized handler routes', () => { test('daemon command registry owns session handler subroutes', () => { assert.equal(getSessionCommandKind(INTERNAL_COMMANDS.sessionList), 'inventory'); assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.devices), 'inventory'); + assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.capabilities), 'inventory'); assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.doctor), 'inventory'); assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.apps), 'inventory'); assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.boot), 'state'); @@ -54,6 +55,7 @@ test('daemon command registry owns session handler subroutes', () => { test('daemon command registry preserves request admission traits', () => { for (const command of [ INTERNAL_COMMANDS.sessionList, + PUBLIC_COMMANDS.capabilities, PUBLIC_COMMANDS.devices, PUBLIC_COMMANDS.doctor, INTERNAL_COMMANDS.releaseMaterializedPaths, @@ -67,6 +69,7 @@ test('daemon command registry preserves request admission traits', () => { for (const command of [ INTERNAL_COMMANDS.sessionList, + PUBLIC_COMMANDS.capabilities, PUBLIC_COMMANDS.devices, PUBLIC_COMMANDS.doctor, INTERNAL_COMMANDS.releaseMaterializedPaths, @@ -142,6 +145,7 @@ test('daemon command registry preserves Android modal and lock-policy traits', ( assert.equal(shouldGuardAndroidBlockingDialog(PUBLIC_COMMANDS.get), false); assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.apps), true); + assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.capabilities), true); assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.devices), true); assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.doctor), true); assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.open), false); @@ -152,6 +156,10 @@ test('daemon command registry preserves provider device resolution traits', () = shouldPreferExplicitDeviceOverExistingSession(makeRequest(PUBLIC_COMMANDS.apps)), true, ); + assert.equal( + shouldPreferExplicitDeviceOverExistingSession(makeRequest(PUBLIC_COMMANDS.capabilities)), + true, + ); assert.equal( shouldPreferExplicitDeviceOverExistingSession(makeRequest(PUBLIC_COMMANDS.snapshot)), false, diff --git a/src/daemon/handlers/__tests__/session-capabilities.test.ts b/src/daemon/handlers/__tests__/session-capabilities.test.ts new file mode 100644 index 000000000..4beed6613 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-capabilities.test.ts @@ -0,0 +1,83 @@ +import { test, expect } from 'vitest'; +import path from 'node:path'; +import os from 'node:os'; +import { PUBLIC_COMMANDS } from '../../../command-catalog.ts'; +import { makeAndroidSession, makeSessionStore } from '../../../__tests__/test-utils/index.ts'; +import { withTargetDeviceResolutionScope } from '../../../core/dispatch-resolve.ts'; +import type { DeviceInfo } from '../../../kernel/device.ts'; +import { handleSessionCommands } from '../session.ts'; + +test('capabilities reports supported commands for the selected session device', async () => { + const sessionName = 'android-capabilities'; + const sessionStore = makeSessionStore('agent-device-capabilities-'); + sessionStore.set(sessionName, makeAndroidSession(sessionName)); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: PUBLIC_COMMANDS.capabilities, + positionals: [], + flags: {}, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async () => ({ ok: true, data: {} }), + }); + + expect(response?.ok).toBe(true); + if (!response?.ok) return; + + expect(response.data?.device).toMatchObject({ + platform: 'android', + kind: 'emulator', + }); + expect(response.data?.availableCommands).toEqual( + expect.arrayContaining(['open', 'screenshot', 'snapshot', 'press', 'fill', 'network', 'perf']), + ); + expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.capabilities); + expect(response.data?.availableCommands).not.toContain(PUBLIC_COMMANDS.devices); +}); + +test('capabilities accepts a stopped Android AVD placeholder for explicit platform discovery', async () => { + const stoppedAvd: DeviceInfo = { + platform: 'android', + id: 'Pixel_8_API_35', + name: 'Pixel 8 API 35', + kind: 'emulator', + booted: false, + }; + const sessionStore = makeSessionStore('agent-device-capabilities-stopped-avd-'); + + const response = await withTargetDeviceResolutionScope( + async (request) => (request.platform === 'android' ? [stoppedAvd] : []), + async () => + await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: PUBLIC_COMMANDS.capabilities, + positionals: [], + flags: { platform: 'android' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: async () => ({ ok: true, data: {} }), + }), + ); + + expect(response?.ok).toBe(true); + if (!response?.ok) return; + + expect(response.data?.device).toMatchObject({ + platform: 'android', + id: 'Pixel_8_API_35', + kind: 'emulator', + booted: false, + }); + expect(response.data?.availableCommands).toEqual( + expect.arrayContaining(['open', 'screenshot', 'snapshot', 'press', 'fill']), + ); +}); diff --git a/src/daemon/handlers/session-inventory.ts b/src/daemon/handlers/session-inventory.ts index 6345542c8..aefb448cb 100644 --- a/src/daemon/handlers/session-inventory.ts +++ b/src/daemon/handlers/session-inventory.ts @@ -1,3 +1,4 @@ +import { isCommandSupportedOnDevice, listCapabilityCommands } from '../../core/capabilities.ts'; import { listDeviceInventory } from '../../core/dispatch-resolve.ts'; import { assertResolvedAppsFilter } from '../../contracts/app-inventory.ts'; import { asAppError } from '../../kernel/errors.ts'; @@ -104,13 +105,7 @@ export async function handleSessionInventoryCommands(params: { // `platform` stays the PUBLIC leaf via `publicPlatformString` (approach b). The // internal-only `simulatorSetPath` is still stripped. `appleOs` values never equal // the internal `apple` token, so this does not affect the apple-leak guard. - const publicDevices = filtered.map( - ({ simulatorSetPath: _simulatorSetPath, appleOs, ...device }) => ({ - ...device, - platform: publicPlatformString({ platform: device.platform, appleOs }), - ...(isApplePlatform(device.platform) && appleOs ? { appleOs } : {}), - }), - ); + const publicDevices = filtered.map(publicDeviceInfo); return { ok: true, data: { devices: publicDevices } }; } catch (err) { const appErr = asAppError(err); @@ -118,6 +113,29 @@ export async function handleSessionInventoryCommands(params: { } } + if (req.command === 'capabilities') { + const session = sessionStore.get(sessionName); + const flags = req.flags ?? {}; + const guard = requireSessionOrExplicitSelector(req.command, session, flags); + if (guard) return guard; + + const device = await resolveCommandDevice({ + session, + flags, + ensureReady: false, + allowStoppedAndroidAvdPlaceholders: true, + }); + return { + ok: true, + data: { + device: publicDeviceInfo(device), + availableCommands: listCapabilityCommands().filter((command) => + isCommandSupportedOnDevice(command, device), + ), + }, + }; + } + if (req.command === 'apps') { const session = sessionStore.get(sessionName); const flags = req.flags ?? {}; @@ -159,6 +177,18 @@ export async function handleSessionInventoryCommands(params: { return null; } +function publicDeviceInfo({ + simulatorSetPath: _simulatorSetPath, + appleOs, + ...device +}: DeviceInfo): Record { + return { + ...device, + platform: publicPlatformString({ platform: device.platform, appleOs }), + ...(isApplePlatform(device.platform) && appleOs ? { appleOs } : {}), + }; +} + function matchesRequestedPlatform( device: DeviceInfo, requestedPlatform: PlatformSelector | undefined, diff --git a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts index f6b0b28c1..f8dacd3a9 100644 --- a/test/integration/provider-scenarios/apple-platform-output-guard.test.ts +++ b/test/integration/provider-scenarios/apple-platform-output-guard.test.ts @@ -68,6 +68,7 @@ const DRIVEN_COMMANDS: Record = { // -- platform-bearing priority commands (success responses emit the leaf) -- [PUBLIC_COMMANDS.open]: ({ world }) => [WORLDS[world].open], [PUBLIC_COMMANDS.appState]: () => one(), + [PUBLIC_COMMANDS.capabilities]: () => one(), [PUBLIC_COMMANDS.devices]: () => one(), [PUBLIC_COMMANDS.doctor]: () => one(), [PUBLIC_COMMANDS.boot]: () => one(), diff --git a/test/integration/provider-scenarios/daemon-command-policy.test.ts b/test/integration/provider-scenarios/daemon-command-policy.test.ts index f5e956aaa..b56ced5cd 100644 --- a/test/integration/provider-scenarios/daemon-command-policy.test.ts +++ b/test/integration/provider-scenarios/daemon-command-policy.test.ts @@ -35,6 +35,23 @@ test('Provider-backed integration daemon command policies gate admission and pro ); assertRpcOk(devices); + const capabilities = await daemon.callCommand( + 'capabilities', + [], + { platform: 'android' }, + { meta: inactiveLeaseMeta }, + ); + const capabilitiesData = assertRpcOk<{ + device?: { id?: unknown; platform?: unknown; kind?: unknown }; + availableCommands?: string[]; + }>(capabilities); + assert.equal(capabilitiesData.device?.id, PROVIDER_SCENARIO_ANDROID.id); + assert.equal(capabilitiesData.device?.platform, 'android'); + assert.equal(capabilitiesData.device?.kind, 'emulator'); + assert.ok(Array.isArray(capabilitiesData.availableCommands)); + assert.ok(capabilitiesData.availableCommands.includes('open')); + assert.ok(capabilitiesData.availableCommands.includes('press')); + const blockedSnapshot = await daemon.callCommand( 'snapshot', [], diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index c5378db79..62108abec 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -76,11 +76,15 @@ const client = createAgentDeviceClient({ }); const devices = await client.devices.list({ platform: 'ios' }); +const capabilities = await client.devices.capabilities({ platform: 'ios' }); const apps = await client.apps.list({ platform: 'ios' }); const device = devices.find((candidate) => candidate.name === 'iPhone 16') ?? devices[0]; if (!device) { throw new Error('No iOS device available'); } +if (!capabilities.availableCommands.includes('snapshot')) { + throw new Error('Selected target does not support snapshots'); +} await client.apps.open({ app: 'com.apple.Preferences', @@ -97,6 +101,8 @@ const snapshot = await client.capture.snapshot({ interactiveOnly: true }); await client.sessions.close(); ``` +`client.devices.capabilities()` returns `{ device, availableCommands }`, using the same capability matrix as the CLI. Use it when a dynamic integration needs to decide which command names are valid for the selected target. + For direct iOS simulator app launches, `client.apps.open({ app, platform: 'ios', launchConsole: './artifacts/app.console.log' })` captures launch-time stdout/stderr. The option mirrors `open --launch-console` and is not valid for URL opens or non-simulator targets. @@ -240,6 +246,7 @@ Supported command methods: Additional CLI-backed methods are exposed on their domain groups with typed option objects so Node consumers do not need to build raw daemon requests: - `client.devices.boot()` +- `client.devices.capabilities()` - `client.devices.shutdown()` - `client.apps.push()` - `client.apps.triggerEvent()` diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 6322b360b..f447e30ab 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -167,11 +167,15 @@ agent-device devices --platform ios agent-device devices --platform android agent-device devices --platform ios --ios-simulator-device-set /tmp/tenant-a/simulators agent-device devices --platform android --android-device-allowlist emulator-5554,device-1234 +agent-device capabilities --platform android +agent-device capabilities --session checkout --json ``` - `devices` lists available targets after applying any platform selector or isolation scope flags. - Use `--platform` to narrow discovery to Apple-family (`ios`, `tvOS`, `macOS`) or Android targets. - Use `--ios-simulator-device-set` and `--android-device-allowlist` when you need tenant- or lab-scoped discovery. +- `capabilities` reports the command names supported by the selected session device or an explicit `--platform`/`--device`/`--udid`/`--serial` target. +- In JSON output, `capabilities` returns `{ device, availableCommands }`. Use `availableCommands` for dynamic integrations instead of maintaining a separate platform support table. ## Prepare Apple runner diff --git a/website/docs/docs/quick-start.md b/website/docs/docs/quick-start.md index f4bc4285e..f7b4616b2 100644 --- a/website/docs/docs/quick-start.md +++ b/website/docs/docs/quick-start.md @@ -53,6 +53,7 @@ agent-device shutdown --platform android --device Pixel_9_Pro_XL ```bash agent-device apps --platform android # Discover the exact package name when unsure +agent-device capabilities --platform android # Discover target-supported commands for dynamic integrations agent-device open SampleApp agent-device snapshot -i # Get visible interactive elements with refs agent-device diff snapshot # Preferred exploration form for structural deltas