From 3dd351458cf9c5398dd9f78a12a8a0f4b66033a2 Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Thu, 14 May 2026 14:54:51 -0400
Subject: [PATCH 1/6] refactor(cli): restructure flat 52-command surface into
 12 subcommand groups
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The CLI is becoming the primary agent tool surface (replacing MCP),
but 52 commands in a flat alphabetical list is unusable for discovery.
This restructure groups them hierarchically so `sprout --help` shows
12 domain groups and `sprout <group> --help` shows that group's
subcommands with descriptions and examples.

Breaking change — zero external consumers exist today. Key migrations:
`sprout send-message` -> `sprout messages send`,
`sprout approve-step --approved true` -> `sprout workflows approve`.

Also deduplicates parse_event_id/parse_uuid into validate.rs, inlines
the vestigial sign_and_submit_builder wrapper in channels.rs, and
converts --approved from string to proper boolean flag.
---
 crates/sprout-cli/README.md                |  166 +--
 crates/sprout-cli/TESTING.md               |  466 ++++----
 crates/sprout-cli/src/commands/channels.rs |   41 +-
 crates/sprout-cli/src/commands/messages.rs |   16 +-
 crates/sprout-cli/src/commands/social.rs   |    8 +-
 crates/sprout-cli/src/lib.rs               | 1141 +++++++++++---------
 crates/sprout-cli/src/validate.rs          |   38 +
 7 files changed, 977 insertions(+), 899 deletions(-)

diff --git a/crates/sprout-cli/README.md b/crates/sprout-cli/README.md
index 51cd866622..e1e2078c01 100644
--- a/crates/sprout-cli/README.md
+++ b/crates/sprout-cli/README.md
@@ -20,11 +20,11 @@ Two modes, checked in order:
 ```bash
 # Option 1: Private key (NIP-98 signed requests)
 export SPROUT_PRIVATE_KEY="nsec1..."
-sprout list-channels
+sprout channels list
 
 # Option 2: Dev mode (no auth)
 export SPROUT_PUBKEY="<hex>"
-sprout list-channels
+sprout channels list
 ```
 
 ## Usage
@@ -36,106 +36,116 @@ 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> --approved
 
 # 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
-
-| 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 |
+## 54 Subcommands across 12 Groups
+
+| 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-note` | Publish a NIP-01 note |
+| | `set-contact-list` | Set NIP-02 contact list |
+| | `get-event` | Get a Nostr event |
+| | `get-user-notes` | Get notes for a user |
+| | `get-contact-list` | 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)
diff --git a/crates/sprout-cli/TESTING.md b/crates/sprout-cli/TESTING.md
index 0c4f39ab7f..5dcc824116 100644
--- a/crates/sprout-cli/TESTING.md
+++ b/crates/sprout-cli/TESTING.md
@@ -71,44 +71,28 @@ 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` |
 
 ---
 
@@ -127,147 +111,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 +233,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 +245,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 .
-
-# get-users — single pubkey
-sprout get-users --pubkey "$MY_PUBKEY" | jq .
+# users get — own profile (0 pubkeys)
+sprout users get | jq .
 
-# get-users — batch (2+ pubkeys)
-sprout get-users --pubkey "$MY_PUBKEY" --pubkey "$MY_PUBKEY" | jq .
+# users get — single pubkey
+sprout users get --pubkey "$MY_PUBKEY" | jq .
 
-# set-profile
-sprout set-profile --name "CLI Test Agent" --about "Testing sprout-cli" | jq .
+# users get — batch (2+ pubkeys)
+sprout users get --pubkey "$MY_PUBKEY" --pubkey "$MY_PUBKEY" | jq .
 
-# get-presence
-sprout get-presence --pubkeys "$MY_PUBKEY" | jq .
+# users set-profile
+sprout users set-profile --name "CLI Test Agent" --about "Testing sprout-cli" | jq .
 
-# set-presence
-sprout set-presence --status online | jq .
-sprout set-presence --status away | jq .
-sprout set-presence --status offline | jq .
+# users presence
+sprout users presence --pubkeys "$MY_PUBKEY" | 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 +341,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 +357,43 @@ 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" --approved 2>&1 || true
 # Should fail with relay error (token not found), not a validation error
 
-# 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,43 +404,37 @@ 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 -u SPROUT_PUBKEY \
+  cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?"
+# stderr: {"error":"auth_error","message":"auth error: Set 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: $?"
+sprout channels get --channel "00000000-0000-0000-0000-000000000000" 2>&1; echo "exit: $?"
 # stderr: {"error":"relay_error","message":"..."}
 # exit: 2
 ```
@@ -499,24 +443,20 @@ sprout get-channel --channel "00000000-0000-0000-0000-000000000000" 2>&1; echo "
 
 ## 8. Auth Mode Testing
 
-Test all three authentication tiers.
+Test both authentication modes.
 
 ```bash
