Input Sources

standout-input provides a unified way to acquire input before your handler runs. This enables interactive workflows like:

  • Opening an editor for commit messages
  • Prompting for confirmation ("Delete 5 items?")
  • Selecting from a list of options
  • Reading piped stdin for scripting
  • Pre-filling from clipboard

All without polluting your handler logic.


Why Input Sources?

CLI commands often need content that doesn't fit in command-line arguments. The gh pr create pattern is common:

# Option 1: Inline (awkward for long text)
gh pr create --body "Long description..."

# Option 2: Editor (interactive)
gh pr create --editor

# Option 3: Piped (scriptable)
echo "Description" | gh pr create --body-file -

Your CLI should support these patterns, but the logic doesn't belong in handlers:

  • Separation of concerns: Handlers produce results, input acquisition is a setup concern
  • Testability: Handler adapters receive already-resolved data through an explicit seam
  • Composability: Different commands can mix input sources

An InputChain runs as a pre-dispatch phase, before your handler executes. The handler receives the resolved value; input acquisition is transparent.


Source Types

Every source implements InputCollector<T> and composes into an InputChain<T>. See Backends for the full constructor and feature-flag reference for each one.

Non-Interactive Sources at a Glance

These work in scripts and CI pipelines:

SourceTypeUse Case
ArgSourceStringShort content as a CLI argument
FlagSourceboolA CLI flag, with an optional .inverted()
StdinSourceStringPiped content (cat file | cmd)
EnvSourceStringEnvironment variable
ClipboardSourceStringPre-filled content from the clipboard
DefaultSource<T>THardcoded fallback

Interactive Sources at a Glance

These require a terminal and are grouped by feature flag:

SourceFeatureTypeUse Case
TextPromptSourcesimple-prompts (default)StringShort text input
ConfirmPromptSourcesimple-prompts (default)boolYes/no questions
EditorSourceeditor (default)StringLong-form text (commit messages)
InquireTextinquireStringRich text input with autocomplete
InquireConfirminquireboolPolished yes/no prompt
InquireSelect<T>inquireTPick one from a list
InquireMultiSelect<T>inquireVec<T>Pick many from a list
InquirePasswordinquireStringHidden text input
InquireEditorinquireStringEditor with an inquire preview

Building a Chain

Chain sources in priority order with InputChain:

#![allow(unused)]
fn main() {
use standout_input::{InputChain, ArgSource, StdinSource, EditorSource};

let body = InputChain::<String>::new()
    .try_source(ArgSource::new("body"))   // First: try the CLI argument
    .try_source(StdinSource::new())       // Second: try piped stdin
    .try_source(EditorSource::new()       // Third: open the editor
        .extension(".md"))
    .resolve(&matches)?;
}

The chain stops at the first source whose is_available() returns true and whose collect() returns Some(_). This is the gh pr create pattern:

  • gh pr create --body "text" → uses the argument
  • echo "text" | gh pr create → uses stdin
  • gh pr create → opens the editor

Add .default(value) to fall back to a literal value instead of erroring with InputError::NoInput when every source is skipped, and .validate(f, "message") to apply a rule regardless of which source produced the value. See Introduction to Input for the full walkthrough.


Wiring a Chain to a Command

Outside the framework, a handler resolves a chain itself, passing the run's InputSources so stdin/clipboard/prompt mocks are honored in tests:

#![allow(unused)]
fn main() {
fn create(matches: &ArgMatches, ctx: &CommandContext) -> HandlerResult<Pad> {
    let body = InputChain::<String>::new()
        .try_source(ArgSource::new("body"))
        .try_source(StdinSource::new())
        .try_source(EditorSource::new())
        .resolve_from(matches, ctx.input_sources())?;

    /* business logic ... */
}
}

With the standout framework, CommandConfig::input(name, chain) registers the same chain to run in pre-dispatch, and the handler reads the resolved value with ctx.input::<T>(name) instead of resolving it itself. See Framework Integration for the full wiring and the CommandContextInput trait.


Skipping Interactive Sources

Some commands want a flag like --no-editor to skip interactive input entirely. Since chain construction is ordinary Rust, build the chain conditionally instead of adding sources that would prompt:

