diff --git a/package-lock.json b/package-lock.json index 9aed7e83..6978e36c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3697,7 +3697,9 @@ "name": "@argent/cli", "version": "0.10.0", "dependencies": { - "@argent/tools-client": "file:../argent-tools-client" + "@argent/tools-client": "file:../argent-tools-client", + "@clack/prompts": "^1.1.0", + "picocolors": "^1.1.1" }, "devDependencies": { "@types/node": "^25.9.0", diff --git a/packages/argent-private b/packages/argent-private index 27dac191..73c94914 160000 --- a/packages/argent-private +++ b/packages/argent-private @@ -1 +1 @@ -Subproject commit 27dac191c0ea609a5befac5429c64d5fa0369e78 +Subproject commit 73c949142bc06189945bb4176357037ab64e9a47 diff --git a/packages/skills/skills/argent-device-interact/SKILL.md b/packages/skills/skills/argent-device-interact/SKILL.md index e2911ccd..22c8063d 100644 --- a/packages/skills/skills/argent-device-interact/SKILL.md +++ b/packages/skills/skills/argent-device-interact/SKILL.md @@ -15,6 +15,8 @@ If you delegate simulator tasks to sub-agents, make sure they have MCP permissio Use `list-devices` to get a target id. Results are tagged with `platform` (`ios` or `android`); booted/ready devices come first. Pick the first entry that matches the platform you need — if none are ready, call `boot-device` with `udid` (iOS) or `avdName` (Android). See `argent-ios-simulator-setup` / `argent-android-emulator-setup` for full setup flow. +> **Physical devices** do not support interaction tools (taps, swipes, screenshots, describe). For physical device workflows, use profiling and debugging tools only — see `argent-native-profiler` skill. + **Load tool schemas before first use.** Gesture tools (`gesture-tap`, `gesture-swipe`, `gesture-pinch`, `gesture-rotate`, `gesture-custom`) may be deferred — their parameter schemas are not loaded until fetched. Always use ToolSearch to load the schemas of all gesture tools you plan to use **before** calling any of them. If you skip this step, parameters may be coerced to strings instead of numbers, causing validation errors. ## 2. Best Practices diff --git a/packages/skills/skills/argent-native-profiler/SKILL.md b/packages/skills/skills/argent-native-profiler/SKILL.md index 01f777b1..d62fe54e 100644 --- a/packages/skills/skills/argent-native-profiler/SKILL.md +++ b/packages/skills/skills/argent-native-profiler/SKILL.md @@ -40,13 +40,20 @@ After presenting findings, ask the user whether to investigate further, implemen **Complete all steps in order — do not break mid-flow.** -### Step 0: Ensure the target app is running +### Step 0: Choose device and ensure the target app is running -The `native-profiler-start` tool **auto-detects** the running app on the device. +**Simulator vs physical device:** Call `list-devices` to find a target device. + +- **Prefer simulators** for fast iteration, CI, and most development workflows. +- **Use a physical device** (`include_physical_devices: true`) when the user explicitly asks for device profiling, or when you need accurate real-world data: CPU/GPU timings, thermal throttling, real memory behavior, or hardware-dependent features (camera, GPS, NFC, push notifications). + +> Physical devices do **not** support automated interaction (taps, swipes, screenshots, describe) — only profiling and debugging tools work. The user must navigate the device by hand. + +The `native-profiler-start` tool **auto-detects** the running app on the simulator or device. You do not need to derive `app_process` manually — just make sure the app is launched. -1. If the app is already running on the device, skip to Step 1 (do not pass `app_process`). -2. If the app is not running, use `launch-app` with the correct bundle ID first. +1. If the app is already running, skip to Step 1 (do not pass `app_process`). +2. If the app is not running on a simulator, use `launch-app` with the correct bundle ID first. On a physical device, ask the user to launch the app. 3. Only pass `app_process` explicitly if the tool reports multiple running user apps and you need to disambiguate. > **Note**: If multiple build flavors are installed (dev, staging, prod), the tool will detect whichever one is currently running. If both are running, it will ask you to specify. diff --git a/packages/tool-server/src/blueprints/js-runtime-debugger.ts b/packages/tool-server/src/blueprints/js-runtime-debugger.ts index 27a4596f..c60ce6bb 100644 --- a/packages/tool-server/src/blueprints/js-runtime-debugger.ts +++ b/packages/tool-server/src/blueprints/js-runtime-debugger.ts @@ -153,7 +153,9 @@ export const jsRuntimeDebuggerBlueprint: ServiceBlueprint; avds: Array<{ name: string }>; + // Only present when a physical-device scan was requested but devicectl failed + // (e.g. Xcode < 15, device locked/untrusted) — lets the caller surface the + // reason instead of silently reporting "no devices". + physicalDevicesError?: string; }; -function sortIos(a: IosDevice, b: IosDevice): number { +function sortIos(a: IosSimulatorDevice, b: IosSimulatorDevice): number { const aBooted = a.state === "Booted" ? 0 : 1; const bBooted = b.state === "Booted" ? 0 : 1; if (aBooted !== bBooted) return aBooted - bBooted; @@ -40,31 +47,63 @@ function sortAndroid(a: AndroidDevice, b: AndroidDevice): number { // Float booted/ready devices to the top of the merged list regardless of // platform — without this, all iOS entries are emitted before any Android. +// Connected physical devices are always ready. function readinessRank(d: IosDevice | AndroidDevice): number { - if (d.platform === "ios") return d.state === "Booted" ? 0 : 1; + if (d.platform === "ios") { + if (d.kind === "physical") return 0; + return d.state === "Booted" ? 0 : 1; + } return d.state === "device" ? 0 : 1; } -const zodSchema = z.object({}); +const zodSchema = z.object({ + include_physical_devices: z + .boolean() + .optional() + .describe( + "Also scan for physical iOS devices connected via USB or Wi-Fi (tagged `kind: \"physical\"`). Defaults to false — the scan is slower (~5s) and requires Xcode 15+. Physical devices support profiling/debugging only, not automated interaction." + ), +}); -export const listDevicesTool: ToolDefinition, ListDevicesResult> = { +type Params = z.infer; + +export const listDevicesTool: ToolDefinition = { id: "list-devices", description: `List iOS simulators and Android devices/emulators in one place. Use at the start of a session to pick a target id ('udid' for iOS entries, 'serial' for Android) to pass to interaction tools, and to see which targets are already running. -Returns { devices, avds } where each device carries a 'platform' discriminator ('ios' or 'android'), and 'avds' lists Android AVDs that can be booted via boot-device. +Returns { devices, avds } where each device carries a 'platform' discriminator ('ios' or 'android'); iOS entries also carry 'kind' ('simulator' or 'physical'). 'avds' lists Android AVDs that can be booted via boot-device. +Set include_physical_devices: true to also scan for connected physical iOS devices (slower, requires Xcode 15+); physical devices support profiling/debugging only, not automated interaction. Booted/ready devices are listed first. Platforms whose CLI is unavailable are silently omitted — an empty result usually means xcode-select or Android platform-tools is not installed.`, alwaysLoad: true, - searchHint: "list devices simulators emulators avd serial udid ios android session start", + searchHint: + "list devices simulators emulators physical avd serial udid ios android session start", zodSchema, services: () => ({}), - async execute(_services, _params) { - const [ios, android, avds] = await Promise.all([ + async execute(_services, params) { + const includePhysical = params.include_physical_devices ?? false; + const [ios, android, avds, physical] = await Promise.all([ listIosSimulators(), listAndroidDevices().catch(() => []), listAvds(), + includePhysical ? listPhysicalDevices() : Promise.resolve(null), ]); - const iosTagged: IosDevice[] = ios.map((s) => ({ platform: "ios", ...s })); - iosTagged.sort(sortIos); + + const iosSimTagged: IosSimulatorDevice[] = ios.map((s) => ({ + platform: "ios", + kind: "simulator", + ...s, + })); + iosSimTagged.sort(sortIos); + + let physicalDevicesError: string | undefined; + const iosPhysTagged: IosPhysicalDevice[] = []; + if (physical) { + physicalDevicesError = physical.error; + for (const pd of physical.devices) { + iosPhysTagged.push({ platform: "ios", kind: "physical", ...pd }); + } + } + const androidTagged: AndroidDevice[] = android.map((d) => ({ platform: "android", serial: d.serial, @@ -76,9 +115,13 @@ Booted/ready devices are listed first. Platforms whose CLI is unavailable are si })); androidTagged.sort(sortAndroid); - const devices: Array = [...iosTagged, ...androidTagged]; + const devices: Array = [ + ...iosSimTagged, + ...iosPhysTagged, + ...androidTagged, + ]; devices.sort((a, b) => readinessRank(a) - readinessRank(b)); - return { devices, avds }; + return physicalDevicesError ? { devices, avds, physicalDevicesError } : { devices, avds }; }, }; diff --git a/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-start.ts b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-start.ts index 9e4d8d60..0d0e344f 100644 --- a/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-start.ts +++ b/packages/tool-server/src/tools/profiler/native-profiler/native-profiler-start.ts @@ -10,6 +10,10 @@ import { resolveDevice } from "../../../utils/device-info"; import { getDebugDir } from "../../../utils/react-profiler/debug/dump"; import { listenForDarwinNotification, type NotifyHandle } from "../../../utils/ios-profiler/notify"; import { waitForXctraceReady } from "../../../utils/ios-profiler/startup"; +import { + checkIsSimulator, + detectRunningAppOnDevice, +} from "../../../utils/ios-physical-device"; const DEFAULT_TEMPLATE_PATH = path.resolve(__dirname, "Argent.tracetemplate"); const STARTUP_TIMEOUT_MS = 10_000; @@ -28,7 +32,7 @@ const zodSchema = z.object({ .string() .optional() .describe( - "The exact CFBundleExecutable of the app to profile. If omitted, auto-detects the currently running foreground app on the simulator. Only provide this if auto-detection picks the wrong app (e.g. multiple apps running)." + "The exact CFBundleExecutable of the app to profile. If omitted, auto-detects the currently running foreground app on the simulator or device. Only provide this if auto-detection picks the wrong app (e.g. multiple apps running)." ), template_path: z .string() @@ -208,7 +212,16 @@ Fails if no app is running on the device, the platform is not supported yet, or } const templatePath = params.template_path ?? DEFAULT_TEMPLATE_PATH; - const appProcess = params.app_process ?? detectRunningApp(params.device_id); + + // Auto-detect the running app when not explicitly provided. Simulators are + // queried via simctl; physical devices via devicectl (a different code path). + let appProcess = params.app_process; + if (!appProcess) { + const isSimulator = await checkIsSimulator(params.device_id); + appProcess = isSimulator + ? detectRunningApp(params.device_id) + : await detectRunningAppOnDevice(params.device_id); + } const debugDir = await getDebugDir(); const timestamp = new Date() diff --git a/packages/tool-server/src/utils/ios-physical-device.ts b/packages/tool-server/src/utils/ios-physical-device.ts new file mode 100644 index 00000000..01ed95e0 --- /dev/null +++ b/packages/tool-server/src/utils/ios-physical-device.ts @@ -0,0 +1,254 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as crypto from "node:crypto"; + +const execFileAsync = promisify(execFile); + +interface SimctlDevice { + udid: string; + state: string; + isAvailable: boolean; +} + +interface SimctlOutput { + devices: Record; +} + +const simulatorCache = new Map(); + +/** + * Determine whether a UDID belongs to a simulator or a physical device. + * Checks `xcrun simctl list devices --json` — if the UDID is found, it's a simulator. + * Physical-device detection (`detectRunningAppOnDevice`) and listing + * (`listPhysicalDevices`) go through `devicectl` instead. + */ +export async function checkIsSimulator(udid: string): Promise { + const cached = simulatorCache.get(udid); + if (cached !== undefined) return cached; + + const { stdout } = await execFileAsync("xcrun", ["simctl", "list", "devices", "--json"]); + const data: SimctlOutput = JSON.parse(stdout); + + const allUdids = new Set(); + for (const devices of Object.values(data.devices)) { + for (const device of devices) { + allUdids.add(device.udid); + } + } + + const result = allUdids.has(udid); + simulatorCache.set(udid, result); + return result; +} + +interface DevicectlApp { + bundleIdentifier: string; + name: string; + url: string; + builtByDeveloper: boolean; + removable: boolean; +} + +interface DevicectlProcess { + processIdentifier: number; + executable: string; +} + +interface DevicectlAppsResult { + result: { apps: DevicectlApp[] }; +} + +interface DevicectlProcessesResult { + result: { runningProcesses: DevicectlProcess[] }; +} + +function tmpJsonPath(): string { + return path.join(os.tmpdir(), `argent-devicectl-${crypto.randomUUID()}.json`); +} + +async function readDevicectlJson(tmpFile: string): Promise { + const raw = await fs.promises.readFile(tmpFile, "utf-8"); + return JSON.parse(raw) as T; +} + +/** + * Detect the currently running user app on a physical device using `devicectl`. + * Cross-references installed developer apps with running processes. + * Returns the CFBundleExecutable (process name) for use with `xctrace --attach`. + */ +export async function detectRunningAppOnDevice(udid: string): Promise { + const tmpApps = tmpJsonPath(); + const tmpProcs = tmpJsonPath(); + + try { + // 1. Get installed developer apps + try { + await execFileAsync( + "xcrun", + ["devicectl", "device", "info", "apps", "--device", udid, "--json-output", tmpApps], + { timeout: 15_000 } + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("ENOENT") || msg.includes("not found")) { + throw new Error( + "Physical device profiling requires Xcode 15+ (devicectl not found). Update Xcode or use a simulator." + ); + } + throw new Error( + `Could not query apps on device. Ensure it is unlocked, connected via USB, and trusted. (${msg})` + ); + } + + const appsData = await readDevicectlJson(tmpApps); + const developerApps = appsData.result.apps.filter((a) => a.builtByDeveloper); + + if (developerApps.length === 0) { + throw new Error( + "No developer apps installed on the device. Install and launch your app first, then retry." + ); + } + + // 2. Get running processes + try { + await execFileAsync( + "xcrun", + ["devicectl", "device", "info", "processes", "--device", udid, "--json-output", tmpProcs], + { timeout: 15_000 } + ); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error( + `Could not query running processes on device. Ensure it is unlocked and connected. (${msg})` + ); + } + + const procsData = await readDevicectlJson(tmpProcs); + const processes = procsData.result.runningProcesses; + + // 3. Cross-reference: find running developer apps + const runningApps: { + name: string; + bundleIdentifier: string; + executable: string; + }[] = []; + + for (const app of developerApps) { + // App url: "file:///private/var/containers/Bundle/Application//.app/" + // Process executable: "file:///private/var/containers/Bundle/Application//.app/" + const appUrl = app.url.endsWith("/") ? app.url : `${app.url}/`; + const matchingProc = processes.find( + (p) => p.executable.startsWith(appUrl) && !p.executable.includes(".appex/") + ); + if (matchingProc) { + // Extract executable name: last path component of the process URL + const executableName = matchingProc.executable + .replace(/^file:\/\//, "") + .split("/") + .pop()!; + runningApps.push({ + name: app.name, + bundleIdentifier: app.bundleIdentifier, + executable: executableName, + }); + } + } + + if (runningApps.length === 0) { + throw new Error( + "No running developer apps detected on the device. Launch your app first, then retry." + ); + } + + if (runningApps.length > 1) { + const appList = runningApps + .map((a) => ` - ${a.executable} (${a.bundleIdentifier}, "${a.name}")`) + .join("\n"); + throw new Error( + `Multiple developer apps are running on the device:\n${appList}\nSpecify \`app_process\` with the executable name of the app you want to profile.` + ); + } + + return runningApps[0].executable; + } finally { + await fs.promises.unlink(tmpApps).catch(() => {}); + await fs.promises.unlink(tmpProcs).catch(() => {}); + } +} + +interface DevicectlListDevice { + identifier: string; + hardwareProperties: { + udid: string; + platform: string; + marketingName?: string; + productType?: string; + }; + deviceProperties: { + name: string; + osVersionNumber: string; + bootState?: string; + }; + connectionProperties: { + transportType: string; + pairingState: string; + }; +} + +interface DevicectlListResult { + result: { devices: DevicectlListDevice[] }; +} + +export interface PhysicalDevice { + udid: string; + name: string; + model: string; + osVersion: string; + connectionType: string; +} + +export interface ListPhysicalDevicesResult { + devices: PhysicalDevice[]; + error?: string; +} + +/** + * List physical iOS devices connected to the host via `devicectl`. + * Returns `{ devices: [] }` on success with no devices, or `{ devices: [], error }` + * when devicectl itself fails (e.g. Xcode < 15, device locked/untrusted) so the + * caller can surface the reason instead of silently reporting "no devices". + */ +export async function listPhysicalDevices(): Promise { + const tmpFile = tmpJsonPath(); + try { + await execFileAsync("xcrun", ["devicectl", "list", "devices", "--json-output", tmpFile], { + timeout: 15_000, + }); + + const data = await readDevicectlJson(tmpFile); + + const devices = data.result.devices + .filter((d) => d.hardwareProperties.platform === "iOS") + .map((d) => ({ + udid: d.hardwareProperties.udid, + name: d.deviceProperties.name, + model: d.hardwareProperties.marketingName ?? d.hardwareProperties.productType ?? "Unknown", + osVersion: d.deviceProperties.osVersionNumber, + connectionType: d.connectionProperties.transportType, + })); + + return { devices }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + const hint = + msg.includes("ENOENT") || msg.includes("not found") + ? "devicectl not found — physical device listing requires Xcode 15+." + : `devicectl failed: ${msg}. Ensure the device is unlocked, connected, and trusted.`; + return { devices: [], error: hint }; + } finally { + await fs.promises.unlink(tmpFile).catch(() => {}); + } +} diff --git a/packages/tool-server/test/ios-instruments/cold-start-retry.test.ts b/packages/tool-server/test/ios-instruments/cold-start-retry.test.ts index e515934e..2ac19a34 100644 --- a/packages/tool-server/test/ios-instruments/cold-start-retry.test.ts +++ b/packages/tool-server/test/ios-instruments/cold-start-retry.test.ts @@ -50,6 +50,9 @@ describe("native-profiler-start cold-start retry", () => { vi.doMock("child_process", () => ({ spawn: spawnFn, execSync: vi.fn(() => ""), + // Present so the physical-device util's top-level promisify(execFile) + // doesn't throw on import; never called because app_process is passed. + execFile: vi.fn(), })); vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({ getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"), @@ -91,6 +94,9 @@ describe("native-profiler-start cold-start retry", () => { vi.doMock("child_process", () => ({ spawn: spawnFn, execSync: vi.fn(() => ""), + // Present so the physical-device util's top-level promisify(execFile) + // doesn't throw on import; never called because app_process is passed. + execFile: vi.fn(), })); vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({ getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"), @@ -137,6 +143,9 @@ describe("native-profiler-start cold-start retry", () => { vi.doMock("child_process", () => ({ spawn: spawnFn, execSync: vi.fn(() => ""), + // Present so the physical-device util's top-level promisify(execFile) + // doesn't throw on import; never called because app_process is passed. + execFile: vi.fn(), })); vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({ getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"), diff --git a/packages/tool-server/test/ios-instruments/stop-recovery.test.ts b/packages/tool-server/test/ios-instruments/stop-recovery.test.ts index 8b90de83..ab0519d4 100644 --- a/packages/tool-server/test/ios-instruments/stop-recovery.test.ts +++ b/packages/tool-server/test/ios-instruments/stop-recovery.test.ts @@ -236,6 +236,9 @@ describe("native-profiler-start fresh-start reset", () => { vi.doMock("child_process", () => ({ spawn: vi.fn(() => fakeChild), execSync: vi.fn(() => ""), // detectRunningApp is bypassed via app_process + // Present so the physical-device util's top-level promisify(execFile) + // doesn't throw on import; never called because app_process is passed. + execFile: vi.fn(), })); vi.doMock("../../src/utils/react-profiler/debug/dump", () => ({ getDebugDir: vi.fn(async () => "/tmp/argent-profiler-cwd"),