mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
BUZZ-357: feat(cli): token-lean agent output format for event reads (#8234)
### Why?
Agents read channel history with `buzz messages get/thread`. Every
returned event includes its 128-hex `sig`. Every agent-authored message
also carries a NIP-OA `auth` tag, which is another owner pubkey and a
128-hex signature. An agent can't use either of these, but pays for them
on every history read. Measured with Claude's tokenizer, that is about
88 tokens per human message and about 180 per agent message, so a
history read shrinks 33% for short human messages and 40% once agent
messages are in the mix (details in the comment below). The existing
`compact` format is fixed at `{id, content, created_at}` (#6884), which
drops the author, tags and reply target, so agents can't use it.
### What?
- **New `--format agent` for event reads.** Applies to `messages
get/thread/search` and `feed get`.
- Drops `sig`, and trims the NIP-OA `auth` tag to `["auth", <owner>]` so
agents can still tell an author is an agent and who owns it.
- Folds the NIP-10 `root`/`reply` tags into a `reply_to` field, using
the same resolver as relay ingest, plus `thread_root` on nested replies.
That's the value `messages send --reply-to` takes.
- Keeps every other tag exactly as `json` output shows it: channel,
mentions, edit targets, diff metadata.
- Non-event reads return the same output as `json`.
- **Default for agents.** Without `--format`, the CLI falls back to
`BUZZ_OUTPUT_FORMAT`; an empty or unknown value means `json` with a
warning, so a harness/CLI version mismatch never breaks commands.
buzz-acp sets it to `agent` for every agent runtime through
`default_agent_env` and forwards the effective value in the explicit MCP
server environment, so operator or persona env still wins, and `--format
json` still returns full signed events. buzz-agent adds the variable to
its MCP env allowlist.
- **Docs and cleanup.** The managed-agent skill doc (version bumped to
7) and `AGENTS.md` describe the new default. The two duplicate
`format_events` copies in `messages.rs` and `feed.rs` are now one
`commands/event_output` module, which also does the NIP-10 tag parsing.
The `json` and `compact` output contracts are unchanged.
### Example output
https://github.com/block/buzz/pull/8234#issuecomment-6259679347
### Testing
Started `buzz-acp run` with the deterministic task-agent fixture, then
passed its `session/new` MCP environment to Codex 0.163.0 (`codex
app-server`). With no format override the MCP child received `agent`;
operator overrides of `json` and `compact` reached the MCP child
unchanged. The probes used temporary local sessions and made no model
requests.
### Related
Same change on the other agent read surfaces: each drops `sig`, trims
`auth` to `["auth", <owner>]` and keeps thread context.
- block/buzz-app#1388: buzz-app MCP read lines (text, `claimed
owner`/`reply_to`)
- squareup/cash-server#139132: hosted agents' read tools (JSON events,
raw NIP-10 tags)
Generated with Codex
---------
Signed-off-by: Nikolaj Goodger <ngoodger@squareup.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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(())
|
||||
|
||||
209
crates/buzz-cli/src/commands/event_output.rs
Normal file
209
crates/buzz-cli/src/commands/event_output.rs
Normal file
@@ -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<Vec<&str>> = 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<Value> = serde_json::from_str(normalized).unwrap_or_default();
|
||||
let projected: Vec<Value> = 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<Value> = 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<Value> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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::Value> =
|
||||
serde_json::from_str(normalized).unwrap_or_default();
|
||||
let compact: Vec<serde_json::Value> = 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::Value> =
|
||||
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,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
let parts: Vec<Vec<String>> = 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<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn format_events(normalized: &str, format: &crate::OutputFormat) -> String {
|
||||
match format {
|
||||
crate::OutputFormat::Compact => {
|
||||
let events: Vec<serde_json::Value> =
|
||||
serde_json::from_str(normalized).unwrap_or_default();
|
||||
let compact: Vec<serde_json::Value> = 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::Value> =
|
||||
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 =
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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<std::ffi::OsString> + 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<String>,
|
||||
|
||||
/// 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();
|
||||
|
||||
Reference in New Issue
Block a user