-# Mode 1: Bearer token (SPROUT_API_TOKEN)
-SPROUT_API_TOKEN="sprout_tok_..." sprout list-channels | jq .
+# Mode 1: Private key (SPROUT_PRIVATE_KEY)
+SPROUT_PRIVATE_KEY="nsec1..." sprout channels list | 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 .
+# Mode 2: Dev mode (SPROUT_PUBKEY) — only works with SPROUT_REQUIRE_AUTH_TOKEN=false
+SPROUT_PUBKEY="<your-64-char-hex-pubkey>" 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 -u SPROUT_PUBKEY \
+  cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?"
 # exit: 3
 ```
 
@@ -526,8 +466,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 +476,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); use --approved / --no-approved |
+| 43 | `feed get` | ☐ | |
+| 44 | `social publish-note` | ☐ | |
+| 45 | `social set-contact-list` | ☐ | |
+| 46 | `social get-event` | ☐ | |
+| 47 | `social get-user-notes` | ☐ | |
+| 48 | `social get-contact-list` | ☐ | |
+| 49 | `repos create` | ☐ | |
+| 50 | `repos get` | ☐ | |
+| 51 | `repos list` | ☐ | |
+| 52 | `upload file` | ☐ | |
+| 53 | `pack validate` | ☐ | Local, no relay |
+| 54 | `pack inspect` | ☐ | Local, no relay |
diff --git a/crates/sprout-cli/src/commands/channels.rs b/crates/sprout-cli/src/commands/channels.rs
index f7c08bb364..f809ef9136 100644
--- a/crates/sprout-cli/src/commands/channels.rs
+++ b/crates/sprout-cli/src/commands/channels.rs
@@ -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(())
@@ -150,7 +135,7 @@ pub async fn cmd_update_channel(
     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(())
@@ -167,7 +152,7 @@ pub async fn cmd_set_channel_topic(
     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(())
@@ -184,7 +169,7 @@ pub async fn cmd_set_channel_purpose(
     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(())
@@ -197,7 +182,7 @@ pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result
     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(())
@@ -210,7 +195,7 @@ pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Resul
     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(())
@@ -223,7 +208,7 @@ pub async fn cmd_archive_channel(client: &SproutClient, channel_id: &str) -> Res
     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(())
@@ -239,7 +224,7 @@ pub async fn cmd_unarchive_channel(
     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(())
@@ -252,7 +237,7 @@ pub async fn cmd_delete_channel(client: &SproutClient, channel_id: &str) -> Resu
     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(())
@@ -284,7 +269,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(())
@@ -302,7 +287,7 @@ pub async fn cmd_remove_channel_member(
     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(())
@@ -320,7 +305,7 @@ pub async fn cmd_set_canvas(
     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(())
diff --git a/crates/sprout-cli/src/commands/messages.rs b/crates/sprout-cli/src/commands/messages.rs
index 7b7e36f87b..5e386a2d2a 100644
--- a/crates/sprout-cli/src/commands/messages.rs
+++ b/crates/sprout-cli/src/commands/messages.rs
@@ -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:
diff --git a/crates/sprout-cli/src/commands/social.rs b/crates/sprout-cli/src/commands/social.rs
index efc3b68ce1..dc9a14364b 100644
--- a/crates/sprout-cli/src/commands/social.rs
+++ b/crates/sprout-cli/src/commands/social.rs
@@ -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)]
diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs
index 4cf2b18ae6..4e7ce109bc 100644
--- a/crates/sprout-cli/src/lib.rs
+++ b/crates/sprout-cli/src/lib.rs
@@ -52,6 +52,7 @@ where
 #[derive(Parser)]
 #[command(name = "sprout", about = "Sprout CLI — interact with a Sprout relay")]
 struct Cli {
+    /// Relay URL (http:// or https://). Overrides SPROUT_RELAY_URL env var.
     #[arg(
         long,
         env = "SPROUT_RELAY_URL",
@@ -72,14 +73,60 @@ struct Cli {
 }
 
 // ---------------------------------------------------------------------------
-// Subcommands
+// Subcommand groups
 // ---------------------------------------------------------------------------
 
 #[derive(Subcommand)]
 enum Cmd {
-    // ---- Messages ----------------------------------------------------------
+    /// Send, read, search, and manage messages
+    #[command(subcommand)]
+    Messages(MessagesCmd),
+    /// Create, configure, and manage channels
+    #[command(subcommand)]
+    Channels(ChannelsCmd),
+    /// Get and set channel canvas documents
+    #[command(subcommand)]
+    Canvas(CanvasCmd),
+    /// Add, remove, and list emoji reactions
+    #[command(subcommand)]
+    Reactions(ReactionsCmd),
+    /// List, open, and manage direct messages
+    #[command(subcommand)]
+    Dms(DmsCmd),
+    /// Look up users and manage profiles and presence
+    #[command(subcommand)]
+    Users(UsersCmd),
+    /// Create, trigger, and manage workflows
+    #[command(subcommand)]
+    Workflows(WorkflowsCmd),
+    /// Read the activity feed
+    #[command(subcommand)]
+    Feed(FeedCmd),
+    /// Publish notes and manage the social graph (NIP-01/02)
+    #[command(subcommand)]
+    Social(SocialCmd),
+    /// Announce and discover git repositories (NIP-34)
+    #[command(subcommand)]
+    Repos(ReposCmd),
+    /// Upload files to the relay's Blossom store
+    #[command(subcommand)]
+    Upload(UploadCmd),
+    /// Persona pack operations (local, no relay connection needed)
+    #[command(subcommand)]
+    Pack(PackCmd),
+}
+
+// ---------------------------------------------------------------------------
+// Messages subcommands
+// ---------------------------------------------------------------------------
+
+#[derive(Subcommand)]
+pub enum MessagesCmd {
     /// Send a message to a channel
-    SendMessage {
+    #[command(
+        after_help = "Examples:\n  sprout messages send --channel <UUID> --content \"hello\"\n  sprout messages send --channel <UUID> --content \"@alice check this\" --mention alice"
+    )]
+    Send {
         #[arg(long)]
         channel: String,
         #[arg(long)]
@@ -96,8 +143,8 @@ enum Cmd {
         #[arg(long = "file")]
         files: Vec<String>,
     },
-    /// Send a diff/code-review message
-    SendDiffMessage {
+    /// Send a code diff / patch to a channel
+    SendDiff {
         #[arg(long)]
         channel: String,
         #[arg(long)]
@@ -123,53 +170,60 @@ enum Cmd {
         #[arg(long)]
         reply_to: Option<String>,
     },
+    /// Edit a previously sent message
+    Edit {
+        /// Event ID of the message to edit (64-char hex)
+        #[arg(long)]
+        event: String,
+        /// New message content
+        #[arg(long)]
+        content: String,
+    },
     /// Delete a message by event ID
-    DeleteMessage {
+    Delete {
         #[arg(long)]
         event: String,
     },
-    /// Get messages from a channel
-    GetMessages {
+    /// Retrieve messages from a channel
+    #[command(
+        after_help = "Examples:\n  sprout messages get --channel <UUID>\n  sprout messages get --channel <UUID> --limit 50 --kinds 1,1984"
+    )]
+    Get {
         #[arg(long)]
         channel: String,
+        /// Maximum number of results to return
         #[arg(long)]
         limit: Option<u32>,
         #[arg(long)]
         before: Option<i64>,
         #[arg(long)]
         since: Option<i64>,
+        /// Comma-separated event kinds to filter (e.g. 1,1984)
         #[arg(long)]
         kinds: Option<String>,
     },
-    /// Get a message thread
-    GetThread {
+    /// Get a message thread (replies to a root message)
+    Thread {
         #[arg(long)]
         channel: String,
         #[arg(long)]
         event: String,
         #[arg(long)]
         depth_limit: Option<u32>,
+        /// Maximum number of results to return
         #[arg(long)]
         limit: Option<u32>,
     },
-    /// Search messages
+    /// Full-text search across messages
     Search {
         #[arg(long)]
         query: String,
+        /// Maximum number of results to return
         #[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 {
+    /// Upvote or downvote a forum post
+    Vote {
         /// Event ID of the post to vote on (64-char hex)
         #[arg(long)]
         event: String,
@@ -177,29 +231,36 @@ enum Cmd {
         #[arg(long)]
         direction: String,
     },
+}
 
-    /// Upload a file to the relay (returns BlobDescriptor JSON)
-    UploadFile {
-        /// Path to the file to upload
-        #[arg(long)]
-        file: String,
-    },
+// ---------------------------------------------------------------------------
+// Channels subcommands
+// ---------------------------------------------------------------------------
 
-    // ---- Channels ----------------------------------------------------------
-    /// List channels
-    ListChannels {
+#[derive(Subcommand)]
+pub enum ChannelsCmd {
+    /// List channels visible to the current identity
+    #[command(
+        after_help = "Examples:\n  sprout channels list\n  sprout channels list --visibility open"
+    )]
+    List {
+        /// Filter by visibility (e.g. open, closed)
         #[arg(long)]
         visibility: Option<String>,
+        /// Only show channels where the current identity is a member
         #[arg(long, default_value_t = false)]
         member: bool,
     },
-    /// Get a channel by ID
-    GetChannel {
+    /// Get details for a single channel
+    Get {
         #[arg(long)]
         channel: String,
     },
     /// Create a new channel
-    CreateChannel {
+    #[command(
+        after_help = "Examples:\n  sprout channels create --name general --type stream --visibility open\n  sprout channels create --name design --type forum --visibility open --description \"Design discussions\""
+    )]
+    Create {
         #[arg(long)]
         name: String,
         #[arg(long = "type")]
@@ -209,8 +270,8 @@ enum Cmd {
         #[arg(long)]
         description: Option<String>,
     },
-    /// Update a channel's name or description
-    UpdateChannel {
+    /// Update channel name or description
+    Update {
         #[arg(long)]
         channel: String,
         #[arg(long)]
@@ -218,52 +279,53 @@ enum Cmd {
         #[arg(long)]
         description: Option<String>,
     },
-    /// Set a channel's topic
-    SetChannelTopic {
+    /// Set the channel topic
+    Topic {
         #[arg(long)]
         channel: String,
         #[arg(long)]
         topic: String,
     },
-    /// Set a channel's purpose
-    SetChannelPurpose {
+    /// Set the channel purpose
+    Purpose {
         #[arg(long)]
         channel: String,
         #[arg(long)]
         purpose: String,
     },
     /// Join a channel
-    JoinChannel {
+    Join {
         #[arg(long)]
         channel: String,
     },
     /// Leave a channel
-    LeaveChannel {
+    Leave {
         #[arg(long)]
         channel: String,
     },
     /// Archive a channel
-    ArchiveChannel {
+    Archive {
         #[arg(long)]
         channel: String,
     },
     /// Unarchive a channel
-    UnarchiveChannel {
+    Unarchive {
         #[arg(long)]
         channel: String,
     },
-    /// Delete a channel
-    DeleteChannel {
+    /// Delete a channel permanently
+    Delete {
         #[arg(long)]
         channel: String,
     },
-    /// List channel members
-    ListChannelMembers {
+    /// List members of a channel
+    Members {
         #[arg(long)]
         channel: String,
     },
     /// Add a member to a channel
-    AddChannelMember {
+    #[command(name = "add-member")]
+    AddMember {
         #[arg(long)]
         channel: String,
         #[arg(long)]
@@ -272,75 +334,104 @@ enum Cmd {
         role: Option<String>,
     },
     /// Remove a member from a channel
-    RemoveChannelMember {
+    #[command(name = "remove-member")]
+    RemoveMember {
         #[arg(long)]
         channel: String,
         #[arg(long)]
         pubkey: String,
     },
-    /// Get a channel's canvas
-    GetCanvas {
+}
+
+// ---------------------------------------------------------------------------
+// Canvas subcommands
+// ---------------------------------------------------------------------------
+
+#[derive(Subcommand)]
+pub enum CanvasCmd {
+    /// Get the canvas document for a channel
+    Get {
         #[arg(long)]
         channel: String,
     },
-    /// Set a channel's canvas content
-    SetCanvas {
+    /// Set (replace) the canvas document for a channel
+    Set {
         #[arg(long)]
         channel: String,
         #[arg(long)]
         content: String,
     },
+}
 
-    // ---- Reactions ---------------------------------------------------------
-    /// Add a reaction to a message
-    AddReaction {
+// ---------------------------------------------------------------------------
+// Reactions subcommands
+// ---------------------------------------------------------------------------
+
+#[derive(Subcommand)]
+pub enum ReactionsCmd {
+    /// Add an emoji reaction to a message
+    Add {
         #[arg(long)]
         event: String,
         #[arg(long)]
         emoji: String,
     },
-    /// Remove a reaction from a message
-    RemoveReaction {
+    /// Remove an emoji reaction from a message
+    Remove {
         #[arg(long)]
         event: String,
         #[arg(long)]
         emoji: String,
     },
-    /// Get reactions on a message
-    GetReactions {
+    /// List reactions on a message
+    Get {
         #[arg(long)]
         event: String,
     },
+}
+
+// ---------------------------------------------------------------------------
+// DMs subcommands
+// ---------------------------------------------------------------------------
 
-    // ---- DMs ---------------------------------------------------------------
-    /// List DM conversations
-    ListDms {
+#[derive(Subcommand)]
+pub enum DmsCmd {
+    /// List direct message conversations
+    List {
+        /// Maximum number of results to return
         #[arg(long)]
         limit: Option<u32>,
     },
-    /// Open a DM with one or more users (1–8 pubkeys)
-    OpenDm {
+    /// Open a new direct message with one or more users
+    Open {
         #[arg(long = "pubkey")]
         pubkeys: Vec<String>,
     },
-    /// Add a member to a DM group
-    AddDmMember {
+    /// Add a member to an existing DM conversation
+    AddMember {
         #[arg(long)]
         channel: String,
         #[arg(long)]
         pubkey: String,
     },
+}
+
+// ---------------------------------------------------------------------------
+// Users subcommands
+// ---------------------------------------------------------------------------
 
-    // ---- Users -------------------------------------------------------------
-    /// Get user profiles by pubkey or display name
-    GetUsers {
+#[derive(Subcommand)]
+pub enum UsersCmd {
+    /// Look up user profiles by pubkey or name
+    Get {
         #[arg(long = "pubkey")]
         pubkeys: Vec<String>,
         /// Search by display name (case-insensitive substring match)
         #[arg(long = "name")]
         name: Option<String>,
     },
-    /// Update your profile
+    /// Update the current identity's profile
+    #[command(name = "set-profile")]
     SetProfile {
         #[arg(long)]
         name: Option<String>,
@@ -351,32 +442,44 @@ enum Cmd {
         #[arg(long)]
         nip05: Option<String>,
     },
-    /// Get presence status for users (comma-separated pubkeys)
-    GetPresence {
+    /// Get presence status for users
+    Presence {
         #[arg(long)]
         pubkeys: String,
     },
-    /// Set your presence status
+    /// Set your presence status (online/away/offline)
+    #[command(name = "set-presence")]
     SetPresence {
         #[arg(long)]
         status: String,
     },
+}
+
+// ---------------------------------------------------------------------------
+// Workflows subcommands
+// ---------------------------------------------------------------------------
 
-    // ---- Workflows ---------------------------------------------------------
+#[derive(Subcommand)]
+pub enum WorkflowsCmd {
     /// List workflows in a channel
-    ListWorkflows {
+    List {
         #[arg(long)]
         channel: String,
     },
-    /// Create a workflow in a channel
-    CreateWorkflow {
+    /// Get details for a single workflow
+    Get {
+        #[arg(long)]
+        workflow: String,
+    },
+    /// Create a workflow from a YAML definition
+    Create {
         #[arg(long)]
         channel: String,
         #[arg(long)]
         yaml: String,
     },
-    /// Update a workflow
-    UpdateWorkflow {
+    /// Update a workflow's YAML definition
+    Update {
         #[arg(long)]
         channel: String,
         #[arg(long)]
@@ -385,52 +488,66 @@ enum Cmd {
         yaml: String,
     },
     /// Delete a workflow
-    DeleteWorkflow {
+    Delete {
         #[arg(long)]
         workflow: String,
     },
-    /// Trigger a workflow manually
-    TriggerWorkflow {
+    /// Trigger a workflow run
+    #[command(after_help = "Examples:\n  sprout workflows trigger --workflow <UUID>")]
+    Trigger {
         #[arg(long)]
         workflow: String,
     },
-    /// Get workflow run history
-    GetWorkflowRuns {
+    /// List runs for a workflow
+    Runs {
         #[arg(long)]
         workflow: String,
+        /// Maximum number of results to return
         #[arg(long)]
         limit: Option<u32>,
     },
-    /// Get a workflow definition
-    GetWorkflow {
-        #[arg(long)]
-        workflow: String,
-    },
-    /// Approve or deny a workflow approval step
-    ApproveStep {
+    /// Approve or deny a workflow step
+    #[command(
+        after_help = "Examples:\n  sprout workflows approve --token <UUID>\n  sprout workflows approve --token <UUID> --no-approved --note \"needs revision\""
+    )]
+    Approve {
         /// The approval token UUID (from the approval request)
         #[arg(long)]
         token: String,
-        /// Whether to approve: "true" or "false"
-        #[arg(long)]
-        approved: String,
+        /// Approve the step (pass --no-approved to deny)
+        #[arg(long, default_value_t = true)]
+        approved: bool,
+        /// Optional note to include with the approval/denial
         #[arg(long)]
         note: Option<String>,
     },
+}
 
-    // ---- Feed --------------------------------------------------------------
-    /// Get your activity feed
-    GetFeed {
+// ---------------------------------------------------------------------------
+// Feed subcommands
+// ---------------------------------------------------------------------------
+
+#[derive(Subcommand)]
+pub enum FeedCmd {
+    /// Get recent activity feed entries
+    Get {
         #[arg(long)]
         since: Option<i64>,
+        /// Maximum number of results to return
         #[arg(long)]
         limit: Option<u32>,
         #[arg(long)]
         types: Option<String>,
     },
+}
+
+// ---------------------------------------------------------------------------
+// Social subcommands
+// ---------------------------------------------------------------------------
 
-    // Social
-    /// Publish a short text note (kind:1) to the global feed.
+#[derive(Subcommand)]
+pub enum SocialCmd {
+    /// Publish a text note (NIP-01 kind:1)
     #[command(name = "publish-note")]
     PublishNote {
         /// Text content of the note.
@@ -440,24 +557,21 @@ enum Cmd {
         #[arg(long)]
         reply_to: Option<String>,
     },
-
-    /// Set the authenticated user's contact/follow list (kind:3). Replaces the entire list.
+    /// Set your contact list (NIP-02 kind:3)
     #[command(name = "set-contact-list")]
     SetContactList {
         /// JSON array of contacts: [{"pubkey":"hex","relay_url":"...","petname":"..."}]
         #[arg(long)]
         contacts: String,
     },
-
-    /// Get a single event by event ID (notes, profiles, contacts, articles, channel events).
+    /// Get a single event by ID
     #[command(name = "get-event")]
     GetEvent {
         /// 64-char hex event ID.
         #[arg(long)]
         event: String,
     },
-
-    /// List kind:1 text notes by a specific user.
+    /// Get recent notes published by a user
     #[command(name = "get-user-notes")]
     GetUserNotes {
         /// 64-char hex pubkey of the author.
@@ -470,19 +584,23 @@ enum Cmd {
         #[arg(long)]
         before: Option<i64>,
     },
-
-    /// Get a user's contact/follow list (kind:3) by hex pubkey.
+    /// Get a user's contact list
     #[command(name = "get-contact-list")]
     GetContactList {
         /// 64-char hex pubkey.
         #[arg(long)]
         pubkey: String,
     },
+}
+
+// ---------------------------------------------------------------------------
+// Repos subcommands
+// ---------------------------------------------------------------------------
 
-    // ---- Git Repos ---------------------------------------------------------
-    /// Create a git repository (publishes kind:30617 announcement)
-    #[command(name = "create-repo")]
-    CreateRepo {
+#[derive(Subcommand)]
+pub enum ReposCmd {
+    /// Announce a git repository (NIP-34)
+    Create {
         /// Repository identifier: [a-zA-Z0-9._-]{1,64}
         #[arg(long)]
         id: String,
@@ -502,9 +620,8 @@ enum Cmd {
         #[arg(long = "relay")]
         relays: Vec<String>,
     },
-    /// Get a repository's announcement event by ID
-    #[command(name = "get-repo")]
-    GetRepo {
+    /// Get a repository announcement
+    Get {
         /// Repository identifier (d-tag)
         #[arg(long)]
         id: String,
@@ -512,9 +629,8 @@ enum Cmd {
         #[arg(long)]
         owner: Option<String>,
     },
-    /// List repositories (defaults to your own)
-    #[command(name = "list-repos")]
-    ListRepos {
+    /// List repository announcements
+    List {
         /// Owner pubkey (64-char hex). Omit for your repos.
         #[arg(long)]
         owner: Option<String>,
@@ -522,13 +638,26 @@ enum Cmd {
         #[arg(long)]
         limit: Option<u32>,
     },
+}
 
-    // ---- Pack (local) ------------------------------------------------------
-    /// Persona pack operations (local, no relay connection needed)
-    #[command(subcommand)]
-    Pack(PackCmd),
+// ---------------------------------------------------------------------------
+// Upload subcommands
+// ---------------------------------------------------------------------------
+
+#[derive(Subcommand)]
+pub enum UploadCmd {
+    /// Upload a file to the relay's Blossom store
+    File {
+        /// Path to the file to upload
+        #[arg(long)]
+        file: String,
+    },
 }
 
+// ---------------------------------------------------------------------------
+// Pack subcommands (local, no relay connection needed)
+// ---------------------------------------------------------------------------
+
 /// Subcommands for `sprout pack`.
 #[derive(Subcommand)]
 enum PackCmd {
@@ -545,20 +674,9 @@ enum PackCmd {
 }
 
 // ---------------------------------------------------------------------------
-// Internal helpers
+// Command dispatch
 // ---------------------------------------------------------------------------
 
-/// 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);
 
@@ -597,308 +715,340 @@ async fn run(cli: Cli) -> Result<(), CliError> {
     let client = SproutClient::new(relay_url, keys, auth_tag, auth_tag_json)?;
 
     match cli.command {
-        // ---- Messages ------------------------------------------------------
-        Cmd::SendMessage {
-            channel,
-            content,
-            kind,
-            reply_to,
-            broadcast,
-            mentions,
-            files,
-        } => {
-            commands::messages::cmd_send_message(
-                &client,
-                commands::messages::SendMessageParams {
-                    channel_id: channel,
-                    content,
-                    kind,
-                    reply_to,
-                    broadcast,
-                    mentions,
-                    files,
-                },
-            )
-            .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,
-            since,
-            kinds,
-        } => {
-            commands::messages::cmd_get_messages(
-                &client,
-                &channel,
+        Cmd::Messages(sub) => match sub {
+            MessagesCmd::Send {
+                channel,
+                content,
+                kind,
+                reply_to,
+                broadcast,
+                mentions,
+                files,
+            } => {
+                commands::messages::cmd_send_message(
+                    &client,
+                    commands::messages::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,
+            } => {
+                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
+            }
+            MessagesCmd::Edit { event, content } => {
+                commands::messages::cmd_edit_message(&client, &event, &content).await
+            }
+            MessagesCmd::Delete { event } => {
+                commands::messages::cmd_delete_message(&client, &event).await
+            }
+            MessagesCmd::Get {
+                channel,
                 limit,
                 before,
                 since,
-                kinds.as_deref(),
-            )
-            .await
-        }
-        Cmd::GetThread {
-            channel,
-            event,
-            depth_limit,
-            limit,
-        } => {
-            commands::messages::cmd_get_thread(&client, &channel, &event, depth_limit, limit).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
-        }
-        Cmd::UploadFile { file } => {
-            let desc = client.upload_file(&file).await?;
-            println!(
-                "{}",
-                serde_json::to_string_pretty(&desc).map_err(|e| CliError::Other(e.to_string()))?
-            );
-            Ok(())
-        }
+                kinds,
+            } => {
+                commands::messages::cmd_get_messages(
+                    &client,
+                    &channel,
+                    limit,
+                    before,
+                    since,
+                    kinds.as_deref(),
+                )
+                .await
+            }
+            MessagesCmd::Thread {
+                channel,
+                event,
+                depth_limit,
+                limit,
+            } => {
+                commands::messages::cmd_get_thread(&client, &channel, &event, depth_limit, limit)
+                    .await
+            }
+            MessagesCmd::Search { query, limit } => {
+                commands::messages::cmd_search(&client, &query, limit).await
+            }
+            MessagesCmd::Vote { 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))
+        Cmd::Channels(sub) => match sub {
+            ChannelsCmd::List { visibility, member } => {
+                commands::channels::cmd_list_channels(&client, visibility.as_deref(), Some(member))
+                    .await
+            }
+            ChannelsCmd::Get { channel } => {
+                commands::channels::cmd_get_channel(&client, &channel).await
+            }
+            ChannelsCmd::Create {
+                name,
+                channel_type,
+                visibility,
+                description,
+            } => {
+                commands::channels::cmd_create_channel(
+                    &client,
+                    &name,
+                    &channel_type,
+                    &visibility,
+                    description.as_deref(),
+                )
                 .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())
+            }
+            ChannelsCmd::Update {
+                channel,
+                name,
+                description,
+            } => {
+                commands::channels::cmd_update_channel(
+                    &client,
+                    &channel,
+                    name.as_deref(),
+                    description.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
-        }
+            }
+            ChannelsCmd::Topic { channel, topic } => {
+                commands::channels::cmd_set_channel_topic(&client, &channel, &topic).await
+            }
+            ChannelsCmd::Purpose { channel, purpose } => {
+                commands::channels::cmd_set_channel_purpose(&client, &channel, &purpose).await
+            }
+            ChannelsCmd::Join { channel } => {
+                commands::channels::cmd_join_channel(&client, &channel).await
+            }
+            ChannelsCmd::Leave { channel } => {
+                commands::channels::cmd_leave_channel(&client, &channel).await
+            }
+            ChannelsCmd::Archive { channel } => {
+                commands::channels::cmd_archive_channel(&client, &channel).await
+            }
+            ChannelsCmd::Unarchive { channel } => {
+                commands::channels::cmd_unarchive_channel(&client, &channel).await
+            }
+            ChannelsCmd::Delete { channel } => {
+                commands::channels::cmd_delete_channel(&client, &channel).await
+            }
+            ChannelsCmd::Members { channel } => {
+                commands::channels::cmd_list_channel_members(&client, &channel).await
+            }
+            ChannelsCmd::AddMember {
+                channel,
+                pubkey,
+                role,
+            } => {
+                commands::channels::cmd_add_channel_member(
+                    &client,
+                    &channel,
+                    &pubkey,
+                    role.as_deref(),
+                )
+                .await
+            }
+            ChannelsCmd::RemoveMember { channel, pubkey } => {
+                commands::channels::cmd_remove_channel_member(&client, &channel, &pubkey).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
-        }
+        Cmd::Canvas(sub) => match sub {
+            CanvasCmd::Get { channel } => {
+                commands::channels::cmd_get_canvas(&client, &channel).await
+            }
+            CanvasCmd::Set { channel, content } => {
+                commands::channels::cmd_set_canvas(&client, &channel, &content).await
+            }
+        },
 
-        // ---- DMs -----------------------------------------------------------
-        Cmd::ListDms { limit } => commands::dms::cmd_list_dms(&client, 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
-        }
+        Cmd::Reactions(sub) => match sub {
+            ReactionsCmd::Add { event, emoji } => {
+                commands::reactions::cmd_add_reaction(&client, &event, &emoji).await
+            }
+            ReactionsCmd::Remove { event, emoji } => {
+                commands::reactions::cmd_remove_reaction(&client, &event, &emoji).await
+            }
+            ReactionsCmd::Get { event } => {
+                commands::reactions::cmd_get_reactions(&client, &event).await
+            }
+        },
 
-        // ---- Users ---------------------------------------------------------
-        Cmd::GetUsers { pubkeys, name } => {
-            commands::users::cmd_get_users(&client, &pubkeys, name.as_deref()).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::Dms(sub) => match sub {
+            DmsCmd::List { limit } => commands::dms::cmd_list_dms(&client, limit).await,
+            DmsCmd::Open { pubkeys } => commands::dms::cmd_open_dm(&client, &pubkeys).await,
+            DmsCmd::AddMember { channel, pubkey } => {
+                commands::dms::cmd_add_dm_member(&client, &channel, &pubkey).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 {
-            channel,
-            workflow,
-            yaml,
-        } => commands::workflows::cmd_update_workflow(&client, &channel, &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
-        }
+        Cmd::Users(sub) => match sub {
+            UsersCmd::Get { pubkeys, name } => {
+                commands::users::cmd_get_users(&client, &pubkeys, name.as_deref()).await
+            }
+            UsersCmd::SetProfile {
+                name,
+                avatar,
+                about,
+                nip05,
+            } => {
+                commands::users::cmd_set_profile(
+                    &client,
+                    name.as_deref(),
+                    avatar.as_deref(),
+                    about.as_deref(),
+                    nip05.as_deref(),
+                )
+                .await
+            }
+            UsersCmd::Presence { pubkeys } => {
+                commands::users::cmd_get_presence(&client, &pubkeys).await
+            }
+            UsersCmd::SetPresence { status } => {
+                commands::users::cmd_set_presence(&client, &status).await
+            }
+        },
 
-        // ---- Feed ----------------------------------------------------------
-        Cmd::GetFeed {
-            since,
-            limit,
-            types,
-        } => commands::feed::cmd_get_feed(&client, since, limit, types.as_deref()).await,
+        Cmd::Workflows(sub) => match sub {
+            WorkflowsCmd::List { channel } => {
+                commands::workflows::cmd_list_workflows(&client, &channel).await
+            }
+            WorkflowsCmd::Get { workflow } => {
+                commands::workflows::cmd_get_workflow(&client, &workflow).await
+            }
+            WorkflowsCmd::Create { channel, yaml } => {
+                commands::workflows::cmd_create_workflow(&client, &channel, &yaml).await
+            }
+            WorkflowsCmd::Update {
+                channel,
+                workflow,
+                yaml,
+            } => {
+                commands::workflows::cmd_update_workflow(&client, &channel, &workflow, &yaml).await
+            }
+            WorkflowsCmd::Delete { workflow } => {
+                commands::workflows::cmd_delete_workflow(&client, &workflow).await
+            }
+            WorkflowsCmd::Trigger { workflow } => {
+                commands::workflows::cmd_trigger_workflow(&client, &workflow).await
+            }
+            WorkflowsCmd::Runs { workflow, limit } => {
+                commands::workflows::cmd_get_workflow_runs(&client, &workflow, limit).await
+            }
+            WorkflowsCmd::Approve {
+                token,
+                approved,
+                note,
+            } => {
+                // approved is already a bool — no parse_bool_flag needed
+                commands::workflows::cmd_approve_step(&client, &token, approved, note.as_deref())
+                    .await
+            }
+        },
 
-        // ---- Social --------------------------------------------------------
-        Cmd::PublishNote { content, reply_to } => {
-            commands::social::cmd_publish_note(&client, &content, reply_to.as_deref()).await
-        }
-        Cmd::SetContactList { contacts } => {
-            commands::social::cmd_set_contact_list(&client, &contacts).await
-        }
-        Cmd::GetEvent { event } => commands::social::cmd_get_event(&client, &event).await,
-        Cmd::GetUserNotes {
-            pubkey,
-            limit,
-            before,
-        } => commands::social::cmd_get_user_notes(&client, &pubkey, limit, before).await,
-        Cmd::GetContactList { pubkey } => {
-            commands::social::cmd_get_contact_list(&client, &pubkey).await
-        }
+        Cmd::Feed(sub) => match sub {
+            FeedCmd::Get {
+                since,
+                limit,
+                types,
+            } => commands::feed::cmd_get_feed(&client, since, limit, types.as_deref()).await,
+        },
 
-        // ---- Git Repos -------------------------------------------------
-        Cmd::CreateRepo {
-            id,
-            name,
-            description,
-            clone_urls,
-            web,
-            relays,
-        } => {
-            commands::repos::cmd_create_repo(
-                &client,
-                &id,
-                name.as_deref(),
-                description.as_deref(),
-                &clone_urls,
-                web.as_deref(),
-                &relays,
-            )
-            .await
-        }
-        Cmd::GetRepo { id, owner } => {
-            commands::repos::cmd_get_repo(&client, &id, owner.as_deref()).await
-        }
-        Cmd::ListRepos { owner, limit } => {
-            commands::repos::cmd_list_repos(&client, owner.as_deref(), limit).await
-        }
+        Cmd::Social(sub) => match sub {
+            SocialCmd::PublishNote { content, reply_to } => {
+                commands::social::cmd_publish_note(&client, &content, reply_to.as_deref()).await
+            }
+            SocialCmd::SetContactList { contacts } => {
+                commands::social::cmd_set_contact_list(&client, &contacts).await
+            }
+            SocialCmd::GetEvent { event } => commands::social::cmd_get_event(&client, &event).await,
+            SocialCmd::GetUserNotes {
+                pubkey,
+                limit,
+                before,
+            } => commands::social::cmd_get_user_notes(&client, &pubkey, limit, before).await,
+            SocialCmd::GetContactList { pubkey } => {
+                commands::social::cmd_get_contact_list(&client, &pubkey).await
+            }
+        },
+
+        Cmd::Repos(sub) => match sub {
+            ReposCmd::Create {
+                id,
+                name,
+                description,
+                clone_urls,
+                web,
+                relays,
+            } => {
+                commands::repos::cmd_create_repo(
+                    &client,
+                    &id,
+                    name.as_deref(),
+                    description.as_deref(),
+                    &clone_urls,
+                    web.as_deref(),
+                    &relays,
+                )
+                .await
+            }
+            ReposCmd::Get { id, owner } => {
+                commands::repos::cmd_get_repo(&client, &id, owner.as_deref()).await
+            }
+            ReposCmd::List { owner, limit } => {
+                commands::repos::cmd_list_repos(&client, owner.as_deref(), limit).await
+            }
+        },
+
+        Cmd::Upload(sub) => match sub {
+            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(())
+            }
+        },
 
-        // ---- Pack (local) --------------------------------------------------
         Cmd::Pack(_) => unreachable!("handled above"),
     }
 }
@@ -918,112 +1068,77 @@ mod tests {
         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 set of commands.
-    /// Token commands removed (auth, list-tokens, delete-token, delete-all-tokens).
-    /// SetChannelAddPolicy removed (relay-side policy now).
-    /// Cursor removed from get-thread, list-dms. before_id removed from get-user-notes.
     #[test]
     fn command_inventory_is_stable() {
-        let expected: Vec<&str> = vec![
-            "add-channel-member",
-            "add-dm-member",
-            "add-reaction",
-            "approve-step",
-            "archive-channel",
-            "create-channel",
-            "create-repo",
-            "create-workflow",
-            "delete-channel",
-            "delete-message",
-            "delete-workflow",
-            "edit-message",
-            "get-canvas",
-            "get-channel",
-            "get-contact-list",
-            "get-event",
-            "get-feed",
-            "get-messages",
-            "get-presence",
-            "get-reactions",
-            "get-repo",
-            "get-thread",
-            "get-user-notes",
-            "get-users",
-            "get-workflow",
-            "get-workflow-runs",
-            "join-channel",
-            "leave-channel",
-            "list-channel-members",
-            "list-channels",
-            "list-dms",
-            "list-repos",
-            "list-workflows",
-            "open-dm",
+        let expected_groups: Vec<&str> = vec![
+            "canvas",
+            "channels",
+            "dms",
+            "feed",
+            "messages",
             "pack",
-            "publish-note",
-            "remove-channel-member",
-            "remove-reaction",
-            "search",
-            "send-diff-message",
-            "send-message",
-            "set-canvas",
-            "set-channel-purpose",
-            "set-channel-topic",
-            "set-contact-list",
-            "set-presence",
-            "set-profile",
-            "trigger-workflow",
-            "unarchive-channel",
-            "update-channel",
-            "update-workflow",
-            "upload-file",
-            "vote-on-post",
+            "reactions",
+            "repos",
+            "social",
+            "upload",
+            "users",
+            "workflows",
         ];
 
         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"
+            .filter(|n| n != "help")
             .collect();
         actual.sort();
 
         assert_eq!(
             actual.len(),
-            expected.len(),
-            "Expected {} commands, got {}. Actual: {:?}",
-            expected.len(),
+            expected_groups.len(),
+            "Expected {} groups, got {}. Actual: {:?}",
+            expected_groups.len(),
             actual.len(),
             actual
         );
-        assert_eq!(actual, expected, "Command inventory drift detected");
+        assert_eq!(
+            actual, expected_groups,
+            "Command group inventory drift detected"
+        );
+    }
+
+    #[test]
+    fn subcommand_counts_are_stable() {
+        let expected: Vec<(&str, usize)> = vec![
+            ("canvas", 2),
+            ("channels", 14),
+            ("dms", 3),
+            ("feed", 1),
+            ("messages", 8),
+            ("pack", 2),
+            ("reactions", 3),
+            ("repos", 3),
+            ("social", 5),
+            ("upload", 1),
+            ("users", 4),
+            ("workflows", 8),
+        ];
+
+        let cmd = Cli::command();
+        for (group_name, expected_count) in &expected {
+            let group = cmd
+                .get_subcommands()
+                .find(|s| s.get_name() == *group_name)
+                .unwrap_or_else(|| panic!("group '{}' not found", group_name));
+            let actual_count = group
+                .get_subcommands()
+                .filter(|s| s.get_name() != "help")
+                .count();
+            assert_eq!(
+                actual_count, *expected_count,
+                "Group '{}': expected {} subcommands, got {}",
+                group_name, expected_count, actual_count
+            );
+        }
     }
 }
diff --git a/crates/sprout-cli/src/validate.rs b/crates/sprout-cli/src/validate.rs
index 0903a40885..96dd048da3 100644
--- a/crates/sprout-cli/src/validate.rs
+++ b/crates/sprout-cli/src/validate.rs
@@ -6,6 +6,19 @@ 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 channel 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}")))?;
@@ -367,6 +380,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]

From bc5d844336f5a0e06484b209ce638c43c830a394 Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Thu, 14 May 2026 16:22:26 -0400
Subject: [PATCH 2/6] fix(cli): address 15 review items from 6-source crossfire
 review
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The initial restructuring commit (9809ebe) was reviewed by 3 Claude
specialists, Codex, Gemini, and the plan author. This commit addresses
all 15 findings — 1 blocking bug, 10 should-fix, 4 optional.

Critical: --approved flag used default_value_t which broke --no-approved
negation. Changed to ArgAction::Set accepting --approved true/false.

Added doc comments to all ~78 bare arg fields, value_enum types for
--type/--visibility/--status, per-module dispatch() delegation
(shrinking run() from 370 to 30 lines), social subcommand renames
(publish-note→publish, get-event→event, etc.), subcommand name
assertions in tests, and stale SPROUT_PUBKEY doc removal.
---
 crates/sprout-cli/README.md                 |  28 +-
 crates/sprout-cli/TESTING.md                |  35 +-
 crates/sprout-cli/src/commands/channels.rs  |  74 ++-
 crates/sprout-cli/src/commands/dms.rs       |  13 +
 crates/sprout-cli/src/commands/feed.rs      |  15 +
 crates/sprout-cli/src/commands/messages.rs  |  87 ++-
 crates/sprout-cli/src/commands/mod.rs       |   1 +
 crates/sprout-cli/src/commands/reactions.rs |  13 +
 crates/sprout-cli/src/commands/repos.rs     |  31 ++
 crates/sprout-cli/src/commands/social.rs    |  21 +
 crates/sprout-cli/src/commands/upload.rs    |  15 +
 crates/sprout-cli/src/commands/users.rs     |  30 +
 crates/sprout-cli/src/commands/workflows.rs |  35 ++
 crates/sprout-cli/src/lib.rs                | 589 ++++++++------------
 crates/sprout-cli/src/validate.rs           |   2 +-
 15 files changed, 586 insertions(+), 403 deletions(-)
 create mode 100644 crates/sprout-cli/src/commands/upload.rs

diff --git a/crates/sprout-cli/README.md b/crates/sprout-cli/README.md
index e1e2078c01..871e6b9229 100644
--- a/crates/sprout-cli/README.md
+++ b/crates/sprout-cli/README.md
@@ -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 channels list
-
-# Option 2: Dev mode (no auth)
-export SPROUT_PUBKEY="<hex>"
-sprout channels list
 ```
 
 ## Usage
