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>
26 KiB
buzz-cli Live Testing Guide
Manual testing runbook for verifying every CLI command against a local relay. An agent or developer follows this step by step, running each command and checking the output.
1. Prerequisites
Docker services running and healthy:
docker compose ps
# buzz-postgres healthy
# buzz-redis healthy
If not running: just setup from the repo root.
Tools: jq, curl, Rust toolchain.
2. Build the CLI
cargo build -p buzz-cli
Use cargo run -p buzz-cli -- or the built binary at target/debug/buzz.
3. Start the Relay
In a separate terminal:
cd REPOS/buzz-nostr
set -a && source .env && set +a
cargo run -p buzz-relay
Verify:
curl -s http://localhost:3000/_liveness
# "ok" or 200 status
The .env should have BUZZ_REQUIRE_AUTH_TOKEN=false for local dev.
4. Mint Test Credentials
Option A: buzz-admin (full scopes including admin)
This mints a token with all CLI-relevant scopes (including admin:channels)
via direct DB access. Use this for testing admin operations (archive,
delete-channel, add/remove-channel-member).
DATABASE_URL="${DATABASE_URL:?set DATABASE_URL for the local Buzz database}" \
cargo run -p buzz-admin -- mint-token \
--name "cli-test" \
--scopes "messages:read,messages:write,channels:read,channels:write,users:read,users:write,files:read,files:write,admin:channels"
This generates a keypair and prints:
- Private key (nsec) — save for
BUZZ_PRIVATE_KEYtesting
Export:
export BUZZ_RELAY_URL="http://localhost:3000"
export BUZZ_PRIVATE_KEY="nsec1..." # from the mint output
Scope reference
| Scope | Self-mintable | Needed for |
|---|---|---|
messages:read |
✅ | messages get, messages thread, messages search, feed get |
messages:write |
✅ | messages send, messages edit, messages delete, reactions, messages vote |
channels:read |
✅ | channels list, channels get, channels members |
channels:write |
✅ | channels create, channels update, channels join, channels leave, channels topic, channels purpose |
users:read |
✅ | users get, users presence |
users:write |
✅ | users set-profile, users set-presence, users set-status |
files:read |
✅ | — |
files:write |
✅ | — |
admin:channels |
❌ | channels archive, channels unarchive, channels delete, channels add-member, channels remove-member |
5. Unit Tests
cargo test -p buzz-cli
# Expected: see cargo test -p buzz-cli for current count
cargo clippy -p buzz-cli -- -D warnings
# Expected: zero warnings
6. Live Testing — Command by Command
Run each command, verify exit code 0 and check output. Most commands
return JSON (pipe through jq . to validate). Commands are ordered so
earlier ones create resources that later ones need.
6.1 Channels
# channels create (stream)
buzz channels create --name "test-stream" --type stream --visibility open \
--description "CLI test channel" | jq .
# Save the channel ID:
CHANNEL_ID=$(buzz channels create --name "test-cli" --type stream --visibility open | jq -r '.channel_id')
# Expected: {"event_id":"...","accepted":true,"message":"...","channel_id":"<uuid>"}
# channels create (forum) — needed for messages vote later
FORUM_ID=$(buzz channels create --name "test-forum" --type forum --visibility open | jq -r '.channel_id')
# channels list
buzz channels list | jq .
# Expected: [{"channel_id":"...","name":"...","description":"...","created_at":N}]
buzz channels list --visibility open | jq .
buzz channels list --member | jq .
# channels get
buzz channels get --channel "$CHANNEL_ID" | jq .
# Expected: {"channel_id":"...","name":"...","description":"...","created_at":N,"pubkey":"..."} or null
# channels update
buzz channels update --channel "$CHANNEL_ID" --name "test-cli-updated" \
--description "Updated" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels topic
buzz channels topic --channel "$CHANNEL_ID" --topic "Test topic" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels purpose
buzz channels purpose --channel "$CHANNEL_ID" --purpose "Testing" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels join (may already be a member from create)
buzz channels join --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels leave
# NOTE: Fails with 400 "cannot remove the last owner" if this identity is the
# sole owner (which it is after channels create). To test leave successfully,
# first add-member a second pubkey as owner. The relay enforces ≥1 owner.
buzz channels leave --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."} (or 400 if last owner)
# Re-join so we can send messages
buzz channels join --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels archive (requires admin:channels scope)
buzz channels archive --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
# channels unarchive
buzz channels unarchive --channel "$CHANNEL_ID" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"..."}
6.2 Canvas
# canvas set
buzz canvas set --channel "$CHANNEL_ID" --content "# Test Canvas" | jq .
# canvas set from stdin
echo "# Canvas from stdin" | buzz canvas set --channel "$CHANNEL_ID" --content - | jq .
# canvas get
buzz canvas get --channel "$CHANNEL_ID"
# Expected: raw markdown string, or: null
6.3 Messages
# messages send
MSG=$(buzz messages send --channel "$CHANNEL_ID" --content "Hello from CLI test" | jq .)
echo "$MSG"
EVENT_ID=$(echo "$MSG" | jq -r '.event_id')
# messages send with reply + broadcast
REPLY=$(buzz messages send --channel "$CHANNEL_ID" --content "Reply" \
--reply-to "$EVENT_ID" --broadcast | jq .)
echo "$REPLY"
REPLY_ID=$(echo "$REPLY" | jq -r '.event_id')
# messages send with mentions — @name in content is auto-resolved, no flag needed
buzz messages send --channel "$CHANNEL_ID" --content "Hey @someone" | jq .
# messages send with NIP-27 nostr:npub1… inline mention — auto-resolved to p-tag
buzz messages send --channel "$CHANNEL_ID" \
--content "Check with nostr:npub10elfcs4fr0l0r8af98jlmgdh9c8tcxjvz9qkw038js35mp4dma8qzvjptg on this" | jq .
# messages send from stdin — safe path for content with shell metacharacters
# (backticks, $vars, code blocks) that would otherwise be expanded by the shell.
echo 'Body with `backticks` and $vars stays literal.' \
| buzz messages send --channel "$CHANNEL_ID" --content - | jq .
# messages get
buzz messages get --channel "$CHANNEL_ID" | jq .
buzz messages get --channel "$CHANNEL_ID" --limit 5 | jq .
# messages thread
buzz messages thread --channel "$CHANNEL_ID" --event "$EVENT_ID" | jq .
# messages search
buzz messages search --query "Hello" | jq .
buzz messages search --query "CLI test" --limit 5 | jq .
# messages edit
buzz messages edit --event "$EVENT_ID" --content "Edited by CLI test" | jq .
# messages delete
buzz messages delete --event "$REPLY_ID" | jq .
6.4 Diff Messages
# messages send-diff from stdin
echo '--- a/foo.rs
+++ b/foo.rs
@@ -1,3 +1,3 @@
-fn old() {}
+fn new() {}' | buzz messages send-diff \
--channel "$CHANNEL_ID" \
--diff - \
--repo "https://github.com/example/repo" \
--commit "abcdef1234567890abcdef1234567890abcdef12" | jq .
# messages send-diff with metadata
echo "diff content" | buzz messages send-diff \
--channel "$CHANNEL_ID" \
--diff - \
--repo "https://github.com/example/repo" \
--commit "abcdef1234567890abcdef1234567890abcdef12" \
--file "src/main.rs" \
--lang "rust" \
--description "Refactored main" | jq .
# messages send-diff with branch + PR metadata
echo "diff content" | buzz messages send-diff \
--channel "$CHANNEL_ID" \
--diff - \
--repo "https://github.com/example/repo" \
--commit "abcdef1234567890abcdef1234567890abcdef12" \
--parent-commit "1234567890abcdef1234567890abcdef12345678" \
--source-branch "feature/cli" \
--target-branch "main" \
--pr 42 | jq .
6.5 Reactions
# Send a message to react to
REACT_MSG=$(buzz messages send --channel "$CHANNEL_ID" --content "React to this")
REACT_ID=$(echo "$REACT_MSG" | jq -r '.event_id')
# reactions add
buzz reactions add --event "$REACT_ID" --emoji "👍" | jq .
# reactions get
buzz reactions get --event "$REACT_ID" | jq .
# Expected: {"reactions":[{"emoji":"...","count":N,"pubkeys":["..."]}]}
# reactions remove
buzz reactions remove --event "$REACT_ID" --emoji "👍" | jq .
6.6 DMs
# dms list
buzz dms list | jq .
# Expected: [{"dm_id":"...","participants":["..."],"created_at":N}]
# dms open (needs a real pubkey — use your own or a test one)
# Get your own pubkey first:
MY_PUBKEY=$(buzz users get | jq -r '.[0].pubkey // empty')
echo "My pubkey: $MY_PUBKEY"
# dms open with a synthetic pubkey (relay will create the user)
DM_RESULT=$(buzz dms open --pubkey "0000000000000000000000000000000000000000000000000000000000000001")
echo "$DM_RESULT" | jq .
# Expected: {"event_id":"...","accepted":true,"message":"...","dm_id":"<uuid>"}
DM_ID=$(echo "$DM_RESULT" | jq -r '.dm_id')
# dms add-member (requires messages:write scope — NOT admin:channels)
buzz dms add-member --channel "$DM_ID" \
--pubkey "0000000000000000000000000000000000000000000000000000000000000002" | jq .
6.7 Users & Presence
# users get — own profile (0 pubkeys)
buzz users get | jq .
# Expected: [{...profile...}] — always returns an array, even for single results
# users get — single pubkey
buzz users get --pubkey "$MY_PUBKEY" | jq .
# users get — batch (2+ pubkeys)
buzz users get --pubkey "$MY_PUBKEY" --pubkey "$MY_PUBKEY" | jq .
# users set-profile
buzz users set-profile --name "CLI Test Agent" --about "Testing buzz-cli" | jq .
# users presence
buzz users presence --pubkeys "$MY_PUBKEY" | jq .
# users set-presence
buzz users set-presence --status online | jq .
buzz users set-presence --status away | jq .
buzz users set-presence --status offline | jq .
# Note: set-presence may fail — kind:20001 is ephemeral and rejected by the HTTP bridge
# users set-status — NIP-38 kind:30315 on the d:general coordinate
buzz users set-status --text "reviewing PRs" --emoji "🔍" | jq .
buzz users set-status --text "no emoji this time" | jq .
# users set-status — emoji-only status (intentional: text is blank, emoji is kept)
buzz users set-status --text "" --emoji "🎶" | jq .
# users set-status --clear — removes the status (empty content, d:general only)
buzz users set-status --clear | jq .
# --clear is mutually exclusive with --text/--emoji
buzz users set-status --clear --text "nope" 2>&1; echo "exit: $?"
# Expected: exit 1 — clap conflict error
6.8 Channel Members (add/remove require admin:channels)
# channels add-member
buzz channels add-member --channel "$CHANNEL_ID" \
--pubkey "0000000000000000000000000000000000000000000000000000000000000001" \
--role member | jq .
# channels members
buzz channels members --channel "$CHANNEL_ID" | jq .
# Expected: [{"pubkey":"...","role":"..."}]
# channels remove-member
buzz channels remove-member --channel "$CHANNEL_ID" \
--pubkey "0000000000000000000000000000000000000000000000000000000000000001" | jq .
6.9 Workflows
# workflows create
# NOTE: trigger uses `on:` tag (serde internally tagged enum).
# Valid triggers: message_posted, reaction_added, diff_posted, schedule, webhook
# Steps use `action:` tag: send_message, send_dm, set_channel_topic, add_reaction, etc.
WF=$(buzz workflows create --channel "$CHANNEL_ID" \
--yaml 'name: test-wf
trigger:
on: webhook
steps:
- id: step1
action: send_message
text: "Hello from workflow"' | jq .)
echo "$WF"
WF_ID=$(echo "$WF" | jq -r '.workflow_id')
# workflows list
buzz workflows list --channel "$CHANNEL_ID" | jq .
# workflows get
buzz workflows get --workflow "$WF_ID" | jq .
# Expected: {"workflow_id":"...","content":"<yaml>","created_at":N,"pubkey":"..."} or null
# workflows update (requires --channel)
buzz workflows update --channel "$CHANNEL_ID" --workflow "$WF_ID" \
--yaml 'name: test-wf-updated
trigger:
on: webhook
steps:
- id: step1
action: send_message
text: "Updated"' | jq .
# workflows trigger
# NOTE: May return 400 "workflow not found" — the relay indexes workflow
# definitions into a DB table asynchronously. If the definition event hasn't
# been indexed yet, the trigger handler won't find it.
buzz workflows trigger --workflow "$WF_ID" | jq .
# workflows runs
buzz workflows runs --workflow "$WF_ID" | jq .
# Expected: [] — relay stores runs in DB, not as Nostr events; empty is normal
# workflows approve — requires a workflow run waiting for approval
# This is hard to test ad-hoc without a workflow that has an approval gate.
# Test the validation instead:
buzz workflows approve --token "00000000-0000-0000-0000-000000000000" 2>&1 || true
# Should fail with relay error (token not found), not a validation error
# To test the deny path: buzz workflows approve --token <UUID> --approved false
# workflows delete
buzz workflows delete --workflow "$WF_ID" | jq .
6.10 Feed
buzz feed get | jq .
buzz feed get --limit 5 | jq .
# Expected: [{id,pubkey,kind,content,created_at,tags}] — sig-stripped, sorted newest-first
6.11 Forum & Voting
# Send a forum post (kind 45001) to the forum channel
FORUM_POST=$(buzz messages send --channel "$FORUM_ID" \
--content "Forum post for vote testing" --kind 45001 | jq .)
echo "$FORUM_POST"
FORUM_EVENT_ID=$(echo "$FORUM_POST" | jq -r '.event_id')
# messages vote (up)
buzz messages vote --event "$FORUM_EVENT_ID" --direction up | jq .
# messages vote (down)
buzz messages vote --event "$FORUM_EVENT_ID" --direction down | jq .
6.12 Notes (NIP-23 long-form, kind:30023)
Editable team-knowledge notes keyed by (kind:30023, you, d=slug). set is an
idempotent upsert; rm is a NIP-09 a-tag deletion. Output is plain text (refs),
not JSON — except get/ls, which emit JSON.
# set (first publish — --title required, body from stdin)
cat <<'EOF' | buzz notes set --name dco-check --title "DCO Check" \
--summary "How we verify DCO" --tag dco --tag ci --content -
Run `git log --format='%(trailers:key=Signed-off-by)'` ...
EOF
# → prints event_id / naddr / coordinate / slug / title
# set (edit — omit --title to carry it forward; published_at preserved)
echo "Updated body." | buzz notes set --name dco-check --content -
# get by name (own author resolves directly; cross-author #d query otherwise)
buzz notes get --name dco-check | jq .
buzz notes get --name dco-check --content-only
# get by naddr (exact coordinate; paste the naddr from a set/get above)
buzz notes get --naddr "$NADDR" | jq .
# ls (own by default; --author all across the team; --tag filters)
buzz notes ls | jq .
buzz notes ls --tag dco | jq .
buzz notes ls --author all --limit 10 | jq .
# rm (NIP-09 a-tag deletion; subsequent get must 404)
buzz notes rm --name dco-check
# → prints deleted <coordinate> / deletion <event-id>
buzz notes get --name dco-check # exits non-zero: not found
# rm of a slug you never published → NotFound, no kind:5 emitted
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.
# 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:
# 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
Verify the CLI produces correct JSON on stderr and correct exit codes.
# Exit 1: Invalid UUID
buzz channels get --channel "not-a-uuid" 2>&1; echo "exit: $?"
# stderr: {"error":"user_error","message":"invalid UUID: not-a-uuid"}
# exit: 1
# Exit 1: Invalid hex64
buzz messages delete --event "not-hex" 2>&1; echo "exit: $?"
# stderr: {"error":"user_error","message":"must be a 64-character hex string: not-hex"}
# exit: 1
# Exit 1: Invalid --type value (clap validates the enum — multi-line error)
buzz channels create --name x --type invalid --visibility open 2>&1; echo "exit: $?"
# stderr: {"error":"user_error","message":"error: invalid value 'invalid' for '--type <CHANNEL_TYPE>'\n [possible values: stream, forum]\n..."}
# exit: 1
# Exit 1: Invalid --direction value
buzz messages vote --event "$(printf '0%.0s' {1..64})" \
--direction sideways 2>&1; echo "exit: $?"
# exit: 1
# Exit 1: Empty body guard
buzz users set-profile 2>&1; echo "exit: $?"
# exit: 1 (at least one field required)
# Exit 3: No auth configured
env -u BUZZ_PRIVATE_KEY \
cargo run -p buzz-cli -- channels list 2>&1; echo "exit: $?"
# stderr: {"error":"auth_error","message":"auth error: BUZZ_PRIVATE_KEY is required (use --private-key or set env var)"}
# exit: 3
# Not-found returns null, not an error (exit 0)
buzz channels get --channel "00000000-0000-0000-0000-000000000000"
# stdout: null
# exit: 0
8. Auth Testing
Test authentication.
# Private key (BUZZ_PRIVATE_KEY)
BUZZ_PRIVATE_KEY="nsec1..." buzz channels list | jq .
# Should succeed
# No auth → exit 3
env -u BUZZ_PRIVATE_KEY \
cargo run -p buzz-cli -- channels list 2>&1; echo "exit: $?"
# stderr: {"error":"auth_error","message":"auth error: BUZZ_PRIVATE_KEY is required (use --private-key or set env var)"}
# exit: 3
9. Cleanup
# Delete test channels
buzz channels delete --channel "$CHANNEL_ID" | jq .
buzz channels delete --channel "$FORUM_ID" | jq .
10. Checklist
| # | Command | Tested | Notes |
|---|---|---|---|
| 1 | messages send |
☐ | Basic, reply, broadcast, mentions, stdin |
| 2 | messages send-diff |
☐ | Stdin, metadata, branch/PR |
| 3 | messages edit |
☐ | |
| 4 | messages delete |
☐ | |
| 5 | messages get |
☐ | With limit |
| 6 | messages thread |
☐ | |
| 7 | messages search |
☐ | With limit |
| 8 | messages vote |
☐ | Up and down |
| 9 | channels list |
☐ | With visibility, member |
| 10 | channels get |
☐ | |
| 11 | channels create |
☐ | Stream and forum |
| 12 | channels update |
☐ | |
| 13 | channels topic |
☐ | |
| 14 | channels purpose |
☐ | |
| 15 | channels join |
☐ | |
| 16 | channels leave |
☐ | |
| 17 | channels archive |
☐ | Needs admin:channels |
| 18 | channels unarchive |
☐ | Needs admin:channels |
| 19 | channels delete |
☐ | Needs admin:channels |
| 20 | channels members |
☐ | |
| 21 | channels add-member |
☐ | Needs admin:channels |
| 22 | channels remove-member |
☐ | Needs admin:channels |
| 23 | canvas get |
☐ | |
| 24 | canvas set |
☐ | Direct and stdin |
| 25 | reactions add |
☐ | |
| 26 | reactions remove |
☐ | |
| 27 | reactions get |
☐ | |
| 28 | dms list |
☐ | |
| 29 | dms open |
☐ | |
| 30 | dms add-member |
☐ | Needs messages:write |
| 31 | users get |
☐ | Self, single, batch |
| 32 | users set-profile |
☐ | |
| 33 | users presence |
☐ | |
| 34 | users set-presence |
☐ | online, away, offline |
| 35 | workflows list |
☐ | |
| 36 | workflows create |
☐ | |
| 37 | workflows update |
☐ | |
| 38 | workflows delete |
☐ | |
| 39 | workflows trigger |
☐ | |
| 40 | workflows runs |
☐ | |
| 41 | workflows get |
☐ | |
| 42 | workflows approve |
☐ | Validation only (needs approval gate); bare = approve, --approved false = deny |
| 43 | feed get |
☐ | |
| 44 | social publish |
☐ | |
| 45 | social set-contacts |
☐ | |
| 46 | social event |
☐ | |
| 47 | social notes |
☐ | |
| 48 | social contacts |
☐ | |
| 49 | repos create |
☐ | |
| 50 | repos get |
☐ | |
| 51 | repos list |
☐ | |
| 52 | repos protect list |
☐ | Empty/populated rules; unknown rules visible; malformed rule reported in validation_error |
| 53 | repos protect set |
☐ | Create and replace complete exact-ref rule; verify metadata is preserved |
| 54 | repos protect remove |
☐ | Remove exact ref; missing rule → NotFound |
| 55 | upload file |
☐ | |
| 56 | pack validate |
☐ | Local, no relay |
| 57 | pack inspect |
☐ | Local, no relay |
| 58 | notes set |
☐ | First publish, edit/carry, --clear-tags, ambiguity, empty-stdin guard |
| 59 | notes get |
☐ | By name, by naddr, --content-only, cross-author, ambiguous → exit 1 |
| 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 |