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 @@ -23,6 +23,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes
- `--qr` on a terminal draws the invoice black on white with a quiet zone, so it scans on dark themes, under a `Scan to pay` caption with the amount, memo, and expiry, and after the result so it stays on screen; `--wait completed` counts down to the expiry. The caption leads with the network, its dot in the website's color.
- A payment that settles on a terminal closes with `⚡ Payment ID completed` instead of a check mark, printed after the payment's record so it stays on screen.
- `voltage payments list --watch` follows payments until Ctrl-C: a live table that marks new and changed payments on a terminal, or one `snapshot`, `new`, or `updated` event per payment when piped or with `--json`. `--interval` sets the refresh (default 5 seconds).
- `voltage` with no command, on a terminal, greets you with the wordmark, who you are signed in as, your saved scope, and commands to try; off a terminal it is still a usage error. An empty wallet, payment, or webhook list, and a new profile, end with a `›` line naming the next command.

## 0.2.0 - 2026-10-22

Expand Down
2 changes: 2 additions & 0 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Install the CLI from the [install guide](guides/install.md), then sign in throug
voltage login --account me
```

Run `voltage` on its own at any time to see who you are signed in as, your saved scope, and a few commands to try.

## 2. Save your scope as a profile

Find your organization and environment, then save both with your login as a profile:
Expand Down
4 changes: 4 additions & 0 deletions docs/reference/output.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ On a terminal, results are readable tables; when piped, they are JSON. `--json`
- A single resource is shown as aligned `field value` lines, with nested fields joined by dots. `voltage context` shows each setting as `name value (source)`.
- On a terminal, `voltage context` and `voltage auth status` draw those lines inside a titled card (rounded corners, or `+-|` without a UTF-8 locale). A card wider than the terminal falls back to the plain lines, and redirected output always gets them.

- With human output on a terminal, an unfiltered empty `wallets list`, `payments list`, or `webhooks list` ends with a `›` line naming the command that makes the first one, and `profiles create` ends with a command that uses the new profile (keeping `--config-dir`). The line comes after the result. `--quiet` hides it, and JSON or redirected output never has it.

`voltage` with no command, on a terminal, greets you: the wordmark, who you are signed in as (read from the configuration, without a network request), your profile, organization, and wallet when set, and three commands to try; it exits 0. When commands would reject the credential, the greeting leads with that problem instead, such as `VOLTAGE_API_KEY is empty` or `Multiple credentials are saved`, with its next step first. Off a terminal, or with `--json`, it prints its usage and exits 2 as before.

The table layout may change between releases; scripts should use JSON.

## JSON
Expand Down
52 changes: 50 additions & 2 deletions src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ use crate::{
dry_run,
error::{CredentialSources, ErrorDetail, ErrorKind, ResponseLimit},
input::{PaginationMode, Request},
output::{Envelope, LiveScreen, Outcome, Output},
output::{Envelope, LiveScreen, NextStep, Outcome, Output},
payment::{Currency, PaymentDirection, PaymentView, ReceiveKind, WaitProgress, WaitTarget},
price::{PRICE_URL, PriceService, UsdPerBtc},
qr,
Expand Down Expand Up @@ -942,7 +942,21 @@ impl Call<'_> {
if operation.id == OperationId::GetWallet && self.out.on_terminal() {
self.lead_with_balance(&response.body).await?;
}
self.write_pages(response, Outcome::Retrieved, follow).await
let empty = watch::items(&response.body).is_empty();
self.write_pages(response, Outcome::Retrieved, follow)
.await?;
// Only an unfiltered list is empty because nothing was made yet.
let unfiltered =
self.invocation.filters.is_empty() && self.invocation.query.is_empty();
if let Some((what, command)) =
empty_list_next_step(operation.id).filter(|_| empty && unfiltered)
{
self.out.suggest(&NextStep {
what,
command: command.into(),
});
}
Ok(())
}
}
}
Expand Down Expand Up @@ -1613,6 +1627,20 @@ impl Call<'_> {
}
}

/// What to run when a list a person reads is empty: how to make its first item.
fn empty_list_next_step(operation: OperationId) -> Option<(&'static str, &'static str)> {
match operation {
OperationId::GetAllOrganizationsWalletsAsUser => {
Some(("Create a wallet", "voltage wallets create --help"))
}
OperationId::GetPayments => Some(("Receive a payment", "voltage payments receive --help")),
OperationId::GetWebhooksAsUser => {
Some(("Create a webhook", "voltage webhooks create --help"))
}
_ => None,
}
}

/// The live watch's first line: what it is, how often it refreshes, how to stop, and why
/// the last refresh failed, if it did.
fn watch_header(interval: Duration, failure: Option<&Error>, terminal: Terminal) -> Line {
Expand Down Expand Up @@ -1823,6 +1851,26 @@ fn next_page(body: &Value, request: &Request) -> Result<Option<(String, String)>
mod tests {
use super::*;

#[test]
fn empty_lists_suggest_a_command_that_exists() {
for operation in [
OperationId::GetAllOrganizationsWalletsAsUser,
OperationId::GetPayments,
OperationId::GetWebhooksAsUser,
] {
let (_, command) = empty_list_next_step(operation).unwrap();
let error = crate::cli::command()
.try_get_matches_from(command.split(' '))
.unwrap_err();
assert_eq!(
error.kind(),
clap::error::ErrorKind::DisplayHelp,
"{command}"
);
}
assert_eq!(empty_list_next_step(OperationId::GetWallet), None);
}

#[test]
fn the_watch_header_says_how_to_stop_and_why_a_refresh_failed() {
let terminal = Terminal::new(false, true);
Expand Down
54 changes: 53 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ use clap::{
error::ErrorKind,
};
use clap_complete::Shell;
use std::{collections::BTreeSet, ffi::OsStr, path::PathBuf, time::Duration};
use std::{
collections::BTreeSet,
ffi::{OsStr, OsString},
path::PathBuf,
time::Duration,
};
use uuid::Uuid;

/// Friendly payment commands beside `create_payment`'s generated command.
Expand Down Expand Up @@ -131,6 +136,28 @@ impl GlobalFlags {
OutputFormat::select(self.json, self.output)
}

/// The global flags of a command line that names no command, such as bare `voltage` or
/// `voltage --profile prod`; `None` when it names one or does not parse.
pub fn without_command(args: &[OsString]) -> Option<Self> {
let missing = command()
.try_get_matches_from(args)
.err()
.is_some_and(|error| {
matches!(
error.kind(),
ErrorKind::DisplayHelpOnMissingArgumentOrSubcommand
| ErrorKind::MissingSubcommand
)
});
if !missing {
return None;
}
// The same flags in front of `context`, the local command that reads only them.
let with_context = args.iter().cloned().chain([OsString::from("context")]);
let matches = command().try_get_matches_from(with_context).ok()?;
parse(leaf(&matches).1).ok()
}

/// `--color` or `--no-color`, else `FORCE_COLOR`, else automatic.
pub fn color(&self) -> ColorChoice {
if self.no_color {
Expand Down Expand Up @@ -1618,6 +1645,31 @@ mod tests {
assert!(api.body.is_none());
}

#[test]
fn global_flags_without_a_command_are_read_for_the_greeting() {
let args = |list: &[&str]| list.iter().map(OsString::from).collect::<Vec<_>>();
let bare = GlobalFlags::without_command(&args(&["voltage"])).unwrap();
assert_eq!(bare.profile, None);
let profiled =
GlobalFlags::without_command(&args(&["voltage", "--profile", "prod"])).unwrap();
assert_eq!(profiled.profile.as_deref(), Some("prod"));
assert!(
GlobalFlags::without_command(&args(&["voltage", "--json"]))
.unwrap()
.json
);
for named in [
&["voltage", "context"][..],
&["voltage", "--help"],
&["voltage", "--bogus"],
] {
assert!(
GlobalFlags::without_command(&args(named)).is_none(),
"{named:?}"
);
}
}

#[test]
fn only_payments_list_watches_and_its_interval_needs_the_watch() {
let watched = api(&["payments", "list", "--watch"]);
Expand Down
Loading
Loading