[3/4] buzz personas: publish agent definitions from the CLI

Personas could only be created by clicking through Buzz Desktop, so
nothing scriptable could stand up an agent roster — including the agents
themselves. `buzz personas create|list|get|delete` writes the same
kind:30175 coordinates Desktop reads, from a flag set or straight from a
`.agent.json` export.

These are owner-authored events, so the signing key IS the owner: no
NIP-OA auth tag is involved, and running with a different key publishes
to a coordinate space the owner's Desktop never reads. Publishing a
definition does not start an agent; launching one mints key material and
stays a Desktop operation.

Two relay behaviors shape the implementation:

- A write must be stamped past the coordinate's current head. NIP-33
  breaks a `created_at` tie by lowest event id, so a same-second rewrite
  can otherwise lose to the event it was replacing.
- `soft_delete_by_coordinate` matches `created_at <= tombstone`, and its
  result only feeds a debug log. A tombstone older than its target is
  accepted, deletes nothing, and reports OK — so delete stamps from the
  head it just read and then re-reads the coordinate to confirm. The
  re-read only raises a conflict for a head strictly newer than the
  tombstone, since a lagging replica can still return the deleted head.

`--from` refuses rather than repairs a snapshot that would publish a
persona Desktop can't mint from: `respondTo=allowlist` with no pubkeys,
an unknown `respondTo`, or definition text carrying invisible characters.

Avatars follow Desktop's reader rather than a CLI-only rule. `--avatar`
takes a local image and carries it inline as a data URL when it fits the
bounds Desktop renders — 8 KiB for SVG, 256 KiB for raster, both inside
the relay's 256 KiB content cap. Anything larger is downscaled to 512px
and re-encoded, then re-checked against the bound: flat art typically
lands back inside it and never reaches media storage at all, and only
what still doesn't fit is uploaded.

Re-encoding is also what makes that upload work. Media storage refuses
images carrying metadata — EXIF, colour profiles, comments — as an
identity channel, so a photo straight off a camera failed outright.
Decoding and re-encoding drops metadata by construction rather than by
stripping known chunks, so it cannot drift from the relay's allowlist the
way a structural stripper would. EXIF orientation is baked into the
pixels first, because dropping the tag without applying it publishes the
avatar sideways. GIF passes through untouched, since re-encoding would
flatten animation, and WebP re-encodes to PNG because `image`'s WebP
encoder is lossless-only and would inflate a lossy source.

`--from` carries a snapshot's inlined avatar through that same path, so a
Desktop export round-trips with its image. The upload runs after the
`--replace` conflict check: an upload that a rejected write would strand
leaves an orphan blob behind.

Signed-off-by: Max Lampert <maxwell@squareup.com>
This commit is contained in:
Max Lampert
2026-08-17 14:27:13 -07:00
committed by GitHub
parent 10b845a144
commit 582e993daf
8 changed files with 2549 additions and 0 deletions
Generated
+1
View File
@@ -984,6 +984,7 @@ dependencies = [
"diffy",
"dirs",
"hex",
"image",
"infer",
"nostr 0.44.7",
"rand 0.10.1",
+11
View File
@@ -63,6 +63,17 @@ bytes = "1"
# MIME type detection via magic bytes — file upload validation
infer = "0.19"
# Avatar normalization — decode/downscale/re-encode drops image metadata by
# construction, which media storage requires. Same version and feature set as
# buzz-media and desktop; depending on buzz-media instead would pull rust-s3,
# axum, and mp4 into this binary.
image = { version = "0.25", default-features = false, features = [
"jpeg",
"png",
"gif",
"webp",
] }
# URL parsing — extract server domain for Blossom auth tag
url = { workspace = true }
+105
View File
@@ -482,6 +482,107 @@ buzz notes get --name dco-check # exits non-zero: not found
buzz notes rm --name does-not-exist # exits non-zero
```
### 6.13 Personas (NIP-AP, kind:30175)
Owner-authored agent definitions. These are the same coordinates
Buzz Desktop reads, so run them with the **same key as the Desktop you expect
the definitions to appear in** — a different key writes to a different
coordinate space and Desktop shows nothing.
```bash
# create (flags)
buzz personas create --display-name "Herring" --prompt "Ask the annoying question." \
--runtime claude --model claude-opus-5 | jq .
# → {event_id, accepted, message, slug} — slug is the d-tag ("herring")
# create (from a Desktop export; --replace required to overwrite)
buzz personas create --from ~/Downloads/Herring.agent.json --replace | jq .
# avatars: --avatar takes a local image, --avatar-url takes a hosted URL.
# Every raster is downscaled to 512px and re-encoded, then measured: it rides
# inline in the event when it fits (so Desktop renders it without a fetch),
# and uploads when it doesn't. Flat art usually lands back inside the bound.
buzz personas create --display-name "Herring" --prompt x --avatar ~/Pictures/herring.png | jq .
# a photo straight off a camera — carries EXIF, which media storage refuses.
# Re-encoding drops it, so this must succeed rather than fail with a 422.
buzz personas create --display-name "Herring" --prompt x --avatar ~/Pictures/IMG_1234.jpg --replace | jq .
# check the avatar is upright: a rotated result means EXIF orientation was
# dropped instead of applied.
# a small photo rides inline, which skips media storage entirely — so confirm
# the CLI, not the validator, is what dropped the EXIF:
buzz personas get herring --json | jq -r '.[0].content' \
| jq -r .avatar_url | sed 's/^data:[^,]*,//' | base64 -d | exiftool -
# → no GPS, no Make/Model, no colour profile
# list / get (the slug is positional, matching `projects` and `mem`)
buzz personas list
buzz personas get herring
buzz personas get herring --json | jq . # sig-stripped array of one
# delete (NIP-09 a-tag tombstone; the CLI re-reads to confirm the coordinate
# is gone, because the relay accepts a tombstone that deleted nothing)
buzz personas delete herring
buzz personas get herring # exits non-zero: not found
```
Checks worth making by hand:
```bash
# Desktop rejects invisible characters in definition text; so must the CLI
buzz personas create --display-name $'Reviewer' --prompt x; echo "exit: $?" # 1
# Re-creating without --replace is a write conflict, not a silent overwrite
buzz personas create --display-name "Herring" --prompt y; echo "exit: $?" # 5
# --replace keeps catalog visibility: --shared survives a replace that omits it
buzz personas create --display-name "Herring" --prompt x --shared --replace
buzz personas create --display-name "Herring" --prompt y --replace
buzz personas get herring | grep shared # → shared: true
# A non-image --avatar is refused locally rather than after a round trip
buzz personas create --display-name "Herring" --prompt x --avatar ./notes.txt; echo "exit: $?" # 1
# An --avatar-url Desktop's reader drops is refused rather than published to
# render as nothing
buzz personas create --display-name "Herring" --prompt x \
--avatar-url 'ftp://example.test/h.png'; echo "exit: $?" # 1
# Bounds Desktop enforces at mint, so the CLI must not publish past them
buzz personas create --display-name "Herring" --prompt x --parallelism 99; echo "exit: $?"
# → "99 is not in 1..=32", exit 1
# Re-importing a persona Desktop already published must not mint a second
# coordinate. Desktop publishes in-app personas under their record UUID, so
# the import adopts that id rather than the slug its name derives.
buzz personas create --from ~/Downloads/Herring.agent.json; echo "exit: $?"
# → note: 'Herring' is already published as '<uuid>'
# → exit 5, "persona '<uuid>' already exists — pass --replace to overwrite it"
buzz personas create --from ~/Downloads/Herring.agent.json --replace | jq -r .slug
# → the UUID, not "herring". `buzz personas list` still shows one Herring.
# Only an identical definition adopts. Edit the prompt and it is a different
# persona, published at its own slug rather than overwriting Desktop's.
jq '.definition.systemPrompt = "Something else."' ~/Downloads/Herring.agent.json > /tmp/other.agent.json
buzz personas create --from /tmp/other.agent.json | jq -r .slug
# → note: a different persona named 'Herring' is published as '<uuid>'
# → "herring"
# A snapshot import is fail-closed: a wrong-typed field is refused, not
# dropped, so the persona published always matches the file
jq '.definition.namePool = ["Herring", 42]' ~/Downloads/Herring.agent.json > /tmp/bad.agent.json
buzz personas create --from /tmp/bad.agent.json; echo "exit: $?"
# → "not a valid v1 agent snapshot", exit 1
# Past the 5 MiB Desktop itself refuses to import
head -c 6000000 /dev/zero | tr '\0' a > /tmp/big.agent.json
buzz personas create --from /tmp/big.agent.json; echo "exit: $?" # 1, "snapshot limit"
```
Cross-check in Desktop: after `personas create`, the definition appears in the
agent picker.
---
## 7. Error Path Testing
@@ -621,3 +722,7 @@ buzz channels delete --channel "$FORUM_ID" | jq .
| 60 | `notes ls` | ☐ | Own, --author all, --tag, --limit |
| 61 | `notes rm` | ☐ | Delete→get 404, double-delete idempotent, missing slug → NotFound |
| 62 | `users set-status` | ☐ | Text+emoji, text only, emoji-only (`--text ""`), `--clear`, `--clear` + `--text` → exit 1 |
| 63 | `personas create` | ☐ | Flags, `--from` snapshot, `--replace` conflict → exit 5, invisible-character reject → exit 1 |
| 63a | `personas create --avatar` | ☐ | Small image inlines with its metadata stripped; large flat art downscales back to inline; EXIF-bearing photo succeeds and lands upright; non-image → exit 1; `--avatar-url` conflict or non-http(s) URL → exit 1 |
| 64 | `personas list` / `get` | ☐ | `--json` is a sig-stripped array |
| 65 | `personas delete` | ☐ | Delete→get 404; warns when a published team still lists it |
+9
View File
@@ -1108,6 +1108,15 @@ impl BuzzClient {
let bytes = std::fs::read(file_path)
.map_err(|e| CliError::Other(format!("failed to read {file_path}: {e}")))?;
self.upload_bytes(bytes).await
}
/// Upload an in-memory blob to the relay's Blossom endpoint.
///
/// The MIME type is detected from the bytes themselves, so callers holding
/// decoded image data — an avatar carried inline in a snapshot, say — do
/// not have to stage a temporary file to reuse the upload path.
pub async fn upload_bytes(&self, bytes: Vec<u8>) -> Result<BlobDescriptor, CliError> {
// 2. Detect MIME from magic bytes
let mime = infer::get(&bytes)
.map(|t| t.mime_type().to_string())
+351
View File
@@ -0,0 +1,351 @@
//! Relay plumbing shared by `buzz personas` and `buzz teams`.
//!
//! Persona (kind:30175) and team (kind:30176) events are NIP-33
//! parameterized-replaceable and authored by the OWNER. The CLI identity is
//! therefore the owner itself — unlike `buzz agents draft-*`, which is an agent
//! asking an owner to act, no NIP-OA auth tag is involved. Run these commands
//! with the same key as the Buzz Desktop you expect the definitions to appear
//! in; a different key publishes to a different coordinate space.
use std::time::{SystemTime, UNIX_EPOCH};
use buzz_sdk::agent_definitions::event_d_tag;
use nostr::{EventBuilder, JsonUtil};
use crate::client::BuzzClient;
use crate::error::CliError;
/// Maximum definitions fetched per listing query.
pub const LIST_LIMIT: usize = 500;
/// Relay ceiling on event content, mirroring `MAX_EVENT_CONTENT_BYTES` in
/// `buzz-relay`'s ingest path.
pub const MAX_EVENT_CONTENT_LEN: usize = 256 * 1024;
/// Seconds since the Unix epoch, saturating at 0 on a pre-epoch clock.
pub fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// Parse a relay-response JSON array of events, discarding entries that fail to
/// deserialize so one corrupt record can't deny-of-service a whole listing.
///
/// Returns the parsed events and how many the relay actually sent. Callers that
/// test against a query limit must use the returned count, not the vector
/// length, or a dropped record hides a real truncation.
fn parse_events(json: &str) -> Result<(Vec<nostr::Event>, usize), CliError> {
let value: serde_json::Value = serde_json::from_str(json)
.map_err(|e| CliError::Other(format!("relay returned invalid JSON: {e}")))?;
let arr = value
.as_array()
.ok_or_else(|| CliError::Other("relay response is not an array".into()))?;
let parsed = arr
.iter()
.filter_map(|ev| serde_json::from_value::<nostr::Event>(ev.clone()).ok())
.collect();
Ok((parsed, arr.len()))
}
/// Fetch definitions of `kind` authored by the CLI identity, up to
/// [`LIST_LIMIT`] events.
///
/// The relay retains one event per coordinate, but a response may still carry
/// several events for one d-tag across reconnects, so this keeps the NIP-33
/// winner per d-tag: greatest `created_at`, ties broken by lowest event id.
///
/// Not paginated. An identity at the cap gets a truncated listing, which would
/// also make `teams create` report a published persona as missing, so a hit on
/// the cap warns rather than silently truncating.
pub async fn list_owned(client: &BuzzClient, kind: u32) -> Result<Vec<nostr::Event>, CliError> {
let filter = serde_json::json!({
"kinds": [kind],
"authors": [client.keys().public_key().to_hex()],
"limit": LIST_LIMIT,
});
let (events, returned) = parse_events(&client.query(&filter).await?)?;
if returned >= LIST_LIMIT {
eprintln!(
"warning: hit the {LIST_LIMIT}-event listing cap for kind {kind}; \
results may be incomplete"
);
}
let mut heads: Vec<nostr::Event> = Vec::new();
for event in events {
let Some(d_tag) = event_d_tag(&event).map(str::to_owned) else {
continue; // not addressable — cannot belong to any coordinate
};
match heads
.iter()
.position(|e| event_d_tag(e) == Some(d_tag.as_str()))
{
Some(i) if supersedes(&event, &heads[i]) => heads[i] = event,
Some(_) => {}
None => heads.push(event),
}
}
heads.sort_by(|a, b| event_d_tag(a).cmp(&event_d_tag(b)));
Ok(heads)
}
/// NIP-33 head selection: greater `created_at` wins, ties broken by lower id.
fn supersedes(candidate: &nostr::Event, incumbent: &nostr::Event) -> bool {
match candidate.created_at.cmp(&incumbent.created_at) {
std::cmp::Ordering::Greater => true,
std::cmp::Ordering::Less => false,
std::cmp::Ordering::Equal => candidate.id < incumbent.id,
}
}
/// Fetch the retained head for one `(kind, owner, d_tag)` coordinate.
///
/// A head that exists but does not deserialize is an error, not `None`:
/// callers read `None` as "coordinate is free" and would then stamp a write at
/// a bare `now`, which can lose the NIP-33 tie-break to the head they could not
/// read — or, for a delete, be silently discarded by the relay.
pub async fn fetch_head(
client: &BuzzClient,
kind: u32,
d_tag: &str,
) -> Result<Option<nostr::Event>, CliError> {
let filter = serde_json::json!({
"kinds": [kind],
"authors": [client.keys().public_key().to_hex()],
"#d": [d_tag],
"limit": 16,
});
let (events, returned) = parse_events(&client.query(&filter).await?)?;
if events.is_empty() && returned > 0 {
return Err(CliError::Other(format!(
"relay holds {returned} unreadable event(s) at kind {kind} d-tag \
'{d_tag}'; refusing to write over a head this CLI cannot parse"
)));
}
Ok(events
.into_iter()
.fold(None, |head: Option<nostr::Event>, e| match head {
Some(h) if !supersedes(&e, &h) => Some(h),
_ => Some(e),
}))
}
/// Stamp for a write at `now` over `prior_head`.
///
/// Passing `None` for a coordinate that does have a head is the silent failure
/// this exists to make testable: the write lands at a bare `now`, which a head
/// already bumped into the future outranks — and for a kind:5 tombstone the
/// relay accepts the miss and reports OK.
fn write_created_at(now: u64, prior_head: Option<&nostr::Event>) -> u64 {
buzz_core::engram::monotonic_created_at(now, prior_head.map(|e| e.created_at.as_secs()))
}
/// Sign `builder` at the NIP-AP monotonic `created_at` for this coordinate and
/// submit it.
///
/// `prior_head` is the coordinate's current head, if any. NIP-33 keeps the
/// greatest `created_at` and breaks ties by lowest event id, so a same-second
/// rewrite can otherwise lose to the event it was meant to replace; the bump
/// past the head makes the write win regardless of clock skew.
///
/// Returns the signed event and the relay's normalized write response, which
/// callers emit on stdout per the buzz-cli write contract.
pub async fn publish_definition(
client: &BuzzClient,
builder: EventBuilder,
prior_head: Option<&nostr::Event>,
) -> Result<(nostr::Event, serde_json::Value), CliError> {
let created_at = write_created_at(now_secs(), prior_head);
let event = builder
.custom_created_at(nostr::Timestamp::from(created_at))
.sign_with_keys(client.keys())
.map_err(|e| CliError::Other(format!("failed to sign event: {e}")))?;
let raw = client.submit_event(event.clone()).await?;
let normalized = super::parse_write_response(
&raw,
"relay reported the write as duplicate / dominated by a newer head",
)?;
let response = serde_json::from_str(&normalized)
.map_err(|e| CliError::Other(format!("relay response is not JSON: {e}")))?;
Ok((event, response))
}
/// Emit a write response on stdout with the entity's own id folded in.
///
/// The buzz-cli contract is that writes return `{event_id, accepted, message}`
/// and creates add the entity id, so agent callers can consume the result.
pub fn print_write_response(mut response: serde_json::Value, key: &str, value: &str) {
if let Some(obj) = response.as_object_mut() {
obj.insert(
key.to_string(),
serde_json::Value::String(value.to_string()),
);
}
println!("{response}");
}
/// Read a definition body from a file, or stdin when `path` is `-`, rejecting
/// an empty one.
///
/// An empty prompt or instruction file almost always means an upstream step
/// produced nothing, and publishing it would silently blank the definition.
pub fn read_body_file(path: &str, what: &str) -> Result<String, CliError> {
let body = crate::validate::read_file_or_stdin(path)?;
if body.trim().is_empty() {
return Err(CliError::Usage(format!(
"{what} file '{path}' is empty — refusing to publish a blank {what}"
)));
}
Ok(body)
}
/// The snapshot schema version this CLI knows how to read.
///
/// Matches Buzz Desktop's own snapshot validators, which reject anything else.
pub const SNAPSHOT_VERSION: u64 = 1;
/// Largest agent snapshot this CLI reads, mirroring `MAX_SNAPSHOT_JSON_BYTES`
/// in Buzz Desktop's persona import.
pub const MAX_AGENT_SNAPSHOT_BYTES: usize = 5 * 1024 * 1024;
/// Load and parse a Buzz Desktop snapshot export (`.agent.json` /
/// `.team.json`), checking the `format` and `version` envelope before the caller
/// reads its payload. Refuses a file past `max_bytes`.
pub fn read_snapshot(
path: &str,
expected_format: &str,
max_bytes: usize,
) -> Result<serde_json::Value, CliError> {
use std::io::Read;
let file = std::fs::File::open(path)
.map_err(|e| CliError::Usage(format!("cannot read '{path}': {e}")))?;
// Read one byte past the cap rather than trusting metadata: the length a
// fifo or a file growing mid-read reports need not be what arrives.
let mut raw = String::new();
file.take(max_bytes as u64 + 1)
.read_to_string(&mut raw)
.map_err(|e| CliError::Usage(format!("cannot read '{path}': {e}")))?;
if raw.len() > max_bytes {
return Err(CliError::Usage(format!(
"'{path}' is larger than the {max_bytes}-byte snapshot limit, \
which Buzz Desktop also refuses to import"
)));
}
let value: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Usage(format!("'{path}' is not valid JSON: {e}")))?;
let format = value.get("format").and_then(|v| v.as_str()).unwrap_or("");
if format != expected_format {
return Err(CliError::Usage(format!(
"'{path}' has format '{format}', expected '{expected_format}'"
)));
}
// Field meanings are version-scoped, so reading a future export under v1
// semantics would misinterpret it rather than fail.
match value.get("version").and_then(serde_json::Value::as_u64) {
Some(SNAPSHOT_VERSION) => {}
Some(v) => {
return Err(CliError::Usage(format!(
"'{path}' is snapshot version {v}, but this CLI reads version \
{SNAPSHOT_VERSION} upgrade buzz"
)));
}
None => {
return Err(CliError::Usage(format!(
"'{path}' has no numeric `version` field"
)));
}
}
Ok(value)
}
/// Print an event as a sig-stripped one-element JSON array.
///
/// The buzz-cli contract is that reads emit sig-stripped arrays, so a `get`
/// stays parseable by the same consumer that reads a `list`.
pub fn print_event_json(event: &nostr::Event) -> Result<(), CliError> {
let value = serde_json::from_str(&event.as_json())
.map_err(|e| CliError::Other(format!("failed to re-encode event: {e}")))?;
println!("{}", crate::client::normalize_events(&[value]));
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{Keys, Kind, Tag};
fn event_at(d_tag: &str, created_at: u64) -> nostr::Event {
EventBuilder::new(Kind::Custom(30175), "{}")
.tags(vec![Tag::parse(["d", d_tag]).unwrap()])
.custom_created_at(nostr::Timestamp::from(created_at))
.sign_with_keys(&Keys::generate())
.unwrap()
}
#[test]
fn later_created_at_supersedes() {
let old = event_at("x", 100);
let new = event_at("x", 200);
assert!(supersedes(&new, &old));
assert!(!supersedes(&old, &new));
}
/// NIP-33 breaks a `created_at` tie by lowest event id, so a rewrite at the
/// same second is not guaranteed to win — the reason writes bump past the
/// head instead of relying on the tiebreak.
#[test]
fn created_at_tie_breaks_on_lower_id() {
let (a, b) = (event_at("x", 100), event_at("x", 100));
let (lower, higher) = if a.id < b.id { (a, b) } else { (b, a) };
assert!(supersedes(&lower, &higher));
assert!(!supersedes(&higher, &lower));
}
/// A rewrite in the same second bumps the head past the wall clock, so a
/// later write — including a delete — must be stamped from the head, not
/// from `now`, or the relay keeps the event it was meant to replace.
#[test]
fn a_write_is_stamped_past_the_head_it_replaces() {
assert_eq!(write_created_at(100, None), 100);
assert_eq!(write_created_at(100, Some(&event_at("x", 100))), 101);
assert_eq!(
write_created_at(100, Some(&event_at("x", 200))),
201,
"a head ahead of the clock must still be outranked"
);
}
#[test]
fn read_body_file_rejects_blank_input() {
let dir = std::env::temp_dir().join(format!("buzz-defs-{}", now_secs()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("blank.md");
std::fs::write(&path, " \n\t\n").unwrap();
let err = read_body_file(path.to_str().unwrap(), "prompt").unwrap_err();
assert!(err.to_string().contains("refusing to publish"), "{err}");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn read_snapshot_rejects_the_wrong_envelope() {
let dir = std::env::temp_dir().join(format!("buzz-defs-snap-{}", now_secs()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("wrong.json");
std::fs::write(&path, r#"{"format":"buzz-team-snapshot","version":1}"#).unwrap();
let err = read_snapshot(
path.to_str().unwrap(),
"buzz-agent-snapshot",
MAX_AGENT_SNAPSHOT_BYTES,
)
.unwrap_err();
assert!(
err.to_string().contains("expected 'buzz-agent-snapshot'"),
"{err}"
);
std::fs::remove_dir_all(&dir).ok();
}
}
+2
View File
@@ -1,6 +1,7 @@
pub mod agents;
pub mod channel_templates;
pub mod channels;
pub mod definitions;
pub mod dms;
pub mod emoji;
pub mod feed;
@@ -11,6 +12,7 @@ pub mod moderation;
pub mod notes;
pub mod pack;
pub mod patches;
pub mod personas;
pub mod pr;
pub mod projects;
pub mod reactions;
File diff suppressed because it is too large Load Diff
+120
View File
@@ -237,6 +237,9 @@ enum Cmd {
/// Persona pack operations (local, no relay connection needed)
#[command(subcommand)]
Pack(PackCmd),
/// Publish and manage agent definitions (personas) on the relay
#[command(subcommand)]
Personas(PersonasCmd),
/// Community moderation — reports queue, bans, timeouts, audit trail
#[command(subcommand)]
Moderation(ModerationCmd),
@@ -1858,6 +1861,99 @@ pub enum PackCmd {
},
}
/// Flags for `buzz personas create`.
///
/// A `clap::Args` struct rather than inline variant fields so the CLI surface
/// and the resolver share one definition of the create inputs.
#[derive(clap::Args)]
pub struct PersonaCreateArgs {
/// Persona slug (the event d-tag). Defaults to the display name,
/// normalized to the relay's `[a-z0-9][a-z0-9_-]{0,63}` grammar
#[arg(long)]
pub slug: Option<String>,
/// Human-readable name shown in clients
#[arg(long)]
pub display_name: Option<String>,
/// System prompt text
#[arg(long, conflicts_with = "prompt_file")]
pub prompt: Option<String>,
/// Read the system prompt from a file
#[arg(long)]
pub prompt_file: Option<String>,
/// Agent harness to run under (e.g. claude, codex, buzz-agent)
#[arg(long)]
pub runtime: Option<String>,
/// Model identifier, interpreted relative to the runtime
#[arg(long)]
pub model: Option<String>,
/// Inference provider, when the runtime supports more than one
#[arg(long)]
pub provider: Option<String>,
/// Avatar image file. Carried in the event when small enough for Desktop to
/// render inline, uploaded to media storage otherwise
#[arg(long, conflicts_with = "avatar_url")]
pub avatar: Option<String>,
/// Avatar URL, used as-is. Use `--avatar` to publish a local image
#[arg(long)]
pub avatar_url: Option<String>,
/// Who instances answer by default
#[arg(long, value_enum)]
pub respond_to: Option<RespondToArg>,
/// Concurrent turn limit copied onto instances at creation (1-32)
#[arg(long, value_parser = clap::value_parser!(u32).range(1..=32))]
pub parallelism: Option<u32>,
/// Mark the persona for catalog discovery by other members
#[arg(long)]
pub shared: bool,
/// Read fields from a Buzz Desktop `.agent.json` export; individual flags
/// override the file
#[arg(long)]
pub from: Option<String>,
/// Overwrite an existing persona at this slug
#[arg(long)]
pub replace: bool,
}
/// Subcommands for `buzz personas` — kind:30175 agent definitions.
///
/// These events are owner-authored, so the signing key IS the owner: run them
/// with the same key as the Buzz Desktop you expect the personas to appear in.
/// Publishing a definition does not start an agent — launching one mints key
/// material and a NIP-OA auth tag and stays a Desktop operation.
// clap cannot derive `Args` through a `Box`, and a subcommand enum is built
// once per process — boxing to even out variant sizes would trade an allocation
// for nothing.
#[allow(clippy::large_enum_variant)]
#[derive(Subcommand)]
pub enum PersonasCmd {
/// Publish a persona definition
#[command(after_help = "Examples:\n \
buzz personas create --display-name Herring --prompt-file ./herring.md \\\n \
--runtime buzz-agent --model databricks-kimi-3\n \
buzz personas create --from ./herring.agent.json\n \
buzz personas create --from ./herring.agent.json --model claude-opus-5[1m] --runtime claude")]
Create(PersonaCreateArgs),
/// List personas published by this identity
List {
/// Emit JSON instead of a table
#[arg(long)]
json: bool,
},
/// Show one persona
Get {
/// Persona slug
slug: String,
/// Emit the relay event as a sig-stripped JSON array
#[arg(long)]
json: bool,
},
/// Delete a persona (NIP-09 coordinate tombstone)
Delete {
/// Persona slug
slug: String,
},
}
/// Community moderation commands.
///
/// The community (tenant) is selected by the relay host in `--relay` /
@@ -2058,6 +2154,7 @@ async fn run(cli: Cli) -> Result<(), CliError> {
Cmd::Media(sub) => commands::upload::dispatch_media(sub, &client).await,
Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await,
Cmd::Mem(sub) => commands::mem::dispatch(sub, &client).await,
Cmd::Personas(sub) => commands::personas::dispatch(sub, &client).await,
Cmd::Moderation(sub) => commands::moderation::dispatch(sub, &client, &cli.format).await,
Cmd::Pack(_) => unreachable!("handled above"),
}
@@ -2143,6 +2240,24 @@ mod tests {
assert!(Cli::try_parse_from(["buzz", "users", "set-status", "--clear"]).is_ok());
}
/// Buzz Desktop's mint gate is 1..=32, and the flag path must refuse the
/// same values `--from` does rather than publishing a persona that fails at
/// launch. 33 is the first value past the ceiling; 4294967296 overflows u32.
#[test]
fn persona_parallelism_is_bounded_at_the_flag() {
for value in ["0", "33", "4294967296"] {
assert!(
Cli::try_parse_from(["buzz", "personas", "create", "--parallelism", value])
.is_err(),
"--parallelism {value} must be refused"
);
}
assert!(
Cli::try_parse_from(["buzz", "personas", "create", "--parallelism", "32"]).is_ok(),
"32 is the ceiling, not past it"
);
}
#[test]
fn command_inventory_is_stable() {
let expected_groups: Vec<&str> = vec![
@@ -2160,6 +2275,7 @@ mod tests {
"notes",
"pack",
"patches",
"personas",
"pr",
"projects",
"reactions",
@@ -2253,6 +2369,10 @@ mod tests {
"update"
]
);
assert_eq!(
names(&cmd, "personas"),
vec!["create", "delete", "get", "list"]
);
assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]);
assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]);
assert_eq!(