diff --git a/rust/crates/rusty-claude-cli/src/main.rs b/rust/crates/rusty-claude-cli/src/main.rs index d1d9081..ae6b763 100644 --- a/rust/crates/rusty-claude-cli/src/main.rs +++ b/rust/crates/rusty-claude-cli/src/main.rs @@ -13,7 +13,7 @@ mod render; use std::collections::BTreeSet; use std::env; use std::fs; -use std::io::{self, Read, Write}; +use std::io::{self, IsTerminal, Read, Write}; use std::net::TcpListener; use std::ops::{Deref, DerefMut}; 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 { + 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> { let args: Vec = env::args().skip(1).collect(); match parse_args(&args)? { @@ -157,9 +196,12 @@ fn run() -> Result<(), Box> { output_format, allowed_tools, permission_mode, - compact, - } => LiveCli::new(model, true, allowed_tools, permission_mode)? - .run_turn_with_output(&prompt, output_format, compact)?, + } => { + let stdin_context = read_piped_stdin(); + 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::Logout { output_format } => run_logout(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_ultraplan_report, format_unknown_slash_command, 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, 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, @@ -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] fn parses_bare_prompt_and_json_output_flag() { let _guard = env_lock();