diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 1a5e69cdcf6..c1c7969225d 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -11,7 +11,12 @@ Auth env vars: `BUZZ_RELAY_URL`, `BUZZ_PRIVATE_KEY`, `BUZZ_AUTH_TAG`. Exit codes 0 ok, 1 user error, 2 network, 3 auth, 4 other, 5 write conflict. Output is structured JSON. `--format compact` is global — it goes before the subcommand. -Run `buzz --help` or `buzz --help` for full usage. For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. +For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. +For long or multiline Unicode content, you can also write a UTF-8 file and use `buzz messages send --content-file `. +The CLI validates the file as UTF-8 and reports an error if it is invalid. + +`buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; +if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. When opening a pull request in response to channel work, always pass `--channel ` using the UUID from ``. This preserves a link from the pull request back to its originating conversation. diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 6c272187f3c..be45cf22f54 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -5278,7 +5278,8 @@ mod agent_draft_prompt_tests { fn shared_base_prompt_teaches_real_newlines_for_multiline_messages() { let prompt = include_str!("base_prompt.md"); assert!(prompt.contains("pass real newline bytes through stdin")); - assert!(prompt.contains("single-quoted shell strings preserve `\\n` literally")); + assert!(prompt.contains("--content-file ")); + assert!(prompt.contains("validates the file as UTF-8")); assert!(prompt.contains("buzz messages send ... --content -")); } diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index f80f928d316..ef87ea22fd5 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -600,7 +600,8 @@ fn match_profiles_by_name(events: &[serde_json::Value], name: &str) -> Vec<(Stri pub struct SendMessageParams { pub channel_id: String, - pub content: String, + pub content: Option, + pub content_file: Option, pub kind: Option, pub reply_to: Option, pub broadcast: bool, @@ -608,6 +609,28 @@ pub struct SendMessageParams { pub mentions: Vec, } +fn resolve_message_content( + content: Option, + content_file: Option, +) -> Result { + match (content, content_file) { + (Some(content), None) => read_or_stdin(&content), + (None, Some(path)) => { + let bytes = std::fs::read(&path) + .map_err(|e| CliError::Usage(format!("failed to read {path:?}: {e}")))?; + String::from_utf8(bytes).map_err(|e| { + CliError::Usage(format!("message file {path:?} is not valid UTF-8: {e}")) + }) + } + (Some(_), Some(_)) => Err(CliError::Usage( + "--content and --content-file cannot be used together".into(), + )), + (None, None) => Err(CliError::Usage( + "one of --content or --content-file is required".into(), + )), + } +} + pub async fn cmd_send_message( client: &BuzzClient, mut p: SendMessageParams, @@ -616,15 +639,23 @@ pub async fn cmd_send_message( // 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)?; + p.content = Some(resolve_message_content( + p.content.take(), + p.content_file.take(), + )?); + let Some(content) = p.content.as_ref() else { + return Err(CliError::Usage( + "one of --content or --content-file is required".into(), + )); + }; + validate_content_size(content)?; if let Some(ref r) = p.reply_to { validate_hex64(r)?; } let channel_uuid = parse_uuid(&p.channel_id)?; let explicit_mentions = normalize_explicit_mentions(&p.mentions)?; - let stripped = strip_code_regions(&p.content); + let stripped = strip_code_regions(content); let uri_pubkeys = extract_nostr_uris(&stripped); // Supplying any identity explicitly authorizes unresolved or ambiguous @Name text // as presentation-only, matching Desktop's separate visible-label and p-tag model. @@ -632,7 +663,7 @@ pub async fn cmd_send_message( // every intended identity whose visible label cannot be resolved uniquely. let has_explicit_mentions = !explicit_mentions.is_empty() || !uri_pubkeys.is_empty(); let (member_pubkeys, auto_resolved) = - resolve_content_mentions(client, &p.channel_id, &p.content, has_explicit_mentions).await?; + resolve_content_mentions(client, &p.channel_id, content, has_explicit_mentions).await?; let mention_pubkeys = merge_message_mentions(&explicit_mentions, &uri_pubkeys, &auto_resolved)?; let missing = missing_members(&mention_pubkeys, &member_pubkeys); @@ -665,9 +696,9 @@ pub async fn cmd_send_message( media_content.push(')'); } let final_content = if media_content.is_empty() { - p.content.clone() + content.to_string() } else { - format!("{}{media_content}", p.content) + format!("{}{media_content}", content) }; // Build thread ref if replying. `--reply-to` is the immediate parent; the @@ -940,6 +971,7 @@ pub async fn dispatch( MessagesCmd::Send { channel, content, + content_file, kind, reply_to, broadcast, @@ -951,6 +983,7 @@ pub async fn dispatch( SendMessageParams { channel_id: channel, content, + content_file, kind, reply_to, broadcast, @@ -1711,7 +1744,8 @@ mod tests { fn send_params(content: &str) -> super::SendMessageParams { super::SendMessageParams { channel_id: SEND_TEST_CHANNEL.to_string(), - content: content.to_string(), + content: Some(content.to_string()), + content_file: None, kind: None, reply_to: None, broadcast: false, @@ -1720,6 +1754,48 @@ mod tests { } } + #[test] + fn resolve_message_content_reads_utf8_file_bytes() { + let path = std::env::temp_dir().join(format!( + "buzz-message-{}-{}.txt", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let expected = "UTF-8-Test: ä ö ü Ä Ö Ü ß „Anführungszeichen“"; + std::fs::write(&path, expected.as_bytes()).unwrap(); + + let actual = + super::resolve_message_content(None, Some(path.to_string_lossy().into_owned())) + .unwrap(); + std::fs::remove_file(path).unwrap(); + + assert_eq!(actual, expected); + } + + #[test] + fn resolve_message_content_rejects_non_utf8_file() { + let path = std::env::temp_dir().join(format!( + "buzz-message-invalid-{}-{}.txt", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, [0xff, 0xfe]).unwrap(); + + let error = super::resolve_message_content(None, Some(path.to_string_lossy().into_owned())) + .unwrap_err(); + std::fs::remove_file(path).unwrap(); + + assert!( + matches!(error, crate::error::CliError::Usage(message) if message.contains("not valid UTF-8")) + ); + } + #[tokio::test] async fn cmd_send_message_attaches_emoji_tags_for_known_shortcodes() { // Content contains `:wave:` which resolves in the palette. diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index b38486417a8..0d4472185df 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -417,8 +417,15 @@ pub enum MessagesCmd { #[arg(long)] channel: String, /// Message text — supports @mentions and markdown. Use '-' to read from stdin. - #[arg(long)] - content: String, + #[arg( + long, + conflicts_with = "content_file", + required_unless_present = "content_file" + )] + content: Option, + /// Read message content from a UTF-8 file, avoiding shell pipe encoding. + #[arg(long, conflicts_with = "content", required_unless_present = "content")] + content_file: Option, /// Nostr event kind (default: channel default) #[arg(long)] kind: Option,