Document every CLI argument

This commit is contained in:
Eric Wendland 2026-07-18 16:08:19 +02:00
commit c20cc47914
2 changed files with 226 additions and 4 deletions

View file

@ -1,6 +1,6 @@
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use base64::Engine; use base64::Engine;
use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use clap_complete::{Shell, generate}; use clap_complete::{Shell, generate};
use geth_config::GethPaths; use geth_config::GethPaths;
use geth_control::{ControlRequest, ControlResponse, SyncStreamStatus}; use geth_control::{ControlRequest, ControlResponse, SyncStreamStatus};
@ -727,6 +727,9 @@ pub enum NodeCommand {
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
pub enum NodeEnrollCommand { pub enum NodeEnrollCommand {
/// Create a signed enrollment request on the new node /// Create a signed enrollment request on the new node
#[command(
after_help = "Examples:\n geth node enroll request --node-name workstation --out workstation.enroll.json\n geth node enroll request --node-name ci-runner --capability resource:kv:builds=kv.read"
)]
Request { Request {
#[arg(long)] #[arg(long)]
node_name: String, node_name: String,
@ -988,6 +991,9 @@ pub enum AuthCommand {
/// Pull verified authorization operations from a trusted peer /// Pull verified authorization operations from a trusted peer
Sync { node: String }, Sync { node: String },
/// Add an admin-signed resource capability grant /// Add an admin-signed resource capability grant
#[command(
after_help = "Example:\n geth auth grant <node-id> resource:kv:preferences kv.read --signing-key ~/.ssh/id_ed25519"
)]
Grant { Grant {
subject: String, subject: String,
resource: String, resource: String,
@ -1138,6 +1144,9 @@ pub enum CasRootCommand {
bearer_secret: Option<String>, bearer_secret: Option<String>,
}, },
/// Materialize a tree without overwriting local edits /// Materialize a tree without overwriting local edits
#[command(
after_help = "Examples:\n geth cas root apply photos --to ./restored-photos --dry-run\n geth cas root apply remote-<peer-id>-photos --to ./restored-photos"
)]
Apply { Apply {
source: String, source: String,
#[arg(long)] #[arg(long)]
@ -1182,6 +1191,9 @@ pub enum KvCommand {
/// Create a named local key-value store /// Create a named local key-value store
Create { name: String }, Create { name: String },
/// Set a local key, optionally checking a non-owner subject /// Set a local key, optionally checking a non-owner subject
#[command(
after_help = "Examples:\n geth kv set preferences theme dark\n geth kv set preferences theme dark --subject <node-id>"
)]
Set { Set {
name: String, name: String,
key: String, key: String,
@ -1459,7 +1471,8 @@ pub enum SshRevocationCommand {
pub struct EmptyArgs {} pub struct EmptyArgs {}
pub async fn run() -> Result<()> { pub async fn run() -> Result<()> {
let cli = Cli::parse(); let matches = documented_cli_command().get_matches();
let cli = Cli::from_arg_matches(&matches).context("parse geth command")?;
let json = cli.json || cli.jsonl; let json = cli.json || cli.jsonl;
match run_inner(cli).await { match run_inner(cli).await {
Ok(()) => Ok(()), Ok(()) => Ok(()),
@ -1471,6 +1484,173 @@ pub async fn run() -> Result<()> {
} }
} }
fn documented_cli_command() -> clap::Command {
document_missing_arguments(Cli::command(), "geth")
}
fn document_missing_arguments(mut command: clap::Command, path: &str) -> clap::Command {
let command_path = path.to_owned();
command = command.mut_args(|argument| {
if argument.get_help().is_some() {
argument
} else if let Some(help) = argument_help(&command_path, argument.get_id().as_str()) {
argument.help(help)
} else {
argument
}
});
command.mut_subcommands(|subcommand| {
let child_path = format!("{path} {}", subcommand.get_name());
document_missing_arguments(subcommand, &child_path)
})
}
fn argument_help(path: &str, id: &str) -> Option<&'static str> {
let contextual = match (path, id) {
("geth resource create", "kind") => {
Some("Resource kind, such as cas, kv, document, db, pipe, or pubsub")
}
("geth overlay interface-plan", "platform") => {
Some("Target platform: linux, macos, or windows; defaults to this host")
}
("geth ssh cert request", "kind") => Some("Certificate kind: user or host"),
("geth ssh revocation add", "kind") => {
Some("Revocation kind: public-key, certificate, serial, or key-id")
}
("geth cas conflict record", "kind") => {
Some("Conflict kind, such as concurrent-edit, delete-edit, or divergent-rename")
}
("geth pipe connect" | "geth pipe send", "target") => Some("Registered pipe listener name"),
("geth pipe forward-tcp", "target") => {
Some("Remote loopback TCP target, for example 127.0.0.1:5432")
}
("geth pipe forward-unix", "target") => Some("Absolute Unix socket path on the peer"),
("geth ssh revocation add", "target") => {
Some("Key, certificate, serial, or key-id selected by KIND")
}
("geth ssh admin-shell", "command") => Some("Restricted command: help, status, or node-id"),
("geth node enroll list", "status") => {
Some("Optional request status filter: pending, approved, or rejected")
}
("geth ssh revocation export" | "geth ssh revocation import", "format") => {
Some("Format: jsonl, openssh, or krl where supported")
}
("geth cas root apply", "source") => {
Some("Local or peer-qualified file-root name to materialize")
}
("geth cas conflict resolve", "resolution") => {
Some("Resolution: keep-local, accept-remote, keep-both, or manual")
}
("geth node enroll request", "capabilities") => {
Some("Requested RESOURCE=CAPABILITY pair; repeat to request more than one")
}
("geth kv create" | "geth kv set" | "geth kv get" | "geth kv sync", "name") => {
Some("Name of the KV store")
}
("geth kv set" | "geth kv get", "key") => Some("Key within the named KV store"),
("geth db add" | "geth db status" | "geth db changes" | "geth db sync", "name") => {
Some("Name of the database resource")
}
(
"geth document create"
| "geth document set"
| "geth document get"
| "geth document sync",
"name",
) => Some("Name of the document resource"),
("geth cas root add" | "geth cas root scan" | "geth cas root sync", "name") => {
Some("Name of the file root")
}
_ => None,
};
contextual.or(match id {
"topic" => Some("Embedded guide topic; omit it to list available topics"),
"shell" => Some("Shell whose completion script should be generated"),
"node" | "owner_node" => Some("Trusted node id or friendly node name"),
"timeout_ms" => Some("Maximum time to wait, in milliseconds"),
"interval_ms" => Some("Delay between readiness checks, in milliseconds"),
"out" => Some("Output file or directory; command output is used when omitted if supported"),
"backup_dir" => Some("Backup directory containing manifest.json and the home payload"),
"target_home" => Some("New empty directory that will receive the restored home"),
"node_name" => Some("Human-friendly name for the node"),
"capabilities" | "capability" => {
Some("Resource capability name; repeat the flag where supported")
}
"reason" => Some("Optional operator-readable reason recorded with the operation"),
"request_id" => Some("Enrollment or certificate request identifier"),
"path" => Some("Local input file or directory path"),
"signing_key" => {
Some("Private OpenSSH key, public agent key, or FIDO/YubiKey key stub used to sign")
}
"admin_key" => Some("Matching OpenSSH admin public key; inferred where possible"),
"name" => Some("Name of the command-specific local object"),
"resource" => Some("Canonical resource id, for example resource:kv:preferences"),
"grant_id" => Some("Stable grant identifier; generated when omitted where supported"),
"endpoint" => Some("Iroh EndpointID to bind to or revoke from the node"),
"kind" => Some("Command-specific object kind"),
"cidr" => Some("Overlay IPv4 CIDR, for example 172.22.0.0/24"),
"secret" => Some("Private resource or bearer token; it is never stored in command logs"),
"platform" => Some("Target operating-system platform"),
"bearer_secret" => Some("Optional resource-scoped bearer token for remote authorization"),
"mtu" => Some("Overlay interface MTU in bytes"),
"packet_base64" => Some("Complete IPv4 packet encoded as base64"),
"peek" => Some("Read current data without draining it"),
"principal" | "principals" => {
Some("OpenSSH signer or certificate principal; repeat where supported")
}
"valid_after_ms" => Some("Earliest validity time as Unix milliseconds"),
"valid_before_ms" => Some("Latest validity time as Unix milliseconds"),
"input" => Some("Input file path"),
"namespace" => Some("SSH signature namespace; defaults to the relevant geth namespace"),
"signature" => Some("OpenSSH signature file path"),
"allowed_signers" => Some("OpenSSH allowed_signers file used for verification"),
"base_url" => Some("Publication base URL recorded in signed discovery metadata"),
"snapshots" => Some("Named snapshot mapping NAME=PATH; repeatable"),
"checkpoint" => Some("Signed publication checkpoint file"),
"sigchain" => Some("Canonical keychain signature-chain JSONL file"),
"url" => Some("HTTPS URL to fetch"),
"import" => Some("Import the verified result into local state"),
"op_id" => Some("Canonical keychain operation identifier"),
"subject" => Some("Principal evaluated or authorized instead of the local owner"),
"expires_at_ms" => Some("Optional bearer expiration as Unix milliseconds"),
"token" => Some("Private bearer token returned when access was created"),
"nonce" => Some("Challenge nonce returned by the challenge command"),
"response" => Some("Bearer possession proof response"),
"bearer_id" => Some("Public bearer-access identifier, not the private token"),
"hash" => Some("BLAKE3 CAS blob hash"),
"dry_run" => Some("Preview changes without mutating local state or files"),
"source" => Some("Command-specific source object"),
"to" => Some("Destination directory"),
"root" => Some("File-root name; omit the filter to include all roots"),
"detail" => Some("Operator-readable conflict detail"),
"base_tree" => Some("Optional common-ancestor CAS tree hash"),
"local_tree" => Some("Optional local CAS tree hash"),
"remote_tree" => Some("Optional remote CAS tree hash"),
"conflict_id" => Some("Durable file-conflict identifier"),
"resolution" => Some("Operator-selected conflict resolution"),
"note" => Some("Optional operator note recorded with the action"),
"value" => Some("UTF-8 value to store"),
"key" => Some("Key name, signer id, or fingerprint selected by the command"),
"message" => Some("UTF-8 message payload"),
"target" => Some("Command-specific destination or target"),
"listen" => Some("Local loopback address or absolute Unix socket path to listen on"),
"after_db_version" => Some("Return changes strictly after this cr-sqlite db_version"),
"limit" => Some("Maximum number of change rows to read or synchronize"),
"state_json" => Some("Complete JSON object or value used as the new document state"),
"command" => Some("Restricted command name"),
"public_key" => Some("OpenSSH public key to certify"),
"valid_for" => Some("OpenSSH validity interval, for example 8h or 7d"),
"renewal_of" => Some("Certificate id this request renews"),
"serial" => Some("Explicit OpenSSH certificate serial number"),
"sign" => Some("Run ssh-keygen now and import the generated certificate"),
"cert" => Some("Signed OpenSSH certificate file"),
"format" => Some("Input or output format selected by the command"),
"ca_public" => Some("OpenSSH CA public key used for KRL generation"),
"ca_key" => Some("OpenSSH CA private key or hardware-key stub used to sign"),
_ => None,
})
}
async fn run_inner(cli: Cli) -> Result<()> { async fn run_inner(cli: Cli) -> Result<()> {
if let Command::Completions { shell } = &cli.command { if let Command::Completions { shell } = &cli.command {
print_completions(*shell); print_completions(*shell);
@ -1771,7 +1951,7 @@ fn print_guide(topic: Option<GuideTopic>, json: bool) -> Result<()> {
} }
fn print_completions(shell: Shell) { fn print_completions(shell: Shell) {
let mut command = Cli::command(); let mut command = documented_cli_command();
generate(shell, &mut command, "geth", &mut stdout()); generate(shell, &mut command, "geth", &mut stdout());
} }
@ -4619,7 +4799,7 @@ mod tests {
#[test] #[test]
fn help_describes_common_workflows_and_command_families() { fn help_describes_common_workflows_and_command_families() {
let help = Cli::command().render_long_help().to_string(); let help = documented_cli_command().render_long_help().to_string();
assert!(help.contains("geth daemon install")); assert!(help.contains("geth daemon install"));
assert!(help.contains("geth daemon run --ephemeral")); assert!(help.contains("geth daemon run --ephemeral"));
assert!(help.contains("Show daemon, storage, Iroh, and backend health")); assert!(help.contains("Show daemon, storage, Iroh, and backend health"));
@ -4627,6 +4807,36 @@ mod tests {
assert!(help.contains("--home <DIR>")); assert!(help.contains("--home <DIR>"));
} }
#[test]
fn every_cli_argument_has_operator_facing_help() {
fn check(command: clap::Command, path: String, missing: &mut Vec<String>) {
for argument in command.get_arguments() {
let id = argument.get_id().as_str();
if matches!(id, "help" | "version") {
continue;
}
if argument.get_help().is_none() {
missing.push(format!("{path}: {id}"));
}
}
for subcommand in command.get_subcommands() {
check(
subcommand.clone(),
format!("{path} {}", subcommand.get_name()),
missing,
);
}
}
let mut missing = Vec::new();
check(documented_cli_command(), "geth".to_owned(), &mut missing);
assert!(
missing.is_empty(),
"missing argument help:\n{}",
missing.join("\n")
);
}
#[test] #[test]
fn common_daemon_lifecycle_commands_parse_directly() { fn common_daemon_lifecycle_commands_parse_directly() {
for command in ["install", "start", "stop", "status", "uninstall"] { for command in ["install", "start", "stop", "status", "uninstall"] {

View file

@ -55,6 +55,18 @@ For deployment-readiness work that cuts across feature areas, see
- `[x]` Tests cover lifecycle parsing, help discoverability, and explicit - `[x]` Tests cover lifecycle parsing, help discoverability, and explicit
home selection without mutating a real user service manager. home selection without mutating a real user service manager.
- `[x]` Make leaf-command arguments self-explanatory.
Acceptance criteria:
- `[x]` Every positional argument and option has operator-facing help text,
including fields generated across deeply nested command families.
- `[x]` Ambiguous fields such as KV keys, resource names, conflict kinds, and
forwarding targets use command-specific wording where it affects correct
use.
- `[x]` Enrollment requests, authorization grants, CAS root application, and
KV writes include copy-paste examples in their leaf help.
- `[x]` A recursive CLI test fails when a future argument is added without a
description.
- `[x]` Document task-oriented user stories. - `[x]` Document task-oriented user stories.
Acceptance criteria: Acceptance criteria:
- `[x]` Workflows cover disposable evaluation, persistent background use, - `[x]` Workflows cover disposable evaluation, persistent background use,