diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md index d1d0343f..4d3aeb16 100644 --- a/.agents/AGENTS.md +++ b/.agents/AGENTS.md @@ -101,7 +101,7 @@ make patch | make minor | make major | make custom VERSION=0.x.y It commits `Release ` and tags `v` locally; pushing is manual. Crate publishing is separate and manual (`cargo workspaces publish`, see `docs/development.md`). The per-target release workflows (`release-*.yml`) build the distributable artifacts. -`server.json` (the MCP Registry listing) is version-synced by the same Makefile step, and `release-nuget.yml` triggers `release-mcp-registry.yml` once its publish job succeeds — the listing needs both npm and NuGet live at that version, and the registry validates its metadata against the real packages. See `docs/mcp-registry.md`. +`server.json` and `server-xcrs.json` (the MCP Registry listings) are version-synced by the same Makefile step, and `release-nuget.yml` triggers `release-mcp-registry.yml` once its publish job succeeds — the listings need the npm, NuGet, and Cargo packages live at their recorded versions, and the registry validates their metadata against the real packages. See `docs/mcp-registry.md`. --- diff --git a/.github/workflows/release-mcp-registry.yml b/.github/workflows/release-mcp-registry.yml index 9780d038..68d38d63 100644 --- a/.github/workflows/release-mcp-registry.yml +++ b/.github/workflows/release-mcp-registry.yml @@ -15,7 +15,7 @@ permissions: jobs: publish: - name: Publish server.json to the MCP Registry + name: Publish MCP servers to the MCP Registry runs-on: ubuntu-latest steps: - name: Checkout @@ -44,14 +44,28 @@ jobs: # NuGet proves ownership through an `mcp-name:` marker in the package README. grep -q "mcp-name: ${server_name}" nuget/smbcloud-cli/README.md - - name: Set the version in server.json + xcrs_server_name="$(jq -r '.name' server-xcrs.json)" + grep -q "mcp-name: ${xcrs_server_name}" crates/xcrs/README.md + + - name: Set release versions in server metadata shell: bash run: | jq --arg v "${RELEASE_VERSION}" \ '.version = $v | .packages |= map(.version = $v)' \ server.json > server.tmp mv server.tmp server.json + + xcrs_version="$(sed -n 's/^version = "\(.*\)"/\1/p' crates/xcrs/Cargo.toml | head -n 1)" + if [ "${xcrs_version}" != "${RELEASE_VERSION}" ]; then + echo "xcrs version ${xcrs_version} does not match release version ${RELEASE_VERSION}." >&2 + exit 1 + fi + jq --arg v "${xcrs_version}" \ + '.version = $v | .packages |= map(.version = $v)' \ + server-xcrs.json > server-xcrs.tmp + mv server-xcrs.tmp server-xcrs.json cat server.json + cat server-xcrs.json - name: Verify the published packages carry the ownership marker shell: bash @@ -109,6 +123,29 @@ jobs: fi rm package.nupkg + xcrs_server_name="$(jq -r '.name' server-xcrs.json)" + xcrs_version="$(jq -r '.version' server-xcrs.json)" + for attempt in $(seq 1 20); do + if curl -fsSL "https://crates.io/api/v1/crates/xcrs/${xcrs_version}" >/dev/null; then + echo "xcrs ${xcrs_version} is indexed on crates.io." + break + fi + if [ "${attempt}" -eq 20 ]; then + echo "xcrs ${xcrs_version} is still not indexed on crates.io after 10 minutes." >&2 + exit 1 + fi + echo "Waiting for crates.io to index xcrs ${xcrs_version} (attempt ${attempt}/20)..." + sleep 30 + done + + curl -fsSL -o xcrs.crate \ + "https://crates.io/api/v1/crates/xcrs/${xcrs_version}/download" + if ! tar -xOf xcrs.crate "xcrs-${xcrs_version}/README.md" | grep -q "mcp-name: ${xcrs_server_name}"; then + echo "xcrs ${xcrs_version} README is missing the 'mcp-name: ${xcrs_server_name}' marker." >&2 + exit 1 + fi + rm xcrs.crate + - name: Install mcp-publisher shell: bash run: | @@ -120,10 +157,15 @@ jobs: - name: Publish shell: bash - run: ./mcp-publisher publish + run: | + ./mcp-publisher publish + cp server-xcrs.json server.json + ./mcp-publisher publish - name: Verify the server is listed shell: bash run: | server_name="$(jq -r '.name' server.json)" curl -fsSL "https://registry.modelcontextprotocol.io/v0.1/servers?search=${server_name}" | jq . + xcrs_server_name="$(jq -r '.name' server-xcrs.json)" + curl -fsSL "https://registry.modelcontextprotocol.io/v0.1/servers?search=${xcrs_server_name}" | jq . diff --git a/Cargo.lock b/Cargo.lock index 17993fd9..bfbf51cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2628,6 +2628,7 @@ dependencies = [ "tracing-bunyan-formatter", "tracing-subscriber", "url-builder", + "xcrs", ] [[package]] @@ -3994,6 +3995,22 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xcrs" +version = "0.4.13" +dependencies = [ + "anyhow", + "base64", + "clap 4.6.2", + "reqwest", + "rmcp", + "schemars", + "serde", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index a1e08eeb..4662510b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,11 @@ [workspace] resolver = "2" -members = ["crates/cli", "crates/smbcloud-*"] +members = ["crates/cli", "crates/smbcloud-*", "crates/xcrs"] [workspace.dependencies] anyhow = "1.0.58" async-trait = "0.1.51" +base64 = "0.22" chrono = "0.4" clap = "4.1.6" clap_mangen = "0.2.10" @@ -28,17 +29,17 @@ schemars = "1" serde = "1" serde_json = "1.0.82" serde_repr = "0.1" -smbcloud-auth = { version = "0.4.9", path = "crates/smbcloud-auth" } -smbcloud-auth-sdk = { version = "0.4.9", path = "crates/smbcloud-auth-sdk" } -smbcloud-deploy = { version = "0.4.9", path = "crates/smbcloud-deploy" } -smbcloud-email-sdk = { version = "0.4.9", path = "crates/smbcloud-email-sdk" } -smbcloud-gresiq-sdk = { version = "0.4.9", path = "crates/smbcloud-gresiq-sdk" } -smbcloud-mail = { version = "0.4.9", path = "crates/smbcloud-mail" } -smbcloud-model = { version = "0.4.9", path = "crates/smbcloud-model" } -smbcloud-network = { version = "0.4.9", path = "crates/smbcloud-network" } -smbcloud-networking = { version = "0.4.9", path = "crates/smbcloud-networking" } -smbcloud-networking-project = { version = "0.4.9", path = "crates/smbcloud-networking-project" } -smbcloud-utils = { version = "0.4.9", path = "crates/smbcloud-utils" } +smbcloud-auth = { version = "0.4.13", path = "crates/smbcloud-auth" } +smbcloud-auth-sdk = { version = "0.4.13", path = "crates/smbcloud-auth-sdk" } +smbcloud-deploy = { version = "0.4.13", path = "crates/smbcloud-deploy" } +smbcloud-email-sdk = { version = "0.4.13", path = "crates/smbcloud-email-sdk" } +smbcloud-gresiq-sdk = { version = "0.4.13", path = "crates/smbcloud-gresiq-sdk" } +smbcloud-mail = { version = "0.4.13", path = "crates/smbcloud-mail" } +smbcloud-model = { version = "0.4.13", path = "crates/smbcloud-model" } +smbcloud-network = { version = "0.4.13", path = "crates/smbcloud-network" } +smbcloud-networking = { version = "0.4.13", path = "crates/smbcloud-networking" } +smbcloud-networking-project = { version = "0.4.13", path = "crates/smbcloud-networking-project" } +smbcloud-utils = { version = "0.4.13", path = "crates/smbcloud-utils" } spinners = "4.1.0" strum = "0.27" strum_macros = "0.27" @@ -55,3 +56,4 @@ tsync = "2" uniffi = "0.31" url-builder = "0.1.1" wasm-bindgen = "0.2" +xcrs = { version = "0.4.13", path = "crates/xcrs" } diff --git a/Makefile b/Makefile index 402f94e2..1befe5bc 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,11 @@ -.PHONY: help release patch minor major custom sync-release-metadata regenerate-release-lockfiles +.PHONY: help release patch minor major custom sync-release-metadata regenerate-release-lockfiles check-release-versions help: @echo "Release commands:" @echo " make patch" @echo " make minor" @echo " make major" - @echo " make custom VERSION=0.3.40" + @echo " make custom VERSION=0.4.14" release: @test -n "$(BUMP)" || (echo "BUMP is required" && exit 1) @@ -19,6 +19,7 @@ release: fi @$(MAKE) sync-release-metadata @$(MAKE) regenerate-release-lockfiles + @$(MAKE) check-release-versions @release_version=$$(sed -n 's/^version = "\(.*\)"/\1/p' crates/cli/Cargo.toml | head -n 1); \ if [ -z "$$release_version" ]; then echo "Unable to determine release version from crates/cli/Cargo.toml"; exit 1; fi; \ git add -A; \ @@ -32,9 +33,14 @@ sync-release-metadata: regenerate-release-lockfiles: @cargo generate-lockfile --manifest-path sdk/gems/auth/Cargo.toml + @cargo generate-lockfile --manifest-path sdk/gems/email/Cargo.toml @cargo generate-lockfile --manifest-path sdk/gems/model/Cargo.toml - @bundle lock --gemfile sdk/gems/auth/Gemfile - @bundle lock --gemfile sdk/gems/model/Gemfile + @BUNDLE_GEMFILE=sdk/gems/auth/Gemfile bundle lock + @BUNDLE_GEMFILE=sdk/gems/email/Gemfile bundle lock + @BUNDLE_GEMFILE=sdk/gems/model/Gemfile bundle lock + +check-release-versions: + @node ./scripts/check-release-versions.mjs patch: @$(MAKE) release BUMP=patch diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index efb33125..34afd0e3 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -64,6 +64,7 @@ tracing = { workspace = true, features = ["log"] } tracing-bunyan-formatter = { workspace = true } tracing-subscriber = { workspace = true, features = ["registry", "env-filter"] } url-builder = { workspace = true } +xcrs = { workspace = true } [target.'cfg(not(windows))'.dependencies] openssl = { workspace = true } diff --git a/crates/cli/src/cli/mod.rs b/crates/cli/src/cli/mod.rs index 06825713..a2bdecf0 100644 --- a/crates/cli/src/cli/mod.rs +++ b/crates/cli/src/cli/mod.rs @@ -3,6 +3,7 @@ use { clap::{Parser, Subcommand}, smbcloud_network::environment::Environment, spinners::Spinner, + std::path::PathBuf, }; pub struct CommandResult { @@ -96,9 +97,59 @@ pub enum Commands { #[clap(subcommand)] command: cloud_auth::cli::Commands, }, + #[clap(about = "Run and control XCRS ControlKit on Apple devices.")] + ControlKit { + #[clap(subcommand)] + command: ControlKitCommands, + }, #[clap( about = "Migrate local .smb/config.toml deploy fields to the smbCloud server.", display_order = 4 )] Migrate {}, } + +#[derive(Subcommand)] +pub enum ControlKitCommands { + #[clap(about = "Build a signed ControlKit XCTest runner for a physical device.")] + Build { + #[arg(long)] + project_path: PathBuf, + #[arg(long, default_value = "ControlKit")] + scheme: String, + #[arg(long, default_value = "Release")] + configuration: String, + #[arg(long)] + device_udid: String, + #[arg(long)] + derived_data_path: PathBuf, + }, + #[clap(about = "Start a built ControlKit XCTest runner on a physical device.")] + Start { + #[arg(long)] + device_udid: String, + #[arg(long)] + xctestrun_path: PathBuf, + #[arg(long, default_value = "::")] + listen_host: String, + #[arg(long, default_value_t = 12004)] + listen_port: u16, + #[arg(long, default_value_t = 120)] + timeout_seconds: u64, + #[arg(long)] + log_path: Option, + }, + #[clap(about = "Call a running ControlKit JSON-RPC method.")] + Call { + #[arg(long, required_unless_present = "host", conflicts_with = "host")] + device_udid: Option, + #[arg(long, conflicts_with = "device_udid")] + host: Option, + #[arg(long, default_value_t = 12004)] + port: u16, + #[arg(long)] + method: String, + #[arg(long, default_value = "{}")] + params: String, + }, +} diff --git a/crates/cli/src/controlkit.rs b/crates/cli/src/controlkit.rs new file mode 100644 index 00000000..9af6f25f --- /dev/null +++ b/crates/cli/src/controlkit.rs @@ -0,0 +1,129 @@ +use { + crate::{ + cli::{CommandResult, ControlKitCommands}, + ui::{succeed_message, succeed_symbol}, + }, + anyhow::{anyhow, Context, Result}, + spinners::{Spinner, Spinners}, + std::{path::PathBuf, time::Duration}, + xcrs::{ + BuildControlKitDeviceRunner, ControlKit, StartControlKitDeviceRunner, XcodeCommandLineTools, + }, +}; + +pub async fn process_controlkit(command: ControlKitCommands) -> Result { + match command { + ControlKitCommands::Build { + project_path, + scheme, + configuration, + device_udid, + derived_data_path, + } => { + let test_run_path = XcodeCommandLineTools::new().build_controlkit_device_runner( + &BuildControlKitDeviceRunner { + project_path, + scheme, + configuration, + device_udid, + derived_data_path, + }, + )?; + Ok(done_result(&format!( + "Built ControlKit runner: {}", + test_run_path.display() + ))) + } + ControlKitCommands::Start { + device_udid, + xctestrun_path, + listen_host, + listen_port, + timeout_seconds, + log_path, + } => { + let tools = XcodeCommandLineTools::new(); + let log_path = log_path.unwrap_or_else(|| { + std::env::temp_dir().join(format!("controlkit-{device_udid}.log")) + }); + let mut runner = + tools.start_controlkit_device_runner(&StartControlKitDeviceRunner { + device_udid, + xctestrun_path, + listen_host, + listen_port, + log_path: log_path.clone(), + })?; + let controlkit = ControlKit::with_host(&runner.tunnel_host, runner.listen_port); + let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_seconds); + + loop { + if controlkit.health().await.is_ok() { + break; + } + if let Some(status) = runner.process.try_wait()? { + return Err(runner_failure(status.to_string(), &runner.log_path)); + } + if tokio::time::Instant::now() >= deadline { + return Err(runner_failure( + format!("did not become healthy within {timeout_seconds} seconds"), + &runner.log_path, + )); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + + Ok(done_result(&format!( + "ControlKit is running at http://[{}]:{} (PID {}, log {})", + runner.tunnel_host, + runner.listen_port, + runner.process.id(), + runner.log_path.display() + ))) + } + ControlKitCommands::Call { + device_udid, + host, + port, + method, + params, + } => { + let host = match (host, device_udid) { + (Some(host), None) => host, + (None, Some(device_udid)) => { + XcodeCommandLineTools::new().device_tunnel_address(&device_udid)? + } + _ => { + return Err(anyhow!( + "provide either --host or --device-udid for the ControlKit runner" + )); + } + }; + let params = serde_json::from_str(¶ms) + .with_context(|| format!("invalid JSON for --params: {params}"))?; + let result = ControlKit::with_host(&host, port) + .call(&method, params) + .await?; + Ok(done_result(&serde_json::to_string_pretty(&result)?)) + } + } +} + +fn runner_failure(reason: String, log_path: &PathBuf) -> anyhow::Error { + let log = std::fs::read_to_string(log_path).unwrap_or_default(); + let tail = log.lines().rev().take(20).collect::>(); + let tail = tail.into_iter().rev().collect::>().join("\n"); + if tail.is_empty() { + anyhow!("ControlKit runner {reason}; see {}", log_path.display()) + } else { + anyhow!("ControlKit runner {reason}:\n{tail}") + } +} + +fn done_result(message: &str) -> CommandResult { + CommandResult { + spinner: Spinner::new(Spinners::SimpleDotsScrolling, succeed_message("Done")), + symbol: succeed_symbol(), + msg: succeed_message(message), + } +} diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 57ba2e09..8ae2637e 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -5,6 +5,7 @@ pub mod ci; pub mod cli; #[path = "cloud-auth/mod.rs"] pub mod cloud_auth; +pub mod controlkit; #[path = "cloud-deploy/mod.rs"] pub mod deploy; pub mod interface; diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index c6526c21..abf20e3c 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -7,6 +7,7 @@ use { clear_smb_token, cli::{Cli, CommandResult, Commands}, cloud_auth::process::process_cloud_auth, + controlkit::process_controlkit, deploy::{process_deploy::process_deploy, process_migrate::process_migrate}, mail::process::process_mail, project::{crud_create::process_project_init, process::process_project}, @@ -184,6 +185,7 @@ async fn run(cli: Cli) -> Result { | Some(Commands::Tenant { .. }) | Some(Commands::Migrate {}) | None => true, + Some(Commands::ControlKit { .. }) => false, Some(Commands::Init {}) => true, }; @@ -205,6 +207,7 @@ async fn run(cli: Cli) -> Result { Some(Commands::Auth { command }) => process_cloud_auth(cli.environment, command).await, Some(Commands::Project { command }) => process_project(cli.environment, command).await, Some(Commands::Tenant { command }) => process_tenant(cli.environment, command).await, + Some(Commands::ControlKit { command }) => process_controlkit(command).await, Some(Commands::Migrate {}) => process_migrate(cli.environment).await, None => process_deploy(cli.environment, None).await, } diff --git a/crates/cli/src/mcp.rs b/crates/cli/src/mcp.rs index 45ad51f5..f19ec15a 100644 --- a/crates/cli/src/mcp.rs +++ b/crates/cli/src/mcp.rs @@ -66,6 +66,9 @@ pub struct SmbMcpServer { environment: Environment, } +#[path = "mcp_xcrs.rs"] +mod mcp_xcrs; + impl SmbMcpServer { pub fn new(environment: Environment) -> Self { Self { environment } @@ -371,7 +374,7 @@ struct AuthAppDeleteArgs { id: String, } -#[tool_router] +#[tool_router(router = cloud_tool_router, vis = "pub(crate)")] impl SmbMcpServer { #[tool(description = "Get the authenticated smbCloud user's account info. \ Requires a prior `smb login`; returns the user as JSON.")] @@ -975,7 +978,9 @@ impl SmbMcpServer { } } -#[tool_handler] +#[tool_handler( + router = (Self::cloud_tool_router() + Self::xcrs_tool_router()) +)] impl ServerHandler for SmbMcpServer { fn get_info(&self) -> ServerInfo { // `Implementation` is `#[non_exhaustive]`, so start from the build-env @@ -990,7 +995,8 @@ impl ServerHandler for SmbMcpServer { "smbCloud CLI exposed as MCP tools. Authentication uses the token stored by \ `smb login`; tools run non-interactively. `tenant_use` / `project_use` select \ the tenant/project context other tools default to, shared with the CLI's own \ - `smb tenant use` / `smb project use`.", + `smb tenant use` / `smb project use`. ControlKit tools cover iOS, tvOS, \ + visionOS, watchOS, and macOS runners.", ) } } diff --git a/crates/cli/src/mcp_xcrs.rs b/crates/cli/src/mcp_xcrs.rs new file mode 100644 index 00000000..2df20e93 --- /dev/null +++ b/crates/cli/src/mcp_xcrs.rs @@ -0,0 +1,26 @@ +use super::SmbMcpServer; + +xcrs::xcrs_mcp_tools!( + SmbMcpServer, + "smb_simulator_list", + "smb_simulator_find", + "smb_ios_app_test", + "smb_simulator_screenshot", + "smb_simulator_launch_app", + "smb_simulator_terminate_app", + "smb_simulator_open_url", + "smb_simulator_ui_dump", + "smb_simulator_list_elements", + "smb_simulator_tap", + "smb_simulator_type_text", + "smb_simulator_swipe", + "smb_simulator_press_home", + "smb_simulator_press_button", + "smb_simulator_orientation_get", + "smb_simulator_orientation_set", + "smb_controlkit_capabilities", + "smb_macos_click", + "smb_visionos_spatial_tap", + "smb_watchos_tap", + "smb_device_screenshot" +); diff --git a/crates/xcrs/Cargo.toml b/crates/xcrs/Cargo.toml new file mode 100644 index 00000000..98cdc716 --- /dev/null +++ b/crates/xcrs/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "xcrs" +version = "0.4.13" +edition = "2021" +authors = ["Seto Elkahfi "] +description = "Rust abstraction for Xcode command line tools." +license = "Apache-2.0" +repository = "https://github.com/smbcloudXYZ/smbcloud-cli" +documentation = "https://smbcloud.xyz/posts" +keywords = ["xcode", "ios", "simulator", "cli"] +categories = ["command-line-utilities", "development-tools"] + +[lib] +path = "src/xcrs.rs" + +[[bin]] +name = "xcrs" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +base64 = { workspace = true } +clap = { workspace = true, features = ["derive", "env"] } +rmcp = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +reqwest = { workspace = true, features = ["json", "rustls-tls-native-roots"] } +tempfile = { workspace = true } +tokio = { workspace = true, features = ["full"] } diff --git a/crates/xcrs/README.md b/crates/xcrs/README.md new file mode 100644 index 00000000..d84b0f58 --- /dev/null +++ b/crates/xcrs/README.md @@ -0,0 +1,18 @@ +# XCRS + +Rust-native Xcode command-line and Apple simulator automation tools with an +MCP server for simulator and ControlKit workflows. + +MCP Registry name: `mcp-name: io.github.smbcloudXYZ/xcrs` + +## Run the MCP server + +```sh +cargo install xcrs +xcrs --mcp +``` + +## Repository + +Source and documentation are available in the +[smbcloud-cli repository](https://github.com/smbcloudXYZ/smbcloud-cli/tree/main/crates/xcrs). diff --git a/crates/xcrs/src/main.rs b/crates/xcrs/src/main.rs new file mode 100644 index 00000000..7521991d --- /dev/null +++ b/crates/xcrs/src/main.rs @@ -0,0 +1,111 @@ +use anyhow::{anyhow, Result}; +use clap::{Parser, Subcommand}; +use std::path::PathBuf; +use xcrs::{IosAppTest, XcodeCommandLineTools}; + +#[derive(Debug, Parser)] +#[command(name = "xcrs")] +#[command(about = "Rust abstraction over Xcode command line tools")] +struct Cli { + /// Run as a standalone Model Context Protocol server over stdio. + #[arg(long)] + mcp: bool, + #[command(subcommand)] + command: Option, +} + +#[derive(Debug, Subcommand)] +enum Commands { + #[command(subcommand)] + Simulator(SimulatorCommand), + IosAppTest { + #[arg(long, conflicts_with = "simulator_udid")] + simulator_name: Option, + #[arg(long, conflicts_with = "simulator_name")] + simulator_udid: Option, + #[arg(long)] + app_path: PathBuf, + #[arg(long)] + bundle_id: String, + #[arg(long)] + terminate_before_launch: bool, + #[arg(long)] + json: bool, + }, +} + +#[derive(Debug, Subcommand)] +enum SimulatorCommand { + Find { + #[arg(long)] + name: String, + #[arg(long)] + json: bool, + }, + List { + #[arg(long)] + json: bool, + }, +} + +#[tokio::main] +async fn main() -> Result<()> { + let cli = Cli::parse(); + + if cli.mcp { + return xcrs::mcp::serve().await; + } + + let command = cli + .command + .ok_or_else(|| anyhow!("a command is required unless --mcp is provided"))?; + let tools = XcodeCommandLineTools::new(); + + match command { + Commands::Simulator(SimulatorCommand::Find { name, json }) => { + let simulator = tools.simctl().find_simulator_by_name(&name)?; + if json { + println!("{}", serde_json::to_string_pretty(&simulator)?); + } else { + println!("{} {}", simulator.name, simulator.udid); + } + } + Commands::Simulator(SimulatorCommand::List { json }) => { + let simulators = tools.simctl().list_simulators()?; + if json { + println!("{}", serde_json::to_string_pretty(&simulators)?); + } else { + for simulator in simulators { + println!("{} {} {}", simulator.name, simulator.udid, simulator.state); + } + } + } + Commands::IosAppTest { + simulator_name, + simulator_udid, + app_path, + bundle_id, + terminate_before_launch, + json, + } => { + let result = tools.run_ios_app_test(&IosAppTest { + simulator_name, + simulator_udid, + app_path, + bundle_id, + terminate_before_launch, + })?; + + if json { + println!("{}", serde_json::to_string_pretty(&result)?); + } else { + println!( + "Launched {} on {} ({})", + result.bundle_id, result.simulator.name, result.simulator.udid + ); + } + } + } + + Ok(()) +} diff --git a/crates/xcrs/src/mcp.rs b/crates/xcrs/src/mcp.rs new file mode 100644 index 00000000..0869087f --- /dev/null +++ b/crates/xcrs/src/mcp.rs @@ -0,0 +1,1210 @@ +use { + anyhow::{anyhow, Result}, + rmcp::{ + model::{Implementation, ServerCapabilities, ServerInfo}, + transport::stdio, + ServerHandler, ServiceExt, + }, + schemars::JsonSchema, + serde::Deserialize, + std::path::PathBuf, +}; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorFindArgs { + /// Exact simulator name to find. + pub name: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct DeviceScreenshotArgs { + /// Physical device identifier known to CoreDevice: UDID, ECID, serial + /// number, user-provided name, or DNS name. + pub device: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct IosAppTestArgs { + /// Exact simulator name to use. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID to use. + #[serde(default)] + pub simulator_udid: Option, + /// Path to the `.app` bundle. + pub app_path: PathBuf, + /// Installed app bundle identifier. + pub bundle_id: String, + /// Terminate the app before launching it. + #[serde(default)] + pub terminate_before_launch: bool, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorTargetArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// Local ControlKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub controlkit_port: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorAppArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + #[serde(default)] + pub host: Option, + /// Local ControlKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub controlkit_port: Option, + /// App bundle identifier. + pub bundle_id: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorOpenUrlArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// Local ControlKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub controlkit_port: Option, + /// HTTP(S) URL or custom URL scheme. + pub url: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorTapArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + #[serde(default)] + pub host: Option, + /// Local ControlKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub controlkit_port: Option, + /// Horizontal screen coordinate. + pub x: f32, + /// Vertical screen coordinate. + pub y: f32, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorTextArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + #[serde(default)] + pub host: Option, + /// Local ControlKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub controlkit_port: Option, + /// Text to type into the focused field. + pub text: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorSwipeArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + #[serde(default)] + pub host: Option, + /// Local ControlKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub controlkit_port: Option, + /// Swipe start horizontal coordinate. + pub x1: i32, + /// Swipe start vertical coordinate. + pub y1: i32, + /// Swipe end horizontal coordinate. + pub x2: i32, + /// Swipe end vertical coordinate. + pub y2: i32, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorOrientationArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + #[serde(default)] + pub host: Option, + /// Local ControlKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub controlkit_port: Option, + /// Desired orientation: PORTRAIT or LANDSCAPE. + pub orientation: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorControlKitArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + #[serde(default)] + pub host: Option, + /// Local ControlKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub controlkit_port: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct SimulatorButtonArgs { + /// Exact simulator name. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. + #[serde(default)] + pub simulator_udid: Option, + /// ControlKit host for a physical device or remote runner. Omit for a local simulator. + #[serde(default)] + pub host: Option, + /// Local ControlKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub controlkit_port: Option, + /// tvOS button: up, down, left, right, select, menu, home, or playPause. + pub button: String, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ControlKitEndpointArgs { + /// Exact simulator name. Omit this when connecting to a local macOS runner. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. Omit this when connecting to a local macOS runner. + #[serde(default)] + pub simulator_udid: Option, + /// ControlKit host. Defaults to 127.0.0.1 for local runners. + #[serde(default)] + pub host: Option, + /// Local ControlKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub controlkit_port: Option, +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ControlKitCoordinateArgs { + /// Exact simulator name. Omit this when connecting to a local macOS runner. + #[serde(default)] + pub simulator_name: Option, + /// Simulator UDID. Omit this when connecting to a local macOS runner. + #[serde(default)] + pub simulator_udid: Option, + /// ControlKit host. Defaults to 127.0.0.1 for local runners. + #[serde(default)] + pub host: Option, + /// Local ControlKit JSON-RPC port. Defaults to 12004. + #[serde(default)] + pub controlkit_port: Option, + /// Horizontal screen coordinate. + pub x: f32, + /// Vertical screen coordinate. + pub y: f32, +} + +#[derive(Debug, Default)] +pub struct XcrsMcpServer; + +impl XcrsMcpServer { + pub fn new() -> Self { + Self + } +} + +#[macro_export] +macro_rules! xcrs_mcp_tools { + ( + $server:ty, + $simulator_list_name:literal, + $simulator_find_name:literal, + $ios_app_test_name:literal, + $simulator_screenshot_name:literal, + $simulator_launch_app_name:literal, + $simulator_terminate_app_name:literal, + $simulator_open_url_name:literal, + $simulator_ui_dump_name:literal, + $simulator_list_elements_name:literal, + $simulator_tap_name:literal, + $simulator_text_name:literal, + $simulator_swipe_name:literal, + $simulator_home_name:literal, + $simulator_button_name:literal, + $simulator_orientation_get_name:literal, + $simulator_orientation_set_name:literal, + $controlkit_capabilities_name:literal, + $macos_click_name:literal, + $visionos_spatial_tap_name:literal, + $watchos_tap_name:literal, + $device_screenshot_name:literal + ) => { + #[::rmcp::tool_router(router = xcrs_tool_router, vis = "pub(crate)")] + impl $server { + fn simulator_from_target( + target: &$crate::mcp::SimulatorTargetArgs, + ) -> ::std::result::Result<$crate::Simulator, ::rmcp::model::ErrorData> { + let tools = $crate::XcodeCommandLineTools::new(); + match (&target.simulator_udid, &target.simulator_name) { + (Some(udid), _) => tools + .simctl() + .find_simulator_by_udid(udid) + .map_err(|error| { + ::rmcp::model::ErrorData::invalid_request(error.to_string(), None) + }), + (None, Some(name)) => tools + .simctl() + .find_simulator_by_name(name) + .map_err(|error| { + ::rmcp::model::ErrorData::invalid_request(error.to_string(), None) + }), + (None, None) => Err(::rmcp::model::ErrorData::invalid_request( + "Provide either simulator_name or simulator_udid.", + None, + )), + } + } + + fn controlkit_from_target( + simulator_name: &Option, + simulator_udid: &Option, + host: &Option, + controlkit_port: Option, + ) -> ::std::result::Result< + (Option<$crate::Simulator>, $crate::ControlKit), + ::rmcp::model::ErrorData, + > { + if let Some(host) = host { + return Ok(( + None, + $crate::ControlKit::with_host(host, controlkit_port.unwrap_or(12004)), + )); + } + let target = $crate::mcp::SimulatorTargetArgs { + simulator_name: simulator_name.clone(), + simulator_udid: simulator_udid.clone(), + controlkit_port, + }; + let simulator = Self::simulator_from_target(&target)?; + Ok(( + Some(simulator), + $crate::ControlKit::new(controlkit_port.unwrap_or(12004)), + )) + } + + fn target_label(simulator: &Option<$crate::Simulator>, host: &Option) -> String { + if let Some(simulator) = simulator { + return simulator.name.clone(); + } + host.clone().unwrap_or_else(|| "127.0.0.1".to_string()) + } + + fn controlkit_from_endpoint( + endpoint: &$crate::mcp::ControlKitEndpointArgs, + ) -> ::std::result::Result< + ($crate::ControlKit, Option<$crate::Simulator>), + ::rmcp::model::ErrorData, + > { + let simulator = match (&endpoint.simulator_udid, &endpoint.simulator_name) { + (Some(udid), _) => Some( + $crate::XcodeCommandLineTools::new() + .simctl() + .find_simulator_by_udid(udid) + .map_err(|error| { + ::rmcp::model::ErrorData::invalid_request( + error.to_string(), + None, + ) + })?, + ), + (None, Some(name)) => Some( + $crate::XcodeCommandLineTools::new() + .simctl() + .find_simulator_by_name(name) + .map_err(|error| { + ::rmcp::model::ErrorData::invalid_request( + error.to_string(), + None, + ) + })?, + ), + (None, None) => None, + }; + let host = endpoint.host.as_deref().unwrap_or("127.0.0.1"); + Ok(( + $crate::ControlKit::with_host( + host, + endpoint.controlkit_port.unwrap_or(12004), + ), + simulator, + )) + } + + fn endpoint_from_coordinate( + args: &$crate::mcp::ControlKitCoordinateArgs, + ) -> $crate::mcp::ControlKitEndpointArgs { + $crate::mcp::ControlKitEndpointArgs { + simulator_name: args.simulator_name.clone(), + simulator_udid: args.simulator_udid.clone(), + host: args.host.clone(), + controlkit_port: args.controlkit_port, + } + } + + #[::rmcp::tool( + name = $controlkit_capabilities_name, + description = "Read the platform and supported capabilities from a ControlKit runner. Use a simulator name/UDID for iOS, tvOS, watchOS, or visionOS; omit them for a local macOS runner." + )] + async fn controlkit_capabilities( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::ControlKitEndpointArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (controlkit, simulator) = Self::controlkit_from_endpoint(&args)?; + let result = controlkit + .call("device.capabilities", ::serde_json::json!({})) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ + "simulator": simulator, + "capabilities": result, + }))?, + ])) + } + + #[::rmcp::tool( + name = $macos_click_name, + description = "Click a macOS ControlKit runner at screen coordinates. Omit simulator_name and simulator_udid for the local Mac." + )] + async fn macos_click( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::ControlKitCoordinateArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let endpoint = Self::endpoint_from_coordinate(&args); + let (controlkit, _) = Self::controlkit_from_endpoint(&endpoint)?; + controlkit + .call( + "device.io.click", + ::serde_json::json!({ "x": args.x, "y": args.y }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Clicked ({}, {}).", + args.x, args.y + )), + ])) + } + + #[::rmcp::tool( + name = $visionos_spatial_tap_name, + description = "Perform a spatial tap on a visionOS ControlKit runner." + )] + async fn visionos_spatial_tap( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::ControlKitCoordinateArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let endpoint = Self::endpoint_from_coordinate(&args); + let (controlkit, _) = Self::controlkit_from_endpoint(&endpoint)?; + controlkit + .call( + "device.io.spatial.tap", + ::serde_json::json!({ "x": args.x, "y": args.y }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Performed spatial tap at ({}, {}).", + args.x, args.y + )), + ])) + } + + #[::rmcp::tool( + name = $watchos_tap_name, + description = "Tap a watchOS ControlKit runner at screen coordinates." + )] + async fn watchos_tap( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::ControlKitCoordinateArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let endpoint = Self::endpoint_from_coordinate(&args); + let (controlkit, _) = Self::controlkit_from_endpoint(&endpoint)?; + controlkit + .call( + "device.io.tap", + ::serde_json::json!({ "x": args.x, "y": args.y }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Tapped ({}, {}).", + args.x, args.y + )), + ])) + } + + #[::rmcp::tool( + name = $simulator_list_name, + description = "List all Apple simulator devices known to Xcode, including their platform, runtime, UDID, availability, and current state." + )] + async fn simulator_list( + &self, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let simulators = $crate::XcodeCommandLineTools::new() + .simctl() + .list_simulators() + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&simulators)?, + ])) + } + + #[::rmcp::tool( + name = $simulator_find_name, + description = "Find an Apple simulator by its exact name and return its details as JSON." + )] + async fn simulator_find( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorFindArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let simulator = $crate::XcodeCommandLineTools::new() + .simctl() + .find_simulator_by_name(&args.name) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&simulator)?, + ])) + } + + #[::rmcp::tool( + name = $ios_app_test_name, + description = "Boot an iOS simulator, install an .app bundle, optionally terminate the bundle, launch it, and return its app container path." + )] + async fn ios_app_test( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::IosAppTestArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + if args.simulator_name.is_none() && args.simulator_udid.is_none() { + return Err(::rmcp::model::ErrorData::invalid_request( + "Provide either simulator_name or simulator_udid.", + None, + )); + } + + let result = $crate::XcodeCommandLineTools::new() + .run_ios_app_test(&$crate::IosAppTest { + simulator_name: args.simulator_name, + simulator_udid: args.simulator_udid, + app_path: args.app_path, + bundle_id: args.bundle_id, + terminate_before_launch: args.terminate_before_launch, + }) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&result)?, + ])) + } + + #[::rmcp::tool( + name = $simulator_screenshot_name, + description = "Capture a PNG screenshot from an Apple simulator." + )] + async fn simulator_screenshot( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorTargetArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let simulator = Self::simulator_from_target(&args)?; + let screenshot = $crate::XcodeCommandLineTools::new() + .simctl() + .screenshot(&simulator.udid) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + let encoded = $crate::encode_base64(screenshot); + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::image(encoded, "image/png"), + ])) + } + + #[::rmcp::tool( + name = $device_screenshot_name, + description = "Capture a PNG screenshot from a physical iOS, tvOS, watchOS, or visionOS device paired with this Mac. Accepts a UDID, ECID, serial number, user-provided name, or DNS name." + )] + async fn device_screenshot( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::DeviceScreenshotArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let screenshot = $crate::XcodeCommandLineTools::new() + .devicectl() + .screenshot(&args.device) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + let encoded = $crate::encode_base64(screenshot); + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::image(encoded, "image/png"), + ])) + } + + #[::rmcp::tool( + name = $simulator_launch_app_name, + description = "Launch an installed app on an Apple simulator by bundle identifier." + )] + async fn simulator_launch_app( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorAppArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + if let Some(host) = &args.host { + let controlkit = + $crate::ControlKit::with_host(host, args.controlkit_port.unwrap_or(12004)); + controlkit + .call( + "device.apps.launch", + ::serde_json::json!({ "bundleId": args.bundle_id }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + return Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Launched {} on {}.", + args.bundle_id, host + )), + ])); + } + let target = $crate::mcp::SimulatorTargetArgs { + simulator_name: args.simulator_name, + simulator_udid: args.simulator_udid, + controlkit_port: args.controlkit_port, + }; + let simulator = Self::simulator_from_target(&target)?; + $crate::XcodeCommandLineTools::new() + .simctl() + .launch_app(&simulator.udid, &args.bundle_id) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Launched {} on {}.", + args.bundle_id, simulator.name + )), + ])) + } + + #[::rmcp::tool( + name = $simulator_terminate_app_name, + description = "Terminate an installed app on an Apple simulator by bundle identifier." + )] + async fn simulator_terminate_app( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorAppArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + if let Some(host) = &args.host { + let controlkit = + $crate::ControlKit::with_host(host, args.controlkit_port.unwrap_or(12004)); + controlkit + .call( + "device.apps.terminate", + ::serde_json::json!({ "bundleId": args.bundle_id }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + return Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Terminated {} on {}.", + args.bundle_id, host + )), + ])); + } + let target = $crate::mcp::SimulatorTargetArgs { + simulator_name: args.simulator_name, + simulator_udid: args.simulator_udid, + controlkit_port: args.controlkit_port, + }; + let simulator = Self::simulator_from_target(&target)?; + $crate::XcodeCommandLineTools::new() + .simctl() + .terminate_app(&simulator.udid, &args.bundle_id) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Terminated {} on {}.", + args.bundle_id, simulator.name + )), + ])) + } + + #[::rmcp::tool( + name = $simulator_open_url_name, + description = "Open a URL or custom URL scheme on an Apple simulator." + )] + async fn simulator_open_url( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorOpenUrlArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let target = $crate::mcp::SimulatorTargetArgs { + simulator_name: args.simulator_name, + simulator_udid: args.simulator_udid, + controlkit_port: args.controlkit_port, + }; + let simulator = Self::simulator_from_target(&target)?; + $crate::XcodeCommandLineTools::new() + .simctl() + .open_url(&simulator.udid, &args.url) + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Opened {} on {}.", + args.url, simulator.name + )), + ])) + } + + #[::rmcp::tool( + name = $simulator_ui_dump_name, + description = "Return the accessibility UI hierarchy from the foreground Apple app through ControlKit." + )] + async fn simulator_ui_dump( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorControlKitArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, controlkit) = Self::controlkit_from_target( + &args.simulator_name, + &args.simulator_udid, + &args.host, + args.controlkit_port, + )?; + let result = controlkit + .call("device.dump.ui", ::serde_json::json!({ "format": "json" })) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ + "simulator": simulator, + "ui": result, + }))?, + ])) + } + + #[::rmcp::tool( + name = $simulator_list_elements_name, + description = "List actionable accessibility elements and coordinates from the foreground Apple app through ControlKit." + )] + async fn simulator_list_elements( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorControlKitArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, controlkit) = Self::controlkit_from_target( + &args.simulator_name, + &args.simulator_udid, + &args.host, + args.controlkit_port, + )?; + let ui = controlkit + .call("device.dump.ui", ::serde_json::json!({ "format": "json" })) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + let elements = $crate::extract_controlkit_elements(&ui); + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ + "simulator": simulator, + "elements": elements, + }))?, + ])) + } + + #[::rmcp::tool( + name = $simulator_tap_name, + description = "Tap an Apple simulator screen at the given coordinates through ControlKit." + )] + async fn simulator_tap( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorTapArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, controlkit) = Self::controlkit_from_target( + &args.simulator_name, + &args.simulator_udid, + &args.host, + args.controlkit_port, + )?; + controlkit + .call( + "device.io.tap", + ::serde_json::json!({ "x": args.x, "y": args.y }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Tapped ({}, {}) on {}.", + args.x, args.y, Self::target_label(&simulator, &args.host) + )), + ])) + } + + #[::rmcp::tool( + name = $simulator_text_name, + description = "Type text into the focused Apple simulator field through ControlKit." + )] + async fn simulator_text( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorTextArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, controlkit) = Self::controlkit_from_target( + &args.simulator_name, + &args.simulator_udid, + &args.host, + args.controlkit_port, + )?; + controlkit + .call("device.io.text", ::serde_json::json!({ "text": args.text })) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Typed text on {}.", + Self::target_label(&simulator, &args.host) + )), + ])) + } + + #[::rmcp::tool( + name = $simulator_swipe_name, + description = "Swipe between two screen coordinates on an Apple simulator through ControlKit." + )] + async fn simulator_swipe( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorSwipeArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, controlkit) = Self::controlkit_from_target( + &args.simulator_name, + &args.simulator_udid, + &args.host, + args.controlkit_port, + )?; + controlkit + .call( + "device.io.swipe", + ::serde_json::json!({ + "x1": args.x1, + "y1": args.y1, + "x2": args.x2, + "y2": args.y2, + }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Swiped on {}.", + Self::target_label(&simulator, &args.host) + )), + ])) + } + + #[::rmcp::tool( + name = $simulator_home_name, + description = "Press the iOS Home button through ControlKit." + )] + async fn simulator_home( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorControlKitArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, controlkit) = Self::controlkit_from_target( + &args.simulator_name, + &args.simulator_udid, + &args.host, + args.controlkit_port, + )?; + controlkit + .call("device.io.button", ::serde_json::json!({ "button": "home" })) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Pressed Home on {}.", + Self::target_label(&simulator, &args.host) + )), + ])) + } + + #[::rmcp::tool( + name = $simulator_button_name, + description = "Press a ControlKit remote button on an iOS or tvOS simulator. tvOS supports up, down, left, right, select, menu, home, and playPause." + )] + async fn simulator_button( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorButtonArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, controlkit) = Self::controlkit_from_target( + &args.simulator_name, + &args.simulator_udid, + &args.host, + args.controlkit_port, + )?; + let supported_buttons = [ + "up", + "down", + "left", + "right", + "select", + "menu", + "home", + "playPause", + ]; + if !supported_buttons.contains(&args.button.as_str()) { + return Err(::rmcp::model::ErrorData::invalid_request( + "button must be one of up, down, left, right, select, menu, home, or playPause", + None, + )); + } + controlkit + .call( + "device.io.button", + ::serde_json::json!({ "button": args.button }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Pressed {} on {}.", + args.button, Self::target_label(&simulator, &args.host) + )), + ])) + } + + #[::rmcp::tool( + name = $simulator_orientation_get_name, + description = "Read the current iOS simulator orientation through ControlKit." + )] + async fn simulator_orientation_get( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorControlKitArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, controlkit) = Self::controlkit_from_target( + &args.simulator_name, + &args.simulator_udid, + &args.host, + args.controlkit_port, + )?; + let result = controlkit + .call("device.io.orientation.get", ::serde_json::json!({})) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::json(&::serde_json::json!({ + "simulator": simulator, + "orientation": result, + }))?, + ])) + } + + #[::rmcp::tool( + name = $simulator_orientation_set_name, + description = "Set the iOS simulator orientation to PORTRAIT or LANDSCAPE through ControlKit." + )] + async fn simulator_orientation_set( + &self, + ::rmcp::handler::server::wrapper::Parameters( + args, + ): ::rmcp::handler::server::wrapper::Parameters< + $crate::mcp::SimulatorOrientationArgs, + >, + ) -> ::std::result::Result< + ::rmcp::model::CallToolResult, + ::rmcp::model::ErrorData, + > { + let (simulator, controlkit) = Self::controlkit_from_target( + &args.simulator_name, + &args.simulator_udid, + &args.host, + args.controlkit_port, + )?; + let orientation = args.orientation.to_uppercase(); + if orientation != "PORTRAIT" && orientation != "LANDSCAPE" { + return Err(::rmcp::model::ErrorData::invalid_request( + "orientation must be PORTRAIT or LANDSCAPE", + None, + )); + } + controlkit + .call( + "device.io.orientation.set", + ::serde_json::json!({ "orientation": orientation }), + ) + .await + .map_err(|error| { + ::rmcp::model::ErrorData::internal_error(error.to_string(), None) + })?; + Ok(::rmcp::model::CallToolResult::success(vec![ + ::rmcp::model::ContentBlock::text(format!( + "Set orientation to {} on {}.", + orientation, Self::target_label(&simulator, &args.host) + )), + ])) + } + } + }; +} + +xcrs_mcp_tools!( + XcrsMcpServer, + "xcrs_simulator_list", + "xcrs_simulator_find", + "xcrs_ios_app_test", + "xcrs_simulator_screenshot", + "xcrs_simulator_launch_app", + "xcrs_simulator_terminate_app", + "xcrs_simulator_open_url", + "xcrs_simulator_ui_dump", + "xcrs_simulator_list_elements", + "xcrs_simulator_tap", + "xcrs_simulator_type_text", + "xcrs_simulator_swipe", + "xcrs_simulator_press_home", + "xcrs_simulator_press_button", + "xcrs_simulator_orientation_get", + "xcrs_simulator_orientation_set", + "xcrs_controlkit_capabilities", + "xcrs_macos_click", + "xcrs_visionos_spatial_tap", + "xcrs_watchos_tap", + "xcrs_device_screenshot" +); + +#[rmcp::tool_handler(router = Self::xcrs_tool_router())] +impl ServerHandler for XcrsMcpServer { + fn get_info(&self) -> ServerInfo { + let mut implementation = Implementation::from_build_env(); + implementation.name = "xcrs".to_string(); + implementation.version = env!("CARGO_PKG_VERSION").to_string(); + + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(implementation) + .with_instructions( + "xcrs exposes Xcode command line tools and ControlKit runners for Apple platform testing.", + ) + } +} + +pub async fn serve() -> Result<()> { + let running = XcrsMcpServer::new() + .serve(stdio()) + .await + .map_err(|error| anyhow!("Failed to start xcrs MCP server: {error}"))?; + running + .waiting() + .await + .map_err(|error| anyhow!("xcrs MCP server stopped unexpectedly: {error}"))?; + Ok(()) +} diff --git a/crates/xcrs/src/xcrs.rs b/crates/xcrs/src/xcrs.rs new file mode 100644 index 00000000..a19f0b79 --- /dev/null +++ b/crates/xcrs/src/xcrs.rs @@ -0,0 +1,866 @@ +use anyhow::{anyhow, Context, Result}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Output, Stdio}; +use std::time::Duration; + +pub mod mcp; + +const IOS_SIMULATOR_DESTINATION_PREFIX: &str = "platform=iOS Simulator,id="; + +pub fn encode_base64(data: impl AsRef<[u8]>) -> String { + use base64::{engine::general_purpose::STANDARD, Engine}; + + STANDARD.encode(data) +} + +pub fn extract_controlkit_elements(root: &serde_json::Value) -> Vec { + let mut elements = Vec::new(); + collect_controlkit_elements(root, &mut elements); + elements +} + +fn collect_controlkit_elements(element: &serde_json::Value, elements: &mut Vec) { + let object = match element.as_object() { + Some(object) => object, + None => return, + }; + + let rect = object.get("rect").and_then(serde_json::Value::as_object); + let has_visible_rect = rect + .and_then(|rect| { + Some(( + rect.get("x")?.as_f64()?, + rect.get("y")?.as_f64()?, + rect.get("width")?.as_f64()?, + rect.get("height")?.as_f64()?, + )) + }) + .is_some_and(|(x, y, width, height)| x >= 0.0 && y >= 0.0 && width > 0.0 && height > 0.0); + let has_identity = ["label", "name", "value", "rawIdentifier"] + .iter() + .any(|key| { + object + .get(*key) + .and_then(serde_json::Value::as_str) + .is_some() + }); + + if has_visible_rect && has_identity { + let keys = [ + "type", + "label", + "name", + "value", + "placeholderValue", + "rawIdentifier", + "rect", + ]; + let element = keys + .iter() + .filter_map(|key| { + object + .get(*key) + .map(|value| ((*key).to_string(), value.clone())) + }) + .collect(); + elements.push(serde_json::Value::Object(element)); + } + + if let Some(children) = object.get("children").and_then(serde_json::Value::as_array) { + for child in children { + collect_controlkit_elements(child, elements); + } + } +} + +#[derive(Debug, Clone)] +pub struct ControlKit { + client: reqwest::Client, + base_url: String, +} + +impl Default for ControlKit { + fn default() -> Self { + Self::new(12004) + } +} + +impl ControlKit { + pub fn new(port: u16) -> Self { + Self::with_host("127.0.0.1", port) + } + + pub fn with_host(host: &str, port: u16) -> Self { + let host = if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_string() + }; + Self { + client: reqwest::Client::new(), + base_url: format!("http://{host}:{port}"), + } + } + + pub async fn health(&self) -> Result<()> { + let response = self + .client + .get(format!("{}/health", self.base_url)) + .timeout(Duration::from_secs(5)) + .send() + .await + .with_context(|| format!("failed to connect to ControlKit at {}", self.base_url))?; + + if !response.status().is_success() { + return Err(anyhow!( + "ControlKit health check returned HTTP {}", + response.status() + )); + } + + Ok(()) + } + + pub async fn call(&self, method: &str, params: serde_json::Value) -> Result { + let response = self + .client + .post(format!("{}/rpc", self.base_url)) + .timeout(Duration::from_secs(30)) + .json(&serde_json::json!({ + "jsonrpc": "2.0", + "method": method, + "params": params, + "id": 1, + })) + .send() + .await + .with_context(|| format!("failed to connect to ControlKit at {}", self.base_url))?; + + let status = response.status(); + let body: serde_json::Value = response + .json() + .await + .context("failed to decode ControlKit JSON-RPC response")?; + + if !status.is_success() { + return Err(anyhow!("ControlKit returned HTTP {}: {}", status, body)); + } + + if let Some(error) = body.get("error") { + return Err(anyhow!("ControlKit method '{method}' failed: {error}")); + } + + body.get("result") + .cloned() + .ok_or_else(|| anyhow!("ControlKit response for '{method}' did not contain a result")) + } +} + +#[derive(Debug, Clone)] +pub struct XcodeCommandLineTools { + xcrun_path: PathBuf, + xcodebuild_path: PathBuf, +} + +impl Default for XcodeCommandLineTools { + fn default() -> Self { + Self { + xcrun_path: PathBuf::from("xcrun"), + xcodebuild_path: PathBuf::from("xcodebuild"), + } + } +} + +impl XcodeCommandLineTools { + pub fn new() -> Self { + Self::default() + } + + pub fn with_paths(xcrun_path: impl Into, xcodebuild_path: impl Into) -> Self { + Self { + xcrun_path: xcrun_path.into(), + xcodebuild_path: xcodebuild_path.into(), + } + } + + pub fn simctl(&self) -> Simctl<'_> { + Simctl { tools: self } + } + + pub fn xcodebuild(&self) -> Xcodebuild<'_> { + Xcodebuild { tools: self } + } + + pub fn devicectl(&self) -> Devicectl<'_> { + Devicectl { tools: self } + } + + fn run_xcrun(&self, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + run_command(&self.xcrun_path, args) + } + + fn run_xcodebuild(&self, args: I) -> Result + where + I: IntoIterator, + S: AsRef, + { + run_command(&self.xcodebuild_path, args) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub enum ApplePlatform { + #[serde(rename = "iOS")] + Ios, + #[serde(rename = "tvOS")] + Tvos, + #[serde(rename = "macOS")] + Macos, + #[serde(rename = "watchOS")] + Watchos, + #[serde(rename = "visionOS")] + Visionos, + #[serde(rename = "unknown")] + Unknown, +} + +impl ApplePlatform { + fn from_runtime_identifier(runtime_identifier: &str) -> Self { + if runtime_identifier.contains(".iOS-") { + Self::Ios + } else if runtime_identifier.contains(".tvOS-") { + Self::Tvos + } else if runtime_identifier.contains(".macOS-") { + Self::Macos + } else if runtime_identifier.contains(".watchOS-") { + Self::Watchos + } else if runtime_identifier.contains(".visionOS-") || runtime_identifier.contains(".xrOS-") + { + Self::Visionos + } else { + Self::Unknown + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct Simulator { + pub platform: ApplePlatform, + pub runtime_identifier: String, + pub name: String, + pub udid: String, + pub state: String, + pub is_available: bool, +} + +impl Simulator { + pub fn is_booted(&self) -> bool { + self.state == "Booted" + } +} + +#[derive(Debug, Deserialize)] +struct SimctlDeviceList { + devices: BTreeMap>, +} + +#[derive(Debug, Deserialize)] +struct SimctlDevice { + name: String, + udid: String, + state: String, + #[serde(rename = "isAvailable")] + is_available: Option, +} + +pub struct Simctl<'a> { + tools: &'a XcodeCommandLineTools, +} + +impl Simctl<'_> { + pub fn list_simulators(&self) -> Result> { + let output = self + .tools + .run_xcrun(["simctl", "list", "devices", "--json"])?; + parse_simctl_devices(&output) + } + + pub fn find_simulator_by_name(&self, name: &str) -> Result { + self.list_simulators()? + .into_iter() + .find(|simulator| simulator.name == name) + .ok_or_else(|| anyhow!("iOS simulator named '{name}' was not found")) + } + + pub fn find_simulator_by_udid(&self, udid: &str) -> Result { + self.list_simulators()? + .into_iter() + .find(|simulator| simulator.udid == udid) + .ok_or_else(|| anyhow!("iOS simulator with UDID '{udid}' was not found")) + } + + pub fn boot(&self, udid: &str) -> Result<()> { + if self.find_simulator_by_udid(udid)?.is_booted() { + return Ok(()); + } + + self.tools.run_xcrun(["simctl", "boot", udid])?; + Ok(()) + } + + pub fn shutdown(&self, udid: &str) -> Result<()> { + if !self.find_simulator_by_udid(udid)?.is_booted() { + return Ok(()); + } + + self.tools.run_xcrun(["simctl", "shutdown", udid])?; + Ok(()) + } + + pub fn install_app(&self, udid: &str, app_path: impl AsRef) -> Result<()> { + let app_path = app_path.as_ref(); + ensure_path_exists(app_path)?; + + let path = app_path + .to_str() + .ok_or_else(|| anyhow!("app path is not valid UTF-8: {}", app_path.display()))?; + + self.tools.run_xcrun(["simctl", "install", udid, path])?; + Ok(()) + } + + pub fn uninstall_app(&self, udid: &str, bundle_id: &str) -> Result<()> { + self.tools + .run_xcrun(["simctl", "uninstall", udid, bundle_id])?; + Ok(()) + } + + pub fn launch_app(&self, udid: &str, bundle_id: &str) -> Result<()> { + self.tools + .run_xcrun(["simctl", "launch", udid, bundle_id])?; + Ok(()) + } + + pub fn terminate_app(&self, udid: &str, bundle_id: &str) -> Result<()> { + self.tools + .run_xcrun(["simctl", "terminate", udid, bundle_id])?; + Ok(()) + } + + pub fn open_url(&self, udid: &str, url: &str) -> Result<()> { + self.tools.run_xcrun(["simctl", "openurl", udid, url])?; + Ok(()) + } + + pub fn screenshot(&self, udid: &str) -> Result> { + let temporary_directory = tempfile::tempdir()?; + let screenshot_path = temporary_directory.path().join("screenshot.png"); + let screenshot_path_string = screenshot_path + .to_str() + .ok_or_else(|| anyhow!("screenshot path is not valid UTF-8"))?; + + self.tools + .run_xcrun(["simctl", "io", udid, "screenshot", screenshot_path_string])?; + + std::fs::read(&screenshot_path) + .with_context(|| format!("failed to read {}", screenshot_path.display())) + } + + pub fn app_container_path(&self, udid: &str, bundle_id: &str) -> Result { + let output = + self.tools + .run_xcrun(["simctl", "get_app_container", udid, bundle_id, "app"])?; + let path = output.trim(); + if path.is_empty() { + return Err(anyhow!( + "simctl returned an empty app container path for '{bundle_id}'" + )); + } + + Ok(PathBuf::from(path)) + } +} + +pub struct Devicectl<'a> { + tools: &'a XcodeCommandLineTools, +} + +impl Devicectl<'_> { + /// Captures a PNG screenshot from a physical device known to CoreDevice. + /// `device` accepts anything `devicectl --device` does: UDID, ECID, + /// serial number, user-provided name, or DNS name. + pub fn screenshot(&self, device: &str) -> Result> { + let temporary_directory = tempfile::tempdir()?; + let screenshot_path = temporary_directory.path().join("screenshot.png"); + let screenshot_path_string = screenshot_path + .to_str() + .ok_or_else(|| anyhow!("screenshot path is not valid UTF-8"))?; + + self.tools.run_xcrun([ + "devicectl", + "device", + "capture", + "screenshot", + "--device", + device, + "--destination", + screenshot_path_string, + ])?; + + std::fs::read(&screenshot_path) + .with_context(|| format!("failed to read {}", screenshot_path.display())) + } +} + +#[derive(Debug, Clone)] +pub enum XcodeProject { + Project(PathBuf), + Workspace(PathBuf), +} + +#[derive(Debug, Clone)] +pub struct BuildIosApp { + pub project: XcodeProject, + pub scheme: String, + pub simulator_udid: String, + pub derived_data_path: PathBuf, + pub configuration: Option, +} + +pub struct Xcodebuild<'a> { + tools: &'a XcodeCommandLineTools, +} + +impl Xcodebuild<'_> { + pub fn build_ios_app(&self, request: &BuildIosApp) -> Result<()> { + let mut args = Vec::new(); + match &request.project { + XcodeProject::Project(project_path) => { + ensure_path_exists(project_path)?; + args.push("-project".to_string()); + args.push(path_to_string(project_path)?); + } + XcodeProject::Workspace(workspace_path) => { + ensure_path_exists(workspace_path)?; + args.push("-workspace".to_string()); + args.push(path_to_string(workspace_path)?); + } + } + + args.push("-scheme".to_string()); + args.push(request.scheme.clone()); + args.push("-destination".to_string()); + args.push(format!( + "{IOS_SIMULATOR_DESTINATION_PREFIX}{}", + request.simulator_udid + )); + args.push("-derivedDataPath".to_string()); + args.push(path_to_string(&request.derived_data_path)?); + + if let Some(configuration) = &request.configuration { + args.push("-configuration".to_string()); + args.push(configuration.clone()); + } + + args.push("build".to_string()); + + self.tools.run_xcodebuild(args)?; + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct IosAppTestResult { + pub simulator: Simulator, + pub bundle_id: String, + pub app_container_path: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct IosAppTest { + pub simulator_name: Option, + pub simulator_udid: Option, + pub app_path: PathBuf, + pub bundle_id: String, + pub terminate_before_launch: bool, +} + +impl XcodeCommandLineTools { + pub fn run_ios_app_test(&self, request: &IosAppTest) -> Result { + if request.simulator_name.is_none() && request.simulator_udid.is_none() { + return Err(anyhow!( + "either simulator_name or simulator_udid must be provided" + )); + } + + let simctl = self.simctl(); + let simulator = match (&request.simulator_udid, &request.simulator_name) { + (Some(udid), _) => simctl.find_simulator_by_udid(udid)?, + (None, Some(name)) => simctl.find_simulator_by_name(name)?, + (None, None) => unreachable!("validated above"), + }; + + simctl.boot(&simulator.udid)?; + simctl.install_app(&simulator.udid, &request.app_path)?; + + if request.terminate_before_launch { + simctl.terminate_app(&simulator.udid, &request.bundle_id)?; + } + + simctl.launch_app(&simulator.udid, &request.bundle_id)?; + let app_container_path = simctl.app_container_path(&simulator.udid, &request.bundle_id)?; + + Ok(IosAppTestResult { + simulator: simctl.find_simulator_by_udid(&simulator.udid)?, + bundle_id: request.bundle_id.clone(), + app_container_path, + }) + } + + pub fn device_tunnel_address(&self, device_udid: &str) -> Result { + let output = self.run_xcrun([ + "devicectl", + "device", + "info", + "details", + "--device", + device_udid, + ])?; + + parse_device_tunnel_address(&output) + .ok_or_else(|| anyhow!("device {device_udid} did not report a tunnel IP address")) + } + + pub fn build_controlkit_device_runner( + &self, + request: &BuildControlKitDeviceRunner, + ) -> Result { + ensure_path_exists(&request.project_path)?; + let args = [ + "build-for-testing".to_string(), + "-project".to_string(), + path_to_string(&request.project_path)?, + "-scheme".to_string(), + request.scheme.clone(), + "-configuration".to_string(), + request.configuration.clone(), + "-destination".to_string(), + format!("id={}", request.device_udid), + "-derivedDataPath".to_string(), + path_to_string(&request.derived_data_path)?, + ]; + self.run_xcodebuild(args)?; + + let products_path = request.derived_data_path.join("Build/Products"); + let entries = std::fs::read_dir(&products_path) + .with_context(|| format!("failed to read {}", products_path.display()))?; + let mut test_run_paths = entries + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| { + path.extension() + .is_some_and(|extension| extension == "xctestrun") + }) + .collect::>(); + test_run_paths.sort(); + + test_run_paths.into_iter().next().ok_or_else(|| { + anyhow!( + "Xcode did not create an .xctestrun file in {}", + products_path.display() + ) + }) + } + + pub fn start_controlkit_device_runner( + &self, + request: &StartControlKitDeviceRunner, + ) -> Result { + ensure_path_exists(&request.xctestrun_path)?; + set_xctestrun_environment( + &request.xctestrun_path, + "CONTROLKIT_LISTEN_HOST", + &request.listen_host, + )?; + set_xctestrun_environment( + &request.xctestrun_path, + "CONTROLKIT_LISTEN_PORT", + &request.listen_port.to_string(), + )?; + + let tunnel_host = self.device_tunnel_address(&request.device_udid)?; + let log_file = std::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .open(&request.log_path) + .with_context(|| format!("failed to open {}", request.log_path.display()))?; + let error_log_file = log_file + .try_clone() + .with_context(|| format!("failed to clone {}", request.log_path.display()))?; + + let child = Command::new(&self.xcodebuild_path) + .arg("test-without-building") + .arg("-xctestrun") + .arg(&request.xctestrun_path) + .arg("-destination") + .arg(format!("id={}", request.device_udid)) + .stdin(Stdio::null()) + .stdout(Stdio::from(log_file)) + .stderr(Stdio::from(error_log_file)) + .spawn() + .with_context(|| { + format!( + "failed to start ControlKit runner for device {}", + request.device_udid + ) + })?; + + Ok(RunningControlKitDeviceRunner { + process: child, + tunnel_host, + listen_port: request.listen_port, + log_path: request.log_path.clone(), + }) + } +} + +#[derive(Debug, Clone)] +pub struct BuildControlKitDeviceRunner { + pub project_path: PathBuf, + pub scheme: String, + pub configuration: String, + pub device_udid: String, + pub derived_data_path: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct StartControlKitDeviceRunner { + pub device_udid: String, + pub xctestrun_path: PathBuf, + pub listen_host: String, + pub listen_port: u16, + pub log_path: PathBuf, +} + +#[derive(Debug)] +pub struct RunningControlKitDeviceRunner { + pub process: Child, + pub tunnel_host: String, + pub listen_port: u16, + pub log_path: PathBuf, +} + +fn set_xctestrun_environment(path: &Path, name: &str, value: &str) -> Result<()> { + let key = format!(":TestConfigurations:0:TestTargets:0:EnvironmentVariables:{name}"); + let set_status = Command::new("/usr/libexec/PlistBuddy") + .arg("-c") + .arg(format!("Set {key} {value}")) + .arg(path) + .status() + .with_context(|| format!("failed to update {}", path.display()))?; + + if set_status.success() { + return Ok(()); + } + + let add_status = Command::new("/usr/libexec/PlistBuddy") + .arg("-c") + .arg(format!("Add {key} string {value}")) + .arg(path) + .status() + .with_context(|| format!("failed to update {}", path.display()))?; + + if add_status.success() { + return Ok(()); + } + + Err(anyhow!( + "failed to set {name} in XCTest plan {}", + path.display() + )) +} + +fn parse_device_tunnel_address(output: &str) -> Option { + output + .lines() + .find_map(|line| line.split_once("Tunnel IP Address:")) + .map(|(_, address)| address.trim().to_string()) + .filter(|address| !address.is_empty()) +} + +pub fn parse_simctl_devices(output: &str) -> Result> { + let list: SimctlDeviceList = + serde_json::from_str(output).context("failed to parse simctl device list JSON")?; + + let mut simulators = Vec::new(); + for (runtime_identifier, devices) in list.devices { + for device in devices { + simulators.push(Simulator { + platform: ApplePlatform::from_runtime_identifier(&runtime_identifier), + runtime_identifier: runtime_identifier.clone(), + name: device.name, + udid: device.udid, + state: device.state, + is_available: device.is_available.unwrap_or(true), + }); + } + } + + Ok(simulators) +} + +fn ensure_path_exists(path: &Path) -> Result<()> { + if !path.exists() { + return Err(anyhow!("path does not exist: {}", path.display())); + } + + Ok(()) +} + +fn path_to_string(path: &Path) -> Result { + path.to_str() + .map(ToOwned::to_owned) + .ok_or_else(|| anyhow!("path is not valid UTF-8: {}", path.display())) +} + +fn run_command(program: &Path, args: I) -> Result +where + I: IntoIterator, + S: AsRef, +{ + let output = Command::new(program) + .args(args) + .output() + .with_context(|| format!("failed to run {}", program.display()))?; + + command_output_to_result(program, output) +} + +fn command_output_to_result(program: &Path, output: Output) -> Result { + if output.status.success() { + return String::from_utf8(output.stdout) + .with_context(|| format!("{} wrote invalid UTF-8 to stdout", program.display())); + } + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let message = [stderr.trim(), stdout.trim()] + .into_iter() + .filter(|part| !part.is_empty()) + .collect::>() + .join("\n"); + + if message.is_empty() { + return Err(anyhow!( + "{} exited with status {}", + program.display(), + output.status + )); + } + + Err(anyhow!( + "{} exited with status {}: {}", + program.display(), + output.status, + message + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_simctl_devices() { + let output = r#"{ + "devices": { + "com.apple.CoreSimulator.SimRuntime.iOS-26-0": [ + { + "lastBootedAt": "2026-07-30T08:00:00Z", + "dataPath": "/tmp/device", + "dataPathSize": 1, + "logPath": "/tmp/logs", + "udid": "00000000-0000-0000-0000-000000000000", + "isAvailable": true, + "deviceTypeIdentifier": "com.apple.CoreSimulator.SimDeviceType.iPhone-16", + "state": "Booted", + "name": "Test-iOS-26" + } + ] + } + }"#; + + let devices = parse_simctl_devices(output).expect("devices should parse"); + + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].platform, ApplePlatform::Ios); + assert_eq!(devices[0].name, "Test-iOS-26"); + assert_eq!(devices[0].state, "Booted"); + assert!(devices[0].is_booted()); + } + + #[test] + fn parses_xros_simulator_runtime() { + let output = r#"{ + "devices": { + "com.apple.CoreSimulator.SimRuntime.xrOS-26-5": [ + { + "udid": "00000000-0000-0000-0000-000000000001", + "isAvailable": true, + "state": "Shutdown", + "name": "Apple Vision Pro" + } + ] + } + }"#; + + let devices = parse_simctl_devices(output).expect("devices should parse"); + + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].platform, ApplePlatform::Visionos); + assert_eq!(devices[0].name, "Apple Vision Pro"); + } + + #[test] + fn parses_macos_runtime() { + let output = r#"{ + "devices": { + "com.apple.CoreSimulator.SimRuntime.macOS-26-0": [ + { + "udid": "00000000-0000-0000-0000-000000000002", + "isAvailable": true, + "state": "Booted", + "name": "Mac" + } + ] + } + }"#; + + let devices = parse_simctl_devices(output).expect("devices should parse"); + + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].platform, ApplePlatform::Macos); + } + + #[test] + fn formats_ipv6_controlkit_url() { + let controlkit = ControlKit::with_host("fdb4:e020:7377::1", 12006); + assert_eq!(controlkit.base_url, "http://[fdb4:e020:7377::1]:12006"); + } + + #[test] + fn parses_device_tunnel_address() { + let output = "• Device Name: iPhone\n• Tunnel IP Address: fd55:33ce:ad87::1\n"; + assert_eq!( + parse_device_tunnel_address(output).as_deref(), + Some("fd55:33ce:ad87::1") + ); + } +} diff --git a/docs/controlkit.md b/docs/controlkit.md new file mode 100644 index 00000000..08d1fd77 --- /dev/null +++ b/docs/controlkit.md @@ -0,0 +1,145 @@ +# ControlKit runners + +ControlKit is the native XCTest runner used by the CLI's Apple-platform MCP +tools. The public runner project is +[`xcrs-controlkit`](https://github.com/ondeinference/xcrs-controlkit). It uses +one Xcode project generated by XcodeGen, with platform-specific targets and +schemes: + +| Platform | Scheme | App product | +| --- | --- | --- | +| iOS / iPadOS | `ControlKit-iOS` | `ControlKit.app` | +| tvOS | `ControlKit-tvOS` | `ControlKit.app` | +| visionOS | `ControlKit-visionOS` | `ControlKit.app` | +| watchOS | `ControlKit-watchOS` | `ControlKit.app` | +| macOS | `ControlKit-macOS` | `ControlKit.app` | + +The scheme and target names keep their platform suffixes so Xcode can select +the correct SDK, while each built app is named `ControlKit`. + +## Build a runner + +Install XcodeGen once, clone the runner project, and generate the Xcode +project: + +```sh +brew install xcodegen +cd /Runners +xcodegen generate +``` + +Build the simulator runner for the platform you need: + +```sh +xcodebuild -project XCRSControlKitRunner.xcodeproj \ + -scheme ControlKit- \ + -destination 'generic/platform= Simulator' \ + build-for-testing +``` + +Replace `` with `iOS`, `tvOS`, `watchOS`, or `visionOS`. macOS is +built with `-destination 'platform=macOS'` and runs on the local Mac rather +than a simulator. + +## Start the runner + +Boot a compatible simulator and run the UI-test host: + +```sh +xcodebuild -project XCRSControlKitRunner.xcodeproj \ + -scheme ControlKit- \ + -destination 'id=' \ + test-without-building +``` + +The shared XCTest runner starts a local ControlKit JSON-RPC server on +`127.0.0.1:12004`. Keep that test process running while calling the MCP tools. +The macOS UI-test target includes the network-server entitlement required to +bind the local port. + +## Run on a physical device + +The same runner also works installed on a real iOS, tvOS, watchOS, or +visionOS device, not just a simulator. Build and start it against the +device's UDID (find it with `xcrun devicectl list devices`) instead of a +simulator destination: + +```sh +xcodebuild -project XCRSControlKitRunner.xcodeproj \ + -scheme ControlKit- \ + -destination 'id=' \ + build-for-testing + +TEST_RUNNER_CONTROLKIT_LISTEN_HOST=0.0.0.0 xcodebuild \ + -project XCRSControlKitRunner.xcodeproj \ + -scheme ControlKit- \ + -destination 'id=' \ + test-without-building +``` + +The `CONTROLKIT_LISTEN_HOST` environment variable controls what the +in-process RPC server binds to (`Shared/ControlKitRPCServer.swift`) — it +defaults to `127.0.0.1`, which is loopback-only *on the device itself* and +unreachable from your Mac. `xcodebuild` forwards any `TEST_RUNNER_`-prefixed +environment variable into the test process with the prefix stripped, so +setting `TEST_RUNNER_CONTROLKIT_LISTEN_HOST=0.0.0.0` makes the server bind on +every interface, including the one reachable over your LAN. Find the device's +address with its Bonjour hostname (`.local`, discoverable via +`dns-sd -B _airplay._tcp local` for an Apple TV) or its CoreDevice tunnel +address (`xcrun devicectl device info details --device `, under +`Tunnel IP Address`). + +## Connect through `smb --mcp` + +Start the CLI MCP server: + +```sh +smb --mcp +``` + +The runner tools accept either `simulator_name` or `simulator_udid` for a +simulator, or `host` (plus `controlkit_port`, defaulting to `12004`) for a +physical device or any other remote runner. The standalone `xcrs --mcp` +server exposes the same tools with the `xcrs_` prefix. + +| Tool | Runner | Purpose | +| --- | --- | --- | +| `smb_controlkit_capabilities` | All platforms | Read platform and capability information. | +| `smb_macos_click` | macOS | Click at screen coordinates. | +| `smb_visionos_spatial_tap` | visionOS | Perform a spatial tap. | +| `smb_watchos_tap` | watchOS | Perform a touch tap. | +| `smb_simulator_tap` | iOS / tvOS, simulator or physical | Perform a touch tap. | +| `smb_simulator_type_text` | iOS / tvOS, simulator or physical | Type into the focused field. | +| `smb_simulator_swipe` | iOS / tvOS, simulator or physical | Swipe between coordinates. | +| `smb_simulator_press_button` | iOS / tvOS, simulator or physical | Press a Home or tvOS remote button. | +| `smb_simulator_launch_app`/`terminate_app` | simulator or physical | Launch/terminate an app by bundle ID. On a physical device this calls the runner's `device.apps.launch`/`device.apps.terminate` RPC methods instead of `simctl`. | +| `smb_simulator_screenshot` | Simulator only | Capture a PNG screenshot via `simctl`. | +| `smb_device_screenshot` | Physical device only | Capture a PNG screenshot via `devicectl device capture screenshot` — works independently of the ControlKit runner, so it succeeds even if the RPC server isn't running. | +| `smb_simulator_ui_dump` | All UI-test runners | Read the accessibility hierarchy. | + +For a local macOS runner, omit simulator fields and pass `host` and +`controlkit_port` when needed: + +```json +{ + "host": "127.0.0.1", + "controlkit_port": 12004, + "x": 400, + "y": 300 +} +``` + +For a physical device, pass its Bonjour hostname or tunnel address as `host` +instead: + +```json +{ + "host": ".local", + "controlkit_port": 12004, + "button": "select" +} +``` + +Platform-specific operations intentionally reject unsupported input. For +example, visionOS rejects raw touch, watchOS rejects pointer and spatial input, +and macOS rejects touch and Home-button requests. diff --git a/docs/interfaces.md b/docs/interfaces.md index 209d6eb2..c2320f78 100644 --- a/docs/interfaces.md +++ b/docs/interfaces.md @@ -50,8 +50,13 @@ dialog under `--tui`; in the headless interface they ask inline instead. `smb --mcp` starts an MCP (Model Context Protocol) server that speaks JSON-RPC over stdio. Instead of running a single command and exiting, it stays +<<<<<<< HEAD +up and exposes 50 tools spanning accounts, projects, tenants, Mail, Auth, +simulators, and ControlKit runners as MCP **tools** that an MCP-capable client +======= up and exposes smbCloud operations — 31 tools spanning accounts, projects, tenants, Mail, and Auth — as MCP **tools** that an MCP-capable client +>>>>>>> development (Claude Desktop, Claude Code, Cursor, or any other assistant/agent) can call. The subcommand is ignored in this mode. @@ -61,4 +66,5 @@ including the `*_delete` tools, which apply immediately with no confirmation prompt. For the client setup guide and the full tool reference, see -[MCP Server](./mcp.md). +[MCP Server](./mcp.md). For running the native Apple-platform runners, see +[ControlKit runners](./controlkit.md). diff --git a/docs/mcp-registry.md b/docs/mcp-registry.md index 2d1e2fdd..3830a46b 100644 --- a/docs/mcp-registry.md +++ b/docs/mcp-registry.md @@ -1,10 +1,13 @@ # MCP Registry -The smbCloud CLI's MCP server is listed in the [official MCP Registry](https://registry.modelcontextprotocol.io) -as **`io.github.smbcloudXYZ/smbcloud-cli`**. The registry is a metadata index — -it doesn't host binaries — so the listing points at packages we already publish -to npm and NuGet, and clients that browse the registry can offer a one-click -install of `smb --mcp`. +The repository publishes two MCP servers to the [official MCP Registry](https://registry.modelcontextprotocol.io): + +- **`io.github.smbcloudXYZ/smbcloud-cli`** — the `smb --mcp` server for smbCloud resources, simulators, and ControlKit runners. +- **`io.github.smbcloudXYZ/xcrs`** — the standalone Rust `xcrs --mcp` server for Xcode and Apple simulator tooling. + +The registry is a metadata index — it doesn't host binaries. The smbCloud +listing points at packages published to npm and NuGet, while the standalone +XCRS listing points at the `xcrs` crate on crates.io. For running and configuring the server itself, see [MCP Server](./mcp.md). @@ -12,9 +15,11 @@ For running and configuring the server itself, see [MCP Server](./mcp.md). | Piece | Where | | --- | --- | -| Server metadata | [`server.json`](../server.json) at the repo root | +| smbCloud server metadata | [`server.json`](../server.json) at the repo root | +| XCRS server metadata | [`server-xcrs.json`](../server-xcrs.json) at the repo root | | npm ownership proof | `mcpName` in the generated `@smbcloud/cli` `package.json` (`npm/scripts/render-main-package.cjs`) | | NuGet ownership proof | `` in `nuget/smbcloud-cli/README.md` | +| Cargo ownership proof | Visible `mcp-name: ...` text in `crates/xcrs/README.md` | | Publishing | `.github/workflows/release-mcp-registry.yml` | The registry checks each listed package for a marker naming the server. If the @@ -60,9 +65,9 @@ documented in [Install](./cli-install.md). ## Releasing a new version -`server.json` carries the version twice — once for the server, once per package -— and both are updated by `make patch | minor | major` along with the rest of -the release metadata (`scripts/sync-release-version.mjs`). +Each server metadata file carries its version twice — once for the server, once +per package — and both are updated by `make patch | minor | major` along with +the rest of the release metadata (`scripts/sync-release-version.mjs`). Publishing is chained off the release: pushing a `v*` tag runs the crates.io workflow, which fans out to the distribution workflows, and **NuGet CLI @@ -83,10 +88,11 @@ the tag: gh workflow run release-mcp-registry.yml -f tag=v0.4.13 ``` -Confirm the listing afterwards: +Confirm both listings afterwards: ```sh curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.smbcloudXYZ/smbcloud-cli" +curl "https://registry.modelcontextprotocol.io/v0.1/servers?search=io.github.smbcloudXYZ/xcrs" ``` ## Publishing by hand @@ -99,5 +105,7 @@ mcp-publisher login github # device flow, needs push access to smbcloudXY mcp-publisher publish # reads ./server.json ``` -Versions are immutable — republishing the same version is rejected. Fixing a -bad listing means shipping a new patch version. +The release workflow publishes `server.json`, then temporarily swaps in +`server-xcrs.json` and publishes the standalone entry as well. Versions are +immutable — republishing the same version is rejected. Fixing a bad listing +means shipping a new patch version. diff --git a/docs/mcp.md b/docs/mcp.md index 95aa8415..c653da70 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2,17 +2,27 @@ The smbCloud CLI can run as a **Model Context Protocol (MCP) server**, so an AI assistant or agent — Claude Desktop, Claude Code, Cursor, or any other +<<<<<<< HEAD +MCP-capable client — can manage your smbCloud projects, tenants, Mail apps, and +Auth apps directly, without you leaving the chat to run `smb` commands by hand. +Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 50 +tools covering the account, project, tenant, Mail, Auth, simulator, and +ControlKit runner surfaces. +======= MCP-capable client — can send transactional email and manage your smbCloud projects, tenants, Mail apps, and Auth apps directly, without you leaving the chat to run `smb` commands by hand. Start it with `smb --mcp`; it speaks standard MCP over stdio and exposes 31 tools covering the account, project, tenant, Mail, and Auth surfaces. +>>>>>>> development This page is the setup guide and the full tool reference. For how `--mcp` compares to the CLI's other two interfaces (headless and `--tui`), see [Interfaces](./interfaces.md). The server is also listed in the official MCP Registry as `io.github.smbcloudXYZ/smbcloud-cli`, so clients that browse the registry can install it for you — see [MCP Registry](./mcp-registry.md). +For building and starting the native runners, see +[ControlKit runners](./controlkit.md). ## Prerequisites @@ -241,6 +251,50 @@ users; it belongs to a project. | `auth_app_update` | `id`, `name`/`support_email` (at least one) | The updated Auth app. | | `auth_app_delete` | `id` | Confirmation. **Destructive and irreversible.** | +### ControlKit runners + +The `smb` MCP server also exposes local Xcode simulator and ControlKit runner +tools. Simulator arguments accept either `simulator_name` or `simulator_udid`; +ControlKit arguments default to `127.0.0.1:12004`. For a local macOS runner, +omit the simulator fields and use the optional `host` and `controlkit_port` +fields. + +The same `host` field also targets a **physical** iOS/tvOS/watchOS/visionOS +device running a ControlKit runner reachable over the network (see +[ControlKit runners](./controlkit.md)) — omit `simulator_name`/`simulator_udid` +and pass the device's host and the runner's listen port instead. This applies +to `smb_simulator_tap`, `smb_simulator_type_text`, `smb_simulator_swipe`, +`smb_simulator_press_button`, `smb_simulator_press_home`, +`smb_simulator_orientation_get`/`set`, `smb_simulator_ui_dump`, +`smb_simulator_list_elements`, and `smb_simulator_launch_app`/`terminate_app` +(the latter two route through the runner's `device.apps.launch`/`terminate` +RPC methods instead of `simctl` when `host` is given). + +| Tool | Arguments | Returns | +| --- | --- | --- | +| `smb_simulator_list` | _(none)_ | All Apple simulator devices known to Xcode. | +| `smb_simulator_find` | `name` | A simulator's details. | +| `smb_controlkit_capabilities` | simulator target or optional `host`, `controlkit_port` | Runner platform and capabilities. | +| `smb_macos_click` | `x`, `y`, plus optional endpoint fields | A pointer click on a macOS runner. | +| `smb_visionos_spatial_tap` | `x`, `y`, plus optional endpoint fields | A spatial tap on a visionOS runner. | +| `smb_watchos_tap` | `x`, `y`, plus optional endpoint fields | A touch tap on a watchOS runner. | +| `smb_simulator_tap` | simulator target or `host`, `x`, `y` | A touch tap through ControlKit. | +| `smb_simulator_type_text` | simulator target or `host`, `text` | Text input through ControlKit. | +| `smb_simulator_swipe` | simulator target or `host`, `x1`, `y1`, `x2`, `y2` | A swipe through ControlKit. | +| `smb_simulator_press_button` | simulator target or `host`, `button` | A supported remote-button press. | +| `smb_simulator_press_home` | simulator target or `host` | An iOS Home-button press. | +| `smb_simulator_orientation_get`/`set` | simulator target or `host`; `orientation` for `set` | Current or updated iOS orientation. | +| `smb_simulator_launch_app`/`terminate_app` | simulator target or `host`, `bundle_id` | App lifecycle confirmation. | +| `smb_simulator_open_url` | simulator target, `url` | URL-open confirmation. | +| `smb_simulator_ui_dump` | simulator target or `host` | Accessibility UI hierarchy. | +| `smb_simulator_list_elements` | simulator target or `host` | Actionable accessibility elements. | +| `smb_simulator_screenshot` | simulator target | A PNG image. | +| `smb_device_screenshot` | `device` (UDID, ECID, serial number, name, or DNS name) | A PNG image captured from a physical device via `devicectl`. | +| `smb_ios_app_test` | simulator target, `app_path`, `bundle_id` | Installed/launched app details. | + +The standalone `xcrs --mcp` server exposes the same runner surface with the +`xcrs_` prefix instead of `smb_`. + ## Safety: tools run without confirmation The CLI's own `project delete`, `tenant delete`, and similar commands ask for diff --git a/npm/smbcloud-cli/package-lock.json b/npm/smbcloud-cli/package-lock.json index c9a62213..b4b58903 100644 --- a/npm/smbcloud-cli/package-lock.json +++ b/npm/smbcloud-cli/package-lock.json @@ -1,12 +1,12 @@ { "name": "@smbcloud/cli", - "version": "0.4.7", + "version": "0.4.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@smbcloud/cli", - "version": "0.4.7", + "version": "0.4.13", "license": "Apache-2.0", "bin": { "smb": "lib/index.js" @@ -19,11 +19,11 @@ "typescript": "^5.0.0" }, "optionalDependencies": { - "@smbcloud/cli-darwin-arm64": "0.4.7", - "@smbcloud/cli-darwin-x64": "0.4.7", - "@smbcloud/cli-linux-x64": "0.4.7", - "@smbcloud/cli-windows-arm64": "0.4.7", - "@smbcloud/cli-windows-x64": "0.4.7" + "@smbcloud/cli-darwin-arm64": "0.4.13", + "@smbcloud/cli-darwin-x64": "0.4.13", + "@smbcloud/cli-linux-x64": "0.4.13", + "@smbcloud/cli-windows-arm64": "0.4.13", + "@smbcloud/cli-windows-x64": "0.4.13" } }, "node_modules/@eslint-community/eslint-utils": { diff --git a/npm/smbcloud-cli/package.json b/npm/smbcloud-cli/package.json index 83e6156b..9207e553 100644 --- a/npm/smbcloud-cli/package.json +++ b/npm/smbcloud-cli/package.json @@ -1,6 +1,6 @@ { "name": "@smbcloud/cli", - "version": "0.4.7", + "version": "0.4.13", "keywords": [ "smbcloud", "cli", @@ -58,10 +58,10 @@ "typescript": "^5.0.0" }, "optionalDependencies": { - "@smbcloud/cli-darwin-arm64": "0.4.7", - "@smbcloud/cli-darwin-x64": "0.4.7", - "@smbcloud/cli-linux-x64": "0.4.7", - "@smbcloud/cli-windows-arm64": "0.4.7", - "@smbcloud/cli-windows-x64": "0.4.7" + "@smbcloud/cli-darwin-arm64": "0.4.13", + "@smbcloud/cli-darwin-x64": "0.4.13", + "@smbcloud/cli-linux-x64": "0.4.13", + "@smbcloud/cli-windows-arm64": "0.4.13", + "@smbcloud/cli-windows-x64": "0.4.13" } } diff --git a/nuget/smbcloud-cli/SmbCloud.Cli.csproj b/nuget/smbcloud-cli/SmbCloud.Cli.csproj index f6d7a3d0..e17bf054 100644 --- a/nuget/smbcloud-cli/SmbCloud.Cli.csproj +++ b/nuget/smbcloud-cli/SmbCloud.Cli.csproj @@ -7,7 +7,7 @@ true smb SmbCloud.Cli - 0.0.0-local + 0.4.13 $(PackageVersion) Seto Elkahfi smbCloud command line interface. diff --git a/scripts/check-release-versions.mjs b/scripts/check-release-versions.mjs new file mode 100644 index 00000000..188173fd --- /dev/null +++ b/scripts/check-release-versions.mjs @@ -0,0 +1,102 @@ +import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { resolve } from "node:path"; + +const repoRoot = resolve(import.meta.dirname, ".."); +const expectedVersion = readFileSync(resolve(repoRoot, "crates/cli/Cargo.toml"), "utf8") + .match(/^version\s*=\s*"([^"]+)"/m)?.[1]; + +if (!expectedVersion) { + throw new Error("Unable to determine the release version from crates/cli/Cargo.toml"); +} + +const mismatches = []; + +function read(path) { + return readFileSync(resolve(repoRoot, path), "utf8"); +} + +function check(path, actualVersion) { + if (actualVersion !== expectedVersion) { + mismatches.push(`${path}: ${actualVersion ?? "missing"} (expected ${expectedVersion})`); + } +} + +function cargoPackageVersion(path) { + return read(path).match(/^\[package\][\s\S]*?^version\s*=\s*"([^"]+)"/m)?.[1]; +} + +for (const directory of ["crates", "sdk/gems"]) { + const directoryPath = resolve(repoRoot, directory); + for (const entry of readdirSync(directoryPath, { withFileTypes: true })) { + if (!entry.isDirectory()) { + continue; + } + + const candidates = [ + `${directory}/${entry.name}/Cargo.toml`, + ...readdirSync(resolve(directoryPath, entry.name), { withFileTypes: true }) + .filter((nestedEntry) => nestedEntry.isDirectory()) + .map((nestedEntry) => `${directory}/${entry.name}/${nestedEntry.name}/Cargo.toml`), + ]; + + for (const candidate of candidates) { + if (existsSync(resolve(repoRoot, candidate))) { + const version = cargoPackageVersion(candidate); + if (version) { + check(candidate, version); + } + } + } + } +} + +for (const match of read("Cargo.toml").matchAll( + /^([\w-]+)\s*=\s*\{\s*version\s*=\s*"([^"]+)"\s*,\s*path\s*=\s*"crates\//gm, +)) { + check(`Cargo.toml workspace dependency ${match[1]}`, match[2]); +} + +for (const path of [ + "sdk/gems/auth/lib/auth/version.rb", + "sdk/gems/email/lib/email/version.rb", + "sdk/gems/model/lib/model/version.rb", +]) { + check(path, read(path).match(/VERSION\s*=\s*['"]([^'"]+)['"]/)?.[1]); +} + +const npmPackage = JSON.parse(read("npm/smbcloud-cli/package.json")); +check("npm/smbcloud-cli/package.json", npmPackage.version); + +const sdkNpmPackage = JSON.parse(read("sdk/npm/smbcloud-auth/package.json")); +check("sdk/npm/smbcloud-auth/package.json", sdkNpmPackage.version); + +const npmLock = JSON.parse(read("npm/smbcloud-cli/package-lock.json")); +check("npm/smbcloud-cli/package-lock.json", npmLock.version); +check("npm/smbcloud-cli/package-lock.json packages root", npmLock.packages?.[""]?.version); + +for (const path of ["server.json", "server-xcrs.json"]) { + const server = JSON.parse(read(path)); + check(path, server.version); + for (const [index, packageEntry] of (server.packages ?? []).entries()) { + check(`${path} package ${index + 1}`, packageEntry.version); + } +} + +check( + "nuget/smbcloud-cli/SmbCloud.Cli.csproj", + read("nuget/smbcloud-cli/SmbCloud.Cli.csproj").match(/([^<]+)<\/PackageVersion>/)?.[1], +); +check( + "sdk/gems/email/Gemfile.lock", + read("sdk/gems/email/Gemfile.lock").match(/smbcloud-email \(([^)]+)\)/)?.[1], +); + +if (mismatches.length > 0) { + console.error(`Release version mismatch detected for ${expectedVersion}:`); + for (const mismatch of mismatches) { + console.error(`- ${mismatch}`); + } + process.exit(1); +} + +console.log(`All release artifacts use version ${expectedVersion}.`); diff --git a/scripts/sync-release-version.mjs b/scripts/sync-release-version.mjs index e5fead4a..9f25f8f5 100644 --- a/scripts/sync-release-version.mjs +++ b/scripts/sync-release-version.mjs @@ -3,12 +3,19 @@ import { resolve } from "node:path"; const repoRoot = resolve(import.meta.dirname, ".."); const cliCargoTomlPath = resolve(repoRoot, "crates/cli/Cargo.toml"); +const npmCliPackageJsonPath = resolve(repoRoot, "npm/smbcloud-cli/package.json"); +const npmCliPackageLockPath = resolve(repoRoot, "npm/smbcloud-cli/package-lock.json"); +const nugetCliProjectPath = resolve(repoRoot, "nuget/smbcloud-cli/SmbCloud.Cli.csproj"); const sdkNpmPackageJsonPath = resolve(repoRoot, "sdk/npm/smbcloud-auth/package.json"); const authGemVersionPath = resolve(repoRoot, "sdk/gems/auth/lib/auth/version.rb"); const authGemCargoTomlPath = resolve(repoRoot, "sdk/gems/auth/ext/auth/Cargo.toml"); const modelGemVersionPath = resolve(repoRoot, "sdk/gems/model/lib/model/version.rb"); const modelGemCargoTomlPath = resolve(repoRoot, "sdk/gems/model/ext/model/Cargo.toml"); +const emailGemVersionPath = resolve(repoRoot, "sdk/gems/email/lib/email/version.rb"); +const emailGemCargoTomlPath = resolve(repoRoot, "sdk/gems/email/ext/email/Cargo.toml"); +const emailGemLockPath = resolve(repoRoot, "sdk/gems/email/Gemfile.lock"); const mcpServerJsonPath = resolve(repoRoot, "server.json"); +const xcrsMcpServerJsonPath = resolve(repoRoot, "server-xcrs.json"); function readText(path) { return readFileSync(path, "utf8"); @@ -53,6 +60,50 @@ const [major = "0", minor = "0"] = releaseVersion.split("."); const rubySdkRequirement = `${major}.${minor}`; const updatedPaths = []; +if ( + updateFile(npmCliPackageJsonPath, (content) => { + const parsed = JSON.parse(content); + parsed.version = releaseVersion; + + for (const packageName of Object.keys(parsed.optionalDependencies ?? {})) { + parsed.optionalDependencies[packageName] = releaseVersion; + } + + return `${JSON.stringify(parsed, null, 2)}\n`; + }) +) { + updatedPaths.push(npmCliPackageJsonPath); +} + +if ( + updateFile(npmCliPackageLockPath, (content) => { + const parsed = JSON.parse(content); + parsed.version = releaseVersion; + parsed.packages[""].version = releaseVersion; + + for (const packageName of Object.keys(parsed.packages[""].optionalDependencies ?? {})) { + parsed.packages[""].optionalDependencies[packageName] = releaseVersion; + } + + return `${JSON.stringify(parsed, null, 2)}\n`; + }) +) { + updatedPaths.push(npmCliPackageLockPath); +} + +if ( + updateFile(nugetCliProjectPath, (content) => + replaceOrThrow( + content, + /[^<]+<\/PackageVersion>/, + `${releaseVersion}`, + `${nugetCliProjectPath} package version`, + ), + ) +) { + updatedPaths.push(nugetCliProjectPath); +} + if ( updateFile(sdkNpmPackageJsonPath, (content) => { const parsed = JSON.parse(content); @@ -130,6 +181,45 @@ if ( updatedPaths.push(modelGemCargoTomlPath); } +if ( + updateFile(emailGemVersionPath, (content) => + replaceOrThrow( + content, + /VERSION\s*=\s*'[^']+'/, + `VERSION = '${releaseVersion}'`, + `${emailGemVersionPath} VERSION constant`, + ), + ) +) { + updatedPaths.push(emailGemVersionPath); +} + +if ( + updateFile(emailGemCargoTomlPath, (content) => + replaceOrThrow( + content, + /^version\s*=\s*"[^"]+"/m, + `version = "${releaseVersion}"`, + `${emailGemCargoTomlPath} package version`, + ), + ) +) { + updatedPaths.push(emailGemCargoTomlPath); +} + +if ( + updateFile(emailGemLockPath, (content) => + replaceOrThrow( + content, + /(smbcloud-email \()[^)]+(\))/, + `$1${releaseVersion}$2`, + `${emailGemLockPath} gem version`, + ), + ) +) { + updatedPaths.push(emailGemLockPath); +} + if ( updateFile(mcpServerJsonPath, (content) => { const parsed = JSON.parse(content); @@ -145,6 +235,21 @@ if ( updatedPaths.push(mcpServerJsonPath); } +if ( + updateFile(xcrsMcpServerJsonPath, (content) => { + const parsed = JSON.parse(content); + parsed.version = releaseVersion; + + for (const pkg of parsed.packages ?? []) { + pkg.version = releaseVersion; + } + + return `${JSON.stringify(parsed, null, 2)}\n`; + }) +) { + updatedPaths.push(xcrsMcpServerJsonPath); +} + console.log(`Release version: ${releaseVersion}`); if (updatedPaths.length === 0) { diff --git a/sdk/gems/email/Cargo.lock b/sdk/gems/email/Cargo.lock index 36f447b7..6d33da06 100644 --- a/sdk/gems/email/Cargo.lock +++ b/sdk/gems/email/Cargo.lock @@ -114,7 +114,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cexpr", "clang-sys", "itertools", @@ -123,7 +123,7 @@ dependencies = [ "regex", "rustc-hash", "shlex 1.3.0", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -134,9 +134,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "bumpalo" @@ -146,15 +146,15 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.2.65" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" dependencies = [ "find-msvc-tools", "shlex 2.0.1", @@ -177,9 +177,20 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] [[package]] name = "chrono" @@ -197,9 +208,9 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", @@ -223,9 +234,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" dependencies = [ "clap_builder", "clap_derive", @@ -233,9 +244,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -245,14 +256,14 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -292,26 +303,35 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "email" -version = "0.4.7" +version = "0.4.13" dependencies = [ "magnus", "serde_json", @@ -336,30 +356,30 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-task", @@ -395,23 +415,23 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.4" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", - "wasip2", + "rand_core", "wasm-bindgen", ] [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "heck" @@ -445,9 +465,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -455,9 +475,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -465,9 +485,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" dependencies = [ "bytes", "futures-core", @@ -484,9 +504,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" dependencies = [ "atomic-waker", "bytes", @@ -715,9 +735,9 @@ checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -782,7 +802,7 @@ checksum = "5968c820e2960565f647819f5928a42d6e874551cab9d88d75e3e0660d7f71e3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -802,9 +822,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "minimal-lexical" @@ -814,9 +834,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "wasi", @@ -890,15 +910,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "proc-macro-error" version = "1.0.4" @@ -925,9 +936,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -954,14 +965,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", "rand", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -975,60 +987,57 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] [[package]] name = "r-efi" -version = "5.3.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.4" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "rand_chacha", + "chacha20", + "getrandom 0.4.3", "rand_core", ] [[package]] -name = "rand_chacha" -version = "0.9.0" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] -name = "rand_core" -version = "0.9.5" +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "getrandom 0.3.4", + "rand_core", ] [[package]] @@ -1052,7 +1061,7 @@ dependencies = [ "quote", "regex", "shell-words", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1063,9 +1072,9 @@ checksum = "a35802679f07360454b418a5d1735c89716bde01d35b1560fc953c1415a0b3bb" [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -1075,9 +1084,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" dependencies = [ "aho-corasick", "memchr", @@ -1145,15 +1154,15 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "once_cell", "ring", @@ -1177,9 +1186,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -1198,9 +1207,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -1238,7 +1247,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "core-foundation", "core-foundation-sys", "libc", @@ -1263,9 +1272,9 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -1273,29 +1282,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -1306,13 +1315,13 @@ dependencies = [ [[package]] name = "serde_repr" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -1368,7 +1377,7 @@ checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "smbcloud-email-sdk" -version = "0.4.7" +version = "0.4.13" dependencies = [ "log", "reqwest", @@ -1382,7 +1391,7 @@ dependencies = [ [[package]] name = "smbcloud-model" -version = "0.4.7" +version = "0.4.13" dependencies = [ "chrono", "log", @@ -1398,9 +1407,9 @@ dependencies = [ [[package]] name = "smbcloud-network" -version = "0.4.7" +version = "0.4.13" dependencies = [ - "clap 4.6.1", + "clap 4.6.4", "log", "reqwest", "serde", @@ -1411,9 +1420,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -1518,7 +1527,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1540,9 +1549,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -1566,7 +1586,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1580,29 +1600,29 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] @@ -1619,9 +1639,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -1634,9 +1654,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -1677,7 +1697,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "bytes", "futures-util", "http", @@ -1720,7 +1740,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -1779,7 +1799,7 @@ dependencies = [ "quote", "state", "structopt", - "syn 2.0.118", + "syn 2.0.119", "tsync-macro", "walkdir", ] @@ -1881,15 +1901,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.126" @@ -1932,7 +1943,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "wasm-bindgen-shared", ] @@ -1967,9 +1978,9 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -2035,7 +2046,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2046,7 +2057,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] @@ -2082,15 +2093,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2124,30 +2126,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -2160,12 +2145,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -2178,12 +2157,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -2196,24 +2169,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -2226,12 +2187,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -2244,12 +2199,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -2262,12 +2211,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -2280,18 +2223,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" version = "0.6.3" @@ -2317,30 +2248,10 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.52" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.118", -] - [[package]] name = "zerofrom" version = "0.1.8" @@ -2358,7 +2269,7 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", "synstructure", ] @@ -2398,11 +2309,11 @@ checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" dependencies = [ "proc-macro2", "quote", - "syn 2.0.118", + "syn 2.0.119", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/sdk/gems/email/Gemfile.lock b/sdk/gems/email/Gemfile.lock index 485b57f4..39ad5b7a 100644 --- a/sdk/gems/email/Gemfile.lock +++ b/sdk/gems/email/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - smbcloud-email (0.4.7) + smbcloud-email (0.4.13) json rb_sys (~> 0.9.91) diff --git a/sdk/gems/email/ext/email/Cargo.toml b/sdk/gems/email/ext/email/Cargo.toml index 9342b19c..0c397c41 100644 --- a/sdk/gems/email/ext/email/Cargo.toml +++ b/sdk/gems/email/ext/email/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "email" -version = "0.4.7" +version = "0.4.13" edition = "2024" authors = ["Seto Elkahfi "] publish = false diff --git a/sdk/gems/email/lib/email/version.rb b/sdk/gems/email/lib/email/version.rb index 4d8cbc3f..62b06b02 100644 --- a/sdk/gems/email/lib/email/version.rb +++ b/sdk/gems/email/lib/email/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Email - VERSION = '0.4.7' + VERSION = '0.4.13' end diff --git a/server-xcrs.json b/server-xcrs.json new file mode 100644 index 00000000..63d6f86b --- /dev/null +++ b/server-xcrs.json @@ -0,0 +1,52 @@ +{ + "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json", + "name": "io.github.smbcloudXYZ/xcrs", + "title": "XCRS Xcode Simulator Tools", + "description": "Rust-native Xcode command-line and Apple simulator automation tools exposed through MCP.", + "version": "0.4.13", + "websiteUrl": "https://github.com/smbcloudXYZ/smbcloud-cli/tree/main/crates/xcrs", + "repository": { + "url": "https://github.com/smbcloudXYZ/smbcloud-cli", + "source": "github", + "id": "597171611", + "subfolder": "crates/xcrs" + }, + "packages": [ + { + "registryType": "cargo", + "registryBaseUrl": "https://crates.io", + "identifier": "xcrs", + "version": "0.4.13", + "transport": { + "type": "stdio" + }, + "packageArguments": [ + { + "type": "named", + "name": "--mcp", + "description": "Run the XCRS server as an MCP server over stdio.", + "isRequired": true + } + ] + } + ], + "_meta": { + "io.modelcontextprotocol.registry/publisher-provided": { + "categories": [ + "developer-tools", + "xcode", + "ios", + "simulator" + ], + "keywords": [ + "Xcode", + "iOS simulator", + "tvOS", + "visionOS", + "watchOS", + "macOS", + "ControlKit" + ] + } + } +} diff --git a/website/.gitignore b/website/.gitignore index 30e34054..84b0f05c 100644 --- a/website/.gitignore +++ b/website/.gitignore @@ -44,6 +44,7 @@ yarn-error.log* /content/developer/cli/pypi.mdx /content/developer/cli/interfaces.mdx /content/developer/cli/mcp.mdx +/content/developer/cli/controlkit.mdx /content/developer/cli/ci.mdx /content/developer/contributing/_meta.js /content/developer/contributing/debugging.mdx diff --git a/website/content/developer/cli/index.mdx b/website/content/developer/cli/index.mdx index 29a18179..93a33ab2 100644 --- a/website/content/developer/cli/index.mdx +++ b/website/content/developer/cli/index.mdx @@ -7,8 +7,8 @@ description: Install the smbCloud CLI (smb), run it headless/--tui/--mcp, and co # CLI `smb` is the smbCloud command-line tool. You use it to log in and to manage and -deploy your smbCloud resources — projects, tenants, Mail, and Auth — from the -terminal, a script, or an MCP-connected AI agent. +deploy your smbCloud resources — projects, tenants, Mail, Auth, simulators, and +ControlKit runners — from the terminal, a script, or an MCP-connected AI agent. ```text Usage: smb [OPTIONS] [COMMAND] @@ -62,7 +62,8 @@ You can also download a binary for any release directly from the | Guide | What it covers | | --- | --- | | [Interfaces](/developer/cli/interfaces) | The three ways `smb` runs — headless, `--tui`, and `--mcp` — and when to use each. | -| [MCP Server](/developer/cli/mcp) | Connect Claude Desktop, Claude Code, Cursor, or any MCP client to `smb --mcp`; the full 30-tool reference. | +| [MCP Server](/developer/cli/mcp) | Connect Claude Desktop, Claude Code, Cursor, or any MCP client to `smb --mcp`; the full tool reference. | +| [ControlKit runners](/developer/cli/controlkit) | Build native iOS, tvOS, visionOS, watchOS, and macOS runners and expose them through the MCP servers. | | [CI](/developer/cli/ci) | Run `smb` non-interactively with `--ci` and deploy from GitHub Actions. | import { Callout } from 'nextra/components' diff --git a/website/scripts/generate-developer-docs.mjs b/website/scripts/generate-developer-docs.mjs index 3b63fa24..f2fb7873 100644 --- a/website/scripts/generate-developer-docs.mjs +++ b/website/scripts/generate-developer-docs.mjs @@ -69,7 +69,14 @@ const MANIFEST = { slug: 'mcp', title: 'MCP Server', description: - 'Run the smbCloud CLI as an MCP server and connect Claude Desktop, Claude Code, Cursor, or any MCP client to manage your projects, tenants, Mail, and Auth apps. Setup guide plus the full 30-tool reference.' + 'Run the smbCloud CLI as an MCP server and connect Claude Desktop, Claude Code, Cursor, or any MCP client to manage smbCloud resources, simulators, and ControlKit runners. Setup guide plus the full tool reference.' + }, + { + src: 'controlkit.md', + slug: 'controlkit', + title: 'ControlKit runners', + description: + 'Build and connect the native ControlKit runners for iOS, tvOS, visionOS, watchOS, and macOS through the smbCloud and xcrs MCP servers.' }, { src: 'ci.md',