Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <head>` 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-<module>-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

Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `--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.

## Style & Terminology

- Use "onchain" (one word, no hyphen), never "on-chain"
Expand Down
16 changes: 12 additions & 4 deletions crates/doublezero-cli-core/src/validators.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
248 changes: 248 additions & 0 deletions docs/cli-standard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
# 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-<module>-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-<future-module>-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-<module>-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. 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
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.
- 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. |
| `--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
`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<C: CliCommand, W: Write>(
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.
6 changes: 3 additions & 3 deletions rfcs/rfc20-cli-standardization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down Expand Up @@ -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`.
Expand Down
Loading