Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion crates/buzz-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ export BUZZ_RELAY_URL="https://relay.example.com"
# Messages
buzz messages send --channel <uuid> --content "Hello"
buzz messages send --channel <uuid> --content "Reply" --reply-to <event-id> --broadcast
buzz messages send --channel <uuid> --content - < message.md # read body from stdin
buzz messages send --channel <uuid> --content - < message.md # read body from stdin (byte-exact)
# argv --content decodes JSON-style \n at Markdown paragraph/list boundaries
buzz messages get --channel <uuid> --limit 20
buzz messages thread --channel <uuid> --event <event-id>
buzz messages thread --link 'buzz://message?channel=<uuid>&id=<event-id>&thread=<root-id>'
Expand Down
4 changes: 2 additions & 2 deletions crates/buzz-cli/src/commands/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ use crate::client::{
use crate::commands::agents::fetch_archived_snapshot;
use crate::commands::channel_templates::{self, ChannelTemplateRecord, TemplateAgentRoster};
use crate::error::CliError;
use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid};
use crate::validate::{parse_uuid, read_markdown_or_stdin, validate_hex64, validate_uuid};

fn extract_channel_metadata(e: &serde_json::Value) -> serde_json::Value {
serde_json::json!({
Expand Down Expand Up @@ -1068,7 +1068,7 @@ pub async fn cmd_set_canvas(
channel_id: &str,
content: &str,
) -> Result<(), CliError> {
let content = read_or_stdin(content)?;
let content = read_markdown_or_stdin(content)?;
let channel_uuid = parse_uuid(channel_id)?;

let builder = buzz_sdk::build_set_canvas(channel_uuid, &content)
Expand Down
6 changes: 3 additions & 3 deletions crates/buzz-cli/src/commands/issues.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet};
use crate::client::BuzzClient;
use crate::commands::with_git_provenance;
use crate::error::CliError;
use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id};
use crate::validate::{read_markdown_or_stdin, sdk_err, validate_hex64, validate_repo_id};
use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta};
use nostr::Timestamp;
use serde::Deserialize;
Expand Down Expand Up @@ -239,7 +239,7 @@ pub async fn cmd_create_issue(
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
let body = read_or_stdin(content)?;
let body = read_markdown_or_stdin(content)?;

let meta = GitIssueMeta {
labels: labels.to_vec(),
Expand Down Expand Up @@ -501,7 +501,7 @@ pub async fn cmd_issue_status(
validate_hex64(issue)?;
let status = crate::commands::patches::parse_status(status)?;
let body = match content {
Some(c) => read_or_stdin(c)?,
Some(c) => read_markdown_or_stdin(c)?,
None => String::new(),
};

Expand Down
18 changes: 9 additions & 9 deletions crates/buzz-cli/src/commands/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ use uuid::Uuid;
use crate::client::{normalize_events, normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::{
infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff,
validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES,
infer_language, parse_event_id, parse_uuid, read_markdown_or_stdin, read_or_stdin,
truncate_diff, validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES,
};
use buzz_sdk::mentions::{
extract_at_mentions_with_known, extract_nostr_uris, strip_code_regions, MENTION_CAP,
Expand Down Expand Up @@ -612,11 +612,10 @@ 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)?;
// Allow '-' to read content from stdin so callers skip argv quoting.
// Argv `--content` still decodes JSON-style `\n` at Markdown
// paragraph/list boundaries (Codex/shell quoting); stdin stays raw.
p.content = read_markdown_or_stdin(&p.content)?;
validate_content_size(&p.content)?;
if let Some(ref r) = p.reply_to {
validate_hex64(r)?;
Expand Down Expand Up @@ -855,13 +854,14 @@ pub async fn cmd_edit_message(
content: &str,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
validate_content_size(content)?;
let content = read_markdown_or_stdin(content)?;
validate_content_size(&content)?;

// Resolve channel_id from the event's h-tag
let channel_uuid = resolve_channel_id(client, event_id).await?;
let target_eid = parse_event_id(event_id)?;

let builder = buzz_sdk::build_edit(channel_uuid, target_eid, content)
let builder = buzz_sdk::build_edit(channel_uuid, target_eid, &content)
.map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?;

let event = client.sign_event(builder)?;
Expand Down
2 changes: 1 addition & 1 deletion crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -371,7 +371,7 @@ buzz agents archived"
pub enum MessagesCmd {
/// Send a message to a channel
#[command(
after_help = "Examples:\n buzz messages send --channel <UUID> --content \"hello\"\n buzz messages send --channel <UUID> --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel <UUID> --content -"
after_help = "Examples:\n buzz messages send --channel <UUID> --content \"hello\"\n buzz messages send --channel <UUID> --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel <UUID> --content -\n\nArgv --content decodes JSON-style \\n at Markdown paragraph/list boundaries (so `--content 'a\\n\\nb'` publishes real line breaks). `--content -` reads stdin byte-exact."
)]
Send {
/// Channel UUID (from 'buzz channels list')
Expand Down
209 changes: 209 additions & 0 deletions crates/buzz-cli/src/validate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,152 @@ pub fn read_or_stdin(value: &str) -> Result<String, CliError> {
}
}

/// Like [`read_or_stdin`], then decode JSON-style `\n` at Markdown
/// paragraph/list boundaries when the value came from argv.
///
/// `--content -` (stdin) is returned byte-exact so callers who already
/// stream real LF, or who need a literal backslash-n in prose, are unchanged.
/// Do not use this for YAML, diffs, or other non-Markdown payloads.
pub fn read_markdown_or_stdin(value: &str) -> Result<String, CliError> {
let from_stdin = value == "-";
let content = read_or_stdin(value)?;
if from_stdin {
Ok(content)
} else {
Ok(decode_argv_multiline_escapes(&content))
}
}

/// Decode JSON-style `\n` escapes at Markdown paragraph/list boundaries.
///
/// Agents (especially Codex) JSON-escape a body then pass
/// `--content 'para1\n\n- item'`. Bash does not expand `\n` in quotes, so
/// the two-character sequence would otherwise be signed and rendered
/// literally. Isolated `\n` (Windows paths, prose, code samples) is left
/// intact. Real LF bytes are untouched.
pub fn decode_argv_multiline_escapes(content: &str) -> String {
let bytes = content.as_bytes();
if !bytes.windows(2).any(|w| w == [b'\\', b'n']) {
return content.to_string();
}
let in_code = markdown_code_mask(content);
let mut out = String::with_capacity(content.len());
let mut i = 0;
while i < bytes.len() {
if !in_code[i]
&& bytes[i] == b'\\'
&& i + 1 < bytes.len()
&& bytes[i + 1] == b'n'
&& escaped_nl_is_markdown_boundary(bytes, i)
{
out.push('\n');
i += 2;
continue;
}
let Some(ch) = content[i..].chars().next() else {
break;
};
out.push(ch);
i += ch.len_utf8();
}
out
}

fn escaped_nl_is_markdown_boundary(bytes: &[u8], i: usize) -> bool {
let rest = &bytes[i + 2..];
if rest.starts_with(br"\n")
|| rest.starts_with(b"- ")
|| rest.starts_with(b"* ")
|| rest.starts_with(b"# ")
|| rest.starts_with(b"> ")
|| rest.starts_with(b"```")
|| rest_is_ordered_list(rest)
{
return true;
}
i >= 2 && bytes[i - 2] == b'\\' && bytes[i - 1] == b'n'
}

fn rest_is_ordered_list(rest: &[u8]) -> bool {
match rest.first() {
Some(b) if b.is_ascii_digit() => {}
_ => return false,
}
let mut j = 0;
while j < rest.len() && rest[j].is_ascii_digit() {
j += 1;
}
j + 1 < rest.len() && rest[j] == b'.' && rest[j + 1] == b' '
}

/// Byte mask of Markdown fenced blocks and inline code spans. Mirrors
/// `buzz_sdk::mentions::strip_code_regions` so `\n` inside code is not
/// rewritten.
fn markdown_code_mask(content: &str) -> Vec<bool> {
let mut mask = vec![false; content.len()];
let mut i = 0;
while i < content.len() {
if content[i..].starts_with("```") && at_line_start(content, i) {
let after_fence = i + 3;
let rest = &content[after_fence..];
let line_end = rest
.find('\n')
.map_or(content.len(), |p| after_fence + p + 1);
let mut search_from = line_end;
let close_end = loop {
if search_from >= content.len() {
break content.len();
}
if let Some(pos) = content[search_from..].find("```") {
let abs_pos = search_from + pos;
if at_line_start(content, abs_pos) {
let after_close = abs_pos + 3;
break content[after_close..]
.find('\n')
.map_or(content.len(), |p| after_close + p + 1);
}
search_from = abs_pos + 3;
} else {
break content.len();
}
};
mask[i..close_end].fill(true);
i = close_end;
continue;
}
if content.as_bytes()[i] == b'`' {
let after_tick = i + 1;
if after_tick < content.len() {
if let Some(rel_end) = content[after_tick..].find('`') {
let close_pos = after_tick + rel_end;
if !content[after_tick..close_pos].contains('\n') {
mask[i..=close_pos].fill(true);
i = close_pos + 1;
continue;
}
}
}
}
let Some(ch) = content[i..].chars().next() else {
break;
};
i += ch.len_utf8();
}
mask
}

fn at_line_start(content: &str, i: usize) -> bool {
if i == 0 {
return true;
}
let before = &content[..i];
before.ends_with('\n')
|| before.chars().all(|c| c.is_ascii_whitespace())
|| before
.rsplit_once('\n')
.is_some_and(|(_, after_nl)| after_nl.chars().all(|c| c.is_ascii_whitespace()))
}

/// Read content from a file path, or stdin if the value is "-".
///
/// Unlike [`read_or_stdin`], `value` is never treated as literal content —
Expand Down Expand Up @@ -476,6 +622,69 @@ mod tests {
assert_eq!(super::read_or_stdin("").unwrap(), "");
}

// --- decode_argv_multiline_escapes ---

#[test]
fn json_escaped_markdown_boundaries_decode_to_lf() {
let content = r"First paragraph.\n\n- first item\n- second item";
let decoded = super::decode_argv_multiline_escapes(content);
assert_eq!(decoded, "First paragraph.\n\n- first item\n- second item");
assert!(decoded.as_bytes().contains(&0x0a));
assert!(!decoded.contains(r"\n"));
}

#[test]
fn multiline_markdown_keeps_real_lf_bytes() {
let content = "First paragraph.\n\n- first item\n- second item";
assert_eq!(super::decode_argv_multiline_escapes(content), content);
}

#[test]
fn intentional_backslash_n_in_code_is_preserved() {
let content = r"Use `printf 'first\n\n- literal'` in this code sample.";
let decoded = super::decode_argv_multiline_escapes(content);
assert_eq!(decoded, content);
assert!(decoded.contains(r"\n\n"));
}

#[test]
fn fenced_code_backslash_n_is_preserved() {
let content = "before\n```\nprintf 'a\\n\\nb'\n```\nafter";
assert_eq!(super::decode_argv_multiline_escapes(content), content);
}

#[test]
fn isolated_backslash_n_is_not_decoded() {
let content = r"Path C:\new\tmp and the token \n in prose.";
assert_eq!(super::decode_argv_multiline_escapes(content), content);
}

#[test]
fn ordered_list_and_heading_boundaries_decode() {
let content = r"Intro.\n\n1. first\n2. second\n\n# Title";
let decoded = super::decode_argv_multiline_escapes(content);
assert_eq!(decoded, "Intro.\n\n1. first\n2. second\n\n# Title");
}

#[test]
fn mixed_real_and_escaped_newlines_decode_only_escapes() {
let content = "Real break.\n\nThen escaped.\\n\\n- item";
let decoded = super::decode_argv_multiline_escapes(content);
assert_eq!(decoded, "Real break.\n\nThen escaped.\n\n- item");
}

#[test]
fn read_markdown_or_stdin_decodes_argv() {
let got = super::read_markdown_or_stdin(r"para\n\n- item").unwrap();
assert_eq!(got, "para\n\n- item");
}

#[test]
fn read_or_stdin_does_not_decode_argv() {
let raw = r"para\n\n- item";
assert_eq!(super::read_or_stdin(raw).unwrap(), raw);
}

// --- read_file_or_stdin ---

#[test]
Expand Down