feat: b5-stdin-pipe — batch 5 upstream parity

This commit is contained in:
YeonGyu-Kim
2026-04-07 14:51:12 +09:00
parent 5bcbc86a2b
commit 0d8fd51a6c

View File

@@ -13,7 +13,7 @@ mod render;
use std::collections::BTreeSet; use std::collections::BTreeSet;
use std::env; use std::env;
use std::fs; use std::fs;
use std::io::{self, Read, Write}; use std::io::{self, IsTerminal, Read, Write};
use std::net::TcpListener; use std::net::TcpListener;
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -112,6 +112,45 @@ Run `claw --help` for usage."
} }
} }
/// Read piped stdin content when stdin is not a terminal.
///
/// Returns `None` when stdin is attached to a terminal (interactive REPL use),
/// when reading fails, or when the piped content is empty after trimming.
/// Returns `Some(raw_content)` when a pipe delivered non-empty content.
fn read_piped_stdin() -> Option<String> {
if io::stdin().is_terminal() {
return None;
}
let mut buffer = String::new();
if io::stdin().read_to_string(&mut buffer).is_err() {
return None;
}
if buffer.trim().is_empty() {
return None;
}
Some(buffer)
}
/// Merge a piped stdin payload into a prompt argument.
///
/// When `stdin_content` is `None` or empty after trimming, the prompt is
/// returned unchanged. Otherwise the trimmed stdin content is appended to the
/// prompt separated by a blank line so the model sees the prompt first and the
/// piped context immediately after it.
fn merge_prompt_with_stdin(prompt: &str, stdin_content: Option<&str>) -> String {
let Some(raw) = stdin_content else {
return prompt.to_string();
};
let trimmed = raw.trim();
if trimmed.is_empty() {
return prompt.to_string();
}
if prompt.is_empty() {
return trimmed.to_string();
}
format!("{prompt}\n\n{trimmed}")
}
fn run() -> Result<(), Box<dyn std::error::Error>> { fn run() -> Result<(), Box<dyn std::error::Error>> {
let args: Vec<String> = env::args().skip(1).collect(); let args: Vec<String> = env::args().skip(1).collect();
match parse_args(&args)? { match parse_args(&args)? {
@@ -157,9 +196,12 @@ fn run() -> Result<(), Box<dyn std::error::Error>> {
output_format, output_format,
allowed_tools, allowed_tools,
permission_mode, permission_mode,
compact, } => {
} => LiveCli::new(model, true, allowed_tools, permission_mode)? let stdin_context = read_piped_stdin();
.run_turn_with_output(&prompt, output_format, compact)?, let effective_prompt = merge_prompt_with_stdin(&prompt, stdin_context.as_deref());
LiveCli::new(model, true, allowed_tools, permission_mode)?
.run_turn_with_output(&effective_prompt, output_format)?;
}
CliAction::Login { output_format } => run_login(output_format)?, CliAction::Login { output_format } => run_login(output_format)?,
CliAction::Logout { output_format } => run_logout(output_format)?, CliAction::Logout { output_format } => run_logout(output_format)?,
CliAction::Doctor { output_format } => run_doctor(output_format)?, CliAction::Doctor { output_format } => run_doctor(output_format)?,
@@ -7417,7 +7459,7 @@ mod tests {
format_resume_report, format_status_report, format_tool_call_start, format_tool_result, format_resume_report, format_status_report, format_tool_call_start, format_tool_result,
format_ultraplan_report, format_unknown_slash_command, format_ultraplan_report, format_unknown_slash_command,
format_unknown_slash_command_message, format_user_visible_api_error, format_unknown_slash_command_message, format_user_visible_api_error,
normalize_permission_mode, parse_args, parse_export_args, parse_git_status_branch, merge_prompt_with_stdin, normalize_permission_mode, parse_args, parse_git_status_branch,
parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy, parse_git_status_metadata_for, parse_git_workspace_summary, permission_policy,
print_help_to, push_output_block, render_config_report, render_diff_report, print_help_to, push_output_block, render_config_report, render_diff_report,
render_diff_report_for, render_memory_report, render_repl_help, render_resume_usage, render_diff_report_for, render_memory_report, render_repl_help, render_resume_usage,
@@ -7917,6 +7959,70 @@ mod tests {
); );
} }
#[test]
fn merge_prompt_with_stdin_returns_prompt_unchanged_when_no_pipe() {
// given
let prompt = "Review this";
// when
let merged = merge_prompt_with_stdin(prompt, None);
// then
assert_eq!(merged, "Review this");
}
#[test]
fn merge_prompt_with_stdin_ignores_whitespace_only_pipe() {
// given
let prompt = "Review this";
let piped = " \n\t\n ";
// when
let merged = merge_prompt_with_stdin(prompt, Some(piped));
// then
assert_eq!(merged, "Review this");
}
#[test]
fn merge_prompt_with_stdin_appends_piped_content_as_context() {
// given
let prompt = "Review this";
let piped = "fn main() { println!(\"hi\"); }\n";
// when
let merged = merge_prompt_with_stdin(prompt, Some(piped));
// then
assert_eq!(merged, "Review this\n\nfn main() { println!(\"hi\"); }");
}
#[test]
fn merge_prompt_with_stdin_trims_surrounding_whitespace_on_pipe() {
// given
let prompt = "Summarize";
let piped = "\n\n some notes \n\n";
// when
let merged = merge_prompt_with_stdin(prompt, Some(piped));
// then
assert_eq!(merged, "Summarize\n\nsome notes");
}
#[test]
fn merge_prompt_with_stdin_returns_pipe_when_prompt_is_empty() {
// given
let prompt = "";
let piped = "standalone body";
// when
let merged = merge_prompt_with_stdin(prompt, Some(piped));
// then
assert_eq!(merged, "standalone body");
}
#[test] #[test]
fn parses_bare_prompt_and_json_output_flag() { fn parses_bare_prompt_and_json_output_flag() {
let _guard = env_lock(); let _guard = env_lock();