@@ -70,7 +63,8 @@ sprout dms list
 # Workflows
 sprout workflows list --channel <uuid>
 sprout workflows trigger --workflow <uuid>
-sprout workflows approve --token <uuid> --approved
+sprout workflows approve --token <uuid>
+sprout workflows approve --token <uuid> --approved false --note "needs revision"
 
 # Forum
 sprout messages vote --event <event-id> --direction up
@@ -130,11 +124,11 @@ sprout channels list | jq '.[].name'
 | | `runs` | Get workflow run history |
 | | `approve` | Approve/deny a workflow step |
 | `feed` | `get` | Get your activity feed |
-| `social` | `publish-note` | Publish a NIP-01 note |
-| | `set-contact-list` | Set NIP-02 contact list |
-| | `get-event` | Get a Nostr event |
-| | `get-user-notes` | Get notes for a user |
-| | `get-contact-list` | Get NIP-02 contact list |
+| `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 |
diff --git a/crates/sprout-cli/TESTING.md b/crates/sprout-cli/TESTING.md
index 5dcc824116..a58c2ae907 100644
--- a/crates/sprout-cli/TESTING.md
+++ b/crates/sprout-cli/TESTING.md
@@ -71,7 +71,6 @@ cargo run -p sprout-admin -- mint-token \
 
 This generates a keypair and prints:
 - **Private key (nsec)** — save for `SPROUT_PRIVATE_KEY` testing
-- **Pubkey** — save for `SPROUT_PUBKEY` testing
 
 Export:
 
@@ -100,7 +99,7 @@ export SPROUT_PRIVATE_KEY="nsec1..."   # from the mint output
 
 ```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
