mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
feat: sprout-cli — agent-first CLI with full MCP parity (48 commands) (#158)
This commit is contained in:
16
Cargo.lock
generated
16
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -15,6 +15,7 @@ members = [
|
||||
"crates/sprout-admin",
|
||||
"crates/sprout-workflow",
|
||||
"crates/sprout-media",
|
||||
"crates/sprout-cli",
|
||||
]
|
||||
exclude = ["desktop/src-tauri"]
|
||||
resolver = "2"
|
||||
|
||||
41
crates/sprout-cli/Cargo.toml
Normal file
41
crates/sprout-cli/Cargo.toml
Normal 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
163
crates/sprout-cli/README.md
Normal 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
|
||||
```
|
||||
586
crates/sprout-cli/TESTING.md
Normal file
586
crates/sprout-cli/TESTING.md
Normal 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) |
|
||||
234
crates/sprout-cli/src/client.rs
Normal file
234
crates/sprout-cli/src/client.rs
Normal 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()))
|
||||
}
|
||||
124
crates/sprout-cli/src/commands/auth.rs
Normal file
124
crates/sprout-cli/src/commands/auth.rs
Normal 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
|
||||
}
|
||||
228
crates/sprout-cli/src/commands/channels.rs
Normal file
228
crates/sprout-cli/src/commands/channels.rs
Normal 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
|
||||
}
|
||||
47
crates/sprout-cli/src/commands/dms.rs
Normal file
47
crates/sprout-cli/src/commands/dms.rs
Normal 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
|
||||
}
|
||||
20
crates/sprout-cli/src/commands/feed.rs
Normal file
20
crates/sprout-cli/src/commands/feed.rs
Normal 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
|
||||
}
|
||||
240
crates/sprout-cli/src/commands/messages.rs
Normal file
240
crates/sprout-cli/src/commands/messages.rs
Normal 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
|
||||
}
|
||||
8
crates/sprout-cli/src/commands/mod.rs
Normal file
8
crates/sprout-cli/src/commands/mod.rs
Normal 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;
|
||||
42
crates/sprout-cli/src/commands/reactions.rs
Normal file
42
crates/sprout-cli/src/commands/reactions.rs
Normal 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
|
||||
}
|
||||
111
crates/sprout-cli/src/commands/users.rs
Normal file
111
crates/sprout-cli/src/commands/users.rs
Normal 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
|
||||
}
|
||||
99
crates/sprout-cli/src/commands/workflows.rs
Normal file
99
crates/sprout-cli/src/commands/workflows.rs
Normal 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
|
||||
}
|
||||
71
crates/sprout-cli/src/error.rs
Normal file
71
crates/sprout-cli/src/error.rs
Normal 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);
|
||||
}
|
||||
835
crates/sprout-cli/src/main.rs
Normal file
835
crates/sprout-cli/src/main.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
382
crates/sprout-cli/src/validate.rs
Normal file
382
crates/sprout-cli/src/validate.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user