RFD 086: Line-oriented stdin input for CLI arguments
- Status: Accepted
- Category: Design
- Authors: Jean Mertz git@jeanmertz.com
- Date: 2026-06-17
- Extends: RFD 048
Summary
This RFD establishes a project-wide CLI convention with two clauses:
- For a multi-value argument that accepts stdin input, the value
-means "read this argument's values from standard input." - That stdin input is line-oriented: one value per line.
The rule is a convention, not a command feature: any multi-value argument that chooses to read stdin does so via -, line-oriented — there is no second spelling, and the rule does not require every argument to read stdin. Its first and only opt-in so far is conversation targeting — jp c archive -, jp c rm -, and the other multi-ID commands — which lets the ID-list output of one command pipe straight into the next.
Single-value arguments are out of scope: - is rejected on them for now (see Non-Goals).
Motivation
JP's management commands (archive, rm, print, path) take one or more conversation IDs. Today the only ways to supply them are literal IDs on the command line, keywords (+pinned, +session), or the interactive picker. There is no way to feed a computed set of IDs — "every conversation whose title matches \Apr-triage:\d{3}\z" — without hand-copying IDs out of one command and into another.
The motivating workflow is archiving a batch of conversations selected by a pattern:
jp c grep -l --regex '\Apr-triage:\d{3}\z' | jp c archive -RFD 050 defines the production half of this story for conversation new and conversation fork: they print one conversation ID per line (JSON array under -F json). jp c grep --list emits the same line-oriented ID format. This RFD defines the consumption half — the matching input convention — so the line-oriented ID output of one JP command is valid input to another with no jq/xargs glue.
Doing nothing leaves two costs. Users assemble per-command shell glue (... | jq -r '.[].id' | xargs ...) that leaks JP's opaque ID format into scripts, and every command that might one day read stdin reinvents the handling ad hoc, with no shared rule for what - means.
Design
The convention
For a multi-value argument that accepts stdin input, the value - means "read this argument's values from stdin," one value per line. It applies whether the argument is positional (jp c archive -) or a multi-value flag. Phase 1 opts in only conversation targeting; other multi-value arguments (--scope, --mount, …) keep their current behavior until they explicitly opt in. It is the rule users already know from cat -, git, and most Unix tools (Principle of Least Astonishment).
The format - consumes is the line-oriented ID list: one opaque conversation ID per nonblank line. This is the same format conversation new, conversation fork, and grep --list emit in text mode — a deliberate, stable machine format, distinct from the human-facing rendered output that JP changes freely. - does not consume JSON: -F json emits an array, which a tool transforms (jq -r '.[].id') before piping into -.
What the user sees
# positional, multi-ID
jp c grep -l --regex '\Apr-triage:\d{3}\z' | jp c archive -
# explicit producer, same shape
jp c ls -F json | jq -r '.[].id' | jp c rm -The IDs arrive resolved before the command runs, exactly as if typed on the command line. Commands stay ignorant of stdin — archive and rm never read it themselves.
Where the read happens
Reading stdin is I/O and belongs in the imperative shell, not in argument parsing. So - parses to a sentinel, and the read happens once, later, when the shell resolves the argument:
- Parsing records "stdin requested" — no I/O, parsing stays pure.
- The shell reads stdin a single time, splits on newlines, normalizes, and expands the sentinel into concrete values.
For conversation targeting this slots into the existing seam with no new machinery. ConversationTarget::parse maps - to a new Stdin variant, and ConversationTarget::resolve (in cmd/target.rs) reads stdin and resolves it, the same way AllSession resolves one token into many IDs.
Affected commands
resolve — not resolve_request — is the seam. Every path that turns a target into IDs funnels through it, including the commands that resolve targets internally, so - reaches every conversation-target command, not only the four in the motivation:
| Command(s) | Resolution path | Notes |
|---|---|---|
archive, rm | resolve_request | destructive; confirmation applies |
print, path, ls, show | resolve_request | read-only |
edit, fork, compact | resolve_request | mutate state |
grep | resolve_request | producer and consumer both |
unarchive | internal resolve_ids | bypasses resolve_request, but |
still calls resolve |
This breadth is intended: - is a generic targeting source, so a reader has no reason to expect print - to work but show - not to.
The invariant the implementation must hold: every command that turns targets into IDs forwards non-Id targets to ConversationTarget::resolve rather than pattern-matching Id and dropping the rest. A command that silently ignores a Stdin target would accept - and do nothing. unarchive already satisfies this; new commands must too.
Normalization
Two layers, kept separate so the global convention does not constrain future argument types:
Global convention — true for any argument that opts into stdin input:
-reads values from stdin.- Input is line-oriented: one value per line, unless the argument defines another format.
Conversation-ID behavior — specific to the conversation-target consumer:
- Trim each line; drop blank lines. Dropping blanks is required, not cosmetic: a well-formed pipe ends in a trailing newline (
printf 'id1\nid2\n'), so without it the stream would yield a spurious empty final value. - Parse each nonblank line as a
ConversationId, with the same error an unparseable ID produces on the command line. - Preserve order and duplicates. The resolved list is exactly the lines, so
printf 'jp-c1\njp-c1\n' | jp c fork -forks twice — identical tojp c fork jp-c1 jp-c1. Commands that want set semantics deduplicate themselves, as they already must for repeated command-line IDs.
Trimming and blank-line dropping are conversation-ID conveniences, not global guarantees: an argument with whitespace- or order-significant values would define its own stdin parsing instead.
Edge cases
- Empty input. Because blank lines are dropped, an empty or whitespace-only stdin resolves to zero values. This is an explicit error ("no conversation IDs provided on stdin"), not a silent no-op — a mistyped upstream filter fails loudly instead of quietly doing nothing.
- Terminal stdin. If
-is requested but stdin is a terminal (nothing piped), the command errors rather than blocking on keyboard EOF. This mirrors the existing picker guards in target resolution (resolve_picker,resolve_multi_picker), which already refuse picker fallback when stdin is not a terminal. - No mixing.
-must be the sole target. Combining it with literal IDs (jp c archive id1 -) or with keywords and pickers (+pinned,?) is rejected. A request either takes its IDs from the command line or from stdin, never both — which keeps resolution unambiguous and sidesteps ordering questions.
Single-consumer invariant
Stdin can be drained once, so at most one consumer may read it per invocation. jp query already reads piped stdin implicitly and prepends it to the prompt (cmd/query.rs); that is a pre-existing, command-specific consumer. query takes no multi-ID argument, so there is no live collision — but the invariant is what guarantees that stays true as commands evolve. A command that grows both a - argument and content-from-stdin must fail that combination rather than race over a single stream.
Relationship to RFD 048
RFD 048 defines JP's channel model: stdin is the data channel, never the prompt channel. Prompts and interactive input always come from /dev/tty (or the detached policy in RFD 049), so echo "y" | jp query "..." is query content, not a prompt answer.
This RFD does not touch that rule. It refines what the data channel carries: stdin supplies either implicit query content (jp query, unchanged) or the explicit - argument values defined here, and at most one of them per invocation. The prompt-channel guarantee from RFD 048 is preserved intact — which is why this RFD extends RFD 048 rather than contradicting it.
Convention now, generic mechanism later
This RFD fixes the convention for the whole CLI. The mechanism starts minimal: the only consumer is conversation targeting, so the sentinel lives in ConversationTarget. A shared, command-agnostic stdin-argument facility is worth extracting only when a second, non-conversation consumer appears (YAGNI, and the midlayer mistake). Until then, the documented rule is what keeps future arguments consistent.
Drawbacks
- Two stdin models coexist.
jp queryconsumes stdin implicitly (no-); everything else is explicit (-opts in). That is a deliberate inconsistency —query's behavior predates this convention and is relied on for interactive use, so it is grandfathered rather than changed. New stdin consumers follow the explicit rule. - A convention is only as good as adherence. Nothing in the type system forces a future multi-value argument to honor
-. The rule lives in docs and review until a shared mechanism exists, so there is drift risk. -becomes reserved for stdin-capable arguments. A multi-value argument that opts into stdin input cannot also use bare-as a literal value. Single-value flags with their own established-meaning are unaffected —--log-file=-(write tracing to stderr, RFD 048) keeps working. A future stdin-capable argument that genuinely needs literal-would have to define an escape or alternate spelling.
Alternatives
Per-command stdin handling
Each command reads stdin itself. Rejected: it duplicates parsing across archive/rm/print/path and punches an I/O hole through the clean "commands receive resolved handles" boundary. One shortcut compounds into N.
A conversation-only Stdin target, without stating the general rule
The mechanism would be identical, but framing it as a conversation feature leaves the next argument that wants stdin input to reinvent what - means. Stating the rule once is the difference; the implementation is the same first step either way.
Implicit stdin for all commands (the query model, generalized)
Every command auto-reads piped stdin. Rejected: it is ambiguous (a command can't tell "piped IDs" from "piped content"), it can't coexist with the interactive picker, and it makes stdin consumption invisible at the call site. Explicit - is the safer default.
xargs / jq at the shell level
Works today without any JP change, but it is per-command glue that leaks the opaque ID format into user scripts and gives up JP-side validation and confirmation (e.g. titles in the archive prompt). The convention makes the common case glue-free.
Non-Goals
- Single-value arguments.
-is accepted only for multi-value arguments; single-value targets reject it for now. The motivating workflows use multi-value commands (archive,rm), where a one-line stdin is simply a one-element list — so single-value stdin buys little beyondjp query --id -, which is also the one command that already owns stdin for prompt content. Designing that arbitration (inline query vs. editor vs. stdin) belongs in a dedicated query-input RFD, not here. - Changing
jp query's prompt-from-stdin behavior. It keeps reading piped stdin as prompt content. This RFD governs argument values, not that pre-existing consumer. - Building a generic cross-command stdin-argument framework now. The convention is stated; the shared mechanism is extracted when a second consumer needs it.
- The producer side.
jp c grep's regex matching and--listoutput, which feed the pipeline above, emit the line-oriented ID format but are implemented as separate work, not specified by this convention.
Implementation Plan
Phase 1: - for conversation targeting
- Add
ConversationTarget::Stdin;ConversationTarget::parsemaps-to it. - In
ConversationTarget::resolve(cmd/target.rs), read stdin once for aStdintarget: split on newlines, trim, drop blanks, parse each as aConversationId(order and duplicates preserved), with the terminal-stdin guard and the empty-input error. - Extend
validate_multi(cmd/conversation_id.rs) so-is rejected when combined with any other target, and rejected on single-value targets. - Available to every conversation-target command at once, because they all funnel non-
Idtargets throughresolve— includingunarchive, which resolves internally. - Audit every prompt reachable after stdin resolution — the
archiveandrmconfirmations and the target pickers. Input is already safe:inquirereads the terminal (/dev/tty), not stdin, so draining stdin for IDs does not starve the prompt. Output needs care: prompts render viaPrinter::prompt_writer, which renders to/dev/ttywhen one is open but falls back to stdout when none is — and for these stdin-targeted commands stdout is the data channel a downstream pipe consumes. So when no prompt channel is available, the command must follow the RFD 049 detached policy (e.g. require--yes) rather than writing the prompt to stdout and proceeding. Changingprompt_writer's fallback globally is out of scope — other callers (the init wizard, lock prompts) may want it; the guard belongs on the destructive commands when they adopt RFD 049's non-interactive handling. - Independently mergeable.
Phase 2 (deferred): shared stdin-argument facility
Extract a command-agnostic helper only when a non-conversation multi-value argument needs -. Not built now.
References
- RFD 048: Four-Channel Output Model — defines the stdin data channel vs. the
/dev/ttyprompt channel that this RFD extends. - RFD 049: Non-Interactive Mode — the detached prompt policy referenced by the channel model.
- RFD 050: Scripting Ergonomics for Conversation Management — defines the one-ID-per-line / JSON-array ID output for
new/forkthat this RFD mirrors as input.