diff --git a/AGENTS.md b/AGENTS.md index 3830f4879c..c2aba29217 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -371,8 +371,13 @@ canonical signed Nostr event fields (`id`, `pubkey`, `kind`, `content`, `{event_id, accepted, message}`; creates add the entity ID. Exit codes: 0=ok, 1=input error, 2=network/relay, 3=auth, 4=other, 5=write conflict (NIP-33 LWW). -`--format compact` is a **global** flag — it goes before the subcommand: +`--format` is a **global** flag — it goes before the subcommand: `buzz --format compact channels list`, NOT `buzz channels list --format compact`. +Without the flag it falls back to `BUZZ_OUTPUT_FORMAT` (unknown values mean +`json`); buzz-acp sets that to `agent` for agent +processes, so their event reads drop `sig`, trim the NIP-OA `auth` tag to +`["auth", ]`, and fold the NIP-10 `root`/`reply` tags into `reply_to` +(plus `thread_root` on nested replies); other tags remain. See `crates/buzz-cli/TESTING.md` for the full live-testing runbook. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index da930dda45..80d527673f 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -815,13 +815,39 @@ fn default_agent_args(command: &str) -> Option> { /// startup budget (see block/buzz#3355). Skip that unrelated global startup /// by default; an operator or persona can still opt back in by setting the /// variable explicitly. +/// +/// Every runtime: `buzz` event reads (`messages get/thread/search`, `feed get`) +/// default to the `agent` format, which drops signature material an agent +/// would otherwise pay for on every history read. pub(crate) fn default_agent_env(command: &str) -> &'static [(&'static str, &'static str)] { match normalize_agent_command_identity(command).as_str() { - "hermes" | "hermes-agent" | "hermes-acp" => &[("HERMES_ACP_SKIP_CONFIGURED_MCP", "1")], - _ => &[], + "hermes" | "hermes-agent" | "hermes-acp" => &[ + AGENT_OUTPUT_FORMAT_ENV, + ("HERMES_ACP_SKIP_CONFIGURED_MCP", "1"), + ], + _ => &[AGENT_OUTPUT_FORMAT_ENV], } } +const AGENT_OUTPUT_FORMAT_ENV: (&str, &str) = ("BUZZ_OUTPUT_FORMAT", "agent"); + +/// Resolve the CLI format for MCP servers that filter inherited environment. +/// Match `AcpClient::spawn`: parent env wins, then the last persona value, +/// then the shared runtime default. +pub(crate) fn agent_output_format(extra_env: &[(String, String)]) -> String { + let (key, default) = AGENT_OUTPUT_FORMAT_ENV; + std::env::var(key) + .ok() + .or_else(|| { + extra_env + .iter() + .rev() + .find(|(name, _)| name == key) + .map(|(_, value)| value.clone()) + }) + .unwrap_or_else(|| default.into()) +} + /// Build the `CODEX_CONFIG` environment variable that enables full outbound /// network access in Codex's macOS Seatbelt sandbox. /// @@ -1777,14 +1803,18 @@ mod tests { ] { assert_eq!( default_agent_env(command), - &[("HERMES_ACP_SKIP_CONFIGURED_MCP", "1")], + &[ + ("BUZZ_OUTPUT_FORMAT", "agent"), + ("HERMES_ACP_SKIP_CONFIGURED_MCP", "1"), + ], "unexpected env defaults for {command}" ); } for command in ["goose", "codex-acp", "claude-agent-acp", "buzz-agent", ""] { - assert!( - default_agent_env(command).is_empty(), - "non-Hermes command must have no env defaults: {command}" + assert_eq!( + default_agent_env(command), + &[("BUZZ_OUTPUT_FORMAT", "agent")], + "non-Hermes command must only get the shared env defaults: {command}" ); } } diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 0249f5be4d..e9f2eb7dbc 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -6464,6 +6464,10 @@ fn build_mcp_servers(config: &Config) -> Vec { args: vec![], env: { let mut env = vec![ + EnvVar { + name: "BUZZ_OUTPUT_FORMAT".into(), + value: config::agent_output_format(&config.persona_env_vars), + }, EnvVar { name: "BUZZ_RELAY_URL".into(), value: config.relay_url.clone(), @@ -10372,6 +10376,46 @@ mod build_mcp_servers_tests { ); } + #[test] + fn session_new_mcp_server_uses_effective_output_format() { + let _guard = ENV_LOCK.lock().unwrap(); + let inherited = std::env::var_os("BUZZ_OUTPUT_FORMAT"); + for (operator, persona, expected) in [ + (None, vec![], "agent"), + (None, vec!["json"], "json"), + (None, vec!["json", "compact"], "compact"), + (Some("compact"), vec![], "compact"), + (Some("json"), vec!["compact"], "json"), + (Some(""), vec!["json"], ""), + ] { + match operator { + Some(value) => std::env::set_var("BUZZ_OUTPUT_FORMAT", value), + None => std::env::remove_var("BUZZ_OUTPUT_FORMAT"), + } + let mut config = test_config(); + config.persona_env_vars = persona + .iter() + .map(|value| ("BUZZ_OUTPUT_FORMAT".into(), (*value).into())) + .collect(); + let servers = build_mcp_servers(&config); + match &inherited { + Some(value) => std::env::set_var("BUZZ_OUTPUT_FORMAT", value), + None => std::env::remove_var("BUZZ_OUTPUT_FORMAT"), + } + let formats: Vec<_> = servers[0] + .env + .iter() + .filter(|entry| entry.name == "BUZZ_OUTPUT_FORMAT") + .map(|entry| entry.value.as_str()) + .collect(); + assert_eq!( + formats, + vec![expected], + "operator={operator:?}, persona={persona:?}" + ); + } + } + #[test] fn session_new_mcp_server_forwards_buzz_auth_tag() { let _guard = ENV_LOCK.lock().unwrap(); diff --git a/crates/buzz-acp/tests/fixtures/task_agent.py b/crates/buzz-acp/tests/fixtures/task_agent.py index 9e02145be9..9f20a21888 100644 --- a/crates/buzz-acp/tests/fixtures/task_agent.py +++ b/crates/buzz-acp/tests/fixtures/task_agent.py @@ -9,6 +9,7 @@ mode = os.environ.get("TASK_AGENT_MODE", "normal") log = open(os.environ["TASK_AGENT_LOG"], "a", buffering=1) log.write(json.dumps({"pid": os.getpid(), "relay": os.environ.get("BUZZ_RELAY_URL"), "key": os.environ.get("BUZZ_PRIVATE_KEY"), + "outputFormat": os.environ.get("BUZZ_OUTPUT_FORMAT"), "gitKeyfile": subprocess.check_output(["git", "config", "nostr.keyfile"], text=True).strip()}) + "\n") if mode.startswith("descendant"): child = subprocess.Popen([sys.executable, "-c", "import time; time.sleep(120)"]) diff --git a/crates/buzz-acp/tests/run_task.rs b/crates/buzz-acp/tests/run_task.rs index fec3d78691..fbea25692a 100644 --- a/crates/buzz-acp/tests/run_task.rs +++ b/crates/buzz-acp/tests/run_task.rs @@ -182,6 +182,7 @@ fn file_and_stdin_run_one_fresh_equipped_session_without_service() { for proc in spawned { assert_eq!(proc["key"], KEY); assert_eq!(proc["relay"], "ws://127.0.0.1:1"); + assert_eq!(proc["outputFormat"], "agent"); assert_dead(proc["pid"].as_u64().unwrap() as u32); assert!(!std::path::Path::new(proc["gitKeyfile"].as_str().unwrap()).exists()); } @@ -196,6 +197,13 @@ fn file_and_stdin_run_one_fresh_equipped_session_without_service() { .collect(); assert_eq!(prompts.len(), 2); for (session, prompt) in sessions.iter().zip(prompts) { + let mcp_env = session["params"]["mcpServers"][0]["env"] + .as_array() + .unwrap(); + let format = mcp_env + .iter() + .find(|entry| entry["name"] == "BUZZ_OUTPUT_FORMAT"); + assert_eq!(format.map(|entry| &entry["value"]), Some(&json!("agent"))); let setup = session["params"].to_string(); assert!(setup.contains("/test/mcp")); assert!(setup.contains("task title")); diff --git a/crates/buzz-agent/src/mcp.rs b/crates/buzz-agent/src/mcp.rs index c9f1c92488..cb6cb70409 100644 --- a/crates/buzz-agent/src/mcp.rs +++ b/crates/buzz-agent/src/mcp.rs @@ -84,6 +84,8 @@ const PASSTHROUGH_ENV: &[&str] = &[ "BUZZ_PRIVATE_KEY", "BUZZ_RELAY_URL", "BUZZ_AUTH_TAG", + // Harness-chosen `buzz` CLI read format (token-lean event reads). + "BUZZ_OUTPUT_FORMAT", // Agent display name for tools. On the // Desktop path this arrives via the wire `mcpServers[].env` declaration // (which wins here anyway); the allowlist entry covers ACP clients that diff --git a/crates/buzz-cli/src/commands/channels.rs b/crates/buzz-cli/src/commands/channels.rs index 31de6035e2..1eaccf434d 100644 --- a/crates/buzz-cli/src/commands/channels.rs +++ b/crates/buzz-cli/src/commands/channels.rs @@ -109,7 +109,9 @@ pub async fn cmd_list_channels( .collect(); serde_json::to_string(&compact).unwrap_or_default() } - crate::OutputFormat::Json => serde_json::to_string(&channels).unwrap_or_default(), + crate::OutputFormat::Json | crate::OutputFormat::Agent => { + serde_json::to_string(&channels).unwrap_or_default() + } }; println!("{output}"); Ok(()) diff --git a/crates/buzz-cli/src/commands/event_output.rs b/crates/buzz-cli/src/commands/event_output.rs new file mode 100644 index 0000000000..5c915a107d --- /dev/null +++ b/crates/buzz-cli/src/commands/event_output.rs @@ -0,0 +1,209 @@ +use buzz_core::nip10::ThreadMarkers; +use serde_json::Value; + +use crate::OutputFormat; + +/// Parse NIP-10 `root`/`reply` markers from a JSON tag array via +/// [`buzz_core::nip10`], the rule shared with relay ingest and ACP. +pub(crate) fn thread_markers(tags: &Value) -> ThreadMarkers { + let parts: Vec> = tags + .as_array() + .into_iter() + .flatten() + .filter_map(|tag| { + tag.as_array() + .map(|a| a.iter().map(|v| v.as_str().unwrap_or("")).collect()) + }) + .collect(); + buzz_core::nip10::parse_thread_markers_from_parts(parts.iter().map(Vec::as_slice)) +} + +/// Render normalized event JSON (from `normalize_events`) in the requested format. +pub(crate) fn format_events(normalized: &str, format: &OutputFormat) -> String { + let project: fn(&Value) -> Value = match format { + OutputFormat::Json => return normalized.to_string(), + OutputFormat::Compact => compact_event, + OutputFormat::Agent => agent_event, + }; + let events: Vec = serde_json::from_str(normalized).unwrap_or_default(); + let projected: Vec = events.iter().map(project).collect(); + serde_json::to_string(&projected).unwrap_or_default() +} + +fn field(event: &Value, key: &str) -> Value { + event.get(key).cloned().unwrap_or_default() +} + +fn compact_event(event: &Value) -> Value { + serde_json::json!({ + "id": field(event, "id"), + "content": field(event, "content"), + "created_at": field(event, "created_at"), + }) +} + +/// Drops signature material (`sig`, and the NIP-OA `auth` conditions and +/// signature while keeping its owner pubkey) and folds the NIP-10 thread +/// markers into `reply_to`, plus `thread_root` for nested replies; every other +/// tag (channel, mentions, edit targets, diff provenance, ...) is kept verbatim. +/// +/// Other agent read surfaces trim the same fields; change them together: +/// buzz-app's MCP (`src/buzz-mcp/tools.ts`, `Tools.messages`) and kgoose's +/// hosted read tools (squareup/cash-server `BuzzExtensionClient.messageJson`). +fn agent_event(event: &Value) -> Value { + let raw_tags = event.get("tags").unwrap_or(&Value::Null); + let thread = thread_markers(raw_tags).resolve(); + let kept: Vec = raw_tags + .as_array() + .into_iter() + .flatten() + .filter_map(|tag| { + let name = tag.get(0).and_then(Value::as_str); + let marker = tag.get(3).and_then(Value::as_str); + match (name, marker) { + (Some("auth"), _) => Some(serde_json::json!([ + "auth", + tag.get(1).cloned().unwrap_or_default() + ])), + (Some("e"), Some("root" | "reply")) if thread.is_some() => None, + _ => Some(tag.clone()), + } + }) + .collect(); + let mut projected = serde_json::json!({ + "id": field(event, "id"), + "pubkey": field(event, "pubkey"), + "kind": field(event, "kind"), + "created_at": field(event, "created_at"), + "content": field(event, "content"), + "tags": kept, + }); + if let Some((root, parent)) = thread { + if root != parent { + projected["thread_root"] = Value::String(root); + } + projected["reply_to"] = Value::String(parent); + } + projected +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const CHANNEL: &str = "6f1c7c1e-1d5b-4c55-9d43-0c1f0b6a2f10"; + + fn auth_tag() -> Value { + json!(["auth", "9".repeat(64), "kind=9", "8".repeat(128)]) + } + + fn signed_reply() -> Value { + json!({ + "id": "a".repeat(64), + "pubkey": "b".repeat(64), + "kind": 9, + "content": "reply content", + "created_at": 1_787_754_972_u64, + "tags": [ + ["h", CHANNEL], + ["e", "c".repeat(64), "", "root"], + ["e", "d".repeat(64), "", "reply"], + ["p", "e".repeat(64)], + auth_tag(), + ], + "sig": "f".repeat(128), + }) + } + + fn render(events: Value, format: &OutputFormat) -> Vec { + serde_json::from_str(&format_events(&events.to_string(), format)).unwrap() + } + + #[test] + fn compact_event_format_remains_the_three_key_contract() { + assert_eq!( + render(json!([signed_reply()]), &OutputFormat::Compact)[0], + json!({ + "id": "a".repeat(64), + "content": "reply content", + "created_at": 1_787_754_972_u64, + }) + ); + } + + #[test] + fn agent_format_drops_signature_material_but_keeps_the_owner_and_thread() { + assert_eq!( + render(json!([signed_reply()]), &OutputFormat::Agent)[0], + json!({ + "id": "a".repeat(64), + "pubkey": "b".repeat(64), + "kind": 9, + "created_at": 1_787_754_972_u64, + "content": "reply content", + "thread_root": "c".repeat(64), + "reply_to": "d".repeat(64), + "tags": [["h", CHANNEL], ["p", "e".repeat(64)], ["auth", "9".repeat(64)]], + }) + ); + } + + #[test] + fn agent_format_omits_thread_root_for_a_direct_reply() { + let mut direct = signed_reply(); + direct["tags"] = json!([["h", CHANNEL], ["e", "c".repeat(64), "", "reply"]]); + let output = &render(json!([direct]), &OutputFormat::Agent)[0]; + assert_eq!(output["reply_to"], "c".repeat(64)); + assert!(output.get("thread_root").is_none()); + assert_eq!(output["tags"], json!([["h", CHANNEL]])); + } + + #[test] + fn agent_format_keeps_semantic_tags_such_as_edit_targets_and_diff_provenance() { + let edit = json!({ + "id": "a".repeat(64), + "pubkey": "b".repeat(64), + "kind": 40003, + "content": "edited", + "created_at": 1_787_754_972_u64, + "tags": [["h", CHANNEL], ["e", "c".repeat(64)], auth_tag()], + "sig": "f".repeat(128), + }); + let diff = json!({ + "id": "d".repeat(64), + "pubkey": "b".repeat(64), + "kind": 40008, + "content": "diff --git a/x b/x", + "created_at": 1_787_754_972_u64, + "tags": [["h", CHANNEL], ["repo", "https://example.com/r.git"], ["commit", "abc123"]], + "sig": "f".repeat(128), + }); + + let output = render(json!([edit, diff]), &OutputFormat::Agent); + + assert_eq!( + output[0]["tags"], + json!([ + ["h", CHANNEL], + ["e", "c".repeat(64)], + ["auth", "9".repeat(64)] + ]) + ); + assert!(output[0].get("reply_to").is_none()); + assert_eq!( + output[1]["tags"], + json!([ + ["h", CHANNEL], + ["repo", "https://example.com/r.git"], + ["commit", "abc123"] + ]) + ); + } + + #[test] + fn json_format_is_passthrough() { + let normalized = json!([signed_reply()]).to_string(); + assert_eq!(format_events(&normalized, &OutputFormat::Json), normalized); + } +} diff --git a/crates/buzz-cli/src/commands/feed.rs b/crates/buzz-cli/src/commands/feed.rs index d5e1dae4b2..64d82d5714 100644 --- a/crates/buzz-cli/src/commands/feed.rs +++ b/crates/buzz-cli/src/commands/feed.rs @@ -1,31 +1,11 @@ use std::cmp::Reverse; use crate::client::{normalize_events, BuzzClient}; +use crate::commands::event_output::format_events; use crate::error::CliError; const VALID_FEED_TYPES: &[&str] = &["mentions", "needs_action", "activity", "agent_activity"]; -fn format_events(normalized: &str, format: &crate::OutputFormat) -> String { - match format { - crate::OutputFormat::Compact => { - let events: Vec = - serde_json::from_str(normalized).unwrap_or_default(); - let compact: Vec = events - .iter() - .map(|e| { - serde_json::json!({ - "id": e.get("id").cloned().unwrap_or_default(), - "content": e.get("content").cloned().unwrap_or_default(), - "created_at": e.get("created_at").cloned().unwrap_or_default(), - }) - }) - .collect(); - serde_json::to_string(&compact).unwrap_or_default() - } - crate::OutputFormat::Json => normalized.to_string(), - } -} - /// Get activity feed — query events mentioning our pubkey (via p-tag). pub async fn cmd_get_feed( client: &BuzzClient, @@ -81,35 +61,3 @@ pub async fn dispatch( } => cmd_get_feed(client, since, limit, types.as_deref(), format).await, } } - -#[cfg(test)] -mod tests { - use super::format_events; - - #[test] - fn compact_event_format_remains_the_three_key_contract() { - let normalized = serde_json::json!([{ - "id": "a".repeat(64), - "pubkey": "b".repeat(64), - "kind": 9, - "content": "compact content", - "created_at": 1_787_754_972_u64, - "tags": [["p", "c".repeat(64)]], - "sig": "d".repeat(128), - }]) - .to_string(); - - let output: Vec = - serde_json::from_str(&format_events(&normalized, &crate::OutputFormat::Compact)) - .unwrap(); - - assert_eq!( - output[0], - serde_json::json!({ - "id": "a".repeat(64), - "content": "compact content", - "created_at": 1_787_754_972_u64, - }) - ); - } -} diff --git a/crates/buzz-cli/src/commands/messages.rs b/crates/buzz-cli/src/commands/messages.rs index f80f928d31..1ef6d9fca4 100644 --- a/crates/buzz-cli/src/commands/messages.rs +++ b/crates/buzz-cli/src/commands/messages.rs @@ -3,6 +3,7 @@ use nostr::PublicKey; use uuid::Uuid; use crate::client::{normalize_events, normalize_write_response, BuzzClient}; +use crate::commands::event_output::{format_events, thread_markers}; use crate::error::CliError; use crate::validate::{ infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff, @@ -23,20 +24,7 @@ use buzz_sdk::mentions::{ /// - A root-only or marker-less parent returns `None` (it is top-level and its /// own root). fn find_root_from_tags(tags: &serde_json::Value) -> Option { - let parts: Vec> = tags - .as_array()? - .iter() - .filter_map(|tag| { - tag.as_array().map(|a| { - a.iter() - .map(|v| v.as_str().unwrap_or("").to_string()) - .collect() - }) - }) - .collect(); - buzz_core::nip10::parse_thread_markers_from_parts(parts.iter().map(Vec::as_slice)) - .resolve() - .map(|(root, _)| root) + thread_markers(tags).resolve().map(|(root, _)| root) } fn thread_ref_from_parent_tags( @@ -332,27 +320,6 @@ fn parse_member_pubkeys(event: &serde_json::Value) -> Vec { .collect() } -fn format_events(normalized: &str, format: &crate::OutputFormat) -> String { - match format { - crate::OutputFormat::Compact => { - let events: Vec = - serde_json::from_str(normalized).unwrap_or_default(); - let compact: Vec = events - .iter() - .map(|e| { - serde_json::json!({ - "id": e.get("id").cloned().unwrap_or_default(), - "content": e.get("content").cloned().unwrap_or_default(), - "created_at": e.get("created_at").cloned().unwrap_or_default(), - }) - }) - .collect(); - serde_json::to_string(&compact).unwrap_or_default() - } - crate::OutputFormat::Json => normalized.to_string(), - } -} - pub async fn cmd_get_messages( client: &BuzzClient, channel_id: &str, @@ -1085,10 +1052,10 @@ pub async fn dispatch( mod tests { use super::{ channel_id_from_event, cmd_get_thread, cmd_send_message, event_mention_pubkeys, - find_root_from_tags, format_events, match_profiles_by_name, merge_message_mentions, - missing_members, normalize_explicit_mentions, parse_member_pubkeys, - resolve_names_to_pubkeys, resolve_thread_target, thread_ref_from_event, - thread_ref_from_parent_tags, BuzzClient, CliError, Uuid, + find_root_from_tags, match_profiles_by_name, merge_message_mentions, missing_members, + normalize_explicit_mentions, parse_member_pubkeys, resolve_names_to_pubkeys, + resolve_thread_target, thread_ref_from_event, thread_ref_from_parent_tags, BuzzClient, + CliError, Uuid, }; use buzz_sdk::mentions::{ extract_at_mentions_with_known, extract_at_names, match_names_to_profiles, MentionProfile, @@ -1106,33 +1073,6 @@ mod tests { const PK_VALID_B: &str = "c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05"; const PK_VALID_C: &str = "f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68"; - #[test] - fn compact_event_format_remains_the_three_key_contract() { - let normalized = serde_json::json!([{ - "id": ID_A, - "pubkey": PUBKEY, - "kind": 9, - "content": "compact content", - "created_at": 1_787_754_972_u64, - "tags": [["h", "channel-id"]], - "sig": "d".repeat(128), - }]) - .to_string(); - - let output: Vec = - serde_json::from_str(&format_events(&normalized, &crate::OutputFormat::Compact)) - .unwrap(); - - assert_eq!( - output[0], - serde_json::json!({ - "id": ID_A, - "content": "compact content", - "created_at": 1_787_754_972_u64, - }) - ); - } - #[tokio::test] async fn malformed_channel_is_rejected_before_thread_fetch() { let client = diff --git a/crates/buzz-cli/src/commands/mod.rs b/crates/buzz-cli/src/commands/mod.rs index fed35ec201..f7a3ebe9e0 100644 --- a/crates/buzz-cli/src/commands/mod.rs +++ b/crates/buzz-cli/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod channel_templates; pub mod channels; pub mod dms; pub mod emoji; +pub(crate) mod event_output; pub mod feed; pub mod gifs; pub mod issues; diff --git a/crates/buzz-cli/src/commands/users.rs b/crates/buzz-cli/src/commands/users.rs index bb2d45dbf1..165de6185a 100644 --- a/crates/buzz-cli/src/commands/users.rs +++ b/crates/buzz-cli/src/commands/users.rs @@ -78,7 +78,9 @@ pub async fn cmd_get_users( .collect(); serde_json::to_string(&compact).unwrap_or_default() } - crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(), + crate::OutputFormat::Json | crate::OutputFormat::Agent => { + serde_json::to_string(&profiles).unwrap_or_default() + } }; println!("{output}"); Ok(()) @@ -350,7 +352,9 @@ async fn search_by_name( .collect(); serde_json::to_string(&compact).unwrap_or_default() } - crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(), + crate::OutputFormat::Json | crate::OutputFormat::Agent => { + serde_json::to_string(&profiles).unwrap_or_default() + } }; println!("{output}"); Ok(()) diff --git a/crates/buzz-cli/src/lib.rs b/crates/buzz-cli/src/lib.rs index c2f6b7e089..00def0d9b9 100644 --- a/crates/buzz-cli/src/lib.rs +++ b/crates/buzz-cli/src/lib.rs @@ -6,7 +6,7 @@ mod help_tree; mod links; mod validate; -use clap::{CommandFactory, FromArgMatches, Parser, Subcommand}; +use clap::{CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use client::BuzzClient; use error::CliError; use nostr::Keys; @@ -93,7 +93,27 @@ where S: Into + Clone, { let matches = build_command().try_get_matches_from(args)?; - Cli::from_arg_matches(&matches) + let mut cli = Cli::from_arg_matches(&matches)?; + if matches.value_source("format") == Some(clap::parser::ValueSource::DefaultValue) { + cli.format = env_output_format(std::env::var_os("BUZZ_OUTPUT_FORMAT").as_deref()); + } + Ok(cli) +} + +/// `BUZZ_OUTPUT_FORMAT` only picks the default `--format`, so an empty or +/// unknown value (e.g. one a newer harness sets) falls back to `json` rather +/// than failing every command, writes included. +fn env_output_format(value: Option<&std::ffi::OsStr>) -> OutputFormat { + let Some(value) = value.filter(|value| !value.is_empty()) else { + return OutputFormat::default(); + }; + value + .to_str() + .and_then(|value| OutputFormat::from_str(value, false).ok()) + .unwrap_or_else(|| { + eprintln!("warning: ignoring unsupported BUZZ_OUTPUT_FORMAT={value:?}; using json"); + OutputFormat::default() + }) } #[derive(Parser)] @@ -107,6 +127,7 @@ Configuration (flags override env vars): BUZZ_RELAY_URL Relay base URL [default: http://localhost:3000] BUZZ_PRIVATE_KEY Nostr private key (hex or nsec) [required] BUZZ_AUTH_TAG NIP-OA auth tag JSON [optional] + BUZZ_OUTPUT_FORMAT Default --format; unknown values fall back to json [optional] The 'pack' subcommand runs locally and does not require a relay connection. @@ -126,7 +147,9 @@ struct Cli { #[arg(long, env = "BUZZ_AUTH_TAG", hide_env_values = true)] auth_tag: Option, - /// Output format: 'json' (default, full fields) or 'compact' (reduced fields). + /// Output format: 'json' (default, full fields), 'compact' (reduced fields), + /// or 'agent' (event reads without signature material). Defaults to + /// `BUZZ_OUTPUT_FORMAT` when that names a known format. #[arg(long, value_enum, default_value = "json")] format: OutputFormat, @@ -206,6 +229,12 @@ pub enum OutputFormat { /// Reduced fields for agent scanning #[value(name = "compact")] Compact, + /// Event reads drop signature material (`sig`, NIP-OA `auth` conditions and + /// signature) and fold NIP-10 thread markers into `reply_to`/`thread_root`; + /// other reads match `json`. + /// Default for harness-managed agents. + #[value(name = "agent")] + Agent, } #[derive(Subcommand)] @@ -2269,6 +2298,24 @@ mod tests { } /// Smoke test: CLI definition is valid and parseable. + #[test] + fn output_format_env_is_a_lenient_default_but_the_flag_stays_strict() { + use std::ffi::OsStr; + assert!(matches!(env_output_format(None), OutputFormat::Json)); + for ignored in ["", "agent-v2"] { + assert!(matches!( + env_output_format(Some(OsStr::new(ignored))), + OutputFormat::Json + )); + } + assert!(matches!( + env_output_format(Some(OsStr::new("agent"))), + OutputFormat::Agent + )); + assert!(parse_args(["buzz", "--format", "agent", "pack", "validate", "x"]).is_ok()); + assert!(parse_args(["buzz", "--format", "agent-v2", "pack", "validate", "x"]).is_err()); + } + #[test] fn cli_definition_is_valid() { Cli::command().debug_assert(); diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs index 9e6f9c8845..470e2e0376 100644 --- a/desktop/src-tauri/src/managed_agents/nest.rs +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -52,7 +52,7 @@ const NEST_AGENTS_VERSION: u32 = 7; /// Template content version for SKILL.md. /// Bump this when changing `nest_skill.md` to trigger refresh on existing installs. -const NEST_SKILL_VERSION: u32 = 6; +const NEST_SKILL_VERSION: u32 = 7; const BEGIN_MARKER: &str = ""; diff --git a/desktop/src-tauri/src/managed_agents/nest_skill.md b/desktop/src-tauri/src/managed_agents/nest_skill.md index bb40fbff1a..891383d0cb 100644 --- a/desktop/src-tauri/src/managed_agents/nest_skill.md +++ b/desktop/src-tauri/src/managed_agents/nest_skill.md @@ -51,7 +51,7 @@ Manage your repository's enforced branch and tag rules with `repos protect list| Output varies by command group — `--help` shows flags but not response shapes. -**Read commands** return JSON arrays. Event reads (`messages get/thread/search`, `feed get`) return normalized, complete signed Nostr events with `{id, pubkey, kind, content, created_at, tags, sig}`. Other reads use command-specific shapes for channels (`{channel_id, name, description, created_at}`), users (kind:0 profile JSON with `pubkey` injected), and workflows (`{workflow_id, content, created_at, pubkey}`). +**Read commands** return JSON arrays. Event reads (`messages get/thread/search`, `feed get`) return `{id, pubkey, kind, created_at, content, tags}` plus `reply_to` (the parent event ID) on replies and `thread_root` on nested replies, whose NIP-10 `root`/`reply` tags are folded into them: the harness sets `BUZZ_OUTPUT_FORMAT=agent`, which drops `sig` and trims the NIP-OA `auth` tag to `["auth", ]` (present on agent-authored messages). Pass `--format json` for complete signed Nostr events with `{id, pubkey, kind, content, created_at, tags, sig}`. Other reads use command-specific shapes for channels (`{channel_id, name, description, created_at}`), users (kind:0 profile JSON with `pubkey` injected), and workflows (`{workflow_id, content, created_at, pubkey}`). **Write commands**: all return `{event_id, accepted, message}`. Create commands add the generated entity ID: `channels create` → `channel_id`, `dms open` → `dm_id`, `workflows create` → `workflow_id`. Agent draft commands add `{request_id, action, saved: false}` because they only open an owner-reviewed Desktop draft. @@ -83,7 +83,7 @@ buzz --format compact users get # [{pubkey, display_name}] buzz --format compact feed get # [{id, content, created_at}] ``` -Write commands are unaffected. `--format json` (default) returns full fields. +Write commands are unaffected. `--format json` returns full fields; `--format agent` (the harness default) is described above. ## Communication Patterns