Skip to content
Open
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 @@ -4,6 +4,7 @@ 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`.
Expand Down
16 changes: 16 additions & 0 deletions docs/guides/login-and-profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,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/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
97 changes: 94 additions & 3 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ use crate::{
};
use clap::{
Arg, ArgAction, ArgMatches, Args, Command as ClapCommand, FromArgMatches, Subcommand,
ValueEnum,
builder::{PossibleValuesParser, TypedValueParser},
error::ErrorKind,
};
Expand Down Expand Up @@ -211,7 +212,7 @@ pub enum LocalCommand {
command: ProfileCommand,
},
/// Show the effective profile, scope, credential, and config path without secrets
Context,
Context(ContextFlags),
/// Show the BTC/USD price and the sats one dollar buys
Price(PriceFlags),
/// Convert an amount between BTC and USD at the current or a past price
Expand All @@ -223,6 +224,44 @@ pub enum LocalCommand {
},
}

#[derive(Debug, Args)]
pub struct ContextFlags {
/// Print the organization, environment, and wallet as VOLTAGE_* dotenv lines instead
#[arg(long, value_enum, value_name = "FORMAT")]
pub format: Option<ContextFormat>,
}

impl ContextFlags {
/// `--format` writes its own lines to stdout, so it takes no result format or file. Checked
/// on the typed flags because clap does not compare a global flag given before the
/// subcommand with the subcommand's own flags.
fn check(&self, global: &GlobalFlags) -> Result<()> {
let result_flag = if global.json {
Some("--json")
} else if global.output.is_some() {
Some("--output")
} else if global.output_file.is_some() {
Some("--output-file")
} else {
None
};
match (self.format, result_flag) {
(Some(_), Some(flag)) => Err(Error::usage(format!(
"--format cannot be combined with {flag}"
))
.with_hint("Redirect stdout to save the lines, as in `voltage context --format env > scope.env`.")),
_ => Ok(()),
}
}
}

/// Another tool's format for `voltage context`.
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum ContextFormat {
/// `NAME=VALUE` lines, the names the `voltage-api` skill's helper reads
Env,
}

#[derive(Debug, Args)]
pub struct PriceFlags {
/// Price at this UTC time, such as 2026-09-18T17:30:00Z, instead of now
Expand Down Expand Up @@ -754,6 +793,9 @@ impl Invocation {
}
Err(error) => return Err(Error::usage(error.to_string())),
};
if let Command::Local(LocalCommand::Context(flags)) = &command {
flags.check(&global)?;
}
Ok(Self { global, command })
}
}
Expand Down Expand Up @@ -1350,7 +1392,9 @@ fn local_examples(path: &[&str]) -> Option<&'static str> {
}
["context"] => {
" # Show what a command with this profile would use, and where each value came from
voltage context --profile prod"
voltage context --profile prod
# Hand the profile's organization, environment, and wallet to another tool as dotenv lines
voltage context --profile prod --format env > scope.env"
}
["convert"] => {
" # What $10 buys in bitcoin now, and 21,000 sats in dollars on a past date
Expand Down Expand Up @@ -1449,6 +1493,45 @@ mod tests {
}
}

#[test]
fn context_format_refuses_a_result_format_or_file_before_or_after_the_command() {
for (args, flag) in [
(
&["voltage", "--json", "context", "--format", "env"][..],
"--json",
),
(
&["voltage", "context", "--format", "env", "--json"],
"--json",
),
(
&["voltage", "-o", "table", "context", "--format", "env"],
"--output",
),
(
&[
"voltage",
"context",
"--format",
"env",
"--output-file",
"scope.env",
],
"--output-file",
),
] {
let error = Invocation::try_parse_from(args).err().unwrap();
assert_eq!(error.kind, Kind::Usage, "{args:?}");
assert_eq!(
error.message,
format!("--format cannot be combined with {flag}"),
"{args:?}"
);
}
assert!(Invocation::try_parse_from(["voltage", "context", "--json"]).is_ok());
assert!(Invocation::try_parse_from(["voltage", "context", "--format", "shell"]).is_err());
}

#[test]
fn command_tree_is_valid() {
command().debug_assert();
Expand Down Expand Up @@ -1947,7 +2030,15 @@ mod tests {
Invocation::try_parse_from(["voltage", "context"])
.unwrap()
.command,
Command::Local(LocalCommand::Context)
Command::Local(LocalCommand::Context(ContextFlags { format: None }))
));
assert!(matches!(
Invocation::try_parse_from(["voltage", "context", "--format", "env"])
.unwrap()
.command,
Command::Local(LocalCommand::Context(ContextFlags {
format: Some(ContextFormat::Env)
}))
));
assert!(matches!(
Invocation::try_parse_from(["voltage", "price"])
Expand Down
31 changes: 30 additions & 1 deletion src/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
//! makes a network request, or creates the configuration directory.

use crate::{
Result,
Error, Result,
auth::{self, Rejected, Selected},
cli::GlobalFlags,
config::{
Expand Down Expand Up @@ -249,6 +249,35 @@ pub fn inspect(settings: &Settings, global: &GlobalFlags) -> Result<Context> {
})
}

impl Context {
/// The organization, environment, and wallet as dotenv lines, named as the `voltage-api`
/// skill's helper reads them, for `voltage context --format env`. An unset value is left
/// out, because the helper treats an empty variable as an override. A credential is never
/// printed: inspection reads no secret. A tool takes one environment, so more than one is
/// refused rather than choosing.
pub fn dotenv(&self) -> Result<String> {
let environment = match self.environment_ids.value.as_slice() {
[] => None,
[environment] => Some(*environment),
environments => {
return Err(Error::usage(format!(
"--format env takes one environment, but {} are selected",
environments.len()
))
.with_hint("Select one with --env ENV_ID."));
}
};
Ok([
("VOLTAGE_ORGANIZATION_ID", self.organization_id.value),
("VOLTAGE_ENVIRONMENT_ID", environment),
("VOLTAGE_WALLET_ID", self.wallet_id.value),
]
.into_iter()
.filter_map(|(name, value)| value.map(|id| format!("{name}={id}\n")))
.collect())
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
2 changes: 1 addition & 1 deletion src/pick.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ fn needs(command: &Command) -> Needs {
command:
ProfileCommand::List | ProfileCommand::Get { .. } | ProfileCommand::Delete { .. },
}
| LocalCommand::Context
| LocalCommand::Context(_)
| LocalCommand::Price(_)
| LocalCommand::Convert(_)
| LocalCommand::Completions { .. },
Expand Down
20 changes: 15 additions & 5 deletions src/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ use crate::{
api::{self, Execution, SubmissionState},
auth::{self, Discovery, Resolver},
cli::{
self, AuthCommand, Command, ConvertFlags, GlobalFlags, Invocation, LocalCommand,
ParseFailure, PriceFlags, ProfileCommand,
self, AuthCommand, Command, ContextFlags, ContextFormat, ConvertFlags, GlobalFlags,
Invocation, LocalCommand, ParseFailure, PriceFlags, ProfileCommand,
},
config::{self, Precedence, Profile, Scope, Settings},
context,
Expand Down Expand Up @@ -150,6 +150,14 @@ async fn execute(invocation: Invocation, submission: &SubmissionState) -> Result
let mut out = output(!execution.describes())?;
api::execute(&api, execution, &global, resolver, &session.scope, &mut out).await
}
// Dotenv lines for another tool, not a result envelope.
Command::Local(LocalCommand::Context(ContextFlags {
format: Some(ContextFormat::Env),
})) => write_stdout(
context::inspect(&session.settings, &global)?
.dotenv()?
.as_bytes(),
),
Command::Local(local) => {
let mut out = output(true)?;
let envelope = session.local(local).await?;
Expand Down Expand Up @@ -273,8 +281,10 @@ impl<'a> Session<'a> {
.discover(scope, Discovery::Environments)
.await?,
),
LocalCommand::Context => Envelope::settings(context::inspect(&self.settings, global)?)
.map(|context| context.titled("voltage context")),
LocalCommand::Context(_) => {
Envelope::settings(context::inspect(&self.settings, global)?)
.map(|context| context.titled("voltage context"))
}
LocalCommand::Profiles { command } => {
profiles(command, &mut self.settings, scope, global).await
}
Expand Down Expand Up @@ -438,7 +448,7 @@ mod tests {
// Any other command comes back unchanged, so `execute` can go on with it.
assert!(matches!(
PriceQuery::try_from(command(&["voltage", "context"])),
Err(Command::Local(LocalCommand::Context))
Err(Command::Local(LocalCommand::Context(_)))
));
assert!(matches!(
PriceQuery::try_from(command(&["voltage", "wallets", "list"])),
Expand Down
45 changes: 45 additions & 0 deletions tests/cli_integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,51 @@ async fn profiles_are_created_from_scope_and_listed_without_ambient_state() {
assert!(reloaded.config.accounts.contains_key(LOGIN_NAME));
}

#[tokio::test]
async fn context_env_format_prints_only_set_scope_and_never_a_credential() {
let (server, dir) = fixture().await;
let mut settings = seeded_api_key(dir.path(), "stage", "stage-key");
settings.config.profiles.insert(
"stage".into(),
Profile {
organization_id: uuid(ORG),
environment_id: uuid(ENV),
account: "stage".into(),
},
);
settings.save().unwrap();
let env_format = |args: &[&str]| {
Command::from_std(human_process_at(dir.path(), &server.uri()))
.args(["context", "--format", "env"])
.args(args)
.assert()
};

let profiled = env_format(&["--profile", "stage", "--wallet", WALLET]).success();
assert_eq!(
stdout(&profiled),
format!(
"VOLTAGE_ORGANIZATION_ID={ORG}\nVOLTAGE_ENVIRONMENT_ID={ENV}\nVOLTAGE_WALLET_ID={WALLET}\n"
)
);
// Neither the profile's saved key nor the ambient key is printed.
assert!(!stdout(&profiled).contains("stage-key"));
assert!(!stdout(&profiled).contains(ACCOUNT_KEY));

// An unset value is left out rather than printed empty.
let partial = env_format(&["--org", ORG]).success();
assert_eq!(stdout(&partial), format!("VOLTAGE_ORGANIZATION_ID={ORG}\n"));
assert_eq!(stdout(&env_format(&[]).success()), "");

let two = env_format(&["--org", ORG, "--env", ENV, "--env", RESOURCE]).code(2);
assert_eq!(stdout(&two), "");
assert!(
stderr(&two).contains("--format env takes one environment, but 2 are selected"),
"{}",
stderr(&two)
);
}

#[tokio::test]
async fn context_reports_effective_scope_and_sources_without_reading_secrets() {
let (server, dir) = fixture().await;
Expand Down
Loading