ffx Development Guide for AI Agentsffx (Fuchsia Command Line Tools) is the primary host-side developer tool for interacting with Fuchsia target devices, product bundles, emulators, and build artifacts. When writing, refactoring, or reviewing code under //src/developer/ffx, follow these architecture rules and team best practices.
tools/) over Built-in Plugins (plugins/)//src/developer/ffx/tools/ (or in the owning team's subsystem directory with file:/src/developer/ffx/OWNERS included in OWNERS), built as standalone binaries using the ffx_tool GN template (//src/developer/ffx/build/ffx_tool.gni).//src/developer/ffx/plugins/: The plugins/ directory is for legacy built-in commands compiled directly into the main ffx binary. Only add to plugins/ if explicitly required.//src/developer/ffx/lib/: Reusable domain logic, target connection handling, protocol wrappers, and configuration schemas should live in lib/ crates rather than inside individual subtools.lib.rs and main.rsStructure every subtool as a library crate (rustc_library("lib") with with_unit_tests = true) paired with a thin ffx_tool binary wrapper:
src/main.rs: Minimal entry point invoking FHO:use ffx_tool_example::ExampleTool; use fho::FfxTool; #[fuchsia_async::run_singlethreaded] async fn main() { ExampleTool::execute_tool().await }
src/lib.rs: Defines the argh command struct (#[derive(ArgsInfo, FromArgs, Debug, PartialEq)]), the #[derive(FfxTool)] struct, the FfxMain implementation, and unit tests.edition = "2024" in all new BUILD.gn targets.ffx operates on a daemonless, direct-connection architecture.
ffx-daemon (DaemonProxy): Never introduce dependencies on DaemonProxy, fidl_fuchsia_developer_ffx::DaemonProxy, or daemon.* configuration keys.fuchsia.developer.ffx) for Target/Discovery State: Do not use legacy fidl_fuchsia_developer_ffx::TargetInfo or TargetProxy. Use native Rust domain types from://src/developer/ffx/lib/discovery (TargetHandle, TargetEvent)//src/developer/ffx/lib/target (TargetInfo, TargetInfoQuery)//src/developer/ffx/lib/mdns_discovery (MdnsTargetInfo)*_rust_fdomain) Instead of Overnet (*_rust):fdomain_client) over direct SSH, VSOCK, or USB transports.BUILD.gn, depend on the _rust_fdomain target of a FIDL library (e.g., //sdk/fidl/fuchsia.device:fuchsia.device_rust_fdomain) and import the fdomain_fuchsia_* crate in Rust.//src/developer/ffx/lib/fho) & Dependency InjectionSubtools use FHO (FfxTool and FfxMain) to declaratively inject environment context, target proxies, and configuration.
use argh::{ArgsInfo, FromArgs}; use async_trait::async_trait; use fdomain_fuchsia_device::NameProviderProxy; use ffx_writer::{ToolIO as _, VerifiedMachineWriter}; use fho::{FfxContext, FfxMain, FfxTool, Result}; use target_holders::moniker; #[derive(ArgsInfo, FromArgs, Debug, PartialEq)] #[argh(subcommand, name = "example", description = "example ffx subtool")] pub struct ExampleCommand {} #[derive(FfxTool)] pub struct ExampleTool { #[command] cmd: ExampleCommand, #[with(moniker("/core/system-update"))] proxy: NameProviderProxy, }
//src/developer/ffx/lib/target/holders)FfxTool struct with #[target(None)]. This is required so ffx --strict does not demand a --target argument when running the command.#[with(moniker("..."))] or #[with(toolbox())] on a FIDL proxy field (or inject RemoteControlProxyHolder, NodenameHolder, SshAddrHolder, HostAddrHolder) when every invocation of the tool needs the target.fho::Deferred<T>): When a tool has subcommands or code paths that may not require a target connection, wrap the proxy or holder in fho::Deferred<T> (or #[with(fho::deferred(moniker("...")))]) and .await? it only on the branch that needs it.Connector<T>): For workflows that reboot or flash the target device and must reconnect across disconnects, use Connector<RemoteControlProxyHolder> or DirectConnector (try_connect()).//src/developer/ffx/lib/writer)Never use println! or eprintln! for tool output. Always write through the Writer passed to FfxMain::main.
When creating a new subtool, always prefer supporting both structured machine output (ffx --machine json / ffx --machine json-pretty) and human-readable terminal output:
VerifiedMachineWriter<T> with schemars::JsonSchema) lets scripts, test harnesses, IDE integrations, and AI agents consume results reliably with a compile-time schema contract.ffx <subtool> interactively get clear, readable text rather than empty output or raw JSON dumps.Use VerifiedMachineWriter<T> by default and emit both formats from your FfxMain::main implementation:
use schemars::JsonSchema; use serde::Serialize; #[derive(Debug, Serialize, JsonSchema, PartialEq)] #[serde(rename_all = "snake_case")] pub enum ExampleOutput { Success { device_name: String }, Error { message: String }, } impl std::fmt::Display for ExampleOutput { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Success { device_name } => write!(f, "Device: {device_name}"), Self::Error { message } => write!(f, "Error: {message}"), } } } #[async_trait(?Send)] impl FfxMain for ExampleTool { type Writer = VerifiedMachineWriter<ExampleOutput>; async fn main(self, mut writer: Self::Writer) -> Result<()> { let name = self.proxy.get_device_name().await.user_message("Failed to query device")?; let output = ExampleOutput::Success { device_name: name }; // Emits JSON when `--machine` is passed, or `Display` text in human mode: writer.item(&output)?; Ok(()) } }
VerifiedMachineWriter Methodwriter.item(&output): Best when your output type implements std::fmt::Display. Automatically emits structured JSON in --machine mode and the Display representation in human mode.writer.machine_or(&output, human_text) / writer.machine_or_else(&output, || ...): Emits structured JSON in --machine mode and the provided string/closure output in human mode without requiring Display on T.writer.is_machine(): When human output requires multi-line formatting, tables, or streaming progress, handle both branches explicitly:writer.machine(&output)? when writer.is_machine() is true.writer.line(...) or writeln!(writer, ...) when writer.is_machine() is false.While supporting both machine and human-readable output is the default expectation for new subtools, the following categories are valid exceptions:
ffx component explore, ffx debug connect) should use SimpleWriter (SimpleWriter automatically rejects --machine json).ffx debug symbolize) should use SimpleWriter unless each streamed record has a well-defined JSON schema.ffx target reboot) should still support --machine for automation by calling writer.machine(&output)? (such as MachineWriter<()> or a status enum with VerifiedMachineWriter) without emitting human stdout on success.ffx scrutiny extract blobfs) may use SimpleWriter or emit a minimal status summary.writer.machine(&output) for data-reporting tools: writer.machine() is a no-op when --machine is not set. Unless the command is intentionally silent on success (like ffx target reboot), calling only writer.machine() leaves interactive CLI users with unexpected blank output.writer.line(...) or writeln!(writer, ...) on VerifiedMachineWriter: Standard Write and line() calls on VerifiedMachineWriter are ignored in --machine mode, producing empty stdout for machine consumers.MachineWriter<String> or MachineWriter<serde_json::Value>: If a command genuinely has no structured output use case, use SimpleWriter. Using MachineWriter<String> creates an untyped contract.cli-goldens & mw-goldens):ArgsInfo) are verified against //src/developer/ffx/tests/cli-goldens.JsonSchema) are verified against //src/developer/ffx/tests/mw-goldens.//src/developer/ffx/lib/command/error)anyhowanyhow (anyhow::Error, anyhow::Result, anyhow!, bail!, Context) in new or refactored ffx code, whether in subtools or library crates.anyhow erases error types, prevents callers from programmatically matching on failure modes, and obscures the critical distinction between actionable user errors and internal tool bugs. When touching existing code that uses anyhow, migrate it to typed errors (thiserror) or fho::Result (ffx_command_error::Result) as appropriate.//src/developer/ffx/lib/*): Define strongly-typed domain error enums using #[derive(thiserror::Error, Debug)].//src/developer/ffx/tools/*): Use fho::Result<T> and fho::Error (ffx_command_error::Error), converting library errors at the subtool boundary via From or fho::FfxContext.ffx distinguishes between Actionable User Errors and Unexpected Internal Bugs via fho::Error (ffx_command_error::Error):
Error::User): Printed cleanly to stderr without stack traces. Use for bad CLI arguments, missing files, target unreachability, or anything the user can act on.Error::Unexpected): Prints a BUG: An internal command error occurred. banner with full error chain diagnostics and instructs the user to file a bug at go/ffx-bug.fho::FfxContext instead of legacy ffx_error! / ffx_bail! or anyhow macros:.user_message("...") or .with_user_message(|| format!(...)):let contents = std::fs::read_to_string(&path) .with_user_message(|| format!("Unable to read manifest at '{}'", path.display()))?;
.bug() or .bug_context("..."):let parsed = parse_internal_state().bug_context("Internal state corrupted")?;
fho::return_user_error!(...), fho::user_error!(...), fho::return_bug!(...), or fho::bug!(...)."Target connection failed. Run 'ffx target list' or 'ffx doctor' to verify device state.").//src/developer/ffx/config)ffx resolves configuration across a 5-level priority hierarchy (ConfigLevel in //src/developer/ffx/config):
Runtime (--config / -c key=val CLI flags — highest priority)User (~/.fuchsia/config.json)Build (active build directory configuration, read-only)Global (system-wide policy configuration)Default (compiled-in defaults via include_default!() — lowest priority)EnvironmentContext Explicitly:EnvironmentContext can be passed or injected.EnvironmentContext implements TryFromEnv and can be injected directly as a field on your #[derive(FfxTool)] struct:#[derive(FfxTool)] pub struct ExampleTool { #[command] cmd: ExampleCommand, context: EnvironmentContext, }
EnvironmentContext:self.context.get::<T, _>(KEY_CONST) or self.context.get_optional::<T, _>(KEY_CONST) for direct typed lookups, or self.context.query(KEY_CONST) (ConfigQueryBuilder) when specifying a ConfigLevel or SelectMode.#[derive(FfxConfigBacked)] (//src/developer/ffx/config/macro) with #[ffx_config_default(key = "...", default = "...")] attributes, or implement ffx_config::TryFromEnvContext.//src/developer/ffx/config/src/keys.rs & Use Constants for Defaults:context.get("discovery.fastboot.timeout")) directly at call sites. Place shared or global configuration keys in //src/developer/ffx/config/src/keys.rs (ffx_config::keys::*), or define a named const at the module/crate level if a key is strictly internal to a single subtool..unwrap_or(...) when falling back to a default configuration value (e.g., avoid .unwrap_or(500)). Define a descriptive named const (e.g., const DEFAULT_FASTBOOT_DISCOVERY_TIMEOUT_MS: u64 = 500;) and/or register the default in //src/developer/ffx/data/config.json.ffx --strict: Avoid assuming ambient host state or implicit user/build config files exist. Any required settings in strict mode must be resolvable via explicit CLI flags or -c runtime config overrides (EnvironmentContext::is_strict()).#[fuchsia::test] for All Unit Tests#[fuchsia::test]: Use #[fuchsia::test] for both synchronous and async unit tests across ffx libraries and subtools.#[fuchsia_async::run_singlethreaded(test)] (or #[fasync::run_singlethreaded(test)]), #[tokio::test], and bare #[test]. #[fuchsia::test] automatically configures the single-threaded fuchsia_async executor for async fn tests and initializes test logging consistently.#[cfg(test)] Confined to the test / tests Module#[cfg(test)] in production code: Do not place #[cfg(test)] attributes on individual functions, methods, struct fields, imports, or impl blocks inside non-test modules.test module:#[cfg(test)] mod test (or mod tests) module has full visibility into the private fields and items of its parent module. Define test-only constructors, mock setup helpers, and impl blocks for parent types directly inside the test module rather than annotating items in the main module.#[cfg(test)] mod test_utils (or a dedicated testonly = true crate if shared across crates) instead of sprinkling #[cfg(test)] throughout production code.#[cfg(test)] prevents struct layouts or control flow from diverging between test and production builds and avoids conditional unused-import or dead-code warnings.fdomain_local::local_client_empty(), target_holders::fake_proxy (or fake_async_proxy), and TestBuffers to verify both Some(Format::Json) (including schema validation) and None (human-readable output) without an emulator or network connection:#[cfg(test)] mod tests { use super::*; use ffx_writer::{Format, TestBuffers}; fn setup_fake_proxy() -> NameProviderProxy { let client = fdomain_local::local_client_empty(); target_holders::fake_proxy::<NameProviderProxy>(client, move |req| match req { fdomain_fuchsia_device::NameProviderRequest::GetDeviceName { responder } => { responder.send(Ok("fuchsia-test-node")).unwrap(); } }) } #[fuchsia::test] async fn test_example_json_output() { let tool = ExampleTool { cmd: ExampleCommand {}, proxy: setup_fake_proxy() }; let buffers = TestBuffers::default(); let writer = VerifiedMachineWriter::<ExampleOutput>::new_test(Some(Format::Json), &buffers); tool.main(writer).await.expect("tool should succeed"); let output = buffers.into_stdout_str(); VerifiedMachineWriter::<ExampleOutput>::verify_schema(&serde_json::from_str(&output).unwrap()) .expect("output must match schema"); } #[fuchsia::test] async fn test_example_human_output() { let tool = ExampleTool { cmd: ExampleCommand {}, proxy: setup_fake_proxy() }; let buffers = TestBuffers::default(); let writer = VerifiedMachineWriter::<ExampleOutput>::new_test(None, &buffers); tool.main(writer).await.expect("tool should succeed"); assert_eq!(buffers.into_stdout_str(), "Device: fuchsia-test-node\n"); } }
ffx_config, initialize an isolated test environment with let test_env = ffx_config::test_init().expect("test env"); and pass &test_env.context.BUILD.gn: Always include a tests group in the subtool/library BUILD.gn and ensure it is wired into the parent tests group (e.g., //src/developer/ffx/tools/BUILD.gn):group("tests") { testonly = true deps = [ ":lib_test($host_toolchain)" ] }
ffx_e2e_emu): When an integration test against a real Fuchsia system is necessary, use //src/developer/ffx/lib/e2e_emu (IsolatedEmulator) and //src/developer/ffx/lib/isolate so the test runs in a sandboxed isolate directory without polluting the developer's host environment.ssh binary), do not construct the Python script as an inline string literal (r#"..."# or format!(...)) inside the Rust test. Instead:test_data/<script>.py (e.g., test_data/mock_driver.py), and start the file with:#!/usr/bin/env python3 # allow-non-vendored-pythonIn Infra test environments, an in-tree vendored Python interpreter is not necessarily available; using
/usr/bin/env python3 alongside # allow-non-vendored-python ensures the script runs reliably both in Infra and on a developer's machine while passing presubmit shebang checks. Pass any dynamic per-test parameters via CLI flags or environment variables rather than interpolating values into Python source code.inputs list in BUILD.gn so GN tracks it for incremental rebuilds:inputs = [ "test_data/mock_driver.py" ]
include_str!("../test_data/mock_driver.py"), write it to the test's TempDir, and set its permissions to 0o755 before invoking it.