@@ -366,8 +365,9 @@ sprout workflows runs --workflow "$WF_ID" | jq .
 # 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 workflows approve --token "00000000-0000-0000-0000-000000000000" --approved 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
 
 # workflows delete
 sprout workflows delete --workflow "$WF_ID" | jq .
@@ -428,9 +428,9 @@ sprout users set-profile 2>&1; echo "exit: $?"
 # exit: 1 (at least one field required)
 
 # Exit 3: No auth configured
-env -u SPROUT_PRIVATE_KEY -u SPROUT_PUBKEY \
+env -u SPROUT_PRIVATE_KEY \
   cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?"
-# stderr: {"error":"auth_error","message":"auth error: Set SPROUT_PRIVATE_KEY or SPROUT_PUBKEY"}
+# 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)
@@ -441,22 +441,19 @@ sprout channels get --channel "00000000-0000-0000-0000-000000000000" 2>&1; echo
 
 ---
 
-## 8. Auth Mode Testing
+## 8. Auth Testing
 
-Test both authentication modes.
+Test authentication.
 
 ```bash
-# Mode 1: Private key (SPROUT_PRIVATE_KEY)
+# Private key (SPROUT_PRIVATE_KEY)
 SPROUT_PRIVATE_KEY="nsec1..." sprout channels list | jq .
 # Should succeed
 
-# Mode 2: Dev mode (SPROUT_PUBKEY) — only works with SPROUT_REQUIRE_AUTH_TOKEN=false
-SPROUT_PUBKEY="<your-64-char-hex-pubkey>" sprout channels list | jq .
-# Should succeed
-
 # No auth → exit 3
-env -u SPROUT_PRIVATE_KEY -u 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
 ```
 
@@ -517,13 +514,13 @@ sprout channels delete --channel "$FORUM_ID" | jq .
 | 39 | `workflows trigger` | ☐ | |
 | 40 | `workflows runs` | ☐ | |
 | 41 | `workflows get` | ☐ | |
-| 42 | `workflows approve` | ☐ | Validation only (needs approval gate); use --approved / --no-approved |
+| 42 | `workflows approve` | ☐ | Validation only (needs approval gate); bare = approve, `--approved false` = deny |
 | 43 | `feed get` | ☐ | |
-| 44 | `social publish-note` | ☐ | |
-| 45 | `social set-contact-list` | ☐ | |
-| 46 | `social get-event` | ☐ | |
-| 47 | `social get-user-notes` | ☐ | |
-| 48 | `social get-contact-list` | ☐ | |
+| 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` | ☐ | |
diff --git a/crates/sprout-cli/src/commands/channels.rs b/crates/sprout-cli/src/commands/channels.rs
index f809ef9136..17018f8ab2 100644
--- a/crates/sprout-cli/src/commands/channels.rs
+++ b/crates/sprout-cli/src/commands/channels.rs
@@ -129,7 +129,6 @@ 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)
@@ -146,7 +145,6 @@ 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)
@@ -163,7 +161,6 @@ 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)
@@ -176,7 +173,6 @@ pub async fn cmd_set_channel_purpose(
 }
 
 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)
@@ -189,7 +185,6 @@ pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result
 }
 
 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)
@@ -202,7 +197,6 @@ pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Resul
 }
 
 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)
@@ -218,7 +212,6 @@ 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)
@@ -231,7 +224,6 @@ pub async fn cmd_unarchive_channel(
 }
 
 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)
@@ -249,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)?;
 
@@ -280,7 +271,6 @@ 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)?;
 
@@ -298,7 +288,6 @@ 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)?;
 
@@ -310,3 +299,66 @@ pub async fn cmd_set_canvas(
     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,
+    }
+}
diff --git a/crates/sprout-cli/src/commands/dms.rs b/crates/sprout-cli/src/commands/dms.rs
index d9ea8913db..8d6a4c22c7 100644
--- a/crates/sprout-cli/src/commands/dms.rs
+++ b/crates/sprout-cli/src/commands/dms.rs
@@ -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,
+    }
+}
diff --git a/crates/sprout-cli/src/commands/feed.rs b/crates/sprout-cli/src/commands/feed.rs
index 44cc58817f..38079bc6de 100644
--- a/crates/sprout-cli/src/commands/feed.rs
+++ b/crates/sprout-cli/src/commands/feed.rs
@@ -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,
+    }
+}
diff --git a/crates/sprout-cli/src/commands/messages.rs b/crates/sprout-cli/src/commands/messages.rs
index 5e386a2d2a..95a8a415e5 100644
--- a/crates/sprout-cli/src/commands/messages.rs
+++ b/crates/sprout-cli/src/commands/messages.rs
@@ -313,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)?;
@@ -399,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)?;
     }
@@ -543,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};
diff --git a/crates/sprout-cli/src/commands/mod.rs b/crates/sprout-cli/src/commands/mod.rs
index 0e61afd4af..077f00397a 100644
--- a/crates/sprout-cli/src/commands/mod.rs
+++ b/crates/sprout-cli/src/commands/mod.rs
@@ -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;
diff --git a/crates/sprout-cli/src/commands/reactions.rs b/crates/sprout-cli/src/commands/reactions.rs
index b1d36b3a6e..bf90ad8215 100644
--- a/crates/sprout-cli/src/commands/reactions.rs
+++ b/crates/sprout-cli/src/commands/reactions.rs
@@ -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,
+    }
+}
diff --git a/crates/sprout-cli/src/commands/repos.rs b/crates/sprout-cli/src/commands/repos.rs
index 779d503a5d..db680912aa 100644
--- a/crates/sprout-cli/src/commands/repos.rs
+++ b/crates/sprout-cli/src/commands/repos.rs
@@ -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,
+    }
+}
diff --git a/crates/sprout-cli/src/commands/social.rs b/crates/sprout-cli/src/commands/social.rs
index dc9a14364b..97448cfd7b 100644
--- a/crates/sprout-cli/src/commands/social.rs
+++ b/crates/sprout-cli/src/commands/social.rs
@@ -111,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,
+    }
+}
diff --git a/crates/sprout-cli/src/commands/upload.rs b/crates/sprout-cli/src/commands/upload.rs
new file mode 100644
index 0000000000..7385c71eec
--- /dev/null
+++ b/crates/sprout-cli/src/commands/upload.rs
@@ -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(())
+        }
+    }
+}
diff --git a/crates/sprout-cli/src/commands/users.rs b/crates/sprout-cli/src/commands/users.rs
index b4e638b833..54db71943c 100644
--- a/crates/sprout-cli/src/commands/users.rs
+++ b/crates/sprout-cli/src/commands/users.rs
@@ -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,
+    }
+}
diff --git a/crates/sprout-cli/src/commands/workflows.rs b/crates/sprout-cli/src/commands/workflows.rs
index 57fb0bc674..064eb7cc29 100644
--- a/crates/sprout-cli/src/commands/workflows.rs
+++ b/crates/sprout-cli/src/commands/workflows.rs
@@ -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
+        }
+    }
+}
diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs
index 4e7ce109bc..a4068b6997 100644
--- a/crates/sprout-cli/src/lib.rs
+++ b/crates/sprout-cli/src/lib.rs
@@ -72,6 +72,64 @@ struct Cli {
     command: Cmd,
 }
 
+// ---------------------------------------------------------------------------
+// Value enums for typed --type / --visibility / --status flags
+// ---------------------------------------------------------------------------
+
+#[derive(Clone, clap::ValueEnum)]
+pub enum ChannelType {
+    #[value(name = "stream")]
+    Stream,
+    #[value(name = "forum")]
+    Forum,
+}
+
+impl std::fmt::Display for ChannelType {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::Stream => write!(f, "stream"),
+            Self::Forum => write!(f, "forum"),
+        }
+    }
+}
+
+#[derive(Clone, clap::ValueEnum)]
+pub enum ChannelVisibility {
+    #[value(name = "open")]
+    Open,
+    #[value(name = "private")]
+    Private,
+}
+
+impl std::fmt::Display for ChannelVisibility {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::Open => write!(f, "open"),
+            Self::Private => write!(f, "private"),
+        }
+    }
+}
+
+#[derive(Clone, clap::ValueEnum)]
+pub enum PresenceStatus {
+    #[value(name = "online")]
+    Online,
+    #[value(name = "away")]
+    Away,
+    #[value(name = "offline")]
+    Offline,
+}
+
+impl std::fmt::Display for PresenceStatus {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        match self {
+            Self::Online => write!(f, "online"),
+            Self::Away => write!(f, "away"),
+            Self::Offline => write!(f, "offline"),
+        }
+    }
+}
+
 // ---------------------------------------------------------------------------
 // Subcommand groups
 // ---------------------------------------------------------------------------
@@ -124,19 +182,25 @@ enum Cmd {
 pub enum MessagesCmd {
     /// Send a message to a channel
     #[command(
-        after_help = "Examples:\n  sprout messages send --channel <UUID> --content \"hello\"\n  sprout messages send --channel <UUID> --content \"@alice check this\" --mention alice"
+        after_help = "Examples:\n  sprout messages send --channel <UUID> --content \"hello\"\n  sprout messages send --channel <UUID> --content \"@alice check this\""
     )]
     Send {
+        /// Channel UUID (from 'sprout channels list')
         #[arg(long)]
         channel: String,
+        /// Message text — supports @mentions and markdown
         #[arg(long)]
         content: String,
+        /// Nostr event kind (default: channel default)
         #[arg(long)]
         kind: Option<u16>,
+        /// Event ID to reply to (creates a thread)
         #[arg(long)]
         reply_to: Option<String>,
+        /// Also publish to the Nostr network
         #[arg(long, default_value_t = false)]
         broadcast: bool,
+        /// Explicit mention pubkeys (64-char hex)
         #[arg(long = "mention")]
         mentions: Vec<String>,
         /// Attach file(s) — uploads and includes as imeta tags
@@ -145,28 +209,40 @@ pub enum MessagesCmd {
     },
     /// Send a code diff / patch to a channel
     SendDiff {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
+        /// Diff/patch content (use '-' to read from stdin)
         #[arg(long)]
         diff: String,
+        /// Repository URL (e.g. https://github.com/org/repo)
         #[arg(long)]
         repo: String,
+        /// Commit SHA
         #[arg(long)]
         commit: String,
+        /// Single file path within the repo
         #[arg(long)]
         file: Option<String>,
+        /// Parent commit SHA for three-way diff context
         #[arg(long)]
         parent_commit: Option<String>,
+        /// Source branch name
         #[arg(long)]
         source_branch: Option<String>,
+        /// Target branch name
         #[arg(long)]
         target_branch: Option<String>,
+        /// Pull request number
         #[arg(long)]
         pr: Option<u32>,
+        /// Language hint (auto-detected from file extension if omitted)
         #[arg(long)]
         lang: Option<String>,
+        /// Human-readable description of the change
         #[arg(long)]
         description: Option<String>,
+        /// Event ID to reply to (creates a thread)
         #[arg(long)]
         reply_to: Option<String>,
     },
@@ -181,6 +257,7 @@ pub enum MessagesCmd {
     },
     /// Delete a message by event ID
     Delete {
+        /// Event ID to delete (64-char hex)
         #[arg(long)]
         event: String,
     },
@@ -189,13 +266,16 @@ pub enum MessagesCmd {
         after_help = "Examples:\n  sprout messages get --channel <UUID>\n  sprout messages get --channel <UUID> --limit 50 --kinds 1,1984"
     )]
     Get {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
         /// Maximum number of results to return
         #[arg(long)]
         limit: Option<u32>,
+        /// Unix timestamp — return messages before this time
         #[arg(long)]
         before: Option<i64>,
+        /// Unix timestamp — return messages after this time
         #[arg(long)]
         since: Option<i64>,
         /// Comma-separated event kinds to filter (e.g. 1,1984)
@@ -204,10 +284,13 @@ pub enum MessagesCmd {
     },
     /// Get a message thread (replies to a root message)
     Thread {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
+        /// Root message event ID (64-char hex)
         #[arg(long)]
         event: String,
+        /// Maximum reply depth to traverse
         #[arg(long)]
         depth_limit: Option<u32>,
         /// Maximum number of results to return
@@ -216,6 +299,7 @@ pub enum MessagesCmd {
     },
     /// Full-text search across messages
     Search {
+        /// Search query string
         #[arg(long)]
         query: String,
         /// Maximum number of results to return
@@ -244,15 +328,16 @@ pub enum ChannelsCmd {
         after_help = "Examples:\n  sprout channels list\n  sprout channels list --visibility open"
     )]
     List {
-        /// Filter by visibility (e.g. open, closed)
-        #[arg(long)]
-        visibility: Option<String>,
+        /// Filter by visibility
+        #[arg(long, value_enum)]
+        visibility: Option<ChannelVisibility>,
         /// Only show channels where the current identity is a member
         #[arg(long, default_value_t = false)]
         member: bool,
     },
     /// Get details for a single channel
     Get {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
     },
@@ -261,83 +346,105 @@ pub enum ChannelsCmd {
         after_help = "Examples:\n  sprout channels create --name general --type stream --visibility open\n  sprout channels create --name design --type forum --visibility open --description \"Design discussions\""
     )]
     Create {
+        /// Channel name
         #[arg(long)]
         name: String,
-        #[arg(long = "type")]
-        channel_type: String,
-        #[arg(long)]
-        visibility: String,
+        /// Channel type
+        #[arg(long = "type", value_enum)]
+        channel_type: ChannelType,
+        /// Channel visibility
+        #[arg(long, value_enum)]
+        visibility: ChannelVisibility,
+        /// Channel description
         #[arg(long)]
         description: Option<String>,
     },
     /// Update channel name or description
     Update {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
+        /// New channel name
         #[arg(long)]
         name: Option<String>,
+        /// New channel description
         #[arg(long)]
         description: Option<String>,
     },
     /// Set the channel topic
     Topic {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
+        /// New topic text
         #[arg(long)]
         topic: String,
     },
     /// Set the channel purpose
     Purpose {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
+        /// New purpose text
         #[arg(long)]
         purpose: String,
     },
     /// Join a channel
     Join {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
     },
     /// Leave a channel
     Leave {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
     },
     /// Archive a channel
     Archive {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
     },
     /// Unarchive a channel
     Unarchive {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
     },
     /// Delete a channel permanently
     Delete {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
     },
     /// List members of a channel
     Members {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
     },
     /// Add a member to a channel
     #[command(name = "add-member")]
     AddMember {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
+        /// Member pubkey (64-char hex)
         #[arg(long)]
         pubkey: String,
+        /// Member role (owner, admin, member, guest, bot)
         #[arg(long)]
         role: Option<String>,
     },
     /// Remove a member from a channel
     #[command(name = "remove-member")]
     RemoveMember {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
+        /// Member pubkey (64-char hex)
         #[arg(long)]
         pubkey: String,
     },
@@ -351,13 +458,16 @@ pub enum ChannelsCmd {
 pub enum CanvasCmd {
     /// Get the canvas document for a channel
     Get {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
     },
     /// Set (replace) the canvas document for a channel
     Set {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
+        /// Canvas content (markdown; use '-' to read from stdin)
         #[arg(long)]
         content: String,
     },
@@ -371,20 +481,25 @@ pub enum CanvasCmd {
 pub enum ReactionsCmd {
     /// Add an emoji reaction to a message
     Add {
+        /// Event ID (64-char hex)
         #[arg(long)]
         event: String,
+        /// Emoji character (e.g. '👍')
         #[arg(long)]
         emoji: String,
     },
     /// Remove an emoji reaction from a message
     Remove {
+        /// Event ID (64-char hex)
         #[arg(long)]
         event: String,
+        /// Emoji character to remove
         #[arg(long)]
         emoji: String,
     },
     /// List reactions on a message
     Get {
+        /// Event ID (64-char hex)
         #[arg(long)]
         event: String,
     },
@@ -404,13 +519,16 @@ pub enum DmsCmd {
     },
     /// Open a new direct message with one or more users
     Open {
+        /// User pubkey(s) to DM (64-char hex, 1-8)
         #[arg(long = "pubkey")]
         pubkeys: Vec<String>,
     },
     /// Add a member to an existing DM conversation
     AddMember {
+        /// DM conversation UUID
         #[arg(long)]
         channel: String,
+        /// User pubkey to add (64-char hex)
         #[arg(long)]
         pubkey: String,
     },
@@ -424,6 +542,7 @@ pub enum DmsCmd {
 pub enum UsersCmd {
     /// Look up user profiles by pubkey or name
     Get {
+        /// User pubkey(s) to look up (64-char hex). Omit for your own profile
         #[arg(long = "pubkey")]
         pubkeys: Vec<String>,
         /// Search by display name (case-insensitive substring match)
@@ -433,25 +552,31 @@ pub enum UsersCmd {
     /// Update the current identity's profile
     #[command(name = "set-profile")]
     SetProfile {
+        /// Display name
         #[arg(long)]
         name: Option<String>,
+        /// Avatar URL
         #[arg(long)]
         avatar: Option<String>,
+        /// Bio / about text
         #[arg(long)]
         about: Option<String>,
+        /// NIP-05 identifier (e.g. user@example.com)
         #[arg(long)]
         nip05: Option<String>,
     },
     /// Get presence status for users
     Presence {
+        /// Comma-separated pubkeys (64-char hex)
         #[arg(long)]
         pubkeys: String,
     },
     /// Set your presence status (online/away/offline)
     #[command(name = "set-presence")]
     SetPresence {
-        #[arg(long)]
-        status: String,
+        /// Presence status
+        #[arg(long, value_enum)]
+        status: PresenceStatus,
     },
 }
 
@@ -463,43 +588,52 @@ pub enum UsersCmd {
 pub enum WorkflowsCmd {
     /// List workflows in a channel
     List {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
     },
     /// Get details for a single workflow
     Get {
+        /// Workflow UUID
         #[arg(long)]
         workflow: String,
     },
     /// Create a workflow from a YAML definition
     Create {
+        /// Channel UUID
         #[arg(long)]
         channel: String,
+        /// Workflow YAML definition
         #[arg(long)]
         yaml: String,
     },
     /// Update a workflow's YAML definition
     Update {
+        /// Workflow UUID
         #[arg(long)]
         channel: String,
         #[arg(long)]
         workflow: String,
+        /// Updated workflow YAML definition
         #[arg(long)]
         yaml: String,
     },
     /// Delete a workflow
     Delete {
+        /// Workflow UUID
         #[arg(long)]
         workflow: String,
     },
     /// Trigger a workflow run
     #[command(after_help = "Examples:\n  sprout workflows trigger --workflow <UUID>")]
     Trigger {
+        /// Workflow UUID
         #[arg(long)]
         workflow: String,
     },
     /// List runs for a workflow
     Runs {
+        /// Workflow UUID
         #[arg(long)]
         workflow: String,
         /// Maximum number of results to return
@@ -508,14 +642,14 @@ pub enum WorkflowsCmd {
     },
     /// Approve or deny a workflow step
     #[command(
-        after_help = "Examples:\n  sprout workflows approve --token <UUID>\n  sprout workflows approve --token <UUID> --no-approved --note \"needs revision\""
+        after_help = "Examples:\n  sprout workflows approve --token <UUID>\n  sprout workflows approve --token <UUID> --approved false --note \"needs revision\""
     )]
     Approve {
         /// The approval token UUID (from the approval request)
         #[arg(long)]
         token: String,
-        /// Approve the step (pass --no-approved to deny)
-        #[arg(long, default_value_t = true)]
+        /// Approve (true) or deny (false) the step
+        #[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
         approved: bool,
         /// Optional note to include with the approval/denial
         #[arg(long)]
@@ -531,11 +665,13 @@ pub enum WorkflowsCmd {
 pub enum FeedCmd {
     /// Get recent activity feed entries
     Get {
+        /// Unix timestamp — return entries after this time
         #[arg(long)]
         since: Option<i64>,
         /// Maximum number of results to return
         #[arg(long)]
         limit: Option<u32>,
+        /// Comma-separated feed entry types to filter
         #[arg(long)]
         types: Option<String>,
     },
@@ -548,7 +684,7 @@ pub enum FeedCmd {
 #[derive(Subcommand)]
 pub enum SocialCmd {
     /// Publish a text note (NIP-01 kind:1)
-    #[command(name = "publish-note")]
+    #[command(name = "publish")]
     PublishNote {
         /// Text content of the note.
         #[arg(long)]
@@ -558,21 +694,21 @@ pub enum SocialCmd {
         reply_to: Option<String>,
     },
     /// Set your contact list (NIP-02 kind:3)
-    #[command(name = "set-contact-list")]
+    #[command(name = "set-contacts")]
     SetContactList {
         /// JSON array of contacts: [{"pubkey":"hex","relay_url":"...","petname":"..."}]
         #[arg(long)]
         contacts: String,
     },
     /// Get a single event by ID
-    #[command(name = "get-event")]
+    #[command(name = "event")]
     GetEvent {
         /// 64-char hex event ID.
         #[arg(long)]
         event: String,
     },
     /// Get recent notes published by a user
-    #[command(name = "get-user-notes")]
+    #[command(name = "notes")]
     GetUserNotes {
         /// 64-char hex pubkey of the author.
         #[arg(long)]
@@ -585,7 +721,7 @@ pub enum SocialCmd {
         before: Option<i64>,
     },
     /// Get a user's contact list
-    #[command(name = "get-contact-list")]
+    #[command(name = "contacts")]
     GetContactList {
         /// 64-char hex pubkey.
         #[arg(long)]
@@ -660,7 +796,7 @@ pub enum UploadCmd {
 
 /// Subcommands for `sprout pack`.
 #[derive(Subcommand)]
-enum PackCmd {
+pub enum PackCmd {
     /// Validate a persona pack directory
     Validate {
         /// Path to the pack directory
@@ -715,340 +851,17 @@ async fn run(cli: Cli) -> Result<(), CliError> {
     let client = SproutClient::new(relay_url, keys, auth_tag, auth_tag_json)?;
 
     match cli.command {
-        Cmd::Messages(sub) => match sub {
-            MessagesCmd::Send {
-                channel,
-                content,
-                kind,
-                reply_to,
-                broadcast,
-                mentions,
-                files,
-            } => {
-                commands::messages::cmd_send_message(
-                    &client,
-                    commands::messages::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,
-            } => {
-                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
-            }
-            MessagesCmd::Edit { event, content } => {
-                commands::messages::cmd_edit_message(&client, &event, &content).await
-            }
-            MessagesCmd::Delete { event } => {
-                commands::messages::cmd_delete_message(&client, &event).await
-            }
-            MessagesCmd::Get {
-                channel,
-                limit,
-                before,
-                since,
-                kinds,
-            } => {
-                commands::messages::cmd_get_messages(
-                    &client,
-                    &channel,
-                    limit,
-                    before,
-                    since,
-                    kinds.as_deref(),
-                )
-                .await
-            }
-            MessagesCmd::Thread {
-                channel,
-                event,
-                depth_limit,
-                limit,
-            } => {
-                commands::messages::cmd_get_thread(&client, &channel, &event, depth_limit, limit)
-                    .await
-            }
-            MessagesCmd::Search { query, limit } => {
-                commands::messages::cmd_search(&client, &query, limit).await
-            }
-            MessagesCmd::Vote { event, direction } => {
-                commands::messages::cmd_vote_on_post(&client, &event, &direction).await
-            }
-        },
-
-        Cmd::Channels(sub) => match sub {
-            ChannelsCmd::List { visibility, member } => {
-                commands::channels::cmd_list_channels(&client, visibility.as_deref(), Some(member))
-                    .await
-            }
-            ChannelsCmd::Get { channel } => {
-                commands::channels::cmd_get_channel(&client, &channel).await
-            }
-            ChannelsCmd::Create {
-                name,
-                channel_type,
-                visibility,
-                description,
-            } => {
-                commands::channels::cmd_create_channel(
-                    &client,
-                    &name,
-                    &channel_type,
-                    &visibility,
-                    description.as_deref(),
-                )
-                .await
-            }
-            ChannelsCmd::Update {
-                channel,
-                name,
-                description,
-            } => {
-                commands::channels::cmd_update_channel(
-                    &client,
-                    &channel,
-                    name.as_deref(),
-                    description.as_deref(),
-                )
-                .await
-            }
-            ChannelsCmd::Topic { channel, topic } => {
-                commands::channels::cmd_set_channel_topic(&client, &channel, &topic).await
-            }
-            ChannelsCmd::Purpose { channel, purpose } => {
-                commands::channels::cmd_set_channel_purpose(&client, &channel, &purpose).await
-            }
-            ChannelsCmd::Join { channel } => {
-                commands::channels::cmd_join_channel(&client, &channel).await
-            }
-            ChannelsCmd::Leave { channel } => {
-                commands::channels::cmd_leave_channel(&client, &channel).await
-            }
-            ChannelsCmd::Archive { channel } => {
-                commands::channels::cmd_archive_channel(&client, &channel).await
-            }
-            ChannelsCmd::Unarchive { channel } => {
-                commands::channels::cmd_unarchive_channel(&client, &channel).await
-            }
-            ChannelsCmd::Delete { channel } => {
-                commands::channels::cmd_delete_channel(&client, &channel).await
-            }
-            ChannelsCmd::Members { channel } => {
-                commands::channels::cmd_list_channel_members(&client, &channel).await
-            }
-            ChannelsCmd::AddMember {
-                channel,
-                pubkey,
-                role,
-            } => {
-                commands::channels::cmd_add_channel_member(
-                    &client,
-                    &channel,
-                    &pubkey,
-                    role.as_deref(),
-                )
-                .await
-            }
-            ChannelsCmd::RemoveMember { channel, pubkey } => {
-                commands::channels::cmd_remove_channel_member(&client, &channel, &pubkey).await
-            }
-        },
-
-        Cmd::Canvas(sub) => match sub {
-            CanvasCmd::Get { channel } => {
-                commands::channels::cmd_get_canvas(&client, &channel).await
-            }
-            CanvasCmd::Set { channel, content } => {
-                commands::channels::cmd_set_canvas(&client, &channel, &content).await
-            }
-        },
-
-        Cmd::Reactions(sub) => match sub {
-            ReactionsCmd::Add { event, emoji } => {
-                commands::reactions::cmd_add_reaction(&client, &event, &emoji).await
-            }
-            ReactionsCmd::Remove { event, emoji } => {
-                commands::reactions::cmd_remove_reaction(&client, &event, &emoji).await
-            }
-            ReactionsCmd::Get { event } => {
-                commands::reactions::cmd_get_reactions(&client, &event).await
-            }
-        },
-
-        Cmd::Dms(sub) => match sub {
-            DmsCmd::List { limit } => commands::dms::cmd_list_dms(&client, limit).await,
-            DmsCmd::Open { pubkeys } => commands::dms::cmd_open_dm(&client, &pubkeys).await,
-            DmsCmd::AddMember { channel, pubkey } => {
-                commands::dms::cmd_add_dm_member(&client, &channel, &pubkey).await
-            }
-        },
-
-        Cmd::Users(sub) => match sub {
-            UsersCmd::Get { pubkeys, name } => {
-                commands::users::cmd_get_users(&client, &pubkeys, name.as_deref()).await
-            }
-            UsersCmd::SetProfile {
-                name,
-                avatar,
-                about,
-                nip05,
-            } => {
-                commands::users::cmd_set_profile(
-                    &client,
-                    name.as_deref(),
-                    avatar.as_deref(),
-                    about.as_deref(),
-                    nip05.as_deref(),
-                )
-                .await
-            }
-            UsersCmd::Presence { pubkeys } => {
-                commands::users::cmd_get_presence(&client, &pubkeys).await
-            }
-            UsersCmd::SetPresence { status } => {
-                commands::users::cmd_set_presence(&client, &status).await
-            }
-        },
-
-        Cmd::Workflows(sub) => match sub {
-            WorkflowsCmd::List { channel } => {
-                commands::workflows::cmd_list_workflows(&client, &channel).await
-            }
-            WorkflowsCmd::Get { workflow } => {
-                commands::workflows::cmd_get_workflow(&client, &workflow).await
-            }
-            WorkflowsCmd::Create { channel, yaml } => {
-                commands::workflows::cmd_create_workflow(&client, &channel, &yaml).await
-            }
-            WorkflowsCmd::Update {
-                channel,
-                workflow,
-                yaml,
-            } => {
-                commands::workflows::cmd_update_workflow(&client, &channel, &workflow, &yaml).await
-            }
-            WorkflowsCmd::Delete { workflow } => {
-                commands::workflows::cmd_delete_workflow(&client, &workflow).await
-            }
-            WorkflowsCmd::Trigger { workflow } => {
-                commands::workflows::cmd_trigger_workflow(&client, &workflow).await
-            }
-            WorkflowsCmd::Runs { workflow, limit } => {
-                commands::workflows::cmd_get_workflow_runs(&client, &workflow, limit).await
-            }
-            WorkflowsCmd::Approve {
-                token,
-                approved,
-                note,
-            } => {
-                // approved is already a bool — no parse_bool_flag needed
-                commands::workflows::cmd_approve_step(&client, &token, approved, note.as_deref())
-                    .await
-            }
-        },
-
-        Cmd::Feed(sub) => match sub {
-            FeedCmd::Get {
-                since,
-                limit,
-                types,
-            } => commands::feed::cmd_get_feed(&client, since, limit, types.as_deref()).await,
-        },
-
-        Cmd::Social(sub) => match sub {
-            SocialCmd::PublishNote { content, reply_to } => {
-                commands::social::cmd_publish_note(&client, &content, reply_to.as_deref()).await
-            }
-            SocialCmd::SetContactList { contacts } => {
-                commands::social::cmd_set_contact_list(&client, &contacts).await
-            }
-            SocialCmd::GetEvent { event } => commands::social::cmd_get_event(&client, &event).await,
-            SocialCmd::GetUserNotes {
-                pubkey,
-                limit,
-                before,
-            } => commands::social::cmd_get_user_notes(&client, &pubkey, limit, before).await,
-            SocialCmd::GetContactList { pubkey } => {
-                commands::social::cmd_get_contact_list(&client, &pubkey).await
-            }
-        },
-
-        Cmd::Repos(sub) => match sub {
-            ReposCmd::Create {
-                id,
-                name,
-                description,
-                clone_urls,
-                web,
-                relays,
-            } => {
-                commands::repos::cmd_create_repo(
-                    &client,
-                    &id,
-                    name.as_deref(),
-                    description.as_deref(),
-                    &clone_urls,
-                    web.as_deref(),
-                    &relays,
-                )
-                .await
-            }
-            ReposCmd::Get { id, owner } => {
-                commands::repos::cmd_get_repo(&client, &id, owner.as_deref()).await
-            }
-            ReposCmd::List { owner, limit } => {
-                commands::repos::cmd_list_repos(&client, owner.as_deref(), limit).await
-            }
-        },
-
-        Cmd::Upload(sub) => match sub {
-            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(())
-            }
-        },
-
+        Cmd::Messages(sub) => commands::messages::dispatch(sub, &client).await,
+        Cmd::Channels(sub) => commands::channels::dispatch(sub, &client).await,
+        Cmd::Canvas(sub) => commands::channels::dispatch_canvas(sub, &client).await,
+        Cmd::Reactions(sub) => commands::reactions::dispatch(sub, &client).await,
+        Cmd::Dms(sub) => commands::dms::dispatch(sub, &client).await,
+        Cmd::Users(sub) => commands::users::dispatch(sub, &client).await,
+        Cmd::Workflows(sub) => commands::workflows::dispatch(sub, &client).await,
+        Cmd::Feed(sub) => commands::feed::dispatch(sub, &client).await,
+        Cmd::Social(sub) => commands::social::dispatch(sub, &client).await,
+        Cmd::Repos(sub) => commands::repos::dispatch(sub, &client).await,
+        Cmd::Upload(sub) => commands::upload::dispatch(sub, &client).await,
         Cmd::Pack(_) => unreachable!("handled above"),
     }
 }
@@ -1107,6 +920,76 @@ mod tests {
         );
     }
 
+    #[test]
+    fn subcommand_names_are_stable() {
+        fn names(cmd: &clap::Command, group: &str) -> Vec<String> {
+            let group_cmd = cmd
+                .get_subcommands()
+                .find(|s| s.get_name() == group)
+                .unwrap_or_else(|| panic!("group '{}' not found", group));
+            let mut names: Vec<String> = group_cmd
+                .get_subcommands()
+                .map(|s| s.get_name().to_string())
+                .filter(|n| n != "help")
+                .collect();
+            names.sort();
+            names
+        }
+
+        let cmd = Cli::command();
+        assert_eq!(
+            names(&cmd, "messages"),
+            vec![
+                "delete",
+                "edit",
+                "get",
+                "search",
+                "send",
+                "send-diff",
+                "thread",
+                "vote"
+            ]
+        );
+        assert_eq!(
+            names(&cmd, "channels"),
+            vec![
+                "add-member",
+                "archive",
+                "create",
+                "delete",
+                "get",
+                "join",
+                "leave",
+                "list",
+                "members",
+                "purpose",
+                "remove-member",
+                "topic",
+                "unarchive",
+                "update"
+            ]
+        );
+        assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]);
+        assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]);
+        assert_eq!(names(&cmd, "dms"), vec!["add-member", "list", "open"]);
+        assert_eq!(
+            names(&cmd, "users"),
+            vec!["get", "presence", "set-presence", "set-profile"]
+        );
+        assert_eq!(
+            names(&cmd, "workflows"),
+            vec!["approve", "create", "delete", "get", "list", "runs", "trigger", "update"]
+        );
+        assert_eq!(names(&cmd, "feed"), vec!["get"]);
+        assert_eq!(
+            names(&cmd, "social"),
+            vec!["contacts", "event", "notes", "publish", "set-contacts"]
+        );
+        assert_eq!(names(&cmd, "repos"), vec!["create", "get", "list"]);
+        assert_eq!(names(&cmd, "upload"), vec!["file"]);
+        assert_eq!(names(&cmd, "pack"), vec!["inspect", "validate"]);
+    }
+
     #[test]
     fn subcommand_counts_are_stable() {
         let expected: Vec<(&str, usize)> = vec![
diff --git a/crates/sprout-cli/src/validate.rs b/crates/sprout-cli/src/validate.rs
index 96dd048da3..6d152729b8 100644
--- a/crates/sprout-cli/src/validate.rs
+++ b/crates/sprout-cli/src/validate.rs
@@ -16,7 +16,7 @@ pub fn parse_event_id(hex: &str) -> Result<nostr::EventId, CliError> {
 /// 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 channel UUID: {e}")))
+    uuid::Uuid::parse_str(s).map_err(|e| CliError::Usage(format!("invalid UUID: {e}")))
 }
 
 /// Validate UUID string. Returns CliError::Usage on failure.

From 444080cbecaca1db84d779122285fddafbd0ea0d Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Thu, 14 May 2026 17:20:02 -0400
Subject: [PATCH 3/6] fix(cli): rename --relay to --nostr-relay on repos create
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

The subcommand-level --relay flag on `repos create` shadowed the global
--relay flag (Sprout API endpoint). Renamed to --nostr-relay to avoid
clap parsing ambiguity — these are NIP-34 preferred relays for repo
discovery, not the Sprout relay URL.
---
 crates/sprout-cli/src/lib.rs | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs
index a4068b6997..f70c4a05ea 100644
--- a/crates/sprout-cli/src/lib.rs
+++ b/crates/sprout-cli/src/lib.rs
@@ -752,8 +752,8 @@ pub enum ReposCmd {
         /// Web browsing URL
         #[arg(long)]
         web: Option<String>,
-        /// Preferred relay(s) — can be specified multiple times
-        #[arg(long = "relay")]
+        /// Preferred Nostr relay(s) for repo discovery — can be specified multiple times
+        #[arg(long = "nostr-relay")]
         relays: Vec<String>,
     },
     /// Get a repository announcement

From 3d1968ae902632a402705949d9b787860cbd6ae1 Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Thu, 14 May 2026 17:28:45 -0400
Subject: [PATCH 4/6] feat(cli): add long_about with env vars, exit codes, and
 error format
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Adds structured help text to the top-level sprout --help showing
configuration env vars, exit code table, and JSON error format —
useful for both humans and agents parsing CLI output programmatically.
---
 crates/sprout-cli/src/lib.rs | 17 ++++++++++++++++-
 1 file changed, 16 insertions(+), 1 deletion(-)

diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs
index f70c4a05ea..ef3ecdbb55 100644
--- a/crates/sprout-cli/src/lib.rs
+++ b/crates/sprout-cli/src/lib.rs
@@ -50,7 +50,22 @@ where
 // ---------------------------------------------------------------------------
 
 #[derive(Parser)]
-#[command(name = "sprout", about = "Sprout CLI — interact with a Sprout relay")]
+#[command(
+    name = "sprout",
+    about = "Sprout CLI — interact with a Sprout relay",
+    long_about = "\
+Sprout CLI — interact with a Sprout relay
+
+Configuration (flags override env vars):
+  SPROUT_RELAY_URL     Relay base URL        [default: http://localhost:3000]
+  SPROUT_PRIVATE_KEY   Nostr private key (hex or nsec)  [required]
+  SPROUT_AUTH_TAG      NIP-OA auth tag JSON  [optional]
+
+The 'pack' subcommand runs locally and does not require a relay connection.
+
+Exit codes: 0=ok  1=bad input  2=relay/network error  3=auth error  4=other
+Errors are JSON on stderr: {\"error\": \"<category>\", \"message\": \"<detail>\"}"
+)]
 struct Cli {
     /// Relay URL (http:// or https://). Overrides SPROUT_RELAY_URL env var.
     #[arg(

From a4942a735580eddbdb51e2d23f39fe1ab65e3a64 Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Thu, 14 May 2026 18:58:12 -0400
Subject: [PATCH 5/6] fix: remove duplicate parse_uuid after rebase on #589
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Both branches added parse_uuid to validate.rs — keep the version
with the more descriptive error message (includes parse error detail).
---
 crates/sprout-cli/src/validate.rs | 5 -----
 1 file changed, 5 deletions(-)

diff --git a/crates/sprout-cli/src/validate.rs b/crates/sprout-cli/src/validate.rs
index 6d152729b8..ee2d9a5d56 100644
--- a/crates/sprout-cli/src/validate.rs
+++ b/crates/sprout-cli/src/validate.rs
@@ -25,11 +25,6 @@ pub fn validate_uuid(s: &str) -> Result<(), CliError> {
     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()) {

From 004834784dfff59b18eef26aecfd72bc964d663c Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
Date: Thu, 14 May 2026 18:59:05 -0400
Subject: [PATCH 6/6] fix(cli): correct help text for workflows update
 --channel

The channel field had "Workflow UUID" as its doc comment instead of
"Channel UUID the workflow belongs to". Also adds missing doc comment
on the workflow field.
---
 crates/sprout-cli/src/lib.rs | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs
index ef3ecdbb55..fe16cbdfca 100644
--- a/crates/sprout-cli/src/lib.rs
+++ b/crates/sprout-cli/src/lib.rs
@@ -624,9 +624,10 @@ pub enum WorkflowsCmd {
     },
     /// Update a workflow's YAML definition
     Update {
-        /// Workflow UUID
+        /// Channel UUID the workflow belongs to
         #[arg(long)]
         channel: String,
+        /// Workflow UUID
         #[arg(long)]
         workflow: String,
         /// Updated workflow YAML definition
