diff --git a/crates/sprout-cli/README.md b/crates/sprout-cli/README.md index 51cd86662..871e6b922 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 list-channels - -# Option 2: Dev mode (no auth) -export SPROUT_PUBKEY="" -sprout list-channels +sprout channels list ``` ## Usage @@ -36,106 +29,117 @@ All output is JSON on stdout. Errors are JSON on stderr. Exit codes: 0=ok, 1=use export SPROUT_RELAY_URL="https://relay.example.com" # Messages -sprout send-message --channel --content "Hello" -sprout send-message --channel --content "Reply" --reply-to --broadcast -sprout get-messages --channel --limit 20 -sprout get-thread --channel --event -sprout search --query "architecture" -sprout edit-message --event --content "Updated text" -sprout delete-message --event +sprout messages send --channel --content "Hello" +sprout messages send --channel --content "Reply" --reply-to --broadcast +sprout messages get --channel --limit 20 +sprout messages thread --channel --event +sprout messages search --query "architecture" +sprout messages edit --event --content "Updated text" +sprout messages delete --event # Diffs -sprout send-diff-message --channel --diff - --repo https://github.com/org/repo --commit abc123 < diff.patch +sprout messages send-diff --channel --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 -sprout set-channel-topic --channel --topic "New topic" +sprout channels list +sprout channels create --name "my-channel" --type stream --visibility open +sprout channels join --channel +sprout channels topic --channel --topic "New topic" # Reactions -sprout add-reaction --event --emoji "👍" -sprout get-reactions --event +sprout reactions add --event --emoji "👍" +sprout reactions get --event # Users & Presence -sprout get-users # your own profile -sprout get-users --pubkey # single user -sprout get-users --pubkey --pubkey # batch (max 200) -sprout set-presence --status online +sprout users get # your own profile +sprout users get --pubkey # single user +sprout users get --pubkey --pubkey # batch (max 200) +sprout users set-presence --status online # DMs -sprout open-dm --pubkey -sprout list-dms +sprout dms open --pubkey +sprout dms list # Workflows -sprout list-workflows --channel -sprout trigger-workflow --workflow -sprout approve-step --token --approved true +sprout workflows list --channel +sprout workflows trigger --workflow +sprout workflows approve --token +sprout workflows approve --token --approved false --note "needs revision" # Forum -sprout vote-on-post --event --direction up +sprout messages vote --event --direction up # Canvas -sprout get-canvas --channel -sprout set-canvas --channel --content "# Welcome" +sprout canvas get --channel +sprout canvas set --channel --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` | Publish a NIP-01 note | +| | `set-contacts` | Set NIP-02 contact list | +| | `event` | Get a Nostr event | +| | `notes` | Get notes for a user | +| | `contacts` | Get NIP-02 contact list | +| `repos` | `create` | Announce a git repository (NIP-34) | +| | `get` | Get a repository announcement | +| | `list` | List repository announcements | +| `upload` | `file` | Upload a file to the Blossom store | +| `pack` | `validate` | Validate a persona pack (local, no relay) | +| | `inspect` | Inspect a persona pack (local, no relay) | ## Architecture ``` -sprout [flags] +sprout [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 0c4f39ab7..a58c2ae90 100644 --- a/crates/sprout-cli/TESTING.md +++ b/crates/sprout-cli/TESTING.md @@ -71,44 +71,27 @@ cargo run -p sprout-admin -- mint-token \ This generates a keypair and prints: - **Private key (nsec)** — save for `SPROUT_PRIVATE_KEY` testing -- **API Token** — save as `SPROUT_API_TOKEN` -- **Pubkey** — save for `SPROUT_PUBKEY` testing Export: ```bash export SPROUT_RELAY_URL="http://localhost:3000" -export SPROUT_API_TOKEN="sprout_tok_..." export SPROUT_PRIVATE_KEY="nsec1..." # from the mint output ``` -### Option B: sprout auth (NIP-98, self-mintable scopes only) - -Tests the CLI's own auth flow. Cannot mint `admin:channels`. - -```bash -export SPROUT_PRIVATE_KEY="nsec1..." -export SPROUT_RELAY_URL="http://localhost:3000" -cargo run -p sprout-cli -- auth -# Prints a token string to stdout -``` - ### Scope reference | Scope | Self-mintable | Needed for | |-------|:---:|------------| -| `messages:read` | ✅ | get-messages, get-thread, search, get-feed | -| `messages:write` | ✅ | send-message, edit-message, delete-message, reactions, vote | -| `channels:read` | ✅ | list-channels, get-channel, list-members | -| `channels:write` | ✅ | create-channel, update-channel, join, leave, topic, purpose | -| `users:read` | ✅ | get-users, get-presence | -| `users:write` | ✅ | set-profile, set-presence, set-channel-add-policy | +| `messages:read` | ✅ | `messages get`, `messages thread`, `messages search`, `feed get` | +| `messages:write` | ✅ | `messages send`, `messages edit`, `messages delete`, `reactions`, `messages vote` | +| `channels:read` | ✅ | `channels list`, `channels get`, `channels members` | +| `channels:write` | ✅ | `channels create`, `channels update`, `channels join`, `channels leave`, `channels topic`, `channels purpose` | +| `users:read` | ✅ | `users get`, `users presence` | +| `users:write` | ✅ | `users set-profile`, `users set-presence` | | `files:read` | ✅ | — | | `files:write` | ✅ | — | -| `admin:channels` | ❌ | archive, unarchive, delete-channel, add/remove-channel-member | - -**Use Option A for full testing.** Option B covers most commands but skips -admin operations. +| `admin:channels` | ❌ | `channels archive`, `channels unarchive`, `channels delete`, `channels add-member`, `channels remove-member` | --- @@ -116,7 +99,7 @@ admin operations. ```bash cargo test -p sprout-cli -# Expected: 38 passed, 0 failed +# Expected: see cargo test -p sprout-cli for current count cargo clippy -p sprout-cli -- -D warnings # Expected: zero warnings @@ -127,147 +110,120 @@ cargo clippy -p sprout-cli -- -D warnings ## 6. Live Testing — Command by Command Run each command, verify exit code 0 and check output. Most commands -return JSON (pipe through `jq .` to validate). Exceptions: `auth` prints -a raw token string, and `delete-token`/`delete-all-tokens` may return -empty (204). Commands are ordered so earlier ones create resources that -later ones need. +return JSON (pipe through `jq .` to validate). Commands are ordered so +earlier ones create resources that later ones need. -### 6.1 Auth & Tokens +### 6.1 Channels ```bash -# list-tokens — list existing tokens -sprout list-tokens | jq . - -# auth — mint a new token (requires SPROUT_PRIVATE_KEY) -SPROUT_PRIVATE_KEY="nsec1..." sprout auth -# Should print: sprout_tok_... - -# delete-token — delete a specific token by UUID -# ⚠️ Do NOT delete the token you're currently using (SPROUT_API_TOKEN). -# Mint a throwaway token first, then delete it: -THROWAWAY=$(sprout auth) # mint a new token -THROWAWAY_LIST=$(SPROUT_API_TOKEN="$THROWAWAY" sprout list-tokens) -# Filter by name to avoid deleting the wrong token -THROWAWAY_ID=$(echo "$THROWAWAY_LIST" | jq -r '[.[] // .tokens[] | select(.name == "sprout-cli")][0].id // empty') -sprout delete-token --id "$THROWAWAY_ID" -# May return 204 (empty) or JSON — both are success - -# delete-all-tokens — DESTRUCTIVE, deletes all tokens for this pubkey -# sprout delete-all-tokens -# ⚠️ Only run this if you're about to re-mint -``` - -### 6.2 Channels - -```bash -# create-channel (stream) -sprout create-channel --name "test-stream" --type stream --visibility open \ +# channels create (stream) +sprout channels create --name "test-stream" --type stream --visibility open \ --description "CLI test channel" | jq . # Save the channel ID: -CHANNEL_ID=$(sprout create-channel --name "test-cli" --type stream --visibility open | jq -r '.id') +CHANNEL_ID=$(sprout channels create --name "test-cli" --type stream --visibility open | jq -r '.id') -# create-channel (forum) — needed for vote-on-post later -FORUM_ID=$(sprout create-channel --name "test-forum" --type forum --visibility open | jq -r '.id') +# channels create (forum) — needed for messages vote later +FORUM_ID=$(sprout channels create --name "test-forum" --type forum --visibility open | jq -r '.id') -# list-channels -sprout list-channels | jq . -sprout list-channels --visibility open | jq . -sprout list-channels --member | jq . +# channels list +sprout channels list | jq . +sprout channels list --visibility open | jq . +sprout channels list --member | jq . -# get-channel -sprout get-channel --channel "$CHANNEL_ID" | jq . +# channels get +sprout channels get --channel "$CHANNEL_ID" | jq . -# update-channel -sprout update-channel --channel "$CHANNEL_ID" --name "test-cli-updated" \ +# channels update +sprout channels update --channel "$CHANNEL_ID" --name "test-cli-updated" \ --description "Updated" | jq . -# set-channel-topic -sprout set-channel-topic --channel "$CHANNEL_ID" --topic "Test topic" | jq . +# channels topic +sprout channels topic --channel "$CHANNEL_ID" --topic "Test topic" | jq . -# set-channel-purpose -sprout set-channel-purpose --channel "$CHANNEL_ID" --purpose "Testing" | jq . +# channels purpose +sprout channels purpose --channel "$CHANNEL_ID" --purpose "Testing" | jq . -# join-channel (may already be a member from create) -sprout join-channel --channel "$CHANNEL_ID" | jq . +# channels join (may already be a member from create) +sprout channels join --channel "$CHANNEL_ID" | jq . -# leave-channel -sprout leave-channel --channel "$CHANNEL_ID" | jq . +# channels leave +sprout channels leave --channel "$CHANNEL_ID" | jq . # Re-join so we can send messages -sprout join-channel --channel "$CHANNEL_ID" | jq . +sprout channels join --channel "$CHANNEL_ID" | jq . -# archive-channel (requires admin:channels scope) -sprout archive-channel --channel "$CHANNEL_ID" | jq . +# channels archive (requires admin:channels scope) +sprout channels archive --channel "$CHANNEL_ID" | jq . -# unarchive-channel -sprout unarchive-channel --channel "$CHANNEL_ID" | jq . +# channels unarchive +sprout channels unarchive --channel "$CHANNEL_ID" | jq . ``` -### 6.3 Canvas +### 6.2 Canvas ```bash -# set-canvas -sprout set-canvas --channel "$CHANNEL_ID" --content "# Test Canvas" | jq . +# canvas set +sprout canvas set --channel "$CHANNEL_ID" --content "# Test Canvas" | jq . -# set-canvas from stdin -echo "# Canvas from stdin" | sprout set-canvas --channel "$CHANNEL_ID" --content - | jq . +# canvas set from stdin +echo "# Canvas from stdin" | sprout canvas set --channel "$CHANNEL_ID" --content - | jq . -# get-canvas -sprout get-canvas --channel "$CHANNEL_ID" | jq . +# canvas get +sprout canvas get --channel "$CHANNEL_ID" | jq . ``` -### 6.4 Messages +### 6.3 Messages ```bash -# send-message -MSG=$(sprout send-message --channel "$CHANNEL_ID" --content "Hello from CLI test" | jq .) +# messages send +MSG=$(sprout messages send --channel "$CHANNEL_ID" --content "Hello from CLI test" | jq .) echo "$MSG" EVENT_ID=$(echo "$MSG" | jq -r '.id // .event_id') -# send-message with reply + broadcast -REPLY=$(sprout send-message --channel "$CHANNEL_ID" --content "Reply" \ +# messages send with reply + broadcast +REPLY=$(sprout messages send --channel "$CHANNEL_ID" --content "Reply" \ --reply-to "$EVENT_ID" --broadcast | jq .) echo "$REPLY" REPLY_ID=$(echo "$REPLY" | jq -r '.id // .event_id') -# send-message with mentions -sprout send-message --channel "$CHANNEL_ID" --content "Hey @someone" \ +# messages send with mentions +sprout messages send --channel "$CHANNEL_ID" --content "Hey @someone" \ --mention "0000000000000000000000000000000000000000000000000000000000000001" | jq . -# get-messages -sprout get-messages --channel "$CHANNEL_ID" | jq . -sprout get-messages --channel "$CHANNEL_ID" --limit 5 | jq . +# messages get +sprout messages get --channel "$CHANNEL_ID" | jq . +sprout messages get --channel "$CHANNEL_ID" --limit 5 | jq . -# get-thread -sprout get-thread --channel "$CHANNEL_ID" --event "$EVENT_ID" | jq . +# messages thread +sprout messages thread --channel "$CHANNEL_ID" --event "$EVENT_ID" | jq . -# search -sprout search --query "Hello" | jq . -sprout search --query "CLI test" --limit 5 | jq . +# messages search +sprout messages search --query "Hello" | jq . +sprout messages search --query "CLI test" --limit 5 | jq . -# edit-message -sprout edit-message --event "$EVENT_ID" --content "Edited by CLI test" | jq . +# messages edit +sprout messages edit --event "$EVENT_ID" --content "Edited by CLI test" | jq . -# delete-message -sprout delete-message --event "$REPLY_ID" | jq . +# messages delete +sprout messages delete --event "$REPLY_ID" | jq . ``` -### 6.5 Diff Messages +### 6.4 Diff Messages ```bash -# send-diff-message from stdin +# messages send-diff from stdin echo '--- a/foo.rs +++ b/foo.rs @@ -1,3 +1,3 @@ -fn old() {} -+fn new() {}' | sprout send-diff-message \ ++fn new() {}' | sprout messages send-diff \ --channel "$CHANNEL_ID" \ --diff - \ --repo "https://github.com/example/repo" \ --commit "abcdef1234567890abcdef1234567890abcdef12" | jq . -# send-diff-message with metadata -echo "diff content" | sprout send-diff-message \ +# messages send-diff with metadata +echo "diff content" | sprout messages send-diff \ --channel "$CHANNEL_ID" \ --diff - \ --repo "https://github.com/example/repo" \ @@ -276,8 +232,8 @@ echo "diff content" | sprout send-diff-message \ --lang "rust" \ --description "Refactored main" | jq . -# send-diff-message with branch + PR metadata -echo "diff content" | sprout send-diff-message \ +# messages send-diff with branch + PR metadata +echo "diff content" | sprout messages send-diff \ --channel "$CHANNEL_ID" \ --diff - \ --repo "https://github.com/example/repo" \ @@ -288,99 +244,92 @@ echo "diff content" | sprout send-diff-message \ --pr 42 | jq . ``` -### 6.6 Reactions +### 6.5 Reactions ```bash # Send a message to react to -REACT_MSG=$(sprout send-message --channel "$CHANNEL_ID" --content "React to this") +REACT_MSG=$(sprout messages send --channel "$CHANNEL_ID" --content "React to this") REACT_ID=$(echo "$REACT_MSG" | jq -r '.id // .event_id') -# add-reaction -sprout add-reaction --event "$REACT_ID" --emoji "👍" | jq . +# reactions add +sprout reactions add --event "$REACT_ID" --emoji "👍" | jq . -# get-reactions -sprout get-reactions --event "$REACT_ID" | jq . +# reactions get +sprout reactions get --event "$REACT_ID" | jq . -# remove-reaction -sprout remove-reaction --event "$REACT_ID" --emoji "👍" | jq . +# reactions remove +sprout reactions remove --event "$REACT_ID" --emoji "👍" | jq . ``` -### 6.7 DMs +### 6.6 DMs ```bash -# list-dms -sprout list-dms | jq . +# dms list +sprout dms list | jq . -# open-dm (needs a real pubkey — use your own or a test one) +# dms open (needs a real pubkey — use your own or a test one) # Get your own pubkey first: -MY_PUBKEY=$(sprout get-users | jq -r '.pubkey // .[0].pubkey // empty') +MY_PUBKEY=$(sprout users get | jq -r '.pubkey // .[0].pubkey // empty') echo "My pubkey: $MY_PUBKEY" -# open-dm with a synthetic pubkey (relay will create the user) -DM_RESULT=$(sprout open-dm --pubkey "0000000000000000000000000000000000000000000000000000000000000001") +# dms open with a synthetic pubkey (relay will create the user) +DM_RESULT=$(sprout dms open --pubkey "0000000000000000000000000000000000000000000000000000000000000001") echo "$DM_RESULT" | jq . DM_ID=$(echo "$DM_RESULT" | jq -r '.channel_id // .id // empty') -# add-dm-member (requires messages:write scope — NOT admin:channels) -sprout add-dm-member --channel "$DM_ID" \ +# dms add-member (requires messages:write scope — NOT admin:channels) +sprout dms add-member --channel "$DM_ID" \ --pubkey "0000000000000000000000000000000000000000000000000000000000000002" | jq . ``` -### 6.8 Users & Presence +### 6.7 Users & Presence ```bash -# get-users — own profile (0 pubkeys) -sprout get-users | jq . +# users get — own profile (0 pubkeys) +sprout users get | jq . -# get-users — single pubkey -sprout get-users --pubkey "$MY_PUBKEY" | jq . +# users get — single pubkey +sprout users get --pubkey "$MY_PUBKEY" | jq . -# get-users — batch (2+ pubkeys) -sprout get-users --pubkey "$MY_PUBKEY" --pubkey "$MY_PUBKEY" | jq . +# users get — batch (2+ pubkeys) +sprout users get --pubkey "$MY_PUBKEY" --pubkey "$MY_PUBKEY" | jq . -# set-profile -sprout set-profile --name "CLI Test Agent" --about "Testing sprout-cli" | jq . +# users set-profile +sprout users set-profile --name "CLI Test Agent" --about "Testing sprout-cli" | jq . -# get-presence -sprout get-presence --pubkeys "$MY_PUBKEY" | jq . +# users presence +sprout users presence --pubkeys "$MY_PUBKEY" | jq . -# set-presence -sprout set-presence --status online | jq . -sprout set-presence --status away | jq . -sprout set-presence --status offline | jq . - -# set-channel-add-policy -sprout set-channel-add-policy --policy anyone | jq . -sprout set-channel-add-policy --policy owner_only | jq . -sprout set-channel-add-policy --policy nobody | jq . -# Reset to default -sprout set-channel-add-policy --policy anyone | jq . +# users set-presence +sprout users set-presence --status online | jq . +sprout users set-presence --status away | jq . +sprout users set-presence --status offline | jq . ``` -### 6.9 Channel Members (add/remove require admin:channels) +### 6.8 Channel Members (add/remove require admin:channels) ```bash -# add-channel-member -sprout add-channel-member --channel "$CHANNEL_ID" \ +# channels add-member +sprout channels add-member --channel "$CHANNEL_ID" \ --pubkey "0000000000000000000000000000000000000000000000000000000000000001" \ --role member | jq . -# list-channel-members -sprout list-channel-members --channel "$CHANNEL_ID" | jq . +# channels members +sprout channels members --channel "$CHANNEL_ID" | jq . -# remove-channel-member -sprout remove-channel-member --channel "$CHANNEL_ID" \ +# channels remove-member +sprout channels remove-member --channel "$CHANNEL_ID" \ --pubkey "0000000000000000000000000000000000000000000000000000000000000001" | jq . ``` -### 6.10 Workflows +### 6.9 Workflows ```bash -# create-workflow +# workflows create # NOTE: trigger uses `on:` tag (serde internally tagged enum). # Valid triggers: message_posted, reaction_added, diff_posted, schedule, webhook # Steps use `action:` tag: send_message, send_dm, set_channel_topic, add_reaction, etc. -WF=$(sprout create-workflow --channel "$CHANNEL_ID" \ +WF=$(sprout workflows create --channel "$CHANNEL_ID" \ --yaml 'name: test-wf trigger: on: webhook @@ -391,14 +340,14 @@ steps: echo "$WF" WF_ID=$(echo "$WF" | jq -r '.id') -# list-workflows -sprout list-workflows --channel "$CHANNEL_ID" | jq . +# workflows list +sprout workflows list --channel "$CHANNEL_ID" | jq . -# get-workflow -sprout get-workflow --workflow "$WF_ID" | jq . +# workflows get +sprout workflows get --workflow "$WF_ID" | jq . -# update-workflow -sprout update-workflow --workflow "$WF_ID" \ +# workflows update +sprout workflows update --workflow "$WF_ID" \ --yaml 'name: test-wf-updated trigger: on: webhook @@ -407,43 +356,44 @@ steps: action: send_message text: "Updated"' | jq . -# trigger-workflow -sprout trigger-workflow --workflow "$WF_ID" | jq . +# workflows trigger +sprout workflows trigger --workflow "$WF_ID" | jq . -# get-workflow-runs -sprout get-workflow-runs --workflow "$WF_ID" | jq . +# workflows runs +sprout workflows runs --workflow "$WF_ID" | jq . -# approve-step — requires a workflow run waiting for approval +# workflows approve — requires a workflow run waiting for approval # This is hard to test ad-hoc without a workflow that has an approval gate. # Test the validation instead: -sprout approve-step --token "00000000-0000-0000-0000-000000000000" --approved true 2>&1 || true +sprout workflows approve --token "00000000-0000-0000-0000-000000000000" 2>&1 || true # Should fail with relay error (token not found), not a validation error +# To test the deny path: sprout workflows approve --token --approved false -# delete-workflow -sprout delete-workflow --workflow "$WF_ID" | jq . +# workflows delete +sprout workflows delete --workflow "$WF_ID" | jq . ``` -### 6.11 Feed +### 6.10 Feed ```bash -sprout get-feed | jq . -sprout get-feed --limit 5 | jq . +sprout feed get | jq . +sprout feed get --limit 5 | jq . ``` -### 6.12 Forum & Voting +### 6.11 Forum & Voting ```bash # Send a forum post (kind 45001) to the forum channel -FORUM_POST=$(sprout send-message --channel "$FORUM_ID" \ +FORUM_POST=$(sprout messages send --channel "$FORUM_ID" \ --content "Forum post for vote testing" --kind 45001 | jq .) echo "$FORUM_POST" FORUM_EVENT_ID=$(echo "$FORUM_POST" | jq -r '.id // .event_id') -# vote-on-post (up) -sprout vote-on-post --event "$FORUM_EVENT_ID" --direction up | jq . +# messages vote (up) +sprout messages vote --event "$FORUM_EVENT_ID" --direction up | jq . -# vote-on-post (down) -sprout vote-on-post --event "$FORUM_EVENT_ID" --direction down | jq . +# messages vote (down) +sprout messages vote --event "$FORUM_EVENT_ID" --direction down | jq . ``` --- @@ -454,69 +404,56 @@ Verify the CLI produces correct JSON on stderr and correct exit codes. ```bash # Exit 1: Invalid UUID -sprout get-channel --channel "not-a-uuid" 2>&1; echo "exit: $?" +sprout channels get --channel "not-a-uuid" 2>&1; echo "exit: $?" # stderr: {"error":"user_error","message":"invalid UUID: not-a-uuid"} # exit: 1 # Exit 1: Invalid hex64 -sprout delete-message --event "not-hex" 2>&1; echo "exit: $?" +sprout messages delete --event "not-hex" 2>&1; echo "exit: $?" # stderr: {"error":"user_error","message":"must be a 64-character hex string: not-hex"} # exit: 1 -# Exit 1: Invalid --approved value -sprout approve-step --token "00000000-0000-0000-0000-000000000000" \ - --approved maybe 2>&1; echo "exit: $?" -# stderr: {"error":"user_error","message":"--approved must be 'true' or 'false' (got: maybe)"} -# exit: 1 - # Exit 1: Invalid --type value -sprout create-channel --name x --type invalid --visibility open 2>&1; echo "exit: $?" +sprout channels create --name x --type invalid --visibility open 2>&1; echo "exit: $?" # stderr: {"error":"user_error","message":"--type must be 'stream' or 'forum' (got: invalid)"} # exit: 1 # Exit 1: Invalid --direction value -sprout vote-on-post --event "$(printf '0%.0s' {1..64})" \ +sprout messages vote --event "$(printf '0%.0s' {1..64})" \ --direction sideways 2>&1; echo "exit: $?" # exit: 1 # Exit 1: Empty body guard -sprout set-profile 2>&1; echo "exit: $?" +sprout users set-profile 2>&1; echo "exit: $?" # exit: 1 (at least one field required) # Exit 3: No auth configured -env -u SPROUT_API_TOKEN -u SPROUT_PRIVATE_KEY -u SPROUT_PUBKEY \ - cargo run -p sprout-cli -- list-channels 2>&1; echo "exit: $?" -# stderr: {"error":"auth_error","message":"auth error: Set SPROUT_API_TOKEN, SPROUT_PRIVATE_KEY, or SPROUT_PUBKEY"} +env -u SPROUT_PRIVATE_KEY \ + cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?" +# stderr: {"error":"auth_error","message":"SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"} # exit: 3 # Exit 2: Non-existent channel (valid UUID) -sprout get-channel --channel "00000000-0000-0000-0000-000000000000" 2>&1; echo "exit: $?" +sprout channels get --channel "00000000-0000-0000-0000-000000000000" 2>&1; echo "exit: $?" # stderr: {"error":"relay_error","message":"..."} # exit: 2 ``` --- -## 8. Auth Mode Testing +## 8. Auth Testing -Test all three authentication tiers. +Test authentication. ```bash -# Mode 1: Bearer token (SPROUT_API_TOKEN) -SPROUT_API_TOKEN="sprout_tok_..." sprout list-channels | jq . -# Should succeed - -# Mode 2: Private key auto-mint (SPROUT_PRIVATE_KEY) -SPROUT_PRIVATE_KEY="nsec1..." sprout list-channels | jq . -# Should succeed (mints a 1-day token at startup) - -# Mode 3: Dev mode (SPROUT_PUBKEY) — only works with SPROUT_REQUIRE_AUTH_TOKEN=false -SPROUT_PUBKEY="" sprout list-channels | jq . +# Private key (SPROUT_PRIVATE_KEY) +SPROUT_PRIVATE_KEY="nsec1..." sprout channels list | jq . # Should succeed # No auth → exit 3 -env -u SPROUT_API_TOKEN -u SPROUT_PRIVATE_KEY -u SPROUT_PUBKEY \ - cargo run -p sprout-cli -- list-channels 2>&1; echo "exit: $?" +env -u SPROUT_PRIVATE_KEY \ + cargo run -p sprout-cli -- channels list 2>&1; echo "exit: $?" +# stderr: {"error":"auth_error","message":"SPROUT_PRIVATE_KEY is required (use --private-key or set env var)"} # exit: 3 ``` @@ -526,8 +463,8 @@ env -u SPROUT_API_TOKEN -u SPROUT_PRIVATE_KEY -u SPROUT_PUBKEY \ ```bash # Delete test channels -sprout delete-channel --channel "$CHANNEL_ID" | jq . -sprout delete-channel --channel "$FORUM_ID" | jq . +sprout channels delete --channel "$CHANNEL_ID" | jq . +sprout channels delete --channel "$FORUM_ID" | jq . ``` --- @@ -536,51 +473,57 @@ sprout delete-channel --channel "$FORUM_ID" | jq . | # | Command | Tested | Notes | |---|---------|:------:|-------| -| 1 | `send-message` | ☐ | Basic, reply, broadcast, mentions | -| 2 | `send-diff-message` | ☐ | Stdin, metadata, branch/PR | -| 3 | `edit-message` | ☐ | | -| 4 | `delete-message` | ☐ | | -| 5 | `get-messages` | ☐ | With limit | -| 6 | `get-thread` | ☐ | | -| 7 | `search` | ☐ | With limit | -| 8 | `list-channels` | ☐ | With visibility, member | -| 9 | `get-channel` | ☐ | | -| 10 | `create-channel` | ☐ | Stream and forum | -| 11 | `update-channel` | ☐ | | -| 12 | `set-channel-topic` | ☐ | | -| 13 | `set-channel-purpose` | ☐ | | -| 14 | `join-channel` | ☐ | | -| 15 | `leave-channel` | ☐ | | -| 16 | `archive-channel` | ☐ | Needs admin:channels | -| 17 | `unarchive-channel` | ☐ | Needs admin:channels | -| 18 | `delete-channel` | ☐ | Needs admin:channels | -| 19 | `list-channel-members` | ☐ | | -| 20 | `add-channel-member` | ☐ | Needs admin:channels | -| 21 | `remove-channel-member` | ☐ | Needs admin:channels | -| 22 | `get-canvas` | ☐ | | -| 23 | `set-canvas` | ☐ | Direct and stdin | -| 24 | `add-reaction` | ☐ | | -| 25 | `remove-reaction` | ☐ | | -| 26 | `get-reactions` | ☐ | | -| 27 | `list-dms` | ☐ | | -| 28 | `open-dm` | ☐ | | -| 29 | `add-dm-member` | ☐ | Needs messages:write | -| 30 | `get-users` | ☐ | Self, single, batch | -| 31 | `set-profile` | ☐ | | -| 32 | `get-presence` | ☐ | | -| 33 | `set-presence` | ☐ | online, away, offline | -| 34 | `set-channel-add-policy` | ☐ | anyone, owner_only, nobody | -| 35 | `list-workflows` | ☐ | | -| 36 | `create-workflow` | ☐ | | -| 37 | `update-workflow` | ☐ | | -| 38 | `delete-workflow` | ☐ | | -| 39 | `trigger-workflow` | ☐ | | -| 40 | `get-workflow-runs` | ☐ | | -| 41 | `get-workflow` | ☐ | | -| 42 | `approve-step` | ☐ | Validation only (needs approval gate) | -| 43 | `get-feed` | ☐ | | -| 44 | `vote-on-post` | ☐ | Up and down | -| 45 | `auth` | ☐ | Mint token via NIP-98 | -| 46 | `list-tokens` | ☐ | | -| 47 | `delete-token` | ☐ | | -| 48 | `delete-all-tokens` | ☐ | Optional (destructive) | +| 1 | `messages send` | ☐ | Basic, reply, broadcast, mentions | +| 2 | `messages send-diff` | ☐ | Stdin, metadata, branch/PR | +| 3 | `messages edit` | ☐ | | +| 4 | `messages delete` | ☐ | | +| 5 | `messages get` | ☐ | With limit | +| 6 | `messages thread` | ☐ | | +| 7 | `messages search` | ☐ | With limit | +| 8 | `messages vote` | ☐ | Up and down | +| 9 | `channels list` | ☐ | With visibility, member | +| 10 | `channels get` | ☐ | | +| 11 | `channels create` | ☐ | Stream and forum | +| 12 | `channels update` | ☐ | | +| 13 | `channels topic` | ☐ | | +| 14 | `channels purpose` | ☐ | | +| 15 | `channels join` | ☐ | | +| 16 | `channels leave` | ☐ | | +| 17 | `channels archive` | ☐ | Needs admin:channels | +| 18 | `channels unarchive` | ☐ | Needs admin:channels | +| 19 | `channels delete` | ☐ | Needs admin:channels | +| 20 | `channels members` | ☐ | | +| 21 | `channels add-member` | ☐ | Needs admin:channels | +| 22 | `channels remove-member` | ☐ | Needs admin:channels | +| 23 | `canvas get` | ☐ | | +| 24 | `canvas set` | ☐ | Direct and stdin | +| 25 | `reactions add` | ☐ | | +| 26 | `reactions remove` | ☐ | | +| 27 | `reactions get` | ☐ | | +| 28 | `dms list` | ☐ | | +| 29 | `dms open` | ☐ | | +| 30 | `dms add-member` | ☐ | Needs messages:write | +| 31 | `users get` | ☐ | Self, single, batch | +| 32 | `users set-profile` | ☐ | | +| 33 | `users presence` | ☐ | | +| 34 | `users set-presence` | ☐ | online, away, offline | +| 35 | `workflows list` | ☐ | | +| 36 | `workflows create` | ☐ | | +| 37 | `workflows update` | ☐ | | +| 38 | `workflows delete` | ☐ | | +| 39 | `workflows trigger` | ☐ | | +| 40 | `workflows runs` | ☐ | | +| 41 | `workflows get` | ☐ | | +| 42 | `workflows approve` | ☐ | Validation only (needs approval gate); bare = approve, `--approved false` = deny | +| 43 | `feed get` | ☐ | | +| 44 | `social publish` | ☐ | | +| 45 | `social set-contacts` | ☐ | | +| 46 | `social event` | ☐ | | +| 47 | `social notes` | ☐ | | +| 48 | `social contacts` | ☐ | | +| 49 | `repos create` | ☐ | | +| 50 | `repos get` | ☐ | | +| 51 | `repos list` | ☐ | | +| 52 | `upload file` | ☐ | | +| 53 | `pack validate` | ☐ | Local, no relay | +| 54 | `pack inspect` | ☐ | Local, no relay | diff --git a/crates/sprout-cli/src/commands/channels.rs b/crates/sprout-cli/src/commands/channels.rs index f7c08bb36..17018f8ab 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::parse_str(s).map_err(|e| CliError::Usage(format!("invalid channel UUID: {e}"))) -} - -fn sign_and_submit_builder( - builder: nostr::EventBuilder, - client: &SproutClient, -) -> Result { - client.sign_event(builder) -} +use crate::validate::{parse_uuid, read_or_stdin, validate_hex64, validate_uuid}; // --------------------------------------------------------------------------- // Read commands — POST /query @@ -127,7 +112,7 @@ pub async fn cmd_create_channel( sprout_sdk::build_create_channel(channel_uuid, name, Some(vis), Some(ct), description) .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -144,13 +129,12 @@ pub async fn cmd_update_channel( "at least one field required (--name, --description)".into(), )); } - validate_uuid(channel_id)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_update_channel(channel_uuid, name, description) .map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -161,13 +145,12 @@ pub async fn cmd_set_channel_topic( channel_id: &str, topic: &str, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_set_topic(channel_uuid, topic) .map_err(|e| CliError::Other(format!("build_set_topic failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -178,52 +161,48 @@ pub async fn cmd_set_channel_purpose( channel_id: &str, purpose: &str, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_set_purpose(channel_uuid, purpose) .map_err(|e| CliError::Other(format!("build_set_purpose failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) } pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { - validate_uuid(channel_id)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_join(channel_uuid) .map_err(|e| CliError::Other(format!("build_join failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) } pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { - validate_uuid(channel_id)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_leave(channel_uuid) .map_err(|e| CliError::Other(format!("build_leave failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) } pub async fn cmd_archive_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { - validate_uuid(channel_id)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_archive(channel_uuid) .map_err(|e| CliError::Other(format!("build_archive failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -233,26 +212,24 @@ pub async fn cmd_unarchive_channel( client: &SproutClient, channel_id: &str, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_unarchive(channel_uuid) .map_err(|e| CliError::Other(format!("build_unarchive failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) } pub async fn cmd_delete_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { - validate_uuid(channel_id)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_delete_channel(channel_uuid) .map_err(|e| CliError::Other(format!("build_delete_channel failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -264,7 +241,6 @@ pub async fn cmd_add_channel_member( pubkey: &str, role: Option<&str>, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; validate_hex64(pubkey)?; let channel_uuid = parse_uuid(channel_id)?; @@ -284,7 +260,7 @@ pub async fn cmd_add_channel_member( let builder = sprout_sdk::build_add_member(channel_uuid, pubkey, typed_role) .map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -295,14 +271,13 @@ pub async fn cmd_remove_channel_member( channel_id: &str, pubkey: &str, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; validate_hex64(pubkey)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_remove_member(channel_uuid, pubkey) .map_err(|e| CliError::Other(format!("build_remove_member failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -313,15 +288,77 @@ pub async fn cmd_set_canvas( channel_id: &str, content: &str, ) -> Result<(), CliError> { - validate_uuid(channel_id)?; let content = read_or_stdin(content)?; let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_set_canvas(channel_uuid, &content) .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; - let event = sign_and_submit_builder(builder, client)?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) } + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +pub async fn dispatch(cmd: crate::ChannelsCmd, client: &SproutClient) -> Result<(), CliError> { + use crate::ChannelsCmd; + match cmd { + ChannelsCmd::List { visibility, member } => { + let vis_str = visibility.as_ref().map(|v| v.to_string()); + cmd_list_channels(client, vis_str.as_deref(), Some(member)).await + } + ChannelsCmd::Get { channel } => cmd_get_channel(client, &channel).await, + ChannelsCmd::Create { + name, + channel_type, + visibility, + description, + } => { + cmd_create_channel( + client, + &name, + &channel_type.to_string(), + &visibility.to_string(), + description.as_deref(), + ) + .await + } + ChannelsCmd::Update { + channel, + name, + description, + } => cmd_update_channel(client, &channel, name.as_deref(), description.as_deref()).await, + ChannelsCmd::Topic { channel, topic } => { + cmd_set_channel_topic(client, &channel, &topic).await + } + ChannelsCmd::Purpose { channel, purpose } => { + cmd_set_channel_purpose(client, &channel, &purpose).await + } + ChannelsCmd::Join { channel } => cmd_join_channel(client, &channel).await, + ChannelsCmd::Leave { channel } => cmd_leave_channel(client, &channel).await, + ChannelsCmd::Archive { channel } => cmd_archive_channel(client, &channel).await, + ChannelsCmd::Unarchive { channel } => cmd_unarchive_channel(client, &channel).await, + ChannelsCmd::Delete { channel } => cmd_delete_channel(client, &channel).await, + ChannelsCmd::Members { channel } => cmd_list_channel_members(client, &channel).await, + ChannelsCmd::AddMember { + channel, + pubkey, + role, + } => cmd_add_channel_member(client, &channel, &pubkey, role.as_deref()).await, + ChannelsCmd::RemoveMember { channel, pubkey } => { + cmd_remove_channel_member(client, &channel, &pubkey).await + } + } +} + +pub async fn dispatch_canvas(cmd: crate::CanvasCmd, client: &SproutClient) -> Result<(), CliError> { + use crate::CanvasCmd; + match cmd { + CanvasCmd::Get { channel } => cmd_get_canvas(client, &channel).await, + CanvasCmd::Set { channel, content } => cmd_set_canvas(client, &channel, &content).await, + } +} diff --git a/crates/sprout-cli/src/commands/dms.rs b/crates/sprout-cli/src/commands/dms.rs index d9ea8913d..8d6a4c22c 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 44cc58817..38079bc6d 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 7b7e36f87..95a8a415e 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::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::parse_str(s).map_err(|e| CliError::Usage(format!("invalid channel UUID: {e}"))) -} - /// Extract the thread root event ID from a Nostr tag array. /// /// Parses `"e"` tags with NIP-10 markers: @@ -323,7 +313,6 @@ pub struct SendMessageParams { } pub async fn cmd_send_message(client: &SproutClient, p: SendMessageParams) -> Result<(), CliError> { - validate_uuid(&p.channel_id)?; validate_content_size(&p.content)?; if let Some(ref r) = p.reply_to { validate_hex64(r)?; @@ -409,7 +398,6 @@ pub async fn cmd_send_diff_message( client: &SproutClient, p: SendDiffParams, ) -> Result<(), CliError> { - validate_uuid(&p.channel_id)?; if let Some(r) = &p.reply_to { validate_hex64(r)?; } @@ -553,6 +541,91 @@ pub async fn cmd_vote_on_post( Ok(()) } +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +pub async fn dispatch(cmd: crate::MessagesCmd, client: &SproutClient) -> Result<(), CliError> { + use crate::MessagesCmd; + match cmd { + MessagesCmd::Send { + channel, + content, + kind, + reply_to, + broadcast, + mentions, + files, + } => { + cmd_send_message( + client, + SendMessageParams { + channel_id: channel, + content, + kind, + reply_to, + broadcast, + mentions, + files, + }, + ) + .await + } + MessagesCmd::SendDiff { + channel, + diff, + repo, + commit, + file, + parent_commit, + source_branch, + target_branch, + pr, + lang, + description, + reply_to, + } => { + cmd_send_diff_message( + client, + SendDiffParams { + channel_id: channel, + diff, + repo_url: repo, + commit_sha: commit, + file_path: file, + parent_commit_sha: parent_commit, + source_branch, + target_branch, + pr_number: pr, + language: lang, + description, + reply_to, + }, + ) + .await + } + MessagesCmd::Edit { event, content } => cmd_edit_message(client, &event, &content).await, + MessagesCmd::Delete { event } => cmd_delete_message(client, &event).await, + MessagesCmd::Get { + channel, + limit, + before, + since, + kinds, + } => cmd_get_messages(client, &channel, limit, before, since, kinds.as_deref()).await, + MessagesCmd::Thread { + channel, + event, + depth_limit, + limit, + } => cmd_get_thread(client, &channel, &event, depth_limit, limit).await, + MessagesCmd::Search { query, limit } => cmd_search(client, &query, limit).await, + MessagesCmd::Vote { event, direction } => { + cmd_vote_on_post(client, &event, &direction).await + } + } +} + #[cfg(test)] mod tests { use super::{find_root_from_tags, parse_member_pubkeys}; diff --git a/crates/sprout-cli/src/commands/mod.rs b/crates/sprout-cli/src/commands/mod.rs index 0e61afd4a..077f00397 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 b1d36b3a6..bf90ad821 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 779d503a5..db680912a 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 efc3b68ce..97448cfd7 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::parse(hex).map_err(|e| CliError::Usage(format!("invalid event ID: {e}"))) -} +use crate::validate::{parse_event_id, validate_hex64}; /// A single contact entry (CLI-local, not from sprout-sdk). #[derive(Debug, Deserialize)] @@ -117,3 +111,24 @@ pub async fn cmd_get_contact_list(client: &SproutClient, pubkey: &str) -> Result println!("{resp}"); Ok(()) } + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +pub async fn dispatch(cmd: crate::SocialCmd, client: &SproutClient) -> Result<(), CliError> { + use crate::SocialCmd; + match cmd { + SocialCmd::PublishNote { content, reply_to } => { + cmd_publish_note(client, &content, reply_to.as_deref()).await + } + SocialCmd::SetContactList { contacts } => cmd_set_contact_list(client, &contacts).await, + SocialCmd::GetEvent { event } => cmd_get_event(client, &event).await, + SocialCmd::GetUserNotes { + pubkey, + limit, + before, + } => cmd_get_user_notes(client, &pubkey, limit, before).await, + SocialCmd::GetContactList { pubkey } => cmd_get_contact_list(client, &pubkey).await, + } +} diff --git a/crates/sprout-cli/src/commands/upload.rs b/crates/sprout-cli/src/commands/upload.rs new file mode 100644 index 000000000..7385c71ee --- /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 b4e638b83..54db71943 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 57fb0bc67..064eb7cc2 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 4cf2b18ae..fe16cbdfc 100644 --- a/crates/sprout-cli/src/lib.rs +++ b/crates/sprout-cli/src/lib.rs @@ -50,8 +50,24 @@ 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\": \"\", \"message\": \"\"}" +)] struct Cli { + /// Relay URL (http:// or https://). Overrides SPROUT_RELAY_URL env var. #[arg( long, env = "SPROUT_RELAY_URL", @@ -72,104 +88,241 @@ struct Cli { } // --------------------------------------------------------------------------- -// Subcommands +// 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 // --------------------------------------------------------------------------- #[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 --content \"hello\"\n sprout messages send --channel --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, + /// Event ID to reply to (creates a thread) #[arg(long)] reply_to: Option, + /// 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, /// Attach file(s) — uploads and includes as imeta tags #[arg(long = "file")] files: Vec, }, - /// Send a diff/code-review message - SendDiffMessage { + /// 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, + /// Parent commit SHA for three-way diff context #[arg(long)] parent_commit: Option, + /// Source branch name #[arg(long)] source_branch: Option, + /// Target branch name #[arg(long)] target_branch: Option, + /// Pull request number #[arg(long)] pr: Option, + /// Language hint (auto-detected from file extension if omitted) #[arg(long)] lang: Option, + /// Human-readable description of the change #[arg(long)] description: Option, + /// Event ID to reply to (creates a thread) #[arg(long)] reply_to: Option, }, + /// 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 { + /// Event ID to delete (64-char hex) #[arg(long)] event: String, }, - /// Get messages from a channel - GetMessages { + /// Retrieve messages from a channel + #[command( + after_help = "Examples:\n sprout messages get --channel \n sprout messages get --channel --limit 50 --kinds 1,1984" + )] + Get { + /// Channel UUID #[arg(long)] channel: String, + /// Maximum number of results to return #[arg(long)] limit: Option, + /// Unix timestamp — return messages before this time #[arg(long)] before: Option, + /// Unix timestamp — return messages after this time #[arg(long)] since: Option, + /// Comma-separated event kinds to filter (e.g. 1,1984) #[arg(long)] kinds: Option, }, - /// Get a message thread - GetThread { + /// 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, + /// Maximum number of results to return #[arg(long)] limit: Option, }, - /// Search messages + /// Full-text search across messages Search { + /// Search query string #[arg(long)] query: String, + /// Maximum number of results to return #[arg(long)] limit: Option, }, - /// 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,261 +330,377 @@ 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 { - #[arg(long)] - visibility: Option, +#[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 + #[arg(long, value_enum)] + visibility: Option, + /// 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 { + /// Channel UUID #[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 { + /// 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, }, - /// Update a channel's name or description - UpdateChannel { + /// Update channel name or description + Update { + /// Channel UUID #[arg(long)] channel: String, + /// New channel name #[arg(long)] name: Option, + /// New channel description #[arg(long)] description: Option, }, - /// Set a channel's topic - SetChannelTopic { + /// Set the channel topic + Topic { + /// Channel UUID #[arg(long)] channel: String, + /// New topic text #[arg(long)] topic: String, }, - /// Set a channel's purpose - SetChannelPurpose { + /// Set the channel purpose + Purpose { + /// Channel UUID #[arg(long)] channel: String, + /// New purpose text #[arg(long)] purpose: String, }, /// Join a channel - JoinChannel { + Join { + /// Channel UUID #[arg(long)] channel: String, }, /// Leave a channel - LeaveChannel { + Leave { + /// Channel UUID #[arg(long)] channel: String, }, /// Archive a channel - ArchiveChannel { + Archive { + /// Channel UUID #[arg(long)] channel: String, }, /// Unarchive a channel - UnarchiveChannel { + Unarchive { + /// Channel UUID #[arg(long)] channel: String, }, - /// Delete a channel - DeleteChannel { + /// Delete a channel permanently + Delete { + /// Channel UUID #[arg(long)] channel: String, }, - /// List channel members - ListChannelMembers { + /// List members of a channel + Members { + /// Channel UUID #[arg(long)] channel: String, }, /// Add a member to a channel - AddChannelMember { + #[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, }, /// Remove a member from a channel - RemoveChannelMember { + #[command(name = "remove-member")] + RemoveMember { + /// Channel UUID #[arg(long)] channel: String, + /// Member pubkey (64-char hex) #[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 { + /// Channel UUID #[arg(long)] channel: String, }, - /// Set a channel's canvas content - SetCanvas { + /// 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, }, +} + +// --------------------------------------------------------------------------- +// Reactions subcommands +// --------------------------------------------------------------------------- - // ---- Reactions --------------------------------------------------------- - /// Add a reaction to a message - AddReaction { +#[derive(Subcommand)] +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 a reaction from a message - RemoveReaction { + /// 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, }, - /// Get reactions on a message - GetReactions { + /// List reactions on a message + Get { + /// Event ID (64-char hex) #[arg(long)] event: String, }, +} - // ---- DMs --------------------------------------------------------------- - /// List DM conversations - ListDms { +// --------------------------------------------------------------------------- +// DMs subcommands +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +pub enum DmsCmd { + /// List direct message conversations + List { + /// Maximum number of results to return #[arg(long)] limit: Option, }, - /// Open a DM with one or more users (1–8 pubkeys) - OpenDm { + /// 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, }, - /// Add a member to a DM group - AddDmMember { + /// 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, }, +} - // ---- Users ------------------------------------------------------------- - /// Get user profiles by pubkey or display name - GetUsers { +// --------------------------------------------------------------------------- +// Users subcommands +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +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, /// Search by display name (case-insensitive substring match) #[arg(long = "name")] name: Option, }, - /// Update your profile + /// Update the current identity's profile + #[command(name = "set-profile")] SetProfile { + /// Display name #[arg(long)] name: Option, + /// Avatar URL #[arg(long)] avatar: Option, + /// Bio / about text #[arg(long)] about: Option, + /// NIP-05 identifier (e.g. user@example.com) #[arg(long)] nip05: Option, }, - /// Get presence status for users (comma-separated pubkeys) - GetPresence { + /// Get presence status for users + Presence { + /// Comma-separated pubkeys (64-char hex) #[arg(long)] pubkeys: String, }, - /// Set your presence status + /// Set your presence status (online/away/offline) + #[command(name = "set-presence")] SetPresence { - #[arg(long)] - status: String, + /// Presence status + #[arg(long, value_enum)] + status: PresenceStatus, }, +} - // ---- Workflows --------------------------------------------------------- +// --------------------------------------------------------------------------- +// Workflows subcommands +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +pub enum WorkflowsCmd { /// List workflows in a channel - ListWorkflows { + List { + /// Channel UUID #[arg(long)] channel: String, }, - /// Create a workflow in a channel - CreateWorkflow { + /// 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 - UpdateWorkflow { + /// Update a workflow's YAML definition + Update { + /// Channel UUID the workflow belongs to #[arg(long)] channel: String, + /// Workflow UUID #[arg(long)] workflow: String, + /// Updated workflow YAML definition #[arg(long)] yaml: String, }, /// Delete a workflow - DeleteWorkflow { + Delete { + /// Workflow UUID #[arg(long)] workflow: String, }, - /// Trigger a workflow manually - TriggerWorkflow { + /// Trigger a workflow run + #[command(after_help = "Examples:\n sprout workflows trigger --workflow ")] + Trigger { + /// Workflow UUID #[arg(long)] workflow: String, }, - /// Get workflow run history - GetWorkflowRuns { + /// List runs for a workflow + Runs { + /// Workflow UUID #[arg(long)] workflow: String, + /// Maximum number of results to return #[arg(long)] limit: Option, }, - /// 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 \n sprout workflows approve --token --approved false --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 (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)] note: Option, }, +} + +// --------------------------------------------------------------------------- +// Feed subcommands +// --------------------------------------------------------------------------- - // ---- Feed -------------------------------------------------------------- - /// Get your activity feed - GetFeed { +#[derive(Subcommand)] +pub enum FeedCmd { + /// Get recent activity feed entries + Get { + /// Unix timestamp — return entries after this time #[arg(long)] since: Option, + /// Maximum number of results to return #[arg(long)] limit: Option, + /// Comma-separated feed entry types to filter #[arg(long)] types: Option, }, +} - // Social - /// Publish a short text note (kind:1) to the global feed. - #[command(name = "publish-note")] +// --------------------------------------------------------------------------- +// Social subcommands +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +pub enum SocialCmd { + /// Publish a text note (NIP-01 kind:1) + #[command(name = "publish")] PublishNote { /// Text content of the note. #[arg(long)] @@ -440,25 +709,22 @@ enum Cmd { #[arg(long)] reply_to: Option, }, - - /// Set the authenticated user's contact/follow list (kind:3). Replaces the entire list. - #[command(name = "set-contact-list")] + /// Set your contact list (NIP-02 kind:3) + #[command(name = "set-contacts")] 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). - #[command(name = "get-event")] + /// Get a single event by ID + #[command(name = "event")] GetEvent { /// 64-char hex event ID. #[arg(long)] event: String, }, - - /// List kind:1 text notes by a specific user. - #[command(name = "get-user-notes")] + /// Get recent notes published by a user + #[command(name = "notes")] GetUserNotes { /// 64-char hex pubkey of the author. #[arg(long)] @@ -470,19 +736,23 @@ enum Cmd { #[arg(long)] before: Option, }, - - /// Get a user's contact/follow list (kind:3) by hex pubkey. - #[command(name = "get-contact-list")] + /// Get a user's contact list + #[command(name = "contacts")] GetContactList { /// 64-char hex pubkey. #[arg(long)] pubkey: String, }, +} - // ---- Git Repos --------------------------------------------------------- - /// Create a git repository (publishes kind:30617 announcement) - #[command(name = "create-repo")] - CreateRepo { +// --------------------------------------------------------------------------- +// Repos subcommands +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +pub enum ReposCmd { + /// Announce a git repository (NIP-34) + Create { /// Repository identifier: [a-zA-Z0-9._-]{1,64} #[arg(long)] id: String, @@ -498,13 +768,12 @@ enum Cmd { /// Web browsing URL #[arg(long)] web: Option, - /// 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, }, - /// 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 +781,8 @@ enum Cmd { #[arg(long)] owner: Option, }, - /// 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, @@ -522,16 +790,29 @@ enum Cmd { #[arg(long)] limit: Option, }, +} - // ---- 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 { +pub enum PackCmd { /// Validate a persona pack directory Validate { /// Path to the pack directory @@ -545,20 +826,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 { - 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 +867,17 @@ 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, - 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(()) - } - - // ---- Channels ------------------------------------------------------ - Cmd::ListChannels { visibility, member } => { - commands::channels::cmd_list_channels(&client, visibility.as_deref(), Some(member)) - .await - } - Cmd::GetChannel { channel } => commands::channels::cmd_get_channel(&client, &channel).await, - Cmd::CreateChannel { - name, - channel_type, - visibility, - description, - } => { - commands::channels::cmd_create_channel( - &client, - &name, - &channel_type, - &visibility, - description.as_deref(), - ) - .await - } - Cmd::UpdateChannel { - channel, - name, - description, - } => { - commands::channels::cmd_update_channel( - &client, - &channel, - name.as_deref(), - description.as_deref(), - ) - .await - } - Cmd::SetChannelTopic { channel, topic } => { - commands::channels::cmd_set_channel_topic(&client, &channel, &topic).await - } - Cmd::SetChannelPurpose { channel, purpose } => { - commands::channels::cmd_set_channel_purpose(&client, &channel, &purpose).await - } - Cmd::JoinChannel { channel } => { - commands::channels::cmd_join_channel(&client, &channel).await - } - Cmd::LeaveChannel { channel } => { - commands::channels::cmd_leave_channel(&client, &channel).await - } - Cmd::ArchiveChannel { channel } => { - commands::channels::cmd_archive_channel(&client, &channel).await - } - Cmd::UnarchiveChannel { channel } => { - commands::channels::cmd_unarchive_channel(&client, &channel).await - } - Cmd::DeleteChannel { channel } => { - commands::channels::cmd_delete_channel(&client, &channel).await - } - Cmd::ListChannelMembers { channel } => { - commands::channels::cmd_list_channel_members(&client, &channel).await - } - Cmd::AddChannelMember { - channel, - pubkey, - role, - } => { - commands::channels::cmd_add_channel_member(&client, &channel, &pubkey, role.as_deref()) - .await - } - Cmd::RemoveChannelMember { channel, pubkey } => { - commands::channels::cmd_remove_channel_member(&client, &channel, &pubkey).await - } - Cmd::GetCanvas { channel } => commands::channels::cmd_get_canvas(&client, &channel).await, - Cmd::SetCanvas { channel, content } => { - commands::channels::cmd_set_canvas(&client, &channel, &content).await - } - - // ---- Reactions ----------------------------------------------------- - Cmd::AddReaction { event, emoji } => { - commands::reactions::cmd_add_reaction(&client, &event, &emoji).await - } - Cmd::RemoveReaction { event, emoji } => { - commands::reactions::cmd_remove_reaction(&client, &event, &emoji).await - } - Cmd::GetReactions { event } => { - commands::reactions::cmd_get_reactions(&client, &event).await - } - - // ---- DMs ----------------------------------------------------------- - Cmd::ListDms { 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 - } - - // ---- 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, - - // ---- 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 - } - - // ---- Feed ---------------------------------------------------------- - Cmd::GetFeed { - since, - limit, - types, - } => commands::feed::cmd_get_feed(&client, since, limit, types.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 - } - - // ---- 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 - } - - // ---- Pack (local) -------------------------------------------------- + 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"), } } @@ -918,112 +897,147 @@ 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 = 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_names_are_stable() { + fn names(cmd: &clap::Command, group: &str) -> Vec { + let group_cmd = cmd + .get_subcommands() + .find(|s| s.get_name() == group) + .unwrap_or_else(|| panic!("group '{}' not found", group)); + let mut names: Vec = 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![ + ("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 0903a4088..ee2d9a5d5 100644 --- a/crates/sprout-cli/src/validate.rs +++ b/crates/sprout-cli/src/validate.rs @@ -6,17 +6,25 @@ pub const MAX_CONTENT_BYTES: usize = 65_536; /// Maximum diff size in bytes (60 KiB). pub const MAX_DIFF_BYTES: usize = 61_440; +/// Parse a hex string into a `nostr::EventId`. Returns `CliError::Usage` on failure. +pub fn parse_event_id(hex: &str) -> Result { + nostr::EventId::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::parse_str(s).map_err(|e| CliError::Usage(format!("invalid UUID: {e}"))) +} + /// Validate UUID string. Returns CliError::Usage on failure. pub fn validate_uuid(s: &str) -> Result<(), CliError> { uuid::Uuid::parse_str(s).map_err(|_| CliError::Usage(format!("invalid UUID: {s}")))?; Ok(()) } -/// Parse and validate a UUID string, returning the parsed Uuid value. -pub fn parse_uuid(s: &str) -> Result { - uuid::Uuid::parse_str(s).map_err(|_| CliError::Usage(format!("invalid UUID: {s}"))) -} - /// Validate 64-character lowercase hex string (event_id, pubkey). pub fn validate_hex64(s: &str) -> Result<(), CliError> { if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) { @@ -367,6 +375,31 @@ mod tests { // Note: `extract_at_names`, `merge_mentions`, and `normalize_mention_pubkeys` // moved to `sprout_sdk::mentions` and are tested there. + // --- parse_event_id --- + + #[test] + fn parse_event_id_valid() { + let hex = "a".repeat(64); + assert!(super::parse_event_id(&hex).is_ok()); + } + + #[test] + fn parse_event_id_invalid() { + assert!(super::parse_event_id("not-a-hex-id").is_err()); + } + + // --- parse_uuid --- + + #[test] + fn parse_uuid_valid() { + assert!(super::parse_uuid("550e8400-e29b-41d4-a716-446655440000").is_ok()); + } + + #[test] + fn parse_uuid_invalid() { + assert!(super::parse_uuid("not-a-uuid").is_err()); + } + // ── validate_repo_id ───────────────────────────────────────────────────── #[test]