Skip to content

feat(cli): pick a missing organization, environment, or wallet - #22

Open
thebrandonlucas wants to merge 1 commit into
docs/help-examples-executefrom
feat/cli-interactive-pickers
Open

thebrandonlucas wants to merge 1 commit into
docs/help-examples-executefrom
feat/cli-interactive-pickers

Conversation

@thebrandonlucas

@thebrandonlucas thebrandonlucas commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

Stack: master ← #20 ← this PR ← #24 ← #19 ← #26 ← #27 ← #28 ← #29.

Summary

On a terminal, a command that lacks the organization, environment, or wallet it needs now asks for it instead of failing. Implements DEV-2903 (first part).

$ voltage payments send --org ORG_ID --env ENV_ID --currency btc --invoice lnbc1…
? Wallet: pay
> Payroll  cccccccc-cccc-4ccc-8ccc-cccccccccccc
Next time, pass --wallet cccccccc-… or save a profile with voltage profiles create.
dry_run  …                       (still only described: --execute is the approval)
Command Asks for
API commands Each scope in the path: organization, environment, wallet
Friendly payments send / receive Wallet
Friendly wallets create Environment
environments list Organization
profiles create Organization and environment
  • Choices come from the same sources as organizations list, environments list, and wallets list (in the selected environment). Each shows as name UUID. Typing filters by any part of the name or ID.
  • Never asks when stdin or stderr is not a terminal, or with --no-input, --json, --output json|ndjson, or --dry-run. Those runs fail with the same error as before.
  • Esc leaves the scope unset, so the command reports what is missing. An empty list says so. Ctrl-C exits with 130.
  • API keys cannot list organizations or environments. With a key, only a wallet is offered, and a missing organization still fails with --org is required (exit 2).
  • The picker resolves the credential once and exchanges at most one organization token, then the command runs as before.

Stack: master ← #15 ← #20 ← #21 ← this PR.

Review exceptions

  • Dependency: inquire =0.9.4, default features off plus crossterm. It draws the picker through crossterm, which feat(cli): fit human tables to the terminal and scrub all API text #15 already added, so the CLI keeps one terminal backend. It enables crossterm's events feature, which brings mio, signal-hook, signal-hook-mio, and derive_more. New crates from inquire itself: inquire and dyn-clone. The fuzzy feature is left off: its default substring filter matches names and IDs, and dropping fuzzy avoids fuzzy-matcher and thread_local.
  • Crate-visible items: terminal::Choice and Terminal::pick (prompts belong to terminal), api::wallets (HTTP belongs to api), and auth::exchange and auth::list (split out of resolve_organization and discover so the picker resolves once). Nothing is added to the library's public API.
  • Getter: Choice::name(). A Choice scrubs its API-supplied name in Choice::new, and private fields keep unscrubbed text from reaching the picker.
  • Guard: PickerScreen turns bracketed paste off and shows the cursor on drop. A signal that ends the command while the picker is open skips inquire's own cleanup. TerminalModes restores the terminal attributes, as for the hidden key prompt.

Deferred

  • Picking a positional target, such as wallets get with no ID. This changes clap's required argument.
  • auth import-key (organization and one environment) and webhook path scopes do not ask yet.
  • Scope-free validation still runs after the pickers. payments send --unit sats without --amount asks first, then fails.

Verification

  • cargo fmt --check, cargo clippy --locked --all-targets -- -D warnings, cargo machete --with-metadata, cargo test --locked, python3 scripts/check-coverage.py
  • Unit tests: which commands ask for which scope, and how listings become scrubbed, sorted choices.
  • Pseudo-terminal integration tests:
    • typing a filter and Enter picks a wallet, and the change is still only described (exit 6);
    • Esc gives the original error (exit 2);
    • typed Ctrl-C gives exit 130;
    • terminal modes are restored each time;
    • --json, --no-input, and --dry-run neither ask nor list;
    • an API key without an organization keeps --org is required with its hint and sends no request.
  • The tests fail when the JSON-output gate is removed.
  • A reviewer (Cicero) reviewed the first version. The fixes for that review are included.

