From c5cb34aef8c2734eef97d6ee8cb7511d774e8d19 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Tue, 19 May 2026 22:19:35 -0400 Subject: [PATCH] cli(messages): allow --content - to read body from stdin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `sprout messages send --content ` forces callers to pass message bodies as argv, which means any backtick, $var, or $(...) in the body gets evaluated by the shell before the CLI ever sees it. This routinely mangles agent- and human-authored messages — especially code snippets and system-prompt excerpts. The CLI already has read_or_stdin() in validate.rs, used by `messages send-diff --diff -` and `canvas set --content -`. Wire the same convention into `messages send`: when --content is -, read the body from stdin. - cmd_send_message: call read_or_stdin on p.content before validation, mention auto-resolve, and media append. - Update --content doc string and after_help example. - Add stdin example to README + TESTING.md; note stdin in the command-checklist row. - Add unit tests covering the non-stdin passthrough branch of read_or_stdin (verbatim literal, empty string). Verified end-to-end against a local relay: piping a body containing literal backticks and $HOME lands on the server byte-for-byte identical to the input. Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com> Co-authored-by: Dawn (sprout agent) --- crates/sprout-cli/README.md | 1 + crates/sprout-cli/TESTING.md | 7 ++++++- crates/sprout-cli/src/commands/messages.rs | 10 +++++++++- crates/sprout-cli/src/lib.rs | 4 ++-- crates/sprout-cli/src/validate.rs | 16 ++++++++++++++++ 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/crates/sprout-cli/README.md b/crates/sprout-cli/README.md index 871e6b9229..8e10accec9 100644 --- a/crates/sprout-cli/README.md +++ b/crates/sprout-cli/README.md @@ -31,6 +31,7 @@ export SPROUT_RELAY_URL="https://relay.example.com" # Messages sprout messages send --channel --content "Hello" sprout messages send --channel --content "Reply" --reply-to --broadcast +sprout messages send --channel --content - < message.md # read body from stdin sprout messages get --channel --limit 20 sprout messages thread --channel --event sprout messages search --query "architecture" diff --git a/crates/sprout-cli/TESTING.md b/crates/sprout-cli/TESTING.md index a58c2ae907..1d0ed04528 100644 --- a/crates/sprout-cli/TESTING.md +++ b/crates/sprout-cli/TESTING.md @@ -190,6 +190,11 @@ REPLY_ID=$(echo "$REPLY" | jq -r '.id // .event_id') sprout messages send --channel "$CHANNEL_ID" --content "Hey @someone" \ --mention "0000000000000000000000000000000000000000000000000000000000000001" | jq . +# messages send from stdin — safe path for content with shell metacharacters +# (backticks, $vars, code blocks) that would otherwise be expanded by the shell. +echo 'Body with `backticks` and $vars stays literal.' \ + | sprout messages send --channel "$CHANNEL_ID" --content - | jq . + # messages get sprout messages get --channel "$CHANNEL_ID" | jq . sprout messages get --channel "$CHANNEL_ID" --limit 5 | jq . @@ -473,7 +478,7 @@ sprout channels delete --channel "$FORUM_ID" | jq . | # | Command | Tested | Notes | |---|---------|:------:|-------| -| 1 | `messages send` | ☐ | Basic, reply, broadcast, mentions | +| 1 | `messages send` | ☐ | Basic, reply, broadcast, mentions, stdin | | 2 | `messages send-diff` | ☐ | Stdin, metadata, branch/PR | | 3 | `messages edit` | ☐ | | | 4 | `messages delete` | ☐ | | diff --git a/crates/sprout-cli/src/commands/messages.rs b/crates/sprout-cli/src/commands/messages.rs index 95a8a415e5..113932e731 100644 --- a/crates/sprout-cli/src/commands/messages.rs +++ b/crates/sprout-cli/src/commands/messages.rs @@ -312,7 +312,15 @@ pub struct SendMessageParams { pub files: Vec, } -pub async fn cmd_send_message(client: &SproutClient, p: SendMessageParams) -> Result<(), CliError> { +pub async fn cmd_send_message( + client: &SproutClient, + mut p: SendMessageParams, +) -> Result<(), CliError> { + // Allow '-' to read content from stdin. This keeps callers from having to + // jam shell-metacharacter-heavy text (backticks, $vars, etc.) through argv + // quoting — the source of countless self-inflicted command-substitution + // bugs for agent and human users alike. + p.content = read_or_stdin(&p.content)?; validate_content_size(&p.content)?; if let Some(ref r) = p.reply_to { validate_hex64(r)?; diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs index 6fa58bb311..effa431ecd 100644 --- a/crates/sprout-cli/src/lib.rs +++ b/crates/sprout-cli/src/lib.rs @@ -200,13 +200,13 @@ enum Cmd { pub enum MessagesCmd { /// Send a message to a channel #[command( - after_help = "Examples:\n sprout messages send --channel --content \"hello\"\n sprout messages send --channel --content \"@alice check this\"" + after_help = "Examples:\n sprout messages send --channel --content \"hello\"\n sprout messages send --channel --content \"@alice check this\"\n echo \"hello from stdin\" | sprout messages send --channel --content -" )] Send { /// Channel UUID (from 'sprout channels list') #[arg(long)] channel: String, - /// Message text — supports @mentions and markdown + /// Message text — supports @mentions and markdown. Use '-' to read from stdin. #[arg(long)] content: String, /// Nostr event kind (default: channel default) diff --git a/crates/sprout-cli/src/validate.rs b/crates/sprout-cli/src/validate.rs index ee2d9a5d56..4d5dbeb226 100644 --- a/crates/sprout-cli/src/validate.rs +++ b/crates/sprout-cli/src/validate.rs @@ -442,4 +442,20 @@ mod tests { assert!(super::validate_repo_id("foo/bar").is_err()); assert!(super::validate_repo_id("a@b").is_err()); } + + // --- read_or_stdin --- + + #[test] + fn read_or_stdin_passthrough_returns_value() { + // Anything other than "-" is returned verbatim — backticks, $vars, + // newlines must all survive untouched (no shell evaluation happens + // here; we're past argv parsing). + let raw = "literal `backticks` and $vars\nwith newline"; + assert_eq!(super::read_or_stdin(raw).unwrap(), raw); + } + + #[test] + fn read_or_stdin_passthrough_empty_string() { + assert_eq!(super::read_or_stdin("").unwrap(), ""); + } }