refactor(cli): restructure flat commands into 12 subcommand groups (#585)

This commit is contained in:
Will Pfleger
2026-05-14 19:13:49 -04:00
committed by GitHub
parent 01a3df7f1f
commit 1d8a130b32
15 changed files with 1258 additions and 986 deletions

View File

@@ -10,21 +10,14 @@ cargo install --path crates/sprout-cli
## Authentication
Two modes, checked in order:
| Priority | Env Var | Mode | Use Case |
|----------|---------|------|----------|
| 1 | `SPROUT_PRIVATE_KEY` | NIP-98 Schnorr signature | Agents with a keypair |
| 2 | `SPROUT_PUBKEY` | X-Pubkey header (dev relay only) | Local development |
| Env Var | Mode | Use Case |
|---------|------|----------|
| `SPROUT_PRIVATE_KEY` | NIP-98 Schnorr signature | Agents with a keypair |
```bash
# Option 1: Private key (NIP-98 signed requests)
# Private key identity (NIP-98 signed requests)
export SPROUT_PRIVATE_KEY="nsec1..."
sprout list-channels
# Option 2: Dev mode (no auth)
export SPROUT_PUBKEY="<hex>"
sprout list-channels
sprout channels list
```
## Usage
@@ -36,106 +29,117 @@ All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=use
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>
sprout messages send --channel <uuid> --content "Hello"
sprout messages send --channel <uuid> --content "Reply" --reply-to <event-id> --broadcast
sprout messages get --channel <uuid> --limit 20
sprout messages thread --channel <uuid> --event <event-id>
sprout messages search --query "architecture"
sprout messages edit --event <event-id> --content "Updated text"
sprout messages delete --event <event-id>
# Diffs
sprout send-diff-message --channel <uuid> --diff - --repo https://github.com/org/repo --commit abc123 < diff.patch
sprout messages send-diff --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"
sprout channels list
sprout channels create --name "my-channel" --type stream --visibility open
sprout channels join --channel <uuid>
sprout channels topic --channel <uuid> --topic "New topic"
# Reactions
sprout add-reaction --event <event-id> --emoji "👍"
sprout get-reactions --event <event-id>
sprout reactions add --event <event-id> --emoji "👍"
sprout reactions get --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
sprout users get # your own profile
sprout users get --pubkey <hex> # single user
sprout users get --pubkey <hex> --pubkey <hex> # batch (max 200)
sprout users set-presence --status online
# DMs
sprout open-dm --pubkey <hex>
sprout list-dms
sprout dms open --pubkey <hex>
sprout dms list
# Workflows
sprout list-workflows --channel <uuid>
sprout trigger-workflow --workflow <uuid>
sprout approve-step --token <uuid> --approved true
sprout workflows list --channel <uuid>
sprout workflows trigger --workflow <uuid>
sprout workflows approve --token <uuid>
sprout workflows approve --token <uuid> --approved false --note "needs revision"
# Forum
sprout vote-on-post --event <event-id> --direction up
sprout messages vote --event <event-id> --direction up
# Canvas
sprout get-canvas --channel <uuid>
sprout set-canvas --channel <uuid> --content "# Welcome"
sprout canvas get --channel <uuid>
sprout canvas set --channel <uuid> --content "# Welcome"
# Pipe to jq
sprout list-channels | jq '.[].name'
sprout channels list | jq '.[].name'
```
## All 44 Commands
## 54 Subcommands across 12 Groups
| 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 |
| Group | Subcommand | Description |
|-------|-----------|-------------|
| `messages` | `send` | Send a message to a channel |
| | `send-diff` | Send a code diff with metadata |
| | `edit` | Edit a message you sent |
| | `delete` | Delete a message |
| | `get` | List messages in a channel |
| | `thread` | Get a message thread |
| | `search` | Full-text search |
| | `vote` | Vote on a forum post |
| `channels` | `list` | List channels |
| | `get` | Get channel details |
| | `create` | Create a channel |
| | `update` | Update channel name/description |
| | `topic` | Set channel topic |
| | `purpose` | Set channel purpose |
| | `join` | Join a channel |
| | `leave` | Leave a channel |
| | `archive` | Archive a channel |
| | `unarchive` | Unarchive a channel |
| | `delete` | Delete a channel |
| | `members` | List channel members |
| | `add-member` | Add a member |
| | `remove-member` | Remove a member |
| `canvas` | `get` | Get channel canvas |
| | `set` | Set channel canvas |
| `reactions` | `add` | React to a message |
| | `remove` | Remove a reaction |
| | `get` | List reactions |
| `dms` | `list` | List DM conversations |
| | `open` | Open a DM (1–8 pubkeys) |
| | `add-member` | Add member to DM group |
| `users` | `get` | Get user profile(s) |
| | `set-profile` | Update your profile |
| | `presence` | Get presence status |
| | `set-presence` | Set presence status |
| `workflows` | `list` | List workflows |
| | `get` | Get workflow definition |
| | `create` | Create a workflow |
| | `update` | Update a workflow |
| | `delete` | Delete a workflow |
| | `trigger` | Trigger a workflow |
| | `runs` | Get workflow run history |
| | `approve` | Approve/deny a workflow step |
| `feed` | `get` | Get your activity feed |
| `social` | `publish` | Publish a NIP-01 note |
| | `set-contacts` | Set NIP-02 contact list |
| | `event` | Get a Nostr event |
| | `notes` | Get notes for a user |
| | `contacts` | Get NIP-02 contact list |
| `repos` | `create` | Announce a git repository (NIP-34) |
| | `get` | Get a repository announcement |
| | `list` | List repository announcements |
| `upload` | `file` | Upload a file to the Blossom store |
| `pack` | `validate` | Validate a persona pack (local, no relay) |
| | `inspect` | Inspect a persona pack (local, no relay) |
## Architecture
```
sprout <command> [flags]
sprout <group> <subcommand> [flags]
│
├─ main.rs ──▶ commands/*.rs ──▶ client.rs ──▶ Sprout Relay REST API
│ (clap) (handlers) (reqwest)

View File

@@ -71,44 +71,27 @@ cargo run -p sprout-admin -- mint-token \
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 |
| `messages:read` | ✅ | `messages get`, `messages thread`, `messages search`, `feed get` |
| `messages:write` | ✅ | `messages send`, `messages edit`, `messages delete`, `reactions`, `messages vote` |
| `channels:read` | ✅ | `channels list`, `channels get`, `channels members` |
| `channels:write` | ✅ | `channels create`, `channels update`, `channels join`, `channels leave`, `channels topic`, `channels purpose` |
| `users:read` | ✅ | `users get`, `users presence` |
| `users:write` | ✅ | `users set-profile`, `users set-presence` |
| `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.
| `admin:channels` | ❌ | `channels archive`, `channels unarchive`, `channels delete`, `channels add-member`, `channels remove-member` |
---
@@ -116,7 +99,7 @@ admin operations.
```bash
cargo test -p sprout-cli
# Expected: 38 passed, 0 failed
# Expected: see cargo test -p sprout-cli for current count
cargo clippy -p sprout-cli -- -D warnings
# Expected: zero warnings
@@ -127,147 +110,120 @@ cargo clippy -p sprout-cli -- -D 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.
return JSON (pipe through `jq .` to validate). Commands are ordered so
earlier ones create resources that later ones need.
### 6.1 Auth & Tokens
### 6.1 Channels
```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 \
# channels create (stream)
sprout channels create --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')
CHANNEL_ID=$(sprout channels create --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')
# channels create (forum) — needed for messages vote later
FORUM_ID=$(sprout channels create --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 .
# channels list
sprout channels list | jq .
sprout channels list --visibility open | jq .
sprout channels list --member | jq .
# get-channel
sprout get-channel --channel "$CHANNEL_ID" | jq .
# channels get
sprout channels get --channel "$CHANNEL_ID" | jq .
# update-channel
sprout update-channel --channel "$CHANNEL_ID" --name "test-cli-updated" \
# channels update
sprout channels update --channel "$CHANNEL_ID" --name "test-cli-updated" \
--description "Updated" | jq .
# set-channel-topic
sprout set-channel-topic --channel "$CHANNEL_ID" --topic "Test topic" | jq .
# channels topic
sprout channels topic --channel "$CHANNEL_ID" --topic "Test topic" | jq .
# set-channel-purpose
sprout set-channel-purpose --channel "$CHANNEL_ID" --purpose "Testing" | jq .
# channels purpose
sprout channels purpose --channel "$CHANNEL_ID" --purpose "Testing" | jq .
# join-channel (may already be a member from create)
sprout join-channel --channel "$CHANNEL_ID" | jq .
# channels join (may already be a member from create)
sprout channels join --channel "$CHANNEL_ID" | jq .
# leave-channel
sprout leave-channel --channel "$CHANNEL_ID" | jq .
# channels leave
sprout channels leave --channel "$CHANNEL_ID" | jq .
# Re-join so we can send messages
sprout join-channel --channel "$CHANNEL_ID" | jq .
sprout channels join --channel "$CHANNEL_ID" | jq .
# archive-channel (requires admin:channels scope)
sprout archive-channel --channel "$CHANNEL_ID" | jq .
# channels archive (requires admin:channels scope)
sprout channels archive --channel "$CHANNEL_ID" | jq .
# unarchive-channel
sprout unarchive-channel --channel "$CHANNEL_ID" | jq .
# channels unarchive
sprout channels unarchive --channel "$CHANNEL_ID" | jq .
```
### 6.3 Canvas
### 6.2 Canvas
```bash
# set-canvas
sprout set-canvas --channel "$CHANNEL_ID" --content "# Test Canvas" | jq .
# canvas set
sprout canvas set --channel "$CHANNEL_ID" --content "# Test Canvas" | jq .
# set-canvas from stdin
echo "# Canvas from stdin" | sprout set-canvas --channel "$CHANNEL_ID" --content - | jq .
# canvas set from stdin
echo "# Canvas from stdin" | sprout canvas set --channel "$CHANNEL_ID" --content - | jq .
# get-canvas
sprout get-canvas --channel "$CHANNEL_ID" | jq .
# canvas get
sprout canvas get --channel "$CHANNEL_ID" | jq .
```
### 6.4 Messages
### 6.3 Messages
```bash
# send-message
MSG=$(sprout send-message --channel "$CHANNEL_ID" --content "Hello from CLI test" | jq .)
# messages send
MSG=$(sprout messages send --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" \
# messages send with reply + broadcast
REPLY=$(sprout messages send --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" \
# messages send with mentions
sprout messages send --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 .
# messages get
sprout messages get --channel "$CHANNEL_ID" | jq .
sprout messages get --channel "$CHANNEL_ID" --limit 5 | jq .
# get-thread
sprout get-thread --channel "$CHANNEL_ID" --event "$EVENT_ID" | jq .
# messages thread
sprout messages thread --channel "$CHANNEL_ID" --event "$EVENT_ID" | jq .
# search
sprout search --query "Hello" | jq .
sprout search --query "CLI test" --limit 5 | jq .
# messages search
sprout messages search --query "Hello" | jq .
sprout messages search --query "CLI test" --limit 5 | jq .
# edit-message
sprout edit-message --event "$EVENT_ID" --content "Edited by CLI test" | jq .
# messages edit
sprout messages edit --event "$EVENT_ID" --content "Edited by CLI test" | jq .
# delete-message
sprout delete-message --event "$REPLY_ID" | jq .
# messages delete
sprout messages delete --event "$REPLY_ID" | jq .
```
### 6.5 Diff Messages
### 6.4 Diff Messages
```bash
# send-diff-message from stdin
# messages send-diff from stdin
echo '--- a/foo.rs
+++ b/foo.rs
@@ -1,3 +1,3 @@
-fn old() {}
+fn new() {}' | sprout send-diff-message \
+fn new() {}' | sprout messages send-diff \
--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 \
# messages send-diff with metadata
echo "diff content" | sprout messages send-diff \
--channel "$CHANNEL_ID" \
--diff - \
--repo "https://github.com/example/repo" \
@@ -276,8 +232,8 @@ echo "diff content" | sprout send-diff-message \
--lang "rust" \
--description "Refactored main" | jq .
# send-diff-message with branch + PR metadata
echo "diff content" | sprout send-diff-message \
# messages send-diff with branch + PR metadata
echo "diff content" | sprout messages send-diff \
--channel "$CHANNEL_ID" \
--diff - \
--repo "https://github.com/example/repo" \
@@ -288,99 +244,92 @@ echo "diff content" | sprout send-diff-message \
--pr 42 | jq .
```
### 6.6 Reactions
### 6.5 Reactions
```bash
# Send a message to react to
REACT_MSG=$(sprout send-message --channel "$CHANNEL_ID" --content "React to this")
REACT_MSG=$(sprout messages send --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 .
# reactions add
sprout reactions add --event "$REACT_ID" --emoji "👍" | jq .
# get-reactions
sprout get-reactions --event "$REACT_ID" | jq .
# reactions get
sprout reactions get --event "$REACT_ID" | jq .
# remove-reaction
sprout remove-reaction --event "$REACT_ID" --emoji "👍" | jq .
# reactions remove
sprout reactions remove --event "$REACT_ID" --emoji "👍" | jq .
```
### 6.7 DMs
### 6.6 DMs
```bash
# list-dms
sprout list-dms | jq .
# dms list
sprout dms list | jq .
# open-dm (needs a real pubkey — use your own or a test one)
# dms open (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')
MY_PUBKEY=$(sprout users get | 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")
# dms open with a synthetic pubkey (relay will create the user)
DM_RESULT=$(sprout dms open --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" \
# dms add-member (requires messages:write scope — NOT admin:channels)
sprout dms add-member --channel "$DM_ID" \
--pubkey "0000000000000000000000000000000000000000000000000000000000000002" | jq .
```
### 6.8 Users & Presence
### 6.7 Users & Presence
```bash
# get-users — own profile (0 pubkeys)
sprout get-users | jq .
# users get — own profile (0 pubkeys)
sprout users get | jq .
# get-users — single pubkey
sprout get-users --pubkey "$MY_PUBKEY" | jq .
# users get — single pubkey
sprout users get --pubkey "$MY_PUBKEY" | jq .
# get-users — batch (2+ pubkeys)
sprout get-users --pubkey "$MY_PUBKEY" --pubkey "$MY_PUBKEY" | jq .
# users get — batch (2+ pubkeys)
sprout users get --pubkey "$MY_PUBKEY" --pubkey "$MY_PUBKEY" | jq .
# set-profile
sprout set-profile --name "CLI Test Agent" --about "Testing sprout-cli" | jq .
# users set-profile
sprout users set-profile --name "CLI Test Agent" --about "Testing sprout-cli" | jq .
# get-presence
sprout get-presence --pubkeys "$MY_PUBKEY" | jq .
# users presence
sprout users 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 .
# users set-presence
sprout users set-presence --status online | jq .
sprout users set-presence --status away | jq .
sprout users set-presence --status offline | jq .
```
### 6.9 Channel Members (add/remove require admin:channels)
### 6.8 Channel Members (add/remove require admin:channels)
```bash
# add-channel-member
sprout add-channel-member --channel "$CHANNEL_ID" \
# channels add-member
sprout channels add-member --channel "$CHANNEL_ID" \
--pubkey "0000000000000000000000000000000000000000000000000000000000000001" \
--role member | jq .
# list-channel-members
sprout list-channel-members --channel "$CHANNEL_ID" | jq .
# channels members
sprout channels members --channel "$CHANNEL_ID" | jq .
# remove-channel-member
sprout remove-channel-member --channel "$CHANNEL_ID" \
# channels remove-member
sprout channels remove-member --channel "$CHANNEL_ID" \
--pubkey "0000000000000000000000000000000000000000000000000000000000000001" | jq .
```
### 6.10 Workflows
### 6.9 Workflows
```bash
# create-workflow
# workflows create
# NOTE: trigger uses `on:` tag (serde internally tagged enum).
# Valid triggers: message_posted, reaction_added, diff_posted, schedule, webhook
# Steps use `action:` tag: send_message, send_dm, set_channel_topic, add_reaction, etc.
WF=$(sprout create-workflow --channel "$CHANNEL_ID" \
WF=$(sprout workflows create --channel "$CHANNEL_ID" \
--yaml 'name: test-wf
trigger:
on: webhook
@@ -391,14 +340,14 @@ steps:
echo "$WF"
WF_ID=$(echo "$WF" | jq -r '.id')
# list-workflows
sprout list-workflows --channel "$CHANNEL_ID" | jq .
# workflows list
sprout workflows list --channel "$CHANNEL_ID" | jq .
# get-workflow
sprout get-workflow --workflow "$WF_ID" | jq .
# workflows get
sprout workflows get --workflow "$WF_ID" | jq .
# update-workflow
sprout update-workflow --workflow "$WF_ID" \
# workflows update
sprout workflows update --workflow "$WF_ID" \
--yaml 'name: test-wf-updated
trigger:
on: webhook
@@ -407,43 +356,44 @@ steps:
action: send_message
text: "Updated"' | jq .
# trigger-workflow
sprout trigger-workflow --workflow "$WF_ID" | jq .
# workflows trigger
sprout workflows trigger --workflow "$WF_ID" | jq .
# get-workflow-runs
sprout get-workflow-runs --workflow "$WF_ID" | jq .
# workflows runs
sprout workflows runs --workflow "$WF_ID" | jq .
# approve-step — requires a workflow run waiting for approval
# workflows approve — requires a workflow run waiting for approval
# This is hard to test ad-hoc without a workflow that has an approval gate.
# Test the validation instead:
sprout approve-step --token "00000000-0000-0000-0000-000000000000" --approved true 2>&1 || true
sprout workflows approve --token "00000000-0000-0000-0000-000000000000" 2>&1 || true
# Should fail with relay error (token not found), not a validation error
# To test the deny path: sprout workflows approve --token <UUID> --approved false
# delete-workflow
sprout delete-workflow --workflow "$WF_ID" | jq .
# workflows delete
sprout workflows delete --workflow "$WF_ID" | jq .
```
### 6.11 Feed
### 6.10 Feed
```bash
sprout get-feed | jq .
sprout get-feed --limit 5 | jq .
sprout feed get | jq .
sprout feed get --limit 5 | jq .
```
### 6.12 Forum & Voting
### 6.11 Forum & Voting
```bash
# Send a forum post (kind 45001) to the forum channel
FORUM_POST=$(sprout send-message --channel "$FORUM_ID" \
FORUM_POST=$(sprout messages send --channel "$FORUM_ID" \
--content "Forum post for vote testing" --kind 45001 | jq .)
echo "$FORUM_POST"
FORUM_EVENT_ID=$(echo "$FORUM_POST" | jq -r '.id // .event_id')
# vote-on-post (up)
sprout vote-on-post --event "$FORUM_EVENT_ID" --direction up | jq .
# messages vote (up)
sprout messages vote --event "$FORUM_EVENT_ID" --direction up | jq .
# vote-on-post (down)
sprout vote-on-post --event "$FORUM_EVENT_ID" --direction down | jq .
# messages vote (down)
sprout messages vote --event "$FORUM_EVENT_ID" --direction down | jq .
```
---
@@ -454,69 +404,56 @@ 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: $?"
sprout channels get --channel "not-a-uuid" 2>&1; echo "exit: $?"
# stderr: {"error":"user_error","message":"invalid UUID: not-a-uuid"}
# exit: 1
# Exit 1: Invalid hex64
sprout delete-message --event "not-hex" 2>&1; echo "exit: $?"
sprout messages delete --event "not-hex" 2>&1; echo "exit: $?"
# stderr: {"error":"user_error","message":"must be a 64-character hex string: not-hex"}
# exit: 1
# Exit 1: Invalid --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: $?"
sprout channels create --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})" \
sprout messages vote --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: $?"
sprout users 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"}
env -u SPROUT_PRIVATE_KEY \
cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?"
# stderr: {"error":"auth_error","message":"SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"}
# exit: 3
# Exit 2: Non-existent channel (valid UUID)
sprout get-channel --channel "00000000-0000-0000-0000-000000000000" 2>&1; echo "exit: $?"
sprout channels get --channel "00000000-0000-0000-0000-000000000000" 2>&1; echo "exit: $?"
# stderr: {"error":"relay_error","message":"..."}
# exit: 2
```
---
## 8. Auth Mode Testing
## 8. Auth Testing
Test all three authentication tiers.
Test authentication.
```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 .
# Private key (SPROUT_PRIVATE_KEY)
SPROUT_PRIVATE_KEY="nsec1..." sprout channels list | 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: $?"
env -u SPROUT_PRIVATE_KEY \
cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?"
# stderr: {"error":"auth_error","message":"SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"}
# exit: 3
```
@@ -526,8 +463,8 @@ env -u SPROUT_API_TOKEN -u SPROUT_PRIVATE_KEY -u SPROUT_PUBKEY \
```bash
# Delete test channels
sprout delete-channel --channel "$CHANNEL_ID" | jq .
sprout delete-channel --channel "$FORUM_ID" | jq .
sprout channels delete --channel "$CHANNEL_ID" | jq .
sprout channels delete --channel "$FORUM_ID" | jq .
```
---
@@ -536,51 +473,57 @@ sprout delete-channel --channel "$FORUM_ID" | jq .
| # | 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) |
| 1 | `messages send` | ☐ | Basic, reply, broadcast, mentions |
| 2 | `messages send-diff` | ☐ | Stdin, metadata, branch/PR |
| 3 | `messages edit` | ☐ | |
| 4 | `messages delete` | ☐ | |
| 5 | `messages get` | ☐ | With limit |
| 6 | `messages thread` | ☐ | |
| 7 | `messages search` | ☐ | With limit |
| 8 | `messages vote` | ☐ | Up and down |
| 9 | `channels list` | ☐ | With visibility, member |
| 10 | `channels get` | ☐ | |
| 11 | `channels create` | ☐ | Stream and forum |
| 12 | `channels update` | ☐ | |
| 13 | `channels topic` | ☐ | |
| 14 | `channels purpose` | ☐ | |
| 15 | `channels join` | ☐ | |
| 16 | `channels leave` | ☐ | |
| 17 | `channels archive` | ☐ | Needs admin:channels |
| 18 | `channels unarchive` | ☐ | Needs admin:channels |
| 19 | `channels delete` | ☐ | Needs admin:channels |
| 20 | `channels members` | ☐ | |
| 21 | `channels add-member` | ☐ | Needs admin:channels |
| 22 | `channels remove-member` | ☐ | Needs admin:channels |
| 23 | `canvas get` | ☐ | |
| 24 | `canvas set` | ☐ | Direct and stdin |
| 25 | `reactions add` | ☐ | |
| 26 | `reactions remove` | ☐ | |
| 27 | `reactions get` | ☐ | |
| 28 | `dms list` | ☐ | |
| 29 | `dms open` | ☐ | |
| 30 | `dms add-member` | ☐ | Needs messages:write |
| 31 | `users get` | ☐ | Self, single, batch |
| 32 | `users set-profile` | ☐ | |
| 33 | `users presence` | ☐ | |
| 34 | `users set-presence` | ☐ | online, away, offline |
| 35 | `workflows list` | ☐ | |
| 36 | `workflows create` | ☐ | |
| 37 | `workflows update` | ☐ | |
| 38 | `workflows delete` | ☐ | |
| 39 | `workflows trigger` | ☐ | |
| 40 | `workflows runs` | ☐ | |
| 41 | `workflows get` | ☐ | |
| 42 | `workflows approve` | ☐ | Validation only (needs approval gate); bare = approve, `--approved false` = deny |
| 43 | `feed get` | ☐ | |
| 44 | `social publish` | ☐ | |
| 45 | `social set-contacts` | ☐ | |
| 46 | `social event` | ☐ | |
| 47 | `social notes` | ☐ | |
| 48 | `social contacts` | ☐ | |
| 49 | `repos create` | ☐ | |
| 50 | `repos get` | ☐ | |
| 51 | `repos list` | ☐ | |
| 52 | `upload file` | ☐ | |
| 53 | `pack validate` | ☐ | Local, no relay |
| 54 | `pack inspect` | ☐ | Local, no relay |

View File

@@ -2,22 +2,7 @@ use uuid::Uuid;
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::{read_or_stdin, validate_hex64, validate_uuid};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn parse_uuid(s: &str) -> Result<Uuid, CliError> {
Uuid::parse_str(s).map_err(|e| CliError::Usage(format!("invalid channel UUID: {e}")))
}
fn sign_and_submit_builder(
builder: nostr::EventBuilder,
client: &SproutClient,
) -> Result<nostr::Event, CliError> {
client.sign_event(builder)
}
use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid};
// ---------------------------------------------------------------------------
// Read commands — POST /query
@@ -127,7 +112,7 @@ pub async fn cmd_create_channel(
sprout_sdk::build_create_channel(channel_uuid, name, Some(vis), Some(ct), description)
.map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?;
let event = sign_and_submit_builder(builder, client)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -144,13 +129,12 @@ pub async fn cmd_update_channel(
"at least one field required (--name, --description)".into(),
));
}
validate_uuid(channel_id)?;
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_update_channel(channel_uuid, name, description)
.map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?;
let event = sign_and_submit_builder(builder, client)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -161,13 +145,12 @@ pub async fn cmd_set_channel_topic(
channel_id: &str,
topic: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_set_topic(channel_uuid, topic)
.map_err(|e| CliError::Other(format!("build_set_topic failed: {e}")))?;
let event = sign_and_submit_builder(builder, client)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -178,52 +161,48 @@ pub async fn cmd_set_channel_purpose(
channel_id: &str,
purpose: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_set_purpose(channel_uuid, purpose)
.map_err(|e| CliError::Other(format!("build_set_purpose failed: {e}")))?;
let event = sign_and_submit_builder(builder, client)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_join(channel_uuid)
.map_err(|e| CliError::Other(format!("build_join failed: {e}")))?;
let event = sign_and_submit_builder(builder, client)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_leave(channel_uuid)
.map_err(|e| CliError::Other(format!("build_leave failed: {e}")))?;
let event = sign_and_submit_builder(builder, client)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_archive_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_archive(channel_uuid)
.map_err(|e| CliError::Other(format!("build_archive failed: {e}")))?;
let event = sign_and_submit_builder(builder, client)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -233,26 +212,24 @@ pub async fn cmd_unarchive_channel(
client: &SproutClient,
channel_id: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_unarchive(channel_uuid)
.map_err(|e| CliError::Other(format!("build_unarchive failed: {e}")))?;
let event = sign_and_submit_builder(builder, client)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
pub async fn cmd_delete_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_delete_channel(channel_uuid)
.map_err(|e| CliError::Other(format!("build_delete_channel failed: {e}")))?;
let event = sign_and_submit_builder(builder, client)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -264,7 +241,6 @@ pub async fn cmd_add_channel_member(
pubkey: &str,
role: Option<&str>,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
validate_hex64(pubkey)?;
let channel_uuid = parse_uuid(channel_id)?;
@@ -284,7 +260,7 @@ pub async fn cmd_add_channel_member(
let builder = sprout_sdk::build_add_member(channel_uuid, pubkey, typed_role)
.map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?;
let event = sign_and_submit_builder(builder, client)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -295,14 +271,13 @@ pub async fn cmd_remove_channel_member(
channel_id: &str,
pubkey: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
validate_hex64(pubkey)?;
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_remove_member(channel_uuid, pubkey)
.map_err(|e| CliError::Other(format!("build_remove_member failed: {e}")))?;
let event = sign_and_submit_builder(builder, client)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
@@ -313,15 +288,77 @@ pub async fn cmd_set_canvas(
channel_id: &str,
content: &str,
) -> Result<(), CliError> {
validate_uuid(channel_id)?;
let content = read_or_stdin(content)?;
let channel_uuid = parse_uuid(channel_id)?;
let builder = sprout_sdk::build_set_canvas(channel_uuid, &content)
.map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?;
let event = sign_and_submit_builder(builder, client)?;
let event = client.sign_event(builder)?;
let resp = client.submit_event(event).await?;
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::ChannelsCmd, client: &SproutClient) -> Result<(), CliError> {
use crate::ChannelsCmd;
match cmd {
ChannelsCmd::List { visibility, member } => {
let vis_str = visibility.as_ref().map(|v| v.to_string());
cmd_list_channels(client, vis_str.as_deref(), Some(member)).await
}
ChannelsCmd::Get { channel } => cmd_get_channel(client, &channel).await,
ChannelsCmd::Create {
name,
channel_type,
visibility,
description,
} => {
cmd_create_channel(
client,
&name,
&channel_type.to_string(),
&visibility.to_string(),
description.as_deref(),
)
.await
}
ChannelsCmd::Update {
channel,
name,
description,
} => cmd_update_channel(client, &channel, name.as_deref(), description.as_deref()).await,
ChannelsCmd::Topic { channel, topic } => {
cmd_set_channel_topic(client, &channel, &topic).await
}
ChannelsCmd::Purpose { channel, purpose } => {
cmd_set_channel_purpose(client, &channel, &purpose).await
}
ChannelsCmd::Join { channel } => cmd_join_channel(client, &channel).await,
ChannelsCmd::Leave { channel } => cmd_leave_channel(client, &channel).await,
ChannelsCmd::Archive { channel } => cmd_archive_channel(client, &channel).await,
ChannelsCmd::Unarchive { channel } => cmd_unarchive_channel(client, &channel).await,
ChannelsCmd::Delete { channel } => cmd_delete_channel(client, &channel).await,
ChannelsCmd::Members { channel } => cmd_list_channel_members(client, &channel).await,
ChannelsCmd::AddMember {
channel,
pubkey,
role,
} => cmd_add_channel_member(client, &channel, &pubkey, role.as_deref()).await,
ChannelsCmd::RemoveMember { channel, pubkey } => {
cmd_remove_channel_member(client, &channel, &pubkey).await
}
}
}
pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &SproutClient) -> Result<(), CliError> {
use crate::CanvasCmd;
match cmd {
CanvasCmd::Get { channel } => cmd_get_canvas(client, &channel).await,
CanvasCmd::Set { channel, content } => cmd_set_canvas(client, &channel, &content).await,
}
}

View File

@@ -45,3 +45,16 @@ pub async fn cmd_add_dm_member(
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::DmsCmd, client: &SproutClient) -> Result<(), CliError> {
use crate::DmsCmd;
match cmd {
DmsCmd::List { limit } => cmd_list_dms(client, limit).await,
DmsCmd::Open { pubkeys } => cmd_open_dm(client, &pubkeys).await,
DmsCmd::AddMember { channel, pubkey } => cmd_add_dm_member(client, &channel, &pubkey).await,
}
}

View File

@@ -24,3 +24,18 @@ pub async fn cmd_get_feed(
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::FeedCmd, client: &SproutClient) -> Result<(), CliError> {
use crate::FeedCmd;
match cmd {
FeedCmd::Get {
since,
limit,
types,
} => cmd_get_feed(client, since, limit, types.as_deref()).await,
}
}

View File

@@ -1,12 +1,12 @@
use nostr::{EventId, PublicKey};
use nostr::PublicKey;
use sprout_sdk::{DiffMeta, ThreadRef, VoteDirection};
use uuid::Uuid;
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::{
infer_language, read_or_stdin, truncate_diff, validate_content_size, validate_hex64,
validate_uuid, MAX_DIFF_BYTES,
infer_language, parse_event_id, parse_uuid, read_or_stdin, truncate_diff,
validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES,
};
use sprout_sdk::mentions::{
extract_at_names, match_names_to_profiles, merge_mentions, normalize_mention_pubkeys,
@@ -17,16 +17,6 @@ use sprout_sdk::mentions::{
// Helpers
// ---------------------------------------------------------------------------
/// Parse a 64-char hex string into a nostr::EventId.
fn parse_event_id(hex: &str) -> Result<EventId, CliError> {
EventId::parse(hex).map_err(|e| CliError::Usage(format!("invalid event ID: {e}")))
}
/// Parse a UUID string into uuid::Uuid.
fn parse_uuid(s: &str) -> Result<Uuid, CliError> {
Uuid::parse_str(s).map_err(|e| CliError::Usage(format!("invalid channel UUID: {e}")))
}
/// Extract the thread root event ID from a Nostr tag array.
///
/// Parses `"e"` tags with NIP-10 markers:
@@ -323,7 +313,6 @@ pub struct SendMessageParams {
}
pub async fn cmd_send_message(client: &SproutClient, p: SendMessageParams) -> Result<(), CliError> {
validate_uuid(&p.channel_id)?;
validate_content_size(&p.content)?;
if let Some(ref r) = p.reply_to {
validate_hex64(r)?;
@@ -409,7 +398,6 @@ 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)?;
}
@@ -553,6 +541,91 @@ pub async fn cmd_vote_on_post(
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::MessagesCmd, client: &SproutClient) -> Result<(), CliError> {
use crate::MessagesCmd;
match cmd {
MessagesCmd::Send {
channel,
content,
kind,
reply_to,
broadcast,
mentions,
files,
} => {
cmd_send_message(
client,
SendMessageParams {
channel_id: channel,
content,
kind,
reply_to,
broadcast,
mentions,
files,
},
)
.await
}
MessagesCmd::SendDiff {
channel,
diff,
repo,
commit,
file,
parent_commit,
source_branch,
target_branch,
pr,
lang,
description,
reply_to,
} => {
cmd_send_diff_message(
client,
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
}
MessagesCmd::Edit { event, content } => cmd_edit_message(client, &event, &content).await,
MessagesCmd::Delete { event } => cmd_delete_message(client, &event).await,
MessagesCmd::Get {
channel,
limit,
before,
since,
kinds,
} => cmd_get_messages(client, &channel, limit, before, since, kinds.as_deref()).await,
MessagesCmd::Thread {
channel,
event,
depth_limit,
limit,
} => cmd_get_thread(client, &channel, &event, depth_limit, limit).await,
MessagesCmd::Search { query, limit } => cmd_search(client, &query, limit).await,
MessagesCmd::Vote { event, direction } => {
cmd_vote_on_post(client, &event, &direction).await
}
}
}
#[cfg(test)]
mod tests {
use super::{find_root_from_tags, parse_member_pubkeys};

View File

@@ -6,5 +6,6 @@ pub mod pack;
pub mod reactions;
pub mod repos;
pub mod social;
pub mod upload;
pub mod users;
pub mod workflows;

View File

@@ -80,3 +80,16 @@ pub async fn cmd_get_reactions(client: &SproutClient, event_id: &str) -> Result<
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::ReactionsCmd, client: &SproutClient) -> Result<(), CliError> {
use crate::ReactionsCmd;
match cmd {
ReactionsCmd::Add { event, emoji } => cmd_add_reaction(client, &event, &emoji).await,
ReactionsCmd::Remove { event, emoji } => cmd_remove_reaction(client, &event, &emoji).await,
ReactionsCmd::Get { event } => cmd_get_reactions(client, &event).await,
}
}

View File

@@ -95,3 +95,34 @@ pub async fn cmd_list_repos(
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::ReposCmd, client: &SproutClient) -> Result<(), CliError> {
use crate::ReposCmd;
match cmd {
ReposCmd::Create {
id,
name,
description,
clone_urls,
web,
relays,
} => {
cmd_create_repo(
client,
&id,
name.as_deref(),
description.as_deref(),
&clone_urls,
web.as_deref(),
&relays,
)
.await
}
ReposCmd::Get { id, owner } => cmd_get_repo(client, &id, owner.as_deref()).await,
ReposCmd::List { owner, limit } => cmd_list_repos(client, owner.as_deref(), limit).await,
}
}

View File

@@ -1,14 +1,8 @@
use nostr::EventId;
use serde::Deserialize;
use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::validate_hex64;
/// Per-module helper.
fn parse_event_id(hex: &str) -> Result<EventId, CliError> {
EventId::parse(hex).map_err(|e| CliError::Usage(format!("invalid event ID: {e}")))
}
use crate::validate::{parse_event_id, validate_hex64};
/// A single contact entry (CLI-local, not from sprout-sdk).
#[derive(Debug, Deserialize)]
@@ -117,3 +111,24 @@ pub async fn cmd_get_contact_list(client: &SproutClient, pubkey: &str) -> Result
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::SocialCmd, client: &SproutClient) -> Result<(), CliError> {
use crate::SocialCmd;
match cmd {
SocialCmd::PublishNote { content, reply_to } => {
cmd_publish_note(client, &content, reply_to.as_deref()).await
}
SocialCmd::SetContactList { contacts } => cmd_set_contact_list(client, &contacts).await,
SocialCmd::GetEvent { event } => cmd_get_event(client, &event).await,
SocialCmd::GetUserNotes {
pubkey,
limit,
before,
} => cmd_get_user_notes(client, &pubkey, limit, before).await,
SocialCmd::GetContactList { pubkey } => cmd_get_contact_list(client, &pubkey).await,
}
}

View File

@@ -0,0 +1,15 @@
use crate::client::SproutClient;
use crate::error::CliError;
pub async fn dispatch(cmd: crate::UploadCmd, client: &SproutClient) -> Result<(), CliError> {
match cmd {
crate::UploadCmd::File { file } => {
let desc = client.upload_file(&file).await?;
println!(
"{}",
serde_json::to_string_pretty(&desc).map_err(|e| CliError::Other(e.to_string()))?
);
Ok(())
}
}
}

View File

@@ -2,6 +2,8 @@ use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::validate_hex64;
// TODO(phase-4): Replace raw nostr::EventBuilder usage in cmd_set_presence with sprout-sdk builder
/// Get user profiles (kind:0 metadata events).
///
/// - 0 pubkeys, no name → query our own profile
@@ -225,3 +227,31 @@ pub async fn cmd_set_presence(client: &SproutClient, status: &str) -> Result<(),
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::UsersCmd, client: &SproutClient) -> Result<(), CliError> {
use crate::UsersCmd;
match cmd {
UsersCmd::Get { pubkeys, name } => cmd_get_users(client, &pubkeys, name.as_deref()).await,
UsersCmd::SetProfile {
name,
avatar,
about,
nip05,
} => {
cmd_set_profile(
client,
name.as_deref(),
avatar.as_deref(),
about.as_deref(),
nip05.as_deref(),
)
.await
}
UsersCmd::Presence { pubkeys } => cmd_get_presence(client, &pubkeys).await,
UsersCmd::SetPresence { status } => cmd_set_presence(client, &status.to_string()).await,
}
}

View File

@@ -4,6 +4,8 @@ use crate::client::SproutClient;
use crate::error::CliError;
use crate::validate::{parse_uuid, read_or_stdin, sdk_err, validate_uuid};
// TODO(phase-4): Replace raw nostr::EventBuilder usage with sprout-sdk builder functions
// ---------------------------------------------------------------------------
// Read commands — POST /query
// ---------------------------------------------------------------------------
@@ -143,3 +145,36 @@ pub async fn cmd_approve_step(
println!("{resp}");
Ok(())
}
// ---------------------------------------------------------------------------
// Dispatch
// ---------------------------------------------------------------------------
pub async fn dispatch(cmd: crate::WorkflowsCmd, client: &SproutClient) -> Result<(), CliError> {
use crate::WorkflowsCmd;
match cmd {
WorkflowsCmd::List { channel } => cmd_list_workflows(client, &channel).await,
WorkflowsCmd::Get { workflow } => cmd_get_workflow(client, &workflow).await,
WorkflowsCmd::Create { channel, yaml } => {
cmd_create_workflow(client, &channel, &yaml).await
}
WorkflowsCmd::Update {
channel,
workflow,
yaml,
} => cmd_update_workflow(client, &channel, &workflow, &yaml).await,
WorkflowsCmd::Delete { workflow } => cmd_delete_workflow(client, &workflow).await,
WorkflowsCmd::Trigger { workflow } => cmd_trigger_workflow(client, &workflow).await,
WorkflowsCmd::Runs { workflow, limit } => {
cmd_get_workflow_runs(client, &workflow, limit).await
}
WorkflowsCmd::Approve {
token,
approved,
note,
} => {
// approved is already a bool — no parse_bool_flag needed
cmd_approve_step(client, &token, approved, note.as_deref()).await
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,17 +6,25 @@ pub const MAX_CONTENT_BYTES: usize = 65_536;
/// Maximum diff size in bytes (60 KiB).
pub const MAX_DIFF_BYTES: usize = 61_440;
/// Parse a hex string into a `nostr::EventId`. Returns `CliError::Usage` on failure.
pub fn parse_event_id(hex: &str) -> Result<nostr::EventId, CliError> {
nostr::EventId::parse(hex).map_err(|e| CliError::Usage(format!("invalid event ID: {e}")))
}
/// Parse a UUID string into a `uuid::Uuid`. Returns `CliError::Usage` on failure.
///
/// Note: `validate_uuid` (below) returns `()` for validation only; this function
/// returns the parsed `Uuid` for callers that need the value.
pub fn parse_uuid(s: &str) -> Result<uuid::Uuid, CliError> {
uuid::Uuid::parse_str(s).map_err(|e| CliError::Usage(format!("invalid UUID: {e}")))
}
/// Validate UUID string. Returns CliError::Usage on failure.
pub fn validate_uuid(s: &str) -> Result<(), CliError> {
uuid::Uuid::parse_str(s).map_err(|_| CliError::Usage(format!("invalid UUID: {s}")))?;
Ok(())
}
/// Parse and validate a UUID string, returning the parsed Uuid value.
pub fn parse_uuid(s: &str) -> Result<uuid::Uuid, CliError> {
uuid::Uuid::parse_str(s).map_err(|_| CliError::Usage(format!("invalid UUID: {s}")))
}
/// 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()) {
@@ -367,6 +375,31 @@ mod tests {
// Note: `extract_at_names`, `merge_mentions`, and `normalize_mention_pubkeys`
// moved to `sprout_sdk::mentions` and are tested there.
// --- parse_event_id ---
#[test]
fn parse_event_id_valid() {
let hex = "a".repeat(64);
assert!(super::parse_event_id(&hex).is_ok());
}
#[test]
fn parse_event_id_invalid() {
assert!(super::parse_event_id("not-a-hex-id").is_err());
}
// --- parse_uuid ---
#[test]
fn parse_uuid_valid() {
assert!(super::parse_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok());
}
#[test]
fn parse_uuid_invalid() {
assert!(super::parse_uuid("not-a-uuid").is_err());
}
// ── validate_repo_id ─────────────────────────────────────────────────────
#[test]