| # `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: |
| ```rust |
| 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. |
| |
| ```rust |
| 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: |
| |
| ```rust |
| 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!(...))`: |
| ```rust |
| 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("...")`: |
| ```rust |
| 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: |
| ```rust |
| #[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: |
| ```rust |
| #[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`): |
| ```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: |
| ```python |
| #!/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: |
| ```gn |
| 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. |