Skip to content
Draft
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 @@ -24,6 +24,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes
- 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.
- Browse with the pickers: on a terminal, `voltage wallets get`, `wallets ledger list`, `wallets policies get`, and `wallets payments summary` without a wallet ID open the wallet picker and show the wallet you pick. New `voltage organizations get` and `voltage environments get` show the organization or environment the scope selects, and on a terminal pick it when none is selected. Wallet changes still name their wallet. Off a terminal, or with `--json` or `--no-input`, nothing is asked and a missing ID fails as before.

## 0.2.0 - 2026-10-22

Expand Down
2 changes: 2 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ These commands use the auth service (`https://auth.voltage.cloud/api/v1`, overri
| `voltage auth status` | local only |
| `voltage auth import-key` | local only |
| `voltage organizations list` | GET `/api/v1/users/current` |
| `voltage organizations get` | GET `/api/v1/users/current` |
| `voltage environments list` | POST `/api/v1/oauth/token`<br>GET `/api/v1/organizations/{organization_id}/environments` |
| `voltage environments get` | POST `/api/v1/oauth/token`<br>GET `/api/v1/organizations/{organization_id}/environments` |
| `voltage context` | local only |
| `voltage profiles create NAME` | local only |
| `voltage profiles get NAME` | local only |
Expand Down
10 changes: 9 additions & 1 deletion docs/guides/login-and-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ secret-manager-command | voltage auth import-key --stdin --account ci --org ORG_
VOLTAGE_API_KEY=... voltage wallets list --org ORG_ID
```

An imported key remembers the organization and environment it belongs to, and a command that selects a different one fails before any request is sent. `VOLTAGE_API_KEY` is used only when neither `--profile` nor `--account` is given. `organizations list` and `environments list` require a browser login.
An imported key remembers the organization and environment it belongs to, and a command that selects a different one fails before any request is sent. `VOLTAGE_API_KEY` is used only when neither `--profile` nor `--account` is given. `organizations list|get` and `environments list|get` require a browser login.

With several saved credentials, select one with `--account` or through a profile.

Expand Down Expand Up @@ -58,6 +58,14 @@ voltage payments receive --currency btc --kind bolt11 --amount 1000 --unit sats

The picker offers every wallet in the environment and shows a wallet's currency when its balances report one. A wallet with no balance yet shows no currency. For this example, pick a BTC wallet. Typing filters on the whole label, so `btc` matches wallets that show `BTC` and any wallet with `btc` in its name. A wallet on a USD line of credit needs a quote for every payment, so the API rejects the receive with `quote_required` unless you pass `--quote QUOTE_ID` from `voltage quotes create`.

The same pickers browse your resources. `organizations get` and `environments get` show the organization or environment that the scope selects (`--org`, `--env`, or a profile), and a read of one wallet, such as `wallets get`, shows the wallet that `WALLET_ID` or `--wallet` names. When none is selected, a terminal picks it and shows it:

```sh
voltage organizations get # asks for the organization, then shows it
voltage environments get --org ORG_ID # asks for the environment
voltage wallets get --profile prod # asks for the wallet
```

An API key cannot list organizations or environments, so with a key the CLI offers only wallets; pass `--org` and `--env` or use a profile. Esc leaves the scope unchanged, and the command then reports what is missing. Ctrl-C exits with code 130. Picking a scope never sends a change: changes still need `--execute`. The CLI never asks when stdin or stderr is not a terminal, when stdout is piped without `--output table` (as in `voltage wallets list | jq`), or with `--no-input`, `--json`, or `--output json|ndjson`. In those cases, the command fails with the same error as before.

## Profiles
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/wallets.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ voltage wallets payments summary WALLET_ID --profile prod

On a terminal, `wallets get` first shows the wallet's name, each available balance, labelled `Available` (with a USD estimate for a BTC balance on mainnet, from the price service), and a sparkline of its completed payment volume over the last 14 days. That takes two extra reads, the BTC price on mainnet and the wallet's 100 newest completed payments (payment checks excluded), summed from each payment's amount in the wallet's currency. Each read has 5 seconds at most, and one that fails or is slow leaves its line out. The wallet's fields follow as usual, and redirected or `--json` output is unchanged.

On a terminal, a read of one wallet without `WALLET_ID` or `--wallet`, such as `voltage wallets get --profile prod`, opens the [wallet picker](login-and-profiles.md#pick-the-scope-interactively) and shows the wallet you pick. A change, such as `wallets update` or `wallets delete`, always names its wallet and never offers a picker. Off a terminal, or with `--json` or `--no-input`, a read without a wallet fails as before and names both ways to pass it.

The ledger lists every credit, hold, capture, and release on the wallet. `--all` follows every page; with `--output ndjson` each page is written as it arrives.

## Change a wallet
Expand Down
2 changes: 2 additions & 0 deletions scripts/check-coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
('voltage auth status', []),
('voltage auth import-key', []),
('voltage organizations list', [('GET', '/api/v1/users/current')]),
('voltage organizations get', [('GET', '/api/v1/users/current')]),
('voltage environments list', [('POST', '/api/v1/oauth/token'), ('GET', '/api/v1/organizations/{organization_id}/environments')]),
('voltage environments get', [('POST', '/api/v1/oauth/token'), ('GET', '/api/v1/organizations/{organization_id}/environments')]),
('voltage context', []),
('voltage profiles create NAME', []),
('voltage profiles get NAME', []),
Expand Down
17 changes: 12 additions & 5 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
use crate::{
Error, Result,
api::{SessionChange, SubmissionState},
cli::{GlobalFlags, ImportKeyFlags, LoginFlags},
cli::{DiscoveryCommand, GlobalFlags, ImportKeyFlags, LoginFlags},
config::{
AUTH_URL, Account, AccountKind, Credential, CredentialProblem, CredentialStore,
InputSource, LOCK_TIMEOUT, Login, Scope, ScopeSources, Settings, Source, name_in_use,
Expand Down Expand Up @@ -965,13 +965,20 @@ impl Resolver<'_> {
}

/// List organizations visible to the login, or environments in the selected organization.
pub async fn discover(self, scope: &Scope, what: Discovery) -> Result<Value> {
/// `command` names the subcommand in the error for an API key.
pub async fn discover(
self,
scope: &Scope,
what: Discovery,
command: DiscoveryCommand,
) -> Result<Value> {
// An API key cannot list organizations or environments; say which key was used.
let requires_login = || {
let command = match what {
Discovery::Organizations => "organizations list",
Discovery::Environments => "environments list",
let noun = match what {
Discovery::Organizations => "organizations",
Discovery::Environments => "environments",
};
let command = format!("{noun} {}", command.as_str());
if ambient_api_key(self.flags).is_some() {
return Error::auth(format!(
"{command} needs a browser login, but this command used VOLTAGE_API_KEY from the environment"
Expand Down
65 changes: 50 additions & 15 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,12 +198,12 @@ pub enum LocalCommand {
/// Organizations visible to the login
Organizations {
#[command(subcommand)]
command: ListCommand,
command: DiscoveryCommand,
},
/// Environments in the selected organization
Environments {
#[command(subcommand)]
command: ListCommand,
command: DiscoveryCommand,
},
/// Named organization, environment, and credential selections
Profiles {
Expand Down Expand Up @@ -290,9 +290,23 @@ pub struct ImportKeyFlags {
pub credential_store: CredentialStore,
}

#[derive(Debug, Subcommand)]
pub enum ListCommand {
/// What `organizations` and `environments` show. `get` takes no ID: it shows the one the
/// scope selects, so `--org` or `--env` names it, as for every other command.
#[derive(Clone, Copy, Debug, Subcommand, Eq, PartialEq)]
pub enum DiscoveryCommand {
/// List every one the login can see
List,
/// Show the selected one; on a terminal, pick it when none is selected
Get,
}

impl DiscoveryCommand {
pub fn as_str(self) -> &'static str {
match self {
Self::List => "list",
Self::Get => "get",
}
}
}

#[derive(Debug, Subcommand, Eq, PartialEq)]
Expand Down Expand Up @@ -815,6 +829,8 @@ fn payment_alias_parent(create_payment: &Operation) -> &[String] {
/// The operation's resource ID: its argument or `--id`. A wallet operation also takes an
/// explicit `--wallet`, which names the wallet everywhere else; a profile's or
/// `VOLTAGE_WALLET_ID`'s wallet never selects it, so a delete always names its wallet.
/// Without either, a wallet operation's ID stays unset here: on a terminal a read picks it,
/// and otherwise `pick` stops with [`missing_wallet`] before any request.
fn target_id(
operation: &Operation,
target: Option<TargetFlags>,
Expand All @@ -829,18 +845,18 @@ fn target_id(
"WALLET_ID {id} and --wallet {wallet} name different wallets"
))
.with_hint("Pass the wallet once, as WALLET_ID or --wallet.")),
(Some(id), _) | (None, Some(id)) => Ok(Some(id)),
(None, None) => {
let command = operation.command.join(" ");
Err(
Error::usage(format!("{command} needs a wallet ID")).with_hint(format!(
"Pass it as an argument or with --wallet, as in `voltage {command} WALLET_ID`. `voltage wallets list` shows the IDs."
)),
)
}
(id, wallet) => Ok(id.or(wallet)),
}
}

/// The error for a wallet operation that names no wallet.
pub fn missing_wallet(operation: &Operation) -> Error {
let command = operation.command.join(" ");
Error::usage(format!("{command} needs a wallet ID")).with_hint(format!(
"Pass it as an argument or with --wallet, as in `voltage {command} WALLET_ID`. `voltage wallets list` shows the IDs."
))
}

fn api_invocation(
operation: &'static Operation,
alias: Option<PaymentDirection>,
Expand Down Expand Up @@ -1040,7 +1056,8 @@ fn list_args(cmd: ClapCommand, operation: &Operation) -> ClapCommand {
}

/// The resource ID argument and `--id`, one of which is required. A wallet command can
/// name its wallet with `--wallet` instead, so `target_id` checks that one was given.
/// name its wallet with `--wallet` instead, or pick it on a terminal, so `pick` checks that
/// one was given.
fn target_args(cmd: ClapCommand, target: ResourceTarget, wallet: bool) -> ClapCommand {
let cmd = TargetFlags::augment_args(cmd)
.mut_arg("resource_id", |arg| arg.value_name(target.value_name()));
Expand Down Expand Up @@ -1202,6 +1219,12 @@ fn endpoint_examples(id: OperationId, command_name: &str) -> Option<&'static str
(OperationId::CreatePayment, _) => {
" # Send a complete payment request body from a file
voltage payments create --profile prod --data @payment.json --execute"
}
(OperationId::GetWallet, _) => {
" # Show one wallet
voltage wallets get WALLET_ID --profile prod
# On a terminal, pick the wallet from a list
voltage wallets get --profile prod"
}
(OperationId::GetPayment, _) => {
" # Wait up to two minutes for a payment to settle
Expand Down Expand Up @@ -1342,6 +1365,16 @@ fn local_examples(path: &[&str]) -> Option<&'static str> {
["profiles", "create"] => {
" # Save an organization, environment, and credential as `prod`
voltage profiles create prod --org ORG_ID --env ENV_ID --account work"
}
["organizations", "get"] => {
" # Show one organization, or pick it from a list on a terminal
voltage organizations get --org ORG_ID
voltage organizations get"
}
["environments", "get"] => {
" # Show the environment a profile selects, or pick one from a list on a terminal
voltage environments get --profile prod
voltage environments get --org ORG_ID"
}
["context"] => {
" # Show what a command with this profile would use, and where each value came from
Expand Down Expand Up @@ -1586,7 +1619,9 @@ mod tests {
Some("Pass the wallet once, as WALLET_ID or --wallet.")
);

let missing = Invocation::try_parse_from(["voltage", "wallets", "get"]).unwrap_err();
// Without either, the ID is left for `pick`, which asks on a terminal or stops.
assert_eq!(api(&["wallets", "get"]).resource_id, None);
let missing = missing_wallet(operation(OperationId::GetWallet));
assert_eq!(missing.kind, Kind::Usage);
assert_eq!(missing.message, "wallets get needs a wallet ID");
assert!(
Expand Down
Loading
Loading