feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+93
View File
@@ -0,0 +1,93 @@
[package]
name = "buzz-cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Agent-first CLI for Buzz relay"
[lib]
name = "buzz_cli"
path = "src/lib.rs"
[[bin]]
name = "buzz"
path = "src/main.rs"
[dependencies]
# CLI argument parsing — derive macros + env var support (BUZZ_API_TOKEN auto-wired)
clap = { version = "4", features = ["derive", "env"] }
# HTTP client — async REST calls to the relay
reqwest = { workspace = true, features = ["json"] }
# Async runtime — tokio macros + multi-thread for reqwest
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
# Serialization — JSON body building and response passthrough
serde = { workspace = true }
serde_json = { workspace = true }
# Structured error types with exit code mapping
thiserror = { workspace = true }
# Nostr event signing — used in `buzz auth`, auto-mint, and signed event writes
nostr = { workspace = true }
# UUID parsing for validate_uuid + event building
uuid = { workspace = true }
# RFC3339 observer timestamps for owner-reviewed agent draft requests
chrono = { workspace = true }
# Typed event builders for all write operations
buzz-sdk = { workspace = true }
buzz-core = { workspace = true }
# Base64 encoding — NIP-98 event serialization for Authorization header
base64 = "0.22"
# SHA-256 — NIP-98 payload hash tag, Blossom file hash, mem patch base-hash
sha2 = "0.11"
# Unified-diff parser and strict applier — `mem patch`
diffy = "0.5"
# Hex encoding — SHA-256 hash output for Blossom uploads
hex = { workspace = true }
# Byte buffers returned by authenticated media downloads
bytes = "1"
# MIME type detection via magic bytes — file upload validation
infer = "0.19"
# URL parsing — extract server domain for Blossom auth tag
url = { workspace = true }
# Persona pack parsing, validation, and resolution
buzz-persona = { path = "../buzz-persona" }
# Platform app-data dir resolution — locates the desktop app's
# channel-templates.json store for `channels create --template`
dirs = "6"
# WebSocket client — ephemeral event publish (kind:20001 is WS-only on the relay)
buzz-ws-client = { path = "../buzz-ws-client" }
# Explicit rustls dep with ring provider — required to install the process-level
# CryptoProvider at startup. Without this the standalone `buzz` binary panics when
# a multi-package release build (buzz-acp + buzz-dev-mcp + buzz-cli in one cargo
# invocation) unifies both ring and aws-lc-rs features, leaving rustls unable to
# auto-select a provider. See crates/buzz-acp/Cargo.toml for the same dependency.
rustls = { version = "0.23", default-features = false, features = ["ring", "std"] }
# Random number generation — full jitter for exponential backoff in with_retry
rand = { workspace = true }
[dev-dependencies]
# Scratch files for channel-templates.json fixtures in tests
tempfile = "3"
# Minimal HTTP test server for retry/policy integration tests
axum = { workspace = true }
+184
View File
@@ -0,0 +1,184 @@
# Buzz CLI
Agent-first command-line interface for Buzz relay. JSON in, JSON out.
## Install
```bash
cargo install --path crates/buzz-cli
```
## Authentication
| Env Var | Mode | Use Case |
|---------|------|----------|
| `BUZZ_PRIVATE_KEY` | NIP-98 Schnorr signature | Agents with a keypair |
```bash
# Private key identity (NIP-98 signed requests)
export BUZZ_PRIVATE_KEY="nsec1..."
buzz channels list
```
## Usage
All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=user error, 2=network, 3=auth, 4=other, 5=write conflict.
```bash
# Set relay URL (defaults to http://localhost:3000)
export BUZZ_RELAY_URL="https://relay.example.com"
# Messages
buzz messages send --channel <uuid> --content "Hello"
buzz messages send --channel <uuid> --content "Reply" --reply-to <event-id> --broadcast
buzz messages send --channel <uuid> --content - < message.md # read body from stdin
buzz messages get --channel <uuid> --limit 20
buzz messages thread --channel <uuid> --event <event-id>
buzz messages search --query "architecture"
buzz messages search --author <pubkey|npub|name> --since <unix-ts>
buzz messages edit --event <event-id> --content "Updated text"
buzz messages delete --event <event-id>
# Diffs
buzz messages send-diff --channel <uuid> --diff - --repo https://github.com/org/repo --commit abc123 < diff.patch
# Channels
buzz channels list
buzz channels create --name "my-channel" --type stream --visibility open
buzz channels join --channel <uuid>
buzz channels topic --channel <uuid> --topic "New topic"
# Reactions
buzz reactions add --event <event-id> --emoji "👍"
buzz reactions get --event <event-id>
# Users & Presence
buzz users get # your own profile
buzz users get --pubkey <hex> # single user
buzz users get --pubkey <hex> --pubkey <hex> # batch (max 200)
buzz users get --name Honey --owner me # exact-name lookup in your managed agents
buzz users set-presence --status online
buzz users set-status --text "heads down on the CLI" --emoji "🚀"
buzz users set-status --clear # remove your status
# DMs
buzz dms open --pubkey <hex>
buzz dms list
# Workflows
buzz workflows list --channel <uuid>
buzz workflows trigger --workflow <uuid>
buzz workflows approve --token <uuid>
buzz workflows approve --token <uuid> --approved false --note "needs revision"
# Forum
buzz messages vote --event <event-id> --direction up
# Canvas
buzz canvas get --channel <uuid>
buzz canvas set --channel <uuid> --content "# Welcome"
# Agent Memory (NIP-AE)
buzz mem ls
buzz mem get <slug>
buzz mem set <slug> "my-value"
buzz mem patch <slug> --base-hash <hex> < diff.patch # or --no-base-hash
buzz mem rm <slug>
# Repository protection
buzz repos protect list --id my-repo
buzz repos protect set --id my-repo --ref refs/heads/main --push admin --no-force-push --no-delete
buzz repos protect remove --id my-repo --ref refs/heads/main
# Pipe to jq
buzz channels list | jq '.[].name'
```
`protect set` replaces every existing rule for the exact ref pattern. Any
constraint omitted from the command is removed. `protect list` reports malformed
stored rules in `validation_error` so an owner can remove and repair them.
## Commands
| Group | Subcommand | Description |
|-------|-----------|-------------|
| `messages` | `send` | Send a message to a channel |
| | `send-diff` | Send a code diff with metadata |
| | `edit` | Edit a message you sent |
| | `delete` | Delete a message |
| | `get` | List messages in a channel |
| | `thread` | Get a message thread |
| | `search` | Full-text search, filterable by author |
| | `vote` | Vote on a forum post |
| `channels` | `list` | List channels |
| | `get` | Get channel details |
| | `create` | Create a channel |
| | `update` | Update channel name/description |
| | `topic` | Set channel topic |
| | `purpose` | Set channel purpose |
| | `join` | Join a channel |
| | `leave` | Leave a channel |
| | `archive` | Archive a channel |
| | `unarchive` | Unarchive a channel |
| | `delete` | Delete a channel |
| | `members` | List channel members |
| | `add-member` | Add a member |
| | `remove-member` | Remove a member |
| `canvas` | `get` | Get channel canvas |
| | `set` | Set channel canvas |
| `reactions` | `add` | React to a message |
| | `remove` | Remove a reaction |
| | `get` | List reactions |
| `dms` | `list` | List DM conversations |
| | `open` | Open a DM (18 pubkeys) |
| | `add-member` | Add member to DM group |
| `users` | `get` | Get user profile(s) |
| | `set-profile` | Update your profile |
| | `presence` | Get presence status |
| | `set-presence` | Set presence status |
| | `set-status` | Set or clear your NIP-38 profile status |
| `workflows` | `list` | List workflows |
| | `get` | Get workflow definition |
| | `create` | Create a workflow |
| | `update` | Update a workflow |
| | `delete` | Delete a workflow |
| | `trigger` | Trigger a workflow |
| | `runs` | Get workflow run history |
| | `approve` | Approve/deny a workflow step |
| `feed` | `get` | Get your activity feed |
| `social` | `publish` | Publish a NIP-01 note |
| | `set-contacts` | Set NIP-02 contact list |
| | `event` | Get a Nostr event |
| | `notes` | Get notes for a user |
| | `contacts` | Get NIP-02 contact list |
| `repos` | `create` | Announce a git repository (NIP-34) |
| | `get` | Get a repository announcement |
| | `list` | List repository announcements |
| | `protect list` | List branch and tag protection rules |
| | `protect set` | Create or replace a protection rule |
| | `protect remove` | Remove a protection rule |
| `upload` | `file` | Upload a file to the Blossom store |
| `pack` | `validate` | Validate a persona pack (local, no relay) |
| | `inspect` | Inspect a persona pack (local, no relay) |
| `mem` | `ls` | List non-tombstoned memories |
| | `get` | Print memory value to stdout |
| | `hash` | Print SHA-256 hex of memory value |
| | `set` | Write a memory value (use `-` for stdin) |
| | `patch` | Apply unified diff to memory value |
| | `rm` | Publish a tombstone to delete memory |
## Architecture
```
buzz <group> <subcommand> [flags]
├─ main.rs ──▶ commands/*.rs ──▶ client.rs ──▶ Buzz Relay REST API
│ (clap) (handlers) (reqwest)
├─ validate.rs (UUID, hex, content size, percent-encode)
└─ error.rs (CliError → JSON stderr + exit code)
stdout: raw relay JSON
stderr: {"error": "category", "message": "detail"}
exit: 0=ok 1=user 2=network 3=auth 4=other 5=write conflict
```
+623
View File
@@ -0,0 +1,623 @@
# 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":"<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
```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":"<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
```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":"<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
```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 <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
```
---
## 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 <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.
```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 |
+277
View File
@@ -0,0 +1,277 @@
//! Owner-reviewed agent draft requests published through Buzz observer frames.
use buzz_core::observer::{encrypt_observer_payload, OBSERVER_FRAME_TELEMETRY};
use nostr::{Event, Keys, PublicKey};
use serde::Serialize;
use crate::error::CliError;
const REQUEST_KIND: &str = "agent_management_request";
const MAX_NAME_CHARS: usize = 120;
const MAX_PROMPT_CHARS: usize = 20_000;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CreateAgentDraft {
pub channel_id: String,
pub display_name: String,
pub system_prompt: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct UpdateAgentDraft {
pub channel_id: String,
pub agent_name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub respond_to: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ManagementRequest<T> {
#[serde(rename = "type")]
request_type: &'static str,
action: &'static str,
request_id: String,
request: T,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct ObserverEvent<T> {
seq: u64,
timestamp: String,
kind: &'static str,
agent_index: Option<usize>,
channel_id: Option<String>,
session_id: Option<String>,
turn_id: Option<String>,
payload: ManagementRequest<T>,
}
#[derive(Debug)]
pub struct BuiltDraftRequest {
pub event: Event,
pub request_id: String,
pub action: &'static str,
}
fn required(value: String, label: &str, max: usize) -> Result<String, CliError> {
let value = value.trim();
if value.is_empty() {
return Err(CliError::Usage(format!("{label} is required")));
}
if value.chars().count() > max {
return Err(CliError::Usage(format!(
"{label} is too long (max {max} characters)"
)));
}
Ok(value.to_owned())
}
fn optional(value: Option<String>, label: &str) -> Result<Option<String>, CliError> {
value.map(|value| required(value, label, 300)).transpose()
}
fn build<T: Serialize>(
keys: &Keys,
owner: &PublicKey,
channel_id: String,
action: &'static str,
request: T,
) -> Result<BuiltDraftRequest, CliError> {
let request_id = uuid::Uuid::new_v4().to_string();
let payload = ObserverEvent {
seq: 0,
timestamp: chrono::Utc::now().to_rfc3339(),
kind: REQUEST_KIND,
agent_index: None,
channel_id: Some(channel_id),
session_id: None,
turn_id: None,
payload: ManagementRequest {
request_type: REQUEST_KIND,
action,
request_id: request_id.clone(),
request,
},
};
let encrypted = encrypt_observer_payload(keys, owner, &payload)
.map_err(|error| CliError::Other(format!("could not encrypt draft request: {error}")))?;
let event = buzz_sdk::build_agent_observer_frame(
&owner.to_hex(),
&keys.public_key().to_hex(),
OBSERVER_FRAME_TELEMETRY,
&encrypted,
)
.map_err(|error| CliError::Other(format!("could not build draft request: {error}")))?
.sign_with_keys(keys)
.map_err(|error| CliError::Other(format!("could not sign draft request: {error}")))?;
Ok(BuiltDraftRequest {
event,
request_id,
action,
})
}
pub fn build_create(
keys: &Keys,
owner: &PublicKey,
draft: CreateAgentDraft,
) -> Result<BuiltDraftRequest, CliError> {
let channel_id = required(draft.channel_id, "channel", 128)?;
uuid::Uuid::parse_str(&channel_id)
.map_err(|_| CliError::Usage(format!("invalid channel UUID: {channel_id}")))?;
let request = CreateAgentDraft {
channel_id: channel_id.clone(),
display_name: required(draft.display_name, "display name", MAX_NAME_CHARS)?,
system_prompt: required(draft.system_prompt, "system prompt", MAX_PROMPT_CHARS)?,
};
build(keys, owner, channel_id, "create", request)
}
pub fn build_update(
keys: &Keys,
owner: &PublicKey,
draft: UpdateAgentDraft,
) -> Result<BuiltDraftRequest, CliError> {
let channel_id = required(draft.channel_id, "channel", 128)?;
uuid::Uuid::parse_str(&channel_id)
.map_err(|_| CliError::Usage(format!("invalid channel UUID: {channel_id}")))?;
let respond_to = optional(draft.respond_to, "respond-to")?;
if respond_to
.as_deref()
.is_some_and(|value| value != "owner-only" && value != "anyone")
{
return Err(CliError::Usage(
"respond-to must be owner-only or anyone".into(),
));
}
let request = UpdateAgentDraft {
channel_id: channel_id.clone(),
agent_name: required(draft.agent_name, "agent name", MAX_NAME_CHARS)?,
display_name: optional(draft.display_name, "display name")?,
system_prompt: draft
.system_prompt
.map(|value| required(value, "system prompt", MAX_PROMPT_CHARS))
.transpose()?,
runtime: optional(draft.runtime, "runtime")?,
provider: optional(draft.provider, "provider")?,
model: optional(draft.model, "model")?,
respond_to,
};
if request.display_name.is_none()
&& request.system_prompt.is_none()
&& request.runtime.is_none()
&& request.provider.is_none()
&& request.model.is_none()
&& request.respond_to.is_none()
{
return Err(CliError::Usage(
"include at least one field to update".into(),
));
}
build(keys, owner, channel_id, "update", request)
}
#[cfg(test)]
mod tests {
use super::*;
use buzz_core::observer::{decrypt_observer_payload, OBSERVER_AGENT_TAG, OBSERVER_FRAME_TAG};
const CHANNEL: &str = "7c07e659-3610-42f4-9a5e-1e9973c09da9";
#[test]
fn create_is_owner_encrypted_and_matches_desktop_contract() {
let agent = Keys::generate();
let owner = Keys::generate();
let built = build_create(
&agent,
&owner.public_key(),
CreateAgentDraft {
channel_id: CHANNEL.into(),
display_name: "Research helper".into(),
system_prompt: "Find sources.".into(),
},
)
.unwrap();
assert_eq!(built.event.kind.as_u16(), 24_200);
let tags: Vec<Vec<String>> = built
.event
.tags
.iter()
.map(|tag| tag.as_slice().to_vec())
.collect();
assert!(tags
.iter()
.any(|tag| tag == &["p", &owner.public_key().to_hex()]));
assert!(tags
.iter()
.any(|tag| tag == &[OBSERVER_AGENT_TAG, &agent.public_key().to_hex()]));
assert!(tags
.iter()
.any(|tag| tag == &[OBSERVER_FRAME_TAG, OBSERVER_FRAME_TELEMETRY]));
assert!(!tags
.iter()
.any(|tag| tag.first().map(String::as_str) == Some("h")));
let payload: serde_json::Value = decrypt_observer_payload(&owner, &built.event).unwrap();
assert_eq!(payload["kind"], REQUEST_KIND);
assert_eq!(payload["channelId"], CHANNEL);
assert_eq!(payload["payload"]["type"], REQUEST_KIND);
assert_eq!(payload["payload"]["action"], "create");
assert_eq!(
payload["payload"]["request"]["displayName"],
"Research helper"
);
assert!(payload["payload"]["request"].get("runtime").is_none());
assert!(payload["payload"]["request"].get("respondTo").is_none());
}
#[test]
fn update_requires_a_change() {
let error = build_update(
&Keys::generate(),
&Keys::generate().public_key(),
UpdateAgentDraft {
channel_id: CHANNEL.into(),
agent_name: "Scout".into(),
display_name: None,
system_prompt: None,
runtime: None,
provider: None,
model: None,
respond_to: None,
},
)
.unwrap_err();
assert!(error.to_string().contains("at least one field"));
}
#[test]
fn create_rejects_invalid_channel() {
let error = build_create(
&Keys::generate(),
&Keys::generate().public_key(),
CreateAgentDraft {
channel_id: "general".into(),
display_name: "Scout".into(),
system_prompt: "Help".into(),
},
)
.unwrap_err();
assert!(error.to_string().contains("invalid channel UUID"));
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,195 @@
//! Desktop-local channel template loading for `buzz channels create --template`.
//!
//! Templates live in a JSON file the desktop app owns
//! (`<app-data>/templates/channel-templates.json`); this module duplicates the
//! wire shape (`desktop/src-tauri/src/templates/types.rs`) rather than sharing
//! a crate, since buzz-cli and desktop-tauri are independent crates and the
//! shape is small and stable. Only the fields the CLI needs to read are kept.
use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::CliError;
/// Tauri bundle identifier for the production desktop app. `dirs::data_dir()`
/// joined with this segment matches `app.path().app_data_dir()` exactly
/// (Tauri resolves app-data as the platform data dir plus the identifier).
const PROD_BUNDLE_IDENTIFIER: &str = "xyz.block.buzz.app";
#[derive(Debug, Clone, Deserialize)]
pub struct ChannelTemplateRecord {
pub name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default = "default_channel_type")]
pub channel_type: String,
#[serde(default = "default_visibility")]
pub visibility: String,
#[serde(default)]
pub canvas_template: Option<String>,
#[serde(default)]
pub agents: TemplateAgentRoster,
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TemplateAgentRoster {
#[serde(default)]
pub personas: Vec<TemplateAgentEntry>,
#[serde(default)]
pub teams: Vec<TemplateTeamEntry>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TemplateAgentEntry {
pub persona_id: String,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TemplateTeamEntry {
pub team_id: String,
}
fn default_channel_type() -> String {
"stream".to_string()
}
fn default_visibility() -> String {
"open".to_string()
}
/// Resolve the desktop app's `channel-templates.json` path.
///
/// `override_path` (from `--templates-file`) always wins — useful for the dev
/// store or tests. Otherwise defaults to the prod bundle's app-data dir:
/// `<platform-data-dir>/xyz.block.buzz.app/templates/channel-templates.json`.
pub fn resolve_templates_path(override_path: Option<&str>) -> Result<PathBuf, CliError> {
if let Some(p) = override_path {
return Ok(PathBuf::from(p));
}
let data_dir = dirs::data_dir().ok_or_else(|| {
CliError::Other("could not resolve platform app-data directory".to_string())
})?;
Ok(data_dir
.join(PROD_BUNDLE_IDENTIFIER)
.join("templates")
.join("channel-templates.json"))
}
/// Load and parse the channel-templates store from `path`.
fn load_templates(path: &Path) -> Result<Vec<ChannelTemplateRecord>, CliError> {
if !path.exists() {
return Err(CliError::NotFound(format!(
"no channel templates store found at {} (create a template in Buzz Desktop first, \
or pass --templates-file)",
path.display()
)));
}
let content = std::fs::read_to_string(path)
.map_err(|e| CliError::Other(format!("failed to read {}: {e}", path.display())))?;
serde_json::from_str(&content)
.map_err(|e| CliError::Other(format!("failed to parse {}: {e}", path.display())))
}
/// Load the templates store and find the template matching `name`
/// (case-insensitive, exact match). Errors list available names if not found.
pub fn find_template(path: &Path, name: &str) -> Result<ChannelTemplateRecord, CliError> {
let templates = load_templates(path)?;
let needle = name.to_ascii_lowercase();
if let Some(t) = templates
.into_iter()
.find(|t| t.name.to_ascii_lowercase() == needle)
{
return Ok(t);
}
Err(CliError::NotFound(format!(
"no channel template named '{name}' (available: {})",
available_names(path)?
)))
}
fn available_names(path: &Path) -> Result<String, CliError> {
let templates = load_templates(path)?;
if templates.is_empty() {
return Ok("<none>".to_string());
}
Ok(templates
.iter()
.map(|t| t.name.as_str())
.collect::<Vec<_>>()
.join(", "))
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
fn write_store(json: &str) -> tempfile::NamedTempFile {
let mut f = tempfile::NamedTempFile::new().expect("tempfile");
f.write_all(json.as_bytes()).expect("write");
f
}
#[test]
fn resolve_templates_path_honors_override() {
let path = resolve_templates_path(Some("/tmp/custom.json")).unwrap();
assert_eq!(path, PathBuf::from("/tmp/custom.json"));
}
#[test]
fn resolve_templates_path_defaults_to_prod_bundle() {
let path = resolve_templates_path(None).unwrap();
assert!(path.ends_with("xyz.block.buzz.app/templates/channel-templates.json"));
}
#[test]
fn find_template_matches_case_insensitive() {
let f = write_store(r#"[{"id":"t1","name":"Buzz Team","createdAt":"x","updatedAt":"x"}]"#);
let t = find_template(f.path(), "buzz team").expect("found");
assert_eq!(t.name, "Buzz Team");
assert_eq!(t.channel_type, "stream");
assert_eq!(t.visibility, "open");
}
#[test]
fn find_template_not_found_lists_available_names() {
let f = write_store(
r#"[{"id":"t1","name":"Buzz Team","createdAt":"x","updatedAt":"x"},
{"id":"t2","name":"Standup","createdAt":"x","updatedAt":"x"}]"#,
);
let err = find_template(f.path(), "nope").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("Buzz Team"));
assert!(msg.contains("Standup"));
}
#[test]
fn find_template_missing_store_is_not_found() {
let err = find_template(Path::new("/nonexistent/channel-templates.json"), "x").unwrap_err();
assert!(matches!(err, CliError::NotFound(_)));
}
#[test]
fn load_templates_parses_full_roster() {
let f = write_store(
r##"[{
"id":"t1","name":"Buzz Team","channel_type":"forum","visibility":"private",
"canvas_template":"# {channel.name}",
"agents":{"personas":[{"personaId":"builtin:fizz"}],"teams":[{"teamId":"team-1"}]},
"created_at":"x","updated_at":"x"
}]"##,
);
let t = find_template(f.path(), "Buzz Team").expect("found");
assert_eq!(t.channel_type, "forum");
assert_eq!(t.visibility, "private");
assert_eq!(t.canvas_template.as_deref(), Some("# {channel.name}"));
assert_eq!(t.agents.personas.len(), 1);
assert_eq!(t.agents.personas[0].persona_id, "builtin:fizz");
assert_eq!(t.agents.teams.len(), 1);
assert_eq!(t.agents.teams[0].team_id, "team-1");
}
}
File diff suppressed because it is too large Load Diff
+136
View File
@@ -0,0 +1,136 @@
use uuid::Uuid;
use crate::client::{extract_d_tag, normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::{parse_uuid, sdk_err, validate_hex64};
/// List DM conversations by querying kind:41001 (relay-confirmed DMs) filtered by our pubkey.
pub async fn cmd_list_dms(client: &BuzzClient, limit: Option<u32>) -> Result<(), CliError> {
let my_pk = client.keys().public_key().to_hex();
let limit = limit.unwrap_or(50).min(200);
let filter = serde_json::json!({
"kinds": [41001],
"#p": [my_pk],
"limit": limit
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let dms: Vec<serde_json::Value> = events
.iter()
.map(|e| {
let dm_id = extract_d_tag(e);
let participants: Vec<String> = e
.get("tags")
.and_then(|t| t.as_array())
.map(|tags| {
tags.iter()
.filter_map(|tag| {
let arr = tag.as_array()?;
if arr.first()?.as_str()? == "p" {
arr.get(1)?.as_str().map(|s| s.to_string())
} else {
None
}
})
.collect()
})
.unwrap_or_default();
serde_json::json!({
"dm_id": dm_id,
"participants": participants,
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
})
})
.collect();
let output = serde_json::to_string(&dms).unwrap_or_default();
println!("{output}");
Ok(())
}
/// Open a DM with one or more users — sign and submit a kind:41010 event with a d-tag.
pub async fn cmd_open_dm(client: &BuzzClient, pubkeys: &[String]) -> Result<(), CliError> {
if pubkeys.is_empty() || pubkeys.len() > 8 {
return Err(CliError::Usage("--pubkey: must provide 1-8 pubkeys".into()));
}
for pk in pubkeys {
validate_hex64(pk)?;
}
let dm_id = Uuid::new_v4().to_string();
let refs: Vec<&str> = pubkeys.iter().map(String::as_str).collect();
// build_dm_open doesn't accept a d-tag, so we build the event manually
// using the SDK builder and add the d-tag ourselves.
use nostr::{EventBuilder, Kind, Tag};
let mut tags: Vec<Tag> = refs
.iter()
.map(|pk| Tag::parse(["p", *pk]).map_err(|e| CliError::Other(format!("tag error: {e}"))))
.collect::<Result<Vec<_>, _>>()?;
tags.push(Tag::parse(["d", &dm_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?);
let builder = EventBuilder::new(Kind::Custom(41010), "").tags(tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
// Try to extract relay-assigned channel_id from response message.
// Relay returns: {"event_id":"...","accepted":true,"message":"response:{\"channel_id\":\"...\",\"created\":true}"}
let relay_dm_id = serde_json::from_str::<serde_json::Value>(&resp)
.ok()
.and_then(|v| v.get("message")?.as_str().map(|s| s.to_string()))
.and_then(|msg| {
let json_part = msg.strip_prefix("response:")?;
serde_json::from_str::<serde_json::Value>(json_part).ok()
})
.and_then(|v| v.get("channel_id")?.as_str().map(|s| s.to_string()));
let final_dm_id = relay_dm_id.unwrap_or(dm_id);
let mut normalized: serde_json::Value =
serde_json::from_str(&resp).unwrap_or(serde_json::json!({}));
normalized["dm_id"] = serde_json::json!(final_dm_id);
if normalized.get("accepted").is_none() {
normalized["accepted"] = serde_json::json!(true);
}
println!("{normalized}");
Ok(())
}
/// Hide a DM channel — sign and submit a kind:41012 event with h-tag.
pub async fn cmd_hide_dm(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
use nostr::{EventBuilder, Kind, Tag};
let tags = vec![Tag::parse(["h", &channel_uuid.to_string()])
.map_err(|e| CliError::Other(format!("tag error: {e}")))?];
let builder =
EventBuilder::new(Kind::Custom(buzz_sdk::kind::KIND_DM_HIDE as u16), "").tags(tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Add a member to a DM group — sign and submit a kind:41011 event.
pub async fn cmd_add_dm_member(
client: &BuzzClient,
channel_id: &str,
pubkey: &str,
) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
validate_hex64(pubkey)?;
let builder = buzz_sdk::build_dm_add_member(channel_uuid, pubkey).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn dispatch(cmd: crate::DmsCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::DmsCmd;
match cmd {
DmsCmd::List { limit } => cmd_list_dms(client, limit).await,
DmsCmd::Open { pubkeys } => cmd_open_dm(client, &pubkeys).await,
DmsCmd::AddMember { channel, pubkey } => cmd_add_dm_member(client, &channel, &pubkey).await,
DmsCmd::Hide { channel } => cmd_hide_dm(client, &channel).await,
}
}
+390
View File
@@ -0,0 +1,390 @@
use std::io::Read;
use crate::client::{normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::i18n;
use buzz_sdk::CustomEmoji;
/// d-tag for a member's own custom emoji set (kind:30030). Mirrors the SDK
/// constant; the workspace palette is the union of every member's own set.
const CUSTOM_EMOJI_SET_D_TAG: &str = buzz_sdk::CUSTOM_EMOJI_SET_D_TAG;
/// Custom emoji entry in CLI output.
#[derive(Debug, serde::Serialize)]
struct EmojiEntry {
shortcode: String,
url: String,
}
/// Parse `["emoji", shortcode, url]` tags from one event into entries.
fn emoji_tags_of(event: &serde_json::Value) -> Vec<EmojiEntry> {
let Some(tags) = event.get("tags").and_then(|v| v.as_array()) else {
return vec![];
};
let mut out = Vec::new();
for tag in tags {
let Some(parts) = tag.as_array() else {
continue;
};
if parts.first().and_then(|v| v.as_str()) != Some("emoji") {
continue;
}
let (Some(shortcode), Some(url)) = (
parts.get(1).and_then(|v| v.as_str()),
parts.get(2).and_then(|v| v.as_str()),
) else {
continue;
};
out.push(EmojiEntry {
shortcode: shortcode.to_string(),
url: url.to_string(),
});
}
out
}
/// Union every member's kind:30030 set, collapsed to one entry per shortcode.
/// The most recently published set (`created_at`) wins; equal timestamps
/// tie-break to the lexicographically-smallest URL. Deterministic and
/// fetch-order-independent. Sorted by shortcode.
fn union_custom_emoji(events: &[serde_json::Value]) -> Vec<EmojiEntry> {
let mut by_shortcode: std::collections::HashMap<String, (String, i64)> =
std::collections::HashMap::new();
for event in events {
let created_at = event
.get("created_at")
.and_then(|v| v.as_i64())
.unwrap_or(0);
for entry in emoji_tags_of(event) {
match by_shortcode.get(&entry.shortcode) {
Some((url, at)) if *at > created_at || (*at == created_at && *url <= entry.url) => {
}
_ => {
by_shortcode.insert(entry.shortcode, (entry.url, created_at));
}
}
}
}
let mut out: Vec<EmojiEntry> = by_shortcode
.into_iter()
.map(|(shortcode, (url, _))| EmojiEntry { shortcode, url })
.collect();
out.sort_by(|a, b| a.shortcode.cmp(&b.shortcode));
out
}
/// List the workspace custom emoji palette: the union of every member's
/// own kind:30030 set (d=`buzz:custom-emoji`).
async fn cmd_list(client: &BuzzClient) -> Result<(), CliError> {
let filter = serde_json::json!({
"kinds": [buzz_sdk::kind::KIND_EMOJI_SET],
"#d": [CUSTOM_EMOJI_SET_D_TAG],
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse emoji set query: {e}")))?;
let emojis = union_custom_emoji(&events);
let output = serde_json::json!({ "emojis": emojis });
println!("{}", serde_json::to_string(&output).unwrap_or_default());
Ok(())
}
/// Fetch the caller's own current custom emoji set (latest kind:30030 under
/// the d-tag, authored by the caller). Empty when none published yet.
async fn fetch_own_emoji(client: &BuzzClient) -> Result<Vec<CustomEmoji>, CliError> {
let me = client.keys().public_key().to_hex();
let filter = serde_json::json!({
"kinds": [buzz_sdk::kind::KIND_EMOJI_SET],
"#d": [CUSTOM_EMOJI_SET_D_TAG],
"authors": [me],
"limit": 1,
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse own emoji set: {e}")))?;
// The relay keeps only the latest per (pubkey, d_tag), but be defensive.
let Some(event) = events.last() else {
return Ok(vec![]);
};
Ok(emoji_tags_of(event)
.into_iter()
.map(|e| CustomEmoji {
shortcode: e.shortcode,
url: e.url,
})
.collect())
}
/// Publish the caller's own (replaced) kind:30030 set, signed as the caller.
async fn publish_own_set(client: &BuzzClient, emojis: &[CustomEmoji]) -> Result<(), CliError> {
let builder = buzz_sdk::build_custom_emoji_set(emojis)
.map_err(|e| CliError::Other(format!("build_custom_emoji_set failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Add/update a shortcode in the caller's own set (read-modify-write).
async fn cmd_set(client: &BuzzClient, shortcode: &str, url: &str) -> Result<(), CliError> {
let normalized = buzz_sdk::normalize_custom_emoji_shortcode(shortcode)
.map_err(|e| CliError::Other(format!("invalid shortcode: {e}")))?;
let mut emojis = fetch_own_emoji(client).await?;
emojis.retain(|e| e.shortcode != normalized);
emojis.push(CustomEmoji {
shortcode: normalized,
url: url.to_string(),
});
publish_own_set(client, &emojis).await
}
/// Remove a shortcode from the caller's own set (read-modify-write).
async fn cmd_rm(client: &BuzzClient, shortcode: &str) -> Result<(), CliError> {
let normalized = buzz_sdk::normalize_custom_emoji_shortcode(shortcode)
.map_err(|e| CliError::Other(format!("invalid shortcode: {e}")))?;
let mut emojis = fetch_own_emoji(client).await?;
let before = emojis.len();
emojis.retain(|e| e.shortcode != normalized);
if emojis.len() == before {
// Nothing to remove; avoid republishing an unchanged set.
println!(
"{}",
serde_json::json!({"accepted": true, "message": "not present"})
);
return Ok(());
}
publish_own_set(client, &emojis).await
}
/// 10 MiB — a safety rail against runaway producers. An emoji manifest will
/// never approach this size in practice.
const STDIN_MAX_BYTES: u64 = 10_000_000;
/// Read from a file path or stdin. Returns `CliError::Usage` on empty stdin,
/// `CliError::Other` on I/O failure.
fn read_source(file: Option<&str>) -> Result<String, CliError> {
match file {
Some(path) => std::fs::read_to_string(path)
.map_err(|e| CliError::Other(format!("failed to read file '{path}': {e}"))),
None => {
let mut buf = String::new();
std::io::stdin()
.take(STDIN_MAX_BYTES)
.read_to_string(&mut buf)
.map_err(|e| CliError::Other(format!("stdin read failed: {e}")))?;
if buf.is_empty() {
return Err(CliError::Usage(
"no input: provide --file or pipe JSON to stdin".into(),
));
}
Ok(buf)
}
}
}
/// Write to a file path or stdout.
fn write_output(output: &str, file: Option<&str>) -> Result<(), CliError> {
match file {
Some(path) => std::fs::write(path, output)
.map_err(|e| CliError::Other(format!("failed to write file '{path}': {e}"))),
None => {
println!("{output}");
Ok(())
}
}
}
/// Export custom emojis to stdout or a file.
async fn cmd_export(
client: &BuzzClient,
file: Option<&str>,
scope: &crate::EmojiScope,
) -> Result<(), CliError> {
let entries: Vec<EmojiEntry> = match scope {
crate::EmojiScope::Own => {
let mut entries: Vec<EmojiEntry> = fetch_own_emoji(client)
.await?
.into_iter()
.map(|e| EmojiEntry {
shortcode: e.shortcode,
url: e.url,
})
.collect();
// Sort to match union_custom_emoji output order so repeated
// export | import --replace cycles are stable.
entries.sort_by(|a, b| a.shortcode.cmp(&b.shortcode).then(a.url.cmp(&b.url)));
entries
}
crate::EmojiScope::Workspace => {
let filter = serde_json::json!({
"kinds": [buzz_sdk::kind::KIND_EMOJI_SET],
"#d": [CUSTOM_EMOJI_SET_D_TAG],
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse emoji set query: {e}")))?;
union_custom_emoji(&events)
}
};
let output = serde_json::to_string(&serde_json::json!({ "emojis": entries }))
.map_err(|e| CliError::Other(format!("serialization failed: {e}")))?;
write_output(&output, file)
}
/// Import custom emojis from stdin or a file into the caller's own set.
async fn cmd_import(
client: &BuzzClient,
file: Option<&str>,
replace: bool,
dry_run: bool,
) -> Result<(), CliError> {
// 1. Read raw JSON
let raw = read_source(file)?;
// 2. Parse and extract ["emojis"] array
let parsed: serde_json::Value =
serde_json::from_str(&raw).map_err(|e| CliError::Usage(format!("invalid JSON: {e}")))?;
let arr = parsed
.get("emojis")
.and_then(|v| v.as_array())
.ok_or_else(|| {
CliError::Usage("input must be a JSON object with an \"emojis\" array".into())
})?;
// 34. Parse each element and normalize shortcodes
let mut import_entries: Vec<CustomEmoji> = Vec::with_capacity(arr.len());
for (i, item) in arr.iter().enumerate() {
let shortcode = item
.get("shortcode")
.and_then(|v| v.as_str())
.ok_or_else(|| CliError::Usage(format!("emojis[{i}]: missing \"shortcode\" field")))?;
let url = item
.get("url")
.and_then(|v| v.as_str())
.ok_or_else(|| CliError::Usage(format!("emojis[{i}]: missing \"url\" field")))?;
let normalized = buzz_sdk::normalize_custom_emoji_shortcode(shortcode)
.map_err(|e| CliError::Usage(format!("emojis[{i}]: invalid shortcode: {e}")))?;
import_entries.push(CustomEmoji {
shortcode: normalized,
url: url.to_string(),
});
}
// 5. Deduplicate within the import batch (first occurrence wins)
let mut seen = std::collections::HashSet::new();
import_entries.retain(|e| seen.insert(e.shortcode.clone()));
// 6. Build final set
let final_set: Vec<CustomEmoji> = if replace {
import_entries
} else {
let mut existing = fetch_own_emoji(client).await?;
let existing_shortcodes: std::collections::HashSet<String> =
existing.iter().map(|e| e.shortcode.clone()).collect();
for entry in import_entries {
if !existing_shortcodes.contains(&entry.shortcode) {
existing.push(entry);
}
}
existing
};
// 7. Dry-run: print final set to stdout, warn to stderr
if dry_run {
let entries: Vec<EmojiEntry> = final_set
.iter()
.map(|e| EmojiEntry {
shortcode: e.shortcode.clone(),
url: e.url.clone(),
})
.collect();
let output = serde_json::to_string(&serde_json::json!({ "emojis": entries }))
.map_err(|e| CliError::Other(format!("serialization failed: {e}")))?;
println!("{output}");
eprintln!("{}", i18n::label(i18n::current(), "dry_run_not_published"));
return Ok(());
}
// 8. Publish
publish_own_set(client, &final_set).await
}
pub async fn dispatch(cmd: crate::EmojiCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::EmojiCmd;
match cmd {
EmojiCmd::List => cmd_list(client).await,
EmojiCmd::Set { shortcode, url } => cmd_set(client, &shortcode, &url).await,
EmojiCmd::Rm { shortcode } => cmd_rm(client, &shortcode).await,
EmojiCmd::Export { file, scope } => cmd_export(client, file.as_deref(), &scope).await,
EmojiCmd::Import {
file,
replace,
dry_run,
} => cmd_import(client, file.as_deref(), replace, dry_run).await,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn union_latest_set_wins_per_shortcode() {
let events = vec![
serde_json::json!({
"created_at": 100,
"tags": [
["d", "buzz:custom-emoji"],
["emoji", "zort", "https://example.com/zort.png"],
["emoji", "narf", "https://example.com/narf.png"]
]
}),
serde_json::json!({
"created_at": 200,
"tags": [
["d", "buzz:custom-emoji"],
// newer set claims zort with a different url — newer wins
["emoji", "zort", "https://example.com/zort2.png"]
]
}),
];
let emojis = union_custom_emoji(&events);
let pairs: Vec<(&str, &str)> = emojis
.iter()
.map(|e| (e.shortcode.as_str(), e.url.as_str()))
.collect();
assert_eq!(
pairs,
vec![
("narf", "https://example.com/narf.png"),
("zort", "https://example.com/zort2.png"),
]
);
// Order-independence: reversed input yields the identical palette.
let reversed: Vec<_> = events.into_iter().rev().collect();
let emojis_rev = union_custom_emoji(&reversed);
let pairs_rev: Vec<(&str, &str)> = emojis_rev
.iter()
.map(|e| (e.shortcode.as_str(), e.url.as_str()))
.collect();
assert_eq!(pairs, pairs_rev);
}
#[test]
fn union_equal_timestamps_tie_break_to_smallest_url() {
let events = vec![
serde_json::json!({
"created_at": 100,
"tags": [["emoji", "zort", "https://example.com/zort2.png"]]
}),
serde_json::json!({
"created_at": 100,
"tags": [["emoji", "zort", "https://example.com/zort.png"]]
}),
];
let emojis = union_custom_emoji(&events);
assert_eq!(emojis.len(), 1);
assert_eq!(emojis[0].shortcode, "zort");
assert_eq!(emojis[0].url, "https://example.com/zort.png");
}
}
+80
View File
@@ -0,0 +1,80 @@
use std::cmp::Reverse;
use crate::client::{normalize_events, BuzzClient};
use crate::error::CliError;
const VALID_FEED_TYPES: &[&str] = &["mentions", "needs_action", "activity", "agent_activity"];
/// Get activity feed — query events mentioning our pubkey (via p-tag).
pub async fn cmd_get_feed(
client: &BuzzClient,
since: Option<i64>,
limit: Option<u32>,
types: Option<&str>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
let my_pk = client.keys().public_key().to_hex();
let limit = limit.unwrap_or(20).min(50);
let mut filter = serde_json::json!({
"#p": [my_pk],
"limit": limit
});
if let Some(s) = since {
filter["since"] = serde_json::json!(s);
}
if let Some(types_str) = types {
let type_list: Vec<&str> = types_str.split(',').map(str::trim).collect();
for t in &type_list {
if !VALID_FEED_TYPES.contains(t) {
return Err(crate::error::CliError::Usage(format!(
"invalid feed type {t:?} — must be one of: {}",
VALID_FEED_TYPES.join(", ")
)));
}
}
filter["feed_types"] = serde_json::json!(type_list);
}
let resp = client.query(&filter).await?;
let mut events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
events.sort_by_key(|e| Reverse(e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0)));
let normalized = normalize_events(&events);
let output = match format {
crate::OutputFormat::Compact => {
let evts: Vec<serde_json::Value> =
serde_json::from_str(&normalized).unwrap_or_default();
let compact: Vec<serde_json::Value> = evts
.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,
};
println!("{output}");
Ok(())
}
pub async fn dispatch(
cmd: crate::FeedCmd,
client: &BuzzClient,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
use crate::FeedCmd;
match cmd {
FeedCmd::Get {
since,
limit,
types,
} => cmd_get_feed(client, since, limit, types.as_deref(), format).await,
}
}
+206
View File
@@ -0,0 +1,206 @@
use crate::client::BuzzClient;
use crate::commands::with_git_provenance;
use crate::error::CliError;
use crate::validate::{read_or_stdin, sdk_err, validate_hex64, validate_repo_id};
use buzz_sdk::{GitIssueMeta, GitRepoCoord, GitStatusMeta};
pub async fn cmd_create_issue(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
subject: &str,
content: &str,
labels: &[String],
to: &[String],
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
let body = read_or_stdin(content)?;
let meta = GitIssueMeta {
labels: labels.to_vec(),
recipients: to.to_vec(),
};
let repo = GitRepoCoord {
owner: repo_owner.to_string(),
id: repo_id.to_string(),
};
let builder = with_git_provenance(
buzz_sdk::build_git_issue(&repo, subject, &body, &meta).map_err(sdk_err)?,
)?;
let event = client.sign_event(builder)?;
let event_id = event.id.to_hex();
let resp = client.submit_event(event).await?;
// `link` renders as a rich preview card in Buzz Desktop when included in
// a chat message — agents announce issues with it (see base_prompt.md).
let link = crate::links::issue_link(&event_id, repo_owner, repo_id);
crate::client::print_create_response(&resp, "link", &link);
Ok(())
}
pub async fn cmd_get_issue(client: &BuzzClient, event: &str) -> Result<(), CliError> {
validate_hex64(event)?;
let filter = serde_json::json!({
"kinds": [1621],
"ids": [event]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_list_issues(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
author: Option<&str>,
label: Option<&str>,
limit: Option<u32>,
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
let a_value = format!("30617:{repo_owner}:{repo_id}");
let mut filter = serde_json::json!({
"kinds": [1621],
"#a": [a_value]
});
if let Some(pk) = author {
validate_hex64(pk)?;
filter["authors"] = serde_json::json!([pk]);
}
if let Some(l) = label {
filter["#t"] = serde_json::json!([l]);
}
if let Some(n) = limit {
filter["limit"] = serde_json::json!(n);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_issue_status(
client: &BuzzClient,
issue: &str,
status: &str,
content: Option<&str>,
repo_owner: Option<&str>,
repo_id: Option<&str>,
euc: Option<&str>,
to: &[String],
) -> Result<(), CliError> {
validate_hex64(issue)?;
let status = crate::commands::patches::parse_status(status)?;
let body = match content {
Some(c) => read_or_stdin(c)?,
None => String::new(),
};
let repo = match (repo_owner, repo_id) {
(Some(owner), Some(id)) => {
validate_hex64(owner)?;
validate_repo_id(id)?;
Some(GitRepoCoord {
owner: owner.to_string(),
id: id.to_string(),
})
}
(None, None) => None,
_ => {
return Err(CliError::Usage(
"--repo-owner and --repo-id must be given together".into(),
))
}
};
// Mirrors `buzz patches status`: default a `p` tag to the repo owner
// for discoverability, plus a `--to` escape hatch for the issue author
// or anyone else who should be notified of the status change.
let mut recipients = Vec::new();
if let Some(ref repo) = repo {
recipients.push(repo.owner.clone());
}
for recipient in to {
validate_hex64(recipient)?;
if !recipients.contains(recipient) {
recipients.push(recipient.clone());
}
}
let meta = GitStatusMeta {
root_event: issue.to_string(),
accepted_revision_root: None,
repo,
euc: euc.map(str::to_string),
recipients,
applied_patches: vec![],
merge_commit: None,
applied_as_commits: vec![],
};
let builder =
with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
pub async fn dispatch(cmd: crate::IssuesCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::IssuesCmd;
match cmd {
IssuesCmd::Create {
repo_owner,
repo_id,
title,
content,
label,
to,
} => cmd_create_issue(client, &repo_owner, &repo_id, &title, &content, &label, &to).await,
IssuesCmd::Get { event } => cmd_get_issue(client, &event).await,
IssuesCmd::List {
repo_owner,
repo_id,
author,
label,
limit,
} => {
cmd_list_issues(
client,
&repo_owner,
&repo_id,
author.as_deref(),
label.as_deref(),
limit,
)
.await
}
IssuesCmd::Status {
issue,
status,
content,
repo_owner,
repo_id,
euc,
to,
} => {
cmd_issue_status(
client,
&issue,
&status,
content.as_deref(),
repo_owner.as_deref(),
repo_id.as_deref(),
euc.as_deref(),
&to,
)
.await
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
pub mod agents;
pub mod channel_templates;
pub mod channels;
pub mod dms;
pub mod emoji;
pub mod feed;
pub mod issues;
pub mod mem;
pub mod messages;
pub mod moderation;
pub mod notes;
pub mod pack;
pub mod patches;
pub mod pr;
pub mod projects;
pub mod reactions;
pub mod repos;
pub mod social;
pub mod upload;
pub mod users;
pub mod workflows;
use crate::{client::normalize_write_response, error::CliError};
use nostr::{EventBuilder, Tag};
const GIT_ORIGIN_CHANNEL_ENV: &str = "BUZZ_GIT_ORIGIN_CHANNEL_ID";
const GIT_ORIGIN_AGENT_ENV: &str = "BUZZ_GIT_ORIGIN_AGENT_NAME";
/// Add trusted, session-scoped provenance supplied by the ACP harness.
///
/// Public channels use the standard NIP-29 `h` tag. Private conversations
/// intentionally omit their channel coordinate and retain only the agent's
/// display name.
pub(crate) fn with_git_provenance(builder: EventBuilder) -> Result<EventBuilder, CliError> {
apply_git_provenance(
builder,
std::env::var(GIT_ORIGIN_CHANNEL_ENV).ok().as_deref(),
std::env::var(GIT_ORIGIN_AGENT_ENV).ok().as_deref(),
)
}
fn apply_git_provenance(
builder: EventBuilder,
channel_id: Option<&str>,
agent_name: Option<&str>,
) -> Result<EventBuilder, CliError> {
if let Some(channel_id) = channel_id {
let channel_id = channel_id.trim();
uuid::Uuid::parse_str(channel_id)
.map_err(|_| CliError::Other("invalid git origin channel ID".into()))?;
let origin_tag = Tag::parse(["h", channel_id])
.map_err(|error| CliError::Other(format!("invalid git origin tag: {error}")))?;
return Ok(builder.tag(origin_tag));
}
if let Some(agent_name) = agent_name {
let agent_name = agent_name.trim();
if agent_name.is_empty()
|| agent_name.len() > 256
|| agent_name.chars().any(char::is_control)
{
return Err(CliError::Other(
"invalid private-conversation agent name".into(),
));
}
let origin_tag = Tag::parse(["buzz-origin-agent", agent_name])
.map_err(|error| CliError::Other(format!("invalid git origin tag: {error}")))?;
return Ok(builder.tag(origin_tag));
}
Ok(builder)
}
/// Parse a relay write-response JSON blob, mapping a duplicate (dominated)
/// write to [`CliError::Conflict`] with the caller-supplied message.
///
/// Used by every command that publishes an NIP-33 addressable event and
/// needs to tell accepted from duplicate/dominated.
pub fn parse_write_response(raw: &str, conflict_msg: &str) -> Result<String, CliError> {
let response: serde_json::Value = serde_json::from_str(raw)
.map_err(|e| CliError::Other(format!("relay response is not JSON: {e} ({raw})")))?;
let accepted = response
.get("accepted")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
let message = response
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if !accepted {
return Err(CliError::Other(format!("relay rejected event: {message}")));
}
if message == "duplicate" || message.starts_with("duplicate:") {
return Err(CliError::Conflict(conflict_msg.to_string()));
}
Ok(normalize_write_response(raw))
}
#[cfg(test)]
mod tests {
use super::*;
use nostr::{Keys, Kind};
fn event_with_origin(channel_id: Option<&str>, agent_name: Option<&str>) -> nostr::Event {
apply_git_provenance(
EventBuilder::new(Kind::Custom(1621), "issue"),
channel_id,
agent_name,
)
.expect("apply provenance")
.sign_with_keys(&Keys::generate())
.expect("sign event")
}
#[test]
fn public_channel_origin_uses_h_tag_and_suppresses_agent_name() {
let channel_id = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50";
let event = event_with_origin(Some(channel_id), Some("Builder"));
assert!(event
.tags
.iter()
.any(|tag| tag.as_slice() == ["h", channel_id]));
assert!(!event
.tags
.iter()
.any(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-origin-agent")));
}
#[test]
fn private_origin_exposes_only_agent_name() {
let event = event_with_origin(None, Some("Builder"));
assert!(event
.tags
.iter()
.any(|tag| tag.as_slice() == ["buzz-origin-agent", "Builder"]));
assert!(!event
.tags
.iter()
.any(|tag| tag.as_slice().first().map(String::as_str) == Some("h")));
}
}
+165
View File
@@ -0,0 +1,165 @@
//! `buzz moderation` — community moderation queue, enforcement, and audit.
//!
//! Mutations (`ban`/`unban`/`timeout`/`untimeout`/`resolve`) are signed
//! command events (kinds 90409044) submitted via `POST /events`, mirroring
//! the NIP-43 relay-admin 9030-series: the relay validates, authorizes
//! (owner/admin only), and executes them directly — they are never stored.
//!
//! Reads (`reports`/`restricted`/`audit`) hit dedicated mod-only,
//! NIP-98-authed relay endpoints under `/moderation/*`, because reports and
//! audit rows are structured queue rows, not public nostr events — serving
//! them over a REQ filter would mean synthesizing fake events and threading a
//! privileged authz check into the public read path.
//!
//! The community (tenant) is selected by the relay host — moderation commands
//! carry no channel scope.
use nostr::Timestamp;
use crate::client::{normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::validate_hex64;
use crate::{ModerationCmd, OutputFormat};
/// Resolve `--expires-in <secs>` / `--expires-at <unix>` into an absolute
/// unix-seconds expiry. At most one may be set (enforced by clap).
fn resolve_expiry(expires_in: Option<u64>, expires_at: Option<u64>) -> Option<u64> {
match (expires_in, expires_at) {
(Some(secs), _) => Some(Timestamp::now().as_secs() + secs),
(None, Some(ts)) => Some(ts),
(None, None) => None,
}
}
async fn cmd_ban(
client: &BuzzClient,
pubkey: &str,
expires_in: Option<u64>,
expires_at: Option<u64>,
reason: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pubkey)?;
let expiry = resolve_expiry(expires_in, expires_at);
let builder = buzz_sdk::build_moderation_ban(pubkey, expiry, reason)
.map_err(|e| CliError::Usage(format!("invalid ban: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
async fn cmd_unban(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> {
validate_hex64(pubkey)?;
let builder = buzz_sdk::build_moderation_unban(pubkey)
.map_err(|e| CliError::Usage(format!("invalid unban: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
async fn cmd_timeout(
client: &BuzzClient,
pubkey: &str,
expires_in: Option<u64>,
expires_at: Option<u64>,
reason: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pubkey)?;
let expiry = resolve_expiry(expires_in, expires_at)
.ok_or_else(|| CliError::Usage("timeout requires --expires-in or --expires-at".into()))?;
let builder = buzz_sdk::build_moderation_timeout(pubkey, expiry, reason)
.map_err(|e| CliError::Usage(format!("invalid timeout: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
async fn cmd_untimeout(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> {
validate_hex64(pubkey)?;
let builder = buzz_sdk::build_moderation_untimeout(pubkey)
.map_err(|e| CliError::Usage(format!("invalid untimeout: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
async fn cmd_resolve(
client: &BuzzClient,
report: &str,
status: &str,
action: &str,
reason: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(report)?;
let builder = buzz_sdk::build_moderation_resolve_report(report, status, action, reason)
.map_err(|e| CliError::Usage(format!("invalid resolution: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
async fn cmd_reports(
client: &BuzzClient,
status: Option<&str>,
limit: i64,
) -> Result<(), CliError> {
let mut path = format!("/moderation/reports?limit={limit}");
if let Some(s) = status {
path.push_str(&format!("&status={s}"));
}
let resp = client.get_authed(&path).await?;
println!("{resp}");
Ok(())
}
async fn cmd_restricted(client: &BuzzClient) -> Result<(), CliError> {
let resp = client.get_authed("/moderation/restricted").await?;
println!("{resp}");
Ok(())
}
async fn cmd_audit(client: &BuzzClient, limit: i64) -> Result<(), CliError> {
let resp = client
.get_authed(&format!("/moderation/audit?limit={limit}"))
.await?;
println!("{resp}");
Ok(())
}
pub async fn dispatch(
cmd: ModerationCmd,
client: &BuzzClient,
_format: &OutputFormat,
) -> Result<(), CliError> {
match cmd {
ModerationCmd::Reports { status, limit } => {
cmd_reports(client, status.as_deref(), limit).await
}
ModerationCmd::Resolve {
report,
status,
action,
reason,
} => cmd_resolve(client, &report, &status, &action, reason.as_deref()).await,
ModerationCmd::Ban {
pubkey,
expires_in,
expires_at,
reason,
} => cmd_ban(client, &pubkey, expires_in, expires_at, reason.as_deref()).await,
ModerationCmd::Unban { pubkey } => cmd_unban(client, &pubkey).await,
ModerationCmd::Timeout {
pubkey,
expires_in,
expires_at,
reason,
} => cmd_timeout(client, &pubkey, expires_in, expires_at, reason.as_deref()).await,
ModerationCmd::Untimeout { pubkey } => cmd_untimeout(client, &pubkey).await,
ModerationCmd::Restricted => cmd_restricted(client).await,
ModerationCmd::Audit { limit } => cmd_audit(client, limit).await,
}
}
File diff suppressed because it is too large Load Diff
+208
View File
@@ -0,0 +1,208 @@
//! `buzz pack` subcommands — local persona pack operations.
//!
//! These commands operate on local pack directories. No relay connection needed.
use std::path::Path;
use crate::error::CliError;
use crate::i18n;
/// Run `buzz pack validate <path>`.
///
/// Calls `validate_pack()` from the persona crate, prints diagnostics,
/// and exits with the appropriate code:
/// - 0: valid (may have warnings)
/// - 1: errors found
pub fn cmd_validate(path: &str) -> Result<(), CliError> {
let pack_dir = Path::new(path);
if !pack_dir.exists() {
return Err(CliError::Usage(format!("path does not exist: {path}")));
}
if !pack_dir.is_dir() {
return Err(CliError::Usage(format!("not a directory: {path}")));
}
let report = buzz_persona::validate::validate_pack(pack_dir);
let locale = i18n::current();
for diag in &report.diagnostics {
match diag {
buzz_persona::validate::ValidationDiagnostic::Error(msg) => {
eprintln!(" {}: {msg}", i18n::label(locale, "error"));
}
buzz_persona::validate::ValidationDiagnostic::Warning(msg) => {
eprintln!(" {}: {msg}", i18n::label(locale, "warn"));
}
}
}
if report.has_errors() {
let message = if locale.is_chinese() {
"验证失败。"
} else {
"Validation failed."
};
return Err(CliError::Usage(message.into()));
} else if report.has_warnings() {
println!("{}", i18n::label(locale, "valid_warnings"));
} else {
println!("{}", i18n::label(locale, "valid"));
}
Ok(())
}
/// Run `buzz pack inspect <path>`.
///
/// Loads and resolves a pack, then pretty-prints a summary of each persona's
/// effective configuration.
pub fn cmd_inspect(path: &str) -> Result<(), CliError> {
let pack_dir = Path::new(path);
if !pack_dir.exists() {
return Err(CliError::Usage(format!("path does not exist: {path}")));
}
if !pack_dir.is_dir() {
return Err(CliError::Usage(format!("not a directory: {path}")));
}
// Resolve the pack — shows fully effective config (post-merge, post-split).
let pack = buzz_persona::resolve::resolve_pack(pack_dir)
.map_err(|e| CliError::Other(format!("failed to resolve pack: {e}")))?;
// Header
let locale = i18n::current();
println!(
"{}: {} ({})",
i18n::label(locale, "pack"),
pack.name,
pack.id
);
println!("{}: {}", i18n::label(locale, "version"), pack.version);
println!(
"{}: {}",
i18n::label(locale, "personas"),
pack.personas.len()
);
println!();
// Per-persona summary (fully resolved effective config)
for persona in &pack.personas {
println!(" {}", persona.name);
println!(
" {}: {}",
i18n::label(locale, "display"),
persona.display_name
);
println!(
" {}: {}",
i18n::label(locale, "description"),
persona.description
);
if let Some(ref llm_provider) = persona.llm_provider {
if let Some(ref model) = persona.model {
println!(
" {}: {llm_provider}:{model}",
i18n::label(locale, "model")
);
} else {
println!(" {}: {llm_provider}", i18n::label(locale, "provider"));
}
} else if let Some(ref model) = persona.model {
println!(" {}: {model}", i18n::label(locale, "model"));
}
if let Some(temp) = persona.temperature {
println!(" {}: {temp}", i18n::label(locale, "temperature"));
}
if let Some(ctx) = persona.max_context_tokens {
println!(" {}: {ctx}", i18n::label(locale, "max_context_tokens"));
}
if !persona.subscribe.is_empty() {
println!(
" {}: {}",
i18n::label(locale, "subscribe"),
persona.subscribe.join(", ")
);
}
let rt = &persona.triggers;
let mut parts = Vec::new();
if rt.mentions {
parts.push("mentions".to_string());
}
if !rt.keywords.is_empty() {
parts.push(format!("keywords {:?}", rt.keywords));
}
if rt.all_messages {
parts.push("all_messages".to_string());
}
if !parts.is_empty() {
println!(
" {}: {}",
i18n::label(locale, "triggers"),
parts.join(" + ")
);
}
println!(
" {}: {}",
i18n::label(locale, "thread_replies"),
persona.thread_replies
);
println!(
" {}: {}",
i18n::label(locale, "broadcast_replies"),
persona.broadcast_replies
);
if !persona.mcp_servers.is_empty() {
println!(
" {}: {}",
i18n::label(locale, "mcp_servers"),
persona.mcp_servers.len()
);
}
if !persona.skills.is_empty() {
println!(
" {}: {}",
i18n::label(locale, "skills"),
persona.skills.join(", ")
);
}
if let Some(ref avatar) = persona.avatar {
println!(" {}: {avatar}", i18n::label(locale, "avatar"));
}
let prompt_preview = if persona.system_prompt.chars().count() > 80 {
let truncated: String = persona.system_prompt.chars().take(77).collect();
format!("{truncated}...")
} else {
persona.system_prompt.clone()
};
println!(
" {}: {} chars ({})",
i18n::label(locale, "system_prompt"),
persona.system_prompt.len(),
prompt_preview.replace('\n', " ")
);
if !persona.runtime_env_vars.is_empty() {
let env_str: Vec<String> = persona
.runtime_env_vars
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect();
println!(
" {}: {}",
i18n::label(locale, "env_vars"),
env_str.join(", ")
);
}
println!();
}
Ok(())
}
+326
View File
@@ -0,0 +1,326 @@
use crate::client::BuzzClient;
use crate::commands::with_git_provenance;
use crate::error::CliError;
use crate::validate::{
read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id,
};
use buzz_sdk::{GitAppliedPatchRef, GitPatchMeta, GitRepoCoord, GitStatus, GitStatusMeta};
#[allow(clippy::too_many_arguments)]
pub async fn cmd_send_patch(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
patch: &str,
euc: Option<&str>,
to: &[String],
reply_to: Option<&str>,
root: bool,
root_revision: bool,
commit: Option<&str>,
parent_commit: Option<&str>,
commit_pgp_sig: Option<&str>,
committer: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
let content = read_file_or_stdin(patch)?;
let committer = match committer {
Some(spec) => Some(parse_committer(spec)?),
None => None,
};
let meta = GitPatchMeta {
euc: euc.map(str::to_string),
recipients: to.to_vec(),
reply_to: reply_to.map(str::to_string),
root,
root_revision,
commit: commit.map(str::to_string),
parent_commit: parent_commit.map(str::to_string),
commit_pgp_sig: commit_pgp_sig.map(str::to_string),
committer,
};
let repo = GitRepoCoord {
owner: repo_owner.to_string(),
id: repo_id.to_string(),
};
let builder =
with_git_provenance(buzz_sdk::build_git_patch(&repo, &content, &meta).map_err(sdk_err)?)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
/// Parse `--committer 'name|email|timestamp|tz-offset-minutes'`.
fn parse_committer(spec: &str) -> Result<(String, String, String, String), CliError> {
let parts: Vec<&str> = spec.split('|').collect();
match parts.as_slice() {
[name, email, ts, tz] => Ok((
name.to_string(),
email.to_string(),
ts.to_string(),
tz.to_string(),
)),
_ => Err(CliError::Usage(
"--committer must be 'name|email|timestamp|tz-offset-minutes'".into(),
)),
}
}
pub async fn cmd_get_patch(client: &BuzzClient, event: &str) -> Result<(), CliError> {
validate_hex64(event)?;
let filter = serde_json::json!({
"kinds": [1617],
"ids": [event]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_list_patches(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
author: Option<&str>,
limit: Option<u32>,
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
let a_value = format!("30617:{repo_owner}:{repo_id}");
let mut filter = serde_json::json!({
"kinds": [1617],
"#a": [a_value]
});
if let Some(pk) = author {
validate_hex64(pk)?;
filter["authors"] = serde_json::json!([pk]);
}
if let Some(n) = limit {
filter["limit"] = serde_json::json!(n);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_patch_status(
client: &BuzzClient,
root: &str,
status: &str,
content: Option<&str>,
repo_owner: Option<&str>,
repo_id: Option<&str>,
euc: Option<&str>,
revision: Option<&str>,
to: &[String],
q: &[String],
merge_commit: Option<&str>,
applied_as_commit: &[String],
) -> Result<(), CliError> {
validate_hex64(root)?;
let status = parse_status(status)?;
let body = match content {
Some(c) => read_or_stdin(c)?,
None => String::new(),
};
let repo = match (repo_owner, repo_id) {
(Some(owner), Some(id)) => {
validate_hex64(owner)?;
validate_repo_id(id)?;
Some(GitRepoCoord {
owner: owner.to_string(),
id: id.to_string(),
})
}
(None, None) => None,
_ => {
return Err(CliError::Usage(
"--repo-owner and --repo-id must be given together".into(),
))
}
};
// NIP-34 expects status events to `p`-tag the repo owner (plus root/
// revision authors) so they're discoverable by subscription. Default
// to the repo owner when known; `--to` covers root-author / revision-
// author / anyone else the caller wants to notify.
let mut recipients = Vec::new();
if let Some(ref repo) = repo {
recipients.push(repo.owner.clone());
}
for recipient in to {
validate_hex64(recipient)?;
if !recipients.contains(recipient) {
recipients.push(recipient.clone());
}
}
let applied_patches = q
.iter()
.map(|spec| GitAppliedPatchRef::parse(spec).map_err(sdk_err))
.collect::<Result<Vec<_>, _>>()?;
let meta = GitStatusMeta {
root_event: root.to_string(),
accepted_revision_root: revision.map(str::to_string),
repo,
euc: euc.map(str::to_string),
recipients,
applied_patches,
merge_commit: merge_commit.map(str::to_string),
applied_as_commits: applied_as_commit.to_vec(),
};
let builder =
with_git_provenance(buzz_sdk::build_git_status(status, &body, &meta).map_err(sdk_err)?)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
/// Parse the CLI's status word into a `GitStatus`. `merged` and `resolved`
/// are accepted as synonyms for the same underlying kind (1631) — NIP-34
/// uses "applied/merged" for patches and "resolved" for issues, but it's one
/// status kind either way. Shared by `buzz issues status`.
pub(crate) fn parse_status(s: &str) -> Result<GitStatus, CliError> {
match s {
"open" => Ok(GitStatus::Open),
"merged" | "resolved" => Ok(GitStatus::AppliedOrResolved),
"closed" => Ok(GitStatus::Closed),
"draft" => Ok(GitStatus::Draft),
other => Err(CliError::Usage(format!(
"invalid status '{other}' — expected one of: open, merged, resolved, closed, draft"
))),
}
}
pub async fn dispatch(cmd: crate::PatchesCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::PatchesCmd;
match cmd {
PatchesCmd::Send {
repo_owner,
repo_id,
patch_file,
euc,
to,
reply_to,
root,
root_revision,
commit,
parent_commit,
commit_pgp_sig,
committer,
} => {
cmd_send_patch(
client,
&repo_owner,
&repo_id,
&patch_file,
euc.as_deref(),
&to,
reply_to.as_deref(),
root,
root_revision,
commit.as_deref(),
parent_commit.as_deref(),
commit_pgp_sig.as_deref(),
committer.as_deref(),
)
.await
}
PatchesCmd::Get { event } => cmd_get_patch(client, &event).await,
PatchesCmd::List {
repo_owner,
repo_id,
author,
limit,
} => cmd_list_patches(client, &repo_owner, &repo_id, author.as_deref(), limit).await,
PatchesCmd::Status {
root,
status,
content,
repo_owner,
repo_id,
euc,
revision,
to,
q,
merge_commit,
applied_as_commit,
} => {
cmd_patch_status(
client,
&root,
&status,
content.as_deref(),
repo_owner.as_deref(),
repo_id.as_deref(),
euc.as_deref(),
revision.as_deref(),
&to,
&q,
merge_commit.as_deref(),
&applied_as_commit,
)
.await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_committer_valid() {
let result = parse_committer("Jane Doe|jane@example.com|1700000000|-480").unwrap();
assert_eq!(
result,
(
"Jane Doe".to_string(),
"jane@example.com".to_string(),
"1700000000".to_string(),
"-480".to_string()
)
);
}
#[test]
fn parse_committer_rejects_wrong_field_count() {
assert!(parse_committer("Jane Doe|jane@example.com").is_err());
assert!(parse_committer("a|b|c|d|e").is_err());
}
#[test]
fn parse_status_accepts_known_words() {
assert!(matches!(parse_status("open").unwrap(), GitStatus::Open));
assert!(matches!(
parse_status("merged").unwrap(),
GitStatus::AppliedOrResolved
));
assert!(matches!(
parse_status("resolved").unwrap(),
GitStatus::AppliedOrResolved
));
assert!(matches!(parse_status("closed").unwrap(), GitStatus::Closed));
assert!(matches!(parse_status("draft").unwrap(), GitStatus::Draft));
}
#[test]
fn parse_status_rejects_unknown_word() {
let err = parse_status("merge").unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}
}
+352
View File
@@ -0,0 +1,352 @@
use crate::client::BuzzClient;
use crate::commands::with_git_provenance;
use crate::error::CliError;
use crate::validate::{
read_file_or_stdin, read_or_stdin, sdk_err, validate_hex64, validate_repo_id,
};
use buzz_sdk::{GitPrUpdateMeta, GitPullRequestMeta, GitRepoCoord, GitStatusMeta};
fn read_optional_body(body: Option<&str>, body_file: Option<&str>) -> Result<String, CliError> {
match (body, body_file) {
(Some(_), Some(_)) => Err(CliError::Usage(
"--body and --body-file are mutually exclusive".into(),
)),
(Some(value), None) => read_or_stdin(value),
(None, Some(path)) => read_file_or_stdin(path),
(None, None) => Ok(String::new()),
}
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_open_pr(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
subject: &str,
body: Option<&str>,
body_file: Option<&str>,
commit: &str,
clone_urls: &[String],
branch_name: Option<&str>,
merge_base: Option<&str>,
euc: Option<&str>,
labels: &[String],
to: &[String],
channel: Option<&str>,
revision_of: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
let content = read_optional_body(body, body_file)?;
let repo = GitRepoCoord {
owner: repo_owner.to_string(),
id: repo_id.to_string(),
};
let meta = GitPullRequestMeta {
euc: euc.map(str::to_string),
recipients: to.to_vec(),
channel_id: channel.map(str::to_string),
subject: subject.to_string(),
labels: labels.to_vec(),
commit: commit.to_string(),
clone_urls: clone_urls.to_vec(),
branch_name: branch_name.map(str::to_string),
merge_base: merge_base.map(str::to_string),
revision_of: revision_of.map(str::to_string),
};
let builder = with_git_provenance(
buzz_sdk::build_git_pull_request(&repo, &content, &meta).map_err(sdk_err)?,
)?;
let event = client.sign_event(builder)?;
let event_id = event.id.to_hex();
let resp = client.submit_event(event).await?;
// `link` renders as a rich preview card in Buzz Desktop when included in
// a chat message — agents announce PRs with it (see base_prompt.md).
let link = crate::links::pull_request_link(&event_id, repo_owner, repo_id);
crate::client::print_create_response(&resp, "link", &link);
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_update_pr(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
pr: &str,
pr_author: &str,
commit: &str,
clone_urls: &[String],
body: Option<&str>,
body_file: Option<&str>,
merge_base: Option<&str>,
euc: Option<&str>,
to: &[String],
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
validate_hex64(pr)?;
validate_hex64(pr_author)?;
let content = read_optional_body(body, body_file)?;
let repo = GitRepoCoord {
owner: repo_owner.to_string(),
id: repo_id.to_string(),
};
let meta = GitPrUpdateMeta {
euc: euc.map(str::to_string),
recipients: to.to_vec(),
pr_event: pr.to_string(),
pr_author: pr_author.to_string(),
commit: commit.to_string(),
clone_urls: clone_urls.to_vec(),
merge_base: merge_base.map(str::to_string),
};
let builder = with_git_provenance(
buzz_sdk::build_git_pr_update(&repo, &content, &meta).map_err(sdk_err)?,
)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_get_pr(client: &BuzzClient, event: &str) -> Result<(), CliError> {
validate_hex64(event)?;
let filter = serde_json::json!({
"kinds": [1618],
"ids": [event]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_list_prs(
client: &BuzzClient,
repo_owner: &str,
repo_id: &str,
author: Option<&str>,
label: Option<&str>,
limit: Option<u32>,
) -> Result<(), CliError> {
validate_hex64(repo_owner)?;
validate_repo_id(repo_id)?;
let a_value = format!("30617:{repo_owner}:{repo_id}");
let mut filter = serde_json::json!({
"kinds": [1618],
"#a": [a_value]
});
if let Some(pk) = author {
validate_hex64(pk)?;
filter["authors"] = serde_json::json!([pk]);
}
if let Some(l) = label {
filter["#t"] = serde_json::json!([l]);
}
if let Some(n) = limit {
filter["limit"] = serde_json::json!(n);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_pr_status(
client: &BuzzClient,
pr: &str,
status: &str,
body: Option<&str>,
body_file: Option<&str>,
repo_owner: Option<&str>,
repo_id: Option<&str>,
euc: Option<&str>,
to: &[String],
merge_commit: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pr)?;
let status = crate::commands::patches::parse_status(status)?;
let content = read_optional_body(body, body_file)?;
let repo = match (repo_owner, repo_id) {
(Some(owner), Some(id)) => {
validate_hex64(owner)?;
validate_repo_id(id)?;
Some(GitRepoCoord {
owner: owner.to_string(),
id: id.to_string(),
})
}
(None, None) => None,
_ => {
return Err(CliError::Usage(
"--repo-owner and --repo-id must be given together".into(),
))
}
};
// Mirrors patch/issue status: default a `p` tag to the repo owner when
// known; callers can add PR author/reviewers with repeated `--to`.
let mut recipients = Vec::new();
if let Some(ref repo) = repo {
recipients.push(repo.owner.clone());
}
for recipient in to {
validate_hex64(recipient)?;
if !recipients.contains(recipient) {
recipients.push(recipient.clone());
}
}
let meta = GitStatusMeta {
root_event: pr.to_string(),
accepted_revision_root: None,
repo,
euc: euc.map(str::to_string),
recipients,
applied_patches: vec![],
merge_commit: merge_commit.map(str::to_string),
applied_as_commits: vec![],
};
let builder =
with_git_provenance(buzz_sdk::build_git_status(status, &content, &meta).map_err(sdk_err)?)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
pub async fn dispatch(cmd: crate::PrCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::PrCmd;
match cmd {
PrCmd::Open {
repo_owner,
repo_id,
subject,
body,
body_file,
commit,
clone,
branch_name,
merge_base,
euc,
label,
to,
channel,
revision_of,
} => {
cmd_open_pr(
client,
&repo_owner,
&repo_id,
&subject,
body.as_deref(),
body_file.as_deref(),
&commit,
&clone,
branch_name.as_deref(),
merge_base.as_deref(),
euc.as_deref(),
&label,
&to,
channel.as_deref(),
revision_of.as_deref(),
)
.await
}
PrCmd::Update {
repo_owner,
repo_id,
pr,
pr_author,
commit,
clone,
body,
body_file,
merge_base,
euc,
to,
} => {
cmd_update_pr(
client,
&repo_owner,
&repo_id,
&pr,
&pr_author,
&commit,
&clone,
body.as_deref(),
body_file.as_deref(),
merge_base.as_deref(),
euc.as_deref(),
&to,
)
.await
}
PrCmd::Get { event } => cmd_get_pr(client, &event).await,
PrCmd::List {
repo_owner,
repo_id,
author,
label,
limit,
} => {
cmd_list_prs(
client,
&repo_owner,
&repo_id,
author.as_deref(),
label.as_deref(),
limit,
)
.await
}
PrCmd::Status {
pr,
status,
body,
body_file,
repo_owner,
repo_id,
euc,
to,
merge_commit,
} => {
cmd_pr_status(
client,
&pr,
&status,
body.as_deref(),
body_file.as_deref(),
repo_owner.as_deref(),
repo_id.as_deref(),
euc.as_deref(),
&to,
merge_commit.as_deref(),
)
.await
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn read_optional_body_rejects_body_and_body_file_together() {
assert!(read_optional_body(Some("body"), Some("file.md")).is_err());
}
#[test]
fn read_optional_body_defaults_empty() {
assert_eq!(read_optional_body(None, None).unwrap(), "");
}
}
File diff suppressed because it is too large Load Diff
+138
View File
@@ -0,0 +1,138 @@
use std::collections::HashMap;
use nostr::EventId;
use crate::client::{normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::validate_hex64;
pub async fn cmd_add_reaction(
client: &BuzzClient,
event_id: &str,
emoji: &str,
emoji_url: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
let target_eid =
EventId::parse(event_id).map_err(|e| CliError::Usage(format!("invalid event ID: {e}")))?;
let builder = if let Some(url) = emoji_url {
buzz_sdk::build_custom_emoji_reaction(target_eid, emoji, url)
.map_err(|e| CliError::Other(format!("build_custom_emoji_reaction failed: {e}")))?
} else {
buzz_sdk::build_reaction(target_eid, emoji)
.map_err(|e| CliError::Other(format!("build_reaction failed: {e}")))?
};
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_remove_reaction(
client: &BuzzClient,
event_id: &str,
emoji: &str,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
let keys = client.keys();
// Find our reaction event by querying kind:7 reactions on this event from us
let my_pk = keys.public_key().to_hex();
let filter = serde_json::json!({
"kinds": [7],
"#e": [event_id],
"authors": [my_pk]
});
let raw = client.query(&filter).await?;
let events: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse reactions query: {e}")))?;
let arr = events
.as_array()
.ok_or_else(|| CliError::Other("reactions query response is not an array".into()))?;
// Find the reaction event matching the emoji
let reaction_event_id = arr
.iter()
.find(|ev| ev.get("content").and_then(|c| c.as_str()) == Some(emoji))
.and_then(|ev| ev.get("id").and_then(|id| id.as_str()))
.ok_or_else(|| {
CliError::Other(format!(
"no reaction with emoji '{emoji}' found for your pubkey on event {event_id}"
))
})?;
let reaction_eid = EventId::parse(reaction_event_id)
.map_err(|e| CliError::Other(format!("invalid reaction event ID: {e}")))?;
let builder = buzz_sdk::build_remove_reaction(reaction_eid)
.map_err(|e| CliError::Other(format!("build_remove_reaction failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_get_reactions(client: &BuzzClient, event_id: &str) -> Result<(), CliError> {
validate_hex64(event_id)?;
let filter = serde_json::json!({
"kinds": [7],
"#e": [event_id]
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let mut groups: HashMap<String, Vec<String>> = HashMap::new();
for e in &events {
let emoji = e
.get("content")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.unwrap_or("+")
.to_string();
let pubkey = e
.get("pubkey")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
groups.entry(emoji).or_default().push(pubkey);
}
let mut reactions: Vec<serde_json::Value> = groups
.into_iter()
.map(|(emoji, pubkeys)| {
serde_json::json!({
"emoji": emoji,
"count": pubkeys.len(),
"pubkeys": pubkeys,
})
})
.collect();
reactions.sort_by(|a, b| {
a.get("emoji")
.and_then(|v| v.as_str())
.unwrap_or("")
.cmp(b.get("emoji").and_then(|v| v.as_str()).unwrap_or(""))
});
let output = serde_json::json!({ "reactions": reactions });
println!("{}", serde_json::to_string(&output).unwrap_or_default());
Ok(())
}
pub async fn dispatch(cmd: crate::ReactionsCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::ReactionsCmd;
match cmd {
ReactionsCmd::Add {
event,
emoji,
emoji_url,
} => cmd_add_reaction(client, &event, &emoji, emoji_url.as_deref()).await,
ReactionsCmd::Remove { event, emoji } => cmd_remove_reaction(client, &event, &emoji).await,
ReactionsCmd::Get { event } => cmd_get_reactions(client, &event).await,
}
}
+847
View File
@@ -0,0 +1,847 @@
use buzz_core::{
git_perms::{parse_protection_tag, parse_protection_tags, RefPattern},
kind::KIND_GIT_REPO_ANNOUNCEMENT,
};
use nostr::{Event, EventBuilder, Tag, Timestamp};
use crate::client::BuzzClient;
use crate::commands::parse_write_response;
use crate::error::CliError;
use crate::validate::validate_repo_id;
fn parse_events(json: &str) -> Result<Vec<Event>, CliError> {
serde_json::from_str(json)
.map_err(|error| CliError::Other(format!("failed to parse relay response: {error}")))
}
async fn fetch_own_repo_announcement(
client: &BuzzClient,
repo_id: &str,
) -> Result<Option<Event>, CliError> {
let filter = serde_json::json!({
"kinds": [KIND_GIT_REPO_ANNOUNCEMENT],
"authors": [client.keys().public_key().to_hex()],
"#d": [repo_id],
"limit": 1,
});
let raw = client.query(&filter).await?;
let mut events = parse_events(&raw)?;
events.sort_by_key(|event| std::cmp::Reverse(event.created_at));
Ok(events.into_iter().next())
}
fn repo_id_from_event(event: &Event) -> Result<&str, CliError> {
event
.tags
.iter()
.find_map(|tag| {
let values = tag.as_slice();
(values.first().map(String::as_str) == Some("d"))
.then(|| values.get(1).map(String::as_str))
.flatten()
})
.ok_or_else(|| CliError::Other("repository announcement is missing its d tag".into()))
}
fn tag_error(error: impl std::fmt::Display) -> CliError {
CliError::Other(format!("failed to build protection tag: {error}"))
}
fn protection_pattern(tag: &Tag) -> Option<&str> {
let values = tag.as_slice();
(values.first().map(String::as_str) == Some("buzz-protect"))
.then(|| values.get(1).map(String::as_str))
.flatten()
}
fn has_tag_name(tag: &Tag, name: &str) -> bool {
tag.as_slice().first().map(String::as_str) == Some(name)
}
fn build_protection_tag(
ref_pattern: &str,
push_role: Option<&str>,
no_force_push: bool,
no_delete: bool,
require_patch: bool,
) -> Result<Tag, CliError> {
let mut values = vec!["buzz-protect".to_string(), ref_pattern.to_string()];
if let Some(role) = push_role {
values.push(format!("push:{role}"));
}
if no_force_push {
values.push("no-force-push".into());
}
if no_delete {
values.push("no-delete".into());
}
if require_patch {
values.push("require-patch".into());
}
let rule_values: Vec<&str> = values[1..].iter().map(String::as_str).collect();
parse_protection_tag(&rule_values)
.map_err(|error| CliError::Usage(format!("invalid protection rule: {error}")))?;
Tag::parse(values).map_err(tag_error)
}
enum RepoChange {
SetProtection(Box<Tag>),
RemoveProtection(String),
/// Bind (or rebind) the repo to a channel: replaces every existing
/// `buzz-channel` tag with exactly one carrying the validated UUID.
BindChannel(String),
}
fn build_updated_repo_announcement(
existing: &Event,
change: RepoChange,
) -> Result<EventBuilder, CliError> {
let repo_id = repo_id_from_event(existing)?;
// What to strip beyond `auth` (always stripped), and what to append.
let (removed_pattern, removed_channel, replacement) = match change {
RepoChange::SetProtection(tag) => {
let pattern = protection_pattern(&tag)
.ok_or_else(|| CliError::Other("replacement is not a protection tag".into()))?
.to_string();
(Some(pattern), false, Some(*tag))
}
RepoChange::RemoveProtection(pattern) => {
RefPattern::parse(&pattern)
.map_err(|error| CliError::Usage(format!("invalid ref pattern: {error}")))?;
(Some(pattern), false, None)
}
RepoChange::BindChannel(channel) => {
crate::validate::validate_uuid(&channel)?;
let tag = Tag::parse(["buzz-channel", channel.as_str()]).map_err(tag_error)?;
(None, true, Some(tag))
}
};
let mut tags: Vec<Tag> = existing
.tags
.iter()
.filter(|tag| {
if has_tag_name(tag, "auth") {
return false;
}
if removed_channel && has_tag_name(tag, "buzz-channel") {
return false;
}
removed_pattern.is_none() || protection_pattern(tag) != removed_pattern.as_deref()
})
.cloned()
.collect();
if let Some(tag) = replacement {
tags.push(tag);
}
let raw_tags: Vec<Vec<String>> = tags.iter().map(|tag| tag.as_slice().to_vec()).collect();
parse_protection_tags(&raw_tags).map_err(|error| {
CliError::Other(format!(
"repository contains invalid protection rules; refusing update: {error}"
))
})?;
// Advance only the observed head. Using wall-clock time here would let a
// delayed writer leapfrog an intervening update and silently erase metadata.
let next_created_at = existing
.created_at
.as_secs()
.checked_add(1)
.ok_or_else(|| CliError::Other("repository timestamp cannot be advanced".into()))?;
buzz_sdk::build_repo_announcement_with_tags(repo_id, &existing.content, tags)
.map_err(|error| CliError::Other(format!("failed to build repository update: {error}")))
.map(|builder| builder.custom_created_at(Timestamp::from(next_created_at)))
}
fn protection_rules_json(event: &Event) -> Result<serde_json::Value, CliError> {
let raw_tags: Vec<Vec<String>> = event
.tags
.iter()
.map(|tag| tag.as_slice().to_vec())
.collect();
let (unknown_rules, validation_error) = match parse_protection_tags(&raw_tags) {
Ok(parsed) => (parsed.unknown_rules, None),
Err(error) => (Vec::new(), Some(error.to_string())),
};
let protections: Vec<serde_json::Value> = event
.tags
.iter()
.filter_map(|tag| {
let values = tag.as_slice();
(values.first().map(String::as_str) == Some("buzz-protect")).then(|| {
serde_json::json!({
"ref": values.get(1).map(String::as_str).unwrap_or(""),
"rules": values.get(2..).unwrap_or_default(),
})
})
})
.collect();
Ok(serde_json::json!({
"repo_id": repo_id_from_event(event)?,
"protections": protections,
"unknown_rules": unknown_rules,
"validation_error": validation_error,
}))
}
fn validate_write_response(raw: &str) -> Result<String, CliError> {
parse_write_response(
raw,
"repository changed concurrently; fetch the latest rules and retry",
)
}
async fn submit_repo_update(client: &BuzzClient, builder: EventBuilder) -> Result<(), CliError> {
let event = client.sign_event(builder)?;
let raw = client.submit_event(event).await?;
println!("{}", validate_write_response(&raw)?);
Ok(())
}
/// Build the kind:30617 announcement for `repos create`, including the
/// `buzz-channel` binding when requested.
///
/// Pure (no I/O) so the emitted tags are unit-testable. Exactly one
/// validated `buzz-channel` tag is appended — the tag is the git ACL
/// (issue #3527: without it the relay 404s every clone/fetch/push), so the
/// UUID is shape-validated here and its existence/membership is the relay's
/// authority at git-access time, same posture as `repos bind`.
#[allow(clippy::too_many_arguments)]
fn build_create_announcement(
repo_id: &str,
name: Option<&str>,
description: Option<&str>,
clone_urls: &[String],
web_url: Option<&str>,
relays: &[String],
channel: Option<&str>,
) -> Result<EventBuilder, CliError> {
validate_repo_id(repo_id)?;
let clone_refs: Vec<&str> = clone_urls.iter().map(|s| s.as_str()).collect();
let relay_refs: Vec<&str> = relays.iter().map(|s| s.as_str()).collect();
let mut builder = buzz_sdk::build_repo_announcement(
repo_id,
name,
description,
&clone_refs,
web_url,
&relay_refs,
)
.map_err(|e| CliError::Other(format!("build_repo_announcement failed: {e}")))?;
if let Some(channel) = channel {
crate::validate::validate_uuid(channel)?;
builder = builder.tag(Tag::parse(["buzz-channel", channel]).map_err(tag_error)?);
}
Ok(builder)
}
#[allow(clippy::too_many_arguments)]
pub async fn cmd_create_repo(
client: &BuzzClient,
repo_id: &str,
name: Option<&str>,
description: Option<&str>,
clone_urls: &[String],
web_url: Option<&str>,
relays: &[String],
channel: Option<&str>,
) -> Result<(), CliError> {
let builder = build_create_announcement(
repo_id,
name,
description,
clone_urls,
web_url,
relays,
channel,
)?;
let event = client.sign_event(builder)?;
let owner = event.pubkey.to_hex();
let resp = client.submit_event(event).await?;
// `link` renders as a rich preview card in Buzz Desktop when included in
// a chat message — agents announce repos with it (see base_prompt.md).
let link = crate::links::repo_link(&owner, repo_id);
crate::client::print_create_response(&resp, "link", &link);
Ok(())
}
pub async fn cmd_get_repo(
client: &BuzzClient,
repo_id: &str,
owner: Option<&str>,
) -> Result<(), CliError> {
validate_repo_id(repo_id)?;
let mut filter = serde_json::json!({
"kinds": [30617],
"#d": [repo_id]
});
// If owner specified, filter by author pubkey; otherwise return any match.
// Note: without --owner, multiple repos with the same name (different owners) may be returned.
if let Some(pk) = owner {
crate::validate::validate_hex64(pk)?;
filter["authors"] = serde_json::json!([pk]);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_list_repos(
client: &BuzzClient,
owner: Option<&str>,
limit: Option<u32>,
) -> Result<(), CliError> {
// Default to self if no owner specified.
let pubkey = match owner {
Some(pk) => {
crate::validate::validate_hex64(pk)?;
pk.to_string()
}
None => client.keys().public_key().to_hex(),
};
let mut filter = serde_json::json!({
"kinds": [30617],
"authors": [pubkey]
});
if let Some(n) = limit {
filter["limit"] = serde_json::json!(n);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
async fn current_repo(client: &BuzzClient, repo_id: &str) -> Result<Event, CliError> {
validate_repo_id(repo_id)?;
fetch_own_repo_announcement(client, repo_id)
.await?
.ok_or_else(|| {
CliError::NotFound(format!(
"repository {repo_id:?} was not found for the current identity"
))
})
}
async fn cmd_protect_list(client: &BuzzClient, repo_id: &str) -> Result<(), CliError> {
let event = current_repo(client, repo_id).await?;
println!("{}", protection_rules_json(&event)?);
Ok(())
}
async fn cmd_protect_set(
client: &BuzzClient,
repo_id: &str,
ref_pattern: &str,
push_role: Option<crate::RepoPushRole>,
no_force_push: bool,
no_delete: bool,
require_patch: bool,
) -> Result<(), CliError> {
let push_role = push_role.map(|role| match role {
crate::RepoPushRole::Owner => "owner",
crate::RepoPushRole::Admin => "admin",
crate::RepoPushRole::Member => "member",
});
let tag = build_protection_tag(
ref_pattern,
push_role,
no_force_push,
no_delete,
require_patch,
)?;
let event = current_repo(client, repo_id).await?;
let builder =
build_updated_repo_announcement(&event, RepoChange::SetProtection(Box::new(tag)))?;
submit_repo_update(client, builder).await
}
async fn cmd_protect_remove(
client: &BuzzClient,
repo_id: &str,
ref_pattern: &str,
) -> Result<(), CliError> {
RefPattern::parse(ref_pattern)
.map_err(|error| CliError::Usage(format!("invalid ref pattern: {error}")))?;
let event = current_repo(client, repo_id).await?;
if !event
.tags
.iter()
.any(|tag| protection_pattern(tag) == Some(ref_pattern))
{
return Err(CliError::NotFound(format!(
"repository {repo_id:?} has no protection rule for {ref_pattern:?}"
)));
}
let builder = build_updated_repo_announcement(
&event,
RepoChange::RemoveProtection(ref_pattern.to_string()),
)?;
submit_repo_update(client, builder).await
}
/// Bind (or rebind) a repository to a channel — the fix path for issue
/// #3527's permanently-404 repos. Publishes a read-modify-write update of
/// the caller's own kind:30617 with exactly one `buzz-channel` tag; all
/// other metadata (protections, name, description, future tags) is
/// preserved by the same machinery `repos protect` uses.
///
/// The UUID is validated for *shape* only — deliberately. Channel existence
/// and the caller's membership are the relay's authority at git-access
/// time; a CLI-side network pre-check would just be TOCTOU with extra
/// latency.
async fn cmd_bind_repo(client: &BuzzClient, repo_id: &str, channel: &str) -> Result<(), CliError> {
let event = current_repo(client, repo_id).await?;
let builder =
build_updated_repo_announcement(&event, RepoChange::BindChannel(channel.to_string()))?;
submit_repo_update(client, builder).await
}
pub async fn dispatch(cmd: crate::ReposCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::{ReposCmd, ReposProtectCmd};
match cmd {
ReposCmd::Create {
id,
name,
description,
clone_urls,
web,
relays,
channel,
} => {
cmd_create_repo(
client,
&id,
name.as_deref(),
description.as_deref(),
&clone_urls,
web.as_deref(),
&relays,
channel.as_deref(),
)
.await
}
ReposCmd::Get { id, owner } => cmd_get_repo(client, &id, owner.as_deref()).await,
ReposCmd::List { owner, limit } => cmd_list_repos(client, owner.as_deref(), limit).await,
ReposCmd::Bind { id, channel } => cmd_bind_repo(client, &id, &channel).await,
ReposCmd::Protect(command) => match command {
ReposProtectCmd::List { id } => cmd_protect_list(client, &id).await,
ReposProtectCmd::Set {
id,
ref_pattern,
push,
no_force_push,
no_delete,
require_patch,
} => {
cmd_protect_set(
client,
&id,
&ref_pattern,
push,
no_force_push,
no_delete,
require_patch,
)
.await
}
ReposProtectCmd::Remove { id, ref_pattern } => {
cmd_protect_remove(client, &id, &ref_pattern).await
}
},
}
}
#[cfg(test)]
mod tests {
use nostr::{EventBuilder, Keys, Kind, Tag, Timestamp};
use super::{
build_create_announcement, build_protection_tag, build_updated_repo_announcement,
protection_rules_json, validate_write_response, RepoChange,
};
fn signed_repo(tags: Vec<Tag>, content: &str, created_at: u64) -> nostr::Event {
EventBuilder::new(Kind::Custom(30617), content)
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.sign_with_keys(&Keys::generate())
.expect("sign repository event")
}
fn tag(parts: &[&str]) -> Tag {
Tag::parse(parts.iter().copied()).expect("valid test tag")
}
#[test]
fn protection_update_preserves_metadata_and_replaces_only_matching_pattern() {
let existing = signed_repo(
vec![
tag(&["d", "demo"]),
tag(&["name", "Demo"]),
tag(&["buzz-channel", "channel-id"]),
tag(&["future-metadata", "preserve-me"]),
tag(&["auth", &"a".repeat(64), "kind=30617", &"b".repeat(128)]),
tag(&["buzz-protect", "refs/heads/main", "push:member"]),
tag(&["buzz-protect", "refs/tags/*", "no-delete"]),
],
"repository content",
100,
);
let replacement = build_protection_tag("refs/heads/main", Some("admin"), true, true, false)
.expect("valid replacement");
let updated = build_updated_repo_announcement(
&existing,
RepoChange::SetProtection(Box::new(replacement)),
)
.expect("build update")
.sign_with_keys(&Keys::generate())
.expect("sign update");
assert_eq!(updated.content, "repository content");
assert_eq!(updated.created_at.as_secs(), 101);
assert!(!updated
.tags
.iter()
.any(|tag| tag.as_slice().first().map(String::as_str) == Some("auth")));
assert!(updated
.tags
.iter()
.any(|tag| tag.as_slice() == ["buzz-channel", "channel-id"]));
assert!(updated
.tags
.iter()
.any(|tag| tag.as_slice() == ["future-metadata", "preserve-me"]));
assert!(updated.tags.iter().any(|tag| {
tag.as_slice()
== [
"buzz-protect",
"refs/heads/main",
"push:admin",
"no-force-push",
"no-delete",
]
}));
assert!(updated
.tags
.iter()
.any(|tag| { tag.as_slice() == ["buzz-protect", "refs/tags/*", "no-delete"] }));
assert_eq!(
updated
.tags
.iter()
.filter(|tag| {
let values = tag.as_slice();
values.first().map(String::as_str) == Some("buzz-protect")
&& values.get(1).map(String::as_str) == Some("refs/heads/main")
})
.count(),
1
);
}
#[test]
fn protection_remove_preserves_other_patterns() {
let existing = signed_repo(
vec![
tag(&["d", "demo"]),
tag(&["buzz-protect", "refs/heads/main", "no-delete"]),
tag(&["buzz-protect", "refs/heads/release", "push:owner"]),
],
"",
10,
);
let updated = build_updated_repo_announcement(
&existing,
RepoChange::RemoveProtection("refs/heads/main".into()),
)
.expect("build removal")
.sign_with_keys(&Keys::generate())
.expect("sign removal");
assert!(!updated
.tags
.iter()
.any(|tag| tag.as_slice().get(1).map(String::as_str) == Some("refs/heads/main")));
assert!(updated
.tags
.iter()
.any(|tag| { tag.as_slice() == ["buzz-protect", "refs/heads/release", "push:owner"] }));
}
#[test]
fn protection_set_requires_at_least_one_rule() {
assert!(build_protection_tag("refs/heads/main", None, false, false, false).is_err());
}
#[test]
fn protection_update_rejects_malformed_existing_rules() {
let existing = signed_repo(
vec![
tag(&["d", "demo"]),
tag(&["buzz-protect", "refs/heads/main"]),
],
"",
10,
);
let replacement =
build_protection_tag("refs/heads/release", Some("admin"), false, false, false)
.expect("valid replacement");
let error = build_updated_repo_announcement(
&existing,
RepoChange::SetProtection(Box::new(replacement)),
)
.expect_err("malformed existing rule must fail closed");
assert!(error
.to_string()
.contains("repository contains invalid protection rules"));
}
#[test]
fn protection_update_enforces_repository_rule_limit() {
let mut tags = vec![tag(&["d", "demo"])];
for index in 0..50 {
tags.push(tag(&[
"buzz-protect",
&format!("refs/heads/branch-{index}"),
"push:member",
]));
}
let existing = signed_repo(tags, "", 10);
let replacement =
build_protection_tag("refs/heads/main", Some("admin"), false, false, false)
.expect("valid replacement");
let error = build_updated_repo_announcement(
&existing,
RepoChange::SetProtection(Box::new(replacement)),
)
.expect_err("the 51st rule must be rejected");
assert!(error.to_string().contains("exceeds max 50"));
}
#[test]
fn protection_list_keeps_unknown_rules_visible() {
let existing = signed_repo(
vec![
tag(&["d", "demo"]),
tag(&[
"buzz-protect",
"refs/heads/main",
"push:admin",
"future-rule",
]),
],
"",
10,
);
let json = protection_rules_json(&existing).expect("list protections");
assert_eq!(json["repo_id"], "demo");
assert_eq!(json["protections"][0]["ref"], "refs/heads/main");
assert_eq!(
json["protections"][0]["rules"],
serde_json::json!(["push:admin", "future-rule"])
);
assert_eq!(json["validation_error"], serde_json::Value::Null);
}
#[test]
fn protection_list_surfaces_malformed_rules_for_recovery() {
let existing = signed_repo(
vec![
tag(&["d", "demo"]),
tag(&["buzz-protect", "refs/heads/main"]),
],
"",
10,
);
let json = protection_rules_json(&existing).expect("list malformed protections");
assert_eq!(json["protections"][0]["ref"], "refs/heads/main");
assert!(json["validation_error"]
.as_str()
.is_some_and(|error| error.contains("needs pattern + at least one rule")));
}
#[test]
fn bind_channel_replaces_duplicates_and_preserves_everything_else() {
let channel = uuid::Uuid::new_v4().to_string();
let existing = signed_repo(
vec![
tag(&["d", "demo"]),
tag(&["name", "Demo"]),
// Two stale bindings — e.g. from a buggy or vanilla client.
tag(&["buzz-channel", "old-and-broken"]),
tag(&["buzz-channel", &uuid::Uuid::new_v4().to_string()]),
tag(&["auth", &"a".repeat(64), "kind=30617", &"b".repeat(128)]),
tag(&["buzz-protect", "refs/heads/main", "push:admin"]),
tag(&["future-metadata", "preserve-me"]),
],
"repository content",
100,
);
let updated =
build_updated_repo_announcement(&existing, RepoChange::BindChannel(channel.clone()))
.expect("build bind update")
.sign_with_keys(&Keys::generate())
.expect("sign bind update");
assert_eq!(updated.content, "repository content");
assert_eq!(updated.created_at.as_secs(), 101);
// Exactly one binding remains, and it is the requested one.
let bindings: Vec<_> = updated
.tags
.iter()
.filter(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-channel"))
.collect();
assert_eq!(bindings.len(), 1);
assert_eq!(bindings[0].as_slice(), ["buzz-channel", channel.as_str()]);
// Auth stripped (relay re-stamps); everything else preserved.
assert!(!updated
.tags
.iter()
.any(|tag| tag.as_slice().first().map(String::as_str) == Some("auth")));
assert!(updated
.tags
.iter()
.any(|tag| tag.as_slice() == ["buzz-protect", "refs/heads/main", "push:admin"]));
assert!(updated
.tags
.iter()
.any(|tag| tag.as_slice() == ["future-metadata", "preserve-me"]));
assert!(updated
.tags
.iter()
.any(|tag| tag.as_slice() == ["name", "Demo"]));
}
#[test]
fn bind_channel_adds_binding_to_unbound_repo() {
let channel = uuid::Uuid::new_v4().to_string();
let existing = signed_repo(vec![tag(&["d", "demo"])], "", 10);
let updated =
build_updated_repo_announcement(&existing, RepoChange::BindChannel(channel.clone()))
.expect("build bind update")
.sign_with_keys(&Keys::generate())
.expect("sign bind update");
assert!(updated
.tags
.iter()
.any(|tag| tag.as_slice() == ["buzz-channel", channel.as_str()]));
}
#[test]
fn bind_channel_rejects_malformed_uuid() {
let existing = signed_repo(vec![tag(&["d", "demo"])], "", 10);
let error =
build_updated_repo_announcement(&existing, RepoChange::BindChannel("nope".into()))
.expect_err("malformed channel id must not build an update");
assert!(matches!(error, crate::error::CliError::Usage(_)));
}
/// Issue #3527: `repos create --channel` must emit exactly one
/// `buzz-channel` tag so the primary create command stops producing
/// repos the relay 404s forever.
#[test]
fn create_with_channel_emits_exactly_one_binding_tag() {
let channel = uuid::Uuid::new_v4().to_string();
let event = build_create_announcement(
"demo",
Some("Demo"),
None,
&["https://relay.example/git/owner/demo".to_string()],
None,
&[],
Some(&channel),
)
.expect("build create announcement")
.sign_with_keys(&Keys::generate())
.expect("sign create announcement");
assert_eq!(event.kind, Kind::Custom(30617));
let bindings: Vec<_> = event
.tags
.iter()
.filter(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-channel"))
.collect();
assert_eq!(bindings.len(), 1, "exactly one buzz-channel tag");
assert_eq!(bindings[0].as_slice(), ["buzz-channel", channel.as_str()]);
// The standard metadata still rides along.
assert!(event.tags.iter().any(|tag| tag.as_slice() == ["d", "demo"]));
assert!(event
.tags
.iter()
.any(|tag| tag.as_slice() == ["name", "Demo"]));
}
#[test]
fn create_without_channel_emits_no_binding_tag() {
let event = build_create_announcement("demo", None, None, &[], None, &[], None)
.expect("build create announcement")
.sign_with_keys(&Keys::generate())
.expect("sign create announcement");
assert!(
!event
.tags
.iter()
.any(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz-channel")),
"no --channel means no binding tag (vanilla NIP-34 stays possible)"
);
}
#[test]
fn create_rejects_malformed_channel_uuid() {
let error = build_create_announcement("demo", None, None, &[], None, &[], Some("nope"))
.expect_err("malformed channel id must not build an announcement");
assert!(matches!(error, crate::error::CliError::Usage(_)));
}
#[test]
fn duplicate_write_response_is_a_conflict() {
let error = validate_write_response(
r#"{"event_id":"abc","accepted":true,"message":"duplicate: superseded"}"#,
)
.expect_err("dominated writes must not report success");
assert!(matches!(error, crate::error::CliError::Conflict(_)));
}
#[test]
fn successful_write_response_is_normalized() {
let output = validate_write_response(
r#"{"event_id":"abc","accepted":true,"message":"saved","extra":"ignored"}"#,
)
.expect("accepted write");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&output).expect("normalized JSON"),
serde_json::json!({
"event_id": "abc",
"accepted": true,
"message": "saved",
})
);
}
}
+284
View File
@@ -0,0 +1,284 @@
use buzz_sdk::kind::{
KIND_BOOKMARK_LIST, KIND_BOOKMARK_SET, KIND_FOLLOW_SET, KIND_MUTE_LIST,
KIND_NIP65_RELAY_LIST_METADATA, KIND_PIN_LIST,
};
use nostr::{EventBuilder, Kind, Tag};
use serde::Deserialize;
use crate::client::{normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::{parse_event_id, validate_hex64};
/// A single contact entry (CLI-local, not from buzz-sdk).
#[derive(Debug, Deserialize)]
pub struct ContactEntry {
pub pubkey: String,
#[serde(default)]
pub relay_url: Option<String>,
#[serde(default)]
pub petname: Option<String>,
}
pub async fn cmd_publish_note(
client: &BuzzClient,
content: &str,
reply_to: Option<&str>,
) -> Result<(), CliError> {
if let Some(r) = reply_to {
validate_hex64(r)?;
}
let reply_id = reply_to.map(parse_event_id).transpose()?;
let builder = buzz_sdk::build_note(content, reply_id)
.map_err(|e| CliError::Other(format!("build error: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn cmd_set_contact_list(
client: &BuzzClient,
contacts_json: &str,
) -> Result<(), CliError> {
let entries: Vec<ContactEntry> = serde_json::from_str(contacts_json)
.map_err(|e| CliError::Usage(format!("invalid contacts JSON: {e}")))?;
let contacts: Vec<(&str, Option<&str>, Option<&str>)> = entries
.iter()
.map(|c| {
(
c.pubkey.as_str(),
c.relay_url.as_deref(),
c.petname.as_deref(),
)
})
.collect();
let builder = buzz_sdk::build_contact_list(&contacts)
.map_err(|e| CliError::Other(format!("build error: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Get a single event by ID via POST /query.
pub async fn cmd_get_event(client: &BuzzClient, event_id: &str) -> Result<(), CliError> {
validate_hex64(event_id)?;
let filter = serde_json::json!({
"ids": [event_id]
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
/// Get user notes (kind:1) by author pubkey.
pub async fn cmd_get_user_notes(
client: &BuzzClient,
pubkey: &str,
limit: Option<u32>,
before: Option<i64>,
before_id: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pubkey)?;
if let Some(bid) = before_id {
validate_hex64(bid)?;
}
let limit = limit.unwrap_or(50).min(100);
let mut filter = serde_json::json!({
"kinds": [1],
"authors": [pubkey],
"limit": limit
});
if let Some(b) = before {
filter["until"] = serde_json::json!(b);
}
if let Some(bid) = before_id {
filter["before_id"] = serde_json::json!(bid);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
/// Get a user's contact list (kind:3) by pubkey.
pub async fn cmd_get_contact_list(client: &BuzzClient, pubkey: &str) -> Result<(), CliError> {
validate_hex64(pubkey)?;
let filter = serde_json::json!({
"kinds": [3],
"authors": [pubkey],
"limit": 1
});
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
fn validate_social_list_kind(kind: u32) -> Result<(), CliError> {
match kind {
KIND_MUTE_LIST
| KIND_PIN_LIST
| KIND_NIP65_RELAY_LIST_METADATA
| KIND_BOOKMARK_LIST
| KIND_FOLLOW_SET
| KIND_BOOKMARK_SET => Ok(()),
_ => Err(CliError::Usage(format!(
"unsupported social list kind {kind}; supported kinds: 10000, 10001, 10002, 10003, 30000, 30003"
))),
}
}
fn is_parameterized_social_list_kind(kind: u32) -> bool {
matches!(kind, KIND_FOLLOW_SET | KIND_BOOKMARK_SET)
}
fn parse_tags_json(tags_json: &str) -> Result<Vec<Tag>, CliError> {
let raw_tags: Vec<Vec<String>> = serde_json::from_str(tags_json)
.map_err(|e| CliError::Usage(format!("invalid tags JSON: {e}")))?;
raw_tags
.iter()
.map(|parts| {
Tag::parse(parts.iter().map(String::as_str))
.map_err(|e| CliError::Usage(format!("invalid tag {parts:?}: {e}")))
})
.collect::<Result<_, _>>()
}
fn has_d_tag(tags: &[Tag]) -> bool {
tags.iter()
.any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("d"))
}
pub async fn cmd_set_list(
client: &BuzzClient,
kind: u16,
tags_json: &str,
content: &str,
) -> Result<(), CliError> {
let kind_u32 = u32::from(kind);
validate_social_list_kind(kind_u32)?;
let tags = parse_tags_json(tags_json)?;
if is_parameterized_social_list_kind(kind_u32) && !has_d_tag(&tags) {
return Err(CliError::Usage(format!(
"kind {kind} is parameterized replaceable and requires a d tag"
)));
}
let builder = EventBuilder::new(Kind::Custom(kind), content).tags(tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_get_list(
client: &BuzzClient,
pubkey: &str,
kind: u32,
d_tag: Option<&str>,
) -> Result<(), CliError> {
validate_hex64(pubkey)?;
validate_social_list_kind(kind)?;
if !is_parameterized_social_list_kind(kind) && d_tag.is_some() {
return Err(CliError::Usage(format!(
"kind {kind} is not parameterized; omit --d-tag"
)));
}
let mut filter = serde_json::json!({
"kinds": [kind],
"authors": [pubkey],
"limit": 10
});
if let Some(d) = d_tag {
filter["#d"] = serde_json::json!([d]);
}
let resp = client.query(&filter).await?;
println!("{resp}");
Ok(())
}
pub async fn dispatch(cmd: crate::SocialCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::SocialCmd;
match cmd {
SocialCmd::PublishNote { content, reply_to } => {
cmd_publish_note(client, &content, reply_to.as_deref()).await
}
SocialCmd::SetContactList { contacts } => cmd_set_contact_list(client, &contacts).await,
SocialCmd::GetEvent { event } => cmd_get_event(client, &event).await,
SocialCmd::GetUserNotes {
pubkey,
limit,
before,
before_id,
} => cmd_get_user_notes(client, &pubkey, limit, before, before_id.as_deref()).await,
SocialCmd::GetContactList { pubkey } => cmd_get_contact_list(client, &pubkey).await,
SocialCmd::SetList {
kind,
tags,
content,
} => cmd_set_list(client, kind, &tags, &content).await,
SocialCmd::GetList {
pubkey,
kind,
d_tag,
} => cmd_get_list(client, &pubkey, kind, d_tag.as_deref()).await,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn social_list_kind_validation_accepts_supported_kinds() {
for kind in [
KIND_MUTE_LIST,
KIND_PIN_LIST,
KIND_NIP65_RELAY_LIST_METADATA,
KIND_BOOKMARK_LIST,
KIND_FOLLOW_SET,
KIND_BOOKMARK_SET,
] {
assert!(validate_social_list_kind(kind).is_ok(), "kind {kind}");
}
}
#[test]
fn social_list_kind_validation_rejects_unsupported_kinds() {
let err = validate_social_list_kind(30002).unwrap_err();
assert!(
matches!(err, CliError::Usage(msg) if msg.contains("unsupported social list kind 30002"))
);
}
#[test]
fn parses_tags_json_and_detects_d_tag() {
let tags = parse_tags_json(r#"[["d","friends"],["p","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]]"#)
.expect("tags parse");
assert!(has_d_tag(&tags));
}
#[test]
fn malformed_tags_json_is_usage_error() {
let err = parse_tags_json("not json").unwrap_err();
assert!(matches!(err, CliError::Usage(msg) if msg.contains("invalid tags JSON")));
}
#[test]
fn parameterized_social_list_kind_detection() {
assert!(is_parameterized_social_list_kind(KIND_FOLLOW_SET));
assert!(is_parameterized_social_list_kind(KIND_BOOKMARK_SET));
assert!(!is_parameterized_social_list_kind(KIND_MUTE_LIST));
}
}
+36
View File
@@ -0,0 +1,36 @@
use crate::client::BuzzClient;
use crate::error::CliError;
pub async fn dispatch(cmd: crate::UploadCmd, client: &BuzzClient) -> Result<(), CliError> {
match cmd {
crate::UploadCmd::File { file } => {
let desc = client.upload_file(&file).await?;
println!(
"{}",
serde_json::to_string_pretty(&desc).map_err(|e| CliError::Other(e.to_string()))?
);
Ok(())
}
}
}
pub async fn dispatch_media(cmd: crate::MediaCmd, client: &BuzzClient) -> Result<(), CliError> {
match cmd {
crate::MediaCmd::Get { input, output } => {
let bytes = client.download_media(&input).await?;
match output.as_deref() {
Some(path) if path != "-" => {
std::fs::write(path, &bytes)
.map_err(|e| CliError::Other(format!("could not write {path}: {e}")))?;
}
_ => {
use std::io::Write;
std::io::stdout()
.write_all(&bytes)
.map_err(|e| CliError::Other(format!("could not write stdout: {e}")))?;
}
}
Ok(())
}
}
}
+765
View File
@@ -0,0 +1,765 @@
use buzz_core::kind::KIND_MANAGED_AGENT;
use nostr::PublicKey;
use crate::client::{extract_d_tag, normalize_write_response, BuzzClient};
use crate::error::CliError;
use crate::validate::validate_hex64;
// TODO(phase-4): Replace raw nostr::EventBuilder usage in cmd_set_presence with buzz-sdk builder
/// Get user profiles (kind:0 metadata events).
///
/// - 0 pubkeys, no name → query our own profile
/// - 1+ pubkeys → query those users' profiles
/// - --name "foo" → NIP-50 search on kind:0, then client-side filter
pub async fn cmd_get_users(
client: &BuzzClient,
pubkeys: &[String],
name: Option<&str>,
owner: Option<&str>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
if let Some(query) = name {
if !pubkeys.is_empty() {
return Err(CliError::Usage(
"--name and --pubkey are mutually exclusive".into(),
));
}
return search_by_name(client, query, owner, format).await;
}
if owner.is_some() {
return Err(CliError::Usage("--owner requires --name".into()));
}
for pk in pubkeys {
validate_hex64(pk)?;
}
if pubkeys.len() > 200 {
return Err(CliError::Usage("--pubkey: maximum 200 pubkeys".into()));
}
let my_pk = client.keys().public_key().to_hex();
let authors: Vec<&str> = if pubkeys.is_empty() {
vec![my_pk.as_str()]
} else {
pubkeys.iter().map(|s| s.as_str()).collect()
};
let filter = serde_json::json!({
"kinds": [0],
"authors": authors,
"limit": authors.len()
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let profiles: Vec<serde_json::Value> = events
.iter()
.filter_map(|e| {
let content_str = e.get("content")?.as_str()?;
let mut profile: serde_json::Value = serde_json::from_str(content_str).ok()?;
if let Some(obj) = profile.as_object_mut() {
obj.insert(
"pubkey".to_string(),
serde_json::json!(e.get("pubkey").and_then(|v| v.as_str()).unwrap_or("")),
);
}
Some(profile)
})
.collect();
let output = match format {
crate::OutputFormat::Compact => {
let compact: Vec<serde_json::Value> = profiles
.iter()
.map(|p| serde_json::json!({
"pubkey": p.get("pubkey").cloned().unwrap_or_default(),
"display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(),
}))
.collect();
serde_json::to_string(&compact).unwrap_or_default()
}
crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(),
};
println!("{output}");
Ok(())
}
fn effective_owner(client: &BuzzClient) -> String {
client
.auth_tag_owner_hex()
.unwrap_or_else(|| client.keys().public_key().to_hex())
}
fn resolve_owner(client: &BuzzClient, owner: Option<&str>) -> Result<Option<String>, CliError> {
owner
.map(|owner| {
if owner == "me" {
Ok(effective_owner(client))
} else {
PublicKey::parse(owner)
.map(|pubkey| pubkey.to_hex())
.map_err(|e| {
CliError::Usage(format!("--owner must be `me`, a pubkey, or npub: {e}"))
})
}
})
.transpose()
}
fn owned_agent_pubkeys_from_events(events: &[serde_json::Value], query: &str) -> Vec<String> {
let mut pubkeys: Vec<String> = events
.iter()
.filter_map(|event| {
let content: serde_json::Value =
serde_json::from_str(event.get("content")?.as_str()?).ok()?;
let name = content.get("name")?.as_str()?;
if !name.eq_ignore_ascii_case(query) {
return None;
}
let pubkey = extract_d_tag(event);
(!pubkey.is_empty()).then_some(pubkey)
})
.collect();
pubkeys.sort();
pubkeys.dedup();
pubkeys
}
async fn owned_agent_pubkeys_by_name(
client: &BuzzClient,
owner: &str,
query: &str,
) -> Result<Vec<String>, CliError> {
let filter = serde_json::json!({
"kinds": [KIND_MANAGED_AGENT],
"authors": [owner],
});
let events = client.query_all(filter).await?;
Ok(owned_agent_pubkeys_from_events(&events, query))
}
fn profile_content(event: &serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
event
.get("content")
.and_then(|value| value.as_str())
.and_then(|content| serde_json::from_str::<serde_json::Value>(content).ok())
.and_then(|content| content.as_object().cloned())
.unwrap_or_default()
}
fn name_search_profiles(events: &[serde_json::Value], query: &str) -> Vec<serde_json::Value> {
let lower_query = query.to_ascii_lowercase();
events
.iter()
.filter_map(|event| {
let mut profile = profile_content(event);
let display_name = profile
.get("display_name")
.and_then(|value| value.as_str())
.unwrap_or("");
let name = profile
.get("name")
.and_then(|value| value.as_str())
.unwrap_or("");
if !display_name.to_ascii_lowercase().contains(&lower_query)
&& !name.to_ascii_lowercase().contains(&lower_query)
{
return None;
}
profile.insert(
"pubkey".to_string(),
serde_json::json!(event
.get("pubkey")
.and_then(|value| value.as_str())
.unwrap_or("")),
);
Some(serde_json::Value::Object(profile))
})
.collect()
}
fn auth_tag_values(event: &serde_json::Value) -> Vec<&serde_json::Value> {
event
.get("tags")
.and_then(|tags| tags.as_array())
.into_iter()
.flatten()
.filter(|tag| {
tag.as_array()
.and_then(|values| values.first())
.and_then(|value| value.as_str())
== Some("auth")
})
.collect()
}
fn auth_conditions_apply(auth_tag: &serde_json::Value, event: &serde_json::Value) -> bool {
let Some(conditions) = auth_tag
.as_array()
.and_then(|values| values.get(2))
.and_then(|value| value.as_str())
else {
return false;
};
let Some(kind) = event.get("kind").and_then(|value| value.as_u64()) else {
return false;
};
let Some(created_at) = event.get("created_at").and_then(|value| value.as_u64()) else {
return false;
};
conditions.split('&').all(|clause| {
if let Some(value) = clause.strip_prefix("kind=") {
value.parse::<u64>() == Ok(kind)
} else if let Some(value) = clause.strip_prefix("created_at<") {
value.parse::<u64>().is_ok_and(|bound| created_at < bound)
} else if let Some(value) = clause.strip_prefix("created_at>") {
value.parse::<u64>().is_ok_and(|bound| created_at > bound)
} else {
clause.is_empty()
}
})
}
fn owner_verification(event: &serde_json::Value, expected_owner: &str) -> &'static str {
let Some(agent_pubkey) = event
.get("pubkey")
.and_then(|value| value.as_str())
.and_then(|value| PublicKey::parse(value).ok())
else {
return "invalid_agent_pubkey";
};
let auth_tags = auth_tag_values(event);
let [auth_tag] = auth_tags.as_slice() else {
return if auth_tags.is_empty() {
"missing_auth"
} else {
"multiple_auth_tags"
};
};
let Ok(auth_tag_json) = serde_json::to_string(auth_tag) else {
return "invalid_auth";
};
match buzz_sdk::nip_oa::verify_auth_tag(&auth_tag_json, &agent_pubkey) {
Ok(owner) if owner.to_hex() != expected_owner => "owner_mismatch",
Ok(_) if !auth_conditions_apply(auth_tag, event) => "condition_mismatch",
Ok(_) => "verified",
Err(_) => "invalid_auth",
}
}
fn owner_scoped_profiles(
events: &[serde_json::Value],
pubkeys: &[String],
owner: &str,
effective_owner: &str,
) -> Vec<serde_json::Value> {
pubkeys
.iter()
.map(|pubkey| {
let event = events.iter().find(|event| {
event.get("pubkey").and_then(|value| value.as_str()) == Some(pubkey.as_str())
});
let mut profile = event.map(profile_content).unwrap_or_default();
let verification = if PublicKey::parse(pubkey).is_err() {
"invalid_agent_pubkey"
} else {
event
.map(|event| owner_verification(event, owner))
.unwrap_or("missing_profile")
};
profile.insert("pubkey".to_string(), serde_json::json!(pubkey));
profile.insert("verification".to_string(), serde_json::json!(verification));
profile.insert(
"owned_by_me".to_string(),
serde_json::json!(verification == "verified" && owner == effective_owner),
);
if verification == "verified" {
profile.insert("owner_pubkey".to_string(), serde_json::json!(owner));
}
serde_json::Value::Object(profile)
})
.collect()
}
/// Search for users by display name. Owner-scoped searches resolve managed-agent records
/// and verify their profiles; unscoped searches use NIP-50 and return [] if unsupported.
async fn search_by_name(
client: &BuzzClient,
query: &str,
owner: Option<&str>,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
if query.trim().is_empty() {
return Err(CliError::Usage("--name cannot be empty".into()));
}
let owner = resolve_owner(client, owner)?;
let profiles = if let Some(owner) = owner {
let pubkeys = owned_agent_pubkeys_by_name(client, &owner, query).await?;
if pubkeys.is_empty() {
println!("[]");
return Ok(());
}
let valid_pubkeys: Vec<&String> = pubkeys
.iter()
.filter(|pubkey| PublicKey::parse(pubkey.as_str()).is_ok())
.collect();
let events = if valid_pubkeys.is_empty() {
Vec::new()
} else {
let filter = serde_json::json!({
"kinds": [0],
"authors": valid_pubkeys,
"limit": valid_pubkeys.len(),
});
let raw = client.query(&filter).await?;
serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse response: {e}")))?
};
owner_scoped_profiles(&events, &pubkeys, &owner, &effective_owner(client))
} else {
let filter = serde_json::json!({
"kinds": [0],
"search": query,
"limit": 100
});
let raw = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse response: {e}")))?;
name_search_profiles(&events, query)
};
let output = match format {
crate::OutputFormat::Compact => {
let compact: Vec<serde_json::Value> = profiles
.iter()
.map(|p| {
let mut value = serde_json::json!({
"pubkey": p.get("pubkey").cloned().unwrap_or_default(),
"display_name": p.get("display_name").or_else(|| p.get("name")).cloned().unwrap_or_default(),
});
if let Some(obj) = value.as_object_mut() {
for field in ["owner_pubkey", "owned_by_me", "verification"] {
if let Some(field_value) = p.get(field) {
obj.insert(field.to_string(), field_value.clone());
}
}
}
value
})
.collect();
serde_json::to_string(&compact).unwrap_or_default()
}
crate::OutputFormat::Json => serde_json::to_string(&profiles).unwrap_or_default(),
};
println!("{output}");
Ok(())
}
pub async fn cmd_set_profile(
client: &BuzzClient,
display_name: Option<&str>,
avatar_url: Option<&str>,
about: Option<&str>,
nip05_handle: Option<&str>,
) -> Result<(), CliError> {
if display_name.is_none() && avatar_url.is_none() && about.is_none() && nip05_handle.is_none() {
return Err(CliError::Usage(
"at least one field required (--name, --avatar, --about, --nip05)".into(),
));
}
// Read-merge-write: fetch current profile, merge in the new fields, then sign.
let current = fetch_current_profile(client).await?;
// Merge: caller-supplied fields win; fall back to current profile values.
let merged_name = display_name
.map(|s| s.to_string())
.or_else(|| {
current
.get("display_name")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
})
.or_else(|| {
current
.get("name")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
let merged_picture = avatar_url.map(|s| s.to_string()).or_else(|| {
current
.get("picture")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
let merged_about = about.map(|s| s.to_string()).or_else(|| {
current
.get("about")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
let merged_nip05 = nip05_handle.map(|s| s.to_string()).or_else(|| {
current
.get("nip05")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
});
let builder = buzz_sdk::build_profile(
merged_name.as_deref(),
None, // `name` field (username) — not exposed by CLI
merged_picture.as_deref(),
merged_about.as_deref(),
merged_nip05.as_deref(),
)
.map_err(|e| CliError::Other(format!("build_profile failed: {e}")))?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Fetch the current user's profile metadata via POST /query (kind:0).
/// Returns the parsed content JSON object, or an empty object if no profile exists.
async fn fetch_current_profile(
client: &BuzzClient,
) -> Result<serde_json::Map<String, serde_json::Value>, CliError> {
let my_pk = client.keys().public_key().to_hex();
let filter = serde_json::json!({
"kinds": [0],
"authors": [my_pk],
"limit": 1
});
let raw = client.query(&filter).await?;
let events: serde_json::Value = serde_json::from_str(&raw)
.map_err(|e| CliError::Other(format!("failed to parse profile query: {e}")))?;
let Some(arr) = events.as_array() else {
return Ok(serde_json::Map::new());
};
let Some(event) = arr.first() else {
return Ok(serde_json::Map::new());
};
// kind:0 content is a JSON string containing the profile fields
let content_str = event
.get("content")
.and_then(|c| c.as_str())
.unwrap_or("{}");
let content: serde_json::Value = serde_json::from_str(content_str).unwrap_or_default();
Ok(content.as_object().cloned().unwrap_or_default())
}
/// Get presence status for users — query kind:40902 presence snapshot events.
pub async fn cmd_get_presence(client: &BuzzClient, pubkeys_csv: &str) -> Result<(), CliError> {
let pubkeys: Vec<&str> = pubkeys_csv
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.collect();
for pk in &pubkeys {
validate_hex64(pk)?;
}
let filter = serde_json::json!({
"kinds": [40902],
"authors": pubkeys,
"limit": pubkeys.len()
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let presence: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"pubkey": presence_subject(e),
"status": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
"updated_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
})
})
.collect();
let output = serde_json::to_string(&presence).unwrap_or_default();
println!("{output}");
Ok(())
}
fn presence_subject(event: &serde_json::Value) -> &str {
event
.get("tags")
.and_then(|tags| tags.as_array())
.and_then(|tags| {
tags.iter()
.find_map(|tag| match tag.as_array()?.as_slice() {
[name, subject, ..] if name == "p" => subject.as_str(),
_ => None,
})
})
.unwrap_or_else(|| event.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""))
}
/// Set presence status — sign and submit a kind:20001 presence update event via WebSocket.
///
/// Kind 20001 is ephemeral and only accepted via WebSocket connections. This
/// method connects to the relay over WS, performs NIP-42 authentication, and
/// publishes the event directly — bypassing the HTTP bridge.
pub async fn cmd_set_presence(client: &BuzzClient, status: &str) -> Result<(), CliError> {
let builder = buzz_sdk::build_presence_update(status).map_err(crate::validate::sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.publish_ephemeral_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Set user status — sign and submit a NIP-38 kind:30315 user status event.
///
/// Uses the `d:general` coordinate that the desktop client reads for the
/// profile status line. A blank `text` with no `emoji` clears the status.
pub async fn cmd_set_status(
client: &BuzzClient,
text: &str,
emoji: Option<&str>,
) -> Result<(), CliError> {
let builder = buzz_sdk::build_user_status(text, emoji).map_err(crate::validate::sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn dispatch(
cmd: crate::UsersCmd,
client: &BuzzClient,
format: &crate::OutputFormat,
) -> Result<(), CliError> {
use crate::UsersCmd;
match cmd {
UsersCmd::Get {
pubkeys,
name,
owner,
} => cmd_get_users(client, &pubkeys, name.as_deref(), owner.as_deref(), format).await,
UsersCmd::SetProfile {
name,
avatar,
about,
nip05,
} => {
cmd_set_profile(
client,
name.as_deref(),
avatar.as_deref(),
about.as_deref(),
nip05.as_deref(),
)
.await
}
UsersCmd::Presence { pubkeys } => cmd_get_presence(client, &pubkeys).await,
UsersCmd::SetPresence { status } => cmd_set_presence(client, &status.to_string()).await,
UsersCmd::SetStatus { text, emoji, clear } => {
// `--clear` is mutually exclusive with `--text`/`--emoji`: publish the
// empty `d:general` event that clients read as "no status".
let (text, emoji) = if clear {
("", None)
} else {
(text.as_deref().unwrap_or_default(), emoji.as_deref())
};
cmd_set_status(client, text, emoji).await
}
}
}
#[cfg(test)]
mod tests {
use super::{
owned_agent_pubkeys_from_events, owner_scoped_profiles, owner_verification,
presence_subject,
};
use nostr::Keys;
use serde_json::json;
#[test]
fn owned_agent_lookup_matches_exact_name_case_insensitively() {
let events = vec![
json!({"content": r#"{"name":"Honey"}"#, "tags": [["d", "b"]]}),
json!({"content": r#"{"name":"Honeybee"}"#, "tags": [["d", "c"]]}),
json!({"content": r#"{"name":"honey"}"#, "tags": [["d", "a"]]}),
];
assert_eq!(
owned_agent_pubkeys_from_events(&events, "Honey"),
vec!["a", "b"]
);
}
#[test]
fn owned_agent_lookup_ignores_malformed_events() {
let events = vec![
json!({"content": "not json", "tags": [["d", "a"]]}),
json!({"content": r#"{"name":"Honey"}"#, "tags": [["p", "b"]]}),
];
assert!(owned_agent_pubkeys_from_events(&events, "Honey").is_empty());
}
fn profile_event(agent_keys: &Keys, auth_tags: Vec<serde_json::Value>) -> serde_json::Value {
json!({
"pubkey": agent_keys.public_key().to_hex(),
"kind": 0,
"created_at": 100,
"content": r#"{"display_name":"Renamed Honey"}"#,
"tags": auth_tags,
})
}
#[test]
fn owner_verification_requires_one_valid_auth_tag_for_requested_owner() {
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let foreign_owner_keys = Keys::generate();
let valid_tag: serde_json::Value = serde_json::from_str(
&buzz_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "kind=0")
.unwrap(),
)
.unwrap();
let foreign_tag: serde_json::Value = serde_json::from_str(
&buzz_sdk::nip_oa::compute_auth_tag(
&foreign_owner_keys,
&agent_keys.public_key(),
"kind=9",
)
.unwrap(),
)
.unwrap();
assert_eq!(
owner_verification(
&profile_event(&agent_keys, vec![valid_tag.clone()]),
&owner_keys.public_key().to_hex(),
),
"verified"
);
assert_eq!(
owner_verification(
&profile_event(&agent_keys, vec![foreign_tag]),
&owner_keys.public_key().to_hex(),
),
"owner_mismatch"
);
assert_eq!(
owner_verification(
&profile_event(&agent_keys, vec![]),
&owner_keys.public_key().to_hex()
),
"missing_auth"
);
assert_eq!(
owner_verification(
&profile_event(&agent_keys, vec![valid_tag.clone(), valid_tag]),
&owner_keys.public_key().to_hex(),
),
"multiple_auth_tags"
);
assert_eq!(
owner_verification(
&profile_event(
&agent_keys,
vec![json!([
"auth",
owner_keys.public_key().to_hex(),
"kind=9",
"0".repeat(128)
])],
),
&owner_keys.public_key().to_hex(),
),
"invalid_auth"
);
}
#[test]
fn owner_verification_requires_conditions_to_apply_to_profile_event() {
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let verification = |conditions: &str| {
let auth_tag: serde_json::Value = serde_json::from_str(
&buzz_sdk::nip_oa::compute_auth_tag(
&owner_keys,
&agent_keys.public_key(),
conditions,
)
.unwrap(),
)
.unwrap();
owner_verification(
&profile_event(&agent_keys, vec![auth_tag]),
&owner_keys.public_key().to_hex(),
)
};
assert_eq!(verification("kind=9"), "condition_mismatch");
assert_eq!(verification("created_at<100"), "condition_mismatch");
assert_eq!(verification("created_at>100"), "condition_mismatch");
assert_eq!(
verification("kind=0&created_at>99&created_at<101"),
"verified"
);
}
#[test]
fn owner_scoped_profiles_keep_drifted_and_missing_profiles_without_claiming_ownership() {
let owner_keys = Keys::generate();
let agent_keys = Keys::generate();
let missing_keys = Keys::generate();
let auth_tag: serde_json::Value = serde_json::from_str(
&buzz_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "kind=0")
.unwrap(),
)
.unwrap();
let events = vec![profile_event(&agent_keys, vec![auth_tag])];
let pubkeys = vec![
agent_keys.public_key().to_hex(),
missing_keys.public_key().to_hex(),
"malformed".to_string(),
];
let profiles = owner_scoped_profiles(
&events,
&pubkeys,
&owner_keys.public_key().to_hex(),
&owner_keys.public_key().to_hex(),
);
assert_eq!(profiles[0]["display_name"], "Renamed Honey");
assert_eq!(profiles[0]["verification"], "verified");
assert_eq!(profiles[0]["owned_by_me"], true);
assert_eq!(
profiles[0]["owner_pubkey"],
owner_keys.public_key().to_hex()
);
assert_eq!(profiles[1]["verification"], "missing_profile");
assert_eq!(profiles[1]["owned_by_me"], false);
assert!(profiles[1].get("owner_pubkey").is_none());
assert_eq!(profiles[2]["verification"], "invalid_agent_pubkey");
assert_eq!(profiles[2]["owned_by_me"], false);
assert!(profiles[2].get("owner_pubkey").is_none());
}
#[test]
fn presence_subject_uses_p_tag() {
let event = json!({"pubkey": "relay", "tags": [["p", "user"]]});
assert_eq!(presence_subject(&event), "user");
}
#[test]
fn presence_subject_falls_back_to_author_without_p_tag() {
let event = json!({"pubkey": "user", "tags": [["status", "online"]]});
assert_eq!(presence_subject(&event), "user");
}
#[test]
fn presence_subject_falls_back_to_author_for_malformed_p_tag() {
let event = json!({"pubkey": "user", "tags": [["p"]]});
assert_eq!(presence_subject(&event), "user");
}
}
+243
View File
@@ -0,0 +1,243 @@
use sha2::{Digest, Sha256};
use crate::client::{
extract_d_tag, extract_relay_response_field, normalize_write_response, print_create_response,
BuzzClient,
};
use crate::error::CliError;
use crate::validate::{parse_uuid, read_or_stdin, sdk_err, validate_uuid};
// TODO(phase-4): Replace raw nostr::EventBuilder usage with buzz-sdk builder functions
/// List workflows in a channel — query kind:30620 workflow definition events.
pub async fn cmd_list_workflows(client: &BuzzClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let filter = serde_json::json!({
"kinds": [30620],
"#h": [channel_id]
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let workflows: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"workflow_id": extract_d_tag(e),
"content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
"pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""),
})
})
.collect();
let output = serde_json::to_string(&workflows).unwrap_or_default();
println!("{output}");
Ok(())
}
/// Get a single workflow definition.
pub async fn cmd_get_workflow(client: &BuzzClient, workflow_id: &str) -> Result<(), CliError> {
validate_uuid(workflow_id)?;
let filter = serde_json::json!({
"kinds": [30620],
"#d": [workflow_id]
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
if let Some(e) = events.first() {
let normalized = serde_json::json!({
"workflow_id": extract_d_tag(e),
"content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
"pubkey": e.get("pubkey").and_then(|v| v.as_str()).unwrap_or(""),
});
println!("{normalized}");
} else {
println!("null");
}
Ok(())
}
/// Get workflow run history — query kinds [46001, 46002, 46003].
///
/// NOTE: The relay does not currently emit workflow execution events (46001-46003).
/// Run history is stored in the workflow_runs DB table, not as Nostr events.
/// This command will return an empty array until the relay adds event emission
/// or a dedicated REST endpoint for run history.
pub async fn cmd_get_workflow_runs(
client: &BuzzClient,
workflow_id: &str,
limit: Option<u32>,
) -> Result<(), CliError> {
validate_uuid(workflow_id)?;
let limit = limit.unwrap_or(20).min(100);
let filter = serde_json::json!({
"kinds": [46001, 46002, 46003],
"#d": [workflow_id],
"limit": limit
});
let resp = client.query(&filter).await?;
let events: Vec<serde_json::Value> = serde_json::from_str(&resp).unwrap_or_default();
let normalized: Vec<serde_json::Value> = events
.iter()
.map(|e| {
serde_json::json!({
"event_id": e.get("id").and_then(|v| v.as_str()).unwrap_or(""),
"kind": e.get("kind").and_then(|v| v.as_u64()).unwrap_or(0),
"content": e.get("content").and_then(|v| v.as_str()).unwrap_or(""),
"created_at": e.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0),
"tags": e.get("tags").cloned().unwrap_or(serde_json::json!([])),
})
})
.collect();
let output = serde_json::to_string(&normalized).unwrap_or_default();
println!("{output}");
Ok(())
}
/// Create a workflow — sign and submit a kind:30620 event.
pub async fn cmd_create_workflow(
client: &BuzzClient,
channel_id: &str,
yaml: &str,
) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let yaml_definition = read_or_stdin(yaml)?;
let workflow_id = uuid::Uuid::new_v4();
let builder = buzz_sdk::build_workflow_def(channel_uuid, workflow_id, &yaml_definition)
.map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
let final_workflow_id = extract_relay_response_field(&resp, "workflow_id")
.unwrap_or_else(|| workflow_id.to_string());
print_create_response(&resp, "workflow_id", &final_workflow_id);
Ok(())
}
/// Update a workflow — sign and submit an updated kind:30620 event with same d-tag.
pub async fn cmd_update_workflow(
client: &BuzzClient,
channel_id: &str,
workflow_id: &str,
yaml: &str,
) -> Result<(), CliError> {
let channel_uuid = parse_uuid(channel_id)?;
let wf_uuid = parse_uuid(workflow_id)?;
let yaml_definition = read_or_stdin(yaml)?;
let builder = buzz_sdk::build_workflow_update(channel_uuid, wf_uuid, &yaml_definition)
.map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Delete a workflow — sign and submit a kind:5 deletion event.
pub async fn cmd_delete_workflow(client: &BuzzClient, workflow_id: &str) -> Result<(), CliError> {
let wf_uuid = parse_uuid(workflow_id)?;
let keys = client.keys();
let builder =
buzz_sdk::build_workflow_delete(&keys.public_key().to_hex(), wf_uuid).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
/// Trigger a workflow — sign and submit a kind:46020 event.
///
/// When `inputs` is provided, it is parsed as a JSON object and used as the
/// event content (MCP parity). When omitted, the event content is `{}`.
pub async fn cmd_trigger_workflow(
client: &BuzzClient,
workflow_id: &str,
inputs: Option<&str>,
) -> Result<(), CliError> {
let wf_uuid = parse_uuid(workflow_id)?;
if let Some(raw) = inputs {
// Parse and validate it is a JSON object, then build the event manually
// so we can embed the inputs as the event content.
let parsed: serde_json::Value = serde_json::from_str(raw)
.map_err(|e| CliError::Usage(format!("--inputs is not valid JSON: {e}")))?;
if !parsed.is_object() {
return Err(CliError::Usage("--inputs must be a JSON object".into()));
}
let content = serde_json::to_string(&parsed).unwrap_or_default();
use nostr::{EventBuilder, Kind, Tag};
let tags = vec![Tag::parse(["d", &wf_uuid.to_string()])
.map_err(|e| CliError::Other(format!("tag error: {e}")))?];
let builder = EventBuilder::new(
Kind::Custom(buzz_sdk::kind::KIND_WORKFLOW_TRIGGER as u16),
&content,
)
.tags(tags);
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
} else {
let builder = buzz_sdk::build_workflow_trigger(wf_uuid).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
}
Ok(())
}
/// Approve or deny a workflow step — sign and submit a kind:46030 (grant) or 46031 (deny) event.
pub async fn cmd_approve_step(
client: &BuzzClient,
approval_token: &str,
approved: bool,
note: Option<&str>,
) -> Result<(), CliError> {
validate_uuid(approval_token)?;
let content = note.unwrap_or("");
// The relay expects d-tag = hex(SHA256(token)), not the raw token UUID.
let token_hash = hex::encode(Sha256::digest(approval_token.as_bytes()));
let builder =
buzz_sdk::build_workflow_approval(&token_hash, approved, content).map_err(sdk_err)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{}", normalize_write_response(&resp));
Ok(())
}
pub async fn dispatch(cmd: crate::WorkflowsCmd, client: &BuzzClient) -> Result<(), CliError> {
use crate::WorkflowsCmd;
match cmd {
WorkflowsCmd::List { channel } => cmd_list_workflows(client, &channel).await,
WorkflowsCmd::Get { workflow } => cmd_get_workflow(client, &workflow).await,
WorkflowsCmd::Create { channel, yaml } => {
cmd_create_workflow(client, &channel, &yaml).await
}
WorkflowsCmd::Update {
channel,
workflow,
yaml,
} => cmd_update_workflow(client, &channel, &workflow, &yaml).await,
WorkflowsCmd::Delete { workflow } => cmd_delete_workflow(client, &workflow).await,
WorkflowsCmd::Trigger { workflow, inputs } => {
cmd_trigger_workflow(client, &workflow, inputs.as_deref()).await
}
WorkflowsCmd::Runs { workflow, limit } => {
cmd_get_workflow_runs(client, &workflow, limit).await
}
WorkflowsCmd::Approve {
token,
approved,
note,
} => {
// approved is already a bool — no parse_bool_flag needed
cmd_approve_step(client, &token, approved, note.as_deref()).await
}
}
}
+315
View File
@@ -0,0 +1,315 @@
use thiserror::Error;
use crate::i18n::{self, Locale};
#[derive(Debug, Error)]
pub enum CliError {
/// Invalid argument or flag value — user error
#[error("{0}")]
Usage(String),
/// Relay returned a non-2xx response
#[error("relay error {status}: {body}")]
Relay { status: u16, body: String },
/// Network-level failure (connect, timeout, DNS)
#[error("network error: {}", fmt_reqwest_error(.0))]
Network(#[from] reqwest::Error),
/// Auth missing or rejected (401/403)
#[error("auth error: {0}")]
Auth(String),
/// Nostr key error (NIP-98 signing in `buzz auth`)
#[error("key error: {0}")]
Key(String),
/// Relay accepted the event but reported it as superseded by a newer
/// head — used by `buzz mem` set/rm to surface NIP-33 LWW conflicts.
#[error("conflict: {0}")]
Conflict(String),
/// Requested resource was absent or tombstoned (e.g. `buzz mem get`
/// for a slug with no head).
#[error("{0}")]
NotFound(String),
/// A non-idempotent command's outcome is unknown: the request may have
/// reached the relay, but the response was lost. Never auto-retried and
/// never labeled retryable — the relay executes these commands before any
/// dedup, so a blind re-run can duplicate the mutation.
#[error("delivery unknown: {0}")]
DeliveryUnknown(String),
/// Catch-all for unexpected failures
#[error("{0}")]
Other(String),
}
/// Walk the full `std::error::Error::source()` chain on a `reqwest::Error`
/// and render it as a colon-separated string, e.g.
/// `error sending request: dns error: failed to lookup address information: ...`
fn fmt_reqwest_error(e: &reqwest::Error) -> String {
let mut msg = e.to_string();
let mut source: &dyn std::error::Error = e;
while let Some(cause) = source.source() {
let cause_str = cause.to_string();
if !msg.contains(&cause_str) {
msg.push_str(": ");
msg.push_str(&cause_str);
}
source = cause;
}
msg
}
/// Returns `true` when the error is transient and a retry may succeed.
///
/// Transport-level network errors (connect failure, timeout, mid-request,
/// mid-body transfer, or body decode failure) and relay overload responses
/// (429 / 502 / 503 / 504) are retryable. `DeliveryUnknown` is never
/// retryable: the operation may already have executed. All other errors
/// indicate a permanent failure: auth, bad input, builder errors, or logic
/// errors.
pub fn is_retryable_error(e: &CliError) -> bool {
match e {
CliError::Network(ref net_err) => {
net_err.is_connect()
|| net_err.is_timeout()
|| net_err.is_request()
|| net_err.is_body()
|| net_err.is_decode()
}
CliError::Relay { status, .. } => matches!(status, 429 | 502 | 503 | 504),
CliError::DeliveryUnknown(_) => false,
_ => false,
}
}
/// Map CliError to process exit code.
/// 0=success (not an error), 1=user/not-found, 2=network/relay, 3=auth,
/// 4=other, 5=write conflict (NIP-33 dominated head).
pub fn exit_code(e: &CliError) -> i32 {
match e {
CliError::Usage(_) => 1,
CliError::Relay { status, .. } => {
if *status == 401 || *status == 403 {
3
} else {
2
}
}
CliError::Network(_) => 2,
CliError::Auth(_) => 3,
CliError::Key(_) => 3,
CliError::Conflict(_) => 5,
CliError::NotFound(_) => 1,
CliError::DeliveryUnknown(_) => 2,
CliError::Other(_) => 4,
}
}
/// Serialize an error to the stable JSON envelope and localize only its
/// human-readable message when the caller selected Chinese. The category,
/// retryability flag, and all machine-readable fields remain byte-for-byte
/// compatible with the existing CLI contract.
pub fn print_error_with_locale(e: &CliError, locale: Locale) {
let category = match e {
CliError::Usage(_) => "user_error",
CliError::Relay { status, .. } => {
if *status == 401 || *status == 403 {
"auth_error"
} else {
"relay_error"
}
}
CliError::Network(_) => "network_error",
CliError::Auth(_) => "auth_error",
CliError::Key(_) => "key_error",
CliError::Conflict(_) => "conflict",
CliError::NotFound(_) => "not_found",
CliError::DeliveryUnknown(_) => "delivery_unknown",
CliError::Other(_) => "error",
};
let message = localized_message(e, locale, category);
let obj = serde_json::json!({
"error": category,
"message": message,
"retryable": is_retryable_error(e),
});
eprintln!("{}", obj);
}
fn localized_message(e: &CliError, locale: Locale, category: &str) -> String {
if !locale.is_chinese() {
return e.to_string();
}
let detail = match e {
CliError::Usage(message)
| CliError::Auth(message)
| CliError::Key(message)
| CliError::Conflict(message)
| CliError::NotFound(message)
| CliError::DeliveryUnknown(message)
| CliError::Other(message) => message.as_str(),
CliError::Relay { status, body } => {
let prefix = if matches!(category, "auth_error") {
i18n::error_prefix(locale, "auth")
} else {
i18n::error_prefix(locale, "relay")
};
return format!("{prefix} {status}: {body}");
}
CliError::Network(error) => {
let rendered = fmt_reqwest_error(error);
return format!("{}: {rendered}", i18n::error_prefix(locale, "network"));
}
};
// Keep server/user-provided details intact. Only the stable category label
// is translated, avoiding accidental changes to protocol names or paths.
let prefix_key = match category {
"user_error" => "usage",
"auth_error" => "auth",
"key_error" => "key",
other => other,
};
format!("{}: {detail}", i18n::error_prefix(locale, prefix_key))
}
#[cfg(test)]
mod tests {
use super::*;
// ---- is_retryable_error ----
#[test]
fn network_builder_errors_are_not_retryable() {
// A bad URL produces a builder-level reqwest::Error (is_builder() == true).
// Builder errors are not transport failures — not retryable.
// Transport errors (is_connect/timeout/request) require live I/O to construct;
// the predicate here mirrors with_retry's condition exactly.
let e = reqwest::Client::new().get("not-a-url").build().unwrap_err();
assert!(e.is_builder(), "expected a builder error from bad URL");
assert!(!is_retryable_error(&CliError::Network(e)));
}
#[test]
fn relay_429_502_503_504_are_retryable() {
for status in [429u16, 502, 503, 504] {
assert!(
is_retryable_error(&CliError::Relay {
status,
body: String::new()
}),
"status {status} should be retryable"
);
}
}
#[test]
fn relay_400_401_403_404_422_are_not_retryable() {
for status in [400u16, 401, 403, 404, 422] {
assert!(
!is_retryable_error(&CliError::Relay {
status,
body: String::new()
}),
"status {status} should not be retryable"
);
}
}
#[test]
fn other_errors_are_not_retryable() {
assert!(!is_retryable_error(&CliError::Usage("bad flag".into())));
assert!(!is_retryable_error(&CliError::Auth("missing key".into())));
assert!(!is_retryable_error(&CliError::Key("bad key".into())));
assert!(!is_retryable_error(&CliError::Conflict(
"superseded".into()
)));
assert!(!is_retryable_error(&CliError::NotFound("gone".into())));
assert!(!is_retryable_error(&CliError::Other("unexpected".into())));
}
// ---- print_error "retryable" field ----
#[test]
fn json_error_includes_retryable_field_for_network() {
// Builder errors (bad URL) are not transport-level — retryable: false.
// This test verifies the JSON shape and that the field is present.
let e = reqwest::Client::new().get("not-a-url").build().unwrap_err();
let err = CliError::Network(e);
let v = serde_json::json!({
"error": "network_error",
"message": err.to_string(),
"retryable": is_retryable_error(&err),
});
assert_eq!(v["retryable"].as_bool(), Some(false));
assert_eq!(v["error"].as_str(), Some("network_error"));
}
#[test]
fn json_error_retryable_false_for_usage() {
let err = CliError::Usage("bad flag".into());
let v = serde_json::json!({
"error": "user_error",
"message": err.to_string(),
"retryable": is_retryable_error(&err),
});
assert_eq!(v["retryable"].as_bool(), Some(false));
}
// ---- Display source-chain ----
#[test]
fn network_display_includes_detail_beyond_prefix() {
let e = reqwest::Client::new().get("not-a-url").build().unwrap_err();
let display = CliError::Network(e).to_string();
assert!(
display.starts_with("network error:"),
"display should start with 'network error:': {display}"
);
assert!(
display.len() > "network error: ".len(),
"display should contain error detail: {display}"
);
}
#[test]
fn localized_message_translates_only_the_category_prefix() {
let err = CliError::Usage("invalid channel UUID".into());
assert_eq!(
localized_message(&err, Locale::ZhHans, "user_error"),
"用法错误: invalid channel UUID"
);
assert_eq!(
localized_message(&err, Locale::En, "user_error"),
err.to_string()
);
let auth = CliError::Auth("missing key".into());
assert_eq!(
localized_message(&auth, Locale::ZhHans, "auth_error"),
"认证错误: missing key"
);
let key = CliError::Key("bad nsec".into());
assert_eq!(
localized_message(&key, Locale::ZhHans, "key_error"),
"密钥错误: bad nsec"
);
}
#[test]
fn localized_relay_message_keeps_status_and_server_body() {
let err = CliError::Relay {
status: 403,
body: "p-gate: kinds required".into(),
};
assert_eq!(
localized_message(&err, Locale::ZhHans, "auth_error"),
"认证错误 403: p-gate: kinds required"
);
}
}
+837
View File
@@ -0,0 +1,837 @@
//! CLI 人类可读文本的语言选择与最小翻译层。
//!
//! 协议值、命令名、JSON 键和用户输入不经过这里。语言只影响帮助文本、
//! 错误前缀以及少量本地命令的状态提示;这样脚本继续可以依赖原有的
//! `--format json|compact` 输出。
use std::ffi::OsString;
use std::sync::{Mutex, OnceLock};
use clap::ValueEnum;
/// CLI 界面语言。
///
/// `auto`(默认)读取 `BUZZ_LANGUAGE`、`LC_ALL`、`LC_MESSAGES` 或 `LANG`。
/// 没有可识别的 locale 时使用英文,以保持现有脚本和部署环境的行为。
#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
pub enum Language {
/// 根据环境变量选择语言。
#[value(name = "auto")]
Auto,
/// English.
#[value(name = "en", alias = "en-us", alias = "en-gb")]
English,
/// 简体中文。
#[value(name = "zh", alias = "zh-cn", alias = "zh-hans")]
SimplifiedChinese,
/// 繁體中文。
#[value(name = "zh-tw", alias = "zh-hant")]
TraditionalChinese,
}
impl Default for Language {
fn default() -> Self {
Self::Auto
}
}
/// 已解析的界面 locale。
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Locale {
En,
ZhHans,
ZhHant,
}
impl Locale {
/// 返回该 locale 是否为中文变体。
pub const fn is_chinese(self) -> bool {
matches!(self, Self::ZhHans | Self::ZhHant)
}
}
impl Language {
/// 将命令行值解析为语言。该函数比 clap 更宽松,供预扫描 `--help`
/// 时使用;真正的参数校验仍由 clap 完成。
pub fn parse_lossy(value: &str) -> Self {
match value.trim().to_ascii_lowercase().replace('_', "-").as_str() {
"zh" | "zh-cn" | "zh-hans" | "zh-sg" => Self::SimplifiedChinese,
"zh-tw" | "zh-hant" | "zh-hk" | "zh-mo" => Self::TraditionalChinese,
"en" | "en-us" | "en-gb" | "en-au" | "en-ca" => Self::English,
"auto" | "" => Self::Auto,
_ => Self::Auto,
}
}
/// 根据当前环境解析语言。显式的 `BUZZ_LANGUAGE` 优先于系统 locale。
pub fn from_environment() -> Self {
std::env::var("BUZZ_LANGUAGE")
.ok()
.map_or(Self::Auto, |value| Self::parse_lossy(&value))
}
/// 解析语言;`auto` 会继续读取系统 locale。
pub fn resolve(self) -> Locale {
match self {
Self::English => Locale::En,
Self::SimplifiedChinese => Locale::ZhHans,
Self::TraditionalChinese => Locale::ZhHant,
Self::Auto => detect_system_locale(),
}
}
}
/// 预扫描原始参数,确保 `buzz --language zh --help` 在 clap 解析帮助错误
/// 之前也能使用中文命令说明。无效值交给 clap 报错,这里回退到环境语言。
pub fn locale_for_args(args: &[OsString]) -> Locale {
let mut requested = Language::from_environment();
let mut expect_value = false;
for arg in args.iter().skip(1) {
let value = arg.to_string_lossy();
if expect_value {
requested = Language::parse_lossy(&value);
expect_value = false;
continue;
}
if value == "--language" {
expect_value = true;
} else if let Some(value) = value.strip_prefix("--language=") {
requested = Language::parse_lossy(value);
}
}
requested.resolve()
}
fn detect_system_locale() -> Locale {
// POSIX/CI environments normally expose one of these. Windows shells may
// not set them; in that case English remains the compatibility default.
for key in ["LC_ALL", "LC_MESSAGES", "LANGUAGE", "LANG"] {
let Ok(value) = std::env::var(key) else {
continue;
};
let normalized = value.trim().to_ascii_lowercase().replace('_', "-");
if normalized.starts_with("zh-tw")
|| normalized.starts_with("zh-hant")
|| normalized.starts_with("zh-hk")
|| normalized.starts_with("zh-mo")
{
return Locale::ZhHant;
}
if normalized.starts_with("zh") {
return Locale::ZhHans;
}
// The first recognized non-Chinese locale wins. This prevents a stale
// `LANGUAGE=zh` from being overridden by a generic `LANG=C` value.
if !normalized.is_empty() && normalized != "c" && normalized != "posix" {
return Locale::En;
}
}
Locale::En
}
static CURRENT_LOCALE: OnceLock<Mutex<Locale>> = OnceLock::new();
fn locale_cell() -> &'static Mutex<Locale> {
CURRENT_LOCALE.get_or_init(|| Mutex::new(Locale::En))
}
/// 设置本次进程使用的语言。CLI 只有一个运行上下文,但用 Mutex 让它在
/// `buzz-dev-mcp` 嵌入并重复调用 CLI 时仍然安全。
pub fn set_current(locale: Locale) {
if let Ok(mut current) = locale_cell().lock() {
*current = locale;
}
}
/// 读取当前语言,供本地命令的少量人类提示使用。
pub fn current() -> Locale {
locale_cell()
.lock()
.map(|guard| *guard)
.unwrap_or(Locale::En)
}
/// 对 clap 命令树应用高频命令的简体/繁体中文说明。
///
/// 命令名和参数名刻意保持英文,避免破坏现有脚本;未列出的深层说明
/// 继续显示上游英文,后续可按命令逐步补齐翻译表。
pub fn localize_command(command: &mut clap::Command, locale: Locale) {
if !locale.is_chinese() {
return;
}
localize_node(command, locale, &mut Vec::new());
}
fn localize_node(command: &mut clap::Command, locale: Locale, path: &mut Vec<String>) {
let name = command.get_name().to_owned();
if name != "buzz" {
path.push(name);
}
if let Some(about) = command_about(path, locale) {
// `about` consumes Command. Cloning here keeps the recursive traversal
// simple and only runs once at process startup.
*command = command.clone().about(about);
}
let command_key = path.join(" ");
if let Some(after_help) = command_after_help(&command_key, locale) {
*command = command.clone().after_help(after_help);
}
// `mut_args` is the public clap API for changing argument metadata after
// derive has built the command tree. Only the help text is changed; the
// argument id, long/short flag, parser, defaults and conflict rules stay
// byte-for-byte compatible with the English command.
*command = command.clone().mut_args(|arg| {
let id = arg.get_id().as_str();
argument_help(&command_key, id, locale).map_or(arg.clone(), |help| arg.help(help))
});
if path.is_empty() {
let long_about = match locale {
Locale::ZhHant => TOP_LEVEL_LONG_HANT,
Locale::ZhHans => TOP_LEVEL_LONG_HANS,
Locale::En => return,
};
*command = command.clone().long_about(long_about);
}
for subcommand in command.get_subcommands_mut() {
localize_node(subcommand, locale, path);
}
if !path.is_empty() {
path.pop();
}
}
/// Remove clap's English `error:` marker before it is placed in the stable CLI
/// JSON error envelope. The detail itself is intentionally left untouched:
/// it can contain flag names, enum values, paths, or relay-provided text that
/// must not be translated. `print_error_with_locale` adds the localized
/// `用法错误`/`用法錯誤` prefix exactly once.
pub fn clap_error_detail(error: &clap::Error, locale: Locale) -> String {
let rendered = error.to_string();
if !locale.is_chinese() {
return rendered;
}
rendered
.strip_prefix("error: ")
.unwrap_or(&rendered)
.to_owned()
}
fn command_after_help(path: &str, locale: Locale) -> Option<&'static str> {
let translated = match locale {
Locale::ZhHans => match path {
"messages send" => "示例:\n buzz messages send --channel <UUID> --content \"hello\"\n buzz messages send --channel <UUID> --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel <UUID> --content -",
"messages get" => "示例:\n buzz messages get --channel <UUID>\n buzz messages get --channel <UUID> --limit 50 --kinds 1,1984",
"messages search" => "示例:\n buzz messages search --query checkout\n buzz messages search --author npub1... --since 1783497600\n buzz messages search --author Aaron --query checkout --limit 20",
"channels list" => "示例:\n buzz channels list\n buzz channels list --visibility open",
"channels search" => "示例:\n buzz channels search --query composer\n buzz channels search --query buzz-chat-composer --exact\n buzz channels search --query design --include-archived",
"channels create" => "示例:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name design --type forum --visibility open --description \"Design discussions\"\n buzz channels create --name standup --type stream --visibility open --ttl 3600 # 临时频道,空闲 1 小时后归档\n buzz channels create --name project-x --template \"Buzz Team\" # 模板提供 type/visibility/canvas/roster;显式参数优先",
"workflows trigger" => "示例:\n buzz workflows trigger --workflow <UUID>\n buzz workflows trigger --workflow <UUID> --inputs '{\"key\":\"value\"}'",
"workflows approve" => "示例:\n buzz workflows approve --token <UUID>\n buzz workflows approve --token <UUID> --approved false --note \"needs revision\"",
"notes set" => "示例:\n echo '# Hello' | buzz notes set --name hello --title 'Hello' --content -\n buzz notes set --name hello --tag onboarding --content - < draft.md",
"patches send" => "示例:\n git format-patch -1 HEAD --stdout | buzz patches send --repo-owner <hex> --repo-id myrepo --patch-file - --root\n buzz patches send --repo-owner <hex> --repo-id myrepo --patch-file 0001-fix.patch --reply-to <prev-patch-id>",
"pr open" => "示例:\n buzz pr open --repo-owner <hex> --repo-id myrepo --subject 'Fix bug' --body-file - --commit $(git rev-parse HEAD) --clone https://relay/git/owner/myrepo --branch-name fix-bug\n buzz pr update --repo-owner <hex> --repo-id myrepo --pr <event> --pr-author <hex> --commit $(git rev-parse HEAD) --clone https://relay/git/owner/myrepo",
"moderation reports" => "示例:\n buzz moderation reports\n buzz moderation reports --status open --limit 20",
"moderation resolve" => "示例:\n buzz moderation resolve --report <REPORT_EVENT_ID> --status dismissed --action dismiss\n buzz moderation resolve --report <REPORT_EVENT_ID> --status resolved --action ban --reason \"rule 3\"",
"moderation ban" => "示例:\n buzz moderation ban --pubkey <HEX>\n buzz moderation ban --pubkey <HEX> --expires-in 604800 --reason \"repeated spam\"",
"moderation timeout" => "示例:\n buzz moderation timeout --pubkey <HEX> --expires-in 3600\n buzz moderation timeout --pubkey <HEX> --expires-at 1783500000 --reason \"cool off\"",
_ => return None,
},
Locale::ZhHant => match path {
"messages send" => "範例:\n buzz messages send --channel <UUID> --content \"hello\"\n buzz messages send --channel <UUID> --content \"@alice check this\"\n echo \"hello from stdin\" | buzz messages send --channel <UUID> --content -",
"messages get" => "範例:\n buzz messages get --channel <UUID>\n buzz messages get --channel <UUID> --limit 50 --kinds 1,1984",
"messages search" => "範例:\n buzz messages search --query checkout\n buzz messages search --author npub1... --since 1783497600\n buzz messages search --author Aaron --query checkout --limit 20",
"channels list" => "範例:\n buzz channels list\n buzz channels list --visibility open",
"channels search" => "範例:\n buzz channels search --query composer\n buzz channels search --query buzz-chat-composer --exact\n buzz channels search --query design --include-archived",
"channels create" => "範例:\n buzz channels create --name general --type stream --visibility open\n buzz channels create --name design --type forum --visibility open --description \"Design discussions\"\n buzz channels create --name standup --type stream --visibility open --ttl 3600 # 臨時頻道,閒置 1 小時後封存\n buzz channels create --name project-x --template \"Buzz Team\" # 範本提供 type/visibility/canvas/roster;明確參數優先",
"workflows trigger" => "範例:\n buzz workflows trigger --workflow <UUID>\n buzz workflows trigger --workflow <UUID> --inputs '{\"key\":\"value\"}'",
"workflows approve" => "範例:\n buzz workflows approve --token <UUID>\n buzz workflows approve --token <UUID> --approved false --note \"needs revision\"",
"notes set" => "範例:\n echo '# Hello' | buzz notes set --name hello --title 'Hello' --content -\n buzz notes set --name hello --tag onboarding --content - < draft.md",
"patches send" => "範例:\n git format-patch -1 HEAD --stdout | buzz patches send --repo-owner <hex> --repo-id myrepo --patch-file - --root\n buzz patches send --repo-owner <hex> --repo-id myrepo --patch-file 0001-fix.patch --reply-to <prev-patch-id>",
"pr open" => "範例:\n buzz pr open --repo-owner <hex> --repo-id myrepo --subject 'Fix bug' --body-file - --commit $(git rev-parse HEAD) --clone https://relay/git/owner/myrepo --branch-name fix-bug\n buzz pr update --repo-owner <hex> --repo-id myrepo --pr <event> --pr-author <hex> --commit $(git rev-parse HEAD) --clone https://relay/git/owner/myrepo",
"moderation reports" => "範例:\n buzz moderation reports\n buzz moderation reports --status open --limit 20",
"moderation resolve" => "範例:\n buzz moderation resolve --report <REPORT_EVENT_ID> --status dismissed --action dismiss\n buzz moderation resolve --report <REPORT_EVENT_ID> --status resolved --action ban --reason \"rule 3\"",
"moderation ban" => "範例:\n buzz moderation ban --pubkey <HEX>\n buzz moderation ban --pubkey <HEX> --expires-in 604800 --reason \"repeated spam\"",
"moderation timeout" => "範例:\n buzz moderation timeout --pubkey <HEX> --expires-in 3600\n buzz moderation timeout --pubkey <HEX> --expires-at 1783500000 --reason \"cool off\"",
_ => return None,
},
Locale::En => return None,
};
Some(translated)
}
/// Translate the most frequently used argument descriptions. The map is
/// intentionally conservative: protocol literals (`kind`, `NIP-*`, JSON),
/// command/flag names and user supplied examples remain unchanged.
fn argument_help(path: &str, id: &str, locale: Locale) -> Option<&'static str> {
if !locale.is_chinese() {
return None;
}
let hans = match (path, id) {
("", "relay") => "中继 URLhttp:// 或 https://);覆盖 BUZZ_RELAY_URL 环境变量。",
("", "private_key") => "Nostr 私钥(hex 或 nsec),作为 CLI 身份。",
("", "auth_tag") => "NIP-OA 认证标签 JSON(所有签名事件都会注入)。",
("", "format") => "输出格式:json(默认,完整字段)或 compact(精简字段)。",
("", "language") => {
"人类可读界面语言;auto 遵循 BUZZ_LANGUAGE/LANG。命令名、协议值和 JSON 输出保持不变。"
}
("messages send", "channel") => "频道 UUID(来自 `buzz channels list`)。",
("messages send", "content") => {
"消息文本,支持 @提及和 Markdown;使用 `-` 从标准输入读取。"
}
("messages send", "kind") => "Nostr 事件 kind(默认使用频道默认值)。",
("messages send", "reply_to") => "要回复的事件 ID(创建话题)。",
("messages send", "broadcast") => "同时发布到 Nostr 网络。",
("messages send", "files") => "附加文件;上传后作为 imeta 标签包含在事件中。",
("messages send", "mentions") => "要提及的公钥(hex 或 npub,可重复)。",
("messages get", "channel") | ("messages thread", "channel") => "频道 UUID。",
("messages get", "limit") | ("messages thread", "limit") => "最多返回的结果数。",
("messages get", "before") => "Unix 时间戳:返回此时间之前的消息。",
("messages get", "since") | ("messages search", "since") => {
"Unix 时间戳:返回此时间之后的消息。"
}
("messages get", "kinds") => "以逗号分隔的事件 kind(例如 1,1984)。",
("messages thread", "event") => "根消息事件 ID64 位十六进制)。",
("messages thread", "depth_limit") => "包含的最大回复嵌套深度。",
("messages search", "query") => "搜索查询(提供 --author 时可省略)。",
("messages search", "author") => "按作者筛选:64 位十六进制公钥、npub 或显示名。",
("messages search", "limit") => "最多返回的结果数。",
("channels list", "visibility") => "按可见性筛选。",
("channels list", "member") => "仅显示当前身份为成员的频道。",
("channels list", "limit") => "最多返回的频道数(默认:500)。",
("channels get", "channel") => "频道 UUID。",
("channels search", "query") => "搜索频道名称(不区分大小写的子串)。",
("channels search", "exact") => "要求完全匹配,而不是子串匹配。",
("channels search", "include_archived") => "在结果中包含已归档频道。",
("channels search", "limit") => "从中继获取的频道元数据事件上限。",
("channels create", "name") => "频道名称。",
("channels create", "channel_type") => "频道类型;除非 --template 提供,否则必填。",
("channels create", "visibility") => "频道可见性;除非 --template 提供,否则必填。",
("channels create", "description") => "频道描述。",
("channels create", "ttl") => {
"临时频道生命周期(秒);无新消息经过该时长后中继会归档频道。"
}
("channels create", "template") => {
"按名称应用桌面本地频道模板;提供默认 type/visibility/description/canvas,并解析成员。"
}
("channels create", "templates_file") => {
"覆盖 channel-templates.json 路径(默认使用桌面应用生产数据目录)。"
}
("channels update", "channel") => "频道 UUID。",
("channels update", "name") => "新的频道名称。",
("channels update", "description") => "新的频道描述。",
("channels update", "ttl") => "设置或修改临时频道生命周期(秒);与 --no-ttl 冲突。",
("channels update", "no_ttl") => "清除现有 TTL,使频道永久保留。",
("channels topic", "channel") | ("channels purpose", "channel") => "频道 UUID。",
("channels topic", "topic") => "新的话题文本。",
("channels purpose", "purpose") => "新的频道用途文本。",
("channels join", "channel")
| ("channels leave", "channel")
| ("channels archive", "channel")
| ("channels unarchive", "channel")
| ("channels delete", "channel")
| ("channels members", "channel") => "频道 UUID。",
("channels add-member", "channel") | ("channels remove-member", "channel") => "频道 UUID。",
("channels add-member", "pubkey") | ("channels remove-member", "pubkey") => {
"成员公钥(64 位十六进制)。"
}
("channels add-member", "role") => "成员角色(owner、admin、member、guest、bot)。",
("channels set-add-policy", "policy") => "策略:anyone | owner_only | nobody。",
("users get", "pubkeys") => "要查询的用户公钥(64 位十六进制);省略则查询自己的资料。",
("users get", "name") => "按显示名搜索(不区分大小写的子串匹配)。",
("users get", "owner") => "将精确名称的智能体查询限定到其所有者(me、hex 或 npub)。",
("users set-profile", "name") => "显示名称。",
("users set-profile", "avatar") => "头像 URL。",
("users set-profile", "about") => "简介文本。",
("users set-profile", "nip05") => "NIP-05 标识(例如 user@example.com)。",
("users presence", "pubkeys") => "以逗号分隔的公钥(64 位十六进制)。",
("users set-presence", "status") => "在线状态(online/away/offline)。",
("users set-status", "text") => "状态文本(除非使用 --clear,否则必填)。",
("users set-status", "emoji") => "显示在状态文本前的可选表情。",
("users set-status", "clear") => "完全移除当前状态。",
("workflows list", "channel") => "频道 UUID。",
("workflows get", "workflow")
| ("workflows delete", "workflow")
| ("workflows trigger", "workflow")
| ("workflows runs", "workflow") => "工作流 UUID。",
("workflows create", "channel") | ("workflows update", "channel") => "频道 UUID。",
("workflows create", "yaml") | ("workflows update", "yaml") => "工作流 YAML 定义。",
("workflows trigger", "inputs") => "作为事件内容传给工作流的输入变量 JSON 对象。",
("workflows runs", "limit") => "最多返回的结果数。",
("workflows approve", "token") => "审批请求中的审批令牌 UUID。",
("workflows approve", "approved") => "批准(true)或拒绝(false)该步骤。",
("workflows approve", "note") => "附加到批准/拒绝操作的可选备注。",
("pack validate", "path") | ("pack inspect", "path") => "persona 包目录路径。",
("moderation reports", "status") => {
"按状态筛选:open | resolved | dismissed | escalated(默认:全部)。"
}
("moderation reports", "limit") | ("moderation audit", "limit") => "最多返回的审核记录数。",
("moderation resolve", "report") => "待处理的 kind:1984 举报事件 ID(十六进制)。",
("moderation resolve", "status") => "处理状态:resolved | dismissed。",
("moderation resolve", "action") => {
"执行的操作:delete | kick | ban | timeout | dismiss | escalate。"
}
("moderation resolve", "reason") => "可选原因(会转发给举报者,请勿包含墓碑敏感内容)。",
("moderation ban", "pubkey")
| ("moderation unban", "pubkey")
| ("moderation timeout", "pubkey")
| ("moderation untimeout", "pubkey") => "目标成员公钥(hex)。",
("moderation ban", "expires_in") | ("moderation timeout", "expires_in") => {
"从现在起的时长(秒);省略表示永久。"
}
("moderation ban", "expires_at") | ("moderation timeout", "expires_at") => {
"Unix 时间戳形式的绝对到期时间(秒)。"
}
("moderation ban", "reason") | ("moderation timeout", "reason") => {
"可选的私有原因(仅用于审核记录)。"
}
("dms list", "limit") => "最多返回的结果数。",
("dms open", "pubkeys") => "要加入私信的用户公钥(64 位十六进制,1–8 个)。",
("dms add-member", "channel") | ("dms hide", "channel") => "私信会话 UUID。",
("dms add-member", "pubkey") => "要添加的用户公钥(64 位十六进制)。",
("emoji set", "shortcode") | ("emoji rm", "shortcode") => "表情短代码(不含两侧冒号)。",
("emoji set", "url") => "表情图片 URL。",
("emoji export", "file") => "写入此文件,而不是标准输出。",
("emoji export", "scope") => "导出自己的集合(默认)或整个工作区表情板。",
("emoji import", "file") => "从此文件读取 JSON,而不是标准输入。",
("emoji import", "replace") => "替换整个集合,而不是合并。",
("emoji import", "dry_run") => "仅打印将要发布的内容,不写入中继。",
("mem ls", "owner")
| ("mem get", "owner")
| ("mem hash", "owner")
| ("mem set", "owner")
| ("mem patch", "owner")
| ("mem rm", "owner") => "所有者公钥(hex);覆盖 BUZZ_AUTH_TAG。",
("mem ls", "agent") | ("mem get", "agent") | ("mem hash", "agent") => {
"作为该所有者读取的智能体公钥(hex)。"
}
("mem ls", "json") => "输出 JSON,而不是制表符分隔的行。",
("mem set", "allow_empty") | ("mem patch", "allow_empty") => {
"允许提交空值;默认拒绝以防止上游管道静默丢失数据。"
}
("mem patch", "patch_file") => "从文件读取补丁,而不是标准输入。",
("mem patch", "base_hash") => "生成补丁所依据值的 sha256 十六进制摘要。",
("mem patch", "no_base_hash") => "跳过 base-hash 检查;并发编辑时请谨慎。",
("mem patch", "dry_run") => "回显输入补丁及结果 sha256,然后退出而不写入。",
_ => return argument_help_generic(path, id, locale),
};
if matches!(locale, Locale::ZhHans) {
Some(hans)
} else {
argument_help_hant(path, id).or_else(|| argument_help_generic(path, id, locale))
}
}
/// Traditional-Chinese counterparts for the same high-frequency options. A
/// smaller table is sufficient here because the generic fallback below keeps
/// all protocol/user values intact while still translating common labels.
fn argument_help_hant(path: &str, id: &str) -> Option<&'static str> {
Some(match (path, id) {
("", "relay") => "中繼 URLhttp:// 或 https://);覆寫 BUZZ_RELAY_URL 環境變數。",
("", "private_key") => "Nostr 私鑰(hex 或 nsec),作為 CLI 身分。",
("", "auth_tag") => "NIP-OA 認證標籤 JSON(所有簽署事件都會注入)。",
("", "format") => "輸出格式:json(預設,完整欄位)或 compact(精簡欄位)。",
("", "language") => {
"人類可讀介面語言;auto 遵循 BUZZ_LANGUAGE/LANG。命令名稱、協定值和 JSON 輸出保持不變。"
}
("messages send", "channel") => "頻道 UUID(來自 `buzz channels list`)。",
("messages send", "content") => {
"訊息文字,支援 @提及和 Markdown;使用 `-` 從標準輸入讀取。"
}
("messages send", "reply_to") => "要回覆的事件 ID(建立話題)。",
("messages send", "broadcast") => "同時發佈到 Nostr 網路。",
("messages send", "files") => "附加檔案;上傳後作為 imeta 標籤包含在事件中。",
("messages send", "mentions") => "要提及的公鑰(hex 或 npub,可重複)。",
("messages get", "channel") | ("messages thread", "channel") => "頻道 UUID。",
("messages get", "limit") | ("messages thread", "limit") => "最多回傳的結果數。",
("messages search", "query") => "搜尋查詢(提供 --author 時可省略)。",
("messages search", "author") => "按作者篩選:64 位十六進位公鑰、npub 或顯示名稱。",
("channels list", "visibility") => "按可見性篩選。",
("channels list", "member") => "僅顯示目前身分為成員的頻道。",
("channels create", "name") => "頻道名稱。",
("channels create", "channel_type") => "頻道類型;除非 --template 提供,否則必填。",
("channels create", "visibility") => "頻道可見性;除非 --template 提供,否則必填。",
("channels create", "description") => "頻道說明。",
("channels create", "ttl") => {
"臨時頻道生命週期(秒);無新訊息經過該時長後中繼會封存頻道。"
}
("channels create", "template") => {
"按名稱套用桌面本地頻道範本;提供預設 type/visibility/description/canvas,並解析成員。"
}
("users get", "pubkeys") => "要查詢的使用者公鑰(64 位十六進位);省略則查詢自己的資料。",
("users get", "name") => "按顯示名稱搜尋(不區分大小寫的子字串比對)。",
("users get", "owner") => "將精確名稱的智能體查詢限定到其擁有者(me、hex 或 npub)。",
("users set-profile", "name") => "顯示名稱。",
("users set-profile", "avatar") => "頭像 URL。",
("users set-profile", "about") => "簡介文字。",
("users set-profile", "nip05") => "NIP-05 識別碼(例如 user@example.com)。",
("users presence", "pubkeys") => "以逗號分隔的公鑰(64 位十六進位)。",
("users set-presence", "status") => "在線狀態(online/away/offline)。",
("users set-status", "text") => "狀態文字(除非使用 --clear,否則必填)。",
("users set-status", "emoji") => "顯示在狀態文字前的可選表情。",
("users set-status", "clear") => "完全移除目前狀態。",
("workflows list", "channel") => "頻道 UUID。",
("workflows get", "workflow")
| ("workflows delete", "workflow")
| ("workflows trigger", "workflow")
| ("workflows runs", "workflow") => "工作流 UUID。",
("workflows create", "channel") | ("workflows update", "channel") => "頻道 UUID。",
("workflows create", "yaml") | ("workflows update", "yaml") => "工作流 YAML 定義。",
("workflows trigger", "inputs") => "作為事件內容傳給工作流的輸入變數 JSON 物件。",
("workflows approve", "token") => "核准請求中的核准權杖 UUID。",
("workflows approve", "approved") => "核准(true)或拒絕(false)該步驟。",
("workflows approve", "note") => "附加到核准/拒絕操作的可選備註。",
("pack validate", "path") | ("pack inspect", "path") => "persona 套件目錄路徑。",
("moderation reports", "status") => {
"按狀態篩選:open | resolved | dismissed | escalated(預設:全部)。"
}
("moderation reports", "limit") | ("moderation audit", "limit") => "最多回傳的稽核記錄數。",
("moderation resolve", "report") => "待處理的 kind:1984 檢舉事件 ID(十六進位)。",
("moderation resolve", "status") => "處理狀態:resolved | dismissed。",
("moderation resolve", "action") => {
"執行的操作:delete | kick | ban | timeout | dismiss | escalate。"
}
("moderation resolve", "reason") => "可選原因(會轉發給檢舉者,請勿包含墓碑敏感內容)。",
("moderation ban", "pubkey")
| ("moderation unban", "pubkey")
| ("moderation timeout", "pubkey")
| ("moderation untimeout", "pubkey") => "目標成員公鑰(hex)。",
("moderation ban", "expires_in") | ("moderation timeout", "expires_in") => {
"從現在起的時長(秒);省略表示永久。"
}
("moderation ban", "expires_at") | ("moderation timeout", "expires_at") => {
"Unix 時間戳形式的絕對到期時間(秒)。"
}
("moderation ban", "reason") | ("moderation timeout", "reason") => {
"可選的私有原因(僅用於稽核記錄)。"
}
("dms list", "limit") => "最多回傳的結果數。",
("dms open", "pubkeys") => "要加入私訊的使用者公鑰(64 位十六進位,1–8 個)。",
("dms add-member", "channel") | ("dms hide", "channel") => "私訊對話 UUID。",
("dms add-member", "pubkey") => "要新增的使用者公鑰(64 位十六進位)。",
("emoji set", "shortcode") | ("emoji rm", "shortcode") => "表情短碼(不含兩側冒號)。",
("emoji set", "url") => "表情圖片 URL。",
("emoji export", "file") => "寫入此檔案,而不是標準輸出。",
("emoji import", "file") => "從此檔案讀取 JSON,而不是標準輸入。",
("emoji import", "replace") => "取代整個集合,而不是合併。",
("emoji import", "dry_run") => "僅列印將要發佈的內容,不寫入中繼。",
_ => return None,
})
}
fn argument_help_generic(_path: &str, id: &str, locale: Locale) -> Option<&'static str> {
let hans = match id {
"event" => "事件 ID64 位十六进制)。",
"limit" => "最多返回的结果数。",
"since" => "Unix 时间戳:仅返回此时间之后的项目。",
"before" => "Unix 时间戳:仅返回此时间之前的项目。",
"pubkey" | "pubkeys" => "公钥(hex 或 npub)。",
"owner" => "所有者公钥(64 位十六进制)。",
"content" => "内容(使用 `-` 从标准输入读取)。",
"reason" => "可选原因。",
"status" => "新状态。",
"label" => "标签(可重复指定)。",
"channel" => "频道 UUID。",
"repo_owner" => "仓库所有者公钥(64 位十六进制)。",
"repo_id" => "仓库标识(d-tag)。",
"workflow" => "工作流 UUID。",
"description" => "描述。",
_ => return None,
};
match locale {
Locale::ZhHans => Some(hans),
Locale::ZhHant => Some(match id {
"event" => "事件 ID64 位十六進位)。",
"limit" => "最多回傳的結果數。",
"since" => "Unix 時間戳:僅回傳此時間之後的項目。",
"before" => "Unix 時間戳:僅回傳此時間之前的項目。",
"pubkey" | "pubkeys" => "公鑰(hex 或 npub)。",
"owner" => "擁有者公鑰(64 位十六進位)。",
"content" => "內容(使用 `-` 從標準輸入讀取)。",
"reason" => "可選原因。",
"status" => "新狀態。",
"label" => "標籤(可重複指定)。",
"channel" => "頻道 UUID。",
"repo_owner" => "儲存庫擁有者公鑰(64 位十六進位)。",
"repo_id" => "儲存庫識別碼(d-tag)。",
"workflow" => "工作流 UUID。",
"description" => "說明。",
_ => return None,
}),
Locale::En => None,
}
}
fn command_about(path: &[String], locale: Locale) -> Option<&'static str> {
let key = path.join(" ");
match locale {
Locale::ZhHans => Some(match key.as_str() {
"" => "Buzz 命令行工具——与 Buzz 中继交互",
"agents" => "创建和更新需要所有者审核的智能体",
"messages" => "发送、读取、搜索和管理消息",
"channels" => "创建、配置和管理频道",
"canvas" => "读取和设置频道画布文档",
"reactions" => "添加、移除和列出表情回应",
"emoji" => "管理自定义表情集合",
"dms" => "列出、打开和管理私信",
"users" => "查找用户并管理资料和在线状态",
"workflows" => "创建、触发和管理工作流",
"feed" => "读取活动动态",
"social" => "发布笔记并管理社交关系",
"notes" => "发布和编辑长篇笔记(团队知识库)",
"repos" => "发布和发现 Git 仓库",
"projects" => "创建和管理多仓库项目",
"patches" => "发送、读取、列出和更新 Git 补丁",
"issues" => "创建和管理 Git 问题",
"pr" => "打开、更新、列出和管理 Git 拉取请求",
"media" => "上传和下载中继 Blossom 媒体",
"upload" => "上传文件到中继 Blossom 存储",
"mem" => "管理智能体持久记忆",
"pack" => "操作本地 persona 包(无需连接中继)",
"moderation" => "管理社区审核、封禁和审计记录",
"messages send" => "向频道发送消息",
"messages get" => "从频道读取消息",
"messages search" => "搜索消息",
"messages thread" => "读取消息话题及其回复",
"channels list" => "列出可访问的频道",
"channels create" => "创建频道",
"channels get" => "读取频道详情",
"users get" => "读取用户资料",
"users presence" => "读取用户在线状态",
"users set-profile" => "设置当前用户资料",
"users set-presence" => "设置当前用户在线状态",
"workflows list" => "列出工作流",
"workflows trigger" => "触发工作流",
"pack validate" => "验证 persona 包",
"pack inspect" => "查看 persona 包配置",
_ => return None,
}),
Locale::ZhHant => Some(match key.as_str() {
"" => "Buzz 命令列工具——與 Buzz 中繼互動",
"agents" => "建立和更新需要擁有者審核的智能體",
"messages" => "傳送、讀取、搜尋和管理訊息",
"channels" => "建立、設定和管理頻道",
"canvas" => "讀取和設定頻道畫布文件",
"reactions" => "新增、移除和列出表情回應",
"emoji" => "管理自訂表情集合",
"dms" => "列出、開啟和管理私訊",
"users" => "尋找使用者並管理資料與在線狀態",
"workflows" => "建立、觸發和管理工作流",
"feed" => "讀取活動動態",
"social" => "發佈筆記並管理社交關係",
"notes" => "發佈和編輯長篇筆記(團隊知識庫)",
"repos" => "發佈和探索 Git 儲存庫",
"projects" => "建立和管理多儲存庫專案",
"patches" => "傳送、讀取、列出和更新 Git 修補程式",
"issues" => "建立和管理 Git 問題",
"pr" => "開啟、更新、列出和管理 Git 拉取要求",
"media" => "上傳和下載中繼 Blossom 媒體",
"upload" => "上傳檔案到中繼 Blossom 儲存區",
"mem" => "管理智能體持久記憶",
"pack" => "操作本地 persona 套件(無需連線中繼)",
"moderation" => "管理社群審核、封鎖和稽核記錄",
"messages send" => "向頻道傳送訊息",
"messages get" => "從頻道讀取訊息",
"messages search" => "搜尋訊息",
"messages thread" => "讀取訊息話題及其回覆",
"channels list" => "列出可存取的頻道",
"channels create" => "建立頻道",
"channels get" => "讀取頻道詳細資料",
"users get" => "讀取使用者資料",
"users presence" => "讀取使用者在線狀態",
"users set-profile" => "設定目前使用者資料",
"users set-presence" => "設定目前使用者在線狀態",
"workflows list" => "列出工作流",
"workflows trigger" => "觸發工作流",
"pack validate" => "驗證 persona 套件",
"pack inspect" => "檢視 persona 套件設定",
_ => return None,
}),
Locale::En => None,
}
}
/// 返回错误类别对应的人类可读前缀。
pub fn error_prefix(locale: Locale, category: &str) -> &'static str {
match (locale, category) {
(Locale::ZhHans, "usage") => "用法错误",
(Locale::ZhHant, "usage") => "用法錯誤",
(Locale::ZhHans, "relay") => "中继错误",
(Locale::ZhHant, "relay") => "中繼錯誤",
(Locale::ZhHans, "network") => "网络错误",
(Locale::ZhHant, "network") => "網路錯誤",
(Locale::ZhHans, "auth") => "认证错误",
(Locale::ZhHant, "auth") => "認證錯誤",
(Locale::ZhHans, "key") => "密钥错误",
(Locale::ZhHant, "key") => "金鑰錯誤",
(Locale::ZhHans, "conflict") => "写入冲突",
(Locale::ZhHant, "conflict") => "寫入衝突",
(Locale::ZhHans, "not_found") => "未找到",
(Locale::ZhHant, "not_found") => "找不到",
(Locale::ZhHans, "delivery_unknown") => "投递结果未知",
(Locale::ZhHant, "delivery_unknown") => "傳遞結果未知",
(Locale::ZhHans, "error") => "错误",
(Locale::ZhHant, "error") => "錯誤",
(_, _) => "error",
}
}
/// 少量不属于协议的本地命令标签。未知键返回键本身,便于渐进补齐。
pub fn label<'a>(locale: Locale, key: &'a str) -> &'a str {
if !locale.is_chinese() {
return match key {
"error" => "ERROR",
"warn" => "WARN",
"valid" => "Valid.",
"valid_warnings" => "Valid (with warnings).",
"pack" => "Pack",
"version" => "Version",
"personas" => "Personas",
"display" => "Display",
"description" => "Description",
"model" => "Model",
"provider" => "Provider",
"temperature" => "Temperature",
"max_context_tokens" => "Max context tokens",
"subscribe" => "Subscribe",
"triggers" => "Triggers",
"thread_replies" => "Thread replies",
"broadcast_replies" => "Broadcast replies",
"mcp_servers" => "MCP servers",
"skills" => "Skills",
"avatar" => "Avatar",
"system_prompt" => "System prompt",
"env_vars" => "Env vars",
"no_memories" => "(no memories besides core)",
"dry_run_not_published" => "(dry run — not published)",
"wrote" => "wrote",
"tombstoned" => "tombstoned",
_ => key,
};
}
match (locale, key) {
(Locale::ZhHans, "error") => "错误",
(Locale::ZhHant, "error") => "錯誤",
(Locale::ZhHans, "warn") => "警告",
(Locale::ZhHant, "warn") => "警告",
(Locale::ZhHans, "valid") => "有效。",
(Locale::ZhHant, "valid") => "有效。",
(Locale::ZhHans, "valid_warnings") => "有效(有警告)。",
(Locale::ZhHant, "valid_warnings") => "有效(有警告)。",
(Locale::ZhHans, "pack") => "",
(Locale::ZhHant, "pack") => "套件",
(Locale::ZhHans, "version") => "版本",
(Locale::ZhHant, "version") => "版本",
(Locale::ZhHans, "personas") => "角色",
(Locale::ZhHant, "personas") => "角色",
(Locale::ZhHans, "display") => "显示名",
(Locale::ZhHant, "display") => "顯示名稱",
(Locale::ZhHans, "description") => "描述",
(Locale::ZhHant, "description") => "說明",
(Locale::ZhHans, "model") => "模型",
(Locale::ZhHant, "model") => "模型",
(Locale::ZhHans, "provider") => "提供商",
(Locale::ZhHant, "provider") => "提供者",
(Locale::ZhHans, "temperature") => "温度",
(Locale::ZhHant, "temperature") => "溫度",
(Locale::ZhHans, "max_context_tokens") => "最大上下文令牌数",
(Locale::ZhHant, "max_context_tokens") => "最大上下文權杖數",
(Locale::ZhHans, "subscribe") => "订阅",
(Locale::ZhHant, "subscribe") => "訂閱",
(Locale::ZhHans, "triggers") => "触发条件",
(Locale::ZhHant, "triggers") => "觸發條件",
(Locale::ZhHans, "thread_replies") => "话题回复",
(Locale::ZhHant, "thread_replies") => "話題回覆",
(Locale::ZhHans, "broadcast_replies") => "广播回复",
(Locale::ZhHant, "broadcast_replies") => "廣播回覆",
(Locale::ZhHans, "mcp_servers") => "MCP 服务",
(Locale::ZhHant, "mcp_servers") => "MCP 伺服器",
(Locale::ZhHans, "skills") => "技能",
(Locale::ZhHant, "skills") => "技能",
(Locale::ZhHans, "avatar") => "头像",
(Locale::ZhHant, "avatar") => "頭像",
(Locale::ZhHans, "system_prompt") => "系统提示词",
(Locale::ZhHant, "system_prompt") => "系統提示詞",
(Locale::ZhHans, "env_vars") => "环境变量",
(Locale::ZhHant, "env_vars") => "環境變數",
(Locale::ZhHans, "no_memories") => "(除核心记忆外没有其他记忆)",
(Locale::ZhHant, "no_memories") => "(除了核心記憶外沒有其他記憶)",
(Locale::ZhHans, "dry_run_not_published") => "(试运行——未发布)",
(Locale::ZhHant, "dry_run_not_published") => "(試執行——未發佈)",
(Locale::ZhHans, "wrote") => "已写入",
(Locale::ZhHant, "wrote") => "已寫入",
(Locale::ZhHans, "tombstoned") => "已标记删除",
(Locale::ZhHant, "tombstoned") => "已標記刪除",
(_, _) => key,
}
}
const TOP_LEVEL_LONG_HANS: &str = "\
Buzz 命令行工具——与 Buzz 中继交互\n\n\
配置(命令行参数优先于环境变量):\n\
BUZZ_RELAY_URL 中继基础 URL [默认:http://localhost:3000]\n\
BUZZ_PRIVATE_KEY Nostr 私钥(hex 或 nsec[必填]\n\
BUZZ_AUTH_TAG NIP-OA 认证标签 JSON [可选]\n\
BUZZ_LANGUAGE 界面语言(auto、en、zh、zh-hant[可选]\n\n\
`pack` 子命令仅在本地运行,不需要连接中继。\n\n\
退出码:0=成功 1=输入错误 2=中继/网络错误 3=认证错误 4=其他错误 5=写入冲突\n\
错误会以 JSON 写入 stderr;JSON 字段和协议值不会翻译。";
const TOP_LEVEL_LONG_HANT: &str = "\
Buzz 命令列工具——與 Buzz 中繼互動\n\n\
設定(命令列參數優先於環境變數):\n\
BUZZ_RELAY_URL 中繼基礎 URL [預設:http://localhost:3000]\n\
BUZZ_PRIVATE_KEY Nostr 私鑰(hex 或 nsec[必填]\n\
BUZZ_AUTH_TAG NIP-OA 認證標籤 JSON [可選]\n\
BUZZ_LANGUAGE 介面語言(auto、en、zh、zh-hant[可選]\n\n\
`pack` 子命令僅在本地執行,不需要連線中繼。\n\n\
退出碼:0=成功 1=輸入錯誤 2=中繼/網路錯誤 3=認證錯誤 4=其他錯誤 5=寫入衝突\n\
錯誤會以 JSON 寫入 stderr;JSON 欄位和協定值不會翻譯。";
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_languages_resolve_without_environment() {
assert_eq!(Language::English.resolve(), Locale::En);
assert_eq!(Language::SimplifiedChinese.resolve(), Locale::ZhHans);
assert_eq!(Language::TraditionalChinese.resolve(), Locale::ZhHant);
}
#[test]
fn aliases_are_case_and_separator_tolerant_for_pre_scan() {
assert_eq!(Language::parse_lossy("zh-CN"), Language::SimplifiedChinese);
assert_eq!(
Language::parse_lossy("zh_hans"),
Language::SimplifiedChinese
);
assert_eq!(Language::parse_lossy("zh-HK"), Language::TraditionalChinese);
assert_eq!(Language::parse_lossy("en_US"), Language::English);
}
#[test]
fn locale_pre_scan_handles_separate_and_equals_forms() {
let separate = vec!["buzz".into(), "--language".into(), "zh".into()];
let equals = vec!["buzz".into(), "--language=zh-hant".into()];
assert_eq!(locale_for_args(&separate), Locale::ZhHans);
assert_eq!(locale_for_args(&equals), Locale::ZhHant);
}
#[test]
fn clap_error_detail_removes_only_the_english_prefix() {
let error = clap::Command::new("buzz")
.arg(clap::Arg::new("channel").long("channel").required(true))
.try_get_matches_from(["buzz"])
.expect_err("missing required flag should fail");
let detail = clap_error_detail(&error, Locale::ZhHans);
assert!(!detail.starts_with("error: "));
assert!(detail.contains("--channel"));
assert_eq!(clap_error_detail(&error, Locale::En), error.to_string());
}
}
File diff suppressed because it is too large Load Diff
+51
View File
@@ -0,0 +1,51 @@
//! Canonical `buzz://` deep links for Buzz-hosted git entities.
//!
//! Buzz Desktop renders these links as rich preview cards in chat and
//! navigates in-app when they are clicked. The desktop parser lives in
//! `desktop/src/shared/lib/entityLink.ts` — the two implementations must
//! stay format-compatible (see `golden_format_matches_desktop` below and
//! the mirror test in `entityLink.test.mjs`).
//!
//! Callers are expected to validate inputs first (`validate_hex64`,
//! `validate_repo_id`); the identifier charsets need no URL encoding.
/// Build a `buzz://repo` link for a repository announcement (kind 30617).
pub fn repo_link(owner: &str, repo_id: &str) -> String {
format!("buzz://repo?owner={owner}&d={repo_id}")
}
/// Build a `buzz://pr` link for a pull request event (kind 1618).
pub fn pull_request_link(event_id: &str, owner: &str, repo_id: &str) -> String {
format!("buzz://pr?id={event_id}&owner={owner}&d={repo_id}")
}
/// Build a `buzz://issue` link for an issue event (kind 1621).
pub fn issue_link(event_id: &str, owner: &str, repo_id: &str) -> String {
format!("buzz://issue?id={event_id}&owner={owner}&d={repo_id}")
}
#[cfg(test)]
mod tests {
use super::*;
const OWNER: &str = "71d67180ba17e749ee825fc8819c9c6ee7003617e1c126504f9b658070ab9224";
const EVENT_ID: &str = "c3b589fa5713ba25bad6dc095e2de00a4ac8f50050fdea00fc6444e603be1dd1";
// Golden strings shared with desktop/src/shared/lib/entityLink.test.mjs
// ("builders emit the canonical cross-language link format").
#[test]
fn golden_format_matches_desktop() {
assert_eq!(
pull_request_link(EVENT_ID, OWNER, "buzz-world"),
format!("buzz://pr?id={EVENT_ID}&owner={OWNER}&d=buzz-world")
);
assert_eq!(
issue_link(EVENT_ID, OWNER, "buzz-world"),
format!("buzz://issue?id={EVENT_ID}&owner={OWNER}&d=buzz-world")
);
assert_eq!(
repo_link(OWNER, "buzz-world"),
format!("buzz://repo?owner={OWNER}&d=buzz-world")
);
}
}
+4
View File
@@ -0,0 +1,4 @@
#[tokio::main]
async fn main() {
std::process::exit(buzz_cli::run_from_args(std::env::args()).await);
}
+506
View File
@@ -0,0 +1,506 @@
use crate::error::CliError;
/// Maximum content size in bytes (64 KiB).
pub const MAX_CONTENT_BYTES: usize = 65_536;
/// Maximum diff size in bytes (60 KiB).
pub const MAX_DIFF_BYTES: usize = 61_440;
/// Parse a hex string into a `nostr::EventId`. Returns `CliError::Usage` on failure.
pub fn parse_event_id(hex: &str) -> Result<nostr::EventId, CliError> {
nostr::EventId::parse(hex).map_err(|e| CliError::Usage(format!("invalid event ID: {e}")))
}
/// Parse a UUID string into a `uuid::Uuid`. Returns `CliError::Usage` on failure.
///
/// Note: `validate_uuid` (below) returns `()` for validation only; this function
/// returns the parsed `Uuid` for callers that need the value.
pub fn parse_uuid(s: &str) -> Result<uuid::Uuid, CliError> {
uuid::Uuid::parse_str(s).map_err(|e| CliError::Usage(format!("invalid UUID: {e}")))
}
/// Validate UUID string. Returns CliError::Usage on failure.
pub fn validate_uuid(s: &str) -> Result<(), CliError> {
uuid::Uuid::parse_str(s).map_err(|_| CliError::Usage(format!("invalid UUID: {s}")))?;
Ok(())
}
/// Validate 64-character lowercase hex string (event_id, pubkey).
pub fn validate_hex64(s: &str) -> Result<(), CliError> {
if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(CliError::Usage(format!(
"must be a 64-character hex string: {s}"
)));
}
Ok(())
}
/// Validate a git repo identifier: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`.
pub fn validate_repo_id(s: &str) -> Result<(), CliError> {
if s.is_empty() || s.len() > 64 {
return Err(CliError::Usage(format!(
"repo ID must be 1-64 characters (got {})",
s.len()
)));
}
if s.starts_with('.') {
return Err(CliError::Usage("repo ID must not start with '.'".into()));
}
if s.contains("..") {
return Err(CliError::Usage("repo ID must not contain '..'".into()));
}
if !s
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
{
return Err(CliError::Usage(format!(
"repo ID contains invalid characters (allowed: a-z A-Z 0-9 . _ -): {s}"
)));
}
Ok(())
}
/// Validate content does not exceed MAX_CONTENT_BYTES (65,536).
pub fn validate_content_size(content: &str) -> Result<(), CliError> {
if content.len() > MAX_CONTENT_BYTES {
return Err(CliError::Usage(format!(
"content exceeds maximum size ({} > {} bytes)",
content.len(),
MAX_CONTENT_BYTES
)));
}
Ok(())
}
/// Percent-encode for URL path segments and query parameter values.
/// Encodes all bytes except RFC 3986 unreserved: A-Z a-z 0-9 - _ . ~
#[cfg(test)]
pub fn percent_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for byte in s.bytes() {
match byte {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(byte as char);
}
_ => {
let hi = char::from_digit((byte >> 4) as u32, 16)
.unwrap()
.to_ascii_uppercase();
let lo = char::from_digit((byte & 0xf) as u32, 16)
.unwrap()
.to_ascii_uppercase();
out.push('%');
out.push(hi);
out.push(lo);
}
}
}
out
}
/// Truncate diff at hunk boundary within max_bytes (60 KiB for send-diff-message).
/// Returns (truncated_string, was_truncated).
pub fn truncate_diff(diff: &str, max_bytes: usize) -> (String, bool) {
const TRUNCATION_NOTICE: &str = "\n\n[diff truncated — exceeded size limit]";
if diff.len() <= max_bytes {
return (diff.to_string(), false);
}
let effective_limit = max_bytes.saturating_sub(TRUNCATION_NOTICE.len());
let utf8_boundary = diff
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= effective_limit)
.last()
.unwrap_or(0);
let safe_prefix = &diff[..utf8_boundary];
let cut_point = safe_prefix
.rfind("\n@@")
.filter(|&p| p > 0)
.unwrap_or_else(|| safe_prefix.rfind('\n').unwrap_or(utf8_boundary));
let mut result = diff[..cut_point].to_string();
result.push_str(TRUNCATION_NOTICE);
(result, true)
}
/// Infer syntax-highlight language from file extension.
pub fn infer_language(file_path: &str) -> Option<String> {
let ext = file_path.rsplit('.').next()?;
let lang = match ext {
"rs" => "rust",
"ts" | "tsx" => "typescript",
"js" | "jsx" => "javascript",
"py" => "python",
"go" => "go",
"java" => "java",
"rb" => "ruby",
"c" | "h" => "c",
"cpp" | "cc" | "cxx" | "hpp" => "cpp",
"cs" => "csharp",
"swift" => "swift",
"kt" | "kts" => "kotlin",
"scala" => "scala",
"sh" | "bash" | "zsh" => "bash",
"sql" => "sql",
"html" | "htm" => "html",
"css" | "scss" | "sass" => "css",
"json" => "json",
"yaml" | "yml" => "yaml",
"toml" => "toml",
"xml" => "xml",
"md" | "markdown" => "markdown",
"dockerfile" => "dockerfile",
_ => return None,
};
Some(lang.to_string())
}
/// Map `SdkError` to the appropriate `CliError` variant.
///
/// `InvalidInput` is a user error (exit 1), everything else is internal (exit 4).
pub fn sdk_err(e: buzz_sdk::SdkError) -> CliError {
match e {
buzz_sdk::SdkError::InvalidInput(msg) => CliError::Usage(msg),
other => CliError::Other(other.to_string()),
}
}
/// Read content from a string value or stdin if the value is "-".
pub fn read_or_stdin(value: &str) -> Result<String, CliError> {
if value == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| CliError::Other(format!("failed to read stdin: {e}")))?;
Ok(buf)
} else {
Ok(value.to_string())
}
}
/// Read content from a file path, or stdin if the value is "-".
///
/// Unlike [`read_or_stdin`], `value` is never treated as literal content —
/// it always names a file (or `-` for stdin). Use this for flags like
/// `--patch-file` where the argument is a path, not the content itself.
pub fn read_file_or_stdin(value: &str) -> Result<String, CliError> {
if value == "-" {
use std::io::Read;
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.map_err(|e| CliError::Other(format!("failed to read stdin: {e}")))?;
Ok(buf)
} else {
std::fs::read_to_string(value)
.map_err(|e| CliError::Usage(format!("failed to read {value:?}: {e}")))
}
}
#[cfg(test)]
mod tests {
use super::*;
// --- validate_uuid ---
#[test]
fn validate_uuid_valid() {
assert!(validate_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok());
}
#[test]
fn validate_uuid_malformed() {
let err = validate_uuid("not-a-uuid").unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}
#[test]
fn validate_uuid_empty() {
let err = validate_uuid("").unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}
// --- validate_hex64 ---
#[test]
fn validate_hex64_valid() {
let hex = "a".repeat(64);
assert!(validate_hex64(&hex).is_ok());
}
#[test]
fn validate_hex64_all_digits() {
let hex = "0123456789abcdef".repeat(4);
assert!(validate_hex64(&hex).is_ok());
}
#[test]
fn validate_hex64_too_short() {
let hex = "a".repeat(63);
let err = validate_hex64(&hex).unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}
#[test]
fn validate_hex64_too_long() {
let hex = "a".repeat(65);
let err = validate_hex64(&hex).unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}
#[test]
fn validate_hex64_non_hex_char() {
let mut hex = "a".repeat(63);
hex.push('z'); // 'z' is not a hex digit
let err = validate_hex64(&hex).unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}
// --- validate_content_size ---
#[test]
fn validate_content_size_at_limit() {
let content = "x".repeat(MAX_CONTENT_BYTES);
assert!(validate_content_size(&content).is_ok());
}
#[test]
fn validate_content_size_over_limit() {
let content = "x".repeat(MAX_CONTENT_BYTES + 1);
let err = validate_content_size(&content).unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}
#[test]
fn validate_content_size_empty() {
assert!(validate_content_size("").is_ok());
}
// --- percent_encode ---
#[test]
fn percent_encode_unreserved_unchanged() {
let input = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~";
assert_eq!(percent_encode(input), input);
}
#[test]
fn percent_encode_space() {
assert_eq!(percent_encode("hello world"), "hello%20world");
}
#[test]
fn percent_encode_slash() {
assert_eq!(percent_encode("a/b"), "a%2Fb");
}
#[test]
fn percent_encode_unicode_multibyte() {
// '€' is U+20AC, encoded as 3 UTF-8 bytes: 0xE2 0x82 0xAC
assert_eq!(percent_encode(""), "%E2%82%AC");
}
#[test]
fn percent_encode_empty() {
assert_eq!(percent_encode(""), "");
}
// --- truncate_diff ---
#[test]
fn truncate_diff_under_limit_noop() {
let diff = "small diff";
let (result, was_truncated) = truncate_diff(diff, 1000);
assert_eq!(result, diff);
assert!(!was_truncated);
}
#[test]
fn truncate_diff_at_limit_noop() {
let diff = "x".repeat(100);
let (result, was_truncated) = truncate_diff(&diff, 100);
assert_eq!(result, diff);
assert!(!was_truncated);
}
#[test]
fn truncate_diff_cuts_at_hunk_boundary() {
// Build a diff with a @@ hunk marker after the limit
let hunk1 = "@@ -1,3 +1,3 @@\n line1\n line2\n line3\n";
let hunk2 = "@@ -5,3 +5,3 @@\n line4\n line5\n line6\n";
let diff = format!("{}{}", hunk1, hunk2);
// Limit to just past hunk1 but before hunk2 completes
let limit = hunk1.len() + 5;
let (result, was_truncated) = truncate_diff(&diff, limit);
assert!(was_truncated);
assert!(result.contains("[diff truncated — exceeded size limit]"));
// Should cut at the \n@@ boundary before hunk2
assert!(!result.contains("line4"));
}
#[test]
fn truncate_diff_falls_back_to_newline() {
// No @@ marker — should fall back to last newline
let diff = "line one\nline two\nline three extra long content here";
let limit = 20;
let (result, was_truncated) = truncate_diff(diff, limit);
assert!(was_truncated);
assert!(result.contains("[diff truncated — exceeded size limit]"));
}
#[test]
fn truncate_diff_appends_notice() {
let diff = "x".repeat(200);
let (result, was_truncated) = truncate_diff(&diff, 50);
assert!(was_truncated);
assert!(result.ends_with("[diff truncated — exceeded size limit]"));
}
// --- infer_language ---
#[test]
fn infer_language_rust() {
assert_eq!(infer_language("main.rs"), Some("rust".to_string()));
}
#[test]
fn infer_language_tsx() {
assert_eq!(infer_language("App.tsx"), Some("typescript".to_string()));
}
#[test]
fn infer_language_ts() {
assert_eq!(infer_language("index.ts"), Some("typescript".to_string()));
}
#[test]
fn infer_language_unknown_ext() {
assert_eq!(infer_language("file.xyz"), None);
}
#[test]
fn infer_language_no_ext() {
assert_eq!(infer_language("Makefile"), None);
}
#[test]
fn infer_language_path_with_dirs() {
assert_eq!(
infer_language("src/lib/utils.py"),
Some("python".to_string())
);
}
// Note: `extract_at_names`, `extract_at_mentions_with_known`, `merge_mentions`,
// and `normalize_mention_pubkeys` live in `buzz_sdk::mentions` and are tested there.
// --- parse_event_id ---
#[test]
fn parse_event_id_valid() {
let hex = "a".repeat(64);
assert!(super::parse_event_id(&hex).is_ok());
}
#[test]
fn parse_event_id_invalid() {
assert!(super::parse_event_id("not-a-hex-id").is_err());
}
// --- parse_uuid ---
#[test]
fn parse_uuid_valid() {
assert!(super::parse_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok());
}
#[test]
fn parse_uuid_invalid() {
assert!(super::parse_uuid("not-a-uuid").is_err());
}
#[test]
fn validate_repo_id_valid() {
assert!(super::validate_repo_id("my-repo").is_ok());
assert!(super::validate_repo_id("repo_v2.0").is_ok());
assert!(super::validate_repo_id("a").is_ok());
}
#[test]
fn validate_repo_id_boundary_64_chars() {
let id = "a".repeat(64);
assert!(super::validate_repo_id(&id).is_ok());
}
#[test]
fn validate_repo_id_rejects_empty() {
assert!(super::validate_repo_id("").is_err());
}
#[test]
fn validate_repo_id_rejects_over_64() {
let id = "a".repeat(65);
assert!(super::validate_repo_id(&id).is_err());
}
#[test]
fn validate_repo_id_rejects_leading_dot() {
assert!(super::validate_repo_id(".hidden").is_err());
}
#[test]
fn validate_repo_id_rejects_double_dot() {
assert!(super::validate_repo_id("foo..bar").is_err());
}
#[test]
fn validate_repo_id_rejects_invalid_chars() {
assert!(super::validate_repo_id("my repo").is_err());
assert!(super::validate_repo_id("foo/bar").is_err());
assert!(super::validate_repo_id("a@b").is_err());
}
// --- read_or_stdin ---
#[test]
fn read_or_stdin_passthrough_returns_value() {
// Anything other than "-" is returned verbatim — backticks, $vars,
// newlines must all survive untouched (no shell evaluation happens
// here; we're past argv parsing).
let raw = "literal `backticks` and $vars\nwith newline";
assert_eq!(super::read_or_stdin(raw).unwrap(), raw);
}
#[test]
fn read_or_stdin_passthrough_empty_string() {
assert_eq!(super::read_or_stdin("").unwrap(), "");
}
// --- read_file_or_stdin ---
#[test]
fn read_file_or_stdin_reads_file_contents() {
let mut path = std::env::temp_dir();
path.push(format!(
"buzz-cli-test-{}-{}.patch",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::write(&path, "diff --git a/x b/x\n").unwrap();
let got = super::read_file_or_stdin(path.to_str().unwrap()).unwrap();
std::fs::remove_file(&path).unwrap();
assert_eq!(got, "diff --git a/x b/x\n");
}
#[test]
fn read_file_or_stdin_does_not_treat_path_as_literal_content() {
// Regression for the bug where `read_or_stdin` was used for
// `--patch-file`: a nonexistent path must error, not be returned
// verbatim as if it were the patch content.
let err = super::read_file_or_stdin("0001-does-not-exist.patch").unwrap_err();
assert!(matches!(err, CliError::Usage(_)));
}
}