feat: sprout-cli — agent-first CLI with full MCP parity (48 commands) (#158)

This commit is contained in:
tlongwell-block
2026-03-21 21:23:32 -04:00
committed by GitHub
parent 325def6cc4
commit 4c9b8aae9c
21 changed files with 3471 additions and 4 deletions

16
Cargo.lock generated
View File

@@ -3363,6 +3363,22 @@ dependencies = [
"uuid",
]
[[package]]
name = "sprout-cli"
version = "0.1.0"
dependencies = [
"base64",
"clap",
"nostr",
"reqwest",
"serde",
"serde_json",
"sha2",
"thiserror",
"tokio",
"uuid",
]
[[package]]
name = "sprout-core"
version = "0.1.0"

View File

@@ -15,6 +15,7 @@ members = [
"crates/sprout-admin",
"crates/sprout-workflow",
"crates/sprout-media",
"crates/sprout-cli",
]
exclude = ["desktop/src-tauri"]
resolver = "2"

View File

@@ -0,0 +1,41 @@
[package]
name = "sprout-cli"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Agent-first CLI for Sprout relay"
[[bin]]
name = "sprout"
path = "src/main.rs"
[dependencies]
# CLI argument parsing — derive macros + env var support (SPROUT_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 `sprout auth` and auto-mint (NIP-98 token minting)
nostr = { workspace = true }
# UUID parsing for validate_uuid
uuid = { version = "1" }
# Base64 encoding — NIP-98 event serialization for Authorization header
base64 = "0.22"
# SHA-256 — NIP-98 payload hash tag
sha2 = "0.10"

163
crates/sprout-cli/README.md Normal file
View File

@@ -0,0 +1,163 @@
# Sprout CLI
Agent-first command-line interface for Sprout relay. JSON in, JSON out.
## Install
```bash
cargo install --path crates/sprout-cli
```
## Authentication
Three modes, checked in order:
| Priority | Env Var | Mode | Use Case |
|----------|---------|------|----------|
| 1 | `SPROUT_API_TOKEN` | Bearer token | Production — fastest, no extra HTTP call |
| 2 | `SPROUT_PRIVATE_KEY` | Auto-mint short-lived token via NIP-98 | Agents with a keypair |
| 3 | `SPROUT_PUBKEY` | X-Pubkey header (dev relay only) | Local development |
```bash
# Option 1: Pre-minted token
export SPROUT_API_TOKEN="sprout_tok_..."
sprout list-channels
# Option 2: Private key (auto-mints a 1-day token at startup)
export SPROUT_PRIVATE_KEY="nsec1..."
sprout list-channels
# Option 3: Mint a long-lived token explicitly
export SPROUT_API_TOKEN=$(SPROUT_PRIVATE_KEY=nsec1... sprout auth)
```
## 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.
```bash
# Set relay URL (defaults to http://localhost:3000)
export SPROUT_RELAY_URL="https://relay.example.com"
# Messages
sprout send-message --channel <uuid> --content "Hello"
sprout send-message --channel <uuid> --content "Reply" --reply-to <event-id> --broadcast
sprout get-messages --channel <uuid> --limit 20
sprout get-thread --channel <uuid> --event <event-id>
sprout search --query "architecture"
sprout edit-message --event <event-id> --content "Updated text"
sprout delete-message --event <event-id>
# Diffs
sprout send-diff-message --channel <uuid> --diff - --repo https://github.com/org/repo --commit abc123 < diff.patch
# Channels
sprout list-channels
sprout create-channel --name "my-channel" --type stream --visibility open
sprout join-channel --channel <uuid>
sprout set-channel-topic --channel <uuid> --topic "New topic"
# Reactions
sprout add-reaction --event <event-id> --emoji "👍"
sprout get-reactions --event <event-id>
# Users & Presence
sprout get-users # your own profile
sprout get-users --pubkey <hex> # single user
sprout get-users --pubkey <hex> --pubkey <hex> # batch (max 200)
sprout set-presence --status online
# DMs
sprout open-dm --pubkey <hex>
sprout list-dms
# Workflows
sprout list-workflows --channel <uuid>
sprout trigger-workflow --workflow <uuid>
sprout approve-step --token <uuid> --approved true
# Forum
sprout vote-on-post --event <event-id> --direction up
# Canvas
sprout get-canvas --channel <uuid>
sprout set-canvas --channel <uuid> --content "# Welcome"
# Tokens
sprout auth # mint token, print to stdout
sprout list-tokens
sprout delete-token --id <uuid>
sprout delete-all-tokens
# Pipe to jq
sprout list-channels | jq '.[].name'
```
## All 48 Commands
| Command | Description |
|---------|-------------|
| `send-message` | Send a message to a channel |
| `send-diff-message` | Send a code diff with metadata |
| `edit-message` | Edit a message you sent |
| `delete-message` | Delete a message |
| `get-messages` | List messages in a channel |
| `get-thread` | Get a message thread |
| `search` | Full-text search |
| `list-channels` | List channels |
| `get-channel` | Get channel details |
| `create-channel` | Create a channel |
| `update-channel` | Update channel name/description |
| `set-channel-topic` | Set channel topic |
| `set-channel-purpose` | Set channel purpose |
| `join-channel` | Join a channel |
| `leave-channel` | Leave a channel |
| `archive-channel` | Archive a channel |
| `unarchive-channel` | Unarchive a channel |
| `delete-channel` | Delete a channel |
| `list-channel-members` | List channel members |
| `add-channel-member` | Add a member |
| `remove-channel-member` | Remove a member |
| `get-canvas` | Get channel canvas |
| `set-canvas` | Set channel canvas |
| `add-reaction` | React to a message |
| `remove-reaction` | Remove a reaction |
| `get-reactions` | List reactions |
| `list-dms` | List DM conversations |
| `open-dm` | Open a DM (1–8 pubkeys) |
| `add-dm-member` | Add member to DM group |
| `get-users` | Get user profile(s) |
| `set-profile` | Update your profile |
| `get-presence` | Get presence status |
| `set-presence` | Set presence status |
| `set-channel-add-policy` | Set who can add you to channels |
| `list-workflows` | List workflows |
| `create-workflow` | Create a workflow |
| `update-workflow` | Update a workflow |
| `delete-workflow` | Delete a workflow |
| `trigger-workflow` | Trigger a workflow |
| `get-workflow-runs` | Get workflow run history |
| `get-workflow` | Get workflow definition |
| `approve-step` | Approve/deny a workflow step |
| `get-feed` | Get your activity feed |
| `vote-on-post` | Vote on a forum post |
| `auth` | Mint a long-lived API token |
| `list-tokens` | List your API tokens |
| `delete-token` | Delete a token |
| `delete-all-tokens` | Delete all tokens |
## Architecture
```
sprout <command> [flags]
│
├─ main.rs ──▶ commands/*.rs ──▶ client.rs ──▶ Sprout 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
```

View File

@@ -0,0 +1,586 @@
# sprout-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
# sprout-postgres healthy
# sprout-redis healthy
# sprout-typesense healthy
```
If not running: `./scripts/dev-setup.sh` from the repo root.
Tools: `jq`, `curl`, Rust toolchain.
---
## 2. Build the CLI
```bash
cargo build -p sprout-cli
```
Use `cargo run -p sprout-cli --` or the built binary at `target/debug/sprout`.
---
## 3. Start the Relay
In a separate terminal:
```bash
cd REPOS/sprout-nostr
set -a && source .env && set +a
cargo run -p sprout-relay
```
Verify:
```bash
curl -s http://localhost:3000/_liveness
# "ok" or 200 status
```
The `.env` should have `SPROUT_REQUIRE_AUTH_TOKEN=false` for local dev.
---
## 4. Mint Test Credentials
### Option A: sprout-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=postgres://sprout:sprout_dev@localhost:5432/sprout \
cargo run -p sprout-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 `SPROUT_PRIVATE_KEY` testing
- **API Token** — save as `SPROUT_API_TOKEN`
- **Pubkey** — save for `SPROUT_PUBKEY` testing
Export:
```bash
export SPROUT_RELAY_URL="http://localhost:3000"
export SPROUT_API_TOKEN="sprout_tok_..."
export SPROUT_PRIVATE_KEY="nsec1..." # from the mint output
```
### Option B: sprout auth (NIP-98, self-mintable scopes only)
Tests the CLI's own auth flow. Cannot mint `admin:channels`.
```bash
export SPROUT_PRIVATE_KEY="nsec1..."
export SPROUT_RELAY_URL="http://localhost:3000"
cargo run -p sprout-cli -- auth
# Prints a token string to stdout
```
### Scope reference
| Scope | Self-mintable | Needed for |
|-------|:---:|------------|
| `messages:read` | ✅ | get-messages, get-thread, search, get-feed |
| `messages:write` | ✅ | send-message, edit-message, delete-message, reactions, vote |
| `channels:read` | ✅ | list-channels, get-channel, list-members |
| `channels:write` | ✅ | create-channel, update-channel, join, leave, topic, purpose |
| `users:read` | ✅ | get-users, get-presence |
| `users:write` | ✅ | set-profile, set-presence, set-channel-add-policy |
| `files:read` | ✅ | — |
| `files:write` | ✅ | — |
| `admin:channels` | ❌ | archive, unarchive, delete-channel, add/remove-channel-member |
**Use Option A for full testing.** Option B covers most commands but skips
admin operations.
---
## 5. Unit Tests
```bash
cargo test -p sprout-cli
# Expected: 38 passed, 0 failed
cargo clippy -p sprout-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). Exceptions: `auth` prints
a raw token string, and `delete-token`/`delete-all-tokens` may return
empty (204). Commands are ordered so earlier ones create resources that
later ones need.
### 6.1 Auth & Tokens
```bash
# list-tokens — list existing tokens
sprout list-tokens | jq .
# auth — mint a new token (requires SPROUT_PRIVATE_KEY)
SPROUT_PRIVATE_KEY="nsec1..." sprout auth
# Should print: sprout_tok_...
# delete-token — delete a specific token by UUID
# ⚠️ Do NOT delete the token you're currently using (SPROUT_API_TOKEN).
# Mint a throwaway token first, then delete it:
THROWAWAY=$(sprout auth) # mint a new token
THROWAWAY_LIST=$(SPROUT_API_TOKEN="$THROWAWAY" sprout list-tokens)
# Filter by name to avoid deleting the wrong token
THROWAWAY_ID=$(echo "$THROWAWAY_LIST" | jq -r '[.[] // .tokens[] | select(.name == "sprout-cli")][0].id // empty')
sprout delete-token --id "$THROWAWAY_ID"
# May return 204 (empty) or JSON — both are success
# delete-all-tokens — DESTRUCTIVE, deletes all tokens for this pubkey
# sprout delete-all-tokens
# ⚠️ Only run this if you're about to re-mint
```
### 6.2 Channels
```bash
# create-channel (stream)
sprout create-channel --name "test-stream" --type stream --visibility open \
--description "CLI test channel" | jq .
# Save the channel ID:
CHANNEL_ID=$(sprout create-channel --name "test-cli" --type stream --visibility open | jq -r '.id')
# create-channel (forum) — needed for vote-on-post later
FORUM_ID=$(sprout create-channel --name "test-forum" --type forum --visibility open | jq -r '.id')
# list-channels
sprout list-channels | jq .
sprout list-channels --visibility open | jq .
sprout list-channels --member | jq .
# get-channel
sprout get-channel --channel "$CHANNEL_ID" | jq .
# update-channel
sprout update-channel --channel "$CHANNEL_ID" --name "test-cli-updated" \
--description "Updated" | jq .
# set-channel-topic
sprout set-channel-topic --channel "$CHANNEL_ID" --topic "Test topic" | jq .
# set-channel-purpose
sprout set-channel-purpose --channel "$CHANNEL_ID" --purpose "Testing" | jq .
# join-channel (may already be a member from create)
sprout join-channel --channel "$CHANNEL_ID" | jq .
# leave-channel
sprout leave-channel --channel "$CHANNEL_ID" | jq .
# Re-join so we can send messages
sprout join-channel --channel "$CHANNEL_ID" | jq .
# archive-channel (requires admin:channels scope)
sprout archive-channel --channel "$CHANNEL_ID" | jq .
# unarchive-channel
sprout unarchive-channel --channel "$CHANNEL_ID" | jq .
```
### 6.3 Canvas
```bash
# set-canvas
sprout set-canvas --channel "$CHANNEL_ID" --content "# Test Canvas" | jq .
# set-canvas from stdin
echo "# Canvas from stdin" | sprout set-canvas --channel "$CHANNEL_ID" --content - | jq .
# get-canvas
sprout get-canvas --channel "$CHANNEL_ID" | jq .
```
### 6.4 Messages
```bash
# send-message
MSG=$(sprout send-message --channel "$CHANNEL_ID" --content "Hello from CLI test" | jq .)
echo "$MSG"
EVENT_ID=$(echo "$MSG" | jq -r '.id // .event_id')
# send-message with reply + broadcast
REPLY=$(sprout send-message --channel "$CHANNEL_ID" --content "Reply" \
--reply-to "$EVENT_ID" --broadcast | jq .)
echo "$REPLY"
REPLY_ID=$(echo "$REPLY" | jq -r '.id // .event_id')
# send-message with mentions
sprout send-message --channel "$CHANNEL_ID" --content "Hey @someone" \
--mention "0000000000000000000000000000000000000000000000000000000000000001" | jq .
# get-messages
sprout get-messages --channel "$CHANNEL_ID" | jq .
sprout get-messages --channel "$CHANNEL_ID" --limit 5 | jq .
# get-thread
sprout get-thread --channel "$CHANNEL_ID" --event "$EVENT_ID" | jq .
# search
sprout search --query "Hello" | jq .
sprout search --query "CLI test" --limit 5 | jq .
# edit-message
sprout edit-message --event "$EVENT_ID" --content "Edited by CLI test" | jq .
# delete-message
sprout delete-message --event "$REPLY_ID" | jq .
```
### 6.5 Diff Messages
```bash
# send-diff-message from stdin
echo '--- a/foo.rs
+++ b/foo.rs
@@ -1,3 +1,3 @@
-fn old() {}
+fn new() {}' | sprout send-diff-message \
--channel "$CHANNEL_ID" \
--diff - \
--repo "https://github.com/example/repo" \
--commit "abcdef1234567890abcdef1234567890abcdef12" | jq .
# send-diff-message with metadata
echo "diff content" | sprout send-diff-message \
--channel "$CHANNEL_ID" \
--diff - \
--repo "https://github.com/example/repo" \
--commit "abcdef1234567890abcdef1234567890abcdef12" \
--file "src/main.rs" \
--lang "rust" \
--description "Refactored main" | jq .
# send-diff-message with branch + PR metadata
echo "diff content" | sprout send-diff-message \
--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.6 Reactions
```bash
# Send a message to react to
REACT_MSG=$(sprout send-message --channel "$CHANNEL_ID" --content "React to this")
REACT_ID=$(echo "$REACT_MSG" | jq -r '.id // .event_id')
# add-reaction
sprout add-reaction --event "$REACT_ID" --emoji "👍" | jq .
# get-reactions
sprout get-reactions --event "$REACT_ID" | jq .
# remove-reaction
sprout remove-reaction --event "$REACT_ID" --emoji "👍" | jq .
```
### 6.7 DMs
```bash
# list-dms
sprout list-dms | jq .
# open-dm (needs a real pubkey — use your own or a test one)
# Get your own pubkey first:
MY_PUBKEY=$(sprout get-users | jq -r '.pubkey // .[0].pubkey // empty')
echo "My pubkey: $MY_PUBKEY"
# open-dm with a synthetic pubkey (relay will create the user)
DM_RESULT=$(sprout open-dm --pubkey "0000000000000000000000000000000000000000000000000000000000000001")
echo "$DM_RESULT" | jq .
DM_ID=$(echo "$DM_RESULT" | jq -r '.channel_id // .id // empty')
# add-dm-member (requires messages:write scope — NOT admin:channels)
sprout add-dm-member --channel "$DM_ID" \
--pubkey "0000000000000000000000000000000000000000000000000000000000000002" | jq .
```
### 6.8 Users & Presence
```bash
# get-users — own profile (0 pubkeys)
sprout get-users | jq .
# get-users — single pubkey
sprout get-users --pubkey "$MY_PUBKEY" | jq .
# get-users — batch (2+ pubkeys)
sprout get-users --pubkey "$MY_PUBKEY" --pubkey "$MY_PUBKEY" | jq .
# set-profile
sprout set-profile --name "CLI Test Agent" --about "Testing sprout-cli" | jq .
# get-presence
sprout get-presence --pubkeys "$MY_PUBKEY" | jq .
# set-presence
sprout set-presence --status online | jq .
sprout set-presence --status away | jq .
sprout set-presence --status offline | jq .
# set-channel-add-policy
sprout set-channel-add-policy --policy anyone | jq .
sprout set-channel-add-policy --policy owner_only | jq .
sprout set-channel-add-policy --policy nobody | jq .
# Reset to default
sprout set-channel-add-policy --policy anyone | jq .
```
### 6.9 Channel Members (add/remove require admin:channels)
```bash
# add-channel-member
sprout add-channel-member --channel "$CHANNEL_ID" \
--pubkey "0000000000000000000000000000000000000000000000000000000000000001" \
--role member | jq .
# list-channel-members
sprout list-channel-members --channel "$CHANNEL_ID" | jq .
# remove-channel-member
sprout remove-channel-member --channel "$CHANNEL_ID" \
--pubkey "0000000000000000000000000000000000000000000000000000000000000001" | jq .
```
### 6.10 Workflows
```bash
# create-workflow
# 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=$(sprout create-workflow --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 '.id')
# list-workflows
sprout list-workflows --channel "$CHANNEL_ID" | jq .
# get-workflow
sprout get-workflow --workflow "$WF_ID" | jq .
# update-workflow
sprout update-workflow --workflow "$WF_ID" \
--yaml 'name: test-wf-updated
trigger:
on: webhook
steps:
- id: step1
action: send_message
text: "Updated"' | jq .
# trigger-workflow
sprout trigger-workflow --workflow "$WF_ID" | jq .
# get-workflow-runs
sprout get-workflow-runs --workflow "$WF_ID" | jq .
# approve-step — 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:
sprout approve-step --token "00000000-0000-0000-0000-000000000000" --approved true 2>&1 || true
# Should fail with relay error (token not found), not a validation error
# delete-workflow
sprout delete-workflow --workflow "$WF_ID" | jq .
```
### 6.11 Feed
```bash
sprout get-feed | jq .
sprout get-feed --limit 5 | jq .
```
### 6.12 Forum & Voting
```bash
# Send a forum post (kind 45001) to the forum channel
FORUM_POST=$(sprout send-message --channel "$FORUM_ID" \
--content "Forum post for vote testing" --kind 45001 | jq .)
echo "$FORUM_POST"
FORUM_EVENT_ID=$(echo "$FORUM_POST" | jq -r '.id // .event_id')
# vote-on-post (up)
sprout vote-on-post --event "$FORUM_EVENT_ID" --direction up | jq .
# vote-on-post (down)
sprout vote-on-post --event "$FORUM_EVENT_ID" --direction down | jq .
```
---
## 7. Error Path Testing
Verify the CLI produces correct JSON on stderr and correct exit codes.
```bash
# Exit 1: Invalid UUID
sprout get-channel --channel "not-a-uuid" 2>&1; echo "exit: $?"
# stderr: {"error":"user_error","message":"invalid UUID: not-a-uuid"}
# exit: 1
# Exit 1: Invalid hex64
sprout delete-message --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 --approved value
sprout approve-step --token "00000000-0000-0000-0000-000000000000" \
--approved maybe 2>&1; echo "exit: $?"
# stderr: {"error":"user_error","message":"--approved must be 'true' or 'false' (got: maybe)"}
# exit: 1
# Exit 1: Invalid --type value
sprout create-channel --name x --type invalid --visibility open 2>&1; echo "exit: $?"
# stderr: {"error":"user_error","message":"--type must be 'stream' or 'forum' (got: invalid)"}
# exit: 1
# Exit 1: Invalid --direction value
sprout vote-on-post --event "$(printf '0%.0s' {1..64})" \
--direction sideways 2>&1; echo "exit: $?"
# exit: 1
# Exit 1: Empty body guard
sprout set-profile 2>&1; echo "exit: $?"
# exit: 1 (at least one field required)
# Exit 3: No auth configured
env -u SPROUT_API_TOKEN -u SPROUT_PRIVATE_KEY -u SPROUT_PUBKEY \
cargo run -p sprout-cli -- list-channels 2>&1; echo "exit: $?"
# stderr: {"error":"auth_error","message":"auth error: Set SPROUT_API_TOKEN, SPROUT_PRIVATE_KEY, or SPROUT_PUBKEY"}
# exit: 3
# Exit 2: Non-existent channel (valid UUID)
sprout get-channel --channel "00000000-0000-0000-0000-000000000000" 2>&1; echo "exit: $?"
# stderr: {"error":"relay_error","message":"..."}
# exit: 2
```
---
## 8. Auth Mode Testing
Test all three authentication tiers.
```bash
# Mode 1: Bearer token (SPROUT_API_TOKEN)
SPROUT_API_TOKEN="sprout_tok_..." sprout list-channels | jq .
# Should succeed
# Mode 2: Private key auto-mint (SPROUT_PRIVATE_KEY)
SPROUT_PRIVATE_KEY="nsec1..." sprout list-channels | jq .
# Should succeed (mints a 1-day token at startup)
# Mode 3: Dev mode (SPROUT_PUBKEY) — only works with SPROUT_REQUIRE_AUTH_TOKEN=false
SPROUT_PUBKEY="<your-64-char-hex-pubkey>" sprout list-channels | jq .
# Should succeed
# No auth → exit 3
env -u SPROUT_API_TOKEN -u SPROUT_PRIVATE_KEY -u SPROUT_PUBKEY \
cargo run -p sprout-cli -- list-channels 2>&1; echo "exit: $?"
# exit: 3
```
---
## 9. Cleanup
```bash
# Delete test channels
sprout delete-channel --channel "$CHANNEL_ID" | jq .
sprout delete-channel --channel "$FORUM_ID" | jq .
```
---
## 10. Checklist
| # | Command | Tested | Notes |
|---|---------|:------:|-------|
| 1 | `send-message` | ☐ | Basic, reply, broadcast, mentions |
| 2 | `send-diff-message` | ☐ | Stdin, metadata, branch/PR |
| 3 | `edit-message` | ☐ | |
| 4 | `delete-message` | ☐ | |
| 5 | `get-messages` | ☐ | With limit |
| 6 | `get-thread` | ☐ | |
| 7 | `search` | ☐ | With limit |
| 8 | `list-channels` | ☐ | With visibility, member |
| 9 | `get-channel` | ☐ | |
| 10 | `create-channel` | ☐ | Stream and forum |
| 11 | `update-channel` | ☐ | |
| 12 | `set-channel-topic` | ☐ | |
| 13 | `set-channel-purpose` | ☐ | |
| 14 | `join-channel` | ☐ | |
| 15 | `leave-channel` | ☐ | |
| 16 | `archive-channel` | ☐ | Needs admin:channels |
| 17 | `unarchive-channel` | ☐ | Needs admin:channels |
| 18 | `delete-channel` | ☐ | Needs admin:channels |
| 19 | `list-channel-members` | ☐ | |
| 20 | `add-channel-member` | ☐ | Needs admin:channels |
| 21 | `remove-channel-member` | ☐ | Needs admin:channels |
| 22 | `get-canvas` | ☐ | |
| 23 | `set-canvas` | ☐ | Direct and stdin |
| 24 | `add-reaction` | ☐ | |
| 25 | `remove-reaction` | ☐ | |
| 26 | `get-reactions` | ☐ | |
| 27 | `list-dms` | ☐ | |
| 28 | `open-dm` | ☐ | |
| 29 | `add-dm-member` | ☐ | Needs messages:write |
| 30 | `get-users` | ☐ | Self, single, batch |
| 31 | `set-profile` | ☐ | |
| 32 | `get-presence` | ☐ | |
| 33 | `set-presence` | ☐ | online, away, offline |
| 34 | `set-channel-add-policy` | ☐ | anyone, owner_only, nobody |
| 35 | `list-workflows` | ☐ | |
| 36 | `create-workflow` | ☐ | |
| 37 | `update-workflow` | ☐ | |
| 38 | `delete-workflow` | ☐ | |
| 39 | `trigger-workflow` | ☐ | |
| 40 | `get-workflow-runs` | ☐ | |
| 41 | `get-workflow` | ☐ | |
| 42 | `approve-step` | ☐ | Validation only (needs approval gate) |
| 43 | `get-feed` | ☐ | |
| 44 | `vote-on-post` | ☐ | Up and down |
| 45 | `auth` | ☐ | Mint token via NIP-98 |
| 46 | `list-tokens` | ☐ | |
| 47 | `delete-token` | ☐ | |
| 48 | `delete-all-tokens` | ☐ | Optional (destructive) |

View File

@@ -0,0 +1,234 @@
use std::time::Duration;
use crate::error::CliError;
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
pub enum Auth {
Bearer(String), // SPROUT_API_TOKEN or auto-minted via SPROUT_PRIVATE_KEY
DevMode(String), // SPROUT_PUBKEY — X-Pubkey header, relay must have require_auth_token=false
}
// ---------------------------------------------------------------------------
// SproutClient
// ---------------------------------------------------------------------------
pub struct SproutClient {
http: reqwest::Client,
relay_url: String, // base URL, no trailing slash, e.g. "https://relay.sprout.place"
auth: Auth,
}
impl SproutClient {
pub fn new(relay_url: String, auth: Auth) -> Result<Self, CliError> {
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.connect_timeout(Duration::from_secs(5))
.build()
.map_err(|e| CliError::Other(e.to_string()))?;
Ok(Self {
http,
relay_url,
auth,
})
}
// -----------------------------------------------------------------------
// Core request method
// -----------------------------------------------------------------------
async fn request(
&self,
method: reqwest::Method,
path: &str,
body: Option<&serde_json::Value>,
) -> Result<String, CliError> {
let url = format!("{}{}", self.relay_url, path);
let builder = self.http.request(method, &url);
let builder = self.apply_auth(builder);
let builder = match body {
Some(b) => builder.json(b),
None => builder,
};
let resp = builder.send().await?; // reqwest::Error → CliError::Network via From
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
// Try to extract relay's error message from JSON body
let message = serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| {
v.get("error")
.or_else(|| v.get("message"))
.and_then(|m| m.as_str())
.map(|s| s.to_string())
})
.unwrap_or(body);
return Err(CliError::Relay {
status,
body: message,
});
}
Ok(resp.text().await?)
}
fn apply_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
match &self.auth {
Auth::Bearer(token) => builder.header("Authorization", format!("Bearer {}", token)),
Auth::DevMode(pk) => builder.header("X-Pubkey", pk),
}
}
// -----------------------------------------------------------------------
// Convenience wrappers — print response to stdout
// -----------------------------------------------------------------------
pub async fn run_get(&self, path: &str) -> Result<(), CliError> {
let resp = self.request(reqwest::Method::GET, path, None).await?;
println!("{resp}");
Ok(())
}
pub async fn run_post(&self, path: &str, body: &serde_json::Value) -> Result<(), CliError> {
let resp = self
.request(reqwest::Method::POST, path, Some(body))
.await?;
println!("{resp}");
Ok(())
}
pub async fn run_put(&self, path: &str, body: &serde_json::Value) -> Result<(), CliError> {
let resp = self.request(reqwest::Method::PUT, path, Some(body)).await?;
println!("{resp}");
Ok(())
}
pub async fn run_delete(&self, path: &str) -> Result<(), CliError> {
let resp = self.request(reqwest::Method::DELETE, path, None).await?;
println!("{resp}");
Ok(())
}
// For commands that need the raw response string (e.g. get_users multi-dispatch)
pub async fn get_raw(&self, path: &str) -> Result<String, CliError> {
self.request(reqwest::Method::GET, path, None).await
}
pub async fn post_raw(&self, path: &str, body: &serde_json::Value) -> Result<String, CliError> {
self.request(reqwest::Method::POST, path, Some(body)).await
}
}
// ---------------------------------------------------------------------------
// URL normalization
// ---------------------------------------------------------------------------
/// Normalize a relay URL: ws:// → http://, wss:// → https://, strip trailing slash.
/// SPROUT_RELAY_URL may be ws/wss (copied from MCP config).
pub fn normalize_relay_url(url: &str) -> String {
url.replace("wss://", "https://")
.replace("ws://", "http://")
.trim_end_matches('/')
.to_string()
}
// ---------------------------------------------------------------------------
// Auto-mint token (NIP-98)
// ---------------------------------------------------------------------------
/// Mint a short-lived Bearer token using NIP-98 HTTP auth.
/// Called at startup when SPROUT_PRIVATE_KEY is set.
pub async fn auto_mint_token(relay_url: &str, private_key_str: &str) -> Result<String, CliError> {
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
use sha2::{Digest, Sha256};
let keys = Keys::parse(private_key_str)
.map_err(|e| CliError::Key(format!("invalid SPROUT_PRIVATE_KEY: {e}")))?;
let token_url = format!("{}/api/tokens", relay_url);
// Body bytes for payload hash
let body = serde_json::json!({
"name": "sprout-cli-auto",
"scopes": [
"messages:read", "messages:write",
"channels:read", "channels:write",
"users:read", "users:write",
"files:read", "files:write"
],
"expires_in_days": 1
});
let body_bytes = serde_json::to_vec(&body).map_err(|e| CliError::Other(e.to_string()))?;
// SHA-256 hex of body bytes (NIP-98 payload tag)
let hash = Sha256::digest(&body_bytes);
let sha256_hex = hash
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>();
// Build NIP-98 event
let event = EventBuilder::new(
Kind::HttpAuth,
"",
[
Tag::parse(&["u", &token_url]).map_err(|e| CliError::Key(format!("tag error: {e}")))?,
Tag::parse(&["method", "POST"])
.map_err(|e| CliError::Key(format!("tag error: {e}")))?,
Tag::parse(&["payload", &sha256_hex])
.map_err(|e| CliError::Key(format!("tag error: {e}")))?,
],
)
.sign_with_keys(&keys)
.map_err(|e| CliError::Key(format!("signing failed: {e}")))?;
let auth_header = format!("Nostr {}", B64.encode(event.as_json()));
let http = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.build()
.map_err(|e| CliError::Other(e.to_string()))?;
// Tell the relay which scheme we signed so the canonical URL matches.
// The relay defaults x-forwarded-proto to "https"; without this header,
// http:// URLs fail NIP-98 verification on localhost.
let proto = if token_url.starts_with("https://") {
"https"
} else {
"http"
};
let resp = http
.post(&token_url)
.header("Authorization", auth_header)
.header("x-forwarded-proto", proto)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body = resp.text().await.unwrap_or_default();
return Err(CliError::Auth(format!(
"auto-mint failed ({status}): {body}"
)));
}
let json: serde_json::Value = resp
.json()
.await
.map_err(|e| CliError::Other(format!("invalid auto-mint response: {e}")))?;
json.get("token")
.and_then(|t| t.as_str())
.map(|s| s.to_string())
.ok_or_else(|| CliError::Other("auto-mint response missing 'token' field".into()))
}

View File

@@ -0,0 +1,124 @@
use crate::error::CliError;
use crate::validate::validate_uuid;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine;
use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag};
use sha2::{Digest, Sha256};
/// Mint a long-lived (7-day) API token via NIP-98 and print it to stdout.
///
/// `private_key` is the --private-key CLI flag value; falls back to SPROUT_PRIVATE_KEY env var.
/// Caller stores the printed token (e.g. `export SPROUT_API_TOKEN=$(sprout auth)`).
pub async fn cmd_auth(relay_url: &str, private_key: Option<&str>) -> Result<(), CliError> {
let env_key;
let private_key_str = match private_key {
Some(k) => k,
None => {
env_key = std::env::var("SPROUT_PRIVATE_KEY").map_err(|_| {
CliError::Auth(
"SPROUT_PRIVATE_KEY not set (use --private-key or set env var)".into(),
)
})?;
&env_key
}
};
let keys = Keys::parse(private_key_str)
.map_err(|e| CliError::Key(format!("invalid private key: {e}")))?;
let token_url = format!("{}/api/tokens", relay_url);
// Request body — long-lived token, 7 days
let body = serde_json::json!({
"name": "sprout-cli",
"scopes": [
"messages:read", "messages:write",
"channels:read", "channels:write",
"users:read", "users:write",
"files:read", "files:write"
],
"expires_in_days": 7
});
let body_bytes = serde_json::to_vec(&body).map_err(|e| CliError::Other(e.to_string()))?;
// SHA-256 hex of body bytes (NIP-98 payload tag)
let hash = Sha256::digest(&body_bytes);
let sha256_hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect();
// Build NIP-98 event
let event = EventBuilder::new(
Kind::HttpAuth,
"",
[
Tag::parse(&["u", &token_url])
.map_err(|e| CliError::Key(format!("tag build failed: {e}")))?,
Tag::parse(&["method", "POST"])
.map_err(|e| CliError::Key(format!("tag build failed: {e}")))?,
Tag::parse(&["payload", &sha256_hex])
.map_err(|e| CliError::Key(format!("tag build failed: {e}")))?,
],
)
.sign_with_keys(&keys)
.map_err(|e| CliError::Key(format!("signing failed: {e}")))?;
let auth_header = format!("Nostr {}", B64.encode(event.as_json()));
// POST /api/tokens
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| CliError::Other(e.to_string()))?;
let resp = http
.post(&token_url)
.header("Authorization", auth_header)
.header(
"x-forwarded-proto",
if token_url.starts_with("https://") {
"https"
} else {
"http"
},
)
.json(&body)
.send()
.await
.map_err(CliError::Network)?;
if !resp.status().is_success() {
let status = resp.status().as_u16();
let body_text = resp.text().await.unwrap_or_default();
return Err(CliError::Auth(format!(
"token mint failed ({status}): {body_text}"
)));
}
let resp_body: serde_json::Value = resp
.json()
.await
.map_err(|e| CliError::Other(format!("invalid response: {e}")))?;
let token = resp_body
.get("token")
.and_then(|t| t.as_str())
.ok_or_else(|| CliError::Other("response missing 'token' field".into()))?;
println!("{token}");
Ok(())
}
pub async fn cmd_list_tokens(client: &crate::client::SproutClient) -> Result<(), CliError> {
client.run_get("/api/tokens").await
}
pub async fn cmd_delete_token(
client: &crate::client::SproutClient,
id: &str,
) -> Result<(), CliError> {
validate_uuid(id)?;
client.run_delete(&format!("/api/tokens/{}", id)).await
}
pub async fn cmd_delete_all_tokens(client: &crate::client::SproutClient) -> Result<(), CliError> {
client.run_delete("/api/tokens").await
}

View File

@@ -0,0 +1,228 @@
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::{percent_encode, read_or_stdin, validate_hex64, validate_uuid};
pub async fn cmd_list_channels(
client: &SproutClient,
visibility: Option<&str>,
member: Option<bool>,
) -> Result<(), CliError> {
let mut path = "/api/channels".to_string();
let mut sep = '?';
if let Some(v) = visibility {
path.push_str(&format!("{}visibility={}", sep, percent_encode(v)));
sep = '&';
}
if let Some(m) = member {
path.push_str(&format!("{}member={}", sep, m));
}
client.run_get(&path).await
}
pub async fn cmd_get_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
client
.run_get(&format!("/api/channels/{}", channel_id))
.await
}
pub async fn cmd_create_channel(
client: &SproutClient,
name: &str,
channel_type: &str,
visibility: &str,
description: Option<&str>,
) -> Result<(), CliError> {
match channel_type {
"stream" | "forum" => {}
_ => {
return Err(CliError::Usage(format!(
"--type must be 'stream' or 'forum' (got: {channel_type})"
)))
}
}
match visibility {
"open" | "private" => {}
_ => {
return Err(CliError::Usage(format!(
"--visibility must be 'open' or 'private' (got: {visibility})"
)))
}
}
let mut body = serde_json::json!({
"name": name,
"channel_type": channel_type,
"visibility": visibility,
});
if let Some(d) = description {
body["description"] = d.into();
}
client.run_post("/api/channels", &body).await
}
pub async fn cmd_update_channel(
client: &SproutClient,
channel_id: &str,
name: Option<&str>,
description: Option<&str>,
) -> Result<(), CliError> {
if name.is_none() && description.is_none() {
return Err(CliError::Usage(
"at least one field required (--name, --description)".into(),
));
}
validate_uuid(channel_id)?;
let mut body = serde_json::json!({});
if let Some(n) = name {
body["name"] = n.into();
}
if let Some(d) = description {
body["description"] = d.into();
}
client
.run_put(&format!("/api/channels/{}", channel_id), &body)
.await
}
pub async fn cmd_set_channel_topic(
client: &SproutClient,
channel_id: &str,
topic: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
client
.run_put(
&format!("/api/channels/{}/topic", channel_id),
&serde_json::json!({ "topic": topic }),
)
.await
}
pub async fn cmd_set_channel_purpose(
client: &SproutClient,
channel_id: &str,
purpose: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
client
.run_put(
&format!("/api/channels/{}/purpose", channel_id),
&serde_json::json!({ "purpose": purpose }),
)
.await
}
pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
client
.run_post(
&format!("/api/channels/{}/join", channel_id),
&serde_json::json!({}),
)
.await
}
pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
client
.run_post(
&format!("/api/channels/{}/leave", channel_id),
&serde_json::json!({}),
)
.await
}
pub async fn cmd_archive_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
client
.run_post(
&format!("/api/channels/{}/archive", channel_id),
&serde_json::json!({}),
)
.await
}
pub async fn cmd_unarchive_channel(
client: &SproutClient,
channel_id: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
client
.run_post(
&format!("/api/channels/{}/unarchive", channel_id),
&serde_json::json!({}),
)
.await
}
pub async fn cmd_delete_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
client
.run_delete(&format!("/api/channels/{}", channel_id))
.await
}
pub async fn cmd_list_channel_members(
client: &SproutClient,
channel_id: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
client
.run_get(&format!("/api/channels/{}/members", channel_id))
.await
}
pub async fn cmd_add_channel_member(
client: &SproutClient,
channel_id: &str,
pubkey: &str,
role: Option<&str>,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
validate_hex64(pubkey)?;
let mut body = serde_json::json!({ "pubkeys": [pubkey] });
if let Some(r) = role {
body["role"] = r.into();
}
client
.run_post(&format!("/api/channels/{}/members", channel_id), &body)
.await
}
pub async fn cmd_remove_channel_member(
client: &SproutClient,
channel_id: &str,
pubkey: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
validate_hex64(pubkey)?;
client
.run_delete(&format!(
"/api/channels/{}/members/{}",
channel_id,
percent_encode(pubkey),
))
.await
}
pub async fn cmd_get_canvas(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
client
.run_get(&format!("/api/channels/{}/canvas", channel_id))
.await
}
pub async fn cmd_set_canvas(
client: &SproutClient,
channel_id: &str,
content: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let content = read_or_stdin(content)?;
client
.run_put(
&format!("/api/channels/{}/canvas", channel_id),
&serde_json::json!({ "content": content }),
)
.await
}

View File

@@ -0,0 +1,47 @@
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::{percent_encode, validate_hex64, validate_uuid};
pub async fn cmd_list_dms(
client: &SproutClient,
cursor: Option<&str>,
limit: Option<u32>,
) -> Result<(), CliError> {
let mut path = "/api/dms".to_string();
let mut sep = '?';
if let Some(c) = cursor {
path.push_str(&format!("{}cursor={}", sep, percent_encode(c)));
sep = '&';
}
if let Some(l) = limit {
path.push_str(&format!("{}limit={}", sep, l));
}
client.run_get(&path).await
}
pub async fn cmd_open_dm(client: &SproutClient, 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)?;
}
client
.run_post("/api/dms", &serde_json::json!({ "pubkeys": pubkeys }))
.await
}
pub async fn cmd_add_dm_member(
client: &SproutClient,
channel_id: &str,
pubkey: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
validate_hex64(pubkey)?;
client
.run_post(
&format!("/api/dms/{}/members", channel_id),
&serde_json::json!({ "pubkeys": [pubkey] }),
)
.await
}

View File

@@ -0,0 +1,20 @@
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::percent_encode;
pub async fn cmd_get_feed(
client: &SproutClient,
since: Option<i64>,
limit: Option<u32>,
types: Option<&str>,
) -> Result<(), CliError> {
let limit = limit.unwrap_or(20).min(50);
let mut path = format!("/api/feed?limit={}", limit);
if let Some(s) = since {
path.push_str(&format!("&since={s}"));
}
if let Some(t) = types {
path.push_str(&format!("&types={}", percent_encode(t)));
}
client.run_get(&path).await
}

View File

@@ -0,0 +1,240 @@
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::{
infer_language, normalize_mention_pubkeys, percent_encode, read_or_stdin, truncate_diff,
validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES,
};
pub async fn cmd_get_messages(
client: &SproutClient,
channel_id: &str,
limit: Option<u32>,
before: Option<i64>,
kinds: Option<&str>,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let limit = limit.unwrap_or(50).min(200);
let mut path = format!("/api/channels/{}/messages?limit={}", channel_id, limit);
if let Some(b) = before {
path.push_str(&format!("&before={b}"));
}
if let Some(k) = kinds {
path.push_str(&format!("&kinds={}", percent_encode(k)));
}
client.run_get(&path).await
}
pub async fn cmd_get_thread(
client: &SproutClient,
channel_id: &str,
event_id: &str,
depth_limit: Option<u32>,
limit: Option<u32>,
cursor: Option<&str>,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
validate_hex64(event_id)?;
let limit = limit.unwrap_or(100).min(500);
let mut path = format!(
"/api/channels/{}/threads/{}?limit={}",
channel_id, event_id, limit
);
if let Some(d) = depth_limit {
path.push_str(&format!("&depth_limit={d}"));
}
if let Some(c) = cursor {
path.push_str(&format!("&cursor={}", percent_encode(c)));
}
client.run_get(&path).await
}
pub async fn cmd_send_message(
client: &SproutClient,
channel_id: &str,
content: &str,
kind: Option<u16>,
reply_to: Option<&str>,
broadcast: bool,
mentions: &[String],
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
validate_content_size(content)?;
if let Some(r) = reply_to {
validate_hex64(r)?;
}
for m in mentions {
validate_hex64(m)?;
}
// Normalize mentions: lowercase, deduplicate, remove sender (unknown in v1)
let normalized_mentions = normalize_mention_pubkeys(mentions, "");
let mut body = serde_json::json!({
"content": content,
"broadcast_to_channel": broadcast,
});
// kind: u16 flag → u32 in JSON body (all valid Nostr kinds fit in u16)
if let Some(k) = kind {
body["kind"] = (k as u32).into();
}
if let Some(r) = reply_to {
body["parent_event_id"] = r.into();
}
if !normalized_mentions.is_empty() {
body["mention_pubkeys"] = normalized_mentions.into();
}
client
.run_post(&format!("/api/channels/{}/messages", channel_id), &body)
.await
}
pub struct SendDiffParams {
pub channel_id: String,
pub diff: String,
pub repo_url: String,
pub commit_sha: String,
pub file_path: Option<String>,
pub parent_commit_sha: Option<String>,
pub source_branch: Option<String>,
pub target_branch: Option<String>,
pub pr_number: Option<u32>,
pub language: Option<String>,
pub description: Option<String>,
pub reply_to: Option<String>,
}
pub async fn cmd_send_diff_message(
client: &SproutClient,
p: SendDiffParams,
) -> Result<(), CliError> {
validate_uuid(&p.channel_id)?;
if let Some(r) = &p.reply_to {
validate_hex64(r)?;
}
// Branch pairing: both or neither
match (&p.source_branch, &p.target_branch) {
(Some(_), None) | (None, Some(_)) => {
return Err(CliError::Usage(
"--source-branch and --target-branch must both be provided or both omitted".into(),
));
}
_ => {}
}
// Read diff from stdin if "--diff -"
let diff_content = read_or_stdin(&p.diff)?;
// Truncate at 60 KiB hunk boundary
let (diff, truncated) = truncate_diff(&diff_content, MAX_DIFF_BYTES);
// Language inference: explicit flag wins, then infer from file path
let language = p
.language
.clone()
.or_else(|| p.file_path.as_deref().and_then(infer_language));
// NIP-31 alt tag
let alt = match (&p.file_path, &p.description) {
(Some(fp), Some(desc)) => format!("Diff: {} — {}", fp, desc),
(Some(fp), None) => format!("Diff: {}", fp),
_ => "Diff".to_string(),
};
let mut body = serde_json::json!({
"content": diff,
"kind": 40008,
"broadcast_to_channel": false,
"diff_repo_url": p.repo_url,
"diff_commit_sha": p.commit_sha,
"diff_alt": alt,
});
if truncated {
body["diff_truncated"] = true.into();
}
if let Some(fp) = &p.file_path {
body["diff_file_path"] = fp.clone().into();
}
if let Some(pc) = &p.parent_commit_sha {
body["diff_parent_commit_sha"] = pc.clone().into();
}
if let Some(sb) = &p.source_branch {
body["diff_source_branch"] = sb.clone().into();
}
if let Some(tb) = &p.target_branch {
body["diff_target_branch"] = tb.clone().into();
}
if let Some(pr) = p.pr_number {
body["diff_pr_number"] = pr.into();
}
if let Some(lg) = language {
body["diff_language"] = lg.into();
}
if let Some(ds) = &p.description {
body["diff_description"] = ds.clone().into();
}
if let Some(re) = &p.reply_to {
body["parent_event_id"] = re.clone().into();
}
client
.run_post(&format!("/api/channels/{}/messages", p.channel_id), &body)
.await
}
pub async fn cmd_delete_message(client: &SproutClient, event_id: &str) -> Result<(), CliError> {
validate_hex64(event_id)?;
client
.run_delete(&format!("/api/messages/{}", event_id))
.await
}
/// Edit a message you previously sent.
pub async fn cmd_edit_message(
client: &SproutClient,
event_id: &str,
content: &str,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
validate_content_size(content)?;
client
.run_put(
&format!("/api/messages/{}", event_id),
&serde_json::json!({ "content": content }),
)
.await
}
/// Vote on a forum post or comment.
pub async fn cmd_vote_on_post(
client: &SproutClient,
event_id: &str,
direction: &str,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
match direction {
"up" | "down" => {}
_ => {
return Err(CliError::Usage(format!(
"--direction must be 'up' or 'down' (got: {direction})"
)))
}
}
client
.run_post(
&format!("/api/messages/{}/votes", event_id),
&serde_json::json!({ "direction": direction }),
)
.await
}
pub async fn cmd_search(
client: &SproutClient,
query: &str,
limit: Option<u32>,
) -> Result<(), CliError> {
let limit = limit.unwrap_or(20).min(100);
let path = format!("/api/search?q={}&limit={}", percent_encode(query), limit);
client.run_get(&path).await
}

View File

@@ -0,0 +1,8 @@
pub mod auth;
pub mod channels;
pub mod dms;
pub mod feed;
pub mod messages;
pub mod reactions;
pub mod users;
pub mod workflows;

View File

@@ -0,0 +1,42 @@
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::{percent_encode, validate_hex64};
pub async fn cmd_add_reaction(
client: &SproutClient,
event_id: &str,
emoji: &str,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
client
.run_post(
&format!("/api/messages/{}/reactions", percent_encode(event_id)),
&serde_json::json!({ "emoji": emoji }),
)
.await
}
pub async fn cmd_remove_reaction(
client: &SproutClient,
event_id: &str,
emoji: &str,
) -> Result<(), CliError> {
validate_hex64(event_id)?;
client
.run_delete(&format!(
"/api/messages/{}/reactions/{}",
percent_encode(event_id),
percent_encode(emoji),
))
.await
}
pub async fn cmd_get_reactions(client: &SproutClient, event_id: &str) -> Result<(), CliError> {
validate_hex64(event_id)?;
client
.run_get(&format!(
"/api/messages/{}/reactions",
percent_encode(event_id),
))
.await
}

View File

@@ -0,0 +1,111 @@
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::{percent_encode, validate_hex64};
/// 3-way dispatch based on pubkey count:
/// 0 pubkeys → GET /api/users/me/profile
/// 1 pubkey → GET /api/users/{pk}/profile
/// 2+ pubkeys → POST /api/users/batch
pub async fn cmd_get_users(client: &SproutClient, pubkeys: &[String]) -> Result<(), CliError> {
for pk in pubkeys {
validate_hex64(pk)?;
}
if pubkeys.len() > 200 {
return Err(CliError::Usage("--pubkey: maximum 200 pubkeys".into()));
}
let resp = match pubkeys.len() {
0 => client.get_raw("/api/users/me/profile").await?,
1 => {
client
.get_raw(&format!(
"/api/users/{}/profile",
percent_encode(&pubkeys[0])
))
.await?
}
_ => {
client
.post_raw(
"/api/users/batch",
&serde_json::json!({ "pubkeys": pubkeys }),
)
.await?
}
};
println!("{resp}");
Ok(())
}
pub async fn cmd_set_profile(
client: &SproutClient,
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(),
));
}
let mut body = serde_json::json!({});
if let Some(n) = display_name {
body["display_name"] = n.into();
}
if let Some(a) = avatar_url {
body["avatar_url"] = a.into();
}
if let Some(a) = about {
body["about"] = a.into();
}
if let Some(h) = nip05_handle {
body["nip05_handle"] = h.into();
}
client.run_put("/api/users/me/profile", &body).await
}
pub async fn cmd_get_presence(client: &SproutClient, pubkeys_csv: &str) -> Result<(), CliError> {
for pk in pubkeys_csv.split(',') {
let pk = pk.trim();
if !pk.is_empty() {
validate_hex64(pk)?;
}
}
let path = format!("/api/presence?pubkeys={}", percent_encode(pubkeys_csv));
client.run_get(&path).await
}
pub async fn cmd_set_presence(client: &SproutClient, status: &str) -> Result<(), CliError> {
match status {
"online" | "away" | "offline" => {}
_ => {
return Err(CliError::Usage(format!(
"--status must be one of: online, away, offline (got: {status})"
)))
}
}
client
.run_put("/api/presence", &serde_json::json!({ "status": status }))
.await
}
pub async fn cmd_set_channel_add_policy(
client: &SproutClient,
policy: &str,
) -> Result<(), CliError> {
match policy {
"anyone" | "owner_only" | "nobody" => {}
_ => {
return Err(CliError::Usage(format!(
"--policy must be one of: anyone, owner_only, nobody (got: {policy})"
)))
}
}
client
.run_put(
"/api/users/me/channel-add-policy",
&serde_json::json!({ "channel_add_policy": policy }),
)
.await
}

View File

@@ -0,0 +1,99 @@
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::{read_or_stdin, validate_uuid};
pub async fn cmd_list_workflows(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
client
.run_get(&format!("/api/channels/{}/workflows", channel_id))
.await
}
pub async fn cmd_create_workflow(
client: &SproutClient,
channel_id: &str,
yaml: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let yaml_definition = read_or_stdin(yaml)?;
client
.run_post(
&format!("/api/channels/{}/workflows", channel_id),
&serde_json::json!({ "yaml_definition": yaml_definition }),
)
.await
}
pub async fn cmd_update_workflow(
client: &SproutClient,
workflow_id: &str,
yaml: &str,
) -> Result<(), CliError> {
validate_uuid(workflow_id)?;
let yaml_definition = read_or_stdin(yaml)?;
client
.run_put(
&format!("/api/workflows/{}", workflow_id),
&serde_json::json!({ "yaml_definition": yaml_definition }),
)
.await
}
pub async fn cmd_delete_workflow(client: &SproutClient, workflow_id: &str) -> Result<(), CliError> {
validate_uuid(workflow_id)?;
client
.run_delete(&format!("/api/workflows/{}", workflow_id))
.await
}
pub async fn cmd_trigger_workflow(
client: &SproutClient,
workflow_id: &str,
) -> Result<(), CliError> {
validate_uuid(workflow_id)?;
client
.run_post(
&format!("/api/workflows/{}/trigger", workflow_id),
&serde_json::json!({}),
)
.await
}
pub async fn cmd_get_workflow_runs(
client: &SproutClient,
workflow_id: &str,
limit: Option<u32>,
) -> Result<(), CliError> {
validate_uuid(workflow_id)?;
let limit = limit.unwrap_or(20).min(100);
let path = format!("/api/workflows/{}/runs?limit={}", workflow_id, limit);
client.run_get(&path).await
}
pub async fn cmd_get_workflow(client: &SproutClient, workflow_id: &str) -> Result<(), CliError> {
validate_uuid(workflow_id)?;
client
.run_get(&format!("/api/workflows/{}", workflow_id))
.await
}
/// Route is /grant or /deny based on the `approved` flag.
pub async fn cmd_approve_step(
client: &SproutClient,
approval_token: &str,
approved: bool,
note: Option<&str>,
) -> Result<(), CliError> {
validate_uuid(approval_token)?;
let route = if approved { "grant" } else { "deny" };
let mut body = serde_json::json!({});
if let Some(n) = note {
body["note"] = n.into();
}
client
.run_post(
&format!("/api/approvals/{}/{}", approval_token, route),
&body,
)
.await
}

View File

@@ -0,0 +1,71 @@
use thiserror::Error;
#[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: {0}")]
Network(#[from] reqwest::Error),
/// Auth missing or rejected (401/403)
#[error("auth error: {0}")]
Auth(String),
/// Nostr key error (NIP-98 signing in `sprout auth`)
#[error("key error: {0}")]
Key(String),
/// Catch-all for unexpected failures
#[error("{0}")]
Other(String),
}
/// Map CliError to process exit code.
/// 0=success (not an error), 1=user, 2=network/relay, 3=auth, 4=other
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::Other(_) => 4,
}
}
/// Serialize error to JSON and write to stderr.
/// Format: {"error": "<category>", "message": "<human-readable detail>"}
pub fn print_error(e: &CliError) {
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::Other(_) => "error",
};
let obj = serde_json::json!({
"error": category,
"message": e.to_string(),
});
eprintln!("{}", obj);
}

View File

@@ -0,0 +1,835 @@
mod client;
mod commands;
mod error;
mod validate;
use clap::{Parser, Subcommand};
use client::{Auth, SproutClient};
use error::CliError;
// ---------------------------------------------------------------------------
// Top-level CLI
// ---------------------------------------------------------------------------
#[derive(Parser)]
#[command(name = "sprout", about = "Sprout CLI — interact with a Sprout relay")]
struct Cli {
#[arg(
long,
env = "SPROUT_RELAY_URL",
default_value = "http://localhost:3000"
)]
relay: String,
#[arg(long, env = "SPROUT_API_TOKEN")]
token: Option<String>,
#[arg(long, env = "SPROUT_PRIVATE_KEY", hide = true)]
private_key: Option<String>,
#[arg(long, env = "SPROUT_PUBKEY")]
pubkey: Option<String>,
#[command(subcommand)]
command: Cmd,
}
// ---------------------------------------------------------------------------
// Subcommands
// ---------------------------------------------------------------------------
#[derive(Subcommand)]
enum Cmd {
// ---- Messages ----------------------------------------------------------
/// Send a message to a channel
SendMessage {
#[arg(long)]
channel: String,
#[arg(long)]
content: String,
#[arg(long)]
kind: Option<u16>,
#[arg(long)]
reply_to: Option<String>,
#[arg(long, default_value_t = false)]
broadcast: bool,
#[arg(long = "mention")]
mentions: Vec<String>,
},
/// Send a diff/code-review message
SendDiffMessage {
#[arg(long)]
channel: String,
#[arg(long)]
diff: String,
#[arg(long)]
repo: String,
#[arg(long)]
commit: String,
#[arg(long)]
file: Option<String>,
#[arg(long)]
parent_commit: Option<String>,
#[arg(long)]
source_branch: Option<String>,
#[arg(long)]
target_branch: Option<String>,
#[arg(long)]
pr: Option<u32>,
#[arg(long)]
lang: Option<String>,
#[arg(long)]
description: Option<String>,
#[arg(long)]
reply_to: Option<String>,
},
/// Delete a message by event ID
DeleteMessage {
#[arg(long)]
event: String,
},
/// Get messages from a channel
GetMessages {
#[arg(long)]
channel: String,
#[arg(long)]
limit: Option<u32>,
#[arg(long)]
before: Option<i64>,
#[arg(long)]
kinds: Option<String>,
},
/// Get a message thread
GetThread {
#[arg(long)]
channel: String,
#[arg(long)]
event: String,
#[arg(long)]
depth_limit: Option<u32>,
#[arg(long)]
limit: Option<u32>,
#[arg(long)]
cursor: Option<String>,
},
/// Search messages
Search {
#[arg(long)]
query: String,
#[arg(long)]
limit: Option<u32>,
},
/// Edit a message you previously sent
EditMessage {
/// Event ID of the message to edit (64-char hex)
#[arg(long)]
event: String,
/// New message content
#[arg(long)]
content: String,
},
/// Vote on a forum post or comment (up or down)
VoteOnPost {
/// Event ID of the post to vote on (64-char hex)
#[arg(long)]
event: String,
/// Vote direction: "up" or "down"
#[arg(long)]
direction: String,
},
// ---- Channels ----------------------------------------------------------
/// List channels
ListChannels {
#[arg(long)]
visibility: Option<String>,
#[arg(long, default_value_t = false)]
member: bool,
},
/// Get a channel by ID
GetChannel {
#[arg(long)]
channel: String,
},
/// Create a new channel
CreateChannel {
#[arg(long)]
name: String,
#[arg(long = "type")]
channel_type: String,
#[arg(long)]
visibility: String,
#[arg(long)]
description: Option<String>,
},
/// Update a channel's name or description
UpdateChannel {
#[arg(long)]
channel: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
description: Option<String>,
},
/// Set a channel's topic
SetChannelTopic {
#[arg(long)]
channel: String,
#[arg(long)]
topic: String,
},
/// Set a channel's purpose
SetChannelPurpose {
#[arg(long)]
channel: String,
#[arg(long)]
purpose: String,
},
/// Join a channel
JoinChannel {
#[arg(long)]
channel: String,
},
/// Leave a channel
LeaveChannel {
#[arg(long)]
channel: String,
},
/// Archive a channel
ArchiveChannel {
#[arg(long)]
channel: String,
},
/// Unarchive a channel
UnarchiveChannel {
#[arg(long)]
channel: String,
},
/// Delete a channel
DeleteChannel {
#[arg(long)]
channel: String,
},
/// List channel members
ListChannelMembers {
#[arg(long)]
channel: String,
},
/// Add a member to a channel
AddChannelMember {
#[arg(long)]
channel: String,
#[arg(long)]
pubkey: String,
#[arg(long)]
role: Option<String>,
},
/// Remove a member from a channel
RemoveChannelMember {
#[arg(long)]
channel: String,
#[arg(long)]
pubkey: String,
},
/// Get a channel's canvas
GetCanvas {
#[arg(long)]
channel: String,
},
/// Set a channel's canvas content
SetCanvas {
#[arg(long)]
channel: String,
#[arg(long)]
content: String,
},
// ---- Reactions ---------------------------------------------------------
/// Add a reaction to a message
AddReaction {
#[arg(long)]
event: String,
#[arg(long)]
emoji: String,
},
/// Remove a reaction from a message
RemoveReaction {
#[arg(long)]
event: String,
#[arg(long)]
emoji: String,
},
/// Get reactions on a message
GetReactions {
#[arg(long)]
event: String,
},
// ---- DMs ---------------------------------------------------------------
/// List DM conversations
ListDms {
#[arg(long)]
cursor: Option<String>,
#[arg(long)]
limit: Option<u32>,
},
/// Open a DM with one or more users (1–8 pubkeys)
OpenDm {
#[arg(long = "pubkey")]
pubkeys: Vec<String>,
},
/// Add a member to a DM group
AddDmMember {
#[arg(long)]
channel: String,
#[arg(long)]
pubkey: String,
},
// ---- Users -------------------------------------------------------------
/// Get user profiles (0 = self, 1 = single, 2+ = batch)
GetUsers {
#[arg(long = "pubkey")]
pubkeys: Vec<String>,
},
/// Update your profile
SetProfile {
#[arg(long)]
name: Option<String>,
#[arg(long)]
avatar: Option<String>,
#[arg(long)]
about: Option<String>,
#[arg(long)]
nip05: Option<String>,
},
/// Get presence status for users (comma-separated pubkeys)
GetPresence {
#[arg(long)]
pubkeys: String,
},
/// Set your presence status
SetPresence {
#[arg(long)]
status: String,
},
/// Set who can add you to channels
SetChannelAddPolicy {
#[arg(long)]
policy: String,
},
// ---- Workflows ---------------------------------------------------------
/// List workflows in a channel
ListWorkflows {
#[arg(long)]
channel: String,
},
/// Create a workflow in a channel
CreateWorkflow {
#[arg(long)]
channel: String,
#[arg(long)]
yaml: String,
},
/// Update a workflow
UpdateWorkflow {
#[arg(long)]
workflow: String,
#[arg(long)]
yaml: String,
},
/// Delete a workflow
DeleteWorkflow {
#[arg(long)]
workflow: String,
},
/// Trigger a workflow manually
TriggerWorkflow {
#[arg(long)]
workflow: String,
},
/// Get workflow run history
GetWorkflowRuns {
#[arg(long)]
workflow: String,
#[arg(long)]
limit: Option<u32>,
},
/// Get a workflow definition
GetWorkflow {
#[arg(long)]
workflow: String,
},
/// Approve or deny a workflow approval step
ApproveStep {
#[arg(long)]
token: String,
/// Whether to approve: "true" or "false"
#[arg(long)]
approved: String,
#[arg(long)]
note: Option<String>,
},
// ---- Feed --------------------------------------------------------------
/// Get your activity feed
GetFeed {
#[arg(long)]
since: Option<i64>,
#[arg(long)]
limit: Option<u32>,
#[arg(long)]
types: Option<String>,
},
// ---- Auth & Tokens -----------------------------------------------------
/// Mint a long-lived API token (prints token to stdout)
Auth,
/// List your API tokens
ListTokens,
/// Delete an API token by ID
DeleteToken {
#[arg(long)]
id: String,
},
/// Delete all your API tokens
DeleteAllTokens,
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
#[tokio::main]
async fn main() {
let cli = match Cli::try_parse() {
Ok(cli) => cli,
Err(e) => {
if e.use_stderr() {
error::print_error(&CliError::Usage(e.to_string()));
std::process::exit(1);
} else {
// --help and --version: print normally (intentional human output)
let _ = e.print();
std::process::exit(0);
}
}
};
match run(cli).await {
Ok(()) => {}
Err(e) => {
error::print_error(&e);
std::process::exit(error::exit_code(&e));
}
}
}
/// Parse a string flag that must be "true" or "false".
fn parse_bool_flag(flag_name: &str, value: &str) -> Result<bool, CliError> {
match value {
"true" => Ok(true),
"false" => Ok(false),
other => Err(CliError::Usage(format!(
"{flag_name} must be 'true' or 'false' (got: {other})"
))),
}
}
async fn run(cli: Cli) -> Result<(), CliError> {
let relay_url = client::normalize_relay_url(&cli.relay);
// Auth command is special — runs before SproutClient creation.
// Passes --private-key flag; cmd_auth falls back to SPROUT_PRIVATE_KEY env var.
if let Cmd::Auth = &cli.command {
return commands::auth::cmd_auth(&relay_url, cli.private_key.as_deref()).await;
}
// Auth resolution: token > private_key (auto-mint) > pubkey > error
let auth = if let Some(token) = cli.token {
Auth::Bearer(token)
} else if let Some(key) = cli.private_key {
let minted = client::auto_mint_token(&relay_url, &key).await?;
Auth::Bearer(minted)
} else if let Some(pk) = cli.pubkey {
Auth::DevMode(pk)
} else {
return Err(CliError::Auth(
"Set SPROUT_API_TOKEN, SPROUT_PRIVATE_KEY, or SPROUT_PUBKEY".into(),
));
};
let client = SproutClient::new(relay_url, auth)?;
match cli.command {
// ---- Messages ------------------------------------------------------
Cmd::SendMessage {
channel,
content,
kind,
reply_to,
broadcast,
mentions,
} => {
commands::messages::cmd_send_message(
&client,
&channel,
&content,
kind,
reply_to.as_deref(),
broadcast,
&mentions,
)
.await
}
Cmd::SendDiffMessage {
channel,
diff,
repo,
commit,
file,
parent_commit,
source_branch,
target_branch,
pr,
lang,
description,
reply_to,
} => {
commands::messages::cmd_send_diff_message(
&client,
commands::messages::SendDiffParams {
channel_id: channel,
diff,
repo_url: repo,
commit_sha: commit,
file_path: file,
parent_commit_sha: parent_commit,
source_branch,
target_branch,
pr_number: pr,
language: lang,
description,
reply_to,
},
)
.await
}
Cmd::DeleteMessage { event } => {
commands::messages::cmd_delete_message(&client, &event).await
}
Cmd::GetMessages {
channel,
limit,
before,
kinds,
} => {
commands::messages::cmd_get_messages(&client, &channel, limit, before, kinds.as_deref())
.await
}
Cmd::GetThread {
channel,
event,
depth_limit,
limit,
cursor,
} => {
commands::messages::cmd_get_thread(
&client,
&channel,
&event,
depth_limit,
limit,
cursor.as_deref(),
)
.await
}
Cmd::Search { query, limit } => {
commands::messages::cmd_search(&client, &query, limit).await
}
Cmd::EditMessage { event, content } => {
commands::messages::cmd_edit_message(&client, &event, &content).await
}
Cmd::VoteOnPost { event, direction } => {
commands::messages::cmd_vote_on_post(&client, &event, &direction).await
}
// ---- Channels ------------------------------------------------------
Cmd::ListChannels { visibility, member } => {
commands::channels::cmd_list_channels(&client, visibility.as_deref(), Some(member))
.await
}
Cmd::GetChannel { channel } => commands::channels::cmd_get_channel(&client, &channel).await,
Cmd::CreateChannel {
name,
channel_type,
visibility,
description,
} => {
commands::channels::cmd_create_channel(
&client,
&name,
&channel_type,
&visibility,
description.as_deref(),
)
.await
}
Cmd::UpdateChannel {
channel,
name,
description,
} => {
commands::channels::cmd_update_channel(
&client,
&channel,
name.as_deref(),
description.as_deref(),
)
.await
}
Cmd::SetChannelTopic { channel, topic } => {
commands::channels::cmd_set_channel_topic(&client, &channel, &topic).await
}
Cmd::SetChannelPurpose { channel, purpose } => {
commands::channels::cmd_set_channel_purpose(&client, &channel, &purpose).await
}
Cmd::JoinChannel { channel } => {
commands::channels::cmd_join_channel(&client, &channel).await
}
Cmd::LeaveChannel { channel } => {
commands::channels::cmd_leave_channel(&client, &channel).await
}
Cmd::ArchiveChannel { channel } => {
commands::channels::cmd_archive_channel(&client, &channel).await
}
Cmd::UnarchiveChannel { channel } => {
commands::channels::cmd_unarchive_channel(&client, &channel).await
}
Cmd::DeleteChannel { channel } => {
commands::channels::cmd_delete_channel(&client, &channel).await
}
Cmd::ListChannelMembers { channel } => {
commands::channels::cmd_list_channel_members(&client, &channel).await
}
Cmd::AddChannelMember {
channel,
pubkey,
role,
} => {
commands::channels::cmd_add_channel_member(&client, &channel, &pubkey, role.as_deref())
.await
}
Cmd::RemoveChannelMember { channel, pubkey } => {
commands::channels::cmd_remove_channel_member(&client, &channel, &pubkey).await
}
Cmd::GetCanvas { channel } => commands::channels::cmd_get_canvas(&client, &channel).await,
Cmd::SetCanvas { channel, content } => {
commands::channels::cmd_set_canvas(&client, &channel, &content).await
}
// ---- Reactions -----------------------------------------------------
Cmd::AddReaction { event, emoji } => {
commands::reactions::cmd_add_reaction(&client, &event, &emoji).await
}
Cmd::RemoveReaction { event, emoji } => {
commands::reactions::cmd_remove_reaction(&client, &event, &emoji).await
}
Cmd::GetReactions { event } => {
commands::reactions::cmd_get_reactions(&client, &event).await
}
// ---- DMs -----------------------------------------------------------
Cmd::ListDms { cursor, limit } => {
commands::dms::cmd_list_dms(&client, cursor.as_deref(), limit).await
}
Cmd::OpenDm { pubkeys } => commands::dms::cmd_open_dm(&client, &pubkeys).await,
Cmd::AddDmMember { channel, pubkey } => {
commands::dms::cmd_add_dm_member(&client, &channel, &pubkey).await
}
// ---- Users ---------------------------------------------------------
Cmd::GetUsers { pubkeys } => commands::users::cmd_get_users(&client, &pubkeys).await,
Cmd::SetProfile {
name,
avatar,
about,
nip05,
} => {
commands::users::cmd_set_profile(
&client,
name.as_deref(),
avatar.as_deref(),
about.as_deref(),
nip05.as_deref(),
)
.await
}
Cmd::GetPresence { pubkeys } => commands::users::cmd_get_presence(&client, &pubkeys).await,
Cmd::SetPresence { status } => commands::users::cmd_set_presence(&client, &status).await,
Cmd::SetChannelAddPolicy { policy } => {
commands::users::cmd_set_channel_add_policy(&client, &policy).await
}
// ---- Workflows -----------------------------------------------------
Cmd::ListWorkflows { channel } => {
commands::workflows::cmd_list_workflows(&client, &channel).await
}
Cmd::CreateWorkflow { channel, yaml } => {
commands::workflows::cmd_create_workflow(&client, &channel, &yaml).await
}
Cmd::UpdateWorkflow { workflow, yaml } => {
commands::workflows::cmd_update_workflow(&client, &workflow, &yaml).await
}
Cmd::DeleteWorkflow { workflow } => {
commands::workflows::cmd_delete_workflow(&client, &workflow).await
}
Cmd::TriggerWorkflow { workflow } => {
commands::workflows::cmd_trigger_workflow(&client, &workflow).await
}
Cmd::GetWorkflowRuns { workflow, limit } => {
commands::workflows::cmd_get_workflow_runs(&client, &workflow, limit).await
}
Cmd::GetWorkflow { workflow } => {
commands::workflows::cmd_get_workflow(&client, &workflow).await
}
Cmd::ApproveStep {
token,
approved,
note,
} => {
let approved = parse_bool_flag("--approved", &approved)?;
commands::workflows::cmd_approve_step(&client, &token, approved, note.as_deref()).await
}
// ---- Feed ----------------------------------------------------------
Cmd::GetFeed {
since,
limit,
types,
} => commands::feed::cmd_get_feed(&client, since, limit, types.as_deref()).await,
// ---- Auth & Tokens -------------------------------------------------
Cmd::Auth => unreachable!("handled above"),
Cmd::ListTokens => commands::auth::cmd_list_tokens(&client).await,
Cmd::DeleteToken { id } => commands::auth::cmd_delete_token(&client, &id).await,
Cmd::DeleteAllTokens => commands::auth::cmd_delete_all_tokens(&client).await,
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
/// Smoke test: CLI definition is valid and parseable.
#[test]
fn cli_definition_is_valid() {
Cli::command().debug_assert();
}
/// Regression: parse_bool_flag rejects values other than "true"/"false".
#[test]
fn parse_bool_flag_accepts_true() {
assert!(super::parse_bool_flag("--approved", "true").unwrap());
}
#[test]
fn parse_bool_flag_accepts_false() {
assert!(!super::parse_bool_flag("--approved", "false").unwrap());
}
#[test]
fn parse_bool_flag_rejects_invalid() {
let err = super::parse_bool_flag("--approved", "maybe").unwrap_err();
match err {
super::CliError::Usage(msg) => {
assert!(msg.contains("must be 'true' or 'false'"), "got: {msg}");
assert!(msg.contains("maybe"), "got: {msg}");
}
other => panic!("expected Usage error, got: {other:?}"),
}
}
#[test]
fn parse_bool_flag_rejects_empty() {
assert!(super::parse_bool_flag("--approved", "").is_err());
}
/// Parity: the CLI exposes exactly the expected 48 commands.
/// If a command is added or removed, this test forces a conscious update.
#[test]
fn command_inventory_is_48() {
let expected: Vec<&str> = vec![
"add-channel-member",
"add-dm-member",
"add-reaction",
"approve-step",
"archive-channel",
"auth",
"create-channel",
"create-workflow",
"delete-all-tokens",
"delete-channel",
"delete-message",
"delete-token",
"delete-workflow",
"edit-message",
"get-canvas",
"get-channel",
"get-feed",
"get-messages",
"get-presence",
"get-reactions",
"get-thread",
"get-users",
"get-workflow",
"get-workflow-runs",
"join-channel",
"leave-channel",
"list-channel-members",
"list-channels",
"list-dms",
"list-tokens",
"list-workflows",
"open-dm",
"remove-channel-member",
"remove-reaction",
"search",
"send-diff-message",
"send-message",
"set-canvas",
"set-channel-add-policy",
"set-channel-purpose",
"set-channel-topic",
"set-presence",
"set-profile",
"trigger-workflow",
"unarchive-channel",
"update-channel",
"update-workflow",
"vote-on-post",
];
let cmd = Cli::command();
let mut actual: Vec<String> = cmd
.get_subcommands()
.map(|s| s.get_name().to_string())
.filter(|n| n != "help") // clap auto-adds "help"
.collect();
actual.sort();
assert_eq!(
actual.len(),
48,
"Expected 48 commands, got {}. Actual: {:?}",
actual.len(),
actual
);
assert_eq!(actual, expected, "Command inventory drift detected");
}
}

View File

@@ -0,0 +1,382 @@
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;
/// 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 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 - _ . ~
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())
}
/// Normalize mention pubkeys: lowercase, deduplicate, remove sender's own pubkey.
pub fn normalize_mention_pubkeys(pubkeys: &[String], sender_pubkey: &str) -> Vec<String> {
let sender = sender_pubkey.to_ascii_lowercase();
let mut seen = std::collections::HashSet::new();
pubkeys
.iter()
.map(|pk| pk.to_ascii_lowercase())
.filter(|pk| pk != &sender)
.filter(|pk| seen.insert(pk.clone()))
.collect()
}
/// 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())
}
}
#[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())
);
}
// --- normalize_mention_pubkeys ---
#[test]
fn normalize_mention_pubkeys_lowercases() {
// 64 chars total
let pk = "AABBCC".repeat(10) + "aabb";
assert_eq!(pk.len(), 64);
let result = normalize_mention_pubkeys(std::slice::from_ref(&pk), "sender");
assert_eq!(result, vec![pk.to_ascii_lowercase()]);
}
#[test]
fn normalize_mention_pubkeys_removes_sender() {
let sender = "a".repeat(64);
let other = "b".repeat(64);
let pubkeys = vec![sender.clone(), other.clone()];
let result = normalize_mention_pubkeys(&pubkeys, &sender);
assert_eq!(result, vec![other]);
}
#[test]
fn normalize_mention_pubkeys_deduplicates() {
let pk = "c".repeat(64);
let pubkeys = vec![pk.clone(), pk.clone(), pk.clone()];
let result = normalize_mention_pubkeys(&pubkeys, "sender");
assert_eq!(result.len(), 1);
assert_eq!(result[0], pk);
}
#[test]
fn normalize_mention_pubkeys_removes_sender_case_insensitive() {
let sender_lower = "d".repeat(64);
let sender_upper = sender_lower.to_ascii_uppercase();
let other = "e".repeat(64);
let pubkeys = vec![sender_upper, other.clone()];
let result = normalize_mention_pubkeys(&pubkeys, &sender_lower);
assert_eq!(result, vec![other]);
}
#[test]
fn normalize_mention_pubkeys_empty_input() {
let result = normalize_mention_pubkeys(&[], "sender");
assert!(result.is_empty());
}
}

View File

@@ -1269,6 +1269,220 @@ pub async fn get_thread(
})))
}
// ── PUT /api/messages/:event_id ───────────────────────────────────────────────
/// Request body for editing a message.
#[derive(Debug, Deserialize)]
pub struct EditMessageBody {
/// The new text content for the message.
pub content: String,
}
/// Edit a channel message by event ID.
///
/// Produces a kind:40003 (stream message edit) event referencing the original.
/// The caller must be the original author of the message.
/// The event is signed with the relay keypair; the authenticated user is
/// attributed via a `p` tag.
pub async fn edit_message(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(event_id_hex): Path<String>,
Json(body): Json<EditMessageBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
if body.content.trim().is_empty() {
return Err(api_error(StatusCode::BAD_REQUEST, "content is required"));
}
let event_id_bytes = nostr_hex::decode(&event_id_hex)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event_id hex"))?;
// Look up the original event.
let stored = state
.db
.get_event_by_id(&event_id_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?
.ok_or_else(|| not_found("event not found"))?;
let channel_id = stored
.channel_id
.ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "event has no channel"))?;
// Token-level channel restriction check.
check_token_channel_access(&ctx, &channel_id)?;
// Verify the caller is the original author.
let author_bytes = effective_author(&stored.event, &state.relay_keypair.public_key());
if author_bytes != pubkey_bytes {
return Err(forbidden("must be the original message author to edit"));
}
// Verify channel membership.
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
let channel = state
.db
.get_channel(channel_id)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
if channel.archived_at.is_some() {
return Err(api_error(StatusCode::FORBIDDEN, "channel is archived"));
}
// Build the kind:40003 edit event.
let user_pubkey_hex = nostr_hex::encode(&pubkey_bytes);
let kind = Kind::from(sprout_core::kind::KIND_STREAM_MESSAGE_EDIT as u16);
let tags = vec![
Tag::parse(&["h", &channel_id.to_string()])
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
Tag::parse(&["e", &event_id_hex])
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
Tag::parse(&["p", &user_pubkey_hex])
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
];
let event = EventBuilder::new(kind, &body.content, tags)
.sign_with_keys(&state.relay_keypair)
.map_err(|e| internal_error(&format!("event signing error: {e}")))?;
let edit_event_id_hex = event.id.to_hex();
let (stored_edit, was_inserted) = state
.db
.insert_event(&event, Some(channel_id))
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
if was_inserted {
let kind_u32 = u32::from(event.kind.as_u16());
let _ = dispatch_persistent_event(&state, &stored_edit, kind_u32, &user_pubkey_hex).await;
}
Ok(Json(serde_json::json!({
"event_id": edit_event_id_hex,
"edited_event_id": event_id_hex,
})))
}
// ── POST /api/messages/:event_id/votes ───────────────────────────────────────
/// Request body for voting on a forum post or comment.
#[derive(Debug, Deserialize)]
pub struct VoteOnPostBody {
/// Vote direction: "up" or "down".
pub direction: String,
}
/// Vote on a forum post (kind:45001) or forum comment (kind:45003).
///
/// Produces a kind:45002 (forum vote) event referencing the target.
/// The caller must be a member of the channel the target event belongs to.
/// The event is signed with the relay keypair; the authenticated user is
/// attributed via a `p` tag.
pub async fn vote_on_post(
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Path(event_id_hex): Path<String>,
Json(body): Json<VoteOnPostBody>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<serde_json::Value>)> {
let ctx = extract_auth_context(&headers, &state).await?;
sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite)
.map_err(super::scope_error)?;
let pubkey_bytes = ctx.pubkey_bytes.clone();
let direction = body.direction.trim().to_lowercase();
if direction != "up" && direction != "down" {
return Err(api_error(
StatusCode::BAD_REQUEST,
"direction must be \"up\" or \"down\"",
));
}
let event_id_bytes = nostr_hex::decode(&event_id_hex)
.map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event_id hex"))?;
// Look up the target event.
let stored = state
.db
.get_event_by_id(&event_id_bytes)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?
.ok_or_else(|| not_found("event not found"))?;
// Verify the target is a forum post or comment.
let target_kind = u32::from(stored.event.kind.as_u16());
if target_kind != sprout_core::kind::KIND_FORUM_POST
&& target_kind != sprout_core::kind::KIND_FORUM_COMMENT
{
return Err(api_error(
StatusCode::BAD_REQUEST,
"can only vote on forum posts (kind:45001) or forum comments (kind:45003)",
));
}
let channel_id = stored
.channel_id
.ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "event has no channel"))?;
// Token-level channel restriction check.
check_token_channel_access(&ctx, &channel_id)?;
// Verify channel membership.
check_channel_access(&state, channel_id, &pubkey_bytes).await?;
let channel = state
.db
.get_channel(channel_id)
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
if channel.archived_at.is_some() {
return Err(api_error(StatusCode::FORBIDDEN, "channel is archived"));
}
// Build the kind:45002 vote event.
let user_pubkey_hex = nostr_hex::encode(&pubkey_bytes);
let kind = Kind::from(sprout_core::kind::KIND_FORUM_VOTE as u16);
let tags = vec![
Tag::parse(&["h", &channel_id.to_string()])
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
Tag::parse(&["e", &event_id_hex])
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
Tag::parse(&["p", &user_pubkey_hex])
.map_err(|e| internal_error(&format!("tag build error: {e}")))?,
];
let event = EventBuilder::new(kind, &direction, tags)
.sign_with_keys(&state.relay_keypair)
.map_err(|e| internal_error(&format!("event signing error: {e}")))?;
let vote_event_id_hex = event.id.to_hex();
let (stored_vote, was_inserted) = state
.db
.insert_event(&event, Some(channel_id))
.await
.map_err(|e| internal_error(&format!("db error: {e}")))?;
if was_inserted {
let kind_u32 = u32::from(event.kind.as_u16());
let _ = dispatch_persistent_event(&state, &stored_vote, kind_u32, &user_pubkey_hex).await;
}
Ok(Json(serde_json::json!({
"event_id": vote_event_id_hex,
"target_event_id": event_id_hex,
"direction": direction,
})))
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -63,8 +63,8 @@ pub use events::get_event;
pub use feed::feed_handler;
pub use members::{add_members, join_channel, leave_channel, list_members, remove_member};
pub use messages::{
delete_message, get_thread, list_messages, send_message, validate_imeta_tags,
verify_imeta_blobs,
delete_message, edit_message, get_thread, list_messages, send_message, validate_imeta_tags,
verify_imeta_blobs, vote_on_post,
};
pub use presence::{presence_handler, set_presence_handler};
pub use reactions::{add_reaction_handler, list_reactions_handler, remove_reaction_handler};

View File

@@ -146,8 +146,13 @@ pub fn build_router(state: Arc<AppState>) -> Router {
"/api/dms/{channel_id}/members",
post(api::add_dm_member_handler),
)
// Message delete route
.route("/api/messages/{event_id}", delete(api::delete_message))
// Message delete + edit routes
.route(
"/api/messages/{event_id}",
delete(api::delete_message).put(api::edit_message),
)
// Forum vote route
.route("/api/messages/{event_id}/votes", post(api::vote_on_post))
// Reaction routes
.route(
"/api/messages/{event_id}/reactions",