#![allow(unused)]
fn main() {
let no_editor = matches.get_flag("no-editor");

let mut chain = InputChain::<String>::new()
    .try_source(ArgSource::new("body"))
    .try_source(StdinSource::new());

if !no_editor {
    chain = chain.try_source(EditorSource::new());
}

let body = chain.default(String::new()).resolve(&matches)?;
}

Direct Use Without a Chain

For commands with input logic too specific for a declarative chain, call the primitives directly. Every interactive source also has a .prompt() shortcut that skips the chain and the &ArgMatches plumbing (see Standalone Prompts):

#![allow(unused)]
fn main() {
use standout_input::{read_if_piped, EditorSource};

fn create(matches: &ArgMatches, _ctx: &CommandContext) -> HandlerResult<Pad> {
    let no_editor = matches.get_flag("no-editor");
    let title_arg = matches.get_one::<String>("title");

    let content = if let Some(piped) = read_if_piped()? {
        // Piped input takes precedence
        piped
    } else if let Some(title) = title_arg {
        if no_editor {
            title.clone()
        } else {
            let body = EditorSource::new()
                .initial_content(format!("# {}\n\n", title))
                .extension(".md")
                .prompt()?;
            format!("{}\n\n{}", title, body)
        }
    } else if no_editor {
        return Err(anyhow!("No content provided. Use --title or pipe input."));
    } else {
        EditorSource::new().prompt()?
    };

    // ... rest of handler
}
}

Editor Detection

Editor detection follows established conventions:

PrioritySourceExample
1VISUAL env varVISUAL=code
2EDITOR env varEDITOR=vim
3Platform defaultvim, vi, nano (Unix), notepad (Windows)

EditorSource::is_available() also requires stdin to be a terminal, so a piped invocation never blocks on an editor.


Clipboard Integration

#![allow(unused)]
fn main() {
use standout_input::ClipboardSource;

let content = InputChain::<String>::new()
    .try_source(ArgSource::new("content"))
    .try_source(ClipboardSource::new())
    .try_source(EditorSource::new())
    .resolve(&matches)?;
}

Platform support:

PlatformRead Command
macOSpbpaste
Linuxxclip -selection clipboard -o
OtherInputError::ClipboardFailed — not supported

Comparison with Output Piping

Input sources and output piping are symmetric but opposite:

AspectInput SourcesOutput Piping
DirectionExternal → HandlerHandler → External
Pipeline positionPre-dispatchPost-output
InteractiveCan be (editor, prompts)Never
PurposeAcquire contentTransform/route output
              INPUT SOURCES                    OUTPUT PIPING
              ↓                                ↓
[Arg/Stdin/Editor] → Handler → Render → [jq/tee/clipboard]

Error Handling

InputError carries the failure reason so a chain-level ? produces an actionable message:

No editor found. Set VISUAL or EDITOR environment variable.  // InputError::NoEditor
Editor cancelled without saving.                              // InputError::EditorCancelled
Failed to read stdin: <io error>                              // InputError::StdinFailed
Validation failed: Message cannot be empty                    // InputError::ValidationFailed
No input provided and no default available.                   // InputError::NoInput

For interactive sources, a validation failure re-prompts instead of returning an error — see Backends for the retry semantics.


Security Considerations

Editor execution: The editor command is resolved from environment variables. Ensure VISUAL/EDITOR are set by the user, not from untrusted sources.

Temp file handling: EditorSource writes the initial content to a named temp file and hands it to the editor process; the file is removed when the collector drops it. Content may briefly exist on disk in the system temp directory.


Summary

FeatureMethod
From a CLI argumentArgSource::new("name")
From a CLI flagFlagSource::new("name")
From piped stdinStdinSource::new()
From an environment variableEnvSource::new("VAR")
From the clipboardClipboardSource::new()
From the editorEditorSource::new()
Fallback value.default(value)
Validation.validate(f, "error message")
Chain multiple sourcesInputChain::new().try_source(...).try_source(...)

For the full constructor reference and feature flags, see Backends. For wiring a chain into a standout command, see Framework Integration.