CRAP report

After tee8z's review suggestions (tests for the organization and environment pickers with a login, the API-key environment notice, and an empty wallet list; on_a_terminal takes (prompt, keys) steps): scripts/crap-report.sh at 2a34199 finds no function at or above 20. pick::ask went from 28.30 to 13.01 (95.9% coverage), and pick::fill is 11.00. The highest score is api::wait_for_payment at 19.66, which this PR does not change. cargo test --locked: 92 unit and 65 integration tests pass.

tee8z
tee8z previously approved these changes Oct 9, 2026
Base automatically changed from docs/readme-landing to docs/help-examples-execute October 13, 2026 16:38
@thebrandonlucas
thebrandonlucas force-pushed the feat/cli-interactive-pickers branch from 9540228 to 6c29564 Compare October 13, 2026 16:40
@thebrandonlucas
thebrandonlucas force-pushed the feat/cli-interactive-pickers branch from 6c29564 to 33efa27 Compare October 13, 2026 16:44
@thebrandonlucas
thebrandonlucas force-pushed the feat/cli-interactive-pickers branch from 33efa27 to 069b2d5 Compare October 13, 2026 18:56
@thebrandonlucas
thebrandonlucas added this pull request to stack #25 October 13, 2026 18:56
@thebrandonlucas
thebrandonlucas force-pushed the feat/cli-interactive-pickers branch 3 times, most recently from 1a065cb to 37e9bc5 Compare October 19, 2026 18:25
@thebrandonlucas
thebrandonlucas force-pushed the feat/cli-interactive-pickers branch from 37e9bc5 to f5b085e Compare October 19, 2026 18:39
@thebrandonlucas
thebrandonlucas force-pushed the feat/cli-interactive-pickers branch from f5b085e to 7e7814c Compare October 19, 2026 19:19
Comment thread tests/cli_integration.rs
}
let child = command.spawn().unwrap();
let (prompted, shown_prompt) = tokio::sync::oneshot::channel();
let drain = tokio::task::spawn_blocking(move || {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice, love seeing some chunking

@tee8z tee8z left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review against docs/quality.md, with the CRAP report (scripts/crap-report.sh) run on this head.

ask fails the CRAP budget: 28.30 (cyclomatic 13, 55.1% coverage). It is the only function over 20 in the crate. The branching is fine: a straight sequence of early returns with nesting depth 1. The problem is that no test runs the organization and environment pickers with a browser login (src/pick.rs:123-151). The quality guide says to add meaningful tests rather than split functions, so these suggestions add tests:

  • Login, organization then environment: profiles create picks both. environments list picks only the organization.
  • API key with --org but no --env: shows the environments notice.
  • Empty wallet list: "No wallets to choose from."

To drive two pickers in one run, on_a_terminal now takes (prompt, keys) steps instead of Option<prompt> plus keys. The suggestions also drop the empty-list check in Terminal::pick, which can never run because choose returns first.

Apply them all as one batch (Files changed → "Add suggestion to batch" on each). The call-site suggestions only compile with the new on_a_terminal signature.

With all suggestions applied, I ran:

  • cargo fmt --check and cargo clippy --locked --all-targets -- -D warnings: clean.
  • cargo test --locked: 92 unit and 65 integration tests pass.
  • CRAP report: ask is 13.01 (95.9% coverage), fill 11.00 (100%), and no function violates the budget.
  • PR diff against docs/help-examples-execute: 876 changed lines, under the 1,000-line limit.

Not suggested (outside this diff): on_a_terminal repeats most of interrupt_the_hidden_key_prompt (tests/cli_integration.rs:1734): the terminal setup, the unsafe pre_exec block, the output reader, and the kill-on-timeout cleanup. GitHub can't suggest changes to lines this PR doesn't touch. A follow-up could move that helper onto the same spawn and cleanup code so there is one unsafe block.

Comment thread src/terminal.rs Outdated
Comment on lines +66 to +72
/// Ask the person to pick one choice, filtering as they type. `None` means there was
/// nothing to pick or they pressed Esc. Raw mode turns a typed Ctrl-C into a key, so the
/// picker reports Ctrl-C as an interruption.
pub async fn pick(self, question: &'static str, choices: Vec<Choice>) -> Result<Option<Uuid>> {
if choices.is_empty() {
return Ok(None);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

choose in pick.rs already returns before calling pick with an empty list, so this check can never run.

Suggested change
/// Ask the person to pick one choice, filtering as they type. `None` means there was
/// nothing to pick or they pressed Esc. Raw mode turns a typed Ctrl-C into a key, so the
/// picker reports Ctrl-C as an interruption.
pub async fn pick(self, question: &'static str, choices: Vec<Choice>) -> Result<Option<Uuid>> {
if choices.is_empty() {
return Ok(None);
}
/// Ask the person to pick one of a non-empty list of choices, filtering as they type.
/// `None` means they pressed Esc. Raw mode turns a typed Ctrl-C into a key, so the picker
/// reports Ctrl-C as an interruption.

Comment thread tests/cli_integration.rs Outdated
Comment on lines +3011 to +3019
/// Run a command with stdin and stderr on a pseudo-terminal and stdout piped. Once the
/// terminal shows `prompt`, type `keys`; without a prompt, nothing is typed.
#[cfg(unix)]
async fn on_a_terminal(
dir: &Path,
server: &MockServer,
args: &[&str],
prompt: Option<&'static str>,
keys: &'static [u8],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Take a list of (prompt, keys) steps instead of prompt: Option<_> plus keys. Today keys is silently ignored when prompt is None, and the org-then-env test below needs two prompts in one run. An empty list means nothing is typed. Apply this together with the next suggestion.

Suggested change
/// Run a command with stdin and stderr on a pseudo-terminal and stdout piped. Once the
/// terminal shows `prompt`, type `keys`; without a prompt, nothing is typed.
#[cfg(unix)]
async fn on_a_terminal(
dir: &Path,
server: &MockServer,
args: &[&str],
prompt: Option<&'static str>,
keys: &'static [u8],
/// Run a command with stdin and stderr on a pseudo-terminal and stdout piped. For each step,
/// once the terminal shows its prompt after the previous step's, type its keys.
#[cfg(unix)]
async fn on_a_terminal(
dir: &Path,
server: &MockServer,
args: &[&str],
steps: &[(&'static str, &'static [u8])],

Comment thread tests/cli_integration.rs Outdated
Comment on lines +3045 to +3064
let (prompted, shown_prompt) = tokio::sync::oneshot::channel();
let drain = tokio::task::spawn_blocking(move || {
let mut prompted = Some(prompted);
let mut shown = Vec::new();
let mut chunk = [0; 4096];
while let Ok(read @ 1..) = controller.read(&mut chunk) {
shown.extend_from_slice(&chunk[..read]);
if prompt.is_some_and(|prompt| String::from_utf8_lossy(&shown).contains(prompt)) {
prompted.take().map(|prompted| prompted.send(()));
}
}
String::from_utf8_lossy(&shown).into_owned()
});
if prompt.is_some() {
tokio::time::timeout(Duration::from_secs(10), shown_prompt)
.await
.unwrap()
.unwrap();
(&observer).write_all(keys).unwrap();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each step's prompt has to appear after the previous one before its keys are typed. That way a redraw of the first picker can't trigger the second step's keys.

Suggested change
let (prompted, shown_prompt) = tokio::sync::oneshot::channel();
let drain = tokio::task::spawn_blocking(move || {
let mut prompted = Some(prompted);
let mut shown = Vec::new();
let mut chunk = [0; 4096];
while let Ok(read @ 1..) = controller.read(&mut chunk) {
shown.extend_from_slice(&chunk[..read]);
if prompt.is_some_and(|prompt| String::from_utf8_lossy(&shown).contains(prompt)) {
prompted.take().map(|prompted| prompted.send(()));
}
}
String::from_utf8_lossy(&shown).into_owned()
});
if prompt.is_some() {
tokio::time::timeout(Duration::from_secs(10), shown_prompt)
.await
.unwrap()
.unwrap();
(&observer).write_all(keys).unwrap();
}
let mut prompts = steps
.iter()
.map(|&(prompt, _)| prompt.as_bytes())
.collect::<Vec<_>>();
prompts.reverse();
let (prompted, mut shown_prompt) = tokio::sync::mpsc::unbounded_channel();
let drain = tokio::task::spawn_blocking(move || {
let (mut shown, mut seen) = (Vec::new(), 0);
let mut chunk = [0; 4096];
while let Ok(read @ 1..) = controller.read(&mut chunk) {
shown.extend_from_slice(&chunk[..read]);
while let Some(prompt) = prompts.last() {
let Some(at) = shown[seen..]
.windows(prompt.len())
.position(|w| w == *prompt)
else {
break;
};
seen += at + prompt.len();
prompts.pop();
let _ = prompted.send(());
}
}
String::from_utf8_lossy(&shown).into_owned()
});
for &(_, keys) in steps {
tokio::time::timeout(Duration::from_secs(10), shown_prompt.recv())
.await
.unwrap()
.unwrap();
(&observer).write_all(keys).unwrap();
}

Comment thread tests/cli_integration.rs Outdated
};

// Typing filters the list, and Enter picks; the change is still only described.
let picked = on_a_terminal(dir.path(), &server, &send, Some("Wallet:"), b"Pay\r").await;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call site for the new steps signature.

Suggested change
let picked = on_a_terminal(dir.path(), &server, &send, Some("Wallet:"), b"Pay\r").await;
let picked = on_a_terminal(dir.path(), &server, &send, &[("Wallet:", b"Pay\r")]).await;

Comment thread tests/cli_integration.rs Outdated
assert_eq!(picked.modes.0, picked.modes.1);

// Esc leaves the scope unset, so the command fails as it did before pickers.
let escaped = on_a_terminal(dir.path(), &server, &send, Some("Wallet:"), b"\x1b").await;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call site for the new steps signature.

Suggested change
let escaped = on_a_terminal(dir.path(), &server, &send, Some("Wallet:"), b"\x1b").await;
let escaped = on_a_terminal(dir.path(), &server, &send, &[("Wallet:", b"\x1b")]).await;

Comment thread tests/cli_integration.rs Outdated
Comment on lines +3191 to +3192
None,
b"",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call site for the new steps signature.

Suggested change
None,
b"",
&[],

Comment thread tests/cli_integration.rs Outdated
"--output",
"table",
];
let run = on_a_terminal(dir.path(), &server, &receive, None, b"").await;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call site for the new steps signature.

Suggested change
let run = on_a_terminal(dir.path(), &server, &receive, None, b"").await;
let run = on_a_terminal(dir.path(), &server, &receive, &[]).await;

Comment thread tests/cli_integration.rs
.contains("--wallet is required for payment creation"),
"{}",
run.shown
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Covers the API-key notice for environments (pick.rs:143).

Suggested change
);
);
// With an organization, the key still cannot list environments, and says so.
let mut args = receive.to_vec();
args.extend(["--org", ORG]);
let run = on_a_terminal(dir.path(), &server, &args, &[]).await;
assert_eq!(run.code, Some(2), "{}", run.shown);
assert!(
run.shown
.contains("An API key cannot list environments, so there is nothing to pick."),
"{}",
run.shown
);

Comment thread tests/cli_integration.rs Outdated
Comment on lines +3255 to +3256
None,
b"",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Call site for the new steps signature.

Suggested change
None,
b"",
&[],

Comment thread tests/cli_integration.rs
assert!(!run.shown.contains("--wallet is required"), "{}", run.shown);
assert!(server.received_requests().await.unwrap().is_empty());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Runs the organization and environment pickers with a browser login (pick.rs:123-151), the return after only an organization (pick.rs:138), and the profile-hint branch in fill (pick.rs:98). Before this, no test ran them, which is why ask scored CRAP 28.30 (cyclomatic 13, 55.1% coverage).

Suggested change
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_login_picks_an_organization_then_an_environment() {
let auth = MockServer::start().await;
let (api, dir) = fixture().await;
let mut settings = seeded_login(dir.path(), &auth, false);
let organizations = json!({"organizations": [{"id": ORG, "name": "Acme"}]});
respond(&auth, route("GET", "/users/current"), ok(organizations), 2).await;
organization_exchange(&auth, LOGIN_ACCESS, ORG, "picker-access", 2).await;
let environments = route("GET", format!("/organizations/{ORG}/environments"))
.and(header("authorization", "Bearer picker-access"));
respond(
&auth,
environments,
ok(json!([{"id": ENV, "name": "prod"}])),
2,
)
.await;
// A profile needs both. Saving one is what the hint would suggest, so it is left out.
let args = [
"profiles",
"create",
"prod",
"--account",
LOGIN_NAME,
"--output",
"table",
];
let steps: &[(&str, &[u8])] = &[("Organization:", b"\r"), ("Environment:", b"\r")];
let run = on_a_terminal(dir.path(), &api, &args, steps).await;
assert_eq!(run.code, Some(0), "{}", run.shown);
assert!(
run.shown
.contains(&format!("Next time, pass --org {ORG} --env {ENV}.")),
"{}",
run.shown
);
settings.reload().unwrap();
let profile = &settings.config.profiles["prod"];
assert_eq!(
(profile.organization_id, profile.environment_id),
(uuid(ORG), uuid(ENV))
);
// Listing environments needs only the organization, so nothing more is asked.
let args = [
"environments",
"list",
"--account",
LOGIN_NAME,
"--output",
"table",
];
let run = on_a_terminal(dir.path(), &api, &args, &[("Organization:", b"\r")]).await;
assert_eq!(run.code, Some(0), "{}", run.shown);
assert!(!run.shown.contains("Environment:"), "{}", run.shown);
auth.verify().await;
}

On a terminal, a command that lacks the organization, environment, or
wallet it needs now asks for it instead of failing. A picker, filtered
by typing part of a name or ID, lists each choice as "name  UUID" from the same sources as
organizations list, environments list, and wallets list, in the
environment when one is selected. Afterwards a notice names the flags
that select the same scope.

API commands ask for the scope their path needs. Friendly payment
creation asks for a wallet, and friendly wallet creation for an
environment. environments list asks for an organization, and profiles
create for an organization and environment. A positional target is
still typed.

Nothing is asked when stdin or stderr is not a terminal, with
--no-input, with JSON or NDJSON output, or with --dry-run, so scripts
see the same errors as before. Esc leaves the scope unchanged and the
command reports what is missing; Ctrl-C exits with code 130. An API key
cannot list organizations or environments, so with a key only a wallet is
offered and a missing organization fails as before. Picking
never sends a change, which still needs --execute. API names are
scrubbed before the picker draws them.

The picker resolves the credential once and exchanges at most one
organization token. inquire draws it through crossterm, which it now runs
with its event features. Pseudo-terminal tests cover a pick, Esc, Ctrl-C,
the no-prompt flags, an empty wallet list, an API key without an
organization or environment, and a login that picks an organization and
then an environment.
@thebrandonlucas
thebrandonlucas force-pushed the feat/cli-interactive-pickers branch from 7e7814c to 2a34199 Compare October 20, 2026 00:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants