From 422736c9296899cb1a482c0360bb6426982b2f33 Mon Sep 17 00:00:00 2001 From: Juan Olveira Date: Fri, 22 May 2026 10:59:04 +0000 Subject: [PATCH 1/4] docs/cli: add cli-standard.md and CLAUDE.md pointer --- CHANGELOG.md | 2 + CLAUDE.md | 7 ++ docs/cli-standard.md | 244 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 docs/cli-standard.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 837f61df9e..432b001b79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ All notable changes to this project will be documented in this file. - Centralize top-level error rendering through `doublezero_cli_core::error::render_eyre`. Replaces three ad-hoc `eprintln!("Error: {e}")` sites in `client/doublezero/src/main.rs` (env-parse failure, env-config resolution failure, top-level command failure) with a single helper that prints `Error: ` followed by the full chain of causes on stderr. - Rename the `smartcontract/cli/` crate from `doublezero_cli` to `doublezero-serviceability-cli` to satisfy RFC-20's module-crate naming contract (`doublezero--cli` in kebab-case). The crate stays at `smartcontract/cli/`; only the `[package].name` and `[lib].name` change (lib name is `doublezero_serviceability_cli` because Rust requires underscores in import paths). All in-tree consumers are updated: `client/doublezero`, `client/doublezero-geolocation-cli`, `controlplane/doublezero-admin`, and the workspace `Cargo.toml`. External operators who depend on the workspace crate by its old name (`doublezero_cli`) must update their `Cargo.toml` and `use` statements. No user-facing command, flag, or output change. - Migrate `location get` to the RFC-20 conforming verb pattern as the project's reference. `GetLocationCliCommand::execute` is now `async fn`, takes `&CliContext` as its first non-self argument, and emits a `tracing::debug!` event so `-v` surfaces what the verb is doing. The verb's user-facing args, flags, table layout, and JSON schema are unchanged. The unit test consumes the shared `doublezero_cli_core::testing::cli_context_default_for_tests()` helper and continues to use the existing `MockCliCommand` (auto-generated by `#[automock]`) as the backend. Binary dispatch arms in `client/doublezero` and `controlplane/doublezero-admin` are updated to `.await` the new method; other location verbs (Create, Update, List, Delete) keep their current sync signatures and migrate opportunistically. + - Add `docs/cli-standard.md`, the contributor-facing summary of RFC-20 with the `location get` worked example and pointers to the shared validators, formatters, logging facade, and test helpers in `doublezero-cli-core`. + - Update `CLAUDE.md` with a CLI-standard section pointing at RFC-20, the contributor doc, and the reference verb so future contributors land on the standard quickly. ## [v0.24.0](https://github.com/malbeclabs/doublezero/compare/client/v0.23.0...client/v0.24.0) - 2026-05-22 diff --git a/CLAUDE.md b/CLAUDE.md index 8e70d35363..1ab9e77c90 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,6 +100,13 @@ make generate-fixtures # Regenerate .bin/.json fixtures from Rust - When asked if a doc is up to date, evaluate it against its intended purpose and scope — not against whatever was most recently worked on. Implementation bug fixes and edge-case handling are not design decisions. Don't inflate docs with implementation details just because they're fresh in context. +## CLI Standard (RFC-20) + +- New CLI verbs and module crates follow RFC-20 (`rfcs/rfc20-cli-standardization.md`). A contributor-facing summary lives at `docs/cli-standard.md`, with `smartcontract/cli/src/location/get.rs` as the reference verb. +- Shared CLI utilities (`CliContext`, validators, formatters, `RequirementCheck`, `init_logging`) live in `crates/doublezero-cli-core/`. Verbs MUST consume the shared validators and route diagnostic output through `tracing`. The `doublezero` binary owns global flags (`--env`, `--url`, `--ws`, `--solana-url`, `--keypair`, `--program-id`, `--geo-program-id`, `--sock-file`, `--verbose`, `--version`); modules MUST NOT redeclare them. +- The serviceability module crate is named `doublezero-serviceability-cli` (crate path `smartcontract/cli/`, import path `doublezero_serviceability_cli`). +- Migration is opportunistic. Existing verbs are grandfathered; new verbs conform from day one. + ## Style & Terminology - Use "onchain" (one word, no hyphen), never "on-chain" diff --git a/docs/cli-standard.md b/docs/cli-standard.md new file mode 100644 index 0000000000..43ab04c2eb --- /dev/null +++ b/docs/cli-standard.md @@ -0,0 +1,244 @@ +# DoubleZero CLI Standard (RFC-20) + +This document is a contributor-facing summary of the CLI standard defined by +[RFC-20: CLI Standardization and Library Composition](../rfcs/rfc20-cli-standardization.md). +The RFC is the normative source; this page is the day-to-day reference for +writing a new verb or migrating an existing one. + +## The shape + +DoubleZero ships a single `doublezero` binary. The binary is thin: it parses +global flags, builds a `CliContext`, then dispatches to verbs that live in +**module crates**. Each module crate is a library named +`doublezero--cli` and conforms to a fixed contract. + +``` +doublezero (binary) client/doublezero/ + └─ CliContext + dispatch +doublezero-cli-core (shared library) crates/doublezero-cli-core/ + ├─ CliContext, CliContextBuilder, OutputFormat + ├─ RequirementCheck (preflight bitflags) + ├─ shared validators (pubkey, code, bandwidth, latency, ...) + ├─ display formatters + ├─ init_logging (tracing facade) + └─ testing helpers +doublezero-serviceability-cli (module) smartcontract/cli/ +doublezero--cli ... +``` + +The core crate stays small on purpose: it depends on `clap`, the logging +facade, `doublezero-config`, and `doublezero-program-common`. The Solana +SDK, daemon HTTP stack, and remote-service transports live with the module +crates that use them. + +## The module contract + +A CLI module crate **MUST**: + +1. Be a library-only crate named `doublezero--cli`. No `[[bin]]`. +2. Export at least one top-level subcommand type that derives clap's + `Subcommand`. Verbs are variants. +3. Provide an `async fn execute` on each subcommand type. The runtime lives + in the binary; modules MUST NOT call `block_on` or hide async work behind + a sync facade. +4. Define per-verb args and display types **colocated** with the verb. +5. Consume `CliContext` for environment-derived inputs. Modules MUST NOT + read environment variables, configuration files, or `argv` directly. +6. Use the shared validators (`validate_pubkey`, `validate_pubkey_or_code`, + `validate_code`, `validate_parse_bandwidth`, ...) from + `doublezero_cli_core::validators` wherever those types appear. +7. Send all output through the writer. `println!`, `eprintln!`, and + `print!` MUST NOT appear in execute paths. Diagnostic logging goes + through `tracing` (stderr). + +A module **SHOULD** keep each verb in a single file, expose its backend +client(s) behind a mockable trait, and provide per-verb unit tests against a +mocked client. + +## Argument conventions + +- Named flags only. No positional arguments. +- Long names in kebab-case. +- Short aliases on booleans only. +- Identifiers that reference an onchain entity use `validate_pubkey_or_code` + and accept either a pubkey or the entity's code. The literal `"me"` + resolves to the current payer. +- Repeatable inputs use one flag per value (`--add a --add b`), not comma + lists. +- No env-var reads at the verb level. Anything an operator might set in + their environment is parsed at the binary's global-flag layer and + surfaced through `CliContext`. + +## Output conventions + +- Default output is a table. +- Every `get`, `list`, and read command MUST expose `--json`. The display + type MUST be `Serialize`. Pubkey fields use the shared stable + serializer. +- Commands MAY additionally expose `--json-compact` for single-line JSON. + The flag name is fixed. +- Mutating commands print the transaction signature and post-confirmation + status; they MAY accept `-w` / `--wait` to poll for activation. +- All user-facing output flows through the writer passed to `execute`. + +## Global flags + +The binary owns these globals; modules MUST NOT redeclare them: + +| Flag | Purpose | +| ---- | ------- | +| `--env` | Primary config knob; selects deployment and resolves URLs, program IDs, and default service endpoints. | +| `--url` | DZ ledger RPC URL override (does NOT affect Solana L1). | +| `--ws` | DZ ledger WebSocket URL override. | +| `--solana-url` | Solana L1 RPC URL override (does NOT affect DZ ledger). | +| `--keypair` | Path to signer keypair file. | +| `--program-id` | Serviceability program ID override. | +| `--geo-program-id` | Geolocation program ID override. | +| `--sock-file` | Daemon Unix socket path override. | +| `--no-version-warning` | Suppress version-check banner. | +| `--verbose`, `-v` | Diagnostic logging at debug (`-v`) or trace (`-vv`). | +| `--version`, `-V` | Print version and exit. | + +`--env` resolves through `doublezero-config`. Recognized values are +`mainnet-beta`/`m`, `testnet`/`t`, `devnet`/`d`, `local`/`l`. + +## Diagnostic logging + +Diagnostic output goes to **stderr** via `tracing`. Modules use the +standard log macros (`debug!`, `info!`, `warn!`, `error!`, `trace!`) for +anything that explains what a verb is doing internally: backend requests, +retries, pubkey-or-code resolution, polling progress. + +```rust +tracing::debug!(env = %ctx.env, code = %self.code, "location get"); +``` + +Modules MUST NOT call `init_subscriber` themselves; the binary calls +`doublezero_cli_core::init_logging(verbosity)` once at startup. The +`RUST_LOG` env var overrides verbosity for per-module filtering. + +JSON output on stdout stays parseable at every verbosity level because logs +go to stderr. + +## Reference verb: `location get` + +`smartcontract/cli/src/location/get.rs` is the worked example. It demonstrates +the conforming pattern end to end: + +```rust +use clap::Args; +use doublezero_cli_core::CliContext; +use doublezero_sdk::commands::location::get::GetLocationCommand; +use serde::Serialize; +use std::io::Write; +use tabled::Tabled; + +use crate::{doublezerocommand::CliCommand, validators::validate_pubkey_or_code}; + +#[derive(Args, Debug)] +pub struct GetLocationCliCommand { + /// Location Pubkey or code to get details for + #[arg(long, value_parser = validate_pubkey_or_code)] + pub code: String, + /// Output as JSON + #[arg(long)] + pub json: bool, +} + +#[derive(Tabled, Serialize)] +struct LocationDisplay { /* ... */ } + +impl GetLocationCliCommand { + pub async fn execute( + self, + ctx: &CliContext, + client: &C, + out: &mut W, + ) -> eyre::Result<()> { + tracing::debug!(env = %ctx.env, code = %self.code, "location get"); + + let (pubkey, location) = client.get_location(GetLocationCommand { + pubkey_or_code: self.code, + })?; + + let display = LocationDisplay { /* ... */ }; + if self.json { + writeln!(out, "{}", serde_json::to_string_pretty(&display)?)?; + } else { + // render table via Tabled + } + Ok(()) + } +} +``` + +Unit test (excerpt): + +```rust +use doublezero_cli_core::testing::cli_context_default_for_tests; + +let ctx = cli_context_default_for_tests(); +let mut output = Vec::new(); +let res = block_on( + GetLocationCliCommand { code: "test".into(), json: true } + .execute(&ctx, &client, &mut output), +); +assert!(res.is_ok()); +``` + +The test uses `MockCliCommand` (auto-generated by `#[automock]` on the +`CliCommand` trait) as the backend, and the shared +`cli_context_default_for_tests()` helper from +`doublezero_cli_core::testing` to build a `CliContext` with sensible +defaults. + +## Preflight checks + +Verbs MAY call `RequirementCheck` to gate on common preconditions: + +```rust +use doublezero_cli_core::RequirementCheck; + +let checks = RequirementCheck::KEYPAIR | RequirementCheck::BALANCE; +``` + +The bitflags align with the legacy `CHECK_ID_JSON | CHECK_BALANCE | +CHECK_FOUNDATION_ALLOWLIST` `u8` constants in +`smartcontract/cli/src/requirements.rs`: + +| Flag | Bit | +| ---- | --- | +| `RequirementCheck::KEYPAIR` | `0b001` | +| `RequirementCheck::BALANCE` | `0b010` | +| `RequirementCheck::FOUNDATION_ALLOWLIST` | `0b100` | + +The actual `check_requirements` function lives with the module that owns +the typed backend client (today, `smartcontract/cli/src/requirements.rs`). +The bitflag type is shared so future modules consume the same canonical +set. + +## Authorization + +Authorization is **onchain**. The CLI is a thin client. The program +rejects unauthorized signers; the CLI surfaces the error. Modules MUST NOT +gate verbs by inspecting the caller's identity. + +## Migration is opportunistic + +RFC-20 explicitly grandfathers existing CLI surfaces. Existing verbs keep +their current shape until they are touched for unrelated work. New verbs +MUST conform from day one. When you touch a legacy verb, prefer to +migrate it to the conforming pattern; if migration would balloon the +change, leave it for a follow-up and note it in the PR description. + +## Open follow-ups + +Tracked in RFC-20 §Open Questions and in this work's plan: + +- Serviceability `Command` enum lives in the binary today; future PR moves + it into `smartcontract/cli` with `#[command(flatten)]` mounting. +- Geolocation module crate (defer per current scope). +- Daemon-control verbs (Connect, Status, Enable, Disable, Latency, Routes) + become their own module crate. +- JSON schema versioning once `--json` is a stable contract. +- Shell-completion install location. From 62628aaca682e64a7870d444a15c2d5703b040f3 Mon Sep 17 00:00:00 2001 From: Juan Olveira Date: Sun, 24 May 2026 15:07:26 +0000 Subject: [PATCH 2/4] docs/cli: clarify "me" resolution is verb-level, not validator-level --- crates/doublezero-cli-core/src/validators.rs | 16 ++++++++++++---- docs/cli-standard.md | 10 +++++++--- rfcs/rfc20-cli-standardization.md | 6 +++--- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/crates/doublezero-cli-core/src/validators.rs b/crates/doublezero-cli-core/src/validators.rs index 6a9018906b..86d19bf039 100644 --- a/crates/doublezero-cli-core/src/validators.rs +++ b/crates/doublezero-cli-core/src/validators.rs @@ -1,11 +1,19 @@ //! Shared `clap` value-parser validators. //! -//! Per RFC-20 (§Argument conventions): "Identifiers accept both pubkey and +//! Per RFC-20 (§Argument conventions): identifiers accept both pubkey and //! code. Any flag that references an onchain entity MUST accept either a //! Solana pubkey or the entity's human-readable code via the shared -//! validator. The magic value `\"me\"` resolves to the current payer's pubkey -//! at execution time." Module crates re-export and consume these helpers -//! rather than re-implementing them. +//! validator. Module crates re-export and consume these helpers rather than +//! re-implementing them. +//! +//! Resolution of the literal `"me"` to the current payer's pubkey is a +//! verb-level responsibility, performed in the verb's `execute` path using +//! the payer pubkey from `CliContext`. The validators here only enforce +//! grammar; they do not perform runtime resolution. `validate_pubkey` +//! short-circuits `"me"` because pubkey-only fields have no code fallback; +//! `validate_pubkey_or_code` admits `"me"` as a syntactically valid code, +//! and verbs that opt in to payer resolution check for the literal +//! themselves. use doublezero_program_common::{types::parse_utils::bandwidth_parse, validate_account_code}; use solana_sdk::pubkey::Pubkey; diff --git a/docs/cli-standard.md b/docs/cli-standard.md index 43ab04c2eb..76801726a0 100644 --- a/docs/cli-standard.md +++ b/docs/cli-standard.md @@ -61,8 +61,12 @@ mocked client. - Long names in kebab-case. - Short aliases on booleans only. - Identifiers that reference an onchain entity use `validate_pubkey_or_code` - and accept either a pubkey or the entity's code. The literal `"me"` - resolves to the current payer. + and accept either a pubkey or the entity's code. Where a flag denotes a + signer or payer-scoped entity (for example `--administrator`, + `--user-payer`, `--contributor`), the verb MAY also accept the literal + `"me"` and resolve it to the current payer's pubkey at execution time. + `"me"` resolution is a verb-level responsibility, not a validator + behavior; verbs that do not opt in will treat `"me"` as a literal code. - Repeatable inputs use one flag per value (`--add a --add b`), not comma lists. - No env-var reads at the verb level. Anything an operator might set in @@ -96,7 +100,7 @@ The binary owns these globals; modules MUST NOT redeclare them: | `--geo-program-id` | Geolocation program ID override. | | `--sock-file` | Daemon Unix socket path override. | | `--no-version-warning` | Suppress version-check banner. | -| `--verbose`, `-v` | Diagnostic logging at debug (`-v`) or trace (`-vv`). | +| `--log-verbose` | Diagnostic logging. Repeat for higher levels: once raises to `debug`, twice raises to `trace`. No short alias because `connect`/`disconnect` still own `-v`/`--verbose` for their own flags. | | `--version`, `-V` | Print version and exit. | `--env` resolves through `doublezero-config`. Recognized values are diff --git a/rfcs/rfc20-cli-standardization.md b/rfcs/rfc20-cli-standardization.md index 0d7006f1a8..d48e03bd79 100644 --- a/rfcs/rfc20-cli-standardization.md +++ b/rfcs/rfc20-cli-standardization.md @@ -110,7 +110,7 @@ Modules MUST NOT mutate `CliContext` and MUST NOT re-resolve any value from `--e - **Short aliases on booleans only.** Boolean toggles MAY declare a single-letter short alias. Non-boolean flags MUST NOT use short aliases. -- **Identifiers accept both pubkey and code.** Any flag that references an onchain entity MUST accept either a Solana pubkey or the entity's human-readable code via the shared validator. The magic value `"me"` resolves to the current payer's pubkey at execution time. +- **Identifiers accept both pubkey and code.** Any flag that references an onchain entity MUST accept either a Solana pubkey or the entity's human-readable code via the shared validator. Where a flag denotes a signer or payer-scoped entity (for example `--administrator`, `--user-payer`, `--contributor`), the verb MAY also accept the literal `"me"` and resolve it to the current payer's pubkey at execution time. `"me"` resolution is a verb-level responsibility, performed in the verb's `execute` path using the payer pubkey from `CliContext`; the shared validators only enforce grammar. Verbs that do not opt in will treat `"me"` as a literal code. - **Repeatable inputs use one flag per value.** A list of permissions is `--add perm1 --add perm2`, not `--add perm1,perm2`. Exception: values that are naturally lists (such as CIDR prefix lists) MAY use a typed list parser. @@ -136,7 +136,7 @@ Modules MUST NOT mutate `CliContext` and MUST NOT re-resolve any value from `--e Diagnostic output is separate from user-facing output and goes to standard error through the shared logging facade in the CLI core crate. Modules use the standard log macros (`debug!`, `info!`, `warn!`, `error!`, and `trace!` when finer granularity is justified) for anything that explains what a verb is doing internally: backend requests issued, retries, resolution of pubkey-or-code arguments, polling progress, and similar. -The binary configures the global log level from `--verbose`: warnings and errors only by default, `debug` when `--verbose` is set, and `trace` when `-vv` is set. Modules MUST NOT set or override the log level themselves and MUST NOT use `println!` or `eprintln!` for diagnostics. JSON output remains parseable regardless of `--verbose` because diagnostic logs go to stderr and the user-facing writer goes to stdout. +The binary configures the global log level from `--log-verbose`: warnings and errors only by default, `debug` when `--log-verbose` is set once, and `trace` when set twice (`--log-verbose --log-verbose`). The flag is spelled `--log-verbose` rather than `--verbose, -v` because `connect` and `disconnect` still own their own per-subcommand `--verbose` (`-v`) flags from earlier releases; a future RFC may deprecate those and reclaim the shorter spelling. Modules MUST NOT set or override the log level themselves and MUST NOT use `println!` or `eprintln!` for diagnostics. JSON output remains parseable regardless of `--log-verbose` because diagnostic logs go to stderr and the user-facing writer goes to stdout. ### Environments and configuration resolution @@ -168,7 +168,7 @@ The unified binary owns the following global flags, propagated to every subcomma | `--geo-program-id` | Geolocation program ID override. | | `--sock-file` | Daemon Unix socket path override. | | `--no-version-warning` | Suppress the version-check banner. | -| `--verbose`, `-v` | Enable diagnostic logging at `debug` level. Repeating (`-vv`) MAY raise the level to `trace`. | +| `--log-verbose` | Enable diagnostic logging. Repeating (`--log-verbose --log-verbose`) raises the level from `debug` to `trace`. No short alias yet because `connect`/`disconnect` still own `-v`/`--verbose` for legacy per-subcommand flags. | | `--version`, `-V` | Print the binary version and exit. | The DZ-ledger and Solana-L1 transports use separate override flags by design: confusing the two leads to verbs that quietly run against the wrong network. When `--env` is set, all transports resolve consistently; when an override is needed for one transport, the others continue to follow `--env`. From 605202354ae3054bddd2d0376680f8e6d72eca65 Mon Sep 17 00:00:00 2001 From: Juan Olveira Date: Mon, 25 May 2026 13:28:18 +0000 Subject: [PATCH 3/4] docs/cli: update CLI standard to include new global flag `--log-verbose` --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 1ab9e77c90..752b1cfc71 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -103,7 +103,7 @@ make generate-fixtures # Regenerate .bin/.json fixtures from Rust ## CLI Standard (RFC-20) - New CLI verbs and module crates follow RFC-20 (`rfcs/rfc20-cli-standardization.md`). A contributor-facing summary lives at `docs/cli-standard.md`, with `smartcontract/cli/src/location/get.rs` as the reference verb. -- Shared CLI utilities (`CliContext`, validators, formatters, `RequirementCheck`, `init_logging`) live in `crates/doublezero-cli-core/`. Verbs MUST consume the shared validators and route diagnostic output through `tracing`. The `doublezero` binary owns global flags (`--env`, `--url`, `--ws`, `--solana-url`, `--keypair`, `--program-id`, `--geo-program-id`, `--sock-file`, `--verbose`, `--version`); modules MUST NOT redeclare them. +- Shared CLI utilities (`CliContext`, validators, formatters, `RequirementCheck`, `init_logging`) live in `crates/doublezero-cli-core/`. Verbs MUST consume the shared validators and route diagnostic output through `tracing`. The `doublezero` binary owns global flags (`--env`, `--url`, `--ws`, `--solana-url`, `--keypair`, `--program-id`, `--geo-program-id`, `--sock-file`, `--log-verbose`, `--version`); modules MUST NOT redeclare them. - The serviceability module crate is named `doublezero-serviceability-cli` (crate path `smartcontract/cli/`, import path `doublezero_serviceability_cli`). - Migration is opportunistic. Existing verbs are grandfathered; new verbs conform from day one. From e096e1e37fa72e636786509659a53eed69256685 Mon Sep 17 00:00:00 2001 From: Juan Olveira Date: Wed, 27 May 2026 20:28:53 +0000 Subject: [PATCH 4/4] docs(cli-standard): clarify mutating commands output behavior --- docs/cli-standard.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cli-standard.md b/docs/cli-standard.md index 76801726a0..6dd1ed2906 100644 --- a/docs/cli-standard.md +++ b/docs/cli-standard.md @@ -82,7 +82,7 @@ mocked client. - Commands MAY additionally expose `--json-compact` for single-line JSON. The flag name is fixed. - Mutating commands print the transaction signature and post-confirmation - status; they MAY accept `-w` / `--wait` to poll for activation. + status. - All user-facing output flows through the writer passed to `execute`. ## Global flags