mod act;
mod capsule;
mod down;
mod runner;
mod script;
mod selftest_multi;
mod selftest_single;
use clap::{Parser, Subcommand};
use smix_simctl::registry::{self, RegistryError, SimRegistry};
use smix_simctl::{Appearance, LaunchResult, SimctlClient, SimctlError};
use std::path::PathBuf;
use std::process::ExitCode;
#[derive(Parser, Debug)]
#[command(
name = "smix",
about = "AI-native iOS Simulator + Android emulator automation",
version,
long_about = "\
smix — AI-native automation for iOS Simulator + Android emulator.
What smix is:
· A single tool that owns the full sim/emulator lifecycle (boot →
capsule → flow → teardown).
· A pinned-device model: every command takes an explicit DEVICE
(registry alias from `.smix/sims.json` or raw UDID). There is no
`--device booted` fallback; ambiguity is a bug, not a feature.
· A three-layer architecture: sense (tree / find / OCR / popups) and
act (tap / fill / swipe / press-key) are core flat capabilities;
decide lives in driver impls.
· Two yaml dialects:
- smix flows (read maestro-format yaml, plus smix-native extensions:
ocrText / anchorRelative / fallback / cross-platform `app:`).
Run via `smix run flow.yaml`.
- smix-native run-script (shell-friendly sequential subcommand
driver). Run via `smix run-script script.yaml`.
· AI-readable failures: every error carries visibleElements +
suggestions + code, not just a stack trace.
What smix is NOT:
· Not a build tool. smix does not build the app under test; you build,
smix installs + drives.
· Not a maestro wrapper. We read maestro's yaml format because flow
files are portable, not because we are bound to its product surface.
Quick start:
smix sim boot <DEVICE> # boot a registered sim/emulator
smix capsule up <DEVICE> # start runner (XCUITest on iOS,
# Kotlin instrumentation on Android)
smix run flow.yaml --device <DEVICE> # execute a flow
smix find --selector-id <a11y-id> # ad-hoc probe (one-shot)
smix tree --json # inspect current a11y tree
smix capsule down <DEVICE> # teardown
Subcommand categories:
Environment:
doctor, sim, runner, capsule, down, selftest
Flow execution:
run (maestro-format yaml flow)
run-script (smix-native sequential subcommand script)
Live probes (require a running runner):
tap, find, wait-for, fill, press-key, scroll, hide-keyboard,
tree, describe, system-popups
Documentation:
- Master AI guide: docs/AI_GUIDE.md
- Quickstart: docs/ai-guide/01-quickstart.md
- CLI reference: docs/ai-guide/05-cli.md
- Cookbook: docs/ai-guide/08-cookbook.md
- Errors + remedies: docs/ai-guide/07-errors.md
Sim safety hook:
Bare `xcrun simctl <verb>` is BLOCKED for mutating verbs (read-only
`simctl list` is allowed). Use typed `smix sim ...` subcommands or
`smix sim exec <DEVICE> ...` for passthrough. The hook requires an
explicit device id — there is no 'booted' / blanket selector.
"
)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand, Debug)]
enum Cmd {
Doctor,
Sim {
#[command(subcommand)]
action: SimAction,
},
Runner {
#[command(subcommand)]
action: RunnerAction,
},
Down,
Capsule {
#[command(subcommand)]
action: CapsuleAction,
},
Selftest {
#[command(subcommand)]
action: SelftestAction,
},
Tap {
selector: String,
#[arg(long)]
port: Option<u16>,
},
Find {
selector: String,
#[arg(long)]
port: Option<u16>,
},
WaitFor {
selector: String,
#[arg(long, default_value_t = 5)]
timeout: u64,
#[arg(long)]
port: Option<u16>,
},
Fill {
selector: String,
#[arg(long)]
text: String,
#[arg(long)]
port: Option<u16>,
},
PressKey {
key: String,
#[arg(long)]
port: Option<u16>,
},
Scroll {
selector: String,
#[arg(long)]
direction: String,
#[arg(long)]
port: Option<u16>,
},
HideKeyboard {
#[arg(long)]
port: Option<u16>,
},
Tree {
#[arg(long)]
json: bool,
#[arg(long)]
port: Option<u16>,
},
Describe {
#[arg(long)]
json: bool,
#[arg(long)]
port: Option<u16>,
},
SystemPopups {
#[arg(long)]
json: bool,
#[arg(long)]
port: Option<u16>,
},
RunScript {
path: PathBuf,
#[arg(long)]
port: Option<u16>,
},
#[command(long_about = "\
Run a flow file end-to-end on the connected sim/emulator.
A smix flow is a yaml document with two parts: a header (app id / logical \
key) and an ordered list of steps. smix accepts the maestro yaml format \
(40 verbs: assertVisible, tapOn, inputText, scroll, runFlow, ...) plus \
smix-native extensions (ocrText / anchorRelative / fallback selectors, \
cross-platform `app:` resolver via smix-apps.yaml).
Prerequisites:
1. Sim / emulator booted with a known device id (registry alias or UDID)
2. Runner up (`smix capsule up <DEVICE>`)
3. App installed + (optionally) launched
Common invocations:
# iOS (capsule default port 22087)
smix run --device sim-smix-02 flow.yaml
# Android (Kotlin runner on adb-forwarded :28080)
smix run --device emulator-5554 --platform android \\
--apps-config smix-apps.yaml --runner-port 28080 flow.yaml
# Skip auto-foreground (app already on screen)
smix run --device <DEVICE> --no-launch flow.yaml
Exit codes:
0 success
2 yaml parse error
3 runtime SDK failure (sim / app problem mid-flow)
4 unknown verb / direction
5 runFlow cycle / file IO
6 runner unreachable (capsule not up / wrong port)
Documentation: docs/AI_GUIDE.md
")]
Run {
#[arg(required = true, num_args = 1..)]
flows: Vec<PathBuf>,
#[arg(long, env = "SMIX_UDID")]
device: Option<String>,
#[arg(long)]
bundle_id: Option<String>,
#[arg(long, env = "SMIX_RUNNER_PORT")]
runner_port: Option<u16>,
#[arg(long, default_value_t = false)]
no_launch: bool,
#[arg(long, value_enum, env = "SMIX_PLATFORM", default_value_t = RunPlatform::Ios)]
platform: RunPlatform,
#[arg(long, env = "SMIX_APPS_CONFIG")]
apps_config: Option<PathBuf>,
#[arg(long = "env", value_parser = parse_kv_pair, action = clap::ArgAction::Append)]
env: Vec<(String, String)>,
#[arg(long = "debug-output")]
debug_output: Option<PathBuf>,
#[arg(long, default_value_t = false)]
verbose: bool,
#[arg(long, value_enum, default_value_t = RunOutputFormat::Human)]
format: RunOutputFormat,
#[arg(long, default_value_t = false)]
activate: bool,
#[arg(long, default_value_t = false)]
fail_fast: bool,
},
Migrate {
#[arg(num_args = 0..)]
paths: Vec<PathBuf>,
#[arg(long, default_value_t = false)]
in_place: bool,
},
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
enum RunOutputFormat {
Human,
Json,
}
impl RunOutputFormat {
fn to_adapter(self) -> smix_adapter_maestro::OutputFormat {
match self {
Self::Human => smix_adapter_maestro::OutputFormat::Human,
Self::Json => smix_adapter_maestro::OutputFormat::Json,
}
}
}
#[derive(clap::ValueEnum, Clone, Copy, Debug, PartialEq, Eq)]
enum RunPlatform {
Ios,
Android,
}
impl RunPlatform {
fn to_flow(self) -> smix_adapter_maestro::FlowPlatform {
match self {
Self::Ios => smix_adapter_maestro::FlowPlatform::Ios,
Self::Android => smix_adapter_maestro::FlowPlatform::Android,
}
}
}
#[derive(Subcommand, Debug)]
enum SelftestAction {
Single {
device: String,
},
Multi {
#[arg(required = true)]
targets: Vec<String>,
},
}
#[derive(Subcommand, Debug)]
enum CapsuleAction {
Up {
device: String,
#[arg(long)]
soft: bool,
#[arg(long)]
no_capture: bool,
},
Down { device: String },
}
#[derive(Subcommand, Debug)]
enum RunnerAction {
Up {
device: String,
#[arg(long)]
bundle: Option<String>,
#[arg(long = "runner-project", env = "SMIX_RUNNER_PROJECT")]
runner_project: Option<PathBuf>,
#[arg(long = "runner-port", env = "SMIX_RUNNER_PORT")]
runner_port: Option<u16>,
},
Down,
}
#[derive(Subcommand, Debug)]
enum SimAction {
List {
#[arg(long)]
json: bool,
},
Resolve { device: String },
Boot { device: String },
Shutdown { device: String },
Erase { device: String },
Screenshot { device: String, out: PathBuf },
Launch {
device: String,
bundle_id: String,
#[arg(long = "child-env", value_parser = parse_kv_pair, action = clap::ArgAction::Append)]
child_env: Vec<(String, String)>,
#[arg(last = true)]
launch_args: Vec<String>,
},
Terminate { device: String, bundle_id: String },
Install { device: String, app_path: PathBuf },
Uninstall { device: String, bundle_id: String },
Openurl { device: String, url: String },
Appearance {
device: String,
#[arg(value_parser = parse_appearance)]
mode: Appearance,
},
KeychainReset { device: String },
Locale {
device: String,
lang: String,
#[arg(long)]
reboot: bool,
},
Exec {
device: String,
verb: String,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
}
fn parse_kv_pair(s: &str) -> Result<(String, String), String> {
let (k, v) = s
.split_once('=')
.ok_or_else(|| format!("expected `KEY=VALUE`, got `{s}`"))?;
if k.is_empty() {
return Err(format!("empty KEY in `{s}`"));
}
Ok((k.to_string(), v.to_string()))
}
fn parse_appearance(s: &str) -> Result<Appearance, String> {
match s.to_ascii_lowercase().as_str() {
"light" => Ok(Appearance::Light),
"dark" => Ok(Appearance::Dark),
other => Err(format!("expected 'light' or 'dark', got {:?}", other)),
}
}
fn resolve_device(device_ref: &str) -> Result<String, CliError> {
if registry::is_udid(device_ref) {
return Ok(device_ref.to_ascii_uppercase());
}
let path = registry_path()?;
Ok(SimRegistry::load(&path)?.resolve(device_ref)?)
}
fn registry_path() -> Result<PathBuf, CliError> {
if let Some(p) = std::env::var_os("SMIX_SIMS_JSON") {
return Ok(PathBuf::from(p));
}
let cwd = std::env::current_dir()
.map_err(|e| CliError::Other(format!("cannot determine cwd: {e}")))?;
SimRegistry::discover(&cwd).ok_or_else(|| {
CliError::Other(format!(
"no .smix/sims.json was found upward from {} — pass an explicit \
UDID or set SMIX_SIMS_JSON",
cwd.display()
))
})
}
fn lookup_registered(device_ref: &str) -> Option<smix_simctl::registry::RegisteredSim> {
let path = registry_path().ok()?;
let reg = SimRegistry::load(&path).ok()?;
reg.lookup(device_ref).cloned()
}
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli).await {
Ok(code) => code,
Err(e) => {
eprintln!("error: {e}");
ExitCode::from(1)
}
}
}
async fn run(cli: Cli) -> Result<ExitCode, CliError> {
let simctl = SimctlClient::new();
match cli.cmd {
Cmd::Doctor => cmd_doctor(&simctl).await?,
Cmd::Sim { action } => match action {
SimAction::List { json } => cmd_sim_list(&simctl, json).await?,
SimAction::Resolve { device } => {
println!("{}", resolve_device(&device)?);
}
SimAction::Boot { device } => {
let udid = resolve_device(&device)?;
simctl.boot(&udid).await?;
println!("booted: {udid}");
if let Some(spec) = lookup_registered(&device)
&& let Some(desired) = spec.locale.as_ref()
{
let current = simctl.current_locale(&udid).await.ok().flatten();
if current.as_deref() == Some(desired.as_str()) {
println!("locale: {desired} ok");
} else {
eprintln!(
"locale: enforcing {desired} (current {})",
current.as_deref().unwrap_or("<unset>")
);
simctl.set_locale(&udid, desired).await?;
simctl.shutdown(&udid).await?;
simctl
.boot_and_wait(&udid, std::time::Duration::from_secs(60))
.await?;
println!("locale: {desired} enforced + sim re-booted");
}
}
}
SimAction::Shutdown { device } => {
let udid = resolve_device(&device)?;
simctl.shutdown(&udid).await?;
println!("shutdown: {udid}");
}
SimAction::Erase { device } => {
let udid = resolve_device(&device)?;
simctl.erase(&udid).await?;
println!("erased: {udid}");
}
SimAction::Screenshot { device, out } => {
let udid = resolve_device(&device)?;
let png = simctl.screenshot(&udid).await?;
if out.as_os_str() == "-" {
use std::io::Write;
std::io::stdout()
.write_all(&png)
.map_err(|e| CliError::Other(format!("write stdout: {e}")))?;
} else {
std::fs::write(&out, &png)
.map_err(|e| CliError::Other(format!("write {}: {e}", out.display())))?;
println!(
"screenshot: {udid} → {} ({} bytes)",
out.display(),
png.len()
);
}
}
SimAction::Launch {
device,
bundle_id,
child_env,
launch_args,
} => {
let udid = resolve_device(&device)?;
let pairs: Vec<(&str, &str)> = child_env
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
let LaunchResult { pid } = simctl
.launch_with_args_and_env(&udid, &bundle_id, &launch_args, &pairs)
.await?;
println!("launched: {bundle_id} on {udid} (pid {pid})");
}
SimAction::Terminate { device, bundle_id } => {
let udid = resolve_device(&device)?;
simctl.terminate(&udid, &bundle_id).await?;
println!("terminated: {bundle_id} on {udid}");
}
SimAction::Install { device, app_path } => {
let udid = resolve_device(&device)?;
simctl
.install(&udid, &app_path.display().to_string())
.await?;
println!("installed: {} on {udid}", app_path.display());
}
SimAction::Uninstall { device, bundle_id } => {
let udid = resolve_device(&device)?;
simctl.uninstall(&udid, &bundle_id).await?;
println!("uninstalled: {bundle_id} on {udid}");
}
SimAction::Openurl { device, url } => {
let udid = resolve_device(&device)?;
simctl.open_url(&udid, &url).await?;
println!("opened: {url} on {udid}");
}
SimAction::Appearance { device, mode } => {
let udid = resolve_device(&device)?;
simctl.set_appearance(&udid, mode).await?;
println!("appearance: {udid} → {}", mode.as_str());
}
SimAction::KeychainReset { device } => {
let udid = resolve_device(&device)?;
simctl.keychain_reset(&udid).await?;
println!("keychain reset: {udid}");
}
SimAction::Locale {
device,
lang,
reboot,
} => {
let udid = resolve_device(&device)?;
let current = simctl.current_locale(&udid).await.ok().flatten();
if current.as_deref() == Some(lang.as_str()) {
println!("locale already: {lang}");
return Ok(ExitCode::SUCCESS);
}
simctl.set_locale(&udid, &lang).await?;
if reboot {
println!("locale: written {lang} — rebooting sim to apply");
simctl.shutdown(&udid).await?;
simctl.boot(&udid).await?;
println!("locale: {lang} enforced (sim rebooted)");
} else {
println!(
"locale: written {lang}\n\
note: running apps cache locale at process-start — \
restart the target app, or re-run with `--reboot` to \
cycle the sim so subsequent launches see the new locale."
);
}
}
SimAction::Exec { device, verb, args } => {
return cmd_sim_exec(&device, &verb, &args).await;
}
},
Cmd::Runner { action } => {
let root = smix_workspace_root()?;
match action {
RunnerAction::Up {
device,
bundle,
runner_project,
runner_port: port_flag,
} => {
let sims_port = lookup_registered(&device)
.and_then(|s| s.runner_port);
let port = port_flag.or(sims_port).unwrap_or(22087);
let udid = resolve_device(&device)?;
runner::up(
&root,
&udid,
port,
bundle.as_deref(),
false,
runner_project.as_deref(),
)
.map_err(CliError::Other)?;
}
RunnerAction::Down => {
let port = runner_port();
runner::down(&root, port).map_err(CliError::Other)?;
}
}
}
Cmd::Down => {
let root = smix_workspace_root()?;
down::run(&root, runner_port())
.await
.map_err(CliError::Other)?;
}
Cmd::Selftest { action } => match action {
SelftestAction::Single { device } => {
let udid = resolve_device(&device)?;
let port = runner_port();
return Ok(selftest_single::run(udid, port).await);
}
SelftestAction::Multi { targets } => {
return Ok(selftest_multi::run(targets).await);
}
},
Cmd::Capsule { action } => {
let root = smix_workspace_root()?;
let port = runner_port();
let capture_endpoint = std::env::var("SMIX_CAPTURE_ENDPOINT")
.unwrap_or_else(|_| "http://127.0.0.1:8787".to_string());
match action {
CapsuleAction::Up {
device,
soft,
no_capture,
} => {
let udid = resolve_device(&device)?;
capsule::up(capsule::UpOptions {
root: &root,
udid: &udid,
runner_port: port,
capture_endpoint: &capture_endpoint,
bundle: Some("dev.smix.SelftestFixture"),
soft,
no_capture,
})
.await
.map_err(CliError::Other)?;
}
CapsuleAction::Down { device } => {
let udid = resolve_device(&device)?;
capsule::down(&root, &udid).await.map_err(CliError::Other)?;
}
}
}
Cmd::Tap { selector, port } => {
let p = port.unwrap_or_else(act::runner_port_from_env);
act::cmd_tap(selector, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::Find { selector, port } => {
let p = port.unwrap_or_else(act::runner_port_from_env);
act::cmd_find(selector, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::WaitFor {
selector,
timeout,
port,
} => {
let p = port.unwrap_or_else(act::runner_port_from_env);
act::cmd_wait_for(selector, timeout, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::Fill {
selector,
text,
port,
} => {
let p = port.unwrap_or_else(act::runner_port_from_env);
act::cmd_fill(selector, text, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::PressKey { key, port } => {
let p = port.unwrap_or_else(act::runner_port_from_env);
act::cmd_press_key(key, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::Scroll {
selector,
direction,
port,
} => {
let p = port.unwrap_or_else(act::runner_port_from_env);
act::cmd_scroll(selector, direction, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::HideKeyboard { port } => {
let p = port.unwrap_or_else(act::runner_port_from_env);
act::cmd_hide_keyboard(p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::Tree { json, port } => {
let p = port.unwrap_or_else(act::runner_port_from_env);
act::cmd_tree(json, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::Describe { json, port } => {
let p = port.unwrap_or_else(act::runner_port_from_env);
act::cmd_describe(json, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::SystemPopups { json, port } => {
let p = port.unwrap_or_else(act::runner_port_from_env);
act::cmd_system_popups(json, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::RunScript { path, port } => {
let p = port.unwrap_or_else(act::runner_port_from_env);
script::cmd_run_script(&path, p)
.await
.map_err(|e| CliError::Other(e.to_string()))?;
}
Cmd::Run {
flows,
device,
bundle_id,
runner_port,
no_launch,
platform,
apps_config,
env,
debug_output,
verbose,
format,
activate,
fail_fast,
} => {
if verbose && std::env::var_os("SMIX_LOG").is_none() {
unsafe { std::env::set_var("SMIX_LOG", "debug") };
}
let udid = device
.as_deref()
.map(|d| resolve_device(d).unwrap_or_else(|_| d.to_string()));
let bundle = bundle_id.unwrap_or_else(|| "com.focusai.app.mobile".to_string());
let port = runner_port.unwrap_or(22087);
let plat = platform.to_flow();
let out_fmt = format.to_adapter();
let multi_flow = flows.len() > 1;
let mut worst_exit: u8 = 0;
for (idx, flow_path) in flows.iter().enumerate() {
let per_flow_debug = debug_output.as_ref().map(|d| {
if multi_flow {
let stem = flow_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("flow")
.to_string();
d.join(stem)
} else {
d.clone()
}
});
if multi_flow {
eprintln!(
"smix run: [{}/{}] {}",
idx + 1,
flows.len(),
flow_path.display()
);
}
let exit = smix_adapter_maestro::run_flow(smix_adapter_maestro::FlowArgs {
flow: flow_path.clone(),
udid: udid.clone(),
bundle_id: bundle.clone(),
runner_port: port,
no_launch,
platform: plat,
apps_config: apps_config.clone(),
env_vars: env.clone(),
debug_output: per_flow_debug,
verbose,
format: out_fmt,
auto_activate: activate,
})
.await;
let code = exit_code_to_u8(exit);
worst_exit = worst_exit.max(code);
if fail_fast && code != 0 {
eprintln!(
"smix run: --fail-fast — aborting batch on first failure (exit={code})"
);
break;
}
}
return Ok(ExitCode::from(worst_exit));
}
Cmd::Migrate { paths, in_place } => {
return cmd_migrate(paths, in_place).await;
}
}
Ok(ExitCode::SUCCESS)
}
async fn cmd_migrate(paths: Vec<PathBuf>, in_place: bool) -> Result<ExitCode, CliError> {
use std::io::{Read, Write};
let migrator = smix_migrate::Migrator::default();
if paths.is_empty() {
if in_place {
eprintln!("smix migrate: --in-place requires at least one path");
return Ok(ExitCode::from(2));
}
let mut buf = String::new();
if let Err(e) = std::io::stdin().read_to_string(&mut buf) {
eprintln!("smix migrate: failed to read stdin: {e}");
return Ok(ExitCode::from(2));
}
match migrator.migrate(&buf) {
Ok((out, report)) => {
warn_unknown(&report.unknown_verbs, "<stdin>");
print!("{out}");
std::io::stdout().flush().ok();
Ok(ExitCode::SUCCESS)
}
Err(e) => {
eprintln!("smix migrate: <stdin>: {e}");
Ok(ExitCode::from(2))
}
}
} else {
let mut worst: u8 = 0;
for path in &paths {
let input = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) => {
eprintln!("smix migrate: read {}: {e}", path.display());
worst = worst.max(2);
continue;
}
};
match migrator.migrate(&input) {
Ok((out, report)) => {
warn_unknown(&report.unknown_verbs, &path.display().to_string());
if in_place {
let tmp = path.with_extension("smix-migrate.tmp");
if let Err(e) = std::fs::write(&tmp, &out) {
eprintln!(
"smix migrate: write tmp {}: {e}",
tmp.display()
);
worst = worst.max(3);
continue;
}
if let Err(e) = std::fs::rename(&tmp, path) {
eprintln!("smix migrate: rename {}: {e}", tmp.display());
worst = worst.max(3);
continue;
}
if paths.len() > 1 {
eprintln!(
"smix migrate: rewrote {} ({} renames)",
path.display(),
report.renamed.len()
);
}
} else {
print!("{out}");
std::io::stdout().flush().ok();
}
}
Err(e) => {
eprintln!("smix migrate: {}: {e}", path.display());
worst = worst.max(2);
}
}
}
Ok(ExitCode::from(worst))
}
}
fn warn_unknown(unknown: &[String], src: &str) {
if !unknown.is_empty() {
eprintln!(
"smix migrate: WARN {}: unknown verb(s) preserved verbatim: {}",
src,
unknown.join(", ")
);
}
}
fn runner_port() -> u16 {
std::env::var("SMIX_RUNNER_PORT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(22087)
}
fn exit_code_to_u8(code: std::process::ExitCode) -> u8 {
let dbg = format!("{code:?}");
dbg.rsplit_once('(')
.and_then(|(_, tail)| tail.trim_end_matches("))").parse::<u8>().ok())
.unwrap_or(0)
}
fn smix_workspace_root() -> Result<PathBuf, CliError> {
if let Some(p) = std::env::var_os("SMIX_WORKSPACE") {
return Ok(PathBuf::from(p));
}
let cwd = std::env::current_dir()
.map_err(|e| CliError::Other(format!("cannot determine cwd: {e}")))?;
runner::workspace_root(&cwd).ok_or_else(|| {
CliError::Other(format!(
"no .smix/ workspace found upward from {} — cd into the smix \
workspace or set SMIX_WORKSPACE",
cwd.display()
))
})
}
fn exec_argv(verb: &str, udid: &str, args: &[String]) -> Vec<String> {
let mut argv = vec![verb.to_string()];
if args.iter().any(|a| a == "{udid}") {
argv.extend(args.iter().map(|a| {
if a == "{udid}" {
udid.to_string()
} else {
a.clone()
}
}));
} else {
argv.push(udid.to_string());
argv.extend(args.iter().cloned());
}
argv
}
async fn cmd_sim_exec(device: &str, verb: &str, args: &[String]) -> Result<ExitCode, CliError> {
let udid = resolve_device(device)?;
let argv = exec_argv(verb, &udid, args);
use std::os::unix::process::CommandExt;
let err = std::process::Command::new("xcrun")
.arg("simctl")
.args(&argv)
.exec();
Err(CliError::Other(format!("exec xcrun simctl: {err}")))
}
async fn cmd_doctor(simctl: &SimctlClient) -> Result<(), CliError> {
println!("smix doctor");
println!("============");
let runtimes = simctl.list_runtimes().await.map_err(|e| {
CliError::Other(format!(
"xcrun simctl unavailable — check Xcode command-line tools install: {e}"
))
})?;
let avail = runtimes.iter().filter(|r| r.is_available).count();
println!(
"✓ xcrun simctl reachable; {} runtimes detected ({} available)",
runtimes.len(),
avail
);
let devices = simctl.list_devices().await?;
let avail_dev = devices.iter().filter(|d| d.is_available).count();
let booted = devices.iter().filter(|d| d.state == "Booted").count();
println!(
"✓ {} devices total ({} available, {} booted)",
devices.len(),
avail_dev,
booted
);
println!("ℹ smix supports iOS Simulator only — real-device automation is");
println!(" explicitly out of scope per CLAUDE.md §9.");
Ok(())
}
async fn cmd_sim_list(simctl: &SimctlClient, json: bool) -> Result<(), CliError> {
let devices = simctl.list_devices().await?;
if json {
let out = serde_json::to_string_pretty(&devices)
.map_err(|e| CliError::Other(format!("serialize: {e}")))?;
println!("{}", out);
return Ok(());
}
println!("{:<40} {:<28} {:<10} RUNTIME", "UDID", "NAME", "STATE");
for d in &devices {
let runtime_short = d
.runtime_identifier
.rsplit('.')
.next()
.unwrap_or(d.runtime_identifier.as_str());
println!(
"{:<40} {:<28} {:<10} {runtime_short}",
d.udid, d.name, d.state
);
}
Ok(())
}
#[derive(Debug)]
enum CliError {
Simctl(SimctlError),
Registry(RegistryError),
Other(String),
}
impl From<SimctlError> for CliError {
fn from(e: SimctlError) -> Self {
CliError::Simctl(e)
}
}
impl From<RegistryError> for CliError {
fn from(e: RegistryError) -> Self {
CliError::Registry(e)
}
}
impl std::fmt::Display for CliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CliError::Simctl(e) => write!(f, "{e}"),
CliError::Registry(e) => write!(f, "{e}"),
CliError::Other(s) => write!(f, "{s}"),
}
}
}
impl std::error::Error for CliError {}
#[cfg(test)]
mod tests {
use super::*;
const UDID: &str = "5D087114-ECB3-443C-8DDB-40EEF9CFB90C";
#[test]
fn exec_parses_hyphen_args_verbatim() {
let cli = Cli::try_parse_from([
"smix",
"sim",
"exec",
"02",
"status_bar",
"override",
"--time",
"9:41",
])
.unwrap();
let Cmd::Sim {
action: SimAction::Exec { device, verb, args },
} = cli.cmd
else {
panic!("expected sim exec");
};
assert_eq!(device, "02");
assert_eq!(verb, "status_bar");
assert_eq!(args, ["override", "--time", "9:41"]);
}
#[test]
fn exec_argv_injects_udid_after_verb() {
let argv = exec_argv(
"push",
UDID,
&["com.example.app".into(), "payload.json".into()],
);
assert_eq!(argv, ["push", UDID, "com.example.app", "payload.json"]);
}
#[test]
fn exec_argv_substitutes_placeholder_instead_of_injecting() {
let argv = exec_argv(
"spawn",
UDID,
&[
"-s".into(),
"{udid}".into(),
"launchctl".into(),
"list".into(),
],
);
assert_eq!(argv, ["spawn", "-s", UDID, "launchctl", "list"]);
}
#[test]
fn sim_launch_parses_repeated_child_env_flags() {
let cli = Cli::try_parse_from([
"smix",
"sim",
"launch",
"02",
"com.example.app",
"--child-env",
"INSIGHT_PERF_RECEIVER_URL=http://127.0.0.1:9999",
"--child-env",
"LAUNCH_FORCE_PUSH=true",
])
.expect("parse sim launch with --child-env x2");
let Cmd::Sim {
action:
SimAction::Launch {
device,
bundle_id,
child_env,
launch_args,
},
} = cli.cmd
else {
panic!("expected sim launch");
};
assert_eq!(device, "02");
assert_eq!(bundle_id, "com.example.app");
assert_eq!(
child_env,
vec![
(
"INSIGHT_PERF_RECEIVER_URL".to_string(),
"http://127.0.0.1:9999".to_string(),
),
("LAUNCH_FORCE_PUSH".to_string(), "true".to_string()),
]
);
assert!(launch_args.is_empty());
}
#[test]
fn sim_launch_parses_trailing_launch_args_after_double_dash() {
let cli = Cli::try_parse_from([
"smix",
"sim",
"launch",
"02",
"com.example.app",
"--child-env",
"K=V",
"--",
"-uitestV2Root",
"YES",
])
.expect("parse trailing args");
let Cmd::Sim {
action:
SimAction::Launch {
launch_args,
child_env,
..
},
} = cli.cmd
else {
panic!("expected sim launch");
};
assert_eq!(launch_args, vec!["-uitestV2Root", "YES"]);
assert_eq!(child_env.len(), 1);
}
#[test]
fn sim_launch_without_child_env_yields_empty_vec() {
let cli = Cli::try_parse_from(["smix", "sim", "launch", "02", "com.example.app"])
.expect("parse bare launch");
let Cmd::Sim {
action: SimAction::Launch { child_env, .. },
} = cli.cmd
else {
panic!("expected sim launch");
};
assert!(child_env.is_empty());
}
#[test]
fn sim_launch_rejects_child_env_without_equals() {
let err = Cli::try_parse_from([
"smix",
"sim",
"launch",
"02",
"com.example.app",
"--child-env",
"NOEQUALS",
])
.expect_err("must reject KEY without =");
let msg = format!("{err}");
assert!(
msg.contains("KEY=VALUE") || msg.contains("="),
"expected error to hint KEY=VALUE shape; got: {msg}"
);
}
#[test]
fn sim_launch_rejects_child_env_with_empty_key() {
let err = Cli::try_parse_from([
"smix",
"sim",
"launch",
"02",
"com.example.app",
"--child-env",
"=just_value",
])
.expect_err("must reject empty KEY");
let msg = format!("{err}");
assert!(msg.contains("empty KEY"), "msg: {msg}");
}
#[test]
fn parse_kv_pair_allows_equals_in_value() {
let (k, v) = super::parse_kv_pair("URL=http://h:9999/p=q&r=s").expect("parse");
assert_eq!(k, "URL");
assert_eq!(v, "http://h:9999/p=q&r=s");
}
#[test]
fn every_device_subcommand_accepts_alias_ref() {
for argv in [
vec!["smix", "sim", "boot", "02"],
vec!["smix", "sim", "shutdown", "sim-smix-02"],
vec!["smix", "sim", "erase", "02"],
vec!["smix", "sim", "screenshot", "02", "/tmp/x.png"],
vec!["smix", "sim", "launch", "02", "com.example.app"],
vec!["smix", "sim", "terminate", "02", "com.example.app"],
vec!["smix", "sim", "install", "02", "/tmp/App.app"],
vec!["smix", "sim", "uninstall", "02", "com.example.app"],
vec!["smix", "sim", "openurl", "02", "https://example.com"],
vec!["smix", "sim", "appearance", "02", "dark"],
vec!["smix", "sim", "keychain-reset", "02"],
vec!["smix", "sim", "resolve", "02"],
] {
Cli::try_parse_from(&argv).unwrap_or_else(|e| panic!("{argv:?} failed to parse: {e}"));
}
}
}