Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
09f2168
docs: add a staging setup and a Try it section for each PR
thebrandonlucas Oct 22, 2026
c6318f4
fix(cli): accept --all on environments, organizations, and profiles list
thebrandonlucas Oct 26, 2026
760cb7a
refactor(cli): replace hand-rolled terminal code with crossterm
thebrandonlucas Oct 27, 2026
39666d1
fix(cli): warn when a profile or flag overrides a set VOLTAGE_* variable
thebrandonlucas Oct 27, 2026
f736a45
feat(cli): print a profile's scope as dotenv lines with context --for…
thebrandonlucas Oct 27, 2026
174456c
fix(cli): keep import-key and profiles create quiet about flags they …
thebrandonlucas Oct 28, 2026
e43d367
fix(scripts): pass the wrapper config directory as VOLTAGE_CONFIG_DIR
thebrandonlucas Oct 28, 2026
53c55f0
Merge remote-tracking branch 'origin/master' into brandon/dev-2954-vo…
thebrandonlucas Oct 29, 2026
aa63bcc
Merge remote-tracking branch 'origin/master' into HEAD
thebrandonlucas Nov 4, 2026
b32e028
docs: keep the reviewer CRAP note under Before you request review
thebrandonlucas Nov 4, 2026
31d06ab
Merge origin/master into brandon/dev-2904-voltage-cli-replace-hand-ro…
thebrandonlucas Nov 4, 2026
a3bf7cd
Merge remote-tracking branch 'origin/master' into HEAD
thebrandonlucas Nov 4, 2026
3f75e38
fix(terminal): bound the hidden key prompt and keep its buffer from r…
thebrandonlucas Nov 4, 2026
ea540b4
Merge remote-tracking branch 'origin/master' into HEAD
thebrandonlucas Nov 4, 2026
9c7bc29
test(cli): check profiles create still warns about an overridden conf…
thebrandonlucas Nov 4, 2026
445e6b5
Merge remote-tracking branch 'origin/master' into HEAD
thebrandonlucas Nov 4, 2026
161b3d3
fix(terminal): show the hidden prompt only once raw mode is on
thebrandonlucas Nov 4, 2026
08ed105
fix(terminal): keep the hidden prompt's limit within one terminal read
thebrandonlucas Nov 4, 2026
118b143
Merge branch 'docs/try-pr-on-staging' into brandon/dev-2954-voltage-c…
thebrandonlucas Nov 4, 2026
89b144a
Merge branch 'brandon/dev-2954-voltage-cli-accept-all-on-environments…
thebrandonlucas Nov 4, 2026
a64f074
Merge branch 'brandon/dev-2956-voltage-cli-commands-ignore-voltage_-v…
thebrandonlucas Nov 4, 2026
9dfa9d0
Merge branch 'brandon/dev-2957-voltage-cli-no-way-to-hand-a-profiles-…
thebrandonlucas Nov 4, 2026
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
15 changes: 15 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,22 @@
<!-- What changed for users, and why. Name the base PR if this is stacked. -->

## Try it on staging

<!-- See AGENTS.md "Try it on staging". Commands a reviewer pastes after the setup. -->

Setup: [Try a pull request on staging](docs/development.md#try-a-pull-request-on-staging)

```sh
voltage ...
```

Expect: ...

## Checks

- [ ] User-visible change: a guide or reference under `docs/` and `CHANGELOG.md` (**Unreleased**) are updated, or the PR has the `no-changelog` label.
- [ ] The verification gate in `docs/development.md` passes.
- [ ] The diff was reviewed against its base with the `voltage-pr-review` skill, and every new error and early-return path has a test.
- [ ] CRAP report, when `src/` or `tests/` changed: the highest score and each function at or above 20.
- [ ] Checks not run, and why:
- [ ] **Try it on staging** lists every changed `--help` and docs example, with what to expect.
25 changes: 25 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,35 @@ so a pull request is not ready until each one is done and reported.
5. In the pull request description, give the checks you ran and the CRAP result: the
highest score and each function at or above 20. Name any check you could not run, and
why.
6. In the pull request description, add a **Try it on staging** section that a reviewer can
paste after the [staging setup](docs/development.md#try-a-pull-request-on-staging).
See [Try it on staging](#try-it-on-staging).

A reviewer runs the CRAP report on the pull request's head and reports each violation as a
finding, together with the review from step 1.

## Try it on staging

The section tells a reviewer exactly what to run and what to look for. Write it for a
reviewer who is on the branch and has run the setup, and nothing else:

- Start with one line: `Setup: [Try a pull request on staging](docs/development.md#try-a-pull-request-on-staging)`.
- Give the commands in `sh` blocks, ready to paste. Use `voltage` (the staging alias) and
`--account me`, never a profile the reviewer may not have. Use a placeholder only for a
value each reviewer has to supply, such as `WALLET_ID`, and say how to find it, such as
`voltage wallets list --account me`.
- After each command or group, say what to expect in one line: the text, symbol, color, or
exit code that shows the change works.
- List every `--help` and docs example that the pull request adds or changes, adapted to the
setup. To find them, run
`git diff origin/master...HEAD -- README.md docs src | grep '^+.*voltage '`.
- Cover the variants the change affects, such as piped output (`2>&1 | cat`), `--json`,
`--quiet`, `--color never`, or `--no-input`.
- Mark each command that sends a change. It needs `--execute` and a test-network wallet;
say so.
- For a change with nothing to see, such as a refactor or a test-only change, write
`Nothing to try on staging` and the reason.

## Work plans

Agent work plans live in `docs/plans/<work-item>/` as Markdown files. This directory is
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ User-visible changes to `voltage`, newest first. Each pull request that changes

## Unreleased

- `voltage context --format env` prints the effective organization, environment, and wallet as `VOLTAGE_*` dotenv lines, the names the `voltage-api` agent skill's helper reads, so a profile's scope can be handed to it. It leaves out unset values, never prints a credential, and fails with exit code 2 when more than one environment is selected.
- A command that ignores a set `VOLTAGE_*` variable, because `--profile` or a flag such as `--wallet` takes precedence, says so on stderr with one `warning:` line per variable, even with `--quiet`. Before, only `voltage context` reported it, so the CLI and a tool that lets the variable win could act on different wallets in one shell. Stdout is unchanged. `auth import-key` and `profiles create`, which save what their flags name, do not warn about the flags they were given.
- `organizations list`, `environments list`, and `profiles list` accept `--all` and ignore it, like the other lists that return every item at once. Before, they rejected it.
- `ctutils` 0.4.5, whose constant-time comparisons no longer branch on secret values (RUSTSEC-2026-0385). It reaches the CLI through `sha2` and `digest`.
- Filter `payments list` with `--check-only true|false`, and `wallets ledger list` with `--event-type adjustment`, from the refreshed API contract. Omit `--event-type` for payment ledger entries. Filters carry across pagination.
- `webhooks test WEBHOOK_ID` without `--data` sends a test event, with a new `delivery_id` and a payload of type `test` and event `created`. Before, it failed with "This operation requires --data". `--data` still sends your own payload.
Expand All @@ -26,6 +29,7 @@ User-visible changes to `voltage`, newest first. Each pull request that changes
- `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.
- A command reads a saved login's or API key's secret from the OS credential store once, instead of once for each lookup, so macOS asks for keychain access at most once per command. A login refresh still reads it again under the credential lock, to see a refresh that another command saved.
- The hidden API-key prompt of `voltage auth import-key` is shown on stderr, accepts Ctrl-U to clear what was typed, and restores the terminal after Ctrl-C on every platform. Ctrl-C there exits 130 with `Interrupted before any resource change was submitted`, as it did on Unix. A key longer than 1,000 bytes is refused with exit 2 instead of being read without limit.

## 0.2.0 - 2026-10-22

Expand Down
22 changes: 0 additions & 22 deletions Cargo.lock

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

9 changes: 5 additions & 4 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ rustls = { version = "=0.23.46", default-features = false, features = ["ring", "
uuid = { version = "=1.29.0", features = ["v4", "serde"] }
sha2 = "=0.11.1"
hex = "=0.4.3"
rpassword = "=7.5.4"
webbrowser = "=1.2.5"
futures-util = "=0.3.34"
url = "=2.5.8"
Expand All @@ -48,9 +47,11 @@ comfy-table = { version = "=8.0.1", default-features = false }
# Display widths, to find a table column after layout and color it; comfy-table already
# depends on it, so it adds no crate.
unicode-width = "=0.2.2"
# Reads the terminal width on every platform. Without its `windows` feature, crossterm
# refuses to compile on Windows.
crossterm = { version = "=0.29.0", default-features = false, features = ["windows"] }
# The terminal backend: width, raw mode for hidden prompts (it saves and restores the
# terminal's modes), key events, and the screen and cursor commands. Without its `windows`
# feature, crossterm refuses to compile on Windows. `events` and `bracketed-paste` are
# already on through inquire.
crossterm = { version = "=0.29.0", default-features = false, features = ["windows", "events", "bracketed-paste"] }
# Pickers for a missing organization, environment, or wallet. Its default filter matches a
# substring of a name or ID. It draws through crossterm, so the CLI keeps one terminal backend.
inquire = { version = "=0.9.4", default-features = false, features = ["crossterm"] }
Expand Down
16 changes: 15 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ build when they disagree. `docs/commands.md` is generated; regenerate it with
API-key runs, the optional `VOLTAGE_API_KEY`, `VOLTAGE_ORGANIZATION_ID`,
`VOLTAGE_ENVIRONMENT_ID`, and `VOLTAGE_WALLET_ID` from the repository's private `.env`
(copy `.env.example`, fill it in, `chmod 600 .env`) without evaluating it as shell code,
keeps CLI state under `.work/staging/cli`, and refuses `--config-dir`, `--api-url`, and
keeps CLI state under `.work/staging/cli` through `VOLTAGE_CONFIG_DIR`, and refuses `--config-dir`, `--api-url`, and
`--auth-url` so a staging command cannot target another deployment. A leading `--build`
works as it does for `voltage-local`, but checks `.env` before it builds when a command
follows; `--build` alone needs no `.env`. Browser login and
Expand Down Expand Up @@ -203,6 +203,20 @@ command line. A profile ignores `VOLTAGE_WALLET_ID` and the other ambient variab
`--wallet` explicitly. Confirm a wallet's reported network is a test network before creating
a payment. Browser login needs the device-login release deployed on the staging auth service.

### Try a pull request on staging

To check a pull request's change by hand, check out its branch, with `.env` filled in as in
step 1 above, and paste this from the repository root:

```sh
alias voltage="$PWD/scripts/voltage-staging"
voltage --build
voltage login --account me --credential-store file
```

Then run the commands in the pull request's **Try it on staging** section; they assume this
setup. After pulling new commits, run `voltage --build` again.

## Staging acceptance

Complete these steps against the deployed staging services before publishing a release, and
Expand Down
23 changes: 22 additions & 1 deletion docs/guides/login-and-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,12 @@ An API key cannot list organizations or environments, so with a key the CLI offe

## Profiles

A profile bundles an organization, an environment, and a credential, and ignores the scope variables above and `VOLTAGE_API_KEY`; flags still override its scope for one command.
A profile bundles an organization, an environment, and a credential, and ignores the scope variables above and `VOLTAGE_API_KEY`; flags still override its scope for one command. Other tools, such as the `voltage-api` agent skill's helper, read the same variables and let them win, so each command names on stderr every variable it ignores, even with `--quiet`:

```console
$ VOLTAGE_WALLET_ID=WALLET_ID voltage wallets list --profile staging
warning: VOLTAGE_WALLET_ID is set but ignored because --profile staging is selected
```

```sh
voltage profiles create staging --org ORG_ID --env ENV_ID --account work
Expand All @@ -80,6 +85,22 @@ voltage context --profile staging
voltage context --json | jq -r '.data.organization_id.value'
```

### Hand the scope to another tool

`voltage context --format env` prints the effective organization, environment, and wallet as dotenv lines, named as the `voltage-api` agent skill's helper and the [environment variables](../reference/environment.md) read them. Redirect it into the helper's dotenv file, or pass it to `env`:

```console
$ voltage context --profile staging --format env
VOLTAGE_ORGANIZATION_ID=ORG_ID
VOLTAGE_ENVIRONMENT_ID=ENV_ID
VOLTAGE_WALLET_ID=WALLET_ID
$ voltage context --profile staging --format env > scope.env
```

A value that is not set is left out rather than printed empty, because a tool can treat an empty variable as a value. More than one environment, as from repeated `--env`, fails with exit code 2 instead of choosing one. It cannot be combined with `--json`, `--output`, or `--output-file`; redirect stdout instead.

It never prints a credential: like the rest of `voltage context`, it reads no saved secret. Give the other tool an API key some other way, and use a key for the printed organization and environment, or its requests go elsewhere or fail.

## Configuration directory

Configuration lives in `$XDG_CONFIG_HOME/voltage` or `~/.config/voltage`; override the directory with `VOLTAGE_CONFIG_DIR` or `--config-dir`. The directory and everything in it are owner-only.
Expand Down
2 changes: 2 additions & 0 deletions docs/guides/requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,6 @@ voltage payments list --profile prod --statuses completed --statuses failed \

`--all` follows pagination by cursor, which the API recommends and the CLI requests by default; endpoints that only page by offset are followed by offset and count. The API has deprecated explicit offset paging (`--offset`, `--pagination offset`) where cursors exist, and the CLI warns when you use it. `--timeout SECONDS` (default 60) bounds the whole request, including paging and waits.

Every `list` command accepts `--all`. A list without `--limit`, such as `wallets list`, `organizations list`, `environments list`, or `profiles list`, returns every item at once, so it ignores `--all`.

Related: [safety model](../concepts/safety-model.md), [output reference](../reference/output.md).
2 changes: 2 additions & 0 deletions docs/guides/scripting.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ voltage payments list --profile prod --json | jq '.data.items[].status'
voltage context --json | jq -r '.data.organization_id.value'
```

To hand a profile's organization, environment, and wallet to a tool that reads `VOLTAGE_*` variables, use [`voltage context --format env`](login-and-profiles.md#hand-the-scope-to-another-tool).

## Send changes from a script

Commands that change something only describe their request unless you pass `--execute`, then exit with code 6 so a script that forgot it fails loudly. To send every change in a script or chain of commands, set `VOLTAGE_EXECUTE=1` (or `true`) instead:
Expand Down
4 changes: 0 additions & 4 deletions docs/quality.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,6 @@ an existing simpler pattern cannot satisfy it.
- `check_owner` in `config.rs` calls `libc::geteuid` in an `unsafe` block
because std has no safe effective-UID accessor; the block is one call with a
`SAFETY:` comment.
- `TerminalModes` in `terminal.rs` calls `libc::tcgetattr` and `libc::tcsetattr`
in `unsafe` blocks because std cannot save or restore terminal attributes. It
restores echo when Ctrl-C cancels a hidden prompt whose read cannot return;
each block is one call with a `SAFETY:` comment.
- `config`, `registry`, and `secret` are public modules. The integration
suite seeds saved credentials through the real persistence boundary and
walks the operation contract to exercise every route; no other item is
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/environment.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Environment variables

A flag beats a variable. A selected profile is a complete configuration, so `VOLTAGE_ORGANIZATION_ID`, `VOLTAGE_ENVIRONMENT_ID`, `VOLTAGE_WALLET_ID`, and `VOLTAGE_API_KEY` are ignored while one is selected; `--account` alone ignores only `VOLTAGE_API_KEY`. `voltage context` lists every variable that is set but ignored, and where each value it uses came from.
A flag beats a variable. A selected profile is a complete configuration, so `VOLTAGE_ORGANIZATION_ID`, `VOLTAGE_ENVIRONMENT_ID`, `VOLTAGE_WALLET_ID`, and `VOLTAGE_API_KEY` are ignored while one is selected; `--account` alone ignores only `VOLTAGE_API_KEY`. Every command that reads the configuration prints one `warning:` line on stderr for each variable that is set but ignored, before it prompts or sends a request, even with `--quiet`; stdout is unchanged. `auth import-key` and `profiles create` save the scope and account their flags name, so they warn only about a profile or `--config-dir`. `voltage context` lists the same variables, and where each value it uses came from.

| Variable | Used for | Flag that wins |
|---|---|---|
Expand Down
5 changes: 3 additions & 2 deletions scripts/voltage-local
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ if build:
if status or not args:
sys.exit(status)
binary = root / 'target/debug/voltage'
# A variable rather than --config-dir, so hints the CLI prints do not name a refused flag.
env['VOLTAGE_CONFIG_DIR'] = str(root / '.work/local-demo/cli')
if not binary.is_file():
sys.exit('Build the development CLI first: ./scripts/voltage-local --build')
os.execve(binary, [str(binary), '--config-dir', str(root / '.work/local-demo/cli'),
'--auth-url', 'http://127.0.0.1:8081/api/v1',
os.execve(binary, [str(binary), '--auth-url', 'http://127.0.0.1:8081/api/v1',
'--api-url', 'http://127.0.0.1:1/disabled-api', *args], env)
5 changes: 3 additions & 2 deletions scripts/voltage-staging
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,9 @@ if build:

env = {key: value for key, value in os.environ.items() if not key.startswith("VOLTAGE_")}
env.update({key: value for key, value in loaded.items() if key in CLI_ENV})
# The directory is a variable rather than --config-dir, so hints the CLI prints, such as
# the one after `profiles create`, do not name a flag this wrapper refuses.
env["VOLTAGE_CONFIG_DIR"] = str(root / ".work/staging/cli")
binary = root / "target/debug/voltage"
if not binary.is_file():
sys.exit("Build the development CLI first: ./scripts/voltage-staging --build")
Expand All @@ -108,8 +111,6 @@ os.execve(
binary,
[
str(binary),
"--config-dir",
str(root / ".work/staging/cli"),
"--auth-url",
loaded["VOLTAGE_STAGING_AUTH_URL"],
"--api-url",
Expand Down
20 changes: 15 additions & 5 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ use crate::{
cli::{GlobalFlags, ImportKeyFlags, LoginFlags},
config::{
AUTH_URL, Account, AccountKind, Credential, CredentialProblem, CredentialStore,
InputSource, LOCK_TIMEOUT, Login, Scope, ScopeSources, Settings, Source, name_in_use,
read_secret, shell_word, unreadable_name,
IgnoredVariable, InputSource, LOCK_TIMEOUT, Login, Precedence, Scope, ScopeSources,
Settings, Source, name_in_use, read_secret, shell_word, unreadable_name,
},
error::{CredentialRejection, CredentialSources, ErrorDetail, ResponseLimit},
secret::Secret,
Expand Down Expand Up @@ -576,9 +576,19 @@ pub(crate) fn environment_key_sources(
})
}

/// `VOLTAGE_API_KEY` is set, but a profile or account selection takes precedence over it.
pub(crate) fn ignores_ambient_api_key(flags: &GlobalFlags) -> bool {
std::env::var_os("VOLTAGE_API_KEY").is_some() && ambient_api_key(flags).is_none()
/// `VOLTAGE_API_KEY` when it is set but a profile or account selection takes precedence.
pub(crate) fn ignored_api_key(flags: &GlobalFlags) -> Option<IgnoredVariable> {
if std::env::var_os("VOLTAGE_API_KEY").is_none() || ambient_api_key(flags).is_some() {
return None;
}
Some(IgnoredVariable {
name: "VOLTAGE_API_KEY",
precedence: if flags.profile.is_some() {
Precedence::Profile
} else {
Precedence::Flag("--account")
},
})
}

/// The credential a command would use, chosen without reading a saved secret.
Expand Down
Loading
Loading