diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 002a8cc..fc00633 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -1,6 +1,6 @@ use anyhow::{Context, Result, bail}; use base64::Engine; -use clap::{Args, CommandFactory, Parser, Subcommand, ValueEnum}; +use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use clap_complete::{Shell, generate}; use geth_config::GethPaths; use geth_control::{ControlRequest, ControlResponse, SyncStreamStatus}; @@ -727,6 +727,9 @@ pub enum NodeCommand { #[derive(Debug, Subcommand)] pub enum NodeEnrollCommand { /// 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 { #[arg(long)] node_name: String, @@ -988,6 +991,9 @@ pub enum AuthCommand { /// Pull verified authorization operations from a trusted peer Sync { node: String }, /// Add an admin-signed resource capability grant + #[command( + after_help = "Example:\n geth auth grant resource:kv:preferences kv.read --signing-key ~/.ssh/id_ed25519" + )] Grant { subject: String, resource: String, @@ -1138,6 +1144,9 @@ pub enum CasRootCommand { bearer_secret: Option, }, /// 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--photos --to ./restored-photos" + )] Apply { source: String, #[arg(long)] @@ -1182,6 +1191,9 @@ pub enum KvCommand { /// Create a named local key-value store Create { name: String }, /// 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 " + )] Set { name: String, key: String, @@ -1459,7 +1471,8 @@ pub enum SshRevocationCommand { pub struct EmptyArgs {} 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; match run_inner(cli).await { 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<()> { if let Command::Completions { shell } = &cli.command { print_completions(*shell); @@ -1771,7 +1951,7 @@ fn print_guide(topic: Option, json: bool) -> Result<()> { } fn print_completions(shell: Shell) { - let mut command = Cli::command(); + let mut command = documented_cli_command(); generate(shell, &mut command, "geth", &mut stdout()); } @@ -4619,7 +4799,7 @@ mod tests { #[test] 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 run --ephemeral")); assert!(help.contains("Show daemon, storage, Iroh, and backend health")); @@ -4627,6 +4807,36 @@ mod tests { assert!(help.contains("--home ")); } + #[test] + fn every_cli_argument_has_operator_facing_help() { + fn check(command: clap::Command, path: String, missing: &mut Vec) { + 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] fn common_daemon_lifecycle_commands_parse_directly() { for command in ["install", "start", "stop", "status", "uninstall"] { diff --git a/docs/roadmap.md b/docs/roadmap.md index 9ae6517..f02a393 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -55,6 +55,18 @@ For deployment-readiness work that cuts across feature areas, see - `[x]` Tests cover lifecycle parsing, help discoverability, and explicit 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. Acceptance criteria: - `[x]` Workflows cover disposable evaluation, persistent background use,