ffx Development Guide for AI Agents

ffx (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.


1. Subtool Architecture & Code Organization

External Subtools (tools/) over Built-in Plugins (plugins/)

  • New subtools belong in //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).
  • Avoid adding new subtools to //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.
  • Shared libraries belong in //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.

Split Subtools into lib.rs and main.rs

Structure 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.
  • Rust Edition: Use edition = "2024" in all new BUILD.gn targets.

2. Daemonless Architecture & FDomain Target Connections

ffx operates on a daemonless, direct-connection architecture.

Do Not Use Legacy Daemon or Overnet APIs

  • No ffx-daemon (DaemonProxy): Never introduce dependencies on DaemonProxy, fidl_fuchsia_developer_ffx::DaemonProxy, or daemon.* configuration keys.
  • No Host FIDL (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)
  • Use FDomain (*_rust_fdomain) Instead of Overnet (*_rust):
    • Target FIDL communication uses FDomain (fdomain_client) over direct SSH, VSOCK, or USB transports.
    • In 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.

3. FHO (//src/developer/ffx/lib/fho) & Dependency Injection

Subtools 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,
}

Target Connection Declarations & Holders (//src/developer/ffx/lib/target/holders)

  • Tools that do NOT talk to a target device: Always annotate the FfxTool struct with #[target(None)]. This is required so ffx --strict does not demand a --target argument when running the command.
  • Immediate Target Injection: Use #[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.
  • Conditional / Lazy Target Injection (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.
  • Resilient Multi-Shot Reconnection (Connector<T>): For workflows that reboot or flash the target device and must reconnect across disconnects, use Connector<RemoteControlProxyHolder> or DirectConnector (try_connect()).

4. Structured Output & Writers (//src/developer/ffx/lib/writer)

Never use println! or eprintln! for tool output. Always write through the Writer passed to FfxMain::main.

Prefer Supporting Both Machine and Human-Readable Output in New Subtools

When creating a new subtool, always prefer supporting both structured machine output (ffx --machine json / ffx --machine json-pretty) and human-readable terminal output:

  • Machine output (VerifiedMachineWriter<T> with schemars::JsonSchema) lets scripts, test harnesses, IDE integrations, and AI agents consume results reliably with a compile-time schema contract.
  • Human-readable output ensures developers running 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(())
    }
}

Choosing the Right VerifiedMachineWriter Method

  • writer.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.
  • Branching on writer.is_machine(): When human output requires multi-line formatting, tables, or streaming progress, handle both branches explicitly:
    • Call writer.machine(&output)? when writer.is_machine() is true.
    • Call writer.line(...) or writeln!(writer, ...) when writer.is_machine() is false.

Exceptions to Dual-Output

While supporting both machine and human-readable output is the default expectation for new subtools, the following categories are valid exceptions:

  • Interactive-Only / Human-Only Tools: Subtools that launch interactive shells, TUIs, or debuggers (e.g., ffx component explore, ffx debug connect) should use SimpleWriter (SimpleWriter automatically rejects --machine json).
  • Stream / Filter Tools: Subtools that continuously filter or transform byte/text streams over stdio (e.g., ffx debug symbolize) should use SimpleWriter unless each streamed record has a well-defined JSON schema.
  • Commands with No Required Human Output: Action-only commands that succeed silently in human mode (e.g., 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.
  • Artifact / Extraction Tools: Subtools whose primary responsibility is writing extracted files to disk rather than reporting state to stdout (e.g., ffx scrutiny extract blobfs) may use SimpleWriter or emit a minimal status summary.

Output Pitfalls to Avoid

  • Never call only 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.
  • Never call only 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.
  • Do not abuse 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.
  • Golden Checks (cli-goldens & mw-goldens):
    • CLI flags (ArgsInfo) are verified against //src/developer/ffx/tests/cli-goldens.
    • Machine output schemas (JsonSchema) are verified against //src/developer/ffx/tests/mw-goldens.
    • If you modify CLI arguments or machine output types, run the golden tests and update the golden files as instructed by the build failure.

5. Error Handling (//src/developer/ffx/lib/command/error)

Moratorium on anyhow

  • Do NOT use anyhow (anyhow::Error, anyhow::Result, anyhow!, bail!, Context) in new or refactored ffx code, whether in subtools or library crates.
  • Why: 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.
  • What to use instead:
    • In library crates (//src/developer/ffx/lib/*): Define strongly-typed domain error enums using #[derive(thiserror::Error, Debug)].
    • In subtools (//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.

User Errors vs. Internal Bugs

ffx distinguishes between Actionable User Errors and Unexpected Internal Bugs via fho::Error (ffx_command_error::Error):

  1. User Errors (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.
  2. Internal Bugs (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.

Best Practices for Error Propagation

  • Use fho::FfxContext instead of legacy ffx_error! / ffx_bail! or anyhow macros:
    • Attach user-facing context with .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()))?;
      
    • Mark internal invariant failures with .bug() or .bug_context("..."):
      let parsed = parse_internal_state().bug_context("Internal state corrupted")?;
      
    • For early returns or standalone errors, use fho::return_user_error!(...), fho::user_error!(...), fho::return_bug!(...), or fho::bug!(...).
  • Actionable Error Messages: State what failed first, followed by how the user can resolve it (e.g., "Target connection failed. Run 'ffx target list' or 'ffx doctor' to verify device state.").

6. Configuration (//src/developer/ffx/config)

ffx resolves configuration across a 5-level priority hierarchy (ConfigLevel in //src/developer/ffx/config):

  1. Runtime (--config / -c key=val CLI flags — highest priority)
  2. User (~/.fuchsia/config.json)
  3. Build (active build directory configuration, read-only)
  4. Global (system-wide policy configuration)
  5. Default (compiled-in defaults via include_default!() — lowest priority)
  • Always Thread EnvironmentContext Explicitly:
    • Never rely on ambient global configuration state when an 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,
      }
      
  • Querying Configuration via EnvironmentContext:
    • Use 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.
    • For structured config-backed types, use #[derive(FfxConfigBacked)] (//src/developer/ffx/config/macro) with #[ffx_config_default(key = "...", default = "...")] attributes, or implement ffx_config::TryFromEnvContext.
  • Define Configuration Keys in //src/developer/ffx/config/src/keys.rs & Use Constants for Defaults:
    • No Inline Config Key Strings: Never pass raw string literals (e.g., 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.
    • No Magic Default Values: Never pass hardcoded numeric or string literals directly to .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.
  • Support 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()).

7. Testing Guidelines

Use #[fuchsia::test] for All Unit Tests

  • Always annotate unit tests with #[fuchsia::test]: Use #[fuchsia::test] for both synchronous and async unit tests across ffx libraries and subtools.
  • Do not use legacy or runtime-specific test macros: Avoid #[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.

Keep #[cfg(test)] Confined to the test / tests Module

  • Avoid scattering #[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.
  • Place all test helpers, utilities, and test-only methods inside the test module:
    • In Rust, a child #[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.
    • When test utilities are shared across multiple modules within a crate, consolidate them into a single #[cfg(test)] mod test_utils (or a dedicated testonly = true crate if shared across crates) instead of sprinkling #[cfg(test)] throughout production code.
    • Keeping non-test modules free of #[cfg(test)] prevents struct layouts or control flow from diverging between test and production builds and avoids conditional unused-import or dead-code warnings.

Unit Testing Subtools

  • Test Both Machine and Human-Readable Output with Local FDomain Proxies: Use 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");
        }
    }
    
  • Isolated Config in Tests: When testing code that reads or writes ffx_config, initialize an isolated test environment with let test_env = ffx_config::test_init().expect("test env"); and pass &test_env.context.
  • Registering Host Tests in 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)" ]
    }
    
  • End-to-End Tests (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.
  • Python Test Scripts: When a test requires a helper or mock executable written in Python (e.g., a mock driver or fake ssh binary), do not construct the Python script as an inline string literal (r#"..."# or format!(...)) inside the Rust test. Instead:
    1. Place the standalone script in test_data/<script>.py (e.g., test_data/mock_driver.py), and start the file with:
      #!/usr/bin/env python3
      # allow-non-vendored-python
      
      In 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.
    2. Declare the script in the target's inputs list in BUILD.gn so GN tracks it for incremental rebuilds:
      inputs = [ "test_data/mock_driver.py" ]
      
    3. Embed the script in the Rust test using include_str!("../test_data/mock_driver.py"), write it to the test's TempDir, and set its permissions to 0o755 before invoking it.