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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes
- A request that gets no response says why: a timeout (for the auth service, its fixed 30-second limit, which `--timeout` does not change), a connection not made within the 10-second connect limit (which `--timeout` does not change), a failed TLS connection, a connection that could not be made, or one that closed early. A hint names the next step. A write that timed out or closed early may have been applied, so its hint says to look up the resource ID before running the command again, never to retry. This covers API, auth, price, and checkout stream requests, which before said only that the request failed.
- `--output-file` on a path that already exists says so and suggests a new path, instead of showing an OS error; any other refusal keeps its cause and gets the same hint. Both say that no change was sent.
- `-o` is short for `--output`, as in `voltage wallets list -o json`.
- Color on a terminal: help, `error:`, `hint:`, and `details:` labels. `--color auto|always|never` (or `--no-color`) decides, else `NO_COLOR`, `CLICOLOR_FORCE`, and `CLICOLOR`. JSON is never colored.

## 0.2.0 - 2026-10-22

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ path = "src/main.rs"
[dependencies]
clap = { version = "=4.6.7", features = ["derive", "string"] }
clap_complete = "=4.6.11"
# Colors the CLI's own labels with clap's policy: clap already depends on it, so it adds no
# crate. It decides per stream (NO_COLOR, CLICOLOR, terminal) and strips styles otherwise.
anstream = "=1.0.0"
serde = { version = "=1.0.229", features = ["derive"] }
serde_json = { version = "=1.0.154", features = ["arbitrary_precision"] }
toml = "=1.1.6"
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,8 @@ A flag beats a variable. A selected profile is a complete configuration, so `VOL
| `VOLTAGE_CONFIG_DIR` | The configuration directory, as an absolute path | `--config-dir` |
| `XDG_CONFIG_HOME` | Parent of the default configuration directory, `$XDG_CONFIG_HOME/voltage` (else `~/.config/voltage`) | `--config-dir`, `VOLTAGE_CONFIG_DIR` |
| `COLUMNS` | Terminal width for human tables; otherwise the terminal's own width | — |
| `NO_COLOR` | Any non-empty value turns off color in human output and help | `--color` |
| `CLICOLOR_FORCE` | Any non-zero value colors human output and help even when redirected | `--color`, `NO_COLOR` |
| `CLICOLOR` | `0` turns off color on a terminal | `--color`, `NO_COLOR`, `CLICOLOR_FORCE` |

Related: [configuration](configuration.md), [global flags](global-flags.md), [scripting](../guides/scripting.md).
2 changes: 2 additions & 0 deletions docs/reference/global-flags.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ Generated from the command definitions by `VOLTAGE_BLESS=1 cargo test`; do not e
| `-o`, `--output OUTPUT` | Output format (`table`, `json`, `ndjson`) |
| `--output-file PATH` | Save the complete response to a new owner-only file |
| `--show-secrets` | Explicitly allow secrets in result output |
| `--color WHEN` | When to color human output and help (`auto`, `always`, `never`); default `auto` |
| `--no-color` | Same as --color never |
| `-q`, `--quiet` | Hide optional progress and notices, not errors, results, or recovery IDs |

## Safety
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ API fields stay inside `data`. With `--all`, JSON output collects the pages into

Diagnostics, prompts, and progress go to stderr. Interactive stderr shows immediate, cancellable status for network requests and waits, with the time waited so far on payment waits and device approval; redirected stderr gets no spinner/control codes. `-q, --quiet` suppresses optional status and notices, never requested results, errors, warnings, or recovery IDs.

Human output and help are colored on a terminal: `error:` is red, `hint:` cyan, and `details:` dim, and color only marks a label, so the words read the same without it. `--color always|never` (or `--no-color`) decides; otherwise `NO_COLOR`, `CLICOLOR_FORCE`, `CLICOLOR`, `TERM=dumb`, and whether each stream is a terminal decide, separately for stdout and stderr. JSON, NDJSON, `--output-file`, and completions are never colored, even with `--color always`.

Human errors start with `error:`, which leads with the API's reason when it rejects a request with a client error (HTTP 4xx), and may include a safe `hint:` and an indented `details:` block of `field value` lines. `--json` reports the complete API response in `error.detail`, and makes command-line parse errors machine-readable as well as runtime errors. A downstream consumer that closes stdout early is treated as successful pipeline completion.

Related: [scripting](../guides/scripting.md), [safety model](../concepts/safety-model.md#secrets-and-redaction).
41 changes: 37 additions & 4 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use crate::{
payment::{AmountUnit, Currency, Network, PaymentDirection, ReceiveKind, WaitTarget},
price::ConversionRequest,
registry::{Method, OPERATIONS, Operation, OperationId, Parameter, ResourceTarget, operation},
style::{self, ColorChoice},
terminal::Terminal,
};
use clap::{
Expand Down Expand Up @@ -66,6 +67,25 @@ pub struct GlobalFlags {
/// Never prompt for a secret; supply it with --stdin or the environment instead
#[arg(long, global = true, help_heading = "Safety")]
pub no_input: bool,
/// When to color human output and help
#[arg(
long,
global = true,
value_enum,
value_name = "WHEN",
default_value_t,
help_heading = "Output"
)]
pub color: ColorChoice,
/// Same as --color never
#[arg(
long,
global = true,
hide = true,
conflicts_with = "color",
help_heading = "Output"
)]
pub no_color: bool,
/// Hide optional progress and notices, not errors, results, or recovery IDs
#[arg(short = 'q', long, global = true, help_heading = "Output")]
pub quiet: bool,
Expand Down Expand Up @@ -111,6 +131,14 @@ impl GlobalFlags {
OutputFormat::select(self.json, self.output)
}

pub fn color(&self) -> ColorChoice {
if self.no_color {
ColorChoice::Never
} else {
self.color
}
}

pub fn terminal(&self) -> Terminal {
Terminal::new(self.quiet, self.no_input)
}
Expand Down Expand Up @@ -636,9 +664,13 @@ pub struct Invocation {

impl Invocation {
/// Parse process arguments without letting clap terminate the process. Startup decides
/// whether parse failures need clap's human rendering or the JSON error envelope.
pub fn from_env() -> std::result::Result<Self, ParseFailure> {
let matches = command().try_get_matches().map_err(ParseFailure::Clap)?;
/// whether parse failures need clap's human rendering or the JSON error envelope; `color`
/// colors clap's help and errors, since `--color` itself is not parsed yet.
pub fn from_env(color: ColorChoice) -> std::result::Result<Self, ParseFailure> {
let matches = command()
.color(color.as_clap())
.try_get_matches()
.map_err(ParseFailure::Clap)?;
Self::from_matches(&matches).map_err(ParseFailure::Validation)
}

Expand Down Expand Up @@ -868,7 +900,8 @@ pub fn command() -> ClapCommand {
let root = ClapCommand::new("voltage")
.version(env!("VOLTAGE_VERSION"))
.subcommand_required(true)
.arg_required_else_help(true);
.arg_required_else_help(true)
.styles(style::HELP);
let root = GlobalFlags::augment_args(root);
let mut root = LocalCommand::augment_subcommands(root);
for name in root
Expand Down
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ mod price;
pub mod registry;
pub mod secret;
mod startup;
mod style;
mod terminal;
mod wallet_currency;
mod wallet_shortlist;
Expand Down
69 changes: 51 additions & 18 deletions src/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ use crate::{
Error, Result,
config::new_private,
error::ErrorDetail,
style::{self, ColorChoice, Role},
terminal::{self, scrub_line},
};
use clap::ValueEnum;
Expand Down Expand Up @@ -316,8 +317,9 @@ struct ErrorBody<'a> {
detail: Option<&'a ErrorDetail>,
}

/// Print an error to stderr in the format the selected output uses.
pub fn report_error(error: &Error, format: OutputFormat) {
/// Print an error to stderr in the format the selected output uses; `color` styles only the
/// human report's labels.
pub fn report_error(error: &Error, format: OutputFormat, color: ColorChoice) {
let report = ErrorReport {
error: ErrorBody {
message: &error.message,
Expand All @@ -332,25 +334,34 @@ pub fn report_error(error: &Error, format: OutputFormat) {
if format.is_machine_readable() {
eprintln!("{body}");
} else {
// Messages can interpolate untrusted input; keep control characters off the terminal.
eprintln!("error: {}", scrub_line(&error.message));
if let Some(hint) = &error.hint {
// Hints can name saved credentials and profiles, so they are scrubbed like messages.
eprintln!("hint: {}", scrub_line(hint));
}
let repeated = error.detail.as_ref().is_some_and(ErrorDetail::repeats_hint);
if let Some(detail) =
human_detail(&body["error"]["detail"], &error.message).filter(|_| !repeated)
{
let mut lines = Vec::new();
if human::detail(&mut lines, &detail).is_ok() {
eprintln!("details:");
for line in String::from_utf8_lossy(&lines).lines() {
eprintln!(" {line}");
}
style::write_stderr(color, &human_error(error, &body["error"]["detail"]));
}
}

/// The human error report with painted labels, which the stream strips when color is off.
fn human_error(error: &Error, detail: &Value) -> String {
// Messages can interpolate untrusted input; keep control characters off the terminal.
let mut report = format!(
"{} {}\n",
style::paint(Role::Failure, "error:"),
scrub_line(&error.message)
);
if let Some(hint) = &error.hint {
// Hints can name saved credentials and profiles, so they are scrubbed like messages.
let hint = scrub_line(hint);
report.push_str(&format!("{} {hint}\n", style::paint(Role::Hint, "hint:")));
}
let repeated = error.detail.as_ref().is_some_and(ErrorDetail::repeats_hint);
if let Some(detail) = human_detail(detail, &error.message).filter(|_| !repeated) {
let mut lines = Vec::new();
if human::detail(&mut lines, &detail).is_ok() {
report.push_str(&format!("{}\n", style::paint(Role::Muted, "details:")));
for line in String::from_utf8_lossy(&lines).lines() {
report.push_str(&format!(" {line}\n"));
}
}
}
report
}

/// The part of an error's detail a person needs. A client error (4xx) shows the API's error
Expand Down Expand Up @@ -442,6 +453,28 @@ mod tests {
assert_eq!(human_detail(&Value::Null, ""), None);
}

#[test]
fn human_errors_paint_only_their_labels() {
let hostile = "bad\u{1b}[2J\u{1b}]8;;https://evil.test\u{7}key";
let mut error = Error::usage(format!("Rejected {hostile}"));
error.hint = Some(format!("Try {hostile}").into());
let report = human_error(&error, &json!({"request_id": hostile}));
let plain = "error: Rejected bad [2J ]8;;https://evil.test key\n\
hint: Try bad [2J ]8;;https://evil.test key\n\
details:\n request_id bad [2J ]8;;https://evil.test key\n";
assert_eq!(anstream::adapter::strip_str(&report).to_string(), plain);
// Every escape sequence left is one of the three labels' styles.
let mut rest = report.clone();
for (role, label) in [
(Role::Failure, "error:"),
(Role::Hint, "hint:"),
(Role::Muted, "details:"),
] {
rest = rest.replacen(&style::paint(role, label), label, 1);
}
assert_eq!(rest, plain);
}

#[test]
fn secrets_are_redacted_recursively() {
let mut value = json!({"data":{"shared_secret":"abc","description":"token abc"}});
Expand Down
23 changes: 14 additions & 9 deletions src/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use crate::{
output::{Envelope, Output, OutputFormat, report_error, write_stdout},
pick,
price::{PRICE_URL, PriceService},
style::ColorChoice,
terminal::Terminal,
wallet_currency, wallet_shortlist,
};
Expand All @@ -24,11 +25,13 @@ const BINARY_NAME: &str = "voltage";

pub async fn run() -> ExitCode {
let json_errors = cli::json_errors_requested(std::env::args_os());
let invocation = match Invocation::from_env() {
let color = ColorChoice::requested(std::env::args_os());
let invocation = match Invocation::from_env(color) {
Ok(invocation) => invocation,
Err(failure) => return fail_parse(failure, json_errors),
Err(failure) => return fail_parse(failure, json_errors, color),
};
let format = invocation.global.output_format();
let color = invocation.global.color();
let submission = SubmissionState::default();
// Biased, with the interrupt first: tokio installs the SIGINT handler when `ctrl_c` is
// first polled. Polled second, a command that reaches a prompt without awaiting would leave
Expand All @@ -52,15 +55,17 @@ pub async fn run() -> ExitCode {
match result {
Ok(()) => ExitCode::SUCCESS,
Err(error) if error.is_stdout_closed() => ExitCode::SUCCESS,
Err(error) => fail(error, format),
Err(error) => fail(error, format, color),
}
}

fn fail_parse(failure: ParseFailure, json_errors: bool) -> ExitCode {
fn fail_parse(failure: ParseFailure, json_errors: bool, color: ColorChoice) -> ExitCode {
match failure {
ParseFailure::Validation(error) => fail(error, OutputFormat::select(json_errors, None)),
ParseFailure::Validation(error) => {
fail(error, OutputFormat::select(json_errors, None), color)
}
ParseFailure::Clap(error) if error.use_stderr() && json_errors => {
fail(Error::usage(error.to_string()), OutputFormat::Json)
fail(Error::usage(error.to_string()), OutputFormat::Json, color)
}
ParseFailure::Clap(error) => {
let writes_stdout = !error.use_stderr();
Expand All @@ -70,14 +75,14 @@ fn fail_parse(failure: ParseFailure, json_errors: bool) -> ExitCode {
Err(io) if writes_stdout && io.kind() == std::io::ErrorKind::BrokenPipe => {
ExitCode::SUCCESS
}
Err(io) => fail(io.into(), OutputFormat::select(false, None)),
Err(io) => fail(io.into(), OutputFormat::select(false, None), color),
}
}
}
}

fn fail(error: Error, format: OutputFormat) -> ExitCode {
report_error(&error, format);
fn fail(error: Error, format: OutputFormat, color: ColorChoice) -> ExitCode {
report_error(&error, format, color);
process_exit(error.kind.exit_code())
}

Expand Down
Loading
Loading