diff --git a/crates/buzz-cli/README.md b/crates/buzz-cli/README.md index a2dcdce6d2..179115c1b7 100644 --- a/crates/buzz-cli/README.md +++ b/crates/buzz-cli/README.md @@ -31,6 +31,7 @@ export BUZZ_RELAY_URL="https://relay.example.com" # Messages buzz messages send --channel --content "Hello" buzz messages send --channel --content "Reply" --reply-to --broadcast +buzz messages send --channel --content-file report.md # read body directly from a UTF-8 file buzz messages send --channel --content - < message.md # read body from stdin buzz messages get --channel --limit 20 buzz messages thread --channel --event diff --git a/crates/buzz-cli/TESTING.md b/crates/buzz-cli/TESTING.md index 77234b7faa..0891696108 100644 --- a/crates/buzz-cli/TESTING.md +++ b/crates/buzz-cli/TESTING.md @@ -212,6 +212,9 @@ buzz messages send --channel "$CHANNEL_ID" \ echo 'Body with `backticks` and $vars stays literal.' \ | buzz messages send --channel "$CHANNEL_ID" --content - | jq . +# messages send directly from a UTF-8 file — no shell redirection required +buzz messages send --channel "$CHANNEL_ID" --content-file message.md | jq . + # messages get buzz messages get --channel "$CHANNEL_ID" | jq . buzz messages get --channel "$CHANNEL_ID" --limit 5 | jq . diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index 40a9ae80b5..3af6984d95 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -561,9 +561,28 @@ fn match_profiles_by_name(events: &[serde_json::Value], name: &str) -> Vec<(Stri matches } +fn resolve_message_content( + content: Option<&str>, + content_file: Option<&str>, +) -> Result { + match (content, content_file) { + (Some(value), None) => read_or_stdin(value), + (None, Some(path)) => std::fs::read_to_string(path).map_err(|e| { + CliError::Usage(format!("failed to read message content file {path:?}: {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 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, @@ -571,23 +590,16 @@ pub struct SendMessageParams { pub mentions: Vec, } -pub async fn cmd_send_message( - client: &BuzzClient, - 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)?; +pub async fn cmd_send_message(client: &BuzzClient, p: SendMessageParams) -> Result<(), CliError> { + let content = resolve_message_content(p.content.as_deref(), p.content_file.as_deref())?; + 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. @@ -595,7 +607,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); @@ -628,9 +640,9 @@ pub async fn cmd_send_message( media_content.push(')'); } let final_content = if media_content.is_empty() { - p.content.clone() + content.clone() } else { - format!("{}{media_content}", p.content) + format!("{content}{media_content}") }; // Build thread ref if replying. `--reply-to` is the immediate parent; the @@ -875,6 +887,7 @@ pub async fn dispatch( MessagesCmd::Send { channel, content, + content_file, kind, reply_to, broadcast, @@ -886,6 +899,7 @@ pub async fn dispatch( SendMessageParams { channel_id: channel, content, + content_file, kind, reply_to, broadcast, @@ -995,7 +1009,7 @@ mod tests { use super::{ event_mention_pubkeys, find_root_from_tags, match_profiles_by_name, merge_message_mentions, missing_members, normalize_explicit_mentions, parse_member_pubkeys, - resolve_names_to_pubkeys, + resolve_message_content, resolve_names_to_pubkeys, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, @@ -1012,6 +1026,61 @@ mod tests { const PK_VALID_B: &str = "c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05"; const PK_VALID_C: &str = "f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68"; + #[test] + fn message_content_file_reads_utf8_verbatim() { + let path = std::env::temp_dir().join(format!( + "buzz-message-content-{}-{}.md", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let expected = "# Review\n\nLiteral `code` and $variables.\n"; + std::fs::write(&path, expected).unwrap(); + + let actual = resolve_message_content(None, path.to_str()).unwrap(); + + std::fs::remove_file(path).unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn message_content_file_reports_missing_file_as_usage_error() { + let error = + resolve_message_content(None, Some("missing-message-content-file.md")).unwrap_err(); + assert!(matches!(error, crate::error::CliError::Usage(_))); + assert!(error.to_string().contains("message content file")); + } + + #[test] + fn message_content_file_rejects_invalid_utf8() { + let path = std::env::temp_dir().join(format!( + "buzz-message-content-invalid-utf8-{}-{}.md", + 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 = resolve_message_content(None, path.to_str()).unwrap_err(); + + std::fs::remove_file(path).unwrap(); + assert!(matches!(error, crate::error::CliError::Usage(_))); + assert!(error + .to_string() + .to_ascii_lowercase() + .contains("valid utf-8")); + } + + #[test] + fn message_content_source_rejects_both_or_neither() { + assert!(resolve_message_content(Some("inline"), Some("message.md")).is_err()); + assert!(resolve_message_content(None, None).is_err()); + } + #[test] fn root_marker_wins_over_reply_marker() { let tags = json!([ diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index 8a8bb053b0..d351a9caf0 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -5,7 +5,7 @@ mod error; mod links; mod validate; -use clap::{Parser, Subcommand}; +use clap::{ArgGroup, Parser, Subcommand}; use client::BuzzClient; use error::CliError; use nostr::Keys; @@ -352,7 +352,11 @@ buzz agents archived" pub enum MessagesCmd { /// Send a message to a channel #[command( - after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel --content -" + after_help = "Examples:\n buzz messages send --channel --content \"hello\"\n buzz messages send --channel --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel --content -\n buzz messages send --channel --content-file report.md", + group = ArgGroup::new("message_content") + .required(true) + .multiple(false) + .args(["content", "content_file"]) )] Send { /// Channel UUID (from 'buzz channels list') @@ -360,7 +364,10 @@ pub enum MessagesCmd { channel: String, /// Message text — supports @mentions and markdown. Use '-' to read from stdin. #[arg(long)] - content: String, + content: Option, + /// Read the message text from a UTF-8 file. + #[arg(long)] + content_file: Option, /// Nostr event kind (default: channel default) #[arg(long)] kind: Option, @@ -2053,6 +2060,40 @@ mod tests { Cli::command().debug_assert(); } + #[test] + fn messages_send_accepts_exactly_one_content_source() { + assert!(Cli::try_parse_from([ + "buzz", + "messages", + "send", + "--channel", + "550e8400-e29b-41d4-a716-446655440000", + "--content-file", + "report.md", + ]) + .is_ok()); + assert!(Cli::try_parse_from([ + "buzz", + "messages", + "send", + "--channel", + "550e8400-e29b-41d4-a716-446655440000", + ]) + .is_err()); + assert!(Cli::try_parse_from([ + "buzz", + "messages", + "send", + "--channel", + "550e8400-e29b-41d4-a716-446655440000", + "--content", + "inline", + "--content-file", + "report.md", + ]) + .is_err()); + } + #[test] fn set_status_clear_rejects_text_and_emoji() { for extra in [["--text", "busy"], ["--emoji", "🎶"]] { diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index a57676f0a9..d1d85f281f 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -50,7 +50,7 @@ const NEST_AGENTS_VERSION: u32 = 4; /// Template content version for SKILL.md. /// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. -const NEST_SKILL_VERSION: u32 = 5; +const NEST_SKILL_VERSION: u32 = 6; const BEGIN_MARKER: &str = ""; diff --git a/desktop/src-tauri/src/managed_agents/nest/tests.rs b/desktop/src-tauri/src/managed_agents/nest/tests.rs index cbef171f6f..20545dcd73 100644 --- a/desktop/src-tauri/src/managed_agents/nest/tests.rs +++ b/desktop/src-tauri/src/managed_agents/nest/tests.rs @@ -39,6 +39,7 @@ fn nest_skill_contains_safe_mention_workflow() { assert!(BUZZ_CLI_SKILL_MD.contains("no follow-up verification command is needed")); assert!(BUZZ_CLI_SKILL_MD.contains("Add membership separately only when authorized")); assert!(BUZZ_CLI_SKILL_MD.contains("never changes membership automatically")); + assert!(BUZZ_CLI_SKILL_MD.contains("--content-file reports/review.md")); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index 79a5ea301d..571e1518d9 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -94,6 +94,18 @@ buzz messages send --channel \ --content "@Alice check this" --mention ``` +When the complete message already exists in a UTF-8 file, use `--content-file` +so the direct `buzz messages send` command does not need shell redirection, +command substitution, or a pipeline: + +```bash +buzz messages send --channel \ + --reply-to --content-file reports/review.md +``` + +`--content` and `--content-file` are mutually exclusive. The existing +`--content -` form remains available for stdin. + ## DM Management `dms hide --channel ` hides a DM from the agent's DM list. Restore by re-opening with `dms open --pubkey `.