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
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:
@@ -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 }
|
||||
@@ -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 (1–8 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
|
||||
```
|
||||
@@ -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 |
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
})?;
|
||||
|
||||
// 3–4. 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");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
//! `buzz moderation` — community moderation queue, enforcement, and audit.
|
||||
//!
|
||||
//! Mutations (`ban`/`unban`/`timeout`/`untimeout`/`resolve`) are signed
|
||||
//! command events (kinds 9040–9044) 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
@@ -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(())
|
||||
}
|
||||
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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") => "中继 URL(http:// 或 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") => "根消息事件 ID(64 位十六进制)。",
|
||||
("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") => "中繼 URL(http:// 或 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" => "事件 ID(64 位十六进制)。",
|
||||
"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" => "事件 ID(64 位十六進位)。",
|
||||
"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
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
std::process::exit(buzz_cli::run_from_args(std::env::args()).await);
|
||||
}
|
||||
@@ -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(_)));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user