# 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: ```bash 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 ```bash 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: ```bash cd REPOS/buzz-nostr set -a && source .env && set +a cargo run -p buzz-relay ``` Verify: ```bash 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). ```bash 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_KEY` testing Export: ```bash 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 ```bash 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 ```bash # 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":""} # 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 ```bash # 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 ```bash # 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 ```bash # 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 ```bash # 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 ```bash # 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":""} 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 ```bash # 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) ```bash # 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 ```bash # 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":"","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 --approved false # workflows delete buzz workflows delete --workflow "$WF_ID" | jq . ``` ### 6.10 Feed ```bash 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 ```bash # 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. ```bash # 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 / deletion 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 & Teams (NIP-AP, kind:30175 / kind:30176) Owner-authored agent definitions and rosters. 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 # create a team; members resolve by d-tag OR persona display name buzz teams create --name "Red team" --persona Herring --persona quinby | jq . # → {event_id, accepted, message, team_id} buzz teams create --from ~/Downloads/Red\ team.team.json --replace | jq . buzz teams get red-team # 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 teams delete red-team buzz personas delete herring buzz personas get herring # exits non-zero: not found ``` Checks worth making by hand: ```bash # A member with no published persona is refused rather than seated empty buzz teams create --name "Ghost team" --persona nobody-published; echo "exit: $?" # 1 # Desktop rejects invisible characters in definition text; so must the CLI buzz personas create --display-name $'Review​er' --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 team created from --name is addressed by its slug; the miss says so buzz teams create --name "Red team" --persona herring buzz teams get "Red team"; echo "exit: $?" # 1, error names 'red-team' buzz teams get red-team # found # A team past the relay's content cap fails locally, before members resolve buzz teams create --name "Red team" --instructions "$(head -c 300000 /dev/zero | tr '\0' a)" echo "exit: $?" # 1, "too large to publish" # 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 '' # → exit 5, "persona '' 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 '' # → "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" # Same for a team snapshot, at Desktop's 25 MiB team limit jq '.team.instructions = ["be terse"]' ~/Downloads/Red\ team.team.json > /tmp/bad.team.json buzz teams create --from /tmp/bad.team.json; echo "exit: $?" # → "not a valid v1 team snapshot", exit 1 ``` Cross-check in Desktop: after `personas create`, the definition appears in the agent picker; after `teams create`, the team appears with every member seated. --- ## 7. Error Path Testing Verify the CLI produces correct JSON on stderr and correct exit codes. ```bash # 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 '\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. ```bash # 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 ```bash # 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 | | 66 | `teams create` | ☐ | Flags, `--from` snapshot, members by d-tag and by display name, unpublished member → exit 1 | | 67 | `teams list` / `get` | ☐ | Members absent vs. empty render differently | | 68 | `teams delete` | ☐ | Delete→get 404; verify in Desktop the team is gone |