Support Codex CLI stdin piping for codex exec (#15917)

# Summary

Claude Code supports a useful prompt-plus-stdin workflow:

```bash
echo "complex input..." | claude -p "summarize concisely"
```

Codex previously did not support the equivalent `codex exec` form. While
`codex exec` could read the prompt from stdin, it could not combine
piped input with an explicit prompt argument.

This change adds that missing workflow:

```bash
echo "complex input..." | codex exec "summarize concisely"
```

With this change, when `codex exec` receives both a positional prompt
and piped stdin, the prompt remains the instruction and stdin is passed
along as structured `<stdin>...</stdin>` context.

Example:

```bash
curl https://jsonplaceholder.typicode.com/comments \
  | ./target/debug/codex exec --skip-git-repo-check "format the top 20 items into a markdown table" \
  > table.md
```

This PR also adds regression coverage for:
- prompt argument + piped stdin
- legacy stdin-as-prompt behavior
- `codex exec -` forced-stdin behavior
- empty-stdin error cases

---------

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Joe Liccini
2026-03-27 22:21:22 -04:00
committed by GitHub
parent 61dfe0b86c
commit 71923f43a7
5 changed files with 285 additions and 34 deletions

View File

@@ -120,6 +120,18 @@ enum InitialOperation {
},
}
enum StdinPromptBehavior {
/// Read stdin only when there is no positional prompt, which is the legacy
/// `codex exec` behavior for `codex exec` with piped input.
RequiredIfPiped,
/// Always treat stdin as the prompt, used for the explicit `codex exec -`
/// sentinel and similar forced-stdin call sites.
Forced,
/// If stdin is piped alongside a positional prompt, treat stdin as
/// additional context to append rather than as the primary prompt.
OptionalAppend,
}
struct RequestIdSequencer {
next: i64,
}
@@ -615,7 +627,7 @@ async fn run_exec_session(args: ExecRunArgs) -> anyhow::Result<()> {
)
}
(None, root_prompt, imgs) => {
let prompt_text = resolve_prompt(root_prompt);
let prompt_text = resolve_root_prompt(root_prompt);
let mut items: Vec<UserInput> = imgs
.into_iter()
.map(|path| UserInput::LocalImage { path })
@@ -1528,46 +1540,92 @@ fn decode_utf16(
String::from_utf16(&units).map_err(|_| PromptDecodeError::InvalidUtf16 { encoding })
}
fn read_prompt_from_stdin(behavior: StdinPromptBehavior) -> Option<String> {
let stdin_is_terminal = std::io::stdin().is_terminal();
match behavior {
StdinPromptBehavior::RequiredIfPiped if stdin_is_terminal => {
eprintln!(
"No prompt provided. Either specify one as an argument or pipe the prompt into stdin."
);
std::process::exit(1);
}
StdinPromptBehavior::RequiredIfPiped => {
eprintln!("Reading prompt from stdin...");
}
StdinPromptBehavior::Forced => {}
StdinPromptBehavior::OptionalAppend if stdin_is_terminal => return None,
StdinPromptBehavior::OptionalAppend => {
eprintln!("Reading additional input from stdin...");
}
}
let mut bytes = Vec::new();
if let Err(e) = std::io::stdin().read_to_end(&mut bytes) {
eprintln!("Failed to read prompt from stdin: {e}");
std::process::exit(1);
}
let buffer = match decode_prompt_bytes(&bytes) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to read prompt from stdin: {e}");
std::process::exit(1);
}
};
if buffer.trim().is_empty() {
match behavior {
StdinPromptBehavior::OptionalAppend => None,
StdinPromptBehavior::RequiredIfPiped | StdinPromptBehavior::Forced => {
eprintln!("No prompt provided via stdin.");
std::process::exit(1);
}
}
} else {
Some(buffer)
}
}
fn prompt_with_stdin_context(prompt: &str, stdin_text: &str) -> String {
let mut combined = format!("{prompt}\n\n<stdin>\n{stdin_text}");
if !stdin_text.ends_with('\n') {
combined.push('\n');
}
combined.push_str("</stdin>");
combined
}
fn resolve_prompt(prompt_arg: Option<String>) -> String {
match prompt_arg {
Some(p) if p != "-" => p,
maybe_dash => {
let force_stdin = matches!(maybe_dash.as_deref(), Some("-"));
if std::io::stdin().is_terminal() && !force_stdin {
eprintln!(
"No prompt provided. Either specify one as an argument or pipe the prompt into stdin."
);
std::process::exit(1);
}
if !force_stdin {
eprintln!("Reading prompt from stdin...");
}
let mut bytes = Vec::new();
if let Err(e) = std::io::stdin().read_to_end(&mut bytes) {
eprintln!("Failed to read prompt from stdin: {e}");
std::process::exit(1);
}
let buffer = match decode_prompt_bytes(&bytes) {
Ok(s) => s,
Err(e) => {
eprintln!("Failed to read prompt from stdin: {e}");
std::process::exit(1);
}
let behavior = if matches!(maybe_dash.as_deref(), Some("-")) {
StdinPromptBehavior::Forced
} else {
StdinPromptBehavior::RequiredIfPiped
};
if buffer.trim().is_empty() {
eprintln!("No prompt provided via stdin.");
std::process::exit(1);
}
buffer
let Some(prompt) = read_prompt_from_stdin(behavior) else {
unreachable!("required stdin prompt should produce content");
};
prompt
}
}
}
fn resolve_root_prompt(prompt_arg: Option<String>) -> String {
match prompt_arg {
Some(prompt) if prompt != "-" => {
if let Some(stdin_text) = read_prompt_from_stdin(StdinPromptBehavior::OptionalAppend) {
prompt_with_stdin_context(&prompt, &stdin_text)
} else {
prompt
}
}
maybe_dash => resolve_prompt(maybe_dash),
}
}
fn build_review_request(args: &ReviewArgs) -> anyhow::Result<ReviewRequest> {
let target = if args.uncommitted {
ReviewTarget::UncommittedChanges
@@ -1778,6 +1836,26 @@ mod tests {
assert_eq!(err, PromptDecodeError::InvalidUtf8 { valid_up_to: 0 });
}
#[test]
fn prompt_with_stdin_context_wraps_stdin_block() {
let combined = prompt_with_stdin_context("Summarize this concisely", "my output");
assert_eq!(
combined,
"Summarize this concisely\n\n<stdin>\nmy output\n</stdin>"
);
}
#[test]
fn prompt_with_stdin_context_preserves_trailing_newline() {
let combined = prompt_with_stdin_context("Summarize this concisely", "my output\n");
assert_eq!(
combined,
"Summarize this concisely\n\n<stdin>\nmy output\n</stdin>"
);
}
#[test]
fn lagged_event_warning_message_is_explicit() {
assert